Bug bash 2: 11 bugs in Stacks, Alerts, Docker, skins and the installer, fixed and verified (BUGS.md)

Stacks: deploy with every compose file (labels from inspect, not comma-joined ps);
refuse a stale overwrite; empty list without Docker. Alerts: firing state survives a
restart without re-announcing; vanished alerts are recorded as cleared with the reason;
rule values validated; 200 rules. Docker: 404 for a removed container's logs. Skins: four
palettes lifted to readable contrast. Phone: bigger toggles and swatches. Installer:
deploy republishes; an older published build is never installed silently.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 15:59:51 -07:00
co-authored by Claude Fable 5.1
parent 9c4f9dfa9f
commit eddba4250b
10 changed files with 320 additions and 68 deletions
+67
View File
@@ -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.
+4 -1
View File
@@ -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/
+21 -2
View File
@@ -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
+74 -3
View File
@@ -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"
}
+5 -1
View File
@@ -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 {
+134 -48
View File
@@ -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
// <file>.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":
+7 -5
View File
@@ -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<Stack | null>(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 <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
return (
<div className="svc-detail">
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{st.host_file}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{st.host_files?.[file] ?? st.host_file}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
{(st.host_files?.length ?? 0) > 1 && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}</div>}
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}</div>}
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}{/changed on disk/.test(err) && <> <button className="link" onClick={async () => { if (await askYes('Reload the file from disk? Your text in the box is replaced.', 'Reload')) load() }}>reload it</button></>}</div>}
<div className="svc-actions">
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
{dirty && <button className="btn" onClick={() => { setText(orig); setErr('') }}>Revert</button>}
+3 -3
View File
@@ -103,7 +103,7 @@ export interface SmartAttr { id: number; name: string; value: number; worst: num
export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number }
export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number }
export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; via: string; editable: boolean; note?: string; has_env: boolean }
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; via: string; editable: boolean; note?: string; has_env: boolean }
export interface AlertRule { id: string; name: string; sensor: string; op: string; value: number | boolean | string; for_sec: number; severity: 'info' | 'warn' | 'crit'; enabled: boolean; machines: string }
export interface AlertChannel { type: 'ntfy' | 'webhook'; url: string }
export interface AlertEvent { t: number; kind: 'firing' | 'resolved'; rule: string; rule_id: string; machine: string; sensor: string; value: unknown; severity: string }
@@ -194,8 +194,8 @@ export const api = {
dockerLogs: (id: string, since = '') => req<{ lines: { t: string; m: string }[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
stackFile: (name: string) => req<{ stack: Stack; text: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}`),
saveStack: (name: string, text: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}`, { text }),
stackFile: (name: string, file = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
saveStack: (name: string, file: number, text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }),
stackAction: (name: string, action: 'deploy' | 'update' | 'restart' | 'stop') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`),
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
+1 -1
View File
@@ -472,7 +472,7 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
.chan-add { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } .chan-add input { flex: 1 1 220px; min-width: 0; }
.ev { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); font-size: 13px; } .ev:first-child { border-top: 0; } .ev-main { flex: 1; min-width: 0; overflow-wrap: anywhere; } .bad-text { color: var(--bad) !important; }
.rail-badge { margin-left: auto; min-width: 18px; padding: 0 5px; border-radius: 9px; background: var(--bad); color: #fff; font-size: 11px; font-weight: 700; text-align: center; line-height: 18px; }
@media (pointer: coarse) { .toggle { width: 46px; height: 28px; border-radius: 14px; } .toggle i { width: 22px; height: 22px; } .toggle.on i { left: 20px; } }
@media (pointer: coarse) { .toggle { width: 52px; height: 32px; border-radius: 16px; } .toggle i { width: 26px; height: 26px; } .toggle.on i { left: 22px; } .swatch { width: 34px; height: 34px; } .swatches input[type=color] { width: 36px; height: 36px; } }
/* docker */
.stack-head { margin: 0; padding: 8px 12px 4px; font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; background: var(--bg2); position: sticky; top: 0; z-index: 1; }
+4 -4
View File
@@ -75,7 +75,7 @@ export const THEMES: Theme[] = [
['#03070c', '#050c14', '#0a1a28', '#123047', '#bfe9ff', '#5b8ba6', '#35d6ff', '#7df9ff', '#4dffb5', '#ff5470', '#35d6ff', '#ffb547', '#02050a'],
['#02050a', '#ff5470', '#4dffb5', '#ffb547', '#35a8ff', '#b28dff', '#35d6ff', '#bfe9ff', '#3a5a70', '#ff8398', '#8dffd0', '#ffd08a', '#7cc6ff', '#cdb5ff', '#7df9ff', '#ffffff']),
mk('nord', 'Nord', 'Arctic blue-greys. Calm, low contrast, easy at night.',
['#2e3440', '#3b4252', '#434c5e', '#4c566a', '#d8dee9', '#8f9bb3', '#88c0d0', '#81a1c1', '#a3be8c', '#bf616a', '#81a1c1', '#ebcb8b', '#2b303b'],
['#2e3440', '#3b4252', '#434c5e', '#4c566a', '#d8dee9', '#8f9bb3', '#88c0d0', '#81a1c1', '#a3be8c', '#d9828a', '#81a1c1', '#ebcb8b', '#2b303b'],
['#3b4252', '#bf616a', '#a3be8c', '#ebcb8b', '#81a1c1', '#b48ead', '#88c0d0', '#e5e9f0', '#4c566a', '#bf616a', '#a3be8c', '#ebcb8b', '#81a1c1', '#b48ead', '#8fbcbb', '#eceff4']),
mk('gruvbox', 'Gruvbox', 'Warm retro earth tones. Cream on charcoal.',
['#282828', '#32302f', '#3c3836', '#504945', '#ebdbb2', '#a89984', '#fabd2f', '#83a598', '#b8bb26', '#fb4934', '#83a598', '#fe8019', '#1d2021'],
@@ -84,7 +84,7 @@ export const THEMES: Theme[] = [
['#1e1e2e', '#181825', '#313244', '#45475a', '#cdd6f4', '#9399b2', '#cba6f7', '#89b4fa', '#a6e3a1', '#f38ba8', '#89b4fa', '#fab387', '#11111b'],
['#45475a', '#f38ba8', '#a6e3a1', '#f9e2af', '#89b4fa', '#f5c2e7', '#94e2d5', '#bac2de', '#585b70', '#f38ba8', '#a6e3a1', '#f9e2af', '#89b4fa', '#f5c2e7', '#94e2d5', '#a6adc8']),
mk('latte', 'Catppuccin Latte', 'The same pastels, by daylight.',
['#eff1f5', '#e6e9ef', '#dce0e8', '#bcc0cc', '#4c4f69', '#7c7f93', '#8839ef', '#1e66f5', '#40a02b', '#d20f39', '#1e66f5', '#fe640b', '#e6e9ef'],
['#eff1f5', '#e6e9ef', '#dce0e8', '#bcc0cc', '#4c4f69', '#6c6f85', '#8839ef', '#1e66f5', '#35891f', '#d20f39', '#1e66f5', '#fe640b', '#e6e9ef'],
['#5c5f77', '#d20f39', '#40a02b', '#df8e1d', '#1e66f5', '#ea76cb', '#179299', '#acb0be', '#6c6f85', '#d20f39', '#40a02b', '#df8e1d', '#1e66f5', '#ea76cb', '#179299', '#bcc0cc']),
mk('dracula', 'Dracula', 'Purple, pink and green on midnight.',
['#282a36', '#21222c', '#343746', '#44475a', '#f8f8f2', '#8b93b8', '#bd93f9', '#8be9fd', '#50fa7b', '#ff5555', '#8be9fd', '#ffb86c', '#1e1f29'],
@@ -93,10 +93,10 @@ export const THEMES: Theme[] = [
['#1a1b26', '#16161e', '#24283b', '#343b58', '#c0caf5', '#737aa2', '#7aa2f7', '#bb9af7', '#9ece6a', '#f7768e', '#7aa2f7', '#ff9e64', '#13141c'],
['#15161e', '#f7768e', '#9ece6a', '#e0af68', '#7aa2f7', '#bb9af7', '#7dcfff', '#a9b1d6', '#414868', '#f7768e', '#9ece6a', '#e0af68', '#7aa2f7', '#bb9af7', '#7dcfff', '#c0caf5']),
mk('solarized', 'Solarized Dark', 'The precise, low-glare classic.',
['#002b36', '#073642', '#0a4352', '#1a5666', '#93a1a1', '#657b83', '#b58900', '#268bd2', '#859900', '#dc322f', '#268bd2', '#cb4b16', '#00212b'],
['#002b36', '#073642', '#0a4352', '#1a5666', '#a9b5b5', '#7b9199', '#b58900', '#268bd2', '#859900', '#ea4b48', '#268bd2', '#cb4b16', '#00212b'],
['#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5', '#586e75', '#cb4b16', '#93a1a1', '#b58900', '#839496', '#6c71c4', '#2aa198', '#fdf6e3']),
mk('solarized-light', 'Solarized Light', 'The same sixteen colours on warm paper.',
['#fdf6e3', '#eee8d5', '#e4ddc8', '#d3cbb4', '#586e75', '#839496', '#b58900', '#268bd2', '#859900', '#dc322f', '#268bd2', '#cb4b16', '#eee8d5'],
['#fdf6e3', '#eee8d5', '#e4ddc8', '#d3cbb4', '#40555c', '#64777a', '#946f00', '#268bd2', '#6a7a00', '#dc322f', '#268bd2', '#cb4b16', '#eee8d5'],
['#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5', '#002b36', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3']),
]