diff --git a/BUGS.md b/BUGS.md index 08db196..20b4569 100644 --- a/BUGS.md +++ b/BUGS.md @@ -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 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 - reload, and can't be linked to. `#/grid/services/nginx.service` would fix both. -- **Browser Back with unsaved file edits** still leaves without asking (page and machine - changes ask; Back needs the history entry put back on Cancel). -- **Prompts are browser `prompt()`/`confirm()` boxes** — rename, new file, delete, token - name. They work, and look nothing like the rest of the console, worst on a phone. -- **Two step token creation** (name, then patterns) wants to be one small dialog, with the - patterns pickable from the catalog like the widget picker. -- **Widgets can't be edited**, only removed and re-added; no way to change a title or type. -- **The Widgets gallery has no search**, and 300+ tiles on `grid` is a long scroll. -- **A custom widget's sparkline starts empty for states and counts** (no history kept for - the background status sensors — only the sampler's ring remembers). -- **Embeds poll nothing while hidden but say nothing when the token is wrong** beyond - "check the token"; a 401 and an unreachable host look the same. -- **Default widget titles for patterns are the raw pattern** ("CPU.CORE.*.USAGE"). -- **Terminal tabs can't be reordered**, and there's no keyboard shortcut to switch. -- **The hub's own session cookie is 30 days sliding with no "log out everywhere".** +1. ✅ **Selections are in the URL.** `#/grid/services/ssh.service`, `#/hub/cron/job:…`, + `#/hub/files/%2Fetc`: reload keeps them, links carry them, leaving a page drops them. +2. ✅ **Browser Back with unsaved file edits asks**, like page and machine changes do. +3. ✅ **No more browser `prompt()`/`confirm()` boxes** — all 30 are in-app dialogs (`askYes`, + `askText` in `ui.tsx`): Enter submits, Escape or a tap outside cancels, destructive + ones are red and say what they do ("Close", "Delete"), and they fit a phone. +4. ✅ **Token creation is one dialog**: name, patterns, and the machine's own sensor families + as chips to click. +5. ✅ **Widgets can be edited** — sensor, type and title — in place, from Customize. +6. ✅ **The Widgets gallery has a search** over IDs, names and labels (so "gitea" finds its + container's sensors). +7. ✅ **Status sensors keep a history** (300 readings each: an hour of container memory, two + days of drive wear), so their sparklines have a past. +8. ✅ **A stream that fails says why**: "the token was rejected", "too many live streams are + open", "that machine is unreachable", "can't reach Ham Lab" — in embeds and on Monitors. +9. ✅ **Pattern widgets get readable default titles**: `cpu.core.*.usage` → "CPU core usage". +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). diff --git a/internal/api/server.go b/internal/api/server.go index bfa2298..b3455c4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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-update-all", s.auth(s.updateAllMachines)) 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/terminals", s.auth(s.listTerminals)) 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) } +// 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 // reverse proxy set it, else the socket peer. func clientKey(r *http.Request) string { diff --git a/internal/api/status_sensors.go b/internal/api/status_sensors.go index d48f946..f1f5ba3 100644 --- a/internal/api/status_sensors.go +++ b/internal/api/status_sensors.go @@ -25,8 +25,14 @@ type statusSensors struct { by map[string][]sensors.Sensor // source → its sensors 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 ( servicesEvery = 30 * time.Second containersEvery = 15 * time.Second @@ -36,7 +42,7 @@ const ( ) 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("containers", containersEvery, p.containers) go p.loop("updates", updatesEvery, p.updates) @@ -52,6 +58,28 @@ func (p *statusSensors) loop(name string, every time.Duration, read func(context cancel() p.mu.Lock() 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() if len(got) == 0 { // no pools, no Docker, no SMART: look again, but rarely 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 { p.mu.RLock() defer p.mu.RUnlock() diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 80a5bd1..8343180 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -146,6 +146,15 @@ func (s *Sessions) Valid(tok string) bool { 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) { _ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) }) } diff --git a/web/src/App.tsx b/web/src/App.tsx index 8397cb4..b967452 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { api, Me, Unauthenticated } from './api' import Login from './Login' import Shell from './Shell' +import { Dialogs } from './ui' import { applyTheme, currentThemeId } from './themes' import { getRefreshMs, setRefreshMs } from './prefs' @@ -33,5 +34,5 @@ export default function App() { if (me === undefined) return
…
if (me === null) return - return setMe(null)} /> + return <> setMe(null)} /> } diff --git a/web/src/Cron.tsx b/web/src/Cron.tsx index dfe711b..e78310a 100644 --- a/web/src/Cron.tsx +++ b/web/src/Cron.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api' 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 { 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' }) } function rel(ms: number): string { @@ -77,12 +78,12 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob; useEffect(() => () => clearTimeout(poll.current), []) 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('') 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() { - 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('') try { const r = await api.cronJobRun(job.id) @@ -141,8 +142,8 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () => const [busy, setBusy] = useState('') const [err, setErr] = useState('') async function act(a: string) { - if ((a === 'stop' || a === 'disable') && !window.confirm(`${a} ${t.unit}?`)) return - if (a === 'run' && !window.confirm(`Start ${t.activates} now?`)) return + if ((a === 'stop' || a === 'disable') && !await askYes(`${a} ${t.unit}?`)) return + if (a === 'run' && !await askYes(`Start ${t.activates} now?`)) return 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('') } } @@ -175,7 +176,9 @@ export default function Cron() { const [ov, setOv] = useState(null) const [err, setErr] = useState('') const [q, setQ] = useState('') - const [sel, setSel] = useState<{ kind: 'job' | 'timer'; id: string } | null>(null) + const [hashSel, setHashSel] = useHashSel() // "job:" or "timer:", 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(null) const load = useCallback(async () => { diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index ffefdec..964a76a 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -1,7 +1,7 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { api, DashPrefs, Sample, Series, Temp } from './api' import Sparkline from './Sparkline' -import { Loading, Spinner, toast } from './ui' +import { Loading, Spinner, askYes, toast } from './ui' import { fmtMs, useRefreshMs } from './prefs' import { fetchCatalog, fetchHistories, useSensorStream } from './sensors' 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. const [picking, setPicking] = useState(false) + const [editingWidget, setEditingWidget] = useState(null) const live = useSensorStream>(shapeOf(layout.widgets)) if (!latest) return
{err ?
{err}
: }
@@ -381,7 +382,7 @@ export default function Dashboard() { }) 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 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) => { if (!editing) return null @@ -421,6 +422,7 @@ export default function Dashboard() {
+
)} @@ -428,9 +430,10 @@ export default function Dashboard() { ))} {editing && } - {live.error && !live.data && layout.widgets.length > 0 &&
The widgets' stream isn't answering ({live.error}). If one of them has a bad pattern, remove it.
} + {live.error && !live.data && layout.widgets.length > 0 &&
The widgets' stream isn't answering: {live.error}.
} )} + {editingWidget && setEditingWidget(null)} onAdd={(w) => { update((l) => ({ ...l, widgets: l.widgets.map((x) => (x.id === w.id ? w : x)) })); setEditingWidget(null) }} />} {picking && setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />} {visible('card').length > 0 && (
diff --git a/web/src/Disks.tsx b/web/src/Disks.tsx index a3dbe67..00229b2 100644 --- a/web/src/Disks.tsx +++ b/web/src/Disks.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api' import { fmtBytes, fmtRate } from './Dashboard' import TaskOutput from './TaskOutput' -import { Btn, Loading, Spinner, toast } from './ui' +import { Btn, Loading, Spinner, askText, toast } from './ui' import { useRefreshMs } from './prefs' import { Sensor, useSensorStream } from './sensors' @@ -150,7 +150,7 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange } async function snapshot() { 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 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') } diff --git a/web/src/Embed.tsx b/web/src/Embed.tsx index 66ac294..b972ec3 100644 --- a/web/src/Embed.tsx +++ b/web/src/Embed.tsx @@ -24,7 +24,7 @@ export default function Embed() { {!sensor ?
embed needs ?sensor=
: (
- {live.error && !live.data &&
{src.token ? 'waiting for the stream — check the token' : 'no token given'}
} + {live.error && !live.data &&
{src.token ? live.error : 'no token in the URL (&token=…)'}
}
)}
diff --git a/web/src/Files.tsx b/web/src/Files.tsx index 0a45ad1..717872f 100644 --- a/web/src/Files.tsx +++ b/web/src/Files.tsx @@ -1,7 +1,8 @@ import { DragEvent, useCallback, useEffect, useRef, useState } from 'react' import { api, FileEntry, FileInfo } from './api' 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 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) }, [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. useEffect(() => { setUnsaved(dirty ? name : '') @@ -100,7 +101,10 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea // ---- page ---- export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: string) => void }) { - const [path, setPath] = useState(() => 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(() => (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(() => sessionStorage.getItem('hamlab.files.sudo') === '1') const [entries, setEntries] = useState(null) 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.) 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 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 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 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 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 del = (e: FileEntry) => { + 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 = 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 = 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 = 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 = 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 = async (e: FileEntry) => { const full = join(path, e.name) 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 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)}`); } // ---- upload ---- @@ -171,7 +175,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s const f = list[i] let overwrite = false 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 } setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length }) diff --git a/web/src/Gallery.tsx b/web/src/Gallery.tsx index 093f2df..203565d 100644 --- a/web/src/Gallery.tsx +++ b/web/src/Gallery.tsx @@ -35,6 +35,7 @@ export default function Gallery({ demoUrl }: { demoUrl?: string }) { const [cat, setCat] = useState(null) const [err, setErr] = useState('') const rate = useRefreshMs() + const [find, setFind] = useState('') 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. const live = useSensorStream<[Record]>(['*'], { 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 families = new Map() - 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 demo = byId.get('cpu.usage') 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 (
- {sensors.length} sensors on this machine · live · tap a tile to copy its ID + {needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID + setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" /> {demoUrl && External demo ↗}
-

Widget types

-
+ {!needle &&

Widget types

} + {!needle &&
{demo && WIDGET_TYPES.filter((t) => !t.many).map((t) => )} -
+
} + {needle && shown.length === 0 &&
No sensor matches “{find}”.
} {names.map((f) => (
diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx index 0924140..d269dd4 100644 --- a/web/src/Machines.tsx +++ b/web/src/Machines.tsx @@ -1,6 +1,6 @@ import { FormEvent, useCallback, useEffect, useState } from 'react' 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' 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) } } 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) } catch (e) { 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') } } else toast(`${m.name}: ${msg}`, 'err') } } async function updateAll() { 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 { let res = await api.updateAllMachines() 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') setTimeout(load, 4000) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } 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') } } diff --git a/web/src/Procs.tsx b/web/src/Procs.tsx index 888cd08..4b91d8c 100644 --- a/web/src/Procs.tsx +++ b/web/src/Procs.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { api, Proc, ProcTable } from './api' import { fmtBytes, fmtRate } from './Dashboard' -import { Btn, Loading, toast } from './ui' +import { Btn, Loading, askYes, toast } from './ui' import { useRefreshMs } from './prefs' 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 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') } } diff --git a/web/src/Services.tsx b/web/src/Services.tsx index 3b164ab..261d9c3 100644 --- a/web/src/Services.tsx +++ b/web/src/Services.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' 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 { useHashSel } from './hashsel' type Filter = 'all' | 'running' | 'failed' | 'enabled' @@ -118,7 +119,7 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo async function act(action: string) { const confirmFor: Record = { 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('') 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') } @@ -176,7 +177,8 @@ export default function Services() { const [err, setErr] = useState('') const [q, setQ] = useState('') const [filter, setFilter] = useState('all') - const [sel, setSel] = useState(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 load = useCallback(async () => { diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx index 40f8824..6efec96 100644 --- a/web/src/Settings.tsx +++ b/web/src/Settings.tsx @@ -1,7 +1,8 @@ import { FormEvent, useCallback, useEffect, useState } from 'react' +import { fetchCatalog } from './sensors' import { api, Me } from './api' 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' 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 [tokens, setTokens] = useState<{ name: string; created: string; scope?: string; sensors?: string[] }[] | null>(null) const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null) + const [making, setMaking] = useState(false) const [cur, setCur] = useState('') const [pw1, setPw1] = 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') } } 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') } } @@ -118,16 +120,18 @@ export default function Settings({ me }: { me: Me }) {
)}
- { - 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 + setMaking(true)}>+ Read-only sensor token
+

Sessions

+
+
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.
+ { try { await api.logoutAll() } finally { window.dispatchEvent(new Event('hamlab:unauthenticated')) } }}>Log out everywhere +
+ + {making && setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />} +

About

@@ -141,3 +145,33 @@ export default function Settings({ me }: { me: Me }) {
) } + +// 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([]) + 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 ( +
+
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); make() }}> +

Read-only sensor token

+ + +
{fams.map((f) => )}
+ {err &&
{err}
} +
+
+
+ ) +} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 3561d60..0fa2efb 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -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 Rail, { Group } from './Rail' import TerminalPane, { TermHandle } from './TerminalPane' @@ -18,7 +18,7 @@ import MachineSwitcher from './MachineSwitcher' import { MachineStatus, setMachine } from './api' import { LIST_FLOOR, useRefreshMs } from './prefs' 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). const FolderIcon = ( @@ -48,7 +48,7 @@ const HUB_ONLY: View[] = ['machines', 'settings'] // URL hash is the source of truth for where you are: #//. Back and // forward work, reload restores the tab, and links can point at a page on a machine. 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 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]) // Switching machines: reset everything that belongs to a host, then re-fetch its identity. - const switchMachine = (name: string | null) => { - if (name !== machine && !okToLeave()) return + const switchMachine = async (name: string | null) => { + if (name !== machine && !(await okToLeave())) return 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) 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(() => { 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]) 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) return () => window.removeEventListener('popstate', h) }) // 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) } } 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) { 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) { - 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() } 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) } + // 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([]) + 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 termView = view === 'terminal' @@ -175,7 +211,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{ 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) }} />
setDrawer(false)} />
@@ -189,8 +225,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'updates' && } {view === 'disks' && } {view === 'files' && } - {termView && } + {termView && window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />} {termView && terms.length === 0 && (
No terminals.
diff --git a/web/src/TermTabs.tsx b/web/src/TermTabs.tsx index 08c28da..189f411 100644 --- a/web/src/TermTabs.tsx +++ b/web/src/TermTabs.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { Term } from './api' import { Spinner } from './ui' @@ -6,29 +6,42 @@ interface Props { terms: Term[]; active: string | null; creating?: boolean onSelect: (id: string) => void; onNew: () => 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. -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(null) + const [over, setOver] = useState(null) const strip = useRef(null) // keep the active tab in sight when the strip overflows useEffect(() => { strip.current?.querySelector('.ttab.active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [active, terms.length]) return ( -
e.preventDefault()}> - {terms.map((t) => { + // Buttons must not take focus from the terminal; the tabs themselves must stay draggable, which a + // prevented mousedown would stop — so a tab click hands focus back afterwards instead. +
{ if ((e.target as HTMLElement).closest('button')) e.preventDefault() }}> + {terms.map((t, i) => { const sel = t.id === active return ( -
onSelect(t.id)} onDoubleClick={() => onRename(t)} +
{ 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) } }}> {t.name} + {sel && terms.length > 1 && + + + } {sel && }
) })} - +
) } diff --git a/web/src/WidgetPicker.tsx b/web/src/WidgetPicker.tsx index cceccc1..05dee26 100644 --- a/web/src/WidgetPicker.tsx +++ b/web/src/WidgetPicker.tsx @@ -5,13 +5,13 @@ import { WIDGET_TYPES, WidgetSpec, WidgetType } from './widgets' import { Btn, Loading, useEscape } from './ui' // 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(null) const [err, setErr] = useState('') const [filter, setFilter] = useState('') - const [sensor, setSensor] = useState('') - const [type, setType] = useState('stat') - const [title, setTitle] = useState('') + const [sensor, setSensor] = useState(initial?.sensor ?? '') + const [type, setType] = useState(initial?.type ?? 'stat') + const [title, setTitle] = useState(initial?.title ?? '') const [bad, setBad] = useState('') // One bad pattern would 400 the stream every widget on the page shares, so ask the API first. 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 }) }) if (!r.ok) { setBad((await r.json().catch(() => ({}))).error ?? `the API refused it (${r.status})`); 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')) }, []) const shown = useMemo(() => { @@ -32,7 +32,7 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec return (
e.stopPropagation()}> -

Add a widget

+

{initial ? 'Edit widget' : 'Add a widget'}

setFilter(e.target.value)} placeholder="filter sensors — cpu, temp, fan, enp3s0…" /> {err &&
{err}
} {!cat && !err && } @@ -57,7 +57,7 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec {bad &&
{bad}
}
- Add + {initial ? 'Save' : 'Add'}
diff --git a/web/src/api.ts b/web/src/api.ts index 10d6144..0ca8211 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -182,6 +182,7 @@ export const api = { changePassword: (current: string, password: string) => req('POST', '/api/password', { current, password }, 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), + logoutAll: () => req<{ ended: number }>('POST', '/api/logout/all', undefined, true), revokeToken: (name: string) => req('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true), uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true), saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req('PUT', '/api/prefs/ui', p, true), diff --git a/web/src/hashsel.ts b/web/src/hashsel.ts new file mode 100644 index 0000000..da1bd07 --- /dev/null +++ b/web/src/hashsel.ts @@ -0,0 +1,21 @@ +// The part of the URL after the page — #/grid/services/ — 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] +} diff --git a/web/src/sensors.ts b/web/src/sensors.ts index 01981b9..586a24b 100644 --- a/web/src/sensors.ts +++ b/web/src/sensors.ts @@ -67,7 +67,14 @@ export function useSensorStream(shape: Shape | null, opts: StreamOp if (everyMs) params.every = String(everyMs) 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.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() }, [key, srcKey, visible]) // eslint-disable-line react-hooks/exhaustive-deps return { data, error, resumed } diff --git a/web/src/styles.css b/web/src/styles.css index 42a1369..2f82c22 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; } @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; } + +/* 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 */ diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 9f4719c..2fd2aa2 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -17,7 +17,7 @@ export function Btn({ onClick, busy, confirm, className = 'btn', disabled, title return ( + + +
+ +
+ ) +} +export function Dialogs() { + const [list, setList] = useState(asks) + useEffect(() => { askListeners.add(setList); return () => { askListeners.delete(setList) } }, []) + const top = list[list.length - 1] + return top ? : null +} + // Unsaved work: whoever holds some says so, and whoever is about to navigate away asks. let unsaved = '' export function setUnsaved(what: string) { unsaved = what } -export function okToLeave(): boolean { - if (!unsaved || window.confirm(`Discard unsaved changes to ${unsaved}?`)) { unsaved = ''; return true } +export const hasUnsaved = () => unsaved !== '' +export async function okToLeave(): Promise { + if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true } return false } diff --git a/web/src/widgets.tsx b/web/src/widgets.tsx index 3b6ce20..8466d83 100644 --- a/web/src/widgets.tsx +++ b/web/src/widgets.tsx @@ -15,6 +15,14 @@ export const WIDGET_TYPES: { id: WidgetType; label: string; many?: boolean }[] = export type WidgetData = Sensor | Record | null | undefined 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 = { 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 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 }) { - 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 if (data === undefined) body =
…
else if (data === null) body =
no sensor {spec.sensor} on this machine