From 09dc8467306239db186daf32265631eb939aab16 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 25 Sep 2026 21:17:37 -0700 Subject: [PATCH] docker: logs follow a held connection, and networks can be removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps the Docker tab had against Dockhand and against its own Services tab next door. Logs: one `docker logs -f` per viewer over a WebSocket, the shape the Services tab's journal tail already uses, instead of asking again every two seconds. stdout and stderr both, with stderr marked so the page can colour what went wrong. Docker replays the backlog down two pipes in no particular order, so the opening 300 ms is held and sorted by the stamp Docker wrote; after that the log is live and in order. A reconnect resumes from the last line rather than from the top — `--since` is inclusive and two lines can share a nanosecond, so the client drops what it has already shown by line, not by "later than". A container that has stopped ends the stream at once, so reconnects back off to 15 s. Networks: remove and prune, which images and volumes have had all along. The overview now says which containers are attached to each network, so the ✕ appears only on one nothing uses, and never on bridge, host or none. `network rm` prints the real reason and then an aggregate "exit status 1", so the message shown is the first line, not the last. Prune is not exercised against `core`: several networks here belong to stacks that are merely down, and pruning would give them new subnets on their next deploy. Co-Authored-By: Claude Opus 5 --- PLAN.md | 24 ++++- internal/api/docker.go | 93 ++++++++++++++-- internal/api/docker_logs.go | 206 ++++++++++++++++++++++++++++++++++++ internal/api/server.go | 3 + web/src/Docker.tsx | 94 ++++++++++++---- web/src/api.ts | 5 +- 6 files changed, 386 insertions(+), 39 deletions(-) create mode 100644 internal/api/docker_logs.go diff --git a/PLAN.md b/PLAN.md index c57308c..37209c4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -595,11 +595,25 @@ something long in one, switch away and back, refresh — it's all still there. pulls it. Its `git_repositories` and `git_stacks` tables are **empty**, so nothing here uses it — dropping it is the honest answer unless Mitchell says otherwise. - *Webhooks, OIDC, LDAP, vulnerability scans.* All four tables empty. Nothing to move. - - **Known gaps in the tab itself**, worth closing whether or not Dockhand goes: - - *Removing networks* — volumes and images can be pruned and removed, networks cannot. - - *Logs are polled with `--since` rather than followed over a held connection.* That was - deliberate and works, but a long tail is choppier than the Services tab's live tail, - which does hold a connection. Worth reconciling the two. + + **Done 2026-09-25 — the two gaps in the tab itself.** + - *Logs are followed over a held connection*, the way the Services tab follows a journal + (`internal/api/docker_logs.go`, `/api/docker/containers/{id}/logs/tail`): one + `docker logs -f` per viewer, stdout and stderr both, stderr coloured as an error. A + reconnect resumes from the last line's timestamp, so a container that restarts or a + phone that slept doesn't replay what is already on screen; the client dedupes by line + rather than by "later than", because `--since` is inclusive and two lines can share a + timestamp. Docker replays the opening backlog down two pipes in no particular order, so + the first 300 ms is held and sorted before it is sent. The polled `?since=` endpoint + stays — it is what the resume is built on. Reconnects back off to 15 s, since a + container that has stopped ends the stream immediately. + - *Networks can be removed and pruned* — `POST /api/docker/networks/{remove,prune}`, the + shape images and volumes already had. The overview says who is attached to each one, so + the ✕ is only offered on a network nothing uses and never on Docker's own three. Prune + is deliberately untested against this host: `core` has several networks belonging to + stacks that are merely down (`pihole_default`, `transmission_default`, `gluetun_default` + among them), and pruning would take them and hand them new subnets on the next deploy. + - **The `*:2375` question, which this is the moment to settle.** A stack deployed to `grid` or the Mac still goes over `tcp://:2375` from the hub. Decide whether the hub should instead ask the node's own Ham Lab to deploy locally, which would end the diff --git a/internal/api/docker.go b/internal/api/docker.go index 47886b5..d88fb08 100644 --- a/internal/api/docker.go +++ b/internal/api/docker.go @@ -3,8 +3,8 @@ package api // The Docker tab: what is running, grouped the way it was deployed (compose stacks), with // start / stop / restart, logs and inspect — plus images, volumes and networks to look at. // It drives the docker CLI (or podman), as the user first and as root if that is refused. -// Creating, removing and pruning are deliberately absent: whatever deployed the stacks -// (Dockhand, compose) stays the one place that changes what exists. +// What can be removed here is what nothing uses: an untagged image, a volume no container +// holds, a network nothing is attached to. Compose stays the one place a stack is defined. import ( "context" @@ -76,6 +76,18 @@ func dockerRun(ctx context.Context, args ...string) ([]byte, error) { return out, nil } +// dockerWhy picks the line worth showing out of a failed command. Some subcommands — +// `network rm` among them — print the real reason first and then an aggregated "exit +// status 1", which is what dockerRun's last-line rule would otherwise hand back. +func dockerWhy(out []byte, err error) string { + for _, l := range strings.Split(string(out), "\n") { + if l = strings.TrimSpace(l); strings.HasPrefix(l, "Error") || strings.Contains(l, "daemon") { + return l + } + } + return err.Error() +} + func jsonLines(out []byte, each func(map[string]any)) { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { var m map[string]any @@ -139,23 +151,30 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) { } return rows } - // Who uses what, from one inspect of every container: an image or a volume nothing uses is - // the only kind this page offers to remove. - imageUsed, volumeUsers := map[string]bool{}, map[string][]string{} + // Who uses what, from one inspect of every container: an image, a volume or a network + // nothing uses is the only kind this page offers to remove. + imageUsed := map[string]bool{} + volumeUsers, networkUsers := map[string][]string{}, map[string][]string{} if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 { - const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}` + const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}|{{range $k, $v := .NetworkSettings.Networks}}{{$k}},{{end}}` if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - p := strings.SplitN(line, "|", 3) - if len(p) < 3 { + p := strings.SplitN(line, "|", 4) + if len(p) < 4 { continue } + name := strings.TrimPrefix(p[0], "/") if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 { imageUsed[id[:12]] = true } for _, v := range strings.Split(p[2], ",") { if v != "" { - volumeUsers[v] = append(volumeUsers[v], strings.TrimPrefix(p[0], "/")) + volumeUsers[v] = append(volumeUsers[v], name) + } + } + for _, n := range strings.Split(p[3], ",") { + if n != "" { + networkUsers[n] = append(networkUsers[n], name) } } } @@ -175,8 +194,18 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) { v["used_by"] = users v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous") } + networks := list("network", "ls", "--format", "{{json .}}") + for _, n := range networks { + users := networkUsers[dstr(n, "Name")] + if users == nil { + users = []string{} + } + sort.Strings(users) + n["used_by"] = users + n["predefined"] = predefinedNetwork[dstr(n, "Name")] + } jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers, - "images": images, "volumes": volumes, "networks": list("network", "ls", "--format", "{{json .}}")}) + "images": images, "volumes": volumes, "networks": networks}) } var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`) @@ -399,6 +428,50 @@ func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) { jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]}) } +// The three networks Docker makes itself. They hold nothing, cannot be removed, and +// their absence would break every container that asks for one by name. +var predefinedNetwork = map[string]bool{"bridge": true, "host": true, "none": true} + +// networkRemove deletes one network. Unlike a volume there is no data in it, and unlike an +// image nothing has to be downloaded again — a stack that wants it back gets it recreated +// on the next deploy. Docker refuses one a container is still attached to, which is the +// check that matters, so this asks for no second confirmation. +func (s *Server) networkRemove(w http.ResponseWriter, r *http.Request) { + var body struct{ Name string } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) { + jsonErr(w, 400, "which network?") + return + } + if predefinedNetwork[body.Name] { + jsonErr(w, 400, body.Name+" is one of Docker's own networks and can't be removed") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) + defer cancel() + if out, err := dockerRun(ctx, "network", "rm", body.Name); err != nil { + jsonErr(w, 409, dockerWhy(out, err)) + return + } + log.Printf("docker: network rm %s (from %s)", body.Name, clientIP(r)) + w.WriteHeader(204) +} + +// networkPrune removes every network no container is attached to. A stack that is merely +// down counts as unattached, so its network goes too — and comes back on the next deploy, +// with a new subnet. That is the whole cost, and the caller is told it. +func (s *Server) networkPrune(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + out, err := dockerRun(ctx, "network", "prune", "-f") + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + log.Printf("docker: network prune (from %s)", clientIP(r)) + jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]}) +} + // ---- a container's health, its recent life, its console, and its limits ---- // dockerHealth is the health check's verdict and the last few probe results. Docker keeps diff --git a/internal/api/docker_logs.go b/internal/api/docker_logs.go new file mode 100644 index 0000000..a19f24c --- /dev/null +++ b/internal/api/docker_logs.go @@ -0,0 +1,206 @@ +package api + +// Following a container's log over a held connection, the way the Services tab follows a +// journal. The polled `?since=` endpoint next door still exists — it is what a reconnect +// hands back to pick up from — but the live view holds one `docker logs -f` per viewer +// instead of asking again every two seconds. + +import ( + "bufio" + "context" + "encoding/json" + "io" + "net/http" + "os/exec" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "github.com/coder/websocket" + + "hamlab/internal/priv" +) + +// logLine is one line of a container's output: the time Docker stamped it with, the text, +// and which of the two streams it came out of — stderr is where a container says what went +// wrong, and the page colours it accordingly. +type logLine struct { + T string `json:"t"` + M string `json:"m"` + Err bool `json:"err,omitempty"` + at string // when to sort it by: its own stamp, or the last one its stream carried +} + +// splitStamp pulls Docker's RFC 3339 prefix off a line. A line without one (a container +// that writes bare bytes, or a continuation) keeps the whole text and no time — but is +// still sorted where its stream put it, behind the last line that did have one. +func splitStamp(l string, carry *string) logLine { + t, m, ok := strings.Cut(l, " ") + if _, err := time.Parse(time.RFC3339Nano, t); !ok || err != nil { + return logLine{M: l, at: *carry} + } + *carry = t + return logLine{T: t, M: m, at: t} +} + +// dockerTail streams a container's output as JSON text frames until the client goes away. +// `?since=` resumes where a dropped connection left off; otherwise it starts with +// the last `?n=` lines. +func (s *Server) dockerTail(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !dockerRef.MatchString(id) { + jsonErr(w, 400, "bad container") + return + } + tool := dockerTool() + if tool == "" { + jsonErr(w, 500, "neither docker nor podman is installed here") + return + } + args := []string{tool, "logs", "--timestamps", "--follow"} + if since := r.URL.Query().Get("since"); since != "" { + if _, err := time.Parse(time.RFC3339Nano, since); err != nil { + jsonErr(w, 400, "since is an RFC 3339 time") + return + } + args = append(args, "--since", since) + } else { + args = append(args, "--tail", strconv.Itoa(logCount(r))) + } + args = append(args, id) + if priv.Available() && !dockerReachable() { + args = priv.Wrap(args...) + } + + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled}) + if err != nil { + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + // Own process group, so the kill reaches the docker client itself and not just a sudo + // wrapper — the same reason the journal tail does it. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + pg := "-" + strconv.Itoa(cmd.Process.Pid) + if args[0] != tool { // running under sudo: only sudo can signal root's children + k := priv.Wrap("kill", "-9", "--", pg) + return exec.Command(k[0], k[1:]...).Run() + } + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return + } + stderr, err := cmd.StderrPipe() + if err != nil { + return + } + if err := cmd.Start(); err != nil { + _ = conn.Write(ctx, websocket.MessageText, mustJSON(logLine{M: err.Error(), Err: true})) + return + } + + // Both pipes funnel into one writer: a websocket takes one writer at a time. + lines := make(chan logLine, 256) + read := func(p io.Reader, isErr bool) { + sc := bufio.NewScanner(p) + sc.Buffer(make([]byte, 0, 64<<10), 4<<20) + carry := "" + for sc.Scan() { + l := splitStamp(sc.Text(), &carry) + l.Err = isErr + select { + case lines <- l: + case <-ctx.Done(): + return + } + } + } + done := make(chan struct{}, 2) + go func() { read(stdout, false); done <- struct{}{} }() + go func() { read(stderr, true); done <- struct{}{} }() + go func() { // the command ends when the container does; closing says so, having said why + <-done + <-done + _ = cmd.Wait() + close(lines) + }() + + go func() { // reader: only to notice the close + defer cancel() + for { + if _, _, err := conn.Read(ctx); err != nil { + return + } + } + }() + + ping := time.NewTicker(25 * time.Second) + defer ping.Stop() + // stdout and stderr are two pipes, and what Docker replays down them arrives in no + // particular order. So the opening burst is held for a moment and sorted by the time + // Docker stamped each line with; after that the log is live and arrives in order anyway. + const burstFor = 300 * time.Millisecond + warm := time.NewTimer(burstFor) + defer warm.Stop() + warmC := warm.C // nil once the burst has been flushed: a nil channel never fires + var burst []logLine + write := func(l logLine) bool { + return conn.Write(ctx, websocket.MessageText, mustJSON(l)) == nil + } + flush := func() bool { + sort.SliceStable(burst, func(i, j int) bool { return burst[i].at < burst[j].at }) + for _, l := range burst { + if !write(l) { + return false + } + } + burst = nil + return true + } + for { + select { + case <-ctx.Done(): + return + case l, ok := <-lines: + if !ok { // the container stopped, or never existed: say the last of it and go + flush() + return + } + if warmC != nil { + burst = append(burst, l) + if len(burst) < 10000 { // a container that never stops talking still gets read + continue + } + if !flush() { + return + } + continue + } + if !write(l) { + return + } + case <-warmC: + warmC = nil + if !flush() { + return + } + case <-ping.C: + pctx, pc := context.WithTimeout(ctx, 10*time.Second) + err := conn.Ping(pctx) + pc() + if err != nil { + return + } + } + } +} + +func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b } diff --git a/internal/api/server.go b/internal/api/server.go index d7db17c..11e0c3c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -187,6 +187,8 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune)) m.HandleFunc("POST /api/docker/volumes/remove", s.auth(s.volumeRemove)) m.HandleFunc("POST /api/docker/volumes/prune", s.auth(s.volumePrune)) + m.HandleFunc("POST /api/docker/networks/remove", s.auth(s.networkRemove)) + m.HandleFunc("POST /api/docker/networks/prune", s.auth(s.networkPrune)) 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)) @@ -213,6 +215,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me 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("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs)) + m.HandleFunc("GET /api/docker/containers/{id}/logs/tail", s.auth(s.dockerTail)) m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect)) m.HandleFunc("GET /api/docker/containers/{id}/health", s.auth(s.dockerHealth)) m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents)) diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index 0acd191..fdacdc8 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -2,7 +2,7 @@ // logs that follow, and inspect — then images, volumes and networks. Live CPU and memory // come from the same container sensors everything else uses. import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow } from './api' +import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, DockerLogLine, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow, wsURLPath } from './api' import TaskOutput from './TaskOutput' import StackNew, { DupFrom } from './StackNew' import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors' @@ -16,36 +16,77 @@ type Tab = 'containers' | 'stacks' | 'updates' | 'images' | 'volumes' | 'network 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(' ') +// A container's output, followed over a held connection the way the Services tab follows a +// journal. A reconnect resumes from the last line's timestamp, so a container that restarts +// — or a phone that slept — doesn't replay what is already on screen. function LogView({ id }: { id: string }) { - const [lines, setLines] = useState<{ t: string; m: string }[]>([]) + const [lines, setLines] = useState([]) const [err, setErr] = useState('') + const [live, setLive] = useState(false) const [follow, setFollow] = useState(true) + const [paused, setPaused] = useState(false) const box = useRef(null) const last = useRef('') + const pausedRef = useRef(false) + pausedRef.current = paused + const pending = useRef([]) + // What a resume has already shown: `--since` is inclusive, and two lines can share a + // timestamp (a container writing to both streams at once), so the boundary is a set of + // lines rather than a moment. Bounded — only the lines around a reconnect can repeat. + const seen = useRef<{ set: Set; q: string[] }>({ set: new Set(), q: [] }) useEffect(() => { - let stop = false; setLines([]); last.current = '' - const tick = async () => { - if (stop) return - if (document.visibilityState === 'visible') { - try { - const r = await api.dockerLogs(id, last.current) - const fresh = last.current ? r.lines.filter((l) => l.t > last.current) : r.lines - if (fresh.length) { last.current = fresh[fresh.length - 1].t || last.current; setLines((old) => [...old, ...fresh].slice(-2000)) } - setErr('') - } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + setLines([]); setErr(''); setLive(false); last.current = ''; pending.current = [] + seen.current = { set: new Set(), q: [] } + let closed = false + let sock: WebSocket | null = null + let retry: number | undefined + let attempt = 0 + const connect = () => { + if (closed) return + const q = last.current ? `since=${encodeURIComponent(last.current)}` : 'n=300' + // fixed when the log opens: a reconnect must not follow the console to another machine + const s = new WebSocket(wsURLPath(`/api/docker/containers/${encodeURIComponent(id)}/logs/tail?${q}`)) + sock = s + s.onopen = () => { attempt = 0; setLive(true); setErr('') } + s.onmessage = (ev) => { + const l = JSON.parse(ev.data) as DockerLogLine + if (l.t) { + const k = l.t + '|' + (l.err ? 'e' : 'o') + '|' + l.m + const { set, q } = seen.current + if (set.has(k)) return + set.add(k); q.push(k) + if (q.length > 1000) for (const old of q.splice(0, 500)) set.delete(old) + if (l.t > last.current) last.current = l.t + } + if (pausedRef.current) { pending.current.push(l); return } + setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), l] : [...prev, l])) } - window.setTimeout(tick, 2000) + // a container that has stopped — or was never there — ends the stream at once, so + // back off rather than asking Docker the same question twice a second forever + s.onclose = () => { if (closed) return; setLive(false); retry = window.setTimeout(connect, Math.min(15000, 1000 * 2 ** attempt++)) } + s.onerror = () => { setErr('log stream disconnected'); s.close() } } - tick() - return () => { stop = true } + connect() + return () => { closed = true; clearTimeout(retry); sock?.close() } }, [id]) - useEffect(() => { if (follow && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow]) + // Resume: flush what arrived while paused. + useEffect(() => { + if (paused || pending.current.length === 0) return + const p = pending.current; pending.current = [] + setLines((prev) => [...prev, ...p].slice(-2000)) + }, [paused]) + useEffect(() => { if (follow && !paused && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow, paused]) return (
-
{lines.length} lines
- {err &&
{err}
} +
+ {live ? 'following' : 'reconnecting…'} · {lines.length} lines{err && ` · ${err}`} + + + + +
{ const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}> - {lines.map((l, i) =>
{l.t ? new Date(l.t).toLocaleTimeString() : ''}{l.m}
)} + {lines.map((l, i) =>
{l.t ? new Date(l.t).toLocaleTimeString() : ''}{l.m}
)} {lines.length === 0 && !err &&
No output yet.
}
@@ -381,7 +422,7 @@ export default function Docker() { const shownTab: Tab = selStack ? 'stacks' : tab const up = d.containers.filter((c) => c.state === 'running').length const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`] - const cols: Record, [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 imageCols: [string, string][] = [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']] return (
@@ -395,6 +436,7 @@ export default function Docker() { { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune } {shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return
Deleting a volume deletes its data. Only one nothing uses can be deleted.{n > 0 && { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}}
})()} + {shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return
A network holds no data. Only one nothing is attached to can be removed.{n > 0 && { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}}
})()} {shownTab === 'images' && pullTask && load()} />} {shownTab === 'containers' && setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
@@ -431,9 +473,15 @@ export default function Docker() { return {anon ? <>{name.slice(0, 12)}… anonymous : name}{users.length ? users.join(', ') : nothing}{users.length ? in use : } })} {d.volumes.length === 0 && No volumes.} )} - {shownTab !== 'containers' && shownTab !== 'stacks' && shownTab !== 'volumes' && shownTab !== 'updates' && ( - {cols[shownTab as Exclude].map(([, h]) => )}{shownTab === 'images' && - {d[shownTab as Exclude].map((row, i) => {cols[shownTab as Exclude].map(([k]) => )}{shownTab === 'images' && })}
{h}}
{String(row[k] ?? '')}{row.in_use ? in use : { const ref = row.Tag && row.Tag !== '' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕}
+ {shownTab === 'networks' && ( + + {d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined + return })} + {d.networks.length === 0 && }
NetworkDriverUsed by
{name}{own && · Docker's own}{String(row.Driver ?? '')}{users.length ? users.join(', ') : nothing}{users.length ? in use : own ? null : { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕}
No networks.
+ )} + {shownTab === 'images' && ( + {imageCols.map(([, h]) => )} + {d.images.map((row, i) => {imageCols.map(([k]) => )})}
{h}
{String(row[k] ?? '')}{row.in_use ? in use : { const ref = row.Tag && row.Tag !== '' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕}
)}
diff --git a/web/src/api.ts b/web/src/api.ts index 1855078..7fa8c10 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -117,6 +117,7 @@ export interface DockerHealthProbe { Start: string; End: string; ExitCode: numbe export interface DockerHealth { configured: boolean; state: string; status?: string; failing_streak?: number; log?: DockerHealthProbe[] } export interface DockerEvent { t: number; name: string; action: string; image?: string; exit_code?: string; signal?: string; health?: string } +export interface DockerLogLine { t: string; m: string; err?: boolean } export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record[]; volumes: Record[]; networks: Record[] } 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 StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean } @@ -254,7 +255,7 @@ export const api = { createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true), docker: () => req('GET', '/api/docker'), dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`), - 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: DockerLogLine[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`), dockerInspect: (id: string) => req>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`), machineUpdates: (refresh = false) => req('GET', `/api/docker/updates${refresh ? '?refresh=1' : ''}`), updateJobs: () => req<{ jobs: UpdateJobRow[]; file: string }>('GET', '/api/docker/schedules'), @@ -274,6 +275,8 @@ export const api = { pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }), removeVolume: (name: string) => req('POST', '/api/docker/volumes/remove', { name, confirm: name }), pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'), + removeNetwork: (name: string) => req('POST', '/api/docker/networks/remove', { name }), + pruneNetworks: () => req<{ result: string }>('POST', '/api/docker/networks/prune'), stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`), 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}`),