From a446a03b7de7a3e92bd19088f9e5f36969efa071 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sun, 20 Sep 2026 12:30:15 -0700 Subject: [PATCH] Bug bash 3: solid HUD dialogs, remote stacks deployed from the hub's shell, stopped-on-purpose is not down, the New stack dialog keeps what you typed Co-Authored-By: Claude Fable 5.1 --- BUGS.md | 74 ++++++++++++++++++++++++++++++++++ internal/api/stacks.go | 8 +++- internal/api/status_sensors.go | 15 ++++--- web/src/Docker.tsx | 6 +-- web/src/StackNew.tsx | 26 +++++++++--- web/src/styles.css | 8 +++- web/src/ui.tsx | 14 +++++-- 7 files changed, 130 insertions(+), 21 deletions(-) diff --git a/BUGS.md b/BUGS.md index 12883f7..ac3fe9e 100644 --- a/BUGS.md +++ b/BUGS.md @@ -294,3 +294,77 @@ the rail as "1" until the unit is disabled or the rule is narrowed. Left alone. Not chosen, still open: alert acknowledge/snooze; quiet hours and per-rule channels; push straight from Ham Lab; a type hint in the alert editor; switching off the nodes' own alert engines. + +# Bug bash 3 — 2026-09-20: new stacks, new containers, images, volumes, the HUD + +Started from Mitchell's report — the New stack dialog is see-through in HUD, its text +mixing with the page behind. Same method as before: a scratch hub with the real fleet behind +it, headless Chromium at 1440 px and iPhone size. A sweep of all 13 pages on all four +machines, plus a measurement **in every skin** of everything that floats over the page +(dialogs, the question box, the widget picker, menus, the phone drawer: background alpha +must be ≥ 0.9). Then scripted use of what is new on throwaway `hl-` stacks, containers, +images and volumes: the New stack / New container dialog's edges, stacks that are not +deployed, a deploy that fails, a container name that is taken, deletes beside other +people's files, a two-file stack, create → deploy → restart → stop → delete **on the Mac +and on `grid` through the hub**, pulls on the hub and on a node, removal guards, a +container stopped on purpose, a read-only token and hostile input on every new endpoint, +and a regression of Escape everywhere, terminals, Files, sensors, alerts and deep links. + +## Bugs — all ✅ fixed and verified by the script that found them + +1. ✅ **HUD: every dialog was see-through.** The skin's "glass" rule (3 % cyan) was written + for tiles and cards and also caught `.modal` — the New stack dialog, the widget picker, + the Power dialog, the token dialog, the rule editor and every question. Anything that + floats over the page is now solid in HUD (hairline border and glow kept) over a darker + backdrop. Measured in all thirteen skins: nothing that floats is under 0.9 alpha. +2. ✅ **HUD: the phone's slide-out menu was 92 % opaque**, the page showing through it. Solid. +3. ✅ **Deploy, Update, Restart, Stop and Down all failed on the Mac's stack** + (`peaches-and-plums`) — and on any stack held on the hub that was deployed from the hub's + own shell rather than from inside Dockhand. The command was built as + `'DOCKER_HOST=tcp://…' docker compose …`; quoted, `sh` reads that not as a variable but + as the name of a program to run ("not found", exit 127). Now passed through `env`. + Verified by a whole life on the Mac and on `grid` from the hub: create, deploy (running + there, nothing on `core`), restart, stop, delete. +4. ✅ **A container stopped on purpose was reported as down** when it has no restart policy + and doesn't handle SIGTERM: `docker stop` then ends it with 137 (or 143, or 130), a + non-zero code, which read as a crash — "Container down" would have fired for a + deliberate stop. Those three codes now mean "a person", unless the kernel did the + killing for want of memory (`OOMKilled`). A real crash (exit 3) is still down. +5. ✅ **A stack with no containers offered Restart, Stop and Update**, and Restart + "succeeded" having done nothing. Offered only when there is something to act on; the + API answers 409 "isn't deployed: there is nothing to restart". +6. ✅ **One stray tap outside the New stack dialog threw away everything typed** — on a phone, + a compose file's worth. So did Escape. With anything typed, all three ways out (tap + outside, Escape, Cancel) ask first; an untouched dialog still just closes. +7. ✅ **Compose → Form → Compose silently replaced hand-edited compose text** with what the + form says. The form now writes the box only when the form has changed since it last + did, and asks before replacing text edited by hand. +8. ✅ **A question asked from inside a dialog opened underneath it** (same layer, earlier in + the page), so it could not be answered — found the moment the New stack dialog started + asking. Questions now sit above whatever asked them. +9. ✅ **Escape on a question also reached the dialog beneath it**: one key cancelled the + question *and* closed (or re-asked from) the dialog under it. Escape now answers the + question and goes no further; the next Escape is the dialog's. Checked on every dialog + and menu: widget picker, machine menu, rule editor, Power, New stack, and that Escape on + "Delete the rule?" deletes nothing. + +## Checked and fine + +All 13 pages × four machines × both sizes: no page errors, failed requests, sideways +scroll or tap targets under 30 px. A port of `not-a-port` is refused by compose before +anything is written, and leaves no folder. Environment values with quotes, `#`, `$` and +nothing after the `=` arrive in the container exactly as typed. A deploy whose image +doesn't exist shows the registry's error and is **not** remembered as "last deployed". A +container name already taken fails the deploy and says why. Delete moves only the stack's +own compose files and `.env` (kept `600` in the trash) and leaves a `data/` folder and +notes beside them alone; both files of a two-file stack go to the trash; the wrong name, +or none, deletes nothing. A `.env` value never appears on the stack page and stays masked +until asked for. A second stack of the same name is 409; an upper-case name is 400. A pull +of an image that doesn't exist ends as a failed task; a pull on `grid` from the hub lands +on `grid` and its output is followed. A volume or image in use can't be removed; a running +container can't be removed (409); a volume needs its name twice. A read-only token gets +401 from all fourteen new write paths, through the hub as well. `--force`, `-f`, +`--all-tags`, `..%2F..` and a home outside the known ones are refused; a 1.2 MB compose +file is refused. Shaped, fleet and streamed sensor queries, `/metrics`, the ten alert +rules, terminal typing, the file editor's discard question and service deep links are +unchanged. No native browser dialogs anywhere. diff --git a/internal/api/stacks.go b/internal/api/stacks.go index eac039d..783fac4 100644 --- a/internal/api/stacks.go +++ b/internal/api/stacks.go @@ -710,8 +710,8 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) { return fmt.Sprintf("%s exec %s%s docker compose %s %s", dockerTool(), env, priv.Quote([]string{st.Via}), flags, args) } env := "" - if st.Remote != "" { - env = priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " " + if st.Remote != "" { // through env(1): a quoted NAME=value in front of a command is a command name to sh, not an assignment + env = "env " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " " } return fmt.Sprintf("cd %s && %s%s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), env, dockerTool(), flags, args) } @@ -725,6 +725,10 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) { remember += " && " + priv.Quote(priv.Wrap("cp", "-p", p, p+".hamlab-deployed")) } var script string + if a := r.PathValue("action"); len(st.Containers) == 0 && (a == "restart" || a == "stop" || a == "down") { + jsonErr(w, 409, st.Name+" isn't deployed: there is nothing to "+a) + return + } switch r.PathValue("action") { case "deploy": script = base("up -d --remove-orphans") + remember diff --git a/internal/api/status_sensors.go b/internal/api/status_sensors.go index ae8dffc..0267695 100644 --- a/internal/api/status_sensors.go +++ b/internal/api/status_sensors.go @@ -176,13 +176,16 @@ func (p *statusSensors) containers(ctx context.Context, t int64) []sensors.Senso // them apart: Docker refused to start it (State.Error: yesterday's Gitea), it is stuck // restarting, or it died with a non-zero code that no restart policy picked up. Under // `always` / `unless-stopped` a crash is restarted by Docker, so "exited" there means a - // person stopped it. + // person stopped it. With no restart policy, `docker stop` leaves 143 (the process took the + // SIGTERM), 137 (it didn't, and Docker killed it after the grace period) or 130 (SIGINT): + // also a person, unless the kernel did the killing for want of memory. type stopped struct { Name string State struct { - Status string - ExitCode int - Error string + Status string + ExitCode int + Error string + OOMKilled bool } HostConfig struct{ RestartPolicy struct{ Name string } } } @@ -206,8 +209,10 @@ func (p *statusSensors) containers(ctx context.Context, t int64) []sensors.Senso for _, c := range list { pol := c.HostConfig.RestartPolicy.Name managed := pol == "always" || pol == "unless-stopped" + code := c.State.ExitCode + byHand := (code == 130 || code == 137 || code == 143) && !c.State.OOMKilled down[strings.TrimPrefix(c.Name, "/")] = c.State.Status == "restarting" || c.State.Status == "dead" || c.State.Error != "" || - (!managed && c.State.Status == "exited" && c.State.ExitCode != 0) + (!managed && c.State.Status == "exited" && code != 0 && !byHand) } } } diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index b519f53..5eaf63e 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -154,9 +154,9 @@ function StackDetail({ name, startTask, onBack, onChanged }: { name: string; sta {dirty && } Deploy - Update - Restart - Stop + {st.containers.length > 0 && Update} + {st.containers.length > 0 && Restart} + {st.running > 0 && Stop}
{st.containers.length === 0 ? 'Not deployed: no containers exist for this stack.' : ''} diff --git a/web/src/StackNew.tsx b/web/src/StackNew.tsx index 466c5fe..c540772 100644 --- a/web/src/StackNew.tsx +++ b/web/src/StackNew.tsx @@ -4,10 +4,11 @@ // page, where a bare `docker run` could only ever be thrown away and typed again. import { useEffect, useMemo, useState } from 'react' import { api, StackHome } from './api' -import { Btn, Loading, useEscape } from './ui' +import { askYes, Btn, Loading, useEscape } from './ui' interface Form { image: string; ports: string; volumes: string; env: string; restart: string; command: string; host: boolean } const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', restart: 'unless-stopped', command: '', host: false } +const START = 'services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n' const lines = (t: string) => t.split('\n').map((x) => x.trim()).filter(Boolean) const q = (v: string) => JSON.stringify(v) // a JSON string is a valid YAML string, with every awkward character already dealt with @@ -30,10 +31,14 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' | const [name, setName] = useState('') const [how, setHow] = useState<'form' | 'compose'>(mode === 'container' ? 'form' : 'compose') const [form, setForm] = useState
(EMPTY) - const [text, setText] = useState('services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n') + const [text, setText] = useState(START) const [env, setEnv] = useState('') const [err, setErr] = useState('') - useEscape(onClose) + const [seeded, setSeeded] = useState('') // what the form last wrote into the compose box + // a stray tap outside, or Escape, must not throw away a compose file someone has typed + const typed = !!name || !!env || text !== START || JSON.stringify(form) !== JSON.stringify(EMPTY) + const close = async () => { if (!typed || (await askYes('Discard this new ' + mode + '? What you have typed is lost.', 'Discard'))) onClose() } + useEscape(close) useEffect(() => { api.stackHomes().then((r) => { const hs = r.homes ?? []; setHomes(hs); setHome(hs.find((h) => h.default)?.dir ?? hs[0]?.dir ?? '') }).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, []) const okName = /^[a-z0-9][a-z0-9_-]{0,62}$/.test(name) const built = useMemo(() => composeOf(name, form), [name, form]) @@ -42,9 +47,18 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' | const create = (deploy: boolean) => async () => { try { await api.createStack({ name, text: compose, env, home }); onCreated(name, deploy) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } + // The form writes the compose box only when the form has something new to say; text edited by + // hand since is asked about, never silently replaced. + const toCompose = async () => { + if (how === 'form' && form.image.trim() && built !== seeded) { + const mine = text !== START && text !== seeded + if (!mine || (await askYes('Replace the compose text you edited with what the form says now?', 'Replace'))) { setText(built); setSeeded(built) } + } + setHow('compose') + } const set = (k: keyof Form) => (e: { target: { value: string } }) => setForm({ ...form, [k]: e.target.value }) return ( -
+
e.stopPropagation()}>

{mode === 'container' ? 'New container' : 'New stack'}

{mode === 'container' &&
Made as a one-service stack, so you can edit it, redeploy it and update its image later from the Stacks tab.
} @@ -59,7 +73,7 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' | } {picked &&
{picked.dir}/{name || '…'}/compose.yaml{picked.held_by && ` — kept on ${picked.held_by}, deployed here over ${picked.remote}`}{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}
} -
+
{how === 'form' ? <>
@@ -83,7 +97,7 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
Create and deploy Create only - +
} {err && !homes &&
{err}
} diff --git a/web/src/styles.css b/web/src/styles.css index e25da74..3f620bf 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -431,6 +431,8 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); a.btn { text-decoration: none; display: inline-flex; align-items: center; } /* in-app dialogs, gallery search */ +/* a question is asked by something, often another dialog: it goes on top of whatever asked */ +.modal-back.ask-back { z-index: 70; } .modal.ask { width: min(440px, 100%); } .ask-msg { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; } .gallery-find { flex: 1 1 200px; max-width: 340px; padding: 7px 10px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; } .gallery .dash-bar { flex-wrap: wrap; gap: 8px; } @@ -448,7 +450,10 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; } .themes { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); } /* HUD: light drawn on glass — hairlines, no fills, thin type */ -[data-theme="hud"] .tile, [data-theme="hud"] .card, [data-theme="hud"] .box, [data-theme="hud"] .modal { background: rgba(53, 214, 255, .03); border-color: rgba(53, 214, 255, .28); border-radius: 2px; box-shadow: 0 0 0 1px rgba(53, 214, 255, .05), inset 0 0 24px rgba(53, 214, 255, .04); } +[data-theme="hud"] .tile, [data-theme="hud"] .card, [data-theme="hud"] .box { background: rgba(53, 214, 255, .03); border-color: rgba(53, 214, 255, .28); border-radius: 2px; box-shadow: 0 0 0 1px rgba(53, 214, 255, .05), inset 0 0 24px rgba(53, 214, 255, .04); } +/* anything that floats over the page is solid: glass on glass is two layers of text */ +[data-theme="hud"] .modal { background: #061019; border-color: rgba(53, 214, 255, .45); border-radius: 2px; box-shadow: 0 0 0 1px rgba(53, 214, 255, .08), 0 0 40px rgba(53, 214, 255, .12), inset 0 0 24px rgba(53, 214, 255, .04); } +[data-theme="hud"] .modal-back { background: rgba(0, 3, 6, .82); } [data-theme="hud"] .tile::before, [data-theme="hud"] .card::before { content: ''; position: absolute; top: -1px; left: -1px; width: 12px; height: 12px; border-top: 2px solid var(--accent); border-left: 2px solid var(--accent); } [data-theme="hud"] .tile, [data-theme="hud"] .card { position: relative; } [data-theme="hud"] body { font-weight: 300; letter-spacing: .2px; } @@ -456,6 +461,7 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; } [data-theme="hud"] .tile .label, [data-theme="hud"] .card h2, [data-theme="hud"] h2.section { letter-spacing: 2px; color: var(--accent); opacity: .8; } [data-theme="hud"] .meter { height: 3px; border-radius: 0; } [data-theme="hud"] .meter .fill { border-radius: 0; box-shadow: 0 0 8px var(--s1); } [data-theme="hud"] .rail, [data-theme="hud"] .header, [data-theme="hud"] .footer { background: rgba(3, 7, 12, .92); border-color: rgba(53, 214, 255, .22); } +@media (max-width: 800px) { [data-theme="hud"] .rail { background: #03070c; } } [data-theme="hud"] .group-head.active { background: linear-gradient(90deg, rgba(53, 214, 255, .16), transparent); box-shadow: inset 2px 0 0 var(--accent); } [data-theme="hud"] .btn, [data-theme="hud"] .chip { background: transparent; border-color: rgba(53, 214, 255, .35); border-radius: 2px; } [data-theme="hud"] .btn.on, [data-theme="hud"] .chip.on { background: rgba(53, 214, 255, .16); color: var(--accent2); border-color: var(--accent); } [data-theme="hud"] .app { background: radial-gradient(ellipse at 50% -20%, rgba(53, 214, 255, .10), transparent 60%), var(--bg); } diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 2fd2aa2..26c2cdb 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -70,11 +70,17 @@ export function NetBar() { } // useEscape runs fn when Escape is pressed while active — every menu and dialog closes that way. -export function useEscape(fn: () => void, active = true) { +// A question (askYes/askText) sits on top of whatever asked it: Escape answers the question and +// goes no further, or one key would cancel the question and close the dialog underneath as well. +let escapeTaken: Event | null = null +export function useEscape(fn: () => void, active = true, isAsk = false) { const ref = useRef(fn); ref.current = fn useEffect(() => { if (!active) return - const h = (e: KeyboardEvent) => { if (e.key === 'Escape') ref.current() } + const h = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + if (isAsk) { escapeTaken = e; ref.current() } else if (asks.length === 0 && escapeTaken !== e) ref.current() + } window.addEventListener('keydown', h) return () => window.removeEventListener('keydown', h) }, [active]) @@ -103,9 +109,9 @@ function AskBox({ a }: { a: Ask }) { const [val, setVal] = useState(a.input ?? '') const cancel = () => a.done(a.input !== undefined ? null : false) const accept = () => a.done(a.input !== undefined ? val : true) - useEscape(cancel) + useEscape(cancel, true, true) return ( -
+
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); accept() }}>
{a.msg}
{a.input !== undefined && setVal(e.target.value)} onFocus={(e) => e.target.select()} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}