Quality of life: in-app dialogs, selections in the URL, editable widgets, token dialog, gallery search, status history, stream errors that say why, tab reordering and shortcuts, log out everywhere
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
8ee2a2d293
commit
d7ed4d8549
@@ -183,22 +183,26 @@ machine; hub terminals not leaking into another machine's page; CORS only on sen
|
|||||||
token list never exposing hashes; cookie flags; deep nesting, empty bodies, odd methods
|
token list never exposing hashes; cookie flags; deep nesting, empty bodies, odd methods
|
||||||
and `every=` values on the sensor API; phone drawer, key bar and picker.
|
and `every=` values on the sensor API; phone drawer, key bar and picker.
|
||||||
|
|
||||||
## Quality of life — noted, not done
|
## Quality of life — all ✅ done 2026-09-19, each verified in the browser
|
||||||
|
|
||||||
- **Selections aren't in the URL.** Services, Cron and Disks forget what was selected on
|
1. ✅ **Selections are in the URL.** `#/grid/services/ssh.service`, `#/hub/cron/job:…`,
|
||||||
reload, and can't be linked to. `#/grid/services/nginx.service` would fix both.
|
`#/hub/files/%2Fetc`: reload keeps them, links carry them, leaving a page drops them.
|
||||||
- **Browser Back with unsaved file edits** still leaves without asking (page and machine
|
2. ✅ **Browser Back with unsaved file edits asks**, like page and machine changes do.
|
||||||
changes ask; Back needs the history entry put back on Cancel).
|
3. ✅ **No more browser `prompt()`/`confirm()` boxes** — all 30 are in-app dialogs (`askYes`,
|
||||||
- **Prompts are browser `prompt()`/`confirm()` boxes** — rename, new file, delete, token
|
`askText` in `ui.tsx`): Enter submits, Escape or a tap outside cancels, destructive
|
||||||
name. They work, and look nothing like the rest of the console, worst on a phone.
|
ones are red and say what they do ("Close", "Delete"), and they fit a phone.
|
||||||
- **Two step token creation** (name, then patterns) wants to be one small dialog, with the
|
4. ✅ **Token creation is one dialog**: name, patterns, and the machine's own sensor families
|
||||||
patterns pickable from the catalog like the widget picker.
|
as chips to click.
|
||||||
- **Widgets can't be edited**, only removed and re-added; no way to change a title or type.
|
5. ✅ **Widgets can be edited** — sensor, type and title — in place, from Customize.
|
||||||
- **The Widgets gallery has no search**, and 300+ tiles on `grid` is a long scroll.
|
6. ✅ **The Widgets gallery has a search** over IDs, names and labels (so "gitea" finds its
|
||||||
- **A custom widget's sparkline starts empty for states and counts** (no history kept for
|
container's sensors).
|
||||||
the background status sensors — only the sampler's ring remembers).
|
7. ✅ **Status sensors keep a history** (300 readings each: an hour of container memory, two
|
||||||
- **Embeds poll nothing while hidden but say nothing when the token is wrong** beyond
|
days of drive wear), so their sparklines have a past.
|
||||||
"check the token"; a 401 and an unreachable host look the same.
|
8. ✅ **A stream that fails says why**: "the token was rejected", "too many live streams are
|
||||||
- **Default widget titles for patterns are the raw pattern** ("CPU.CORE.*.USAGE").
|
open", "that machine is unreachable", "can't reach Ham Lab" — in embeds and on Monitors.
|
||||||
- **Terminal tabs can't be reordered**, and there's no keyboard shortcut to switch.
|
9. ✅ **Pattern widgets get readable default titles**: `cpu.core.*.usage` → "CPU core usage".
|
||||||
- **The hub's own session cookie is 30 days sliding with no "log out everywhere".**
|
10. ✅ **Terminal tabs reorder** — drag on a desktop, ‹ › on a touch screen, remembered per
|
||||||
|
machine in the browser — and **Alt+Shift+←/→** switches tabs, **Alt+Shift+T** opens one,
|
||||||
|
with the keyboard left in the terminal each time.
|
||||||
|
11. ✅ **"Log out everywhere"** in Settings ends every session on every device (tokens are
|
||||||
|
not sessions and keep working).
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("POST /api/machines/{name}/update", s.auth(s.updateMachine))
|
m.HandleFunc("POST /api/machines/{name}/update", s.auth(s.updateMachine))
|
||||||
m.HandleFunc("POST /api/machines-update-all", s.auth(s.updateAllMachines))
|
m.HandleFunc("POST /api/machines-update-all", s.auth(s.updateAllMachines))
|
||||||
m.HandleFunc("POST /api/logout", s.auth(s.logout))
|
m.HandleFunc("POST /api/logout", s.auth(s.logout))
|
||||||
|
m.HandleFunc("POST /api/logout/all", s.auth(s.logoutAll))
|
||||||
m.HandleFunc("GET /api/me", s.auth(s.me))
|
m.HandleFunc("GET /api/me", s.auth(s.me))
|
||||||
m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals))
|
m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals))
|
||||||
m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal))
|
m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal))
|
||||||
@@ -708,6 +709,15 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(204)
|
w.WriteHeader(204)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// logoutAll ends every session, this one included: a lost phone, a borrowed laptop.
|
||||||
|
// Tokens are not sessions and are untouched; revoke those by name.
|
||||||
|
func (s *Server) logoutAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := s.sessions.DeleteAll()
|
||||||
|
log.Printf("all %d session(s) ended (from %s)", n, clientIP(r))
|
||||||
|
s.setCookie(w, r, "", -1)
|
||||||
|
jsonOK(w, 200, map[string]int{"ended": n})
|
||||||
|
}
|
||||||
|
|
||||||
// clientKey is the address the limiter buckets by: the first X-Forwarded-For hop when a
|
// clientKey is the address the limiter buckets by: the first X-Forwarded-For hop when a
|
||||||
// reverse proxy set it, else the socket peer.
|
// reverse proxy set it, else the socket peer.
|
||||||
func clientKey(r *http.Request) string {
|
func clientKey(r *http.Request) string {
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ type statusSensors struct {
|
|||||||
by map[string][]sensors.Sensor // source → its sensors
|
by map[string][]sensors.Sensor // source → its sensors
|
||||||
|
|
||||||
cpuPrev map[string]cpuMark // container ID → CPU time at the previous read
|
cpuPrev map[string]cpuMark // container ID → CPU time at the previous read
|
||||||
|
|
||||||
|
hist map[string]*sensors.Series // numeric sensors only, statusHistory points each
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statusHistory is how many readings each status sensor keeps: at 15 s that is over an
|
||||||
|
// hour of container memory; at 10 min, two days of drive wear.
|
||||||
|
const statusHistory = 300
|
||||||
|
|
||||||
const (
|
const (
|
||||||
servicesEvery = 30 * time.Second
|
servicesEvery = 30 * time.Second
|
||||||
containersEvery = 15 * time.Second
|
containersEvery = 15 * time.Second
|
||||||
@@ -36,7 +42,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newStatusSensors(s *Server) *statusSensors {
|
func newStatusSensors(s *Server) *statusSensors {
|
||||||
p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}}
|
p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}, hist: map[string]*sensors.Series{}}
|
||||||
go p.loop("services", servicesEvery, p.services)
|
go p.loop("services", servicesEvery, p.services)
|
||||||
go p.loop("containers", containersEvery, p.containers)
|
go p.loop("containers", containersEvery, p.containers)
|
||||||
go p.loop("updates", updatesEvery, p.updates)
|
go p.loop("updates", updatesEvery, p.updates)
|
||||||
@@ -52,6 +58,28 @@ func (p *statusSensors) loop(name string, every time.Duration, read func(context
|
|||||||
cancel()
|
cancel()
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.by[name] = got
|
p.by[name] = got
|
||||||
|
live := map[string]bool{}
|
||||||
|
for _, x := range got {
|
||||||
|
v, ok := x.Value.(float64)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
live[x.ID] = true
|
||||||
|
h := p.hist[x.ID]
|
||||||
|
if h == nil {
|
||||||
|
h = &sensors.Series{ID: x.ID, Unit: x.Unit}
|
||||||
|
p.hist[x.ID] = h
|
||||||
|
}
|
||||||
|
h.T, h.V = append(h.T, x.T), append(h.V, v)
|
||||||
|
if len(h.T) > statusHistory {
|
||||||
|
h.T, h.V = h.T[len(h.T)-statusHistory:], h.V[len(h.V)-statusHistory:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range p.hist { // a container that is gone takes its history with it
|
||||||
|
if strings.HasPrefix(id, prefixOf[name]) && !live[id] && prefixOf[name] != "" {
|
||||||
|
delete(p.hist, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if len(got) == 0 { // no pools, no Docker, no SMART: look again, but rarely
|
if len(got) == 0 { // no pools, no Docker, no SMART: look again, but rarely
|
||||||
time.Sleep(10 * every)
|
time.Sleep(10 * every)
|
||||||
@@ -61,6 +89,22 @@ func (p *statusSensors) loop(name string, every time.Duration, read func(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prefixOf says which IDs a source owns, so its loop forgets only its own departed sensors.
|
||||||
|
var prefixOf = map[string]string{"containers": "container.", "smart": "smart.", "zfs": "zfs."}
|
||||||
|
|
||||||
|
// SensorHistories implements sensors.Historian.
|
||||||
|
func (p *statusSensors) SensorHistories(match func(id string) bool) []sensors.Series {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
var out []sensors.Series
|
||||||
|
for id, h := range p.hist {
|
||||||
|
if match(id) {
|
||||||
|
out = append(out, sensors.Series{ID: h.ID, Unit: h.Unit, T: append([]int64{}, h.T...), V: append([]float64{}, h.V...)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (p *statusSensors) Sensors() []sensors.Sensor {
|
func (p *statusSensors) Sensors() []sensors.Sensor {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
|
|||||||
@@ -146,6 +146,15 @@ func (s *Sessions) Valid(tok string) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAll ends every session on every device and says how many there were.
|
||||||
|
func (s *Sessions) DeleteAll() (n int) {
|
||||||
|
_ = s.st.Update(func(state *store.State) {
|
||||||
|
n = len(state.Sessions)
|
||||||
|
state.Sessions = map[string]time.Time{}
|
||||||
|
})
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Sessions) Delete(tok string) {
|
func (s *Sessions) Delete(tok string) {
|
||||||
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
|
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { api, Me, Unauthenticated } from './api'
|
import { api, Me, Unauthenticated } from './api'
|
||||||
import Login from './Login'
|
import Login from './Login'
|
||||||
import Shell from './Shell'
|
import Shell from './Shell'
|
||||||
|
import { Dialogs } from './ui'
|
||||||
import { applyTheme, currentThemeId } from './themes'
|
import { applyTheme, currentThemeId } from './themes'
|
||||||
import { getRefreshMs, setRefreshMs } from './prefs'
|
import { getRefreshMs, setRefreshMs } from './prefs'
|
||||||
|
|
||||||
@@ -33,5 +34,5 @@ export default function App() {
|
|||||||
|
|
||||||
if (me === undefined) return <div className="center muted">…</div>
|
if (me === undefined) return <div className="center muted">…</div>
|
||||||
if (me === null) return <Login onLogin={refresh} />
|
if (me === null) return <Login onLogin={refresh} />
|
||||||
return <Shell me={me} onLoggedOut={() => setMe(null)} />
|
return <><Dialogs /><Shell me={me} onLoggedOut={() => setMe(null)} /></>
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-6
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
|
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
|
||||||
import { Logs } from './Services'
|
import { Logs } from './Services'
|
||||||
import { Btn, Loading, Spinner, toast } from './ui'
|
import { Btn, Loading, Spinner, askYes, toast } from './ui'
|
||||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||||
|
import { useHashSel } from './hashsel'
|
||||||
|
|
||||||
const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }
|
const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }
|
||||||
function rel(ms: number): string {
|
function rel(ms: number): string {
|
||||||
@@ -77,12 +78,12 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
|
|||||||
useEffect(() => () => clearTimeout(poll.current), [])
|
useEffect(() => () => clearTimeout(poll.current), [])
|
||||||
|
|
||||||
async function toggle() {
|
async function toggle() {
|
||||||
if (job.enabled && !window.confirm(`Disable this job? It will be commented out in ${srcLabel(job.source)}.`)) return
|
if (job.enabled && !await askYes(`Disable this job? It will be commented out in ${srcLabel(job.source)}.`)) return
|
||||||
setBusy('toggle'); setErr('')
|
setBusy('toggle'); setErr('')
|
||||||
try { await api.cronJobToggle(job.id, !job.enabled); toast(job.enabled ? 'job disabled' : 'job enabled'); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
|
try { await api.cronJobToggle(job.id, !job.enabled); toast(job.enabled ? 'job disabled' : 'job enabled'); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
|
||||||
}
|
}
|
||||||
async function runNow() {
|
async function runNow() {
|
||||||
if (!window.confirm(`Run now as ${job.user}?\n\n${job.command}`)) return
|
if (!await askYes(`Run now as ${job.user}?\n\n${job.command}`)) return
|
||||||
setBusy('run'); setErr('')
|
setBusy('run'); setErr('')
|
||||||
try {
|
try {
|
||||||
const r = await api.cronJobRun(job.id)
|
const r = await api.cronJobRun(job.id)
|
||||||
@@ -141,8 +142,8 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () =>
|
|||||||
const [busy, setBusy] = useState('')
|
const [busy, setBusy] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
async function act(a: string) {
|
async function act(a: string) {
|
||||||
if ((a === 'stop' || a === 'disable') && !window.confirm(`${a} ${t.unit}?`)) return
|
if ((a === 'stop' || a === 'disable') && !await askYes(`${a} ${t.unit}?`)) return
|
||||||
if (a === 'run' && !window.confirm(`Start ${t.activates} now?`)) return
|
if (a === 'run' && !await askYes(`Start ${t.activates} now?`)) return
|
||||||
setBusy(a); setErr('')
|
setBusy(a); setErr('')
|
||||||
try { await api.cronTimerAction(t.unit, a); toast(`${t.unit}: ${a} ok`); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${t.unit}: ${a} failed`, 'err') } finally { setBusy('') }
|
try { await api.cronTimerAction(t.unit, a); toast(`${t.unit}: ${a} ok`); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${t.unit}: ${a} failed`, 'err') } finally { setBusy('') }
|
||||||
}
|
}
|
||||||
@@ -175,7 +176,9 @@ export default function Cron() {
|
|||||||
const [ov, setOv] = useState<CronOverview | null>(null)
|
const [ov, setOv] = useState<CronOverview | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [sel, setSel] = useState<{ kind: 'job' | 'timer'; id: string } | null>(null)
|
const [hashSel, setHashSel] = useHashSel() // "job:<id>" or "timer:<unit>", in the URL
|
||||||
|
const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null
|
||||||
|
const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
|
||||||
const [editing, setEditing] = useState<string | null>(null)
|
const [editing, setEditing] = useState<string | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, DashPrefs, Sample, Series, Temp } from './api'
|
import { api, DashPrefs, Sample, Series, Temp } from './api'
|
||||||
import Sparkline from './Sparkline'
|
import Sparkline from './Sparkline'
|
||||||
import { Loading, Spinner, toast } from './ui'
|
import { Loading, Spinner, askYes, toast } from './ui'
|
||||||
import { fmtMs, useRefreshMs } from './prefs'
|
import { fmtMs, useRefreshMs } from './prefs'
|
||||||
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
||||||
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter'
|
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter'
|
||||||
@@ -352,6 +352,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
// Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses.
|
// Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses.
|
||||||
const [picking, setPicking] = useState(false)
|
const [picking, setPicking] = useState(false)
|
||||||
|
const [editingWidget, setEditingWidget] = useState<WidgetSpec | null>(null)
|
||||||
const live = useSensorStream<Record<string, WidgetData>>(shapeOf(layout.widgets))
|
const live = useSensorStream<Record<string, WidgetData>>(shapeOf(layout.widgets))
|
||||||
|
|
||||||
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
|
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
|
||||||
@@ -381,7 +382,7 @@ export default function Dashboard() {
|
|||||||
})
|
})
|
||||||
const hideMod = (id: string) => update((l) => ({ ...l, hidden: new Set([...l.hidden, id]) }))
|
const hideMod = (id: string) => update((l) => ({ ...l, hidden: new Set([...l.hidden, id]) }))
|
||||||
const showMod = (id: string) => update((l) => { const s = new Set(l.hidden); s.delete(id); return { ...l, hidden: s } })
|
const showMod = (id: string) => update((l) => { const s = new Set(l.hidden); s.delete(id); return { ...l, hidden: s } })
|
||||||
const reset = () => { if (window.confirm(layout.widgets.length ? `Reset the layout and remove ${layout.widgets.length} custom widget${layout.widgets.length > 1 ? 's' : ''}?` : 'Reset the layout to the default?')) update(() => normalize({})) }
|
const reset = async () => { if (await askYes(layout.widgets.length ? `Reset the layout and remove ${layout.widgets.length} custom widget${layout.widgets.length > 1 ? 's' : ''}?` : 'Reset the layout to the default?')) update(() => normalize({})) }
|
||||||
|
|
||||||
const tools = (id: string, kind: Kind) => {
|
const tools = (id: string, kind: Kind) => {
|
||||||
if (!editing) return null
|
if (!editing) return null
|
||||||
@@ -421,6 +422,7 @@ export default function Dashboard() {
|
|||||||
<div className="mod-tools">
|
<div className="mod-tools">
|
||||||
<button title="move left" disabled={i === 0} onClick={() => update((l) => { const a = [...l.widgets]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; return { ...l, widgets: a } })}>◀</button>
|
<button title="move left" disabled={i === 0} onClick={() => update((l) => { const a = [...l.widgets]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; return { ...l, widgets: a } })}>◀</button>
|
||||||
<button title="move right" disabled={i === layout.widgets.length - 1} onClick={() => update((l) => { const a = [...l.widgets]; [a[i + 1], a[i]] = [a[i], a[i + 1]]; return { ...l, widgets: a } })}>▶</button>
|
<button title="move right" disabled={i === layout.widgets.length - 1} onClick={() => update((l) => { const a = [...l.widgets]; [a[i + 1], a[i]] = [a[i], a[i + 1]]; return { ...l, widgets: a } })}>▶</button>
|
||||||
|
<button title="change its sensor, type or title" onClick={() => setEditingWidget(w)}>edit</button>
|
||||||
<button title="remove widget" onClick={() => update((l) => ({ ...l, widgets: l.widgets.filter((x) => x.id !== w.id) }))}>remove</button>
|
<button title="remove widget" onClick={() => update((l) => ({ ...l, widgets: l.widgets.filter((x) => x.id !== w.id) }))}>remove</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -428,9 +430,10 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
|
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
|
||||||
{live.error && !live.data && layout.widgets.length > 0 && <div className="notice attn" style={{ gridColumn: '1 / -1' }}>The widgets' stream isn't answering ({live.error}). If one of them has a bad pattern, remove it.</div>}
|
{live.error && !live.data && layout.widgets.length > 0 && <div className="notice attn" style={{ gridColumn: '1 / -1' }}>The widgets' stream isn't answering: {live.error}.</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{editingWidget && <WidgetPicker initial={editingWidget} onClose={() => setEditingWidget(null)} onAdd={(w) => { update((l) => ({ ...l, widgets: l.widgets.map((x) => (x.id === w.id ? w : x)) })); setEditingWidget(null) }} />}
|
||||||
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
|
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
|
||||||
{visible('card').length > 0 && (
|
{visible('card').length > 0 && (
|
||||||
<div className="cards">
|
<div className="cards">
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api'
|
import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api'
|
||||||
import { fmtBytes, fmtRate } from './Dashboard'
|
import { fmtBytes, fmtRate } from './Dashboard'
|
||||||
import TaskOutput from './TaskOutput'
|
import TaskOutput from './TaskOutput'
|
||||||
import { Btn, Loading, Spinner, toast } from './ui'
|
import { Btn, Loading, Spinner, askText, toast } from './ui'
|
||||||
import { useRefreshMs } from './prefs'
|
import { useRefreshMs } from './prefs'
|
||||||
import { Sensor, useSensorStream } from './sensors'
|
import { Sensor, useSensorStream } from './sensors'
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange
|
|||||||
}
|
}
|
||||||
async function snapshot() {
|
async function snapshot() {
|
||||||
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2')
|
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2')
|
||||||
const name = window.prompt(`Snapshot name for ${d.name}@`, def)
|
const name = await askText(`Snapshot name for ${d.name}@`, def)
|
||||||
if (!name) return
|
if (!name) return
|
||||||
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() }
|
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() }
|
||||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ export default function Embed() {
|
|||||||
{!sensor ? <div className="error">embed needs ?sensor=</div> : (
|
{!sensor ? <div className="error">embed needs ?sensor=</div> : (
|
||||||
<div className={'tile w-' + type}>
|
<div className={'tile w-' + type}>
|
||||||
<Widget spec={spec} data={live.data ? live.data.w : undefined} src={src} />
|
<Widget spec={spec} data={live.data ? live.data.w : undefined} src={src} />
|
||||||
{live.error && !live.data && <div className="sub">{src.token ? 'waiting for the stream — check the token' : 'no token given'}</div>}
|
{live.error && !live.data && <div className="sub">{src.token ? live.error : 'no token in the URL (&token=…)'}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+17
-13
@@ -1,7 +1,8 @@
|
|||||||
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, FileEntry, FileInfo } from './api'
|
import { api, FileEntry, FileInfo } from './api'
|
||||||
import { fmtBytes } from './Dashboard'
|
import { fmtBytes } from './Dashboard'
|
||||||
import { Btn, Loading, Spinner, setUnsaved, toast, useEscape } from './ui'
|
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
||||||
|
import { useHashSel } from './hashsel'
|
||||||
|
|
||||||
const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name)
|
const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name)
|
||||||
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
||||||
@@ -64,7 +65,7 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
|||||||
return () => window.removeEventListener('keydown', h)
|
return () => window.removeEventListener('keydown', h)
|
||||||
}, [save])
|
}, [save])
|
||||||
|
|
||||||
const close = () => { if (dirty && !window.confirm('Discard unsaved changes?')) return; setUnsaved(''); onClose() }
|
const close = async () => { if (dirty && !await askYes('Discard unsaved changes?', 'Discard')) return; setUnsaved(''); onClose() }
|
||||||
// Unsaved edits are announced, so leaving the page, switching machine or closing the tab asks first.
|
// Unsaved edits are announced, so leaving the page, switching machine or closing the tab asks first.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUnsaved(dirty ? name : '')
|
setUnsaved(dirty ? name : '')
|
||||||
@@ -100,7 +101,10 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
|||||||
|
|
||||||
// ---- page ----
|
// ---- page ----
|
||||||
export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: string) => void }) {
|
export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: string) => void }) {
|
||||||
const [path, setPath] = useState<string>(() => sessionStorage.getItem('hamlab.files.path') || '/')
|
const [hashPath, setHashPath] = useHashSel() // the folder is in the URL; without one, the last folder that opened
|
||||||
|
const [path, setPath] = useState<string>(() => (hashPath.startsWith('/') ? hashPath : sessionStorage.getItem('hamlab.files.path') || '/'))
|
||||||
|
useEffect(() => { setHashPath(path) }, [path, setHashPath])
|
||||||
|
useEffect(() => { if (hashPath.startsWith('/') && hashPath !== path) setPath(hashPath) }, [hashPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
const [sudo, setSudo] = useState<boolean>(() => sessionStorage.getItem('hamlab.files.sudo') === '1')
|
const [sudo, setSudo] = useState<boolean>(() => sessionStorage.getItem('hamlab.files.sudo') === '1')
|
||||||
const [entries, setEntries] = useState<FileEntry[] | null>(null)
|
const [entries, setEntries] = useState<FileEntry[] | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
@@ -148,20 +152,20 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
}
|
}
|
||||||
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
||||||
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
|
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
|
||||||
const mkdir = () => { const n = window.prompt('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
const mkdir = async () => { const n = await askText('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
||||||
const touch = () => { const n = window.prompt('New file name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
|
const touch = async () => { const n = await askText('New file name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
|
||||||
const rename = (e: FileEntry) => { const n = window.prompt('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
const rename = async (e: FileEntry) => { const n = await askText('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
||||||
const move = (e: FileEntry) => { const n = window.prompt('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
|
const move = async (e: FileEntry) => { const n = await askText('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
|
||||||
const chmod = (e: FileEntry) => { const n = window.prompt(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
|
const chmod = async (e: FileEntry) => { const n = await askText(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
|
||||||
const del = (e: FileEntry) => {
|
const del = async (e: FileEntry) => {
|
||||||
const full = join(path, e.name)
|
const full = join(path, e.name)
|
||||||
if (e.type === 'dir' && !e.target) {
|
if (e.type === 'dir' && !e.target) {
|
||||||
const typed = window.prompt(`Delete the folder "${e.name}" and everything in it?\nType its name to confirm.`)
|
const typed = await askText(`Delete the folder "${e.name}" and everything in it?\nType its name to confirm.`)
|
||||||
if (typed !== e.name) return
|
if (typed !== e.name) return
|
||||||
act(`deleted ${e.name}`, () => api.fileDelete(full, true, sudo))
|
act(`deleted ${e.name}`, () => api.fileDelete(full, true, sudo))
|
||||||
} else if (window.confirm(`Delete ${e.name}?`)) act(`deleted ${e.name}`, () => api.fileDelete(full, false, sudo))
|
} else if (await askYes(`Delete ${e.name}?`)) act(`deleted ${e.name}`, () => api.fileDelete(full, false, sudo))
|
||||||
}
|
}
|
||||||
const copyPath = async (e: FileEntry) => { try { await navigator.clipboard.writeText(join(path, e.name)); toast('path copied') } catch { window.prompt('Path', join(path, e.name)) } }
|
const copyPath = async (e: FileEntry) => { try { await navigator.clipboard.writeText(join(path, e.name)); toast('path copied') } catch { await askText('Path', join(path, e.name)) } }
|
||||||
const openTerm = (p: string) => { onOpenInTerminal?.(`cd ${shellQuote(p)}`); }
|
const openTerm = (p: string) => { onOpenInTerminal?.(`cd ${shellQuote(p)}`); }
|
||||||
|
|
||||||
// ---- upload ----
|
// ---- upload ----
|
||||||
@@ -171,7 +175,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
const f = list[i]
|
const f = list[i]
|
||||||
let overwrite = false
|
let overwrite = false
|
||||||
if ((entries ?? []).some((e) => e.name === f.name)) {
|
if ((entries ?? []).some((e) => e.name === f.name)) {
|
||||||
if (!window.confirm(`"${f.name}" already exists here. Replace it?`)) continue
|
if (!await askYes(`"${f.name}" already exists here. Replace it?`)) continue
|
||||||
overwrite = true
|
overwrite = true
|
||||||
}
|
}
|
||||||
setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length })
|
setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length })
|
||||||
|
|||||||
+10
-5
@@ -35,6 +35,7 @@ export default function Gallery({ demoUrl }: { demoUrl?: string }) {
|
|||||||
const [cat, setCat] = useState<Sensor[] | null>(null)
|
const [cat, setCat] = useState<Sensor[] | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const rate = useRefreshMs()
|
const rate = useRefreshMs()
|
||||||
|
const [find, setFind] = useState('')
|
||||||
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||||
// Values only, at most once a second: the catalog already holds everything that doesn't change.
|
// Values only, at most once a second: the catalog already holds everything that doesn't change.
|
||||||
const live = useSensorStream<[Record<string, Sensor['value']>]>(['*'], { full: false, everyMs: Math.max(1000, rate) })
|
const live = useSensorStream<[Record<string, Sensor['value']>]>(['*'], { full: false, everyMs: Math.max(1000, rate) })
|
||||||
@@ -47,7 +48,9 @@ export default function Gallery({ demoUrl }: { demoUrl?: string }) {
|
|||||||
|
|
||||||
const byId = new Map(sensors.map((s) => [s.id, s]))
|
const byId = new Map(sensors.map((s) => [s.id, s]))
|
||||||
const families = new Map<string, Sensor[]>()
|
const families = new Map<string, Sensor[]>()
|
||||||
for (const s of sensors) { const f = s.id.split('.')[0]; families.set(f, [...(families.get(f) ?? []), s]) }
|
const needle = find.trim().toLowerCase()
|
||||||
|
const shown = needle ? sensors.filter((s) => s.id.includes(needle) || s.name.toLowerCase().includes(needle) || Object.values(s.labels ?? {}).some((l) => l.toLowerCase().includes(needle))) : sensors
|
||||||
|
for (const s of shown) { const f = s.id.split('.')[0]; families.set(f, [...(families.get(f) ?? []), s]) }
|
||||||
const names = [...families.keys()].sort((a, b) => (ORDER.indexOf(a) + 1 || 99) - (ORDER.indexOf(b) + 1 || 99) || a.localeCompare(b))
|
const names = [...families.keys()].sort((a, b) => (ORDER.indexOf(a) + 1 || 99) - (ORDER.indexOf(b) + 1 || 99) || a.localeCompare(b))
|
||||||
const demo = byId.get('cpu.usage')
|
const demo = byId.get('cpu.usage')
|
||||||
const cores = Object.fromEntries(sensors.filter((s) => /^cpu\.core\.\d+\.usage$/.test(s.id)).map((s) => [s.id, s]))
|
const cores = Object.fromEntries(sensors.filter((s) => /^cpu\.core\.\d+\.usage$/.test(s.id)).map((s) => [s.id, s]))
|
||||||
@@ -55,16 +58,18 @@ export default function Gallery({ demoUrl }: { demoUrl?: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="dash gallery">
|
<div className="dash gallery">
|
||||||
<div className="dash-bar">
|
<div className="dash-bar">
|
||||||
<span className="hint">{sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
|
<span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
|
||||||
|
<input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{demoUrl && <a className="btn" href={demoUrl} target="_blank" rel="noreferrer" title="a page outside Ham Lab, built on the same sensor API">External demo ↗</a>}
|
{demoUrl && <a className="btn" href={demoUrl} target="_blank" rel="noreferrer" title="a page outside Ham Lab, built on the same sensor API">External demo ↗</a>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="section">Widget types</h2>
|
{!needle && <h2 className="section">Widget types</h2>}
|
||||||
<div className="kpis">
|
{!needle && <div className="kpis">
|
||||||
{demo && WIDGET_TYPES.filter((t) => !t.many).map((t) => <Tile key={t.id} spec={{ id: t.id, type: t.id, sensor: 'cpu.usage', title: t.label }} data={demo} note={`type=${t.id}`} />)}
|
{demo && WIDGET_TYPES.filter((t) => !t.many).map((t) => <Tile key={t.id} spec={{ id: t.id, type: t.id, sensor: 'cpu.usage', title: t.label }} data={demo} note={`type=${t.id}`} />)}
|
||||||
<Tile spec={{ id: 'list', type: 'list', sensor: 'cpu.core.*.usage', title: 'List' }} data={cores} note="type=list · cpu.core.*.usage" />
|
<Tile spec={{ id: 'list', type: 'list', sensor: 'cpu.core.*.usage', title: 'List' }} data={cores} note="type=list · cpu.core.*.usage" />
|
||||||
</div>
|
</div>}
|
||||||
|
{needle && shown.length === 0 && <div className="muted">No sensor matches “{find}”.</div>}
|
||||||
|
|
||||||
{names.map((f) => (
|
{names.map((f) => (
|
||||||
<section key={f}>
|
<section key={f}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
import { api, MachineStatus, Me } from './api'
|
import { api, MachineStatus, Me } from './api'
|
||||||
import { Btn, Loading, toast } from './ui'
|
import { Btn, Loading, askYes, toast } from './ui'
|
||||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||||
|
|
||||||
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
||||||
@@ -37,28 +37,28 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
|
|||||||
finally { setAdding(false) }
|
finally { setAdding(false) }
|
||||||
}
|
}
|
||||||
async function update(m: MachineStatus) {
|
async function update(m: MachineStatus) {
|
||||||
if (!window.confirm(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return
|
if (!await askYes(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return
|
||||||
try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) }
|
try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) }
|
||||||
catch (e) {
|
catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : 'failed'
|
const msg = e instanceof Error ? e.message : 'failed'
|
||||||
if (msg.startsWith('busy:') && window.confirm(`${m.name} is ${msg.slice(6)}\n\nForce the update anyway?`)) {
|
if (msg.startsWith('busy:') && await askYes(`${m.name} is ${msg.slice(6)}\n\nForce the update anyway?`)) {
|
||||||
try { const r = await api.updateMachine(m.name, true); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e2) { toast(`${m.name}: ${e2 instanceof Error ? e2.message : 'failed'}`, 'err') }
|
try { const r = await api.updateMachine(m.name, true); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e2) { toast(`${m.name}: ${e2 instanceof Error ? e2.message : 'failed'}`, 'err') }
|
||||||
} else toast(`${m.name}: ${msg}`, 'err')
|
} else toast(`${m.name}: ${msg}`, 'err')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function updateAll() {
|
async function updateAll() {
|
||||||
const n = list?.filter(outdated).length ?? 0
|
const n = list?.filter(outdated).length ?? 0
|
||||||
if (!window.confirm(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return
|
if (!await askYes(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return
|
||||||
try {
|
try {
|
||||||
let res = await api.updateAllMachines()
|
let res = await api.updateAllMachines()
|
||||||
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
|
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
|
||||||
if (busy.length && window.confirm(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
|
if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
|
||||||
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
|
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
|
||||||
setTimeout(load, 4000)
|
setTimeout(load, 4000)
|
||||||
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
}
|
}
|
||||||
async function remove(m: MachineStatus) {
|
async function remove(m: MachineStatus) {
|
||||||
if (!window.confirm(`Remove ${m.name}? Its access token will be revoked. Nothing on ${m.name} itself is changed.`)) return
|
if (!await askYes(`Remove ${m.name}? Its access token will be revoked. Nothing on ${m.name} itself is changed.`)) return
|
||||||
try { await api.removeMachine(m.name); toast(`${m.name} removed`); await load(); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
try { await api.removeMachine(m.name); toast(`${m.name} removed`); await load(); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import { api, Proc, ProcTable } from './api'
|
import { api, Proc, ProcTable } from './api'
|
||||||
import { fmtBytes, fmtRate } from './Dashboard'
|
import { fmtBytes, fmtRate } from './Dashboard'
|
||||||
import { Btn, Loading, toast } from './ui'
|
import { Btn, Loading, askYes, toast } from './ui'
|
||||||
import { useRefreshMs } from './prefs'
|
import { useRefreshMs } from './prefs'
|
||||||
|
|
||||||
type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time'
|
type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time'
|
||||||
@@ -78,7 +78,7 @@ export default function Procs() {
|
|||||||
const swapFrac = t.swap.total ? t.swap.used / t.swap.total : 0
|
const swapFrac = t.swap.total ? t.swap.used / t.swap.total : 0
|
||||||
|
|
||||||
const signal = async (p: Proc, sig: 'TERM' | 'KILL') => {
|
const signal = async (p: Proc, sig: 'TERM' | 'KILL') => {
|
||||||
if (!window.confirm(`Send SIG${sig} to ${p.name} (pid ${p.pid})?`)) return
|
if (!await askYes(`Send SIG${sig} to ${p.name} (pid ${p.pid})?`)) return
|
||||||
try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.name}`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.name}`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
|
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
|
||||||
import { Btn, Loading, Spinner, toast } from './ui'
|
import { Btn, Loading, Spinner, askYes, toast } from './ui'
|
||||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||||
|
import { useHashSel } from './hashsel'
|
||||||
|
|
||||||
type Filter = 'all' | 'running' | 'failed' | 'enabled'
|
type Filter = 'all' | 'running' | 'failed' | 'enabled'
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
|
|||||||
|
|
||||||
async function act(action: string) {
|
async function act(action: string) {
|
||||||
const confirmFor: Record<string, string> = { stop: 'Stop', restart: 'Restart', disable: 'Disable' }
|
const confirmFor: Record<string, string> = { stop: 'Stop', restart: 'Restart', disable: 'Disable' }
|
||||||
if (confirmFor[action] && !window.confirm(`${confirmFor[action]} ${name}?`)) return
|
if (confirmFor[action] && !await askYes(`${confirmFor[action]} ${name}?`)) return
|
||||||
setBusy(action); setErr('')
|
setBusy(action); setErr('')
|
||||||
try { await api.serviceAction(name, action); await load(); onChanged(); toast(`${name}: ${action} ok`) }
|
try { await api.serviceAction(name, action); await load(); onChanged(); toast(`${name}: ${action} ok`) }
|
||||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${name}: ${action} failed`, 'err') }
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${name}: ${action} failed`, 'err') }
|
||||||
@@ -176,7 +177,8 @@ export default function Services() {
|
|||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [filter, setFilter] = useState<Filter>('all')
|
const [filter, setFilter] = useState<Filter>('all')
|
||||||
const [sel, setSel] = useState<string | null>(null)
|
const [hashSel, setHashSel] = useHashSel() // the selected unit lives in the URL
|
||||||
|
const sel = hashSel || null, setSel = (v: string | null) => setHashSel(v ?? '')
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
|||||||
+43
-9
@@ -1,7 +1,8 @@
|
|||||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { fetchCatalog } from './sensors'
|
||||||
import { api, Me } from './api'
|
import { api, Me } from './api'
|
||||||
import { applyTheme, currentThemeId, THEMES } from './themes'
|
import { applyTheme, currentThemeId, THEMES } from './themes'
|
||||||
import { Btn, Loading, toast } from './ui'
|
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||||
import { fmtMs, getRefreshMs, REFRESH_OPTIONS, setRefreshMs } from './prefs'
|
import { fmtMs, getRefreshMs, REFRESH_OPTIONS, setRefreshMs } from './prefs'
|
||||||
|
|
||||||
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
||||||
@@ -33,6 +34,7 @@ export default function Settings({ me }: { me: Me }) {
|
|||||||
const [refresh, setRefresh] = useState(getRefreshMs())
|
const [refresh, setRefresh] = useState(getRefreshMs())
|
||||||
const [tokens, setTokens] = useState<{ name: string; created: string; scope?: string; sensors?: string[] }[] | null>(null)
|
const [tokens, setTokens] = useState<{ name: string; created: string; scope?: string; sensors?: string[] }[] | null>(null)
|
||||||
const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null)
|
const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null)
|
||||||
|
const [making, setMaking] = useState(false)
|
||||||
const [cur, setCur] = useState('')
|
const [cur, setCur] = useState('')
|
||||||
const [pw1, setPw1] = useState('')
|
const [pw1, setPw1] = useState('')
|
||||||
const [pw2, setPw2] = useState('')
|
const [pw2, setPw2] = useState('')
|
||||||
@@ -59,7 +61,7 @@ export default function Settings({ me }: { me: Me }) {
|
|||||||
catch (err) { setPwErr(err instanceof Error ? err.message : 'failed') }
|
catch (err) { setPwErr(err instanceof Error ? err.message : 'failed') }
|
||||||
}
|
}
|
||||||
async function revoke(name: string) {
|
async function revoke(name: string) {
|
||||||
if (!window.confirm(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return
|
if (!await askYes(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return
|
||||||
try { await api.revokeToken(name); toast(`${name} revoked`); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
|
try { await api.revokeToken(name); toast(`${name} revoked`); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,16 +120,18 @@ export default function Settings({ me }: { me: Me }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="svc-actions" style={{ marginTop: 8 }}>
|
<div className="svc-actions" style={{ marginTop: 8 }}>
|
||||||
<Btn className="btn" onClick={async () => {
|
<Btn className="btn" onClick={() => setMaking(true)}>+ Read-only sensor token</Btn>
|
||||||
const name = window.prompt('Name for the read-only token (what will use it)', 'glance')?.trim()
|
|
||||||
if (!name) return
|
|
||||||
const pats = window.prompt('Limit it to sensor patterns? Comma-separated, e.g. cpu.*, temp.*, zfs.tank.* — leave empty for every sensor', '')
|
|
||||||
if (pats === null) return
|
|
||||||
try { setFresh(await api.createReadToken(name, pats.split(',').map((x) => x.trim()).filter(Boolean))); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
|
|
||||||
}}>+ Read-only sensor token</Btn>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div className="dash-bar"><h2 className="section">Sessions</h2></div>
|
||||||
|
<section className="card">
|
||||||
|
<div className="muted small" style={{ marginBottom: 8 }}>Signs out every browser and phone that is logged in to this Ham Lab, this one included. Access tokens are not sessions and keep working.</div>
|
||||||
|
<Btn className="btn danger" confirm="Log out everywhere? Every device, including this one, will need the password again." onClick={async () => { try { await api.logoutAll() } finally { window.dispatchEvent(new Event('hamlab:unauthenticated')) } }}>Log out everywhere</Btn>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{making && <TokenDialog onClose={() => setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}
|
||||||
|
|
||||||
<div className="dash-bar"><h2 className="section">About</h2></div>
|
<div className="dash-bar"><h2 className="section">About</h2></div>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="props">
|
<div className="props">
|
||||||
@@ -141,3 +145,33 @@ export default function Settings({ me }: { me: Me }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One dialog for a read-only token: what will use it, and (optionally) which sensor
|
||||||
|
// families it may see — picked from this machine's own catalog, or typed as patterns.
|
||||||
|
function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) {
|
||||||
|
const [name, setName] = useState('glance')
|
||||||
|
const [pats, setPats] = useState('')
|
||||||
|
const [fams, setFams] = useState<string[]>([])
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
useEscape(onClose)
|
||||||
|
useEffect(() => { fetchCatalog({ machine: null }).then((c) => setFams([...new Set(c.map((s) => s.id.split('.')[0]))].sort())).catch(() => {}) }, [])
|
||||||
|
const list = pats.split(',').map((x) => x.trim()).filter(Boolean)
|
||||||
|
const toggle = (f: string) => { const p = f === 'uptime' ? f : f + '.*'; setPats((list.includes(p) ? list.filter((x) => x !== p) : [...list, p]).join(', ')) }
|
||||||
|
const make = async () => { try { onMade(await api.createReadToken(name.trim(), list)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); make() }}>
|
||||||
|
<h3>Read-only sensor token</h3>
|
||||||
|
<label className="muted">Name — what will use it
|
||||||
|
<input autoFocus value={name} onChange={(e) => setName(e.target.value)} maxLength={64} spellCheck={false} autoCapitalize="off" />
|
||||||
|
</label>
|
||||||
|
<label className="muted">Limit it to these sensors — empty means every sensor
|
||||||
|
<input className="mono" value={pats} onChange={(e) => setPats(e.target.value)} placeholder="cpu.*, temp.*, zfs.tank.*" spellCheck={false} autoCapitalize="off" />
|
||||||
|
</label>
|
||||||
|
<div className="chips">{fams.map((f) => <button type="button" key={f} className={'chip' + (list.includes(f === 'uptime' ? f : f + '.*') ? ' on' : '')} onClick={() => toggle(f)}>{f}</button>)}</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions"><button type="submit" className="btn on" disabled={!name.trim()}>Create</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+49
-12
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, Me, Term } from './api'
|
import { api, Me, Term } from './api'
|
||||||
import Rail, { Group } from './Rail'
|
import Rail, { Group } from './Rail'
|
||||||
import TerminalPane, { TermHandle } from './TerminalPane'
|
import TerminalPane, { TermHandle } from './TerminalPane'
|
||||||
@@ -18,7 +18,7 @@ import MachineSwitcher from './MachineSwitcher'
|
|||||||
import { MachineStatus, setMachine } from './api'
|
import { MachineStatus, setMachine } from './api'
|
||||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||||
import Power from './Power'
|
import Power from './Power'
|
||||||
import { Btn, Toasts, okToLeave, toast } from './ui'
|
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
|
||||||
|
|
||||||
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
||||||
const FolderIcon = (
|
const FolderIcon = (
|
||||||
@@ -48,7 +48,7 @@ const HUB_ONLY: View[] = ['machines', 'settings']
|
|||||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||||
// forward work, reload restores the tab, and links can point at a page on a machine.
|
// forward work, reload restores the tab, and links can point at a page on a machine.
|
||||||
function parseHash(): { machine: string | null; view: View } | null {
|
function parseHash(): { machine: string | null; view: View } | null {
|
||||||
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)$/)
|
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection
|
||||||
if (!m || !VIEWS.includes(m[2] as View)) return null
|
if (!m || !VIEWS.includes(m[2] as View)) return null
|
||||||
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View }
|
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View }
|
||||||
}
|
}
|
||||||
@@ -90,8 +90,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate])
|
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate])
|
||||||
|
|
||||||
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
|
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
|
||||||
const switchMachine = (name: string | null) => {
|
const switchMachine = async (name: string | null) => {
|
||||||
if (name !== machine && !okToLeave()) return
|
if (name !== machine && !(await okToLeave())) return
|
||||||
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
||||||
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
|
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
|
||||||
setMachineState(name)
|
setMachineState(name)
|
||||||
@@ -103,10 +103,19 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${machine ?? hubMe.host} — Ham Lab` }, [view, machine, hubMe.host])
|
useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${machine ?? hubMe.host} — Ham Lab` }, [view, machine, hubMe.host])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const want = `#/${machine ?? 'hub'}/${view}`
|
const want = `#/${machine ?? 'hub'}/${view}`
|
||||||
if (location.hash !== want) history.pushState(null, '', want)
|
if (location.hash !== want && !location.hash.startsWith(want + '/')) history.pushState(null, '', want)
|
||||||
}, [machine, view])
|
}, [machine, view])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const h = () => { const p = parseHash(); if (!p) return; if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view) }
|
const h = async () => {
|
||||||
|
const p = parseHash(); if (!p) return
|
||||||
|
if (hasUnsaved() && (p.machine !== machine || p.view !== view)) { // Back/Forward away from unsaved work: stay put until answered
|
||||||
|
const there = location.hash
|
||||||
|
history.pushState(null, '', `#/${machine ?? 'hub'}/${view}`)
|
||||||
|
if (!(await okToLeave())) return
|
||||||
|
history.replaceState(null, '', there)
|
||||||
|
}
|
||||||
|
if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view)
|
||||||
|
}
|
||||||
window.addEventListener('popstate', h)
|
window.addEventListener('popstate', h)
|
||||||
return () => window.removeEventListener('popstate', h)
|
return () => window.removeEventListener('popstate', h)
|
||||||
}) // no deps: needs the latest switchMachine/view each time
|
}) // no deps: needs the latest switchMachine/view each time
|
||||||
@@ -131,13 +140,13 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
finally { setCreating(false) }
|
finally { setCreating(false) }
|
||||||
}
|
}
|
||||||
async function rename(t: Term) {
|
async function rename(t: Term) {
|
||||||
const name = window.prompt('Rename terminal', t.name)
|
const name = await askText('Rename terminal', t.name)
|
||||||
if (name && name.trim() && name !== t.name) {
|
if (name && name.trim() && name !== t.name) {
|
||||||
try { await api.renameTerminal(t.id, name.trim()); await reload() } catch (e) { toast(`rename — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
try { await api.renameTerminal(t.id, name.trim()); await reload() } catch (e) { toast(`rename — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function close(t: Term) {
|
async function close(t: Term) {
|
||||||
if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return
|
if (t.running && !await askYes(`Close "${t.name}"? The shell will be hung up.`)) return
|
||||||
try { await api.deleteTerminal(t.id); delete handles.current[t.id]; await reload() }
|
try { await api.deleteTerminal(t.id); delete handles.current[t.id]; await reload() }
|
||||||
catch (e) { toast(`close — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
catch (e) { toast(`close — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||||
}
|
}
|
||||||
@@ -154,6 +163,33 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
window.setTimeout(() => send(20), 50)
|
window.setTimeout(() => send(20), 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab order is the user's, kept per machine in this browser; new terminals join at the end.
|
||||||
|
const orderKey = `hamlab.termorder.${machine ?? 'hub'}`
|
||||||
|
const [order, setOrder] = useState<string[]>([])
|
||||||
|
useEffect(() => { try { setOrder(JSON.parse(localStorage.getItem(orderKey) ?? '[]')) } catch { setOrder([]) } }, [orderKey])
|
||||||
|
const ordered = useMemo(() => [...terms].sort((a, b) => { const i = order.indexOf(a.id), j = order.indexOf(b.id); return (i < 0 ? 1e9 : i) - (j < 0 ? 1e9 : j) }), [terms, order])
|
||||||
|
const moveTab = (id: string, before: string | null) => {
|
||||||
|
const ids = ordered.map((t) => t.id).filter((x) => x !== id)
|
||||||
|
const at = before ? ids.indexOf(before) : -1
|
||||||
|
ids.splice(at < 0 ? ids.length : at, 0, id)
|
||||||
|
setOrder(ids); localStorage.setItem(orderKey, JSON.stringify(ids))
|
||||||
|
}
|
||||||
|
// Alt+Shift+←/→ switches tabs, Alt+Shift+T opens one. Caught before xterm sees the keys.
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== 'terminal') return
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
if (!e.altKey || !e.shiftKey || e.ctrlKey || e.metaKey) return
|
||||||
|
const dir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0
|
||||||
|
if (dir && ordered.length > 1) { const i = ordered.findIndex((t) => t.id === active); setActive(ordered[(i + dir + ordered.length) % ordered.length].id) }
|
||||||
|
else if (e.key.toLowerCase() === 't') create()
|
||||||
|
else return
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', h, true)
|
||||||
|
return () => window.removeEventListener('keydown', h, true)
|
||||||
|
}) // no deps: needs the latest tabs each time
|
||||||
|
|
||||||
|
const activeRef = useRef(active); activeRef.current = active
|
||||||
const activeTerm = terms.find((t) => t.id === active)
|
const activeTerm = terms.find((t) => t.id === active)
|
||||||
const termView = view === 'terminal'
|
const termView = view === 'terminal'
|
||||||
|
|
||||||
@@ -175,7 +211,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<div className="body">
|
<div className="body">
|
||||||
<Rail groups={GROUPS} view={view}
|
<Rail groups={GROUPS} view={view}
|
||||||
header={<MachineSwitcher hub={hubMe.host} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
header={<MachineSwitcher hub={hubMe.host} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
||||||
onView={(id) => { if (id !== view && !okToLeave()) return; setView(id as View); setDrawer(false) }} />
|
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
|
||||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||||
|
|
||||||
<main className="main" key={machine ?? 'hub'}>
|
<main className="main" key={machine ?? 'hub'}>
|
||||||
@@ -189,8 +225,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
{view === 'updates' && <Updates />}
|
{view === 'updates' && <Updates />}
|
||||||
{view === 'disks' && <Disks />}
|
{view === 'disks' && <Disks />}
|
||||||
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
||||||
{termView && <TermTabs terms={terms} active={active} creating={creating}
|
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
||||||
onSelect={setActive} onNew={create} onRename={rename} onClose={close} />}
|
onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab}
|
||||||
|
onFocusTerm={() => window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />}
|
||||||
{termView && terms.length === 0 && (
|
{termView && terms.length === 0 && (
|
||||||
<div className="center muted">
|
<div className="center muted">
|
||||||
<div>No terminals.</div>
|
<div>No terminals.</div>
|
||||||
|
|||||||
+20
-7
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Term } from './api'
|
import { Term } from './api'
|
||||||
import { Spinner } from './ui'
|
import { Spinner } from './ui'
|
||||||
|
|
||||||
@@ -6,29 +6,42 @@ interface Props {
|
|||||||
terms: Term[]; active: string | null; creating?: boolean
|
terms: Term[]; active: string | null; creating?: boolean
|
||||||
onSelect: (id: string) => void; onNew: () => void
|
onSelect: (id: string) => void; onNew: () => void
|
||||||
onRename: (t: Term) => void; onClose: (t: Term) => void
|
onRename: (t: Term) => void; onClose: (t: Term) => void
|
||||||
|
onMove: (id: string, before: string | null) => void // put a tab before another, or (null) at the end
|
||||||
|
onFocusTerm: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab strip across the top of the terminal page: one tab per session, "+" at the end.
|
// Tab strip across the top of the terminal page: one tab per session, "+" at the end.
|
||||||
export default function TermTabs({ terms, active, creating, onSelect, onNew, onRename, onClose }: Props) {
|
export default function TermTabs({ terms, active, creating, onSelect, onNew, onRename, onClose, onMove, onFocusTerm }: Props) {
|
||||||
|
const [dragging, setDragging] = useState<string | null>(null)
|
||||||
|
const [over, setOver] = useState<string | null>(null)
|
||||||
const strip = useRef<HTMLDivElement>(null)
|
const strip = useRef<HTMLDivElement>(null)
|
||||||
// keep the active tab in sight when the strip overflows
|
// keep the active tab in sight when the strip overflows
|
||||||
useEffect(() => { strip.current?.querySelector('.ttab.active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [active, terms.length])
|
useEffect(() => { strip.current?.querySelector('.ttab.active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [active, terms.length])
|
||||||
return (
|
return (
|
||||||
<div className="term-tabs" ref={strip} onMouseDown={(e) => e.preventDefault()}>
|
// Buttons must not take focus from the terminal; the tabs themselves must stay draggable, which a
|
||||||
{terms.map((t) => {
|
// prevented mousedown would stop — so a tab click hands focus back afterwards instead.
|
||||||
|
<div className="term-tabs" ref={strip} onMouseDown={(e) => { if ((e.target as HTMLElement).closest('button')) e.preventDefault() }}>
|
||||||
|
{terms.map((t, i) => {
|
||||||
const sel = t.id === active
|
const sel = t.id === active
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className={'ttab' + (sel ? ' active' : '') + (t.running ? '' : ' exited')}
|
<div key={t.id} className={'ttab' + (sel ? ' active' : '') + (t.running ? '' : ' exited') + (over === t.id && dragging !== t.id ? ' drop' : '')}
|
||||||
onClick={() => onSelect(t.id)} onDoubleClick={() => onRename(t)}
|
draggable onDragStart={(e) => { setDragging(t.id); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', t.id) }}
|
||||||
|
onDragOver={(e) => { if (dragging) { e.preventDefault(); setOver(t.id) } }} onDragLeave={() => setOver((o) => (o === t.id ? null : o))}
|
||||||
|
onDrop={(e) => { e.preventDefault(); if (dragging && dragging !== t.id) onMove(dragging, t.id); setDragging(null); setOver(null); onFocusTerm() }} onDragEnd={() => { setDragging(null); setOver(null) }}
|
||||||
|
onClick={() => { onSelect(t.id); onFocusTerm() }} onDoubleClick={() => onRename(t)}
|
||||||
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t) } }}>
|
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t) } }}>
|
||||||
<span className={'sdot' + (t.running ? ' run' : '')} />
|
<span className={'sdot' + (t.running ? ' run' : '')} />
|
||||||
<span className="tname" title={t.name}>{t.name}</span>
|
<span className="tname" title={t.name}>{t.name}</span>
|
||||||
|
{sel && terms.length > 1 && <span className="ttab-move">
|
||||||
|
<button className="icon-btn" title="move left" disabled={i === 0} onClick={(e) => { e.stopPropagation(); onMove(t.id, terms[i - 1].id) }}>‹</button>
|
||||||
|
<button className="icon-btn" title="move right" disabled={i === terms.length - 1} onClick={(e) => { e.stopPropagation(); onMove(t.id, terms[i + 2]?.id ?? null) }}>›</button>
|
||||||
|
</span>}
|
||||||
{sel && <button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>}
|
{sel && <button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>}
|
||||||
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
|
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<button className="ttab new" title="new terminal" disabled={creating} onClick={onNew}>{creating ? <Spinner /> : '+'}</button>
|
<button className="ttab new" title="new terminal (Alt+Shift+T) · Alt+Shift+←/→ switches tabs" disabled={creating} onClick={onNew}>{creating ? <Spinner /> : '+'}</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { WIDGET_TYPES, WidgetSpec, WidgetType } from './widgets'
|
|||||||
import { Btn, Loading, useEscape } from './ui'
|
import { Btn, Loading, useEscape } from './ui'
|
||||||
|
|
||||||
// Pick a sensor from this machine's catalog (or type a pattern) and how to draw it.
|
// Pick a sensor from this machine's catalog (or type a pattern) and how to draw it.
|
||||||
export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec) => void; onClose: () => void }) {
|
export default function WidgetPicker({ onAdd, onClose, initial }: { onAdd: (w: WidgetSpec) => void; onClose: () => void; initial?: WidgetSpec }) {
|
||||||
const [cat, setCat] = useState<Sensor[] | null>(null)
|
const [cat, setCat] = useState<Sensor[] | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const [sensor, setSensor] = useState('')
|
const [sensor, setSensor] = useState(initial?.sensor ?? '')
|
||||||
const [type, setType] = useState<WidgetType>('stat')
|
const [type, setType] = useState<WidgetType>(initial?.type ?? 'stat')
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState(initial?.title ?? '')
|
||||||
const [bad, setBad] = useState('')
|
const [bad, setBad] = useState('')
|
||||||
// One bad pattern would 400 the stream every widget on the page shares, so ask the API first.
|
// One bad pattern would 400 the stream every widget on the page shares, so ask the API first.
|
||||||
const add = async () => {
|
const add = async () => {
|
||||||
@@ -19,7 +19,7 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
|
|||||||
const r = await fetch(apiPath('/api/sensors/query'), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ w: sensor }) })
|
const r = await fetch(apiPath('/api/sensors/query'), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ w: sensor }) })
|
||||||
if (!r.ok) { setBad((await r.json().catch(() => ({}))).error ?? `the API refused it (${r.status})`); return }
|
if (!r.ok) { setBad((await r.json().catch(() => ({}))).error ?? `the API refused it (${r.status})`); return }
|
||||||
} catch { setBad('could not reach the API'); return }
|
} catch { setBad('could not reach the API'); return }
|
||||||
onAdd({ id: 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })
|
onAdd({ id: initial?.id ?? 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })
|
||||||
}
|
}
|
||||||
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||||
const shown = useMemo(() => {
|
const shown = useMemo(() => {
|
||||||
@@ -32,7 +32,7 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
|
|||||||
return (
|
return (
|
||||||
<div className="modal-back" onClick={onClose}>
|
<div className="modal-back" onClick={onClose}>
|
||||||
<div className="modal picker" onClick={(e) => e.stopPropagation()}>
|
<div className="modal picker" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>Add a widget</h3>
|
<h3>{initial ? 'Edit widget' : 'Add a widget'}</h3>
|
||||||
<input autoFocus value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter sensors — cpu, temp, fan, enp3s0…" />
|
<input autoFocus value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter sensors — cpu, temp, fan, enp3s0…" />
|
||||||
{err && <div className="error">{err}</div>}
|
{err && <div className="error">{err}</div>}
|
||||||
{!cat && !err && <Loading label="Reading the catalog…" />}
|
{!cat && !err && <Loading label="Reading the catalog…" />}
|
||||||
@@ -57,7 +57,7 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
|
|||||||
</label>
|
</label>
|
||||||
{bad && <div className="error">{bad}</div>}
|
{bad && <div className="error">{bad}</div>}
|
||||||
<div className="svc-actions">
|
<div className="svc-actions">
|
||||||
<Btn className="btn on" disabled={!sensor} onClick={add}>Add</Btn>
|
<Btn className="btn on" disabled={!sensor} onClick={add}>{initial ? 'Save' : 'Add'}</Btn>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<button className="btn" onClick={onClose}>Cancel</button>
|
<button className="btn" onClick={onClose}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export const api = {
|
|||||||
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
|
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
|
||||||
tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true),
|
tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true),
|
||||||
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
||||||
|
logoutAll: () => req<{ ended: number }>('POST', '/api/logout/all', undefined, true),
|
||||||
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
||||||
uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true),
|
uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true),
|
||||||
saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req<void>('PUT', '/api/prefs/ui', p, true),
|
saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req<void>('PUT', '/api/prefs/ui', p, true),
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// The part of the URL after the page — #/grid/services/<this> — is that page's selection:
|
||||||
|
// which unit, which job, which folder. Reload keeps it and a link can carry it.
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const read = () => { try { return decodeURIComponent(location.hash.split('/').slice(3).join('/')) } catch { return '' } }
|
||||||
|
|
||||||
|
export function useHashSel(): [string, (v: string) => void] {
|
||||||
|
const [sel, setSel] = useState(read)
|
||||||
|
useEffect(() => {
|
||||||
|
const h = () => setSel(read())
|
||||||
|
window.addEventListener('popstate', h); window.addEventListener('hashchange', h)
|
||||||
|
return () => { window.removeEventListener('popstate', h); window.removeEventListener('hashchange', h) }
|
||||||
|
}, [])
|
||||||
|
const set = useCallback((v: string) => {
|
||||||
|
const base = location.hash.split('/').slice(0, 3).join('/')
|
||||||
|
const want = v ? `${base}/${encodeURIComponent(v)}` : base
|
||||||
|
if (location.hash !== want) history.replaceState(null, '', want)
|
||||||
|
setSel(v)
|
||||||
|
}, [])
|
||||||
|
return [sel, set]
|
||||||
|
}
|
||||||
+8
-1
@@ -67,7 +67,14 @@ export function useSensorStream<T = unknown>(shape: Shape | null, opts: StreamOp
|
|||||||
if (everyMs) params.every = String(everyMs)
|
if (everyMs) params.every = String(everyMs)
|
||||||
const es = new EventSource(url(srcRef.current, '/api/sensors/stream', params))
|
const es = new EventSource(url(srcRef.current, '/api/sensors/stream', params))
|
||||||
es.onmessage = (e) => { try { setData(JSON.parse(e.data)); setError('') } catch { /* skip a bad frame */ } }
|
es.onmessage = (e) => { try { setData(JSON.parse(e.data)); setError('') } catch { /* skip a bad frame */ } }
|
||||||
es.onerror = () => setError('reconnecting…') // EventSource retries by itself
|
es.onerror = () => {
|
||||||
|
setError('reconnecting…') // EventSource retries by itself, but never says why it failed — so ask once, plainly
|
||||||
|
fetch(url(srcRef.current, '/api/sensors/query', { q: key }), { credentials: 'same-origin' }).then(async (r) => {
|
||||||
|
if (r.ok) return
|
||||||
|
const why = (await r.json().catch(() => ({}))).error
|
||||||
|
setError(r.status === 401 ? 'the token was rejected' : r.status === 429 ? 'too many live streams are open' : r.status === 404 ? 'no such machine' : r.status === 502 ? 'that machine is unreachable' : why || `the API answered ${r.status}`)
|
||||||
|
}).catch(() => setError("can't reach Ham Lab"))
|
||||||
|
}
|
||||||
return () => es.close()
|
return () => es.close()
|
||||||
}, [key, srcKey, visible]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [key, srcKey, visible]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
return { data, error, resumed }
|
return { data, error, resumed }
|
||||||
|
|||||||
@@ -430,3 +430,11 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.gallery-id { margin-top: auto; padding-top: 8px; font-size: 10.5px; color: var(--muted); overflow-wrap: anywhere; }
|
.gallery-id { margin-top: auto; padding-top: 8px; font-size: 10.5px; color: var(--muted); overflow-wrap: anywhere; }
|
||||||
@media (max-width: 800px) { .gallery .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
@media (max-width: 800px) { .gallery .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||||
|
|
||||||
|
/* in-app dialogs, gallery search */
|
||||||
|
.modal.ask { width: min(440px, 100%); } .ask-msg { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; }
|
||||||
|
.gallery-find { flex: 1 1 200px; max-width: 340px; padding: 7px 10px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; }
|
||||||
|
.gallery .dash-bar { flex-wrap: wrap; gap: 8px; }
|
||||||
|
.ttab.drop { box-shadow: inset 3px 0 0 var(--accent2); }
|
||||||
|
.ttab-move { display: none; } .ttab-move .icon-btn:disabled { opacity: .3; }
|
||||||
|
@media (pointer: coarse) { .ttab-move { display: inline-flex; } } /* no drag and drop on a touch screen */
|
||||||
|
|||||||
+49
-3
@@ -17,7 +17,7 @@ export function Btn({ onClick, busy, confirm, className = 'btn', disabled, title
|
|||||||
return (
|
return (
|
||||||
<button className={className + (b ? ' busy' : '')} disabled={disabled || b} title={title}
|
<button className={className + (b ? ' busy' : '')} disabled={disabled || b} title={title}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (confirm && !window.confirm(confirm)) return
|
if (confirm && !(await askYes(confirm))) return
|
||||||
const r = onClick()
|
const r = onClick()
|
||||||
if (r && typeof (r as Promise<unknown>).then === 'function') {
|
if (r && typeof (r as Promise<unknown>).then === 'function') {
|
||||||
setInner(true)
|
setInner(true)
|
||||||
@@ -80,10 +80,56 @@ export function useEscape(fn: () => void, active = true) {
|
|||||||
}, [active])
|
}, [active])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- dialogs: askYes / askText replace the browser's confirm() and prompt() ----
|
||||||
|
// Same promise shape as the natives (false / null on cancel), drawn like the rest of the
|
||||||
|
// console, usable on a phone, closed by Escape or a tap outside.
|
||||||
|
interface Ask { id: number; msg: string; input?: string; ok: string; danger: boolean; done: (v: string | boolean | null) => void }
|
||||||
|
let asks: Ask[] = []
|
||||||
|
let nextId = 0
|
||||||
|
const askListeners = new Set<(a: Ask[]) => void>()
|
||||||
|
const emitAsks = () => askListeners.forEach((l) => l(asks))
|
||||||
|
function ask(msg: string, input: string | undefined, ok?: string): Promise<string | boolean | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const id = ++nextId
|
||||||
|
const danger = /^(delete|remove|revoke|close|send sig|stop|disable|reset|discard|force)/i.test(msg) || /\bforce\b/i.test(msg)
|
||||||
|
asks = [...asks, { id, msg, input, ok: ok ?? (input !== undefined ? 'OK' : danger ? msg.split(/[ ?]/)[0] : 'OK'), danger, done: (v) => { asks = asks.filter((a) => a.id !== id); emitAsks(); resolve(v) } }]
|
||||||
|
emitAsks()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export const askYes = (msg: string, ok?: string) => ask(msg, undefined, ok) as Promise<boolean>
|
||||||
|
export const askText = (msg: string, initial = '', ok?: string) => ask(msg, initial, ok) as Promise<string | null>
|
||||||
|
|
||||||
|
function AskBox({ a }: { a: Ask }) {
|
||||||
|
const [val, setVal] = useState(a.input ?? '')
|
||||||
|
const cancel = () => a.done(a.input !== undefined ? null : false)
|
||||||
|
const accept = () => a.done(a.input !== undefined ? val : true)
|
||||||
|
useEscape(cancel)
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={cancel}>
|
||||||
|
<form className="modal ask" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); accept() }}>
|
||||||
|
<div className="ask-msg">{a.msg}</div>
|
||||||
|
{a.input !== undefined && <input autoFocus value={val} onChange={(e) => setVal(e.target.value)} onFocus={(e) => e.target.select()} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button type="submit" autoFocus={a.input === undefined} className={'btn ' + (a.danger ? 'danger' : 'on')}>{a.ok}</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="btn" onClick={cancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export function Dialogs() {
|
||||||
|
const [list, setList] = useState<Ask[]>(asks)
|
||||||
|
useEffect(() => { askListeners.add(setList); return () => { askListeners.delete(setList) } }, [])
|
||||||
|
const top = list[list.length - 1]
|
||||||
|
return top ? <AskBox key={top.id} a={top} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
// Unsaved work: whoever holds some says so, and whoever is about to navigate away asks.
|
// Unsaved work: whoever holds some says so, and whoever is about to navigate away asks.
|
||||||
let unsaved = ''
|
let unsaved = ''
|
||||||
export function setUnsaved(what: string) { unsaved = what }
|
export function setUnsaved(what: string) { unsaved = what }
|
||||||
export function okToLeave(): boolean {
|
export const hasUnsaved = () => unsaved !== ''
|
||||||
if (!unsaved || window.confirm(`Discard unsaved changes to ${unsaved}?`)) { unsaved = ''; return true }
|
export async function okToLeave(): Promise<boolean> {
|
||||||
|
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -15,6 +15,14 @@ export const WIDGET_TYPES: { id: WidgetType; label: string; many?: boolean }[] =
|
|||||||
export type WidgetData = Sensor | Record<string, Sensor> | null | undefined
|
export type WidgetData = Sensor | Record<string, Sensor> | null | undefined
|
||||||
const isSensor = (d: WidgetData): d is Sensor => !!d && typeof (d as Sensor).id === 'string'
|
const isSensor = (d: WidgetData): d is Sensor => !!d && typeof (d as Sensor).id === 'string'
|
||||||
|
|
||||||
|
// "cpu.core.*.usage" → "CPU core usage", "fan.*" → "Fans": what a pattern is called when nobody named it.
|
||||||
|
const FAMILY_TITLE: Record<string, string> = { cpu: 'CPU', mem: 'Memory', swap: 'Swap', temp: 'Temperatures', fan: 'Fans', net: 'Network', disk: 'Disks', fs: 'Filesystems', ups: 'UPS', zfs: 'ZFS', smart: 'Drive health', service: 'Services', services: 'Services', container: 'Containers', containers: 'Containers', updates: 'Updates', load: 'Load' }
|
||||||
|
export function patternTitle(sel: string): string {
|
||||||
|
const [first, ...rest] = sel.split('.')
|
||||||
|
const tail = rest.filter((x) => !/[*?[]/.test(x)).join(' ').replace(/_/g, ' ')
|
||||||
|
return [FAMILY_TITLE[first] ?? first, tail].filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_POINTS = 300
|
const MAX_POINTS = 300
|
||||||
|
|
||||||
function Gauge({ s }: { s: Sensor }) {
|
function Gauge({ s }: { s: Sensor }) {
|
||||||
@@ -49,7 +57,7 @@ function Spark({ s, src }: { s: Sensor; src?: Source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Widget({ spec, data, src }: { spec: WidgetSpec; data: WidgetData; src?: Source }) {
|
export function Widget({ spec, data, src }: { spec: WidgetSpec; data: WidgetData; src?: Source }) {
|
||||||
const title = spec.title || (isSensor(data) ? data.name : spec.sensor)
|
const title = spec.title || (isSensor(data) ? data.name : /[*?[]/.test(spec.sensor) ? patternTitle(spec.sensor) : spec.sensor)
|
||||||
let body
|
let body
|
||||||
if (data === undefined) body = <div className="value muted">…</div>
|
if (data === undefined) body = <div className="value muted">…</div>
|
||||||
else if (data === null) body = <div className="sub">no sensor <span className="mono">{spec.sensor}</span> on this machine</div>
|
else if (data === null) body = <div className="sub">no sensor <span className="mono">{spec.sensor}</span> on this machine</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user