diff --git a/PLAN.md b/PLAN.md index 3ba47b8..b54e146 100644 --- a/PLAN.md +++ b/PLAN.md @@ -204,8 +204,18 @@ something long in one, switch away and back, refresh — it's all still there. sensors, start / stop / restart (asked first), logs that follow (polled with `--since`, no held connection), inspect (env *names* only), and images / volumes / networks tables. Works on every machine through the hub; `#/grid/docker/immich_server` links to - one. Creating, removing and pruning are deliberately absent — Dockhand stays the one - place that changes what exists. + one. **Stacks** (`internal/api/stacks.go`): every compose project, found from its + containers' labels, with its file to edit and Deploy (`up -d`), Update (pull, then + deploy), Restart and Stop as followed tasks. The file is looked for where the label says + *and through every container's mounts*, because an orchestrator that runs compose inside + its own container (Dockhand here; Dockge, Portainer) records a path in its own + filesystem; compose is then run **inside that container**, so `.env`, relative paths + 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 + `.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 + read-only, saying so — edit those from the machine that holds the file. Removing + containers, images and volumes is still absent. - **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 type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light; diff --git a/internal/api/server.go b/internal/api/server.go index 198bd33..c944e37 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -129,6 +129,10 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("POST /api/nodes/{name}/sensors/query", s.sensorAuth(s.proxyNodeSensors)) m.HandleFunc("POST /api/tokens/read", s.auth(s.issueReadToken)) m.HandleFunc("GET /api/docker", s.auth(s.dockerOverview)) + m.HandleFunc("GET /api/docker/stacks", s.auth(s.stacksList)) + m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile)) + 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/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}/inspect", s.auth(s.dockerInspect)) diff --git a/internal/api/stacks.go b/internal/api/stacks.go new file mode 100644 index 0000000..39b78c1 --- /dev/null +++ b/internal/api/stacks.go @@ -0,0 +1,287 @@ +package api + +// Stacks: the compose projects behind the containers, with their files to edit and a way to +// redeploy them. +// +// A stack is found from its containers' labels (project, config file). The file may not be +// 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 +// up 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 +// then run where the file makes sense — inside that container when it came from one, so +// relative paths, the .env beside it and the labels all stay exactly as they were deployed — +// else on the host. A stack whose file is on no path this machine can see (deployed from +// another host over tcp) is listed read-only, and says so. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "hamlab/internal/priv" +) + +type stack struct { + Name string `json:"name"` + Containers []string `json:"containers"` + Running int `json:"running"` + File string `json:"file"` // as the label has it + HostFile string `json:"host_file"` // where it is on this machine; "" when it isn't + Via string `json:"via"` // container compose runs inside; "" = on the host + Editable bool `json:"editable"` + Note string `json:"note,omitempty"` + HasEnv bool `json:"has_env"` +} + +type mountMap struct{ container, dest, src string } + +func stackMounts(ctx context.Context) []mountMap { + out, err := dockerRun(ctx, "ps", "-q") + if err != nil || len(bytes.TrimSpace(out)) == 0 { + return nil + } + insp, err := dockerRun(ctx, append([]string{"inspect"}, strings.Fields(string(out))...)...) + if err != nil { + return nil + } + var list []struct { + Name string + Mounts []struct{ Type, Source, Destination string } + } + if json.Unmarshal(insp, &list) != nil { + return nil + } + var maps []mountMap + for _, c := range list { + for _, m := range c.Mounts { + if m.Type == "bind" && m.Destination != "/" && m.Source != "" { + maps = append(maps, mountMap{strings.TrimPrefix(c.Name, "/"), filepath.Clean(m.Destination), filepath.Clean(m.Source)}) + } + } + } + sort.Slice(maps, func(i, j int) bool { return len(maps[i].dest) > len(maps[j].dest) }) // the most specific mount wins + return maps +} + +func readable(p string) bool { + if _, err := os.Stat(p); err == nil { + return true + } + if priv.Available() { // stacks kept by a root-owned orchestrator are often unreadable to us + full := priv.Wrap("test", "-f", p) + return exec.Command(full[0], full[1:]...).Run() == nil + } + return false +} + +func (s *Server) findStacks(ctx context.Context) ([]stack, error) { + out, err := dockerRun(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}") + if err != nil { + return nil, err + } + by := map[string]*stack{} + jsonLines(out, func(m map[string]any) { + labels := dstr(m, "Labels") + name := labelOf(labels, "com.docker.compose.project") + if name == "" { + return + } + st := by[name] + if st == nil { + st = &stack{Name: name, File: strings.Split(labelOf(labels, "com.docker.compose.project.config_files"), ",")[0]} + by[name] = st + } + st.Containers = append(st.Containers, strings.Split(dstr(m, "Names"), ",")[0]) + if dstr(m, "State") == "running" { + st.Running++ + } + }) + maps := stackMounts(ctx) + stacks := []stack{} + for _, st := range by { + sort.Strings(st.Containers) + switch { + case st.File == "": + st.Note = "its containers don't say which file deployed them" + case readable(st.File): + st.HostFile = st.File + default: + for _, m := range maps { + if rest, ok := strings.CutPrefix(st.File, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) { + st.HostFile, st.Via = filepath.Join(m.src, rest), m.container + break + } + } + if st.HostFile == "" { + st.Note = "deployed from somewhere this machine can't see (" + st.File + "); edit it there" + } + } + if st.HostFile != "" { + st.Editable = true + st.HasEnv = readable(filepath.Join(filepath.Dir(st.HostFile), ".env")) + } + stacks = append(stacks, *st) + } + sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name }) + return stacks, nil +} + +func (s *Server) stackByName(ctx context.Context, name string) (stack, error) { + all, err := s.findStacks(ctx) + if err != nil { + return stack{}, err + } + for _, st := range all { + if st.Name == name { + return st, nil + } + } + return stack{}, errors.New("no such stack") +} + +func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + stacks, err := s.findStacks(ctx) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonOK(w, 200, map[string]any{"stacks": stacks}) +} + +func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + st, err := s.stackByName(ctx, r.PathValue("name")) + if err != nil || !st.Editable { + jsonErr(w, 404, "that stack's compose file isn't on this machine") + return + } + b, err := os.ReadFile(st.HostFile) + if err != nil && priv.Available() { + full := priv.Wrap("cat", st.HostFile) + b, err = exec.CommandContext(ctx, full[0], full[1:]...).Output() + } + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonOK(w, 200, map[string]any{"stack": st, "text": string(b)}) +} + +// 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 { + tool := dockerTool() + if st.Via != "" { + return exec.CommandContext(ctx, tool, append([]string{"exec", st.Via, "docker", "compose", "-p", st.Name, "-f", file}, args...)...) + } + cmd := exec.CommandContext(ctx, tool, append([]string{"compose", "-p", st.Name, "-f", file}, args...)...) + cmd.Dir = filepath.Dir(file) + return cmd +} + +// stackSave validates the new text with compose itself, keeps the old file beside it as +// .hamlab-bak, and writes. It does not deploy: that is its own button. +func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + st, err := s.stackByName(ctx, r.PathValue("name")) + if err != nil || !st.Editable { + jsonErr(w, 404, "that stack's compose file isn't on this machine") + return + } + var body struct { + Text string `json:"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") + return + } + // Validate a candidate beside the real file, so `.env` and relative paths resolve as they will. + cand := st.HostFile + ".hamlab-new" + err = s.fsLocal.Write(ctx, cand, strings.NewReader(body.Text)) // ours to write, or root's + if err != nil { + err = s.fsSudo.Write(ctx, cand, strings.NewReader(body.Text)) + } + if err != nil { + jsonErr(w, 500, "couldn't write beside the compose file: "+err.Error()) + return + } + inner := cand + if st.Via != "" { + inner = st.File + ".hamlab-new" + } + if msg, err := composeCmd(ctx, st, inner, "config", "-q").CombinedOutput(); err != nil { + _ = s.fsSudo.Delete(ctx, cand, false) + jsonErr(w, 400, "compose rejects it: "+strings.TrimSpace(string(msg))) + return + } + for _, mv := range [][]string{{"cp", "-p", st.HostFile, st.HostFile + ".hamlab-bak"}, {"mv", cand, st.HostFile}} { + full := priv.Wrap(mv...) + if msg, err := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil { + jsonErr(w, 500, strings.TrimSpace(string(msg))) + return + } + } + log.Printf("stack %s: compose file saved (from %s)", st.Name, clientIP(r)) + w.WriteHeader(204) +} + +// stackAction runs deploy (up -d), update (pull, then up -d), restart or stop as a task +// whose output the page follows. +func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + st, err := s.stackByName(ctx, r.PathValue("name")) + if err != nil || !st.Editable { + jsonErr(w, 404, "that stack's compose file isn't on this machine") + return + } + file := st.HostFile + if st.Via != "" { + file = st.File + } + var script string + base := func(args string) string { + if st.Via != "" { + return fmt.Sprintf("%s exec %s docker compose -p %s -f %s %s", dockerTool(), priv.Quote([]string{st.Via}), priv.Quote([]string{st.Name}), priv.Quote([]string{file}), args) + } + return fmt.Sprintf("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) + } + switch r.PathValue("action") { + case "deploy": + script = base("up -d --remove-orphans") + case "update": + script = base("pull") + " && " + base("up -d --remove-orphans") + case "restart": + script = base("restart") + case "stop": + script = base("stop") + default: + jsonErr(w, 400, "deploy, update, restart or stop") + return + } + if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy { + jsonErr(w, 409, "this stack is already busy (task "+t.ID+")") + return + } + t, err := s.tasks.Start("stack:"+st.Name, r.PathValue("action")+" "+st.Name, exec.Command("sh", "-c", script+" 2>&1")) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + 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)) +} diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index f11b91d..adc8f00 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -2,13 +2,14 @@ // logs that follow, and inspect — then images, volumes and networks. Live CPU and memory // come from the same container sensors everything else uses. import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { api, DockerContainer, DockerInfo } from './api' +import { api, DockerContainer, DockerInfo, Stack } from './api' +import TaskOutput from './TaskOutput' import { Sensor, fmtValue, useSensorStream } from './sensors' import { useHashSel } from './hashsel' import { useRefreshMs } from './prefs' -import { Btn, Loading, toast } from './ui' +import { Btn, Loading, setUnsaved, toast } from './ui' -type Tab = 'containers' | '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 ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ') @@ -88,14 +89,50 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco ) } +// 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. +function StackDetail({ name, onBack, onChanged }: { name: string; onBack: () => void; onChanged: () => void }) { + const [st, setSt] = useState(null) + const [text, setText] = useState(''); const [orig, setOrig] = useState('') + const [err, setErr] = useState(''); const [task, setTask] = useState('') + const load = useCallback(async () => { try { const r = await api.stackFile(name); setSt(r.stack); setText(r.text); setOrig(r.text); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [name]) + useEffect(() => { setSt(null); setTask(''); load() }, [load]) + const dirty = text !== orig + useEffect(() => { setUnsaved(dirty ? `${name}'s compose file` : ''); return () => setUnsaved('') }, [dirty, name]) + const save = async () => { try { await api.saveStack(name, text); setOrig(text); setErr(''); toast('saved — the running containers are unchanged until you deploy') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } + const run = (a: 'deploy' | 'update' | 'restart' | 'stop') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } + if (err && !st) return
{err}
+ if (!st) return
+ return ( +
+
{st.name}{st.running}/{st.containers.length} running
+
{st.host_file}{st.via && · compose runs inside {st.via}, as it was deployed}{st.has_env && ' · has a .env beside it'}
+