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