From 8ee2a2d293481a11aac0e2e4897188792f60607b Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sat, 19 Sep 2026 10:18:23 -0700 Subject: [PATCH] Bug bash 2026-09-19: 17 bugs fixed and verified (BUGS.md) Widgets: a bad pattern no longer silences every widget; malformed layouts load. Shell: lost session returns to login at once; tab titles; Escape closes menus and dialogs. Files: names are one path segment; unsaved edits ask before page/machine/tab changes; only folders that opened are remembered. Terminals: unique default names; 64-char names. API: queries capped at 10 000 values; 32 streams per client, 512 in all; only /embed/widget is frameable. ZFS sensors: one unprivileged zpool list, no sudo journal spam. Phone: 36 px inputs; dataset rows as wide as their cells. Machines: valid name pattern. Co-Authored-By: Claude Fable 5.1 --- BUGS.md | 91 ++++++++++++++++++++++++++++++++ internal/api/server.go | 76 ++++++++++++++++++++++++-- internal/api/status_sensors.go | 7 ++- internal/api/streamgate_test.go | 29 ++++++++++ internal/sensors/sensors.go | 18 +++++-- internal/sensors/sensors_test.go | 18 +++++++ internal/setup/setup.go | 7 +++ internal/storage/storage.go | 30 +++++++++++ web/src/App.tsx | 6 +++ web/src/Dashboard.tsx | 8 ++- web/src/Files.tsx | 24 ++++++--- web/src/MachineSwitcher.tsx | 2 + web/src/Machines.tsx | 2 +- web/src/Power.tsx | 3 +- web/src/Shell.tsx | 7 ++- web/src/WidgetPicker.tsx | 18 +++++-- web/src/api.ts | 3 ++ web/src/styles.css | 4 +- web/src/ui.tsx | 21 +++++++- 19 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 internal/api/streamgate_test.go diff --git a/BUGS.md b/BUGS.md index e2ed9b1..08db196 100644 --- a/BUGS.md +++ b/BUGS.md @@ -111,3 +111,94 @@ rejected. Duplicate machine add refused. Unicode + spaces in file names round-tr Upload byte-exact. Terminal scrollback survives switching. Offline node: switcher and header show it, pages show the reason. Remote terminals, logs, tasks and files through the proxy all work on all three nodes. + + +# Bug bash — 2026-09-19 + +Found by driving a scratch instance (same binary, its own state, real fleet behind it) in +headless Chromium at 1440 px and iPhone size: a sweep of every page on every machine for +page errors, failed requests, overflow and small tap targets; then scripted use of every +feature — terminal tabs, Files in a throwaway folder, Processes (killing a process the +test spawned), Monitors customisation and widgets, Services, Cron, Updates, Disks, +Machines, Settings, embeds, machine switching, session loss — and a hostile pass over the +API. Host-changing actions (service control, cron edits, upgrades, power) were opened but +not executed. Every fix below was re-checked by the same script that found it. + +## Bugs — all ✅ fixed and verified + +1. ✅ **One widget with a bad pattern silenced every custom widget.** They share one stream; + `fan.[` made the whole query a 400, so all of them showed "…" forever with no reason + given. The picker now asks the API before adding and shows its answer; a bad pattern + already saved shows a notice instead of dots. +2. ✅ **A saved layout of the wrong shape could not be loaded** ("could not load layout", + and the page could no longer be customised). `normalize()` now accepts anything. +3. ✅ **A lost session left pages saying "unauthenticated"** for up to 30 s. A 401 from the hub + now returns to the login form at once, and logging in returns to the same page. (A 401 + relayed from a managed machine is that machine rejecting the hub, and doesn't.) +4. ✅ **The machine-name rule in "Add a machine" was an invalid regular expression** under the + browser's `v` mode, so it validated nothing and logged a console error on every render. +5. ✅ **Two terminals opened in the same minute got the same name** ("shell 09:45" twice). + Now "shell 09:45 (2)". +6. ✅ **Names had no length limit** — a 328-character terminal tab, a 5000-character token + name. 64 characters, refused with a reason; token names also refuse `/ \ < > "`. +7. ✅ **"+ file", "+ folder" and Rename accepted `../x` and `a/b`**, quietly acting outside the + folder on screen. A name is now one path segment; Move… is how things go elsewhere. +8. ✅ **Unsaved file edits were thrown away without a word** when you picked another page or + machine, or closed the tab. All three now ask; Cancel keeps you where you were. +9. ✅ **A mistyped path in Files was remembered**, so a reload landed on the error. Only a + folder that opened is remembered. +10. ✅ **Escape closed nothing**: the machine menu, the file menu, the Power dialog, the widget + picker. It closes all four. +11. ✅ **Reset on Monitors removed every custom widget with no confirmation.** It asks, and + says how many. +12. ✅ **A few bytes of query bought 27 MB of answer** (`{"k0":"*",…×400}` with `full=1`) — and a + read-only token is public on the demo page. A query may return 10 000 values (thirty + whole machines); past that it is a 400 that says so. +13. ✅ **No ceiling on live streams**: 300 from one token were accepted. 32 per client address, + 512 in all, then a 429 that says so; closed streams free their places. +14. ✅ **Anything under `/embed/` lost the anti-framing headers**, not just the widget page. + Only `/embed/widget` is frameable. +15. ✅ **The new ZFS sensors ran three `sudo` commands a minute on every machine** — including + `core`, which has the tools and no pool — and each wrote three lines to the journal: + most of `hamlab.service`'s log was that. Pools are now read with one unprivileged + `zpool list`; a source that finds nothing is asked again ten times less often; and + the installer's quiet list covers `zpool|zfs list|status`. +16. ✅ **Phone: the filter boxes on Processes and Files, the path box and the "/" crumb were + under 30 px.** 36 px like every other control. +17. ✅ **Disks → datasets on a phone: rows ended 50 px before their own cells** when scrolled + sideways (`min-width` was less than the columns add up to). + +Also: tab titles now say where you are ("Services · grid — Ham Lab"), so history and open +tabs can be told apart. + +## Checked and fine + +Terminal focus after every switch/close/return; scrollback after reload; exited shells; +30 tabs with the + still reachable; re-fit on resize; Back/Forward; unknown machine or page +in the URL; wrong password; XSS through terminal, token and file names (all rendered as +text); hidden files, filter, symlinks (broken ones marked), binary files, upload and +replace, folder delete needing the typed name, root toggle; process sort, filter and +Terminate (asks first); themes; password-change validation; duplicate or unreachable +machine; hub terminals not leaking into another machine's page; CORS only on sensor paths; +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 + +- **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".** diff --git a/internal/api/server.go b/internal/api/server.go index 0623b22..bfa2298 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -58,6 +58,7 @@ type Server struct { mgr *term.Manager metrics *metrics.Collector sensors *sensors.Registry + streams streamGate store *store.Store svc *services.Manager cron *cron.Manager @@ -205,7 +206,7 @@ func (s *Server) Handler() http.Handler { h := w.Header() // The console must never be framed. An embedded widget exists to be: it is a page // that can only read sensors, and only with a read-only token in its URL. - if !strings.HasPrefix(r.URL.Path, "/embed/") { + if r.URL.Path != "/embed/widget" { h.Set("X-Frame-Options", "DENY") h.Set("Content-Security-Policy", "frame-ancestors 'none'") } @@ -323,6 +324,10 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc { } } +// maxNameLen bounds the names people give things (terminals, tokens): they are shown in +// tabs and lists, and nothing good is 300 characters long. +const maxNameLen = 64 + const filterHeader = "X-Hamlab-Sensor-Filter" type filterKey struct{} @@ -390,6 +395,10 @@ func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) { return } name := strings.TrimSpace(body.Name) + if len(name) > maxNameLen || strings.ContainsAny(name, "/\\<>\"") { + jsonErr(w, 400, fmt.Sprintf("token names are at most %d characters, without / \\ < > or quotes", maxNameLen)) + return + } tok, hash, err := auth.NewToken() if err != nil { jsonErr(w, 500, "token error") @@ -842,6 +851,45 @@ func queryOf(w http.ResponseWriter, r *http.Request) (q json.RawMessage, full bo var reservedParam = map[string]bool{"full": true, "token": true, "q": true, "every": true, "machines": true} +// A stream holds a goroutine and a connection for as long as the client likes, and a +// read-only token may be public. So streams are counted: per client address and in all. +const ( + maxStreamsPerClient = 32 + maxStreams = 512 +) + +type streamGate struct { + mu sync.Mutex + by map[string]int + total int +} + +func (g *streamGate) enter(who string) bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.by == nil { + g.by = map[string]int{} + } + if g.total >= maxStreams || g.by[who] >= maxStreamsPerClient { + return false + } + g.by[who]++ + g.total++ + return true +} + +func (g *streamGate) leave(who string) { + g.mu.Lock() + defer g.mu.Unlock() + if g.by[who] <= 0 { + return + } + if g.by[who]--; g.by[who] == 0 { + delete(g.by, who) + } + g.total-- +} + // sensorStream is a shaped query that keeps answering: the same object, pushed as a // server-sent event after every sample. ?every= spaces the events out for a consumer // that doesn't want them at the sampling rate. @@ -854,6 +902,12 @@ func (s *Server) sensorStream(w http.ResponseWriter, r *http.Request) { jsonErr(w, 400, err.Error()) return } + who := clientKey(r) + if !s.streams.enter(who) { + jsonErr(w, 429, fmt.Sprintf("too many live streams (at most %d per client, %d in all); close one first", maxStreamsPerClient, maxStreams)) + return + } + defer s.streams.leave(who) tick, cancel, ok := s.sensors.Subscribe() fl, canFlush := w.(http.Flusher) if !ok || !canFlush { @@ -1843,8 +1897,20 @@ func (s *Server) createTerminal(w http.ResponseWriter, r *http.Request) { } _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body) name := strings.TrimSpace(body.Name) - if name == "" { - name = "shell " + time.Now().Format("15:04") + if len(name) > maxNameLen { + jsonErr(w, 400, fmt.Sprintf("name longer than %d characters", maxNameLen)) + return + } + if name == "" { // "shell 09:45", then "shell 09:45 (2)" for the second one that minute + base := "shell " + time.Now().Format("15:04") + taken := map[string]bool{} + for _, t := range s.mgr.List() { + taken[t.Name] = true + } + name = base + for n := 2; taken[name]; n++ { + name = fmt.Sprintf("%s (%d)", base, n) + } } sess, err := s.mgr.Create("", name) if err != nil { @@ -1863,6 +1929,10 @@ func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) { jsonErr(w, 400, "name required") return } + if len(strings.TrimSpace(body.Name)) > maxNameLen { + jsonErr(w, 400, fmt.Sprintf("name longer than %d characters", maxNameLen)) + return + } if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil { jsonErr(w, 404, err.Error()) return diff --git a/internal/api/status_sensors.go b/internal/api/status_sensors.go index b34b113..d48f946 100644 --- a/internal/api/status_sensors.go +++ b/internal/api/status_sensors.go @@ -53,6 +53,10 @@ func (p *statusSensors) loop(name string, every time.Duration, read func(context p.mu.Lock() p.by[name] = got p.mu.Unlock() + if len(got) == 0 { // no pools, no Docker, no SMART: look again, but rarely + time.Sleep(10 * every) + continue + } time.Sleep(every) } } @@ -207,9 +211,8 @@ func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor { } func (p *statusSensors) zfs(ctx context.Context, t int64) []sensors.Sensor { - z := p.s.storage.ZFS(ctx) var out []sensors.Sensor - for _, pool := range z.Pools { + for _, pool := range p.s.storage.Pools(ctx) { id, l := "zfs."+sensors.Seg(pool.Name), map[string]string{"pool": pool.Name} out = append(out, sensors.Sensor{ID: id + ".health", Name: pool.Name + " health", Kind: "state", Value: pool.Health, T: t, Labels: l}, diff --git a/internal/api/streamgate_test.go b/internal/api/streamgate_test.go new file mode 100644 index 0000000..322e5f5 --- /dev/null +++ b/internal/api/streamgate_test.go @@ -0,0 +1,29 @@ +package api + +import "testing" + +func TestStreamGate(t *testing.T) { + var g streamGate + for i := 0; i < maxStreamsPerClient; i++ { + if !g.enter("a") { + t.Fatalf("stream %d for one client refused early", i) + } + } + if g.enter("a") { + t.Fatal("one client went past its ceiling") + } + if !g.enter("b") { + t.Fatal("another client must still get in") + } + g.leave("a") + if !g.enter("a") { + t.Fatal("a closed stream must free its place") + } + for i := 0; i < maxStreamsPerClient+1; i++ { + g.leave("a") + } + g.leave("b") + if g.total != -1 && g.total != 0 { // leave() past zero must not wedge the gate open or shut + t.Logf("total after over-leave: %d", g.total) + } +} diff --git a/internal/sensors/sensors.go b/internal/sensors/sensors.go index 97b5db3..329900c 100644 --- a/internal/sensors/sensors.go +++ b/internal/sensors/sensors.go @@ -209,10 +209,16 @@ func Shape(all []Sensor, query json.RawMessage, full bool) (any, error) { if err := json.Unmarshal(query, &q); err != nil { return nil, errors.New("query is not valid JSON") } - return shape(all, q, full, 0) + budget := MaxValues + return shape(all, q, full, 0, &budget) } -func shape(all []Sensor, q any, full bool, depth int) (any, error) { +// MaxValues is how many sensor values one query may return. A query is a few bytes that +// can ask for everything many times over ({"a":"*","b":"*",…}); without a ceiling 10 KB of +// request buys 27 MB of response. Ten thousand is thirty times a whole machine. +const MaxValues = 10000 + +func shape(all []Sensor, q any, full bool, depth int, budget *int) (any, error) { if depth > 16 { return nil, errors.New("query nested too deep") } @@ -232,8 +238,12 @@ func shape(all []Sensor, q any, full bool, depth int) (any, error) { if len(got) == 0 { return nil, nil } + *budget-- return leaf(got[0]), nil } + if *budget -= len(got); *budget < 0 { + return nil, fmt.Errorf("query returns more than %d values; ask for less", MaxValues) + } m := make(map[string]any, len(got)) for _, s := range got { m[s.ID] = leaf(s) @@ -242,7 +252,7 @@ func shape(all []Sensor, q any, full bool, depth int) (any, error) { case map[string]any: m := make(map[string]any, len(v)) for k, sub := range v { - r, err := shape(all, sub, full, depth+1) + r, err := shape(all, sub, full, depth+1, budget) if err != nil { return nil, err } @@ -252,7 +262,7 @@ func shape(all []Sensor, q any, full bool, depth int) (any, error) { case []any: a := make([]any, len(v)) for i, sub := range v { - r, err := shape(all, sub, full, depth+1) + r, err := shape(all, sub, full, depth+1, budget) if err != nil { return nil, err } diff --git a/internal/sensors/sensors_test.go b/internal/sensors/sensors_test.go index 0edf028..9ae6fc3 100644 --- a/internal/sensors/sensors_test.go +++ b/internal/sensors/sensors_test.go @@ -75,3 +75,21 @@ func TestWriteProm(t *testing.T) { } } } + +func TestShapeBudget(t *testing.T) { + all := make([]Sensor, 300) + for i := range all { + all[i] = Sensor{ID: "s." + strings.Repeat("x", i%7) + string(rune('a'+i%26)) + strings.Repeat("y", i/26), Value: 1.0} + } + q := map[string]string{} + for i := 0; i < 40; i++ { // 40 × 300 = 12 000 values + q["k"+strings.Repeat("z", i)] = "*" + } + b, _ := json.Marshal(q) + if _, err := Shape(all, b, false); err == nil { + t.Fatal("a query returning more than MaxValues must be refused") + } + if _, err := Shape(all, json.RawMessage(`{"a":"*","b":"*"}`), false); err != nil { + t.Fatalf("an ordinary query must pass: %v", err) + } +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go index e073cc3..8e73451 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -546,6 +546,13 @@ func quietSudoers() { } } } + for _, bin := range []string{"zpool", "zfs"} { // listing only: scrubs and snapshots still log + for _, dir := range []string{"/usr/sbin", "/sbin", "/usr/bin", "/bin"} { + if exists(dir + "/" + bin) { + cmds = append(cmds, dir+"/"+bin+" list *", dir+"/"+bin+" status *") + } + } + } if len(cmds) == 0 { return } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 6fdf169..177c884 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -754,6 +754,36 @@ var snapRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]*$`) func u64(s string) uint64 { v, _ := strconv.ParseUint(s, 10, 64); return v } func atoi(s string) int { v, _ := strconv.Atoi(strings.TrimSuffix(s, "%")); return v } +// Pools is the pool list alone, for callers that poll: one `zpool list`, tried without +// root first (it needs none on Linux) so polling writes nothing to the auth log. +func (m *Manager) Pools(ctx context.Context) []Pool { + bin, ok := lookPath("zpool") + if !ok { + return nil + } + args := []string{"list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup"} + out, err := exec.CommandContext(ctx, bin, args...).Output() + if err != nil { + var code int + if out, code, err = m.run(ctx, append([]string{"zpool"}, args...)...); err != nil || code != 0 { + return nil + } + } + return parsePools(out) +} + +func parsePools(out []byte) []Pool { + pools := []Pool{} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + f := strings.Split(line, "\t") + if len(f) < 8 { + continue + } + pools = append(pools, Pool{Name: f[0], Size: u64(f[1]), Alloc: u64(f[2]), Free: u64(f[3]), Frag: atoi(f[4]), Cap: atoi(f[5]), Health: f[6], Dedup: f[7]}) + } + return pools +} + func (m *Manager) ZFS(ctx context.Context) ZFS { z := ZFS{Pools: []Pool{}, Datasets: []Dataset{}} if _, ok := lookPath("zpool"); !ok { diff --git a/web/src/App.tsx b/web/src/App.tsx index 57935fe..8397cb4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,6 +19,12 @@ export default function App() { }, []) useEffect(() => { refresh() }, [refresh]) + // Any page that gets a 401 from the hub sends us straight back to the login form. + useEffect(() => { + const h = () => setMe((m) => (m ? null : m)) + window.addEventListener('hamlab:unauthenticated', h) + return () => window.removeEventListener('hamlab:unauthenticated', h) + }, []) useEffect(() => { if (!me) return const t = setInterval(refresh, 30000) diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index a7797c1..ffefdec 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -34,7 +34,10 @@ const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms)) interface Layout { order: string[]; hidden: Set; hiddenItems: Set; intervalMs: number; widgets: WidgetSpec[] } // Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules. -function normalize(p: DashPrefs): Layout { +function normalize(raw: DashPrefs): Layout { + // Saved prefs are whatever some client last wrote; a wrong shape must not take the page down. + const list = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []) + const p = { order: list(raw?.order), hidden: list(raw?.hidden), hiddenItems: list(raw?.hiddenItems), widgets: list(raw?.widgets), intervalMs: typeof raw?.intervalMs === 'number' ? raw.intervalMs : undefined } const known = new Set(MODULES.map((m) => m.id)) const order = (p.order ?? []).filter((id) => known.has(id)) for (const m of MODULES) if (!order.includes(m.id)) order.push(m.id) @@ -378,7 +381,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 = () => update(() => normalize({})) + 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 tools = (id: string, kind: Kind) => { if (!editing) return null @@ -425,6 +428,7 @@ 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.
} )} {picking && setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />} diff --git a/web/src/Files.tsx b/web/src/Files.tsx index 7cecf43..0a45ad1 100644 --- a/web/src/Files.tsx +++ b/web/src/Files.tsx @@ -1,7 +1,7 @@ import { DragEvent, useCallback, useEffect, useRef, useState } from 'react' import { api, FileEntry, FileInfo } from './api' import { fmtBytes } from './Dashboard' -import { Btn, Loading, Spinner, toast } from './ui' +import { Btn, Loading, Spinner, setUnsaved, toast, useEscape } from './ui' 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 +64,15 @@ 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; onClose() } + const close = () => { if (dirty && !window.confirm('Discard unsaved changes?')) return; setUnsaved(''); onClose() } + // Unsaved edits are announced, so leaving the page, switching machine or closing the tab asks first. + useEffect(() => { + setUnsaved(dirty ? name : '') + if (!dirty) return + const h = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = '' } + window.addEventListener('beforeunload', h) + return () => { window.removeEventListener('beforeunload', h); setUnsaved('') } + }, [dirty, name]) const dl = api.fileDownloadURL(path, sudo) return ( @@ -112,14 +120,16 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s const l = await api.files(p, s) setEntries(l.entries); setErr('') canSudo.current = true + sessionStorage.setItem('hamlab.files.path', p) // only a folder that opened is worth coming back to } catch (e) { const msg = e instanceof Error ? e.message : 'failed' setErr(msg); setEntries([]) } }, [path, sudo]) - useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.path', path); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load]) + useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load]) useEffect(() => { const h = () => setMenu(null); window.addEventListener('click', h); return () => window.removeEventListener('click', h) }, []) + useEscape(() => setMenu(null), menu !== null) const go = (p: string) => { setOpen(null); setPath(p || '/') } const crumbs = path === '/' ? [] : path.slice(1).split('/') @@ -136,9 +146,11 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s const act = async (label: string, fn: () => Promise) => { try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') } } - const mkdir = () => { const n = window.prompt('New folder name'); if (n?.trim()) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) } - const touch = () => { const n = window.prompt('New file name'); if (n?.trim()) 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 (n?.trim() && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) } + // 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) => { diff --git a/web/src/MachineSwitcher.tsx b/web/src/MachineSwitcher.tsx index 8966317..4ad1d13 100644 --- a/web/src/MachineSwitcher.tsx +++ b/web/src/MachineSwitcher.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { MachineStatus } from './api' +import { useEscape } from './ui' // The host picker at the top of the rail. The hub is always first; managed machines follow // with their live status. Picking one re-points the whole console at it. @@ -13,6 +14,7 @@ export default function MachineSwitcher({ hub, current, machines, onChange, onMa window.addEventListener('click', h) return () => window.removeEventListener('click', h) }, []) + useEscape(() => setOpen(false), open) const cur = current ? machines.find((m) => m.name === current) : null const dotFor = (m?: MachineStatus | null) => (!m ? 'ok' : m.online ? 'ok' : 'bad') return ( diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx index da05018..0924140 100644 --- a/web/src/Machines.tsx +++ b/web/src/Machines.tsx @@ -104,7 +104,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange

Add a machine

- + {addErr &&
{addErr}
} diff --git a/web/src/Power.tsx b/web/src/Power.tsx index aa9fa52..d3ae9d0 100644 --- a/web/src/Power.tsx +++ b/web/src/Power.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { api } from './api' -import { Btn, Spinner } from './ui' +import { Btn, Spinner, useEscape } from './ui' // Header power control: a small button, a dialog that needs the hostname typed back. // `label` is what the user types (the machine name on a remote); `host` is what the node checks. @@ -8,6 +8,7 @@ export default function Power({ host, label, rebootNeeded }: { host: string; lab const [open, setOpen] = useState(false) const [typed, setTyped] = useState('') const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('') + useEscape(() => { if (!sent) setOpen(false) }, open) const [err, setErr] = useState('') const ok = typed.trim() === label diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index ad92648..3561d60 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -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, toast } from './ui' +import { Btn, Toasts, okToLeave, toast } from './ui' // Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour). const FolderIcon = ( @@ -91,6 +91,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: // Switching machines: reset everything that belongs to a host, then re-fetch its identity. const switchMachine = (name: string | null) => { + if (name !== machine && !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) @@ -98,6 +99,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: } // keep the hash in step, and follow it when the browser navigates + // The tab's title says where you are, so history entries and open tabs can be told apart. + 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) @@ -172,7 +175,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{ setView('machines'); setDrawer(false) }} />} - onView={(id) => { setView(id as View); setDrawer(false) }} /> + onView={(id) => { if (id !== view && !okToLeave()) return; setView(id as View); setDrawer(false) }} />
setDrawer(false)} />
diff --git a/web/src/WidgetPicker.tsx b/web/src/WidgetPicker.tsx index 3ab6c7d..cceccc1 100644 --- a/web/src/WidgetPicker.tsx +++ b/web/src/WidgetPicker.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react' import { Sensor, fetchCatalog, fmtValue } from './sensors' +import { apiPath } from './api' import { WIDGET_TYPES, WidgetSpec, WidgetType } from './widgets' -import { Btn, Loading } 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. export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec) => void; onClose: () => void }) { @@ -11,13 +12,23 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec const [sensor, setSensor] = useState('') const [type, setType] = useState('stat') const [title, setTitle] = useState('') + 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 () => { + try { + 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 }) + } useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, []) const shown = useMemo(() => { const f = filter.trim().toLowerCase() return (cat ?? []).filter((s) => !f || s.id.includes(f) || s.name.toLowerCase().includes(f)) }, [cat, filter]) + useEscape(onClose) const pattern = /[*?[]/.test(sensor) - const pick = (id: string) => { setSensor(id); if (/[*?[]/.test(id)) setType('list') } + const pick = (id: string) => { setSensor(id); setBad(''); if (/[*?[]/.test(id)) setType('list') } return (
e.stopPropagation()}> @@ -44,8 +55,9 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec + {bad &&
{bad}
}
- onAdd({ id: 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })}>Add + Add
diff --git a/web/src/api.ts b/web/src/api.ts index 2bd1a2c..10d6144 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -38,6 +38,9 @@ async function req(method: string, path: string, body?: unknown, local = fals track(-1) if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000 if (r.status === 401) { + // The hub itself said no: the session is gone. (A 401 relayed from a managed machine is that + // machine rejecting the hub, which is not ours to fix by logging in again.) + if (!mach && target !== '/api/login') window.dispatchEvent(new Event('hamlab:unauthenticated')) let msg = 'unauthenticated' try { msg = (await r.json()).error ?? msg } catch { /* ignore */ } throw new Unauthenticated(msg) diff --git a/web/src/styles.css b/web/src/styles.css index 37a6320..42a1369 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -245,7 +245,7 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); .attrs td { padding: 3px 6px; border-bottom: 1px solid var(--border); } .attrs td.num, .attrs th:not(:nth-child(2)) { text-align: right; } .attrs tr.warn td { color: var(--s2); } .datasets { padding: 0; overflow-x: auto; } -.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 640px; } +.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; } /* the columns and gaps add up to 692: at 640 the row ended before its own cells did */ .ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; } .ds-row .num { text-align: right; } .ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -387,6 +387,8 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); @media (pointer: coarse) { .btn, .chip, .icon-btn, .link, .th, .mod-tools button, .hide-item, .crumb, .files-tools select, .procs-tools select, .dash-bar select { min-height: 36px; padding-top: 6px; padding-bottom: 6px; } .icon-btn { min-width: 36px; } + .path-input, .filter, .procs-tools input, .files-tools input { min-height: 36px; } + .crumb { min-width: 32px; } :root { --tabs-h: 40px; } .ttab .icon-btn { min-width: 30px; } .ttab:not(.active) .icon-btn { display: none; } diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 30e3120..9f4719c 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useState } from 'react' +import { ReactNode, useEffect, useRef, useState } from 'react' // ---- spinners ---- export function Spinner({ size = 14 }: { size?: number }) { @@ -68,3 +68,22 @@ export function NetBar() { }, []) return
0 ? ' on' : '')} /> } + +// useEscape runs fn when Escape is pressed while active — every menu and dialog closes that way. +export function useEscape(fn: () => void, active = true) { + const ref = useRef(fn); ref.current = fn + useEffect(() => { + if (!active) return + const h = (e: KeyboardEvent) => { if (e.key === 'Escape') ref.current() } + window.addEventListener('keydown', h) + return () => window.removeEventListener('keydown', h) + }, [active]) +} + +// 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 } + return false +}