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:
co-authored by
Claude Fable 5.1
parent
9c4f9dfa9f
commit
eddba4250b
@@ -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
|
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
|
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.
|
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.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||||
GO ?= go
|
GO ?= go
|
||||||
PREFIX ?= /usr/local/bin
|
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
|
# every architecture a release carries; arm is ARMv6 hard-float, so it covers every Raspberry Pi
|
||||||
ARCHES ?= amd64 arm64 arm 386 riscv64
|
ARCHES ?= amd64 arm64 arm 386 riscv64
|
||||||
@@ -53,6 +54,9 @@ test:
|
|||||||
deploy: build cross
|
deploy: build cross
|
||||||
./bin/hamlab install --prefix $(PREFIX)
|
./bin/hamlab install --prefix $(PREFIX)
|
||||||
@sleep 1; $(PREFIX)/hamlab push-nodes || true
|
@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 the installed binary to every managed machine (what deploy does last)
|
||||||
push-nodes:
|
push-nodes:
|
||||||
@@ -68,7 +72,6 @@ clean:
|
|||||||
# Put the release where `curl -fsSL https://hamlab-demo.bigham.dev/install.sh | sh` finds it:
|
# 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
|
# 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.
|
# last, so nobody is ever pointed at files that aren't there yet.
|
||||||
PUBLISH ?= /home/core/apps/hamlab-demo/site
|
|
||||||
publish: release
|
publish: release
|
||||||
mkdir -p $(PUBLISH)/dl
|
mkdir -p $(PUBLISH)/dl
|
||||||
cp dist/hamlab-linux-* dist/SHA256SUMS $(PUBLISH)/dl/
|
cp dist/hamlab-linux-* dist/SHA256SUMS $(PUBLISH)/dl/
|
||||||
|
|||||||
+21
-2
@@ -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)
|
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; }
|
[ -n "$ver" ] || { ver=$BUILT_FOR; case "$ver" in @*) ver=latest ;; esac; }
|
||||||
fi
|
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"
|
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
|
else good "installing Ham Lab ${B}$ver${R}"; fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────── the wizard ──
|
# ─────────────────────────────────────────────────────────── the wizard ──
|
||||||
flags=; password=; listen=; url_host=
|
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
|
blank
|
||||||
confirm "$([ "$have" = "$ver" ] && echo Reinstall || echo Upgrade) now?" y || { blank; out " nothing was changed"; exit 0; }
|
confirm "$([ "$have" = "$ver" ] && echo Reinstall || echo Upgrade) now?" y || { blank; out " nothing was changed"; exit 0; }
|
||||||
elif [ "$INTERACTIVE" = 1 ]; then
|
elif [ "$INTERACTIVE" = 1 ]; then
|
||||||
|
|||||||
+74
-3
@@ -71,8 +71,18 @@ type alertSaved struct {
|
|||||||
Channels []AlertChannel `json:"channels"`
|
Channels []AlertChannel `json:"channels"`
|
||||||
Events []AlertEvent `json:"events"`
|
Events []AlertEvent `json:"events"`
|
||||||
Seeded bool `json:"seeded"`
|
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 {
|
type alertState struct {
|
||||||
pending time.Time
|
pending time.Time
|
||||||
firing bool
|
firing bool
|
||||||
@@ -109,6 +119,9 @@ func newAlertEngine(s *Server) *alertEngine {
|
|||||||
if !e.saved.Seeded {
|
if !e.saved.Seeded {
|
||||||
e.saved.Rules, e.saved.Seeded = append([]AlertRule{}, defaultRules...), true
|
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
|
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" {
|
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
|
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
|
byID := map[string]AlertRule{}
|
||||||
if !seen[key] {
|
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)
|
delete(e.state, key)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
e.last = now
|
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 {
|
if len(events) > 0 {
|
||||||
e.saved.Events = append(e.saved.Events, events...)
|
e.saved.Events = append(e.saved.Events, events...)
|
||||||
if len(e.saved.Events) > maxEvents {
|
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")
|
jsonErr(w, 400, "bad request")
|
||||||
return
|
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 {
|
if body.Rules != nil {
|
||||||
ids := map[string]bool{}
|
ids := map[string]bool{}
|
||||||
for i := range *body.Rules {
|
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))
|
jsonErr(w, 400, fmt.Sprintf("rule %q: the comparison must be one of > >= < <= == !=", ru.Name))
|
||||||
return
|
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" {
|
if ru.Severity != "info" && ru.Severity != "crit" {
|
||||||
ru.Severity = "warn"
|
ru.Severity = "warn"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,11 @@ func (s *Server) dockerLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
out, err := dockerRun(ctx, append(args, id)...)
|
out, err := dockerRun(ctx, append(args, id)...)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
type line struct {
|
type line struct {
|
||||||
|
|||||||
+128
-42
@@ -16,6 +16,8 @@ package api
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -26,6 +28,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,8 +39,10 @@ type stack struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Containers []string `json:"containers"`
|
Containers []string `json:"containers"`
|
||||||
Running int `json:"running"`
|
Running int `json:"running"`
|
||||||
File string `json:"file"` // as the label has it
|
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
|
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
|
Via string `json:"via"` // container compose runs inside; "" = on the host
|
||||||
Editable bool `json:"editable"`
|
Editable bool `json:"editable"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
@@ -86,45 +91,70 @@ func readable(p string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
by := map[string]*stack{}
|
by := map[string]*stack{}
|
||||||
jsonLines(out, func(m map[string]any) {
|
if len(bytes.TrimSpace(ids)) > 0 {
|
||||||
labels := dstr(m, "Labels")
|
const f = `{{.Name}}|{{.State.Status}}|{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.project.config_files"}}`
|
||||||
name := labelOf(labels, "com.docker.compose.project")
|
out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...)
|
||||||
if name == "" {
|
if err != nil {
|
||||||
return
|
return nil, err
|
||||||
}
|
}
|
||||||
st := by[name]
|
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 {
|
if st == nil {
|
||||||
st = &stack{Name: name, File: strings.Split(labelOf(labels, "com.docker.compose.project.config_files"), ",")[0]}
|
st = &stack{Name: p[2]}
|
||||||
by[name] = st
|
if p[3] != "" {
|
||||||
|
st.Files = strings.Split(p[3], ",")
|
||||||
|
st.File = st.Files[0]
|
||||||
}
|
}
|
||||||
st.Containers = append(st.Containers, strings.Split(dstr(m, "Names"), ",")[0])
|
by[p[2]] = st
|
||||||
if dstr(m, "State") == "running" {
|
}
|
||||||
|
st.Containers = append(st.Containers, strings.TrimPrefix(p[0], "/"))
|
||||||
|
if p[1] == "running" {
|
||||||
st.Running++
|
st.Running++
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
}
|
||||||
maps := stackMounts(ctx)
|
maps := stackMounts(ctx)
|
||||||
stacks := []stack{}
|
stacks := []stack{}
|
||||||
for _, st := range by {
|
for _, st := range by {
|
||||||
sort.Strings(st.Containers)
|
sort.Strings(st.Containers)
|
||||||
switch {
|
// Every file must be found — a stack deployed from `-f compose.yaml -f override.yaml` and
|
||||||
case st.File == "":
|
// redeployed from the first alone would quietly lose everything the override set.
|
||||||
st.Note = "its containers don't say which file deployed them"
|
resolve := func(f string) (host, via string) {
|
||||||
case readable(st.File):
|
if readable(f) {
|
||||||
st.HostFile = st.File
|
return f, ""
|
||||||
default:
|
}
|
||||||
for _, m := range maps {
|
for _, m := range maps {
|
||||||
if rest, ok := strings.CutPrefix(st.File, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) {
|
if rest, ok := strings.CutPrefix(f, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) {
|
||||||
st.HostFile, st.Via = filepath.Join(m.src, rest), m.container
|
return filepath.Join(m.src, rest), m.container
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
break
|
||||||
}
|
}
|
||||||
|
st.HostFiles, st.Via = append(st.HostFiles, host), via
|
||||||
}
|
}
|
||||||
if st.HostFile == "" {
|
if len(st.HostFiles) > 0 {
|
||||||
st.Note = "deployed from somewhere this machine can't see (" + st.File + "); edit it there"
|
st.HostFile = st.HostFiles[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if st.HostFile != "" {
|
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) {
|
func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
defer cancel()
|
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)
|
stacks, err := s.findStacks(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 500, err.Error())
|
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})
|
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) {
|
func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
defer cancel()
|
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")
|
jsonErr(w, 404, "that stack's compose file isn't on this machine")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b, err := os.ReadFile(st.HostFile)
|
i, ok := fileIndex(r, st)
|
||||||
if err != nil && priv.Available() {
|
if !ok {
|
||||||
full := priv.Wrap("cat", st.HostFile)
|
jsonErr(w, 400, "no such file in this stack")
|
||||||
b, err = exec.CommandContext(ctx, full[0], full[1:]...).Output()
|
return
|
||||||
}
|
}
|
||||||
|
b, err := readAsRoot(ctx, st.HostFiles[i])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 500, err.Error())
|
jsonErr(w, 500, err.Error())
|
||||||
return
|
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.
|
// 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()
|
tool := dockerTool()
|
||||||
if st.Via != "" {
|
base := []string{"compose", "-p", st.Name}
|
||||||
return exec.CommandContext(ctx, tool, append([]string{"exec", st.Via, "docker", "compose", "-p", st.Name, "-f", file}, args...)...)
|
for _, f := range files {
|
||||||
|
base = append(base, "-f", f)
|
||||||
}
|
}
|
||||||
cmd := exec.CommandContext(ctx, tool, append([]string{"compose", "-p", st.Name, "-f", file}, args...)...)
|
if st.Via != "" {
|
||||||
cmd.Dir = filepath.Dir(file)
|
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
|
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
|
// 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.
|
// <file>.hamlab-bak, and writes. It does not deploy: that is its own button.
|
||||||
func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
|
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 {
|
var body struct {
|
||||||
Text string `json:"text"`
|
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) == "" {
|
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")
|
jsonErr(w, 400, "the file can't be empty")
|
||||||
return
|
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.
|
// 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
|
err = s.fsLocal.Write(ctx, cand, strings.NewReader(body.Text)) // ours to write, or root's
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = s.fsSudo.Write(ctx, cand, strings.NewReader(body.Text))
|
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
|
inner := cand
|
||||||
if st.Via != "" {
|
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)
|
_ = s.fsSudo.Delete(ctx, cand, false)
|
||||||
jsonErr(w, 400, "compose rejects it: "+strings.TrimSpace(string(msg)))
|
jsonErr(w, 400, "compose rejects it: "+strings.TrimSpace(string(msg)))
|
||||||
return
|
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...)
|
full := priv.Wrap(mv...)
|
||||||
if msg, err := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil {
|
if msg, err := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil {
|
||||||
jsonErr(w, 500, strings.TrimSpace(string(msg)))
|
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")
|
jsonErr(w, 404, "that stack's compose file isn't on this machine")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file := st.HostFile
|
files := seenFiles(st, -1, "")
|
||||||
if st.Via != "" {
|
var fl []string
|
||||||
file = st.File
|
for _, f := range files {
|
||||||
|
fl = append(fl, "-f", f)
|
||||||
}
|
}
|
||||||
|
flags := priv.Quote(append([]string{"-p", st.Name}, fl...))
|
||||||
var script string
|
var script string
|
||||||
base := func(args string) string {
|
base := func(args string) string {
|
||||||
if st.Via != "" {
|
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") {
|
switch r.PathValue("action") {
|
||||||
case "deploy":
|
case "deploy":
|
||||||
|
|||||||
+7
-5
@@ -7,7 +7,7 @@ import TaskOutput from './TaskOutput'
|
|||||||
import { Sensor, fmtValue, useSensorStream } from './sensors'
|
import { Sensor, fmtValue, useSensorStream } from './sensors'
|
||||||
import { useHashSel } from './hashsel'
|
import { useHashSel } from './hashsel'
|
||||||
import { useRefreshMs } from './prefs'
|
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'
|
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')
|
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 [st, setSt] = useState<Stack | null>(null)
|
||||||
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
|
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
|
||||||
const [err, setErr] = useState(''); const [task, setTask] = 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])
|
useEffect(() => { setSt(null); setTask(''); load() }, [load])
|
||||||
const dirty = text !== orig
|
const dirty = text !== orig
|
||||||
useEffect(() => { setUnsaved(dirty ? `${name}'s compose file` : ''); return () => setUnsaved('') }, [dirty, name])
|
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') } }
|
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 (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>
|
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
|
||||||
return (
|
return (
|
||||||
<div className="svc-detail">
|
<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="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" />
|
<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">
|
<div className="svc-actions">
|
||||||
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
|
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
|
||||||
{dirty && <button className="btn" onClick={() => { setText(orig); setErr('') }}>Revert</button>}
|
{dirty && <button className="btn" onClick={() => { setText(orig); setErr('') }}>Revert</button>}
|
||||||
|
|||||||
+3
-3
@@ -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 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 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 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 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 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 }
|
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'}`),
|
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`),
|
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
|
||||||
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
|
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
|
||||||
stackFile: (name: string) => req<{ stack: Stack; text: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}`),
|
stackFile: (name: string, file = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
|
||||||
saveStack: (name: string, text: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}`, { text }),
|
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}`),
|
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),
|
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
|
||||||
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
|
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
|
||||||
|
|||||||
+1
-1
@@ -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; }
|
.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; }
|
.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; }
|
.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 */
|
/* 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; }
|
.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
@@ -75,7 +75,7 @@ export const THEMES: Theme[] = [
|
|||||||
['#03070c', '#050c14', '#0a1a28', '#123047', '#bfe9ff', '#5b8ba6', '#35d6ff', '#7df9ff', '#4dffb5', '#ff5470', '#35d6ff', '#ffb547', '#02050a'],
|
['#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']),
|
['#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.',
|
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']),
|
['#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.',
|
mk('gruvbox', 'Gruvbox', 'Warm retro earth tones. Cream on charcoal.',
|
||||||
['#282828', '#32302f', '#3c3836', '#504945', '#ebdbb2', '#a89984', '#fabd2f', '#83a598', '#b8bb26', '#fb4934', '#83a598', '#fe8019', '#1d2021'],
|
['#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'],
|
['#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']),
|
['#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.',
|
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']),
|
['#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.',
|
mk('dracula', 'Dracula', 'Purple, pink and green on midnight.',
|
||||||
['#282a36', '#21222c', '#343746', '#44475a', '#f8f8f2', '#8b93b8', '#bd93f9', '#8be9fd', '#50fa7b', '#ff5555', '#8be9fd', '#ffb86c', '#1e1f29'],
|
['#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'],
|
['#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']),
|
['#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.',
|
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']),
|
['#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.',
|
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']),
|
['#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5', '#002b36', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3']),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user