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 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 10:18:23 -07:00
co-authored by Claude Fable 5.1
parent 36de4ad4a1
commit 8ee2a2d293
19 changed files with 348 additions and 26 deletions
+91
View File
@@ -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 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 header show it, pages show the reason. Remote terminals, logs, tasks and files through
the proxy all work on all three nodes. 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".**
+73 -3
View File
@@ -58,6 +58,7 @@ type Server struct {
mgr *term.Manager mgr *term.Manager
metrics *metrics.Collector metrics *metrics.Collector
sensors *sensors.Registry sensors *sensors.Registry
streams streamGate
store *store.Store store *store.Store
svc *services.Manager svc *services.Manager
cron *cron.Manager cron *cron.Manager
@@ -205,7 +206,7 @@ func (s *Server) Handler() http.Handler {
h := w.Header() h := w.Header()
// The console must never be framed. An embedded widget exists to be: it is a page // 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. // 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("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", "frame-ancestors 'none'") 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" const filterHeader = "X-Hamlab-Sensor-Filter"
type filterKey struct{} type filterKey struct{}
@@ -390,6 +395,10 @@ func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) {
return return
} }
name := strings.TrimSpace(body.Name) 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() tok, hash, err := auth.NewToken()
if err != nil { if err != nil {
jsonErr(w, 500, "token error") 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} 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 // sensorStream is a shaped query that keeps answering: the same object, pushed as a
// server-sent event after every sample. ?every=<ms> spaces the events out for a consumer // server-sent event after every sample. ?every=<ms> spaces the events out for a consumer
// that doesn't want them at the sampling rate. // 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()) jsonErr(w, 400, err.Error())
return 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() tick, cancel, ok := s.sensors.Subscribe()
fl, canFlush := w.(http.Flusher) fl, canFlush := w.(http.Flusher)
if !ok || !canFlush { 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) _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
name := strings.TrimSpace(body.Name) name := strings.TrimSpace(body.Name)
if name == "" { if len(name) > maxNameLen {
name = "shell " + time.Now().Format("15:04") 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) sess, err := s.mgr.Create("", name)
if err != nil { if err != nil {
@@ -1863,6 +1929,10 @@ func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "name required") jsonErr(w, 400, "name required")
return 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 { if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil {
jsonErr(w, 404, err.Error()) jsonErr(w, 404, err.Error())
return return
+5 -2
View File
@@ -53,6 +53,10 @@ func (p *statusSensors) loop(name string, every time.Duration, read func(context
p.mu.Lock() p.mu.Lock()
p.by[name] = got p.by[name] = got
p.mu.Unlock() 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) 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 { func (p *statusSensors) zfs(ctx context.Context, t int64) []sensors.Sensor {
z := p.s.storage.ZFS(ctx)
var out []sensors.Sensor 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} id, l := "zfs."+sensors.Seg(pool.Name), map[string]string{"pool": pool.Name}
out = append(out, out = append(out,
sensors.Sensor{ID: id + ".health", Name: pool.Name + " health", Kind: "state", Value: pool.Health, T: t, Labels: l}, sensors.Sensor{ID: id + ".health", Name: pool.Name + " health", Kind: "state", Value: pool.Health, T: t, Labels: l},
+29
View File
@@ -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)
}
}
+14 -4
View File
@@ -209,10 +209,16 @@ func Shape(all []Sensor, query json.RawMessage, full bool) (any, error) {
if err := json.Unmarshal(query, &q); err != nil { if err := json.Unmarshal(query, &q); err != nil {
return nil, errors.New("query is not valid JSON") 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 { if depth > 16 {
return nil, errors.New("query nested too deep") 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 { if len(got) == 0 {
return nil, nil return nil, nil
} }
*budget--
return leaf(got[0]), nil 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)) m := make(map[string]any, len(got))
for _, s := range got { for _, s := range got {
m[s.ID] = leaf(s) 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: case map[string]any:
m := make(map[string]any, len(v)) m := make(map[string]any, len(v))
for k, sub := range 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 { if err != nil {
return nil, err return nil, err
} }
@@ -252,7 +262,7 @@ func shape(all []Sensor, q any, full bool, depth int) (any, error) {
case []any: case []any:
a := make([]any, len(v)) a := make([]any, len(v))
for i, sub := range 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 { if err != nil {
return nil, err return nil, err
} }
+18
View File
@@ -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)
}
}
+7
View File
@@ -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 { if len(cmds) == 0 {
return return
} }
+30
View File
@@ -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 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 } 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 { func (m *Manager) ZFS(ctx context.Context) ZFS {
z := ZFS{Pools: []Pool{}, Datasets: []Dataset{}} z := ZFS{Pools: []Pool{}, Datasets: []Dataset{}}
if _, ok := lookPath("zpool"); !ok { if _, ok := lookPath("zpool"); !ok {
+6
View File
@@ -19,6 +19,12 @@ export default function App() {
}, []) }, [])
useEffect(() => { refresh() }, [refresh]) 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(() => { useEffect(() => {
if (!me) return if (!me) return
const t = setInterval(refresh, 30000) const t = setInterval(refresh, 30000)
+6 -2
View File
@@ -34,7 +34,10 @@ const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms))
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number; widgets: WidgetSpec[] } interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number; widgets: WidgetSpec[] }
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules. // 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 = <T,>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
const p = { order: list<string>(raw?.order), hidden: list<string>(raw?.hidden), hiddenItems: list<string>(raw?.hiddenItems), widgets: list<WidgetSpec>(raw?.widgets), intervalMs: typeof raw?.intervalMs === 'number' ? raw.intervalMs : undefined }
const known = new Set(MODULES.map((m) => m.id)) const known = new Set(MODULES.map((m) => m.id))
const order = (p.order ?? []).filter((id) => known.has(id)) const order = (p.order ?? []).filter((id) => known.has(id))
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.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 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 = () => 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) => { const tools = (id: string, kind: Kind) => {
if (!editing) return null if (!editing) return null
@@ -425,6 +428,7 @@ 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>}
</div> </div>
)} )}
{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) }} />}
+18 -6
View File
@@ -1,7 +1,7 @@
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, toast } from './ui' import { Btn, Loading, Spinner, setUnsaved, toast, useEscape } from './ui'
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 +64,15 @@ 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; 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) const dl = api.fileDownloadURL(path, sudo)
return ( return (
@@ -112,14 +120,16 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
const l = await api.files(p, s) const l = await api.files(p, s)
setEntries(l.entries); setErr('') setEntries(l.entries); setErr('')
canSudo.current = true canSudo.current = true
sessionStorage.setItem('hamlab.files.path', p) // only a folder that opened is worth coming back to
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : 'failed' const msg = e instanceof Error ? e.message : 'failed'
setErr(msg); setEntries([]) setErr(msg); setEntries([])
} }
}, [path, sudo]) }, [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) }, []) 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 go = (p: string) => { setOpen(null); setPath(p || '/') }
const crumbs = path === '/' ? [] : path.slice(1).split('/') 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<unknown>) => { const act = async (label: string, fn: () => Promise<unknown>) => {
try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') } 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)) } // A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
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 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 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)) } 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 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 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 del = (e: FileEntry) => {
+2
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { MachineStatus } from './api' 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 // 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. // 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) window.addEventListener('click', h)
return () => window.removeEventListener('click', h) return () => window.removeEventListener('click', h)
}, []) }, [])
useEscape(() => setOpen(false), open)
const cur = current ? machines.find((m) => m.name === current) : null const cur = current ? machines.find((m) => m.name === current) : null
const dotFor = (m?: MachineStatus | null) => (!m ? 'ok' : m.online ? 'ok' : 'bad') const dotFor = (m?: MachineStatus | null) => (!m ? 'ok' : m.online ? 'ok' : 'bad')
return ( return (
+1 -1
View File
@@ -104,7 +104,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
<div className="dash-bar"><h2 className="section">Add a machine</h2></div> <div className="dash-bar"><h2 className="section">Add a machine</h2></div>
<form className="card addform" onSubmit={add}> <form className="card addform" onSubmit={add}>
<label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9\\-]*" required /></label> <label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9_\-]*" title="lower-case letters, digits, - and _" required /></label>
<label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label> <label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label>
<label>Its Ham Lab password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="off" required /></label> <label>Its Ham Lab password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="off" required /></label>
{addErr && <div className="error">{addErr}</div>} {addErr && <div className="error">{addErr}</div>}
+2 -1
View File
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { api } from './api' 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. // 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. // `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 [open, setOpen] = useState(false)
const [typed, setTyped] = useState('') const [typed, setTyped] = useState('')
const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('') const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
useEscape(() => { if (!sent) setOpen(false) }, open)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const ok = typed.trim() === label const ok = typed.trim() === label
+5 -2
View File
@@ -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, 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). // Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
const FolderIcon = ( 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. // Switching machines: reset everything that belongs to a host, then re-fetch its identity.
const switchMachine = (name: string | null) => { const switchMachine = (name: string | null) => {
if (name !== machine && !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)
@@ -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 // 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(() => { useEffect(() => {
const want = `#/${machine ?? 'hub'}/${view}` const want = `#/${machine ?? 'hub'}/${view}`
if (location.hash !== want) history.pushState(null, '', want) if (location.hash !== want) history.pushState(null, '', want)
@@ -172,7 +175,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) => { setView(id as View); setDrawer(false) }} /> onView={(id) => { if (id !== view && !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'}>
+15 -3
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Sensor, fetchCatalog, fmtValue } from './sensors' import { Sensor, fetchCatalog, fmtValue } from './sensors'
import { apiPath } from './api'
import { WIDGET_TYPES, WidgetSpec, WidgetType } from './widgets' 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. // 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 }: { onAdd: (w: WidgetSpec) => void; onClose: () => void }) {
@@ -11,13 +12,23 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
const [sensor, setSensor] = useState('') const [sensor, setSensor] = useState('')
const [type, setType] = useState<WidgetType>('stat') const [type, setType] = useState<WidgetType>('stat')
const [title, setTitle] = useState('') 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')) }, []) useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
const shown = useMemo(() => { const shown = useMemo(() => {
const f = filter.trim().toLowerCase() const f = filter.trim().toLowerCase()
return (cat ?? []).filter((s) => !f || s.id.includes(f) || s.name.toLowerCase().includes(f)) return (cat ?? []).filter((s) => !f || s.id.includes(f) || s.name.toLowerCase().includes(f))
}, [cat, filter]) }, [cat, filter])
useEscape(onClose)
const pattern = /[*?[]/.test(sensor) 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 ( 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()}>
@@ -44,8 +55,9 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
<label className="muted">Title (optional) <label className="muted">Title (optional)
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="the sensor's own name" /> <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="the sensor's own name" />
</label> </label>
{bad && <div className="error">{bad}</div>}
<div className="svc-actions"> <div className="svc-actions">
<Btn className="btn on" disabled={!sensor} onClick={() => onAdd({ id: 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })}>Add</Btn> <Btn className="btn on" disabled={!sensor} onClick={add}>Add</Btn>
<span className="spacer" /> <span className="spacer" />
<button className="btn" onClick={onClose}>Cancel</button> <button className="btn" onClick={onClose}>Cancel</button>
</div> </div>
+3
View File
@@ -38,6 +38,9 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
track(-1) track(-1)
if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000 if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000
if (r.status === 401) { 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' let msg = 'unauthenticated'
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ } try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
throw new Unauthenticated(msg) throw new Unauthenticated(msg)
+3 -1
View File
@@ -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 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); } .attrs tr.warn td { color: var(--s2); }
.datasets { padding: 0; overflow-x: auto; } .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.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
.ds-row .num { text-align: right; } .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; } .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) { @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; } .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; } .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; } :root { --tabs-h: 40px; }
.ttab .icon-btn { min-width: 30px; } .ttab .icon-btn { min-width: 30px; }
.ttab:not(.active) .icon-btn { display: none; } .ttab:not(.active) .icon-btn { display: none; }
+20 -1
View File
@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useState } from 'react' import { ReactNode, useEffect, useRef, useState } from 'react'
// ---- spinners ---- // ---- spinners ----
export function Spinner({ size = 14 }: { size?: number }) { export function Spinner({ size = 14 }: { size?: number }) {
@@ -68,3 +68,22 @@ export function NetBar() {
}, []) }, [])
return <div className={'netbar' + (n > 0 ? ' on' : '')} /> return <div className={'netbar' + (n > 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
}