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
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".**
+73 -3
View File
@@ -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=<ms> 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
+5 -2
View File
@@ -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},
+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 {
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
}
+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 {
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 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 {
+6
View File
@@ -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)
+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[] }
// 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 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() {
</div>
))}
{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>
)}
{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 { 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<unknown>) => {
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) => {
+2
View File
@@ -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 (
+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>
<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>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>}
+2 -1
View File
@@ -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
+5 -2
View File
@@ -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:
<div className="body">
<Rail groups={GROUPS} view={view}
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)} />
<main className="main" key={machine ?? 'hub'}>
+15 -3
View File
@@ -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<WidgetType>('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 (
<div className="modal-back" onClick={onClose}>
<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)
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="the sensor's own name" />
</label>
{bad && <div className="error">{bad}</div>}
<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" />
<button className="btn" onClick={onClose}>Cancel</button>
</div>
+3
View File
@@ -38,6 +38,9 @@ async function req<T>(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)
+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 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; }
+20 -1
View File
@@ -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 <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
}