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 && }