diff --git a/BUGS.md b/BUGS.md index 98e4a09..29b831e 100644 --- a/BUGS.md +++ b/BUGS.md @@ -212,3 +212,70 @@ and `every=` values on the sensor API; phone drawer, key bar and picker. the same unconditionally, Ctrl+Insert / Shift+Insert too. It rides the browser's own copy and paste events, which xterm already answers, so it works over plain http on the LAN where `navigator.clipboard` does not exist; bracketed paste is honoured. + + +# Bug bash 2 — 2026-09-19 (evening): Alerts, Docker, Stacks, skins, the installer + +Same method as the morning: a scratch instance with the real fleet behind it, headless +Chromium at 1440 px and iPhone size. A sweep of all 13 pages on all four machines (errors, +failed requests, overflow, tap targets) plus a contrast measurement of every skin; then +scripted use of the new features on throwaway containers and stacks — a multi-file stack, a +file edited behind the editor's back, two actions at once, a chatty log, odd container +names, containers stopped and removed while open, every kind of bad alert rule, a rule's +whole life (fire, narrow, pause, delete, survive a restart), token scope and hostile paths +on the new APIs — and a regression of terminals, Files, widgets, deep links and colours. + +## Bugs — all ✅ fixed and verified by the script that found them + +1. ✅ **Deploying a stack made of several compose files dropped all but the first.** A stack + deployed with `-f compose.yaml -f compose.override.yaml` was redeployed from + `compose.yaml` alone, so everything the override set silently vanished from the new + container. Two causes: only the first file was used, and the file list was read from + `docker ps`, which joins labels with commas — the list is itself comma-separated, so it + arrived truncated. Labels now come from `inspect`; every file is resolved, shown as a + chip, editable, validated together and passed to compose in order. +2. ✅ **A compose file changed on disk after you opened it was silently overwritten** (Dockhand, + or a second tab). The editor now hands back the checksum it was given; a mismatch is a + 409 that says so, keeps your text, and offers to reload. +3. ✅ **Stacks on a machine without Docker answered 500.** An empty list. +4. ✅ **Every hub restart re-fired every firing alert** — and every deploy is a restart, so a + phone would have been told about the same failed service on each one. What is firing is + now kept in `state.json` and carried on, with its original start time. +5. ✅ **An alert whose rule was paused, deleted or narrowed just vanished**, leaving "firing" in + the history forever and no word to the phone. It is now recorded (and sent) as cleared, + with the reason: "rule paused", "rule deleted", "no longer watched". +6. ✅ **Nonsense rule values were accepted**: `null`, an object, `cpu.usage > ninety` (which can + never be true, and would never say so), `true` with `<`. Refused, each with its reason. +7. ✅ **No limit on rules** — 3000 were accepted, each checked on every machine every 15 s. 200. +8. ✅ **Logs of a container that no longer exists answered 500.** 404. +9. ✅ **Four skins had text under 3:1 contrast**: Nord's red; Catppuccin Latte's muted text and + green; Solarized Dark's body text, muted text and red; Solarized Light's body text, + muted text, accent and green. Each colour kept its hue and was moved to a readable value + (measured: all thirteen skins now pass, body text ≥ 7:1). +10. ✅ **Phone: the alert toggles (28 px) and the colour swatches (26 px) were under 30 px.** +11. ✅ **The public installer had fallen four builds behind the fleet** (v65 published, v69 + running), so `curl … | sh` on an up-to-date machine "upgraded" it backwards. `make + deploy` now republishes, and the installer compares versions: an older build is called + older, defaults to No, and unattended runs refuse it without `HAMLAB_ALLOW_DOWNGRADE=1`. + +## Checked and fine + +A second stack action while one runs (409); `../` and encoded tricks in stack names; +shell metacharacters in container names, `since` and actions; `rm` / `kill` / `down` are +not actions; a read-only token is refused on every Docker, Stacks and Alerts endpoint, +directly and through the hub; container names with dots and underscores still get live +CPU and memory; a log at five lines a second follows with no gaps or duplicates; a +container stopped or removed while open updates itself; an alert rule's XSS name renders +as text; per-machine firing, the rail badge, webhook and ntfy delivery; terminals, Files, +widgets over container sensors, deep links, tab titles and machine colours all unchanged. +No native browser dialogs anywhere; no page errors on any of 100 page loads. + +## Found by the alerts themselves + +`core` has one real alert firing: **`zfs-import-cache.service` fails at every boot** — `core` +has the ZFS tools and no pool, so there is nothing to import. Harmless, but it will sit on +the rail as "1" until the unit is disabled or the rule is narrowed. Left alone. + +## Quality of life — proposed, not built (waiting on Mitchell) + +See the list sent with this pass. diff --git a/Makefile b/Makefile index 328d0b1..cf1b813 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev) GO ?= go PREFIX ?= /usr/local/bin +PUBLISH ?= /home/core/apps/hamlab-demo/site # every architecture a release carries; arm is ARMv6 hard-float, so it covers every Raspberry Pi ARCHES ?= amd64 arm64 arm 386 riscv64 @@ -53,6 +54,9 @@ test: deploy: build cross ./bin/hamlab install --prefix $(PREFIX) @sleep 1; $(PREFIX)/hamlab push-nodes || true + @# The public installer must never lag the fleet: it would offer everyone an older build, and + @# "upgrade" a current machine backwards. Publish only where this hub serves the download site. + @if [ -d $(PUBLISH) ]; then $(MAKE) --no-print-directory publish; fi # push the installed binary to every managed machine (what deploy does last) push-nodes: @@ -68,7 +72,6 @@ clean: # Put the release where `curl -fsSL https://hamlab-demo.bigham.dev/install.sh | sh` finds it: # the static site beside the demo page (nginx, public through NPM). Binaries first, VERSION # last, so nobody is ever pointed at files that aren't there yet. -PUBLISH ?= /home/core/apps/hamlab-demo/site publish: release mkdir -p $(PUBLISH)/dl cp dist/hamlab-linux-* dist/SHA256SUMS $(PUBLISH)/dl/ diff --git a/install.sh b/install.sh index 804bde6..cd097e5 100755 --- a/install.sh +++ b/install.sh @@ -177,13 +177,32 @@ else ver=$(fetch "$HAMLAB_URL/VERSION" "$tmp/VERSION" 2>/dev/null && head -n 1 "$tmp/VERSION" | tr -cd 'A-Za-z0-9._+-' || true) [ -n "$ver" ] || { ver=$BUILT_FOR; case "$ver" in @*) ver=latest ;; esac; } fi -if [ -n "$have" ] && [ "$have" = "$ver" ]; then good "Ham Lab ${B}$have${R} is already installed ${D}${ARROW}${R} this reinstalls it"; note " its config, user and password are kept" +# v0.1.0-69-g9c4f9df → 0001000069: versions compare as numbers, builds since the tag included. +vnum() { printf '%s' "$1" | sed 's/^v//; s/-g[0-9a-f]*$//' | awk -F'[.-]' '{ printf "%d%03d%03d%05d", $1, $2, $3, $4 }'; } +older=0 +if [ -n "$have" ] && [ "$have" != "$ver" ]; then + a=$(vnum "$have" 2>/dev/null || echo 0); b=$(vnum "$ver" 2>/dev/null || echo 0) + case "$a$b" in *[!0-9]*) ;; *) [ "$b" -ge "$a" ] || older=1 ;; esac +fi +if [ "$older" = 1 ]; then + warn "Ham Lab ${B}$have${R} is installed, and what is published is ${B}older${R}: $ver" + note " installing it would take this machine backwards" + if [ "$INTERACTIVE" = 1 ]; then + blank; confirm "Go back to $ver anyway?" n || { blank; out " nothing was changed"; exit 0; } + elif [ -z "${HAMLAB_ALLOW_DOWNGRADE:-}" ]; then + die "Not downgrading $have to $ver." "Set HAMLAB_ALLOW_DOWNGRADE=1 if that is really what you want." + fi + have_msg_done=1 +fi +if [ -n "${have_msg_done:-}" ]; then : +elif [ -n "$have" ] && [ "$have" = "$ver" ]; then good "Ham Lab ${B}$have${R} is already installed ${D}${ARROW}${R} this reinstalls it"; note " its config, user and password are kept" elif [ -n "$have" ]; then good "Ham Lab ${B}$have${R} is installed ${D}${ARROW}${R} upgrading to ${B}$ver${R}"; note " its config, user and password are kept" else good "installing Ham Lab ${B}$ver${R}"; fi # ─────────────────────────────────────────────────────────── the wizard ── flags=; password=; listen=; url_host= -if [ "$INTERACTIVE" = 1 ] && [ -n "$have" ]; then +if [ "$INTERACTIVE" = 1 ] && [ -n "$have" ] && [ "$older" = 1 ]; then : +elif [ "$INTERACTIVE" = 1 ] && [ -n "$have" ]; then blank confirm "$([ "$have" = "$ver" ] && echo Reinstall || echo Upgrade) now?" y || { blank; out " nothing was changed"; exit 0; } elif [ "$INTERACTIVE" = 1 ]; then diff --git a/internal/api/alerts.go b/internal/api/alerts.go index 8894bf3..384916f 100644 --- a/internal/api/alerts.go +++ b/internal/api/alerts.go @@ -71,8 +71,18 @@ type alertSaved struct { Channels []AlertChannel `json:"channels"` Events []AlertEvent `json:"events"` Seeded bool `json:"seeded"` + // Firing is what was firing when the hub last looked, so a restart (every deploy is one) + // carries on where it was and doesn't announce the same alerts all over again. + Firing map[string]alertKept `json:"firing,omitempty"` } +type alertKept struct { + Since int64 `json:"since"` + Value any `json:"value"` +} + +const maxRules = 200 + type alertState struct { pending time.Time firing bool @@ -109,6 +119,9 @@ func newAlertEngine(s *Server) *alertEngine { if !e.saved.Seeded { e.saved.Rules, e.saved.Seeded = append([]AlertRule{}, defaultRules...), true } + for key, k := range e.saved.Firing { + e.state[key] = &alertState{pending: time.UnixMilli(k.Since), firing: true, since: time.UnixMilli(k.Since), value: k.Value} + } for i, r := range e.saved.Rules { // the first day's default watched "not running", which a container stopped on purpose also is if r.ID == "container-down" && r.Sensor == "container.*.running" { e.saved.Rules[i].Name, e.saved.Rules[i].Sensor, e.saved.Rules[i].Value = "Container down", "container.*.down", true @@ -275,12 +288,47 @@ func (e *alertEngine) evaluate() { } } } - for key := range e.state { // a sensor that is gone (a removed container, a deleted rule) takes its state with it - if !seen[key] { - delete(e.state, key) + byID := map[string]AlertRule{} + for _, r := range rules { + byID[r.ID] = r + } + for key, st := range e.state { // no longer watched: the rule was paused, deleted or narrowed, or the sensor is gone + if seen[key] { + continue } + if st.firing { // it was on the Alerts tab and perhaps on a phone: say that it is over, and why + part := strings.SplitN(key, "|", 3) + r, known := byID[part[0]] + name, why := r.Name, "no longer watched" + if !known { + name, why = part[0], "rule deleted" + } else if !r.Enabled { + why = "rule paused" + } + if name == "" { + name = part[0] + } + events = append(events, AlertEvent{T: now.UnixMilli(), Kind: "resolved", Rule: name, RuleID: part[0], Machine: part[1], Sensor: part[2], Value: why, Severity: r.Severity}) + } + delete(e.state, key) } e.last = now + kept := map[string]alertKept{} + for key, st := range e.state { + if st.firing { + kept[key] = alertKept{Since: st.since.UnixMilli(), Value: st.value} + } + } + changed := len(kept) != len(e.saved.Firing) + for k := range kept { + if _, ok := e.saved.Firing[k]; !ok { + changed = true + } + } + e.saved.Firing = kept + if changed && len(events) == 0 { + e.persist() + } if len(events) > 0 { e.saved.Events = append(e.saved.Events, events...) if len(e.saved.Events) > maxEvents { @@ -390,6 +438,10 @@ func (s *Server) alertsPut(w http.ResponseWriter, r *http.Request) { jsonErr(w, 400, "bad request") return } + if body.Rules != nil && len(*body.Rules) > maxRules { + jsonErr(w, 400, fmt.Sprintf("at most %d rules: every one is checked on every machine every %d s", maxRules, int(alertEvery.Seconds()))) + return + } if body.Rules != nil { ids := map[string]bool{} for i := range *body.Rules { @@ -408,6 +460,25 @@ func (s *Server) alertsPut(w http.ResponseWriter, r *http.Request) { jsonErr(w, 400, fmt.Sprintf("rule %q: the comparison must be one of > >= < <= == !=", ru.Name)) return } + switch val := ru.Value.(type) { + case float64, bool: + case string: + if len(val) > 200 { + jsonErr(w, 400, fmt.Sprintf("rule %q: the value is too long", ru.Name)) + return + } + if ru.Op != "==" && ru.Op != "!=" { // "cpu.usage > ninety" can never be true, and would never say so + jsonErr(w, 400, fmt.Sprintf("rule %q: %s compares numbers, and %q is not one; text can only be == or !=", ru.Name, ru.Op, val)) + return + } + default: + jsonErr(w, 400, fmt.Sprintf("rule %q: the value must be a number, true/false or text", ru.Name)) + return + } + if _, isBool := ru.Value.(bool); isBool && ru.Op != "==" && ru.Op != "!=" { + jsonErr(w, 400, fmt.Sprintf("rule %q: true/false can only be == or !=", ru.Name)) + return + } if ru.Severity != "info" && ru.Severity != "crit" { ru.Severity = "warn" } diff --git a/internal/api/docker.go b/internal/api/docker.go index 6be66e1..9d974c4 100644 --- a/internal/api/docker.go +++ b/internal/api/docker.go @@ -182,7 +182,11 @@ func (s *Server) dockerLogs(w http.ResponseWriter, r *http.Request) { defer cancel() out, err := dockerRun(ctx, append(args, id)...) if err != nil { - jsonErr(w, 500, err.Error()) + code := 500 + if strings.Contains(strings.ToLower(err.Error()), "no such container") { + code = 404 + } + jsonErr(w, code, err.Error()) return } type line struct { diff --git a/internal/api/stacks.go b/internal/api/stacks.go index 39b78c1..1ccdd14 100644 --- a/internal/api/stacks.go +++ b/internal/api/stacks.go @@ -16,6 +16,8 @@ package api import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -26,6 +28,7 @@ import ( "os/exec" "path/filepath" "sort" + "strconv" "strings" "time" @@ -36,9 +39,11 @@ type stack struct { Name string `json:"name"` Containers []string `json:"containers"` Running int `json:"running"` - File string `json:"file"` // as the label has it - HostFile string `json:"host_file"` // where it is on this machine; "" when it isn't - Via string `json:"via"` // container compose runs inside; "" = on the host + File string `json:"file"` // as the label has it (the first of Files) + HostFile string `json:"host_file"` // where it is on this machine; "" when it isn't + Files []string `json:"files"` // every file it was deployed from, in order: -f a -f b + HostFiles []string `json:"host_files"` // the same, on this machine + Via string `json:"via"` // container compose runs inside; "" = on the host Editable bool `json:"editable"` Note string `json:"note,omitempty"` HasEnv bool `json:"has_env"` @@ -86,45 +91,70 @@ func readable(p string) bool { } func (s *Server) findStacks(ctx context.Context) ([]stack, error) { - out, err := dockerRun(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}") + // Labels come from inspect, not `ps`: ps joins every label with commas, and the label that + // matters here — the list of compose files — is itself a comma-separated list. + ids, err := dockerRun(ctx, "ps", "-aq") if err != nil { return nil, err } by := map[string]*stack{} - jsonLines(out, func(m map[string]any) { - labels := dstr(m, "Labels") - name := labelOf(labels, "com.docker.compose.project") - if name == "" { - return + if len(bytes.TrimSpace(ids)) > 0 { + const f = `{{.Name}}|{{.State.Status}}|{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.project.config_files"}}` + out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...) + if err != nil { + return nil, err } - st := by[name] - if st == nil { - st = &stack{Name: name, File: strings.Split(labelOf(labels, "com.docker.compose.project.config_files"), ",")[0]} - by[name] = st + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + p := strings.SplitN(line, "|", 4) + if len(p) < 4 || p[2] == "" { + continue + } + st := by[p[2]] + if st == nil { + st = &stack{Name: p[2]} + if p[3] != "" { + st.Files = strings.Split(p[3], ",") + st.File = st.Files[0] + } + by[p[2]] = st + } + st.Containers = append(st.Containers, strings.TrimPrefix(p[0], "/")) + if p[1] == "running" { + st.Running++ + } } - st.Containers = append(st.Containers, strings.Split(dstr(m, "Names"), ",")[0]) - if dstr(m, "State") == "running" { - st.Running++ - } - }) + } maps := stackMounts(ctx) stacks := []stack{} for _, st := range by { sort.Strings(st.Containers) - switch { - case st.File == "": - st.Note = "its containers don't say which file deployed them" - case readable(st.File): - st.HostFile = st.File - default: + // Every file must be found — a stack deployed from `-f compose.yaml -f override.yaml` and + // redeployed from the first alone would quietly lose everything the override set. + resolve := func(f string) (host, via string) { + if readable(f) { + return f, "" + } for _, m := range maps { - if rest, ok := strings.CutPrefix(st.File, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) { - st.HostFile, st.Via = filepath.Join(m.src, rest), m.container - break + if rest, ok := strings.CutPrefix(f, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) { + return filepath.Join(m.src, rest), m.container } } - if st.HostFile == "" { - st.Note = "deployed from somewhere this machine can't see (" + st.File + "); edit it there" + return "", "" + } + if st.File == "" { + st.Note = "its containers don't say which file deployed them" + } else { + for i, f := range st.Files { + host, via := resolve(f) + if host == "" || (i > 0 && via != st.Via) { + st.HostFiles, st.Via = nil, "" + st.Note = "deployed from somewhere this machine can't see (" + f + "); edit it there" + break + } + st.HostFiles, st.Via = append(st.HostFiles, host), via + } + if len(st.HostFiles) > 0 { + st.HostFile = st.HostFiles[0] } } if st.HostFile != "" { @@ -153,6 +183,10 @@ func (s *Server) stackByName(ctx context.Context, name string) (stack, error) { func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() + if dockerTool() == "" { // no Docker is an empty answer, not a failure + jsonOK(w, 200, map[string]any{"stacks": []stack{}}) + return + } stacks, err := s.findStacks(ctx) if err != nil { jsonErr(w, 500, err.Error()) @@ -161,6 +195,26 @@ func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) { jsonOK(w, 200, map[string]any{"stacks": stacks}) } +func readAsRoot(ctx context.Context, path string) ([]byte, error) { + b, err := os.ReadFile(path) + if err != nil && priv.Available() { + full := priv.Wrap("cat", path) + b, err = exec.CommandContext(ctx, full[0], full[1:]...).Output() + } + return b, err +} + +func sumOf(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:8]) } + +// fileIndex is which of the stack's files a request means: ?file=1 is the first override. +func fileIndex(r *http.Request, st stack) (int, bool) { + i, err := strconv.Atoi(r.URL.Query().Get("file")) + if r.URL.Query().Get("file") == "" { + return 0, true + } + return i, err == nil && i >= 0 && i < len(st.HostFiles) +} + func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() @@ -169,29 +223,48 @@ func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) { jsonErr(w, 404, "that stack's compose file isn't on this machine") return } - b, err := os.ReadFile(st.HostFile) - if err != nil && priv.Available() { - full := priv.Wrap("cat", st.HostFile) - b, err = exec.CommandContext(ctx, full[0], full[1:]...).Output() + i, ok := fileIndex(r, st) + if !ok { + jsonErr(w, 400, "no such file in this stack") + return } + b, err := readAsRoot(ctx, st.HostFiles[i]) if err != nil { jsonErr(w, 500, err.Error()) return } - jsonOK(w, 200, map[string]any{"stack": st, "text": string(b)}) + // sum is what the editor hands back when it saves, so a file changed meanwhile is noticed + jsonOK(w, 200, map[string]any{"stack": st, "text": string(b), "sum": sumOf(b), "index": i}) } // composeCmd is `docker compose …` for this stack, run where its file makes sense. -func composeCmd(ctx context.Context, st stack, file string, args ...string) *exec.Cmd { +// files are given as compose will see them: the label's paths inside a container, host paths otherwise. +func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd { tool := dockerTool() - if st.Via != "" { - return exec.CommandContext(ctx, tool, append([]string{"exec", st.Via, "docker", "compose", "-p", st.Name, "-f", file}, args...)...) + base := []string{"compose", "-p", st.Name} + for _, f := range files { + base = append(base, "-f", f) } - cmd := exec.CommandContext(ctx, tool, append([]string{"compose", "-p", st.Name, "-f", file}, args...)...) - cmd.Dir = filepath.Dir(file) + if st.Via != "" { + return exec.CommandContext(ctx, tool, append(append([]string{"exec", st.Via, "docker"}, base...), args...)...) + } + cmd := exec.CommandContext(ctx, tool, append(base, args...)...) + cmd.Dir = filepath.Dir(files[0]) return cmd } +// seenFiles is the stack's file list as compose runs it, with file i replaced by a candidate. +func seenFiles(st stack, i int, candidate string) []string { + out := append([]string{}, st.HostFiles...) + if st.Via != "" { + out = append([]string{}, st.Files...) + } + if i >= 0 { + out[i] = candidate + } + return out +} + // stackSave validates the new text with compose itself, keeps the old file beside it as // .hamlab-bak, and writes. It does not deploy: that is its own button. func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) { @@ -204,13 +277,24 @@ func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) { } var body struct { Text string `json:"text"` + Sum string `json:"sum"` // the sum the editor was given when it opened the file } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil || strings.TrimSpace(body.Text) == "" { jsonErr(w, 400, "the file can't be empty") return } + i, ok := fileIndex(r, st) + if !ok { + jsonErr(w, 400, "no such file in this stack") + return + } + target := st.HostFiles[i] + if now, err := readAsRoot(ctx, target); err == nil && body.Sum != "" && sumOf(now) != body.Sum { + jsonErr(w, 409, "the file has changed on disk since you opened it (Dockhand, or another editor). Reload it to see the change; your text is still in the box.") + return + } // Validate a candidate beside the real file, so `.env` and relative paths resolve as they will. - cand := st.HostFile + ".hamlab-new" + cand := target + ".hamlab-new" err = s.fsLocal.Write(ctx, cand, strings.NewReader(body.Text)) // ours to write, or root's if err != nil { err = s.fsSudo.Write(ctx, cand, strings.NewReader(body.Text)) @@ -221,14 +305,14 @@ func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) { } inner := cand if st.Via != "" { - inner = st.File + ".hamlab-new" + inner = st.Files[i] + ".hamlab-new" } - if msg, err := composeCmd(ctx, st, inner, "config", "-q").CombinedOutput(); err != nil { + if msg, err := composeCmd(ctx, st, seenFiles(st, i, inner), "config", "-q").CombinedOutput(); err != nil { _ = s.fsSudo.Delete(ctx, cand, false) jsonErr(w, 400, "compose rejects it: "+strings.TrimSpace(string(msg))) return } - for _, mv := range [][]string{{"cp", "-p", st.HostFile, st.HostFile + ".hamlab-bak"}, {"mv", cand, st.HostFile}} { + for _, mv := range [][]string{{"cp", "-p", target, target + ".hamlab-bak"}, {"mv", cand, target}} { full := priv.Wrap(mv...) if msg, err := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil { jsonErr(w, 500, strings.TrimSpace(string(msg))) @@ -249,16 +333,18 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) { jsonErr(w, 404, "that stack's compose file isn't on this machine") return } - file := st.HostFile - if st.Via != "" { - file = st.File + files := seenFiles(st, -1, "") + var fl []string + for _, f := range files { + fl = append(fl, "-f", f) } + flags := priv.Quote(append([]string{"-p", st.Name}, fl...)) var script string base := func(args string) string { if st.Via != "" { - return fmt.Sprintf("%s exec %s docker compose -p %s -f %s %s", dockerTool(), priv.Quote([]string{st.Via}), priv.Quote([]string{st.Name}), priv.Quote([]string{file}), args) + return fmt.Sprintf("%s exec %s docker compose %s %s", dockerTool(), priv.Quote([]string{st.Via}), flags, args) } - return fmt.Sprintf("cd %s && %s compose -p %s -f %s %s", priv.Quote([]string{filepath.Dir(file)}), dockerTool(), priv.Quote([]string{st.Name}), priv.Quote([]string{file}), args) + return fmt.Sprintf("cd %s && %s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), dockerTool(), flags, args) } switch r.PathValue("action") { case "deploy": diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index adc8f00..81e090b 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -7,7 +7,7 @@ import TaskOutput from './TaskOutput' import { Sensor, fmtValue, useSensorStream } from './sensors' import { useHashSel } from './hashsel' import { useRefreshMs } from './prefs' -import { Btn, Loading, setUnsaved, toast } from './ui' +import { Btn, Loading, askYes, setUnsaved, toast } from './ui' type Tab = 'containers' | 'stacks' | 'images' | 'volumes' | 'networks' const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok') @@ -95,20 +95,22 @@ function StackDetail({ name, onBack, onChanged }: { name: string; onBack: () => const [st, setSt] = useState(null) const [text, setText] = useState(''); const [orig, setOrig] = useState('') const [err, setErr] = useState(''); const [task, setTask] = useState('') - const load = useCallback(async () => { try { const r = await api.stackFile(name); setSt(r.stack); setText(r.text); setOrig(r.text); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [name]) + const [file, setFile] = useState(0); const [sum, setSum] = useState('') + const load = useCallback(async () => { try { const r = await api.stackFile(name, file); setSt(r.stack); setText(r.text); setOrig(r.text); setSum(r.sum); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [name, file]) useEffect(() => { setSt(null); setTask(''); load() }, [load]) const dirty = text !== orig useEffect(() => { setUnsaved(dirty ? `${name}'s compose file` : ''); return () => setUnsaved('') }, [dirty, name]) - const save = async () => { try { await api.saveStack(name, text); setOrig(text); setErr(''); toast('saved — the running containers are unchanged until you deploy') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } + const save = async () => { try { await api.saveStack(name, file, text, sum); setOrig(text); setErr(''); api.stackFile(name, file).then((r) => setSum(r.sum)).catch(() => {}); toast('saved — the running containers are unchanged until you deploy') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } const run = (a: 'deploy' | 'update' | 'restart' | 'stop') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } if (err && !st) return
{err}
if (!st) return
return (
{st.name}{st.running}/{st.containers.length} running
-
{st.host_file}{st.via && · compose runs inside {st.via}, as it was deployed}{st.has_env && ' · has a .env beside it'}
+
{st.host_files?.[file] ?? st.host_file}{st.via && · compose runs inside {st.via}, as it was deployed}{st.has_env && ' · has a .env beside it'}
+ {(st.host_files?.length ?? 0) > 1 &&
{st.host_files!.map((f, i) => )}
}