Stacks: remote stacks editable from the hub, .env editing, deploy diff, image update checks; a gone container says so

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 16:42:54 -07:00
co-authored by Claude Fable 5.1
parent eddba4250b
commit 1d48267d90
7 changed files with 553 additions and 132 deletions
+17 -2
View File
@@ -276,6 +276,21 @@ No native browser dialogs anywhere; no page errors on any of 100 page loads.
has the ZFS tools and no pool, so there is nothing to import. Harmless, but it will sit on 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. 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) ## Quality of life — five chosen by Mitchell, ✅ built and verified 2026-09-19
See the list sent with this pass. 1. ✅ **Stacks on `grid` and the Mac are editable from the hub.** Their files live on `core`
(Dockhand deploys over tcp); the hub resolves them and runs compose with `DOCKER_HOST`
pointed at the node. Verified on a throwaway stack: saved on `core`, recreated **on
`grid`**, same compose labels, nothing created on `core`, output followed on the page.
2. ✅ **A stack's `.env` is editable**, values masked until "Reveal and edit". A `.env` that
breaks the compose file (a required variable removed) is refused and the old one put back.
6. ✅ **A link to a container that no longer exists says so**, by name.
9. ✅ **"Update available" per image** — on request, remembered six hours. Verified with an
image whose tag had moved on (update available) beside a current one (up to date).
10. ✅ **What Deploy would change**: a line diff against what was last deployed from here;
after a deploy it says there is nothing left to change. `.env` diffs name the key and
never show a value.
Not chosen, still open: alert acknowledge/snooze; quiet hours and per-rule channels; push
straight from Ham Lab; a type hint in the alert editor; switching off the nodes' own alert
engines.
+11 -3
View File
@@ -213,9 +213,17 @@ something long in one, switch away and back, refresh — it's all still there.
and the compose labels stay exactly as deployed (verified: labels identical after a and the compose labels stay exactly as deployed (verified: labels identical after a
redeploy). Save validates with `compose config` first, keeps the previous file as redeploy). Save validates with `compose config` first, keeps the previous file as
`<file>.hamlab-bak`, and never deploys by itself. A stack deployed from another host `<file>.hamlab-bak`, and never deploys by itself. A stack deployed from another host
(Dockhand on `core` driving `grid` over tcp) has no file on that machine: listed (Dockhand on `core` driving `grid` over tcp) has no file on that machine, so the node
read-only, saying so — edit those from the machine that holds the file. Removing alone lists it read-only — but **the hub fills that in** (`hubStacks`): asked about a
containers, images and volumes is still absent. node, it resolves the files on itself and runs compose here with `DOCKER_HOST` pointed
at the node's `tcp://…:2375`, the way it was deployed; the task runs on the hub and the
node's page follows it. Also per stack: every file it was deployed from (chips), its
**`.env`** (values masked until "Reveal and edit"; validated in place, put back if
compose objects), **what Deploy would change** (a line diff against the copy kept at the
last deploy from here, `<file>.hamlab-deployed`, else the copy the last save replaced;
`.env` values never shown), and **image update checks** — asked for, never polled, since
each is a registry request Docker Hub counts; remembered six hours. A save refuses a
file changed on disk meanwhile. Removing containers, images and volumes is still absent.
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark, - **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light; type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
+17 -1
View File
@@ -133,6 +133,18 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile)) m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile))
m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave)) m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave))
m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction)) m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction))
m.HandleFunc("GET /api/docker/stacks/{name}/diff", s.auth(s.stackDiff))
m.HandleFunc("GET /api/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
// The same, asked of a node through this hub: the hub answers for the stacks whose files
// it holds (deployed to the node over tcp) and passes everything else to the node.
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackFile))
m.HandleFunc("PUT /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackSave))
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/diff", s.auth(s.stackDiff))
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
m.HandleFunc("POST /api/nodes/{node}/docker/stacks/{name}/{action}", s.auth(s.stackAction))
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}", s.auth(s.taskOnHub(s.getTask)))
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction)) m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction))
m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs)) m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs))
m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect)) m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect))
@@ -674,7 +686,11 @@ func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) { func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
p, ok := s.nodes.Proxy(r.PathValue("name")) node := r.PathValue("node") // routes that also carry a {name} of their own call the machine {node}
if node == "" {
node = r.PathValue("name")
}
p, ok := s.nodes.Proxy(node)
if !ok { if !ok {
jsonErr(w, 404, "no such machine") jsonErr(w, 404, "no such machine")
return return
+446 -113
View File
@@ -3,15 +3,20 @@ package api
// Stacks: the compose projects behind the containers, with their files to edit and a way to // Stacks: the compose projects behind the containers, with their files to edit and a way to
// redeploy them. // redeploy them.
// //
// A stack is found from its containers' labels (project, config file). The file may not be // A stack is found from its containers' labels (project, config files). The files may not be
// where the label says: an orchestrator that runs compose from inside its own container — // where the label says: an orchestrator that runs compose from inside its own container —
// Dockhand, Dockge, Portainer — records a path in *its* filesystem. So the path is looked // Dockhand, Dockge, Portainer — records paths in *its* filesystem. So each path is looked up
// up twice: as it stands on this host, and through every container's mounts // twice: as it stands on this host, and through every container's mounts
// (/app/data/stacks/… in `dockhand` is /home/core/apps/dockhand/stacks/… here). Compose is // (/app/data/stacks/… in `dockhand` is /home/core/apps/dockhand/stacks/… here). Compose is
// then run where the file makes sense — inside that container when it came from one, so // then run where the files make sense — inside that container when they came from one, so
// relative paths, the .env beside it and the labels all stay exactly as they were deployed — // relative paths, the .env beside them and the labels all stay exactly as they were
// else on the host. A stack whose file is on no path this machine can see (deployed from // deployed — else on the host.
// another host over tcp) is listed read-only, and says so. //
// A stack can also be deployed *from another machine*: Dockhand on the hub drives a node's
// Docker over tcp, so the node runs the containers and the hub holds the files. The node
// alone can only list such a stack read-only. The hub, asked about one of its nodes, fills
// in what the node couldn't (hubStacks): it resolves the files on itself and runs compose
// here with DOCKER_HOST pointed at the node.
import ( import (
"bytes" "bytes"
@@ -24,12 +29,13 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"sync"
"time" "time"
"hamlab/internal/priv" "hamlab/internal/priv"
@@ -38,12 +44,15 @@ import (
type stack struct { type stack struct {
Name string `json:"name"` Name string `json:"name"`
Containers []string `json:"containers"` Containers []string `json:"containers"`
Images []string `json:"images"`
Running int `json:"running"` Running int `json:"running"`
File string `json:"file"` // as the label has it (the first of Files) 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 the machine that holds it; "" when unknown
Files []string `json:"files"` // every file it was deployed from, in order: -f a -f b 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 HostFiles []string `json:"host_files"` // the same, on the machine that holds them
Via string `json:"via"` // container compose runs inside; "" = on the host Via string `json:"via"` // container compose runs inside; "" = on the host
Remote string `json:"remote"` // DOCKER_HOST compose is pointed at; "" = this machine's Docker
HeldBy string `json:"held_by"` // the machine holding the files, when it isn't the one running them
Editable bool `json:"editable"` Editable bool `json:"editable"`
Note string `json:"note,omitempty"` Note string `json:"note,omitempty"`
HasEnv bool `json:"has_env"` HasEnv bool `json:"has_env"`
@@ -90,6 +99,41 @@ func readable(p string) bool {
return false return false
} }
// resolve finds every file of a stack on this machine. Every one 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.
func (st *stack) resolve(maps []mountMap) {
st.HostFiles, st.HostFile, st.Via, st.Editable, st.HasEnv = nil, "", "", false, false
if st.File == "" {
st.Note = "its containers don't say which file deployed them"
return
}
one := func(f string) (host, via string) {
if readable(f) {
return f, ""
}
for _, m := range maps {
if rest, ok := strings.CutPrefix(f, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) {
return filepath.Join(m.src, rest), m.container
}
}
return "", ""
}
for i, f := range st.Files {
host, via := one(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"
return
}
st.HostFiles, st.Via = append(st.HostFiles, host), via
}
st.HostFile, st.Editable, st.Note = st.HostFiles[0], true, ""
st.HasEnv = readable(st.envPath())
}
func (st stack) envPath() string { return filepath.Join(filepath.Dir(st.HostFiles[0]), ".env") }
func (s *Server) findStacks(ctx context.Context) ([]stack, error) { func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
// Labels come from inspect, not `ps`: ps joins every label with commas, and the label that // 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. // matters here — the list of compose files — is itself a comma-separated list.
@@ -99,26 +143,29 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
} }
by := map[string]*stack{} by := map[string]*stack{}
if len(bytes.TrimSpace(ids)) > 0 { 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"}}` const f = `{{.Name}}|{{.State.Status}}|{{.Config.Image}}|{{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))...)...) out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
p := strings.SplitN(line, "|", 4) p := strings.SplitN(line, "|", 5)
if len(p) < 4 || p[2] == "" { if len(p) < 5 || p[3] == "" {
continue continue
} }
st := by[p[2]] st := by[p[3]]
if st == nil { if st == nil {
st = &stack{Name: p[2]} st = &stack{Name: p[3]}
if p[3] != "" { if p[4] != "" {
st.Files = strings.Split(p[3], ",") st.Files = strings.Split(p[4], ",")
st.File = st.Files[0] st.File = st.Files[0]
} }
by[p[2]] = st by[p[3]] = st
} }
st.Containers = append(st.Containers, strings.TrimPrefix(p[0], "/")) st.Containers = append(st.Containers, strings.TrimPrefix(p[0], "/"))
if !contains(st.Images, p[2]) {
st.Images = append(st.Images, p[2])
}
if p[1] == "running" { if p[1] == "running" {
st.Running++ st.Running++
} }
@@ -128,61 +175,123 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
stacks := []stack{} stacks := []stack{}
for _, st := range by { for _, st := range by {
sort.Strings(st.Containers) sort.Strings(st.Containers)
// Every file must be found — a stack deployed from `-f compose.yaml -f override.yaml` and sort.Strings(st.Images)
// redeployed from the first alone would quietly lose everything the override set. st.resolve(maps)
resolve := func(f string) (host, via string) {
if readable(f) {
return f, ""
}
for _, m := range maps {
if rest, ok := strings.CutPrefix(f, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) {
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
}
st.HostFiles, st.Via = append(st.HostFiles, host), via
}
if len(st.HostFiles) > 0 {
st.HostFile = st.HostFiles[0]
}
}
if st.HostFile != "" {
st.Editable = true
st.HasEnv = readable(filepath.Join(filepath.Dir(st.HostFile), ".env"))
}
stacks = append(stacks, *st) stacks = append(stacks, *st)
} }
sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name }) sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name })
return stacks, nil return stacks, nil
} }
func (s *Server) stackByName(ctx context.Context, name string) (stack, error) { func contains(list []string, s string) bool {
all, err := s.findStacks(ctx) for _, x := range list {
if err != nil { if x == s {
return stack{}, err return true
}
for _, st := range all {
if st.Name == name {
return st, nil
} }
} }
return stack{}, errors.New("no such stack") return false
}
// hubStacks is a node's stack list with the hub's help: what the node could not find is
// looked for here, and if it is here — and the node's Docker answers over tcp, which is how
// it was deployed in the first place — the stack becomes editable from this hub.
func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks", nil, nil)
if err != nil {
return nil, err
}
var res struct{ Stacks []stack }
if code != 200 || json.Unmarshal(b, &res) != nil {
return nil, fmt.Errorf("%s answered %d", node, code)
}
remote, maps := "", []mountMap(nil)
for i := range res.Stacks {
st := &res.Stacks[i]
if st.Editable || len(st.Files) == 0 {
continue
}
if remote == "" {
remote, maps = s.nodeDocker(ctx, node), stackMounts(ctx)
}
if remote == "-" {
continue
}
note := st.Note
if st.resolve(maps); !st.Editable {
st.Note = note
continue
}
st.Remote, st.HeldBy = remote, s.host
}
return res.Stacks, nil
}
var (
nodeDockerMu sync.Mutex
nodeDockerOK = map[string]string{} // node → "tcp://host:2375", or "-" when it doesn't answer
)
// nodeDocker is the node's Docker endpoint as Dockhand-style deployments use it, if it answers.
func (s *Server) nodeDocker(ctx context.Context, node string) string {
nodeDockerMu.Lock()
defer nodeDockerMu.Unlock()
if v, ok := nodeDockerOK[node]; ok && v != "-" {
return v
}
ep := "-"
for _, n := range s.nodes.List() {
if n.Name != node {
continue
}
if u, err := url.Parse(n.URL); err == nil && u.Hostname() != "" {
cand := "tcp://" + u.Hostname() + ":2375"
c, cancel := context.WithTimeout(ctx, 5*time.Second)
if exec.CommandContext(c, dockerTool(), "-H", cand, "version", "--format", "{{.Server.Version}}").Run() == nil {
ep = cand
}
cancel()
}
}
nodeDockerOK[node] = ep
return ep
}
// stackFor finds the stack a request means: on this machine, or (on a hub route) one of a
// node's stacks that this hub holds the files for. handled is false when the request should
// simply be passed on to the node.
func (s *Server) stackFor(ctx context.Context, r *http.Request) (st stack, handled bool, err error) {
var all []stack
if node := r.PathValue("node"); node != "" {
all, err = s.hubStacks(ctx, node)
} else {
all, err = s.findStacks(ctx)
}
if err != nil {
return stack{}, true, err
}
for _, x := range all {
if x.Name == r.PathValue("name") {
if r.PathValue("node") != "" && x.Remote == "" {
return x, false, nil // the node can serve this one itself
}
return x, true, nil
}
}
return stack{}, true, errors.New("no such stack")
} }
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(), 40*time.Second)
defer cancel() defer cancel()
if node := r.PathValue("node"); node != "" {
stacks, err := s.hubStacks(ctx, node)
if err != nil {
s.proxyNode(w, r) // let the node's own answer (or failure) through
return
}
jsonOK(w, 200, map[string]any{"stacks": stacks})
return
}
if dockerTool() == "" { // no Docker is an empty answer, not a failure if dockerTool() == "" { // no Docker is an empty answer, not a failure
jsonOK(w, 200, map[string]any{"stacks": []stack{}}) jsonOK(w, 200, map[string]any{"stacks": []stack{}})
return return
@@ -206,29 +315,41 @@ func readAsRoot(ctx context.Context, path string) ([]byte, error) {
func sumOf(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:8]) } 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. // target 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) { // ?file=env the .env beside the first file.
i, err := strconv.Atoi(r.URL.Query().Get("file")) func target(r *http.Request, st stack) (host string, index int, ok bool) {
if r.URL.Query().Get("file") == "" { q := r.URL.Query().Get("file")
return 0, true switch {
case q == "env":
return st.envPath(), -1, st.HasEnv
case q == "":
return st.HostFiles[0], 0, true
} }
return i, err == nil && i >= 0 && i < len(st.HostFiles) var i int
if _, err := fmt.Sscanf(q, "%d", &i); err != nil || i < 0 || i >= len(st.HostFiles) {
return "", 0, false
}
return st.HostFiles[i], i, true
} }
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(), 40*time.Second)
defer cancel() defer cancel()
st, err := s.stackByName(ctx, r.PathValue("name")) st, handled, err := s.stackFor(ctx, r)
if err != nil || !st.Editable { if !handled {
jsonErr(w, 404, "that stack's compose file isn't on this machine") s.proxyNode(w, r)
return return
} }
i, ok := fileIndex(r, st) if err != nil || !st.Editable {
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
return
}
path, i, ok := target(r, st)
if !ok { if !ok {
jsonErr(w, 400, "no such file in this stack") jsonErr(w, 400, "no such file in this stack")
return return
} }
b, err := readAsRoot(ctx, st.HostFiles[i]) b, err := readAsRoot(ctx, path)
if err != nil { if err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
@@ -237,8 +358,8 @@ func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
jsonOK(w, 200, map[string]any{"stack": st, "text": string(b), "sum": sumOf(b), "index": i}) 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 files make sense and
// files are given as compose will see them: the label's paths inside a container, host paths otherwise. // pointed at the Docker that runs its containers. files are given as compose will see them.
func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd { func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd {
tool := dockerTool() tool := dockerTool()
base := []string{"compose", "-p", st.Name} base := []string{"compose", "-p", st.Name}
@@ -246,10 +367,17 @@ func composeCmd(ctx context.Context, st stack, files []string, args ...string) *
base = append(base, "-f", f) base = append(base, "-f", f)
} }
if st.Via != "" { if st.Via != "" {
return exec.CommandContext(ctx, tool, append(append([]string{"exec", st.Via, "docker"}, base...), args...)...) pre := []string{"exec"}
if st.Remote != "" {
pre = append(pre, "-e", "DOCKER_HOST="+st.Remote)
}
return exec.CommandContext(ctx, tool, append(append(append(pre, st.Via, "docker"), base...), args...)...)
} }
cmd := exec.CommandContext(ctx, tool, append(base, args...)...) cmd := exec.CommandContext(ctx, tool, append(base, args...)...)
cmd.Dir = filepath.Dir(files[0]) cmd.Dir = filepath.Dir(files[0])
if st.Remote != "" {
cmd.Env = append(os.Environ(), "DOCKER_HOST="+st.Remote)
}
return cmd return cmd
} }
@@ -265,72 +393,156 @@ func seenFiles(st stack, i int, candidate string) []string {
return out return out
} }
func (s *Server) writeAsOwner(ctx context.Context, path, text string) error {
err := s.fsLocal.Write(ctx, path, strings.NewReader(text)) // ours to write, or root's
if err != nil {
err = s.fsSudo.Write(ctx, path, strings.NewReader(text))
}
return err
}
// 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) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel() defer cancel()
st, err := s.stackByName(ctx, r.PathValue("name")) st, handled, err := s.stackFor(ctx, r)
if !handled {
s.proxyNode(w, r)
return
}
if err != nil || !st.Editable { if err != nil || !st.Editable {
jsonErr(w, 404, "that stack's compose file isn't on this machine") jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
return return
} }
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 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) == "" { path, i, ok := target(r, st)
jsonErr(w, 400, "the file can't be empty") if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil || !ok || (i >= 0 && strings.TrimSpace(body.Text) == "") {
jsonErr(w, 400, "no such file in this stack, or nothing to write")
return return
} }
i, ok := fileIndex(r, st) if now, err := readAsRoot(ctx, path); err == nil && body.Sum != "" && sumOf(now) != body.Sum {
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.") 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 return
} }
// Validate a candidate beside the real file, so `.env` and relative paths resolve as they will. // A candidate goes beside the real file, so `.env` and relative paths resolve as they will.
cand := target + ".hamlab-new" // A compose file is validated as that candidate; a .env has to be in place to be read at all,
err = s.fsLocal.Write(ctx, cand, strings.NewReader(body.Text)) // ours to write, or root's // so it is swapped in, validated, and swapped back out if compose objects.
if err != nil { cand := path + ".hamlab-new"
err = s.fsSudo.Write(ctx, cand, strings.NewReader(body.Text)) if err := s.writeAsOwner(ctx, cand, body.Text); err != nil {
} jsonErr(w, 500, "couldn't write beside the file: "+err.Error())
if err != nil {
jsonErr(w, 500, "couldn't write beside the compose file: "+err.Error())
return return
} }
inner := cand sudo := func(args ...string) error {
if st.Via != "" { full := priv.Wrap(args...)
inner = st.Files[i] + ".hamlab-new" if msg, err := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil {
return errors.New(strings.TrimSpace(string(msg)))
}
return nil
} }
if msg, err := composeCmd(ctx, st, seenFiles(st, i, inner), "config", "-q").CombinedOutput(); err != nil { if err := sudo("cp", "-p", path, path+".hamlab-bak"); err != nil {
_ = s.fsSudo.Delete(ctx, cand, false) jsonErr(w, 500, err.Error())
return
}
files := seenFiles(st, -1, "")
if i >= 0 {
inner := cand
if st.Via != "" {
inner = st.Files[i] + ".hamlab-new"
}
files = seenFiles(st, i, inner)
} else if err := sudo("mv", cand, path); err != nil {
jsonErr(w, 500, err.Error())
return
}
if msg, err := composeCmd(ctx, st, files, "config", "-q").CombinedOutput(); err != nil {
if i >= 0 {
_ = s.fsSudo.Delete(ctx, cand, false)
} else {
_ = sudo("cp", "-p", path+".hamlab-bak", path) // put the .env back
}
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", target, target + ".hamlab-bak"}, {"mv", cand, target}} { if i >= 0 {
full := priv.Wrap(mv...) if err := sudo("mv", cand, path); err != nil {
if msg, err := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil { jsonErr(w, 500, err.Error())
jsonErr(w, 500, strings.TrimSpace(string(msg)))
return return
} }
} }
log.Printf("stack %s: compose file saved (from %s)", st.Name, clientIP(r)) log.Printf("stack %s: %s saved (from %s)", st.Name, filepath.Base(path), clientIP(r))
w.WriteHeader(204) w.WriteHeader(204)
} }
// stackDiff is what a Deploy would change: each file now, against the copy kept the last
// time this console deployed it (<file>.hamlab-deployed), or else the last saved-over copy.
func (s *Server) stackDiff(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel()
st, handled, err := s.stackFor(ctx, r)
if !handled {
s.proxyNode(w, r)
return
}
if err != nil || !st.Editable {
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
return
}
type fileDiff struct {
Name string `json:"name"`
Base string `json:"base"` // deployed | saved | ""
Old string `json:"old"`
New string `json:"new"`
}
paths := append([]string{}, st.HostFiles...)
if st.HasEnv {
paths = append(paths, st.envPath())
}
out := []fileDiff{}
for _, p := range paths {
now, _ := readAsRoot(ctx, p)
d := fileDiff{Name: filepath.Base(p), New: string(now)}
for _, b := range [][2]string{{".hamlab-deployed", "deployed"}, {".hamlab-bak", "saved"}} {
if old, err := readAsRoot(ctx, p+b[0]); err == nil {
d.Base, d.Old = b[1], string(old)
break
}
}
if filepath.Base(p) == ".env" { // values are secrets: say which keys changed, not what to
d.Old, d.New = maskEnv(d.Old), maskEnv(d.New)
}
out = append(out, d)
}
jsonOK(w, 200, map[string]any{"files": out})
}
// maskEnv keeps a .env's keys and hides its values, tagging each distinct value so a change
// still shows as a change.
func maskEnv(text string) string {
lines := strings.Split(text, "\n")
for i, l := range lines {
if k, v, ok := strings.Cut(l, "="); ok && !strings.HasPrefix(strings.TrimSpace(l), "#") && v != "" {
lines[i] = k + "=•••• (" + sumOf([]byte(v))[:6] + ")"
}
}
return strings.Join(lines, "\n")
}
// stackAction runs deploy (up -d), update (pull, then up -d), restart or stop as a task // stackAction runs deploy (up -d), update (pull, then up -d), restart or stop as a task
// whose output the page follows. // whose output the page follows.
func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) { func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel() defer cancel()
st, err := s.stackByName(ctx, r.PathValue("name")) st, handled, err := s.stackFor(ctx, r)
if !handled {
s.proxyNode(w, r)
return
}
if err != nil || !st.Editable { if err != nil || !st.Editable {
jsonErr(w, 404, "that stack's compose file isn't on this machine") jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
return return
} }
files := seenFiles(st, -1, "") files := seenFiles(st, -1, "")
@@ -339,18 +551,35 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
fl = append(fl, "-f", f) fl = append(fl, "-f", f)
} }
flags := priv.Quote(append([]string{"-p", st.Name}, fl...)) flags := priv.Quote(append([]string{"-p", st.Name}, fl...))
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 %s %s", dockerTool(), priv.Quote([]string{st.Via}), flags, args) env := ""
if st.Remote != "" {
env = "-e " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
}
return fmt.Sprintf("%s exec %s%s docker compose %s %s", dockerTool(), env, priv.Quote([]string{st.Via}), flags, args)
} }
return fmt.Sprintf("cd %s && %s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), dockerTool(), flags, args) env := ""
if st.Remote != "" {
env = priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
}
return fmt.Sprintf("cd %s && %s%s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), env, dockerTool(), flags, args)
} }
// After a deploy that worked, keep a copy of exactly what was deployed: the next diff is against it.
remember := ""
paths := append([]string{}, st.HostFiles...)
if st.HasEnv {
paths = append(paths, st.envPath())
}
for _, p := range paths {
remember += " && " + priv.Quote(priv.Wrap("cp", "-p", p, p+".hamlab-deployed"))
}
var script string
switch r.PathValue("action") { switch r.PathValue("action") {
case "deploy": case "deploy":
script = base("up -d --remove-orphans") script = base("up -d --remove-orphans") + remember
case "update": case "update":
script = base("pull") + " && " + base("up -d --remove-orphans") script = base("pull") + " && " + base("up -d --remove-orphans") + remember
case "restart": case "restart":
script = base("restart") script = base("restart")
case "stop": case "stop":
@@ -368,6 +597,110 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
} }
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
log.Printf("stack %s: %s started as task %s (from %s)", st.Name, r.PathValue("action"), t.ID, clientIP(r)) log.Printf("stack %s: %s started as task %s (from %s)", st.Name, r.PathValue("action"), t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false)) jsonOK(w, 202, t.Snapshot(false))
} }
// ---- is there a newer image? ----
type imageCheck struct {
Image string `json:"image"`
State string `json:"state"` // current | update | unknown
Detail string `json:"detail,omitempty"`
}
type updAnswer struct {
at time.Time
res []imageCheck
}
var (
updMu sync.Mutex
updCache = map[string]updAnswer{}
)
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
// stackUpdates asks each image's registry for the digest its tag points at now and compares
// it with the digest that was pulled. It is asked for, never polled: every answer is a
// registry request, and Docker Hub counts those. Answers are kept for six hours.
func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
st, handled, err := s.stackFor(ctx, r)
if !handled {
s.proxyNode(w, r)
return
}
if err != nil {
jsonErr(w, 404, err.Error())
return
}
key := r.PathValue("node") + "/" + st.Name
updMu.Lock()
c, ok := updCache[key]
updMu.Unlock()
refresh := r.URL.Query().Get("refresh") == "1"
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli()})
return
}
if !refresh {
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
return
}
local := func(args ...string) ([]byte, error) { // the Docker that holds the images
if st.Remote != "" {
args = append([]string{"-H", st.Remote}, args...)
}
return dockerRun(ctx, args...)
}
res := []imageCheck{}
for _, img := range st.Images {
ic := imageCheck{Image: img, State: "unknown"}
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
var have []string
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
ic.Detail = "built or loaded here, not pulled from a registry"
res = append(res, ic)
continue
}
remote, err := dockerRun(ctx, "buildx", "imagetools", "inspect", img, "--format", "{{.Manifest.Digest}}")
digest := strings.TrimSpace(string(remote))
if i := strings.LastIndex(digest, "\n"); i >= 0 {
digest = digest[i+1:]
}
if err != nil || !strings.HasPrefix(digest, "sha256:") {
ic.Detail = "the registry didn't answer"
if err != nil {
ic.Detail += ": " + err.Error()
}
res = append(res, ic)
continue
}
ic.State = "update"
for _, h := range have {
if strings.HasSuffix(h, "@"+digest) {
ic.State = "current"
}
}
res = append(res, ic)
}
updMu.Lock()
updCache[key] = updAnswer{time.Now(), res}
updMu.Unlock()
jsonOK(w, 200, map[string]any{"images": res, "checked": time.Now().UnixMilli()})
}
// taskOnHub serves a task this hub is running for one of its nodes' stacks; anything else
// under /api/nodes/<node>/tasks belongs to the node.
func (s *Server) taskOnHub(local http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := s.tasks.Get(r.PathValue("id")); ok {
local(w, r)
return
}
s.proxyNode(w, r)
}
}
+48 -10
View File
@@ -2,7 +2,7 @@
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory // logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
// come from the same container sensors everything else uses. // come from the same container sensors everything else uses.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, DockerContainer, DockerInfo, Stack } from './api' import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api'
import TaskOutput from './TaskOutput' import TaskOutput from './TaskOutput'
import { Sensor, fmtValue, useSensorStream } from './sensors' import { Sensor, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
@@ -89,27 +89,58 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
) )
} }
// A plain line diff (longest common subsequence): compose files are short, so the simple way is fine.
function lineDiff(a: string, b: string): { s: ' ' | '+' | '-'; t: string }[] {
const x = a.split('\n'), y = b.split('\n'); const n = x.length, m = y.length
const L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1))
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1])
const out: { s: ' ' | '+' | '-'; t: string }[] = []; let i = 0, j = 0
while (i < n && j < m) { if (x[i] === y[j]) { out.push({ s: ' ', t: x[i] }); i++; j++ } else if (L[i + 1][j] >= L[i][j + 1]) out.push({ s: '-', t: x[i++] }); else out.push({ s: '+', t: y[j++] }) }
while (i < n) out.push({ s: '-', t: x[i++] }); while (j < m) out.push({ s: '+', t: y[j++] })
return out
}
// What a Deploy would change: each file against what this console last deployed (or, before
// the first deploy from here, the copy the last save replaced). Only changed lines and a
// little around them; a .env shows which keys changed, never their values.
function DiffView({ files }: { files: StackDiff[] }) {
const changed = files.filter((f) => f.base && f.old !== f.new)
if (files.every((f) => !f.base)) return <div className="muted small">Nothing to compare with yet: this console hasn't deployed or saved this stack before.</div>
if (changed.length === 0) return <div className="muted small">No changes since {files.some((f) => f.base === 'deployed') ? 'the last deploy from here' : 'the last save'}: Deploy would only recreate what Docker itself thinks has changed.</div>
return <>{changed.map((f) => { const d = lineDiff(f.old, f.new); const keep = new Set<number>(); d.forEach((l, i) => { if (l.s !== ' ') for (let k = i - 2; k <= i + 2; k++) keep.add(k) })
return <div key={f.name}><div className="muted small">{f.name} · against {f.base === 'deployed' ? 'what was last deployed from here' : 'the copy the last save replaced'}</div>
<pre className="diff">{d.map((l, i) => (keep.has(i) ? <div key={i} className={l.s === '+' ? 'add' : l.s === '-' ? 'del' : ''}>{l.s} {l.t}</div> : keep.has(i - 1) ? <div key={i} className="muted"> ⋯</div> : null))}</pre></div> })}</>
}
// A .env is mostly secrets: keys shown, values hidden until asked for.
const maskEnv = (text: string) => text.split('\n').map((l) => { const i = l.indexOf('='); return i > 0 && !l.trim().startsWith('#') && l.length > i + 1 ? l.slice(0, i + 1) + '••••••••' : l }).join('\n')
// One stack: its compose file to edit, and the four things to do with it. Saving validates // One stack: its compose file to edit, and the four things to do with it. Saving validates
// with compose itself and keeps the previous file beside it; deploying is a separate, asked-for act. // with compose itself and keeps the previous file beside it; deploying is a separate, asked-for act.
function StackDetail({ name, onBack, onChanged }: { name: string; onBack: () => void; onChanged: () => void }) { function StackDetail({ name, onBack, onChanged }: { name: string; onBack: () => void; onChanged: () => void }) {
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 [file, setFile] = useState(0); const [sum, setSum] = useState('') const [file, setFile] = useState<number | 'env'>(0); const [sum, setSum] = useState('')
const [reveal, setReveal] = useState(false); const [diff, setDiff] = useState<StackDiff[] | null>(null)
const [upd, setUpd] = useState<{ images: ImageCheck[]; checked: number } | null>(null); const [checking, setChecking] = useState(false)
useEffect(() => { setUpd(null); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }, [name])
useEffect(() => { setReveal(false) }, [file, name])
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]) 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, 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 save = async () => { try { await api.saveStack(name, file, text, sum); setOrig(text); setErr(''); setDiff(null); 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_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> <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{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>} {((st.host_files?.length ?? 0) > 1 || st.has_env) && <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>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" /> {file === 'env' && !reveal ? <div className="env-masked"><pre className="editor stack-editor">{maskEnv(text)}</pre><button className="btn" onClick={() => setReveal(true)}>Reveal and edit</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}{/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>} {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>
@@ -120,7 +151,13 @@ function StackDetail({ name, onBack, onChanged }: { name: string; onBack: () =>
<Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn> <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>
<Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn> <Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>
</div> </div>
{task && <TaskOutput id={task} onDone={() => { onChanged(); load() }} />} {task && <TaskOutput id={task} onDone={() => { onChanged(); load(); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }} />}
<div className="stack-extra">
<div className="sub-head"><span className="k">What Deploy would change</span><span className="spacer" /><button className="link" onClick={async () => { try { setDiff((await api.stackDiff(name)).files) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>{diff ? 'refresh' : 'show'}</button></div>
{diff && <DiffView files={diff} />}
<div className="sub-head"><span className="k">Images</span><span className="spacer" /><span className="muted small">{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked'}</span> <Btn className="link" busy={checking} onClick={async () => { setChecking(true); try { setUpd(await api.stackUpdates(name, true)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setChecking(false) } }}>check for updates</Btn></div>
{(st.images ?? []).map((img) => { const c = upd?.images.find((x) => x.image === img); return <div className="img-row" key={img}><span className="mono small">{img}</span><span className="spacer" />{c ? <span className={'badge' + (c.state === 'update' ? ' attn' : '')} title={c.detail}>{c.state === 'update' ? 'update available' : c.state === 'current' ? 'up to date' : 'unknown'}</span> : <span className="muted small">—</span>}</div> })}
</div>
<div className="muted small">{st.containers.join(' · ')}</div> <div className="muted small">{st.containers.join(' · ')}</div>
</div> </div>
) )
@@ -152,7 +189,7 @@ export default function Docker() {
const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`] const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
const cols: Record<Exclude<Tab, 'containers' | 'stacks'>, [string, string][]> = { images: [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']], volumes: [['Name', 'Name'], ['Driver', 'Driver'], ['Mountpoint', 'Mountpoint']], networks: [['Name', 'Name'], ['Driver', 'Driver'], ['Scope', 'Scope']] } const cols: Record<Exclude<Tab, 'containers' | 'stacks'>, [string, string][]> = { images: [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']], volumes: [['Name', 'Name'], ['Driver', 'Driver'], ['Mountpoint', 'Mountpoint']], networks: [['Name', 'Name'], ['Driver', 'Driver'], ['Scope', 'Scope']] }
return ( return (
<div className={'svc docker' + ((cur && shownTab === 'containers') || selStack ? ' has-sel' : '')}> <div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '')}>
<div className="svc-list"> <div className="svc-list">
<div className="svc-tools"> <div className="svc-tools">
<div className="chips">{(['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t} · {t === 'containers' ? `${up}/${d.containers.length}` : t === 'stacks' ? (stacks?.length ?? '…') : d[t].length}</button>)}</div> <div className="chips">{(['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t} · {t === 'containers' ? `${up}/${d.containers.length}` : t === 'stacks' ? (stacks?.length ?? '…') : d[t].length}</button>)}</div>
@@ -162,7 +199,7 @@ export default function Docker() {
{shownTab === 'stacks' && (stacks ?? []).map((st) => ( {shownTab === 'stacks' && (stacks ?? []).map((st) => (
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}> <div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
<span className={'sdot ' + (st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /> <span className={'sdot ' + (st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? st.host_file : st.note}</div></div> <div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div>
<div className="svc-state">{st.running}/{st.containers.length}{!st.editable && <><br />read-only</>}</div> <div className="svc-state">{st.running}/{st.containers.length}{!st.editable && <><br />read-only</>}</div>
</div> </div>
))} ))}
@@ -186,7 +223,8 @@ export default function Docker() {
)} )}
</div> </div>
</div> </div>
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : <div className="svc-empty muted">Select a container.</div>)} {shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
{shownTab === 'stacks' && (selStack ? <StackDetail name={selStack} onBack={() => setSel('')} onChanged={load} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)} {shownTab === 'stacks' && (selStack ? <StackDetail name={selStack} onBack={() => setSel('')} onChanged={load} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
</div> </div>
) )
+7 -3
View File
@@ -103,7 +103,9 @@ 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; files: string[]; host_files: string[] | null; 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; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean }
export interface ImageCheck { image: string; state: 'current' | 'update' | 'unknown'; detail?: string }
export interface StackDiff { name: string; base: '' | 'deployed' | 'saved'; old: string; new: 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 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 +196,10 @@ 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, file = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`), stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`),
saveStack: (name: string, file: number, text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }), stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`),
stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
saveStack: (name: string, file: number | 'env', 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),
+7
View File
@@ -489,3 +489,10 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
.docker:not(.has-sel) .svc-list { flex: 1 1 0; } .docker:not(.has-sel) .svc-list { flex: 1 1 0; }
.stack-editor { flex: 1; min-height: 46vh; font-size: 13px; tab-size: 2; white-space: pre; overflow: auto; } .stack-editor { flex: 1; min-height: 46vh; font-size: 13px; tab-size: 2; white-space: pre; overflow: auto; }
@media (max-width: 800px) { .docker .svc-list { width: 100%; flex: 1 1 auto; } .stack-editor { min-height: 52vh; font-size: 12px; } } @media (max-width: 800px) { .docker .svc-list { width: 100%; flex: 1 1 auto; } .stack-editor { min-height: 52vh; font-size: 12px; } }
/* stacks: diff, images, .env */
.stack-extra { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid var(--border); padding-top: 10px; } .stack-extra .sub-head .k { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
pre.diff { margin: 4px 0 8px; padding: 8px 10px; background: var(--term-bg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 var(--mono); overflow-x: auto; white-space: pre; }
pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, transparent); } pre.diff .del { color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, transparent); }
.img-row { display: flex; align-items: center; gap: 8px; min-width: 0; } .img-row .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .badge.attn { color: var(--s2); border-color: var(--s2); }
.env-masked { position: relative; display: flex; flex-direction: column; flex: 1; } .env-masked pre { margin: 0; min-height: 30vh; opacity: .8; } .env-masked .btn { position: absolute; top: 10px; right: 10px; }