From b3e1c495f01f98973a019760de1f21d9a7e994c1 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Thu, 24 Sep 2026 19:52:00 -0700 Subject: [PATCH] Docker: a container's stats, health, events, shell and limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five things the tab could not do, all on the container detail. Stats draws CPU and memory over time from the container sensors the sampler already keeps, so there is nothing new to collect. Health shows the check's verdict, the failing streak and what the last five probes actually printed, and says plainly when an image defines no health check at all rather than leaving a blank panel. Events asks Docker what has happened to the container over the last day, week or month — started, died, killed, health going bad, with exit codes — rather than keeping a copy that would only go stale. Shell opens a session inside the container as an ordinary terminal, so it reattaches, resizes and scrolls back like the rest and works on a managed machine through the same proxy. The terminal manager grew a CreateCmd for it; every session is the same thing afterwards. Limits changes memory, CPUs and restart policy on the running container with docker update, live and without recreating it. That is also the catch, and the dialog says so: it does not edit the compose file, so a deploy puts back whatever the file says. Co-Authored-By: Claude Opus 5 --- internal/api/docker.go | 217 +++++++++++++++++++++++++++++++++++++++ internal/api/server.go | 4 + internal/term/manager.go | 11 ++ web/src/Docker.tsx | 186 +++++++++++++++++++++++++++++++-- web/src/Shell.tsx | 14 +++ web/src/api.ts | 8 ++ 6 files changed, 429 insertions(+), 11 deletions(-) diff --git a/internal/api/docker.go b/internal/api/docker.go index e384eca..0ca5a66 100644 --- a/internal/api/docker.go +++ b/internal/api/docker.go @@ -394,3 +394,220 @@ func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) { log.Printf("docker: volume 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 +// the last five itself, which is all anyone wants: is it failing, and what did it say. +func (s *Server) dockerHealth(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !dockerRef.MatchString(id) { + jsonErr(w, 400, "bad container") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + out, err := dockerRun(ctx, "inspect", "--format", "{{json .State}}", id) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + var st struct { + Status string `json:"Status"` + Health *struct { + Status string `json:"Status"` + FailingStreak int `json:"FailingStreak"` + Log []struct { + Start time.Time `json:"Start"` + End time.Time `json:"End"` + ExitCode int `json:"ExitCode"` + Output string `json:"Output"` + } `json:"Log"` + } `json:"Health"` + } + if json.Unmarshal(out, &st) != nil { + jsonErr(w, 500, "could not read the container's state") + return + } + if st.Health == nil { + jsonOK(w, 200, map[string]any{"configured": false, "state": st.Status}) + return + } + jsonOK(w, 200, map[string]any{ + "configured": true, "state": st.Status, "status": st.Health.Status, + "failing_streak": st.Health.FailingStreak, "log": st.Health.Log, + }) +} + +// dockerEvents is what has happened to one container lately — started, died, killed, the +// health checks going bad. Asked of Docker on demand rather than kept: Docker already has +// the history, and a copy of it would only go stale. +func (s *Server) dockerEvents(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !dockerRef.MatchString(id) { + jsonErr(w, 400, "bad container") + return + } + since := r.URL.Query().Get("since") + if !regexp.MustCompile(`^[0-9]+[mhd]$`).MatchString(since) { + since = "7d" + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + out, err := dockerRun(ctx, "events", "--since", since, "--until", "0s", + "--filter", "container="+id, "--format", "{{json .}}") + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + events := []map[string]any{} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if !strings.HasPrefix(line, "{") { + continue + } + var e struct { + Action string `json:"Action"` + Time int64 `json:"time"` + TimeNano int64 `json:"timeNano"` + Actor map[string]any `json:"Actor"` + } + if json.Unmarshal([]byte(line), &e) != nil { + continue + } + m := map[string]any{"action": e.Action, "t": e.Time * 1000} + if e.TimeNano > 0 { + m["t"] = e.TimeNano / 1e6 + } + if at, ok := e.Actor["Attributes"].(map[string]any); ok { + // the interesting ones: why it stopped, and what the health check said + for _, k := range []string{"exitCode", "signal", "name", "image"} { + if v, ok := at[k]; ok && v != "" { + m[k] = v + } + } + } + events = append(events, m) + } + // newest first, and bounded: a flapping container can produce thousands + for i, j := 0, len(events)-1; i < j; i, j = i+1, j-1 { + events[i], events[j] = events[j], events[i] + } + if len(events) > 400 { + events = events[:400] + } + jsonOK(w, 200, map[string]any{"events": events, "since": since}) +} + +// dockerExec opens a shell inside a container, as a terminal session like any other — so +// it reattaches, resizes and scrolls back the same way, and works on a managed machine +// through the same proxy. +func (s *Server) dockerExec(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 + } + var body struct { + Shell string `json:"shell"` + } + _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body) + shell := strings.TrimSpace(body.Shell) + if shell == "" { + // most images have sh; bash is nicer where it exists, so try it first and fall back + shell = "if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi" + } else if strings.ContainsAny(shell, "\n\r") { + jsonErr(w, 400, "bad shell") + return + } + argv := []string{tool, "exec", "-it", id, "sh", "-c", shell} + if priv.Available() && !dockerReachable() { + argv = priv.Wrap(argv...) + } + sess, err := s.mgr.CreateCmd("", "sh "+shortName(id), argv) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + log.Printf("docker exec into %s as terminal %s by %s", id, sess.ID, clientIP(r)) + jsonOK(w, 200, map[string]string{"id": sess.ID, "name": "sh " + shortName(id)}) +} + +func shortName(id string) string { + if len(id) > 20 { + return id[:20] + } + return id +} + +// dockerReachable reports whether docker answers without help. +func dockerReachable() bool { + tool := dockerTool() + if tool == "" { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return exec.CommandContext(ctx, tool, "version", "--format", "{{.Server.Version}}").Run() == nil +} + +// dockerUpdate changes a running container's limits and restart policy. `docker update` +// does this live, without recreating it and without touching the compose file — which is +// also the catch, and the caller is told: a deploy from compose puts back whatever compose +// says. +func (s *Server) dockerUpdate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !dockerRef.MatchString(id) { + jsonErr(w, 400, "bad container") + return + } + var body struct { + Memory string `json:"memory"` // "512m", "2g", or "" to leave alone; "0" removes the limit + CPUs string `json:"cpus"` // "1.5", or "0" for no limit + Restart string `json:"restart"` // no | on-failure | unless-stopped | always + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { + jsonErr(w, 400, "bad request") + return + } + args := []string{"update"} + if m := strings.TrimSpace(body.Memory); m != "" { + if !regexp.MustCompile(`^[0-9]+([bkmgBKMG])?$`).MatchString(m) { + jsonErr(w, 400, "memory: a number, optionally with b, k, m or g — 0 removes the limit") + return + } + args = append(args, "--memory", m, "--memory-swap", m) + } + if c := strings.TrimSpace(body.CPUs); c != "" { + if !regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`).MatchString(c) { + jsonErr(w, 400, "cpus: a number like 1.5 — 0 removes the limit") + return + } + args = append(args, "--cpus", c) + } + if p := strings.TrimSpace(body.Restart); p != "" { + switch p { + case "no", "on-failure", "unless-stopped", "always": + default: + jsonErr(w, 400, "restart: no, on-failure, unless-stopped or always") + return + } + args = append(args, "--restart", p) + } + if len(args) == 1 { + jsonErr(w, 400, "nothing to change") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + if _, err := dockerRun(ctx, append(args, id)...); err != nil { + jsonErr(w, 500, err.Error()) + return + } + log.Printf("docker update %s %v by %s", id, args[1:], clientIP(r)) + w.WriteHeader(204) +} diff --git a/internal/api/server.go b/internal/api/server.go index 26ef695..79ef643 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -206,6 +206,10 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me 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)) + m.HandleFunc("GET /api/docker/containers/{id}/health", s.auth(s.dockerHealth)) + m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents)) + m.HandleFunc("POST /api/docker/containers/{id}/exec", s.auth(s.dockerExec)) + m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate)) m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet)) m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut)) m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest)) diff --git a/internal/term/manager.go b/internal/term/manager.go index e23a79e..07d3b20 100644 --- a/internal/term/manager.go +++ b/internal/term/manager.go @@ -84,10 +84,21 @@ func newID() string { // Create starts a new login shell. id may be empty. func (m *Manager) Create(id, name string) (*Session, error) { + return m.CreateCmd(id, name, nil) +} + +// CreateCmd is Create with something other than a login shell: argv nil is the shell, and +// anything else runs that instead — `docker exec -it …` for a container's console. Every +// session is the same thing afterwards, so attach, resize, the scrollback ring and the tab +// strip work without knowing which it is. +func (m *Manager) CreateCmd(id, name string, argv []string) (*Session, error) { if id == "" { id = newID() } cmd := exec.Command(m.shell, "-l") + if len(argv) > 0 { + cmd = exec.Command(argv[0], argv[1:]...) + } cmd.Dir = m.home cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLAB=1") ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow}) diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index c6ef13c..bbf6dcb 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -1,14 +1,16 @@ // Docker: containers grouped by the stack that deployed them, with start / stop / restart, // 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, ImageCheck, Stack, StackDiff } from './api' +import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, ImageCheck, Stack, StackDiff } from './api' import TaskOutput from './TaskOutput' import StackNew, { DupFrom } from './StackNew' -import { Sensor, fmtValue, useSensorStream } from './sensors' +import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors' import { useHashSel } from './hashsel' -import { useRefreshMs } from './prefs' -import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast } from './ui' +import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs' +import Sparkline from './Sparkline' +import { fmtBytes } from './fmt' +import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast, useEscape } from './ui' 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') @@ -50,18 +52,26 @@ function LogView({ id }: { id: string }) { ) } +// What a container is doing, and what can be done to it: its numbers over time, its health +// checks, what has happened to it lately, a shell inside it, and its limits. function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record; onBack: () => void; onChanged: () => void }) { - const [tab, setTab] = useState<'logs' | 'inspect'>('logs') + const [tab, setTab] = useState<'logs' | 'stats' | 'health' | 'events' | 'inspect'>('logs') const [info, setInfo] = useState | null>(null) - useEffect(() => { setInfo(null); if (tab === 'inspect') api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, c.name]) + const [limits, setLimits] = useState(false) + useEffect(() => { setInfo(null); if (tab === 'inspect' || limits) api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, limits, c.name]) const act = (a: 'start' | 'stop' | 'restart' | 'remove') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } } const running = c.state === 'running' - const s = (k: string) => live[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`] + const seg = c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '') + const s = (k: string) => live[`container.${seg}.${k}`] const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? [] + const shell = async () => { + try { const t = await api.dockerExec(c.name); toast(`shell open as “${t.name}” on the Terminal tab`); window.dispatchEvent(new CustomEvent('hamlab:terminal', { detail: t.id })) } + catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } return (
-
{c.name}
+
{c.name}{c.health && c.health !== 'none' && {c.health}}
Image{c.image}
Status{c.status}
@@ -72,11 +82,22 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
{!running && Start} {!running && { await act('remove')(); onBack() }}>Remove} + {running && Shell} {running && Restart} + setLimits(true)}>Limits {running && Stop}
-
+
+ + + + + +
{tab === 'logs' && } + {tab === 'stats' && } + {tab === 'health' && } + {tab === 'events' && } {tab === 'inspect' && (!info ? : (
Restarts{String(info.restart || 'no')} policy · restarted {String(info.restarts ?? 0)}×
@@ -88,11 +109,153 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
Env{((info.env_names as string[]) ?? []).join(' ')} (names only)
))} + {limits && setLimits(false)} onSaved={() => { setLimits(false); onChanged() }} />} +
+ ) +} + +// CPU and memory over time, from the container sensors the sampler already keeps. +function Stats({ seg, live }: { seg: string; live: Record }) { + const [hist, setHist] = useState<{ id: string; t: number[]; v: number[] }[] | null>(null) + const historySec = useHistorySec() + useEffect(() => { + let alive = true + fetchHistories(`container.${seg}.cpu,container.${seg}.mem`) + .then((h) => { if (alive) setHist(h) }) + .catch(() => { if (alive) setHist([]) }) + return () => { alive = false } + }, [seg]) + const css = (n: string) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || '#3987e5' + const of = (k: string) => hist?.find((h) => h.id === `container.${seg}.${k}`)?.v ?? [] + const cpu = of('cpu'), mem = of('mem') + if (hist === null) return + if (cpu.length < 2 && mem.length < 2) return
No history yet for this container — it is sampled while it runs, so give it a moment.
+ return ( +
+
+
CPU{fmtValue(live[`container.${seg}.cpu`]?.value, '%')} of a core
+ `${v.toFixed(1)}%`} height={64} /> +
the last {fmtWindowSec(historySec)}
+
+
+
Memory{fmtValue(live[`container.${seg}.mem`]?.value, 'B')}
+ fmtBytes(v)} height={64} /> +
the last {fmtWindowSec(historySec)}
+
+
+ ) +} + +// The health check's verdict, and what its last few probes actually said. +function Health({ id }: { id: string }) { + const [h, setH] = useState(null) + const [err, setErr] = useState('') + useEffect(() => { setH(null); api.dockerHealth(id).then(setH).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [id]) + if (err) return
{err}
+ if (!h) return + if (!h.configured) return
This image defines no health check, so Docker only knows whether the process is running — which it is ({h.state}). A health check is a healthcheck: block in the compose file.
+ return ( + <> +
+
Health{h.status}
+
State{h.state}
+ {!!h.failing_streak &&
Failing{h.failing_streak} probe{h.failing_streak === 1 ? '' : 's'} in a row
} +
+
The last {h.log?.length ?? 0} probes — Docker keeps five.
+ {(h.log ?? []).slice().reverse().map((p, i) => ( +
+
+ + {new Date(p.Start).toLocaleString()} + + exit {p.ExitCode} · {Math.max(0, new Date(p.End).getTime() - new Date(p.Start).getTime())} ms +
+ {p.Output.trim() &&
{p.Output.trim()}
} +
+ ))} + + ) +} + +// What has happened to this container lately, asked of Docker rather than kept here. +function Events({ id }: { id: string }) { + const [ev, setEv] = useState(null) + const [since, setSince] = useState('7d') + const [err, setErr] = useState('') + useEffect(() => { setEv(null); setErr(''); api.dockerEvents(id, since).then((r) => setEv(r.events)).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [id, since]) + const bad = (a: string) => /die|kill|oom|destroy|health_status: unhealthy/.test(a) + return ( + <> +
+ {['24h', '7d', '30d'].map((x) => )} +
+ {err &&
{err}
} + {!ev && !err && } + {ev && ev.length === 0 &&
Nothing in the last {since}. Docker keeps events only while the daemon has been up.
} + {ev && ev.length > 0 && ( +
+ {ev.map((e, i) => ( +
+ {e.action} + {new Date(e.t).toLocaleString()} + {e.exitCode !== undefined ? `exit ${e.exitCode}` : ''}{e.signal ? ` signal ${e.signal}` : ''} + + +
+ ))} +
+ )} + + ) +} + +// Memory, CPU and restart policy, changed on the running container. Docker does this live, +// which is the point — and also the catch, said plainly below. +function LimitsDialog({ c, info, onClose, onSaved }: { c: DockerContainer; info: Record | null; onClose: () => void; onSaved: () => void }) { + const curMem = Number(info?.memory_limit ?? 0) + const [mem, setMem] = useState(curMem > 0 ? String(Math.round(curMem / (1 << 20))) + 'm' : '') + const [cpus, setCpus] = useState('') + const [restart, setRestart] = useState(String(info?.restart ?? '')) + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + useEscape(onClose) + const save = async (e: FormEvent) => { + e.preventDefault(); setErr(''); setBusy(true) + try { await api.dockerLimits(c.name, { memory: mem, cpus, restart }); toast(`${c.name} updated`); onSaved() } + catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) } + } + return ( +
+
e.stopPropagation()} onSubmit={save}> +

Limits for {c.name}

+ + + +
+ This changes the running container now, without recreating it. It does not edit the + compose file{c.stack ? <> — so deploying {c.stack} puts back whatever the file says : ''}. + Edit the stack to make it stick. +
+ {err &&
{err}
} +
+ + + +
+
) } -// 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)) @@ -120,6 +283,7 @@ const maskEnv = (text: string) => text.split('\n').map((l) => { const i = l.inde // 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, startTask, onBack, onChanged, onDuplicate }: { name: string; startTask?: string; onBack: () => void; onChanged: () => void; onDuplicate: (from: DupFrom) => void }) { const [st, setSt] = useState(null) const [text, setText] = useState(''); const [orig, setOrig] = useState('') diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 960c51c..592cb04 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -125,6 +125,20 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: useEffect(() => { reload() }, [reload]) + // A shell opened inside a container from the Docker tab is a terminal like any other: + // pick it up, show the Terminal tab, and make it the active one. + useEffect(() => { + const h = async (e: Event) => { + const id = (e as CustomEvent).detail + if (!id) return + await reload() + setActive(id) + setView('terminal') + } + window.addEventListener('hamlab:terminal', h) + return () => window.removeEventListener('hamlab:terminal', h) + }, [reload]) + // Machine list for the switcher (hub-local), refreshed while the console is open. const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, []) useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate]) diff --git a/web/src/api.ts b/web/src/api.ts index 8a3dfeb..65726ef 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -109,6 +109,10 @@ export interface StorageDevice { name: string; kind: string; model?: string; ven export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean } 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 DockerHealthProbe { Start: string; End: string; ExitCode: number; Output: string } +export interface DockerHealth { configured: boolean; state: string; status?: string; failing_streak?: number; log?: DockerHealthProbe[] } +export interface DockerEvent { action: string; t: number; exitCode?: string; signal?: string; name?: string; image?: string } + 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 } @@ -248,6 +252,10 @@ export const api = { 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'}`), dockerInspect: (id: string) => req>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`), + dockerHealth: (id: string) => req('GET', `/api/docker/containers/${encodeURIComponent(id)}/health`), + dockerEvents: (id: string, since = '7d') => req<{ events: DockerEvent[]; since: string }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/events?since=${since}`), + dockerExec: (id: string) => req<{ id: string; name: string }>('POST', `/api/docker/containers/${encodeURIComponent(id)}/exec`, {}), + dockerLimits: (id: string, p: { memory?: string; cpus?: string; restart?: string }) => req('POST', `/api/docker/containers/${encodeURIComponent(id)}/limits`, p), stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'), stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'), createStack: (p: { name: string; text: string; env: string; home: string; env_from?: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p),