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 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-20 12:30:15 -07:00
co-authored by Claude Fable 5.1
parent 634e286796
commit a446a03b7d
7 changed files with 130 additions and 21 deletions
+74
View File
@@ -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.
+6 -2
View File
@@ -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
+7 -2
View File
@@ -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
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)
}
}
}
+3 -3
View File
@@ -154,9 +154,9 @@ function StackDetail({ name, startTask, onBack, onChanged }: { name: string; sta
{dirty && <button className="btn" onClick={() => { setText(orig); setErr('') }}>Revert</button>}
<span className="spacer" />
<Btn className="btn" disabled={dirty} title={dirty ? 'save first' : 'recreate what changed: docker compose up -d'} confirm={`Deploy ${st.name}? Containers whose definition changed are recreated (a few seconds down each).`} onClick={run('deploy')}>Deploy</Btn>
<Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</Btn>
<Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>
<Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</Btn>}
{st.containers.length > 0 && <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>}
{st.running > 0 && <Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>}
</div>
<div className="svc-actions">
<span className="muted small">{st.containers.length === 0 ? 'Not deployed: no containers exist for this stack.' : ''}</span><span className="spacer" />
+20 -6
View File
@@ -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<Form>(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 (
<div className="modal-back" onClick={onClose}>
<div className="modal-back" onClick={close}>
<div className="modal stack-new" onClick={(e) => e.stopPropagation()}>
<h3>{mode === 'container' ? 'New container' : 'New stack'}</h3>
{mode === 'container' && <div className="muted small">Made as a one-service stack, so you can edit it, redeploy it and update its image later from the Stacks tab.</div>}
@@ -59,7 +73,7 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
<select value={home} onChange={(e) => setHome(e.target.value)}>{homes.map((h) => <option key={h.dir + h.via} value={h.dir}>{h.dir}{h.held_by ? ` (on ${h.held_by})` : ''} · {h.stacks} stack{h.stacks === 1 ? '' : 's'}</option>)}</select>
</label>}
{picked && <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{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`}</div>}
<div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={() => { if (how === 'form' && form.image.trim()) setText(built); setHow('compose') }}>Compose</button></div>
<div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={toCompose}>Compose</button></div>
{how === 'form' ? <>
<label className="muted">Image<input className="mono" value={form.image} onChange={set('image')} placeholder="traefik/whoami:latest" spellCheck={false} autoCapitalize="off" autoCorrect="off" /></label>
<div className="two">
@@ -83,7 +97,7 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
<div className="svc-actions">
<Btn className="btn on" disabled={!okName || (how === 'form' && !form.image.trim())} onClick={create(true)}>Create and deploy</Btn>
<Btn className="btn" disabled={!okName || (how === 'form' && !form.image.trim())} onClick={create(false)}>Create only</Btn>
<span className="spacer" /><button className="btn" onClick={onClose}>Cancel</button>
<span className="spacer" /><button className="btn" onClick={close}>Cancel</button>
</div>
</>}
{err && !homes && <div className="error">{err}</div>}
+7 -1
View File
@@ -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); }
+10 -4
View File
@@ -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 (
<div className="modal-back" onClick={cancel}>
<div className="modal-back ask-back" onClick={cancel}>
<form className="modal ask" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); accept() }}>
<div className="ask-msg">{a.msg}</div>
{a.input !== undefined && <input autoFocus value={val} onChange={(e) => setVal(e.target.value)} onFocus={(e) => e.target.select()} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}