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}`),