// 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 { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 CodeEditor from './CodeEditor' import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors' import { useHashSel } from './hashsel' import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs' import Sparkline from './Sparkline' import { fmtBytes } from './fmt' import { describeCron } from './crontext' import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast, useEscape } from './ui' type Tab = 'containers' | 'stacks' | 'updates' | 'images' | 'volumes' | 'networks' const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok') const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ') // 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([]) 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(() => { 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])) } // 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() } } connect() return () => { closed = true; clearTimeout(retry); sock?.close() } }, [id]) // 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 (
{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.length === 0 && !err &&
No output yet.
}
) } // 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' | 'stats' | 'health' | 'events' | 'inspect'>('logs') const [info, setInfo] = useState | null>(null) 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 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.health && c.health !== 'none' && {c.health}}
Image{c.image}
Status{c.status}
{c.stack &&
Stack{c.stack}{c.service && c.service !== c.name ? ` · ${c.service}` : ''}
} {running &&
Using{fmtValue(s('cpu')?.value, '%')} of a core · {fmtValue(s('mem')?.value ?? c.mem, 'B')}
} {ports(c.ports) &&
Ports{ports(c.ports)}
}
{!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)}×
Network{Object.keys((info.networks as object) ?? {}).join(', ') || String(info.network_mode)}
{Number(info.memory_limit) > 0 &&
Mem limit{fmtValue(Number(info.memory_limit), 'B')}
} {!!info.error &&
Error{String(info.error)}
} {!!info.compose_file &&
Compose{String(info.compose_file)}
} {mounts.length > 0 &&
Mounts{mounts.map((m) =>
{m.Source} → {m.Destination}{m.RW ? '' : ' (read only)'}
)}
}
Env names only{((info.env_names as string[]) ?? []).join(' ') || '—'}
))} {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 [kept, setKept] = useState(0) const [since, setSince] = useState('7d') const [err, setErr] = useState('') useEffect(() => { setEv(null); setErr(''); api.dockerEvents(id, since).then((r) => { setEv(r.events); setKept(r.kept) }).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [id, since]) const bad = (a: string, h?: string) => /die|kill|oom|destroy/.test(a) || (h !== undefined && h !== 'healthy') return ( <>
{['24h', '7d', '30d'].map((x) => )}
{err &&
{err}
} {!ev && !err && } {ev && ev.length === 0 &&
Nothing for this container in the last {since}. Ham Lab keeps its own record — Docker's own only reaches back a few minutes — so history starts from when this version was installed. {kept} events kept across every container.
} {ev && ev.length > 0 && (
{ev.map((e, i) => (
{e.action}{e.health ? `: ${e.health}` : ''} {new Date(e.t).toLocaleString()} {e.exit_code ? `exit ${e.exit_code}` : ''}{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}
}
) } 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)) for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]) const out: { s: ' ' | '+' | '-'; t: string }[] = []; let i = 0, j = 0 while (i < n && j < m) { if (x[i] === y[j]) { out.push({ s: ' ', t: x[i] }); i++; j++ } else if (L[i + 1][j] >= L[i][j + 1]) out.push({ s: '-', t: x[i++] }); else out.push({ s: '+', t: y[j++] }) } while (i < n) out.push({ s: '-', t: x[i++] }); while (j < m) out.push({ s: '+', t: y[j++] }) return out } // What a Deploy would change: each file against what this console last deployed (or, before // the first deploy from here, the copy the last save replaced). Only changed lines and a // little around them; a .env shows which keys changed, never their values. function DiffView({ files }: { files: StackDiff[] }) { const changed = files.filter((f) => f.base && f.old !== f.new) if (files.every((f) => !f.base)) return
Nothing to compare with yet: this console hasn't deployed or saved this stack before.
if (changed.length === 0) return
No changes since {files.some((f) => f.base === 'deployed') ? 'the last deploy from here' : 'the last save'}: Deploy would only recreate what Docker itself thinks has changed.
return <>{changed.map((f) => { const d = lineDiff(f.old, f.new); const keep = new Set(); d.forEach((l, i) => { if (l.s !== ' ') for (let k = i - 2; k <= i + 2; k++) keep.add(k) }) return
{f.name} · against {f.base === 'deployed' ? 'what was last deployed from here' : 'the copy the last save replaced'}
{d.map((l, i) => (keep.has(i) ? 
{l.s} {l.t}
: keep.has(i - 1) ?
⋯
: null))}
})} } // A .env is mostly secrets: keys shown, values hidden until asked for. const maskEnv = (text: string) => text.split('\n').map((l) => { const i = l.indexOf('='); return i > 0 && !l.trim().startsWith('#') && l.length > i + 1 ? l.slice(0, i + 1) + '••••••••' : l }).join('\n') // 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('') const [err, setErr] = useState(''); const [task, setTask] = useState('') const [file, setFile] = useState(0); const [sum, setSum] = useState('') const [reveal, setReveal] = useState(false); const [diff, setDiff] = useState(null) const [upd, setUpd] = useState<{ images: ImageCheck[]; checked: number } | null>(null); const [checking, setChecking] = useState(false) useEffect(() => { setUpd(null); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }, [name]) useEffect(() => { setReveal(false) }, [file, name]) const load = useCallback(async () => { try { const r = await api.stackFile(name, file); setSt(r.stack); setText(r.text); setOrig(r.text); setSum(r.sum); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [name, file]) useEffect(() => { setSt(null); setTask(startTask ?? ''); load() }, [load]) // eslint-disable-line react-hooks/exhaustive-deps const dirty = text !== orig useEffect(() => { setUnsaved(dirty ? `${name}'s compose file` : ''); return () => setUnsaved('') }, [dirty, name]) const save = async () => { try { await api.saveStack(name, file, text, sum); setOrig(text); setErr(''); setDiff(null); api.stackFile(name, file).then((r) => setSum(r.sum)).catch(() => {}); toast('saved — the running containers are unchanged until you deploy') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } const remove = async () => { const typed = await askText(`Delete the stack "${name}"?\n\nIts containers are removed and its compose file${st?.has_env ? ' and .env' : ''} moved to .hamlab-trash beside it — moved, not erased. Volumes and bind-mounted data are not touched.\n\nType its name to confirm.`, '', 'Delete') if (typed !== name) { if (typed !== null) toast('that is not its name — nothing was deleted', 'info'); return } try { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const duplicate = async () => { if (!st) return; try { onDuplicate({ name, text: file === 0 ? orig : (await api.stackFile(name, 0)).text, hasEnv: !!st.has_env, files: st.host_files?.length ?? 1 }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const run = (a: 'deploy' | 'update' | 'recreate' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } if (err && !st) return
{err}
if (!st) return
return (
{st.name}{st.running}/{st.containers.length} running
{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.via && · compose runs inside {st.via}, as it was deployed}{st.has_env && ' · has a .env beside it'}
{((st.host_files?.length ?? 0) > 1 || st.has_env) &&
{st.host_files!.map((f, i) => )}{st.has_env && }
} {file === 'env' && !reveal ?
{maskEnv(text)}
: } {err &&
{err}{/changed on disk/.test(err) && <> }
}
Save {dirty && } Deploy {st.containers.length > 0 && Update} {st.containers.length > 0 && Restart} {st.containers.length > 0 && Recreate} {st.running > 0 && Stop}
{st.containers.length === 0 ? 'Not deployed: no containers exist for this stack.' : ''} {st.containers.length > 0 && Down}
{task && { onChanged(); load(); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }} />}
What Deploy would change
{diff && }
Images{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked'} { setChecking(true); try { setUpd(await api.stackUpdates(name, true)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setChecking(false) } }}>check for updates
{(st.images ?? []).map((img) => { const c = upd?.images.find((x) => x.image === img); return
{img}{c ? {c.state === 'update' ? 'update available' : c.state === 'current' ? 'up to date' : 'unknown'} : —}
})}
{st.containers.join(' · ')}
) } export default function Docker() { const [d, setD] = useState(null) const [stacks, setStacks] = useState(null) const [err, setErr] = useState('') const [tab, setTab] = useState('containers') const [filter, setFilter] = useState('') const [sel, setSel] = useHashSel('docker') const [making, setMaking] = useState<'' | 'stack' | 'container'>('') const [dup, setDup] = useState(null) const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null) const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('') const rate = useRefreshMs() const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, []) useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate]) const live = useSensorStream<{ c: Record }>({ c: 'container.*' }, { everyMs: rate }) const sensors = live.data?.c ?? {} const byStack = useMemo(() => { const f = filter.trim().toLowerCase(); const out = new Map() for (const c of d?.containers ?? []) { if (f && !`${c.name} ${c.image} ${c.stack}`.toLowerCase().includes(f)) continue; const k = c.stack || 'started by hand'; out.set(k, [...(out.get(k) ?? []), c]) } return out }, [d, filter]) // what a new stack could join, and what it could collide with const nets = useMemo(() => (d?.networks ?? []).map((n) => String(n.Name)).filter((n) => !['bridge', 'host', 'none'].includes(n)).sort(), [d]) const taken = useMemo(() => { const ports: Record = {}; for (const c of d?.containers ?? []) for (const m of (c.ports ?? '').matchAll(/:(\d+)->/g)) ports[m[1]] = c.name; return { names: (d?.containers ?? []).map((c) => c.name), ports } }, [d]) // What the registry last said about each image, so a container can be marked without // asking again. Only ever read from the cache here — checking is a deliberate act. // Re-read after a stack task too: an update changes this machine's side of the answer. const [upd, setUpd] = useState(null) const readUpd = useCallback(() => { api.machineUpdates().then(setUpd).catch(() => {}) }, []) useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, []) const stackChanged = useCallback(() => { load(); readUpd() }, [load, readUpd]) const imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd]) const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length if (!d) return
{err ?
{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}
: }
if (!d.available) return
{d.message}
const selStack = sel.startsWith('stack:') ? sel.slice(6) : '' const cur = selStack ? undefined : d.containers.find((c) => c.name === sel) 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 imageCols: [string, string][] = [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']] return (
{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => )}
{(shownTab === 'containers' || shownTab === 'stacks') &&
} {shownTab === 'images' &&
{ e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}> setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" /> {(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused} })()} { 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" />}
{shownTab === 'stacks' && (stacks ?? []).map((st) => (
st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
{st.name}
{st.editable ? st.host_file : st.note}
{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <>
read-only}
))} {shownTab === 'stacks' && stacks?.length === 0 &&
No compose stacks here.
} {shownTab === 'containers' && [...byStack].map(([stack, list]) => (

{stack} · {list.filter((c) => c.state === 'running').length}/{list.length}

{list.map((c) => (
setSel(c.name)}>
{c.name}
{c.image}
{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}
{fmtValue(sid(c, 'cpu')?.value, '%')} : c.state}
))}
))} {shownTab === 'containers' && byStack.size === 0 &&
No container matches.
} {shownTab === 'volumes' && ( {d.volumes.map((row) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], anon = !!row.anonymous const remove = async () => { if (anon) { if (!(await askYes(`Delete this anonymous volume and everything in it?\n\n${name}\n\nNothing uses it. It can't be brought back.`, 'Delete'))) return } else { const typed = await askText(`Delete the volume "${name}" and everything in it?\n\nNothing uses it right now — but if it belongs to a stack that is only down, this is that stack's data. It can't be brought back.\n\nType its name to confirm.`, '', 'Delete'); if (typed !== name) { if (typed !== null) toast('that is not its name — nothing was deleted', 'info'); return } } try { await api.removeVolume(name); toast('volume deleted'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } } return })} {d.volumes.length === 0 && }
VolumeUsed by
{anon ? <>{name.slice(0, 12)}… anonymous : name}{users.length ? users.join(', ') : nothing}{users.length ? in use : }
No volumes.
)} {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') } }}>✕}
)}
{shownTab === 'containers' && !cur && sel && !selStack &&
setSel('')} />
There is no container called {sel} on this machine. It may have been removed, renamed or recreated under another name.
} {shownTab === 'containers' && (cur ? setSel('')} onChanged={load} /> : (sel && !selStack ? null :
Select a container.
))} {making && { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />} {shownTab === 'updates' && } {shownTab === 'stacks' && (selStack ? setSel('')} onChanged={stackChanged} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> :
Select a stack to edit its compose file.
)}
) } // A container's image against the registry, as a single mark: nothing when nobody has // asked, so an uncoloured list means "not checked" rather than "all current". function UpdateDot({ state }: { state?: string }) { if (!state || state === 'current') return null const known = state === 'update' return {known ? '↑' : '?'} } // What is out of date here, and the schedules that keep it from happening again. function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u: MachineUpdates) => void }) { const [busy, setBusy] = useState(false) const [jobs, setJobs] = useState(null) const [file, setFile] = useState('') const [editing, setEditing] = useState | null>(null) const [stacks, setStacks] = useState([]) const loadJobs = useCallback(async () => { try { const r = await api.updateJobs(); setJobs(r.jobs); setFile(r.file) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setJobs([]) } }, []) useEffect(() => { loadJobs(); api.stacks().then((r) => setStacks(r.stacks.map((s) => s.name))).catch(() => {}) }, [loadJobs]) const check = async () => { setBusy(true) try { onChecked(await api.machineUpdates(true)); toast('checked') } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy(false) } } const run = async (j: UpdateJob) => { if (!await askYes(`Run “${j.name}” now? It pulls ${j.stack}'s images${j.apply ? ' and deploys them, which restarts its containers' : ', without deploying them'}.`, 'Run now')) return try { await api.runUpdateJob(j.id); toast(`${j.name}: done`); loadJobs(); api.machineUpdates().then(onChecked).catch(() => {}) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const remove = async (j: UpdateJob) => { if (!await askYes(`Remove the schedule “${j.name}”? Nothing is updated or rolled back — the schedule simply stops.`, 'Remove')) return try { await api.deleteUpdateJob(j.id); toast('removed'); loadJobs() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const imgs = upd?.images ?? [] const behind = imgs.filter((i) => i.state === 'update') // Updating is per stack — that is what compose can do — so an image row acts on whichever // stacks use it, and a queue runs them one at a time rather than recreating half the // machine at once. const [queue, setQueue] = useState([]) const [running, setRunning] = useState<{ stack: string; id: string } | null>(null) const stacksFor = useCallback((img: string) => upd?.stacks?.[img] ?? [], [upd]) const startNext = useCallback(async (rest: string[]) => { const next = rest[0] if (!next) { setRunning(null); setQueue([]); return } setQueue(rest.slice(1)) try { setRunning({ stack: next, id: (await api.stackAction(next, 'update')).id }) } catch (e) { toast(`${next}: ${e instanceof Error ? e.message : 'failed'}`, 'err'); setRunning(null); startNext(rest.slice(1)) } }, []) const updateStacks = async (names: string[], what: string) => { const uniq = [...new Set(names)] if (uniq.length === 0) { toast('nothing here names a stack this console can deploy', 'info'); return } if (!await askYes(`Update ${what}?\n\nThis pulls the newer image${uniq.length > 1 ? 's' : ''} and recreates ${uniq.length === 1 ? `the ${uniq[0]} stack` : `these stacks, one at a time: ${uniq.join(', ')}`}. Their containers restart.`, 'Update')) return startNext(uniq) } const behindStacks = [...new Set(behind.flatMap((i) => stacksFor(i.image)))] return (

What is out of date

{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked yet'} {behindStacks.length > 0 && updateStacks(behindStacks, `everything out of date — ${behind.length} image${behind.length === 1 ? '' : 's'} across ${behindStacks.length} stack${behindStacks.length === 1 ? '' : 's'}`)}>Update all · {behind.length}} Check now
Each check is one request per image, which registries count — so it happens when asked, and the answer is kept for six hours.
{imgs.length === 0 &&
Nothing checked yet on this machine.
} {imgs.length > 0 && (
{behind.length === 0 &&
Every image that could be checked is current.
}
{imgs.filter((i) => i.state !== 'current').map((i) => (
{i.image} {i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'} {stacksFor(i.image).join(' ')} {i.state === 'update' && ( updateStacks(stacksFor(i.image), `${i.image}`)}>Update )}
))}
{behind.length === 0 && imgs.some((i) => i.state === 'current') &&
{imgs.filter((i) => i.state === 'current').length} current.
} {running && (
Updating {running.stack}{queue.length > 0 ? `${queue.length} to follow` : 'last one'}
{ if (t.exit_code !== 0 || t.error) toast(`${running.stack}: update failed`, 'err') api.machineUpdates().then(onChecked).catch(() => {}) // an image now held drops off the list startNext(queue) }} />
)}
)}

Schedules

setEditing({ cron: '0 3 * * *', apply: true, enabled: true })}>+ New schedule
Written to {file || '/etc/cron.d/hamlab-docker'}, which Ham Lab owns — so they keep running whether or not this console is up.
{jobs === null && } {jobs?.length === 0 &&
No schedule yet. Nothing on this machine updates itself.
}
{(jobs ?? []).map(({ job, next, last }) => (
{job.name} {!job.enabled && paused} {job.enabled && next ? new Date(next).toLocaleString() : 'not scheduled'}
Stack{job.stack}{job.services?.length ? ` · ${job.services.join(' ')}` : ''}
Runs{describeCron(job.cron) || {job.cron}}{describeCron(job.cron) && · {job.cron}}
Does{job.apply ? 'pulls and deploys' : 'pulls only — you deploy'}
Last run{last?.at ? new Date(last.at).toLocaleString() : 'never'}
{last?.error &&
{last.error}
}
run(job)}>Run now { await api.saveUpdateJob({ ...job, enabled: !job.enabled }); loadJobs() }}>{job.enabled ? 'Pause' : 'Resume'} remove(job)}>Remove
))}
{editing && setEditing(null)} onSaved={() => { setEditing(null); loadJobs() }} />}
) } function JobDialog({ j, stacks, onClose, onSaved }: { j: Partial; stacks: string[]; onClose: () => void; onSaved: () => void }) { const [f, setF] = useState>(j) const [svc, setSvc] = useState((j.services ?? []).join(' ')) const [err, setErr] = useState('') const [busy, setBusy] = useState(false) useEscape(onClose) const set = (k: keyof UpdateJob, v: unknown) => setF((x) => ({ ...x, [k]: v })) const save = async (e: FormEvent) => { e.preventDefault(); setErr(''); setBusy(true) try { await api.saveUpdateJob({ ...f, services: svc.split(/\s+/).filter(Boolean) }); onSaved() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) } } return (
e.stopPropagation()} onSubmit={save}>

{f.id ? 'Edit schedule' : 'Keep a stack up to date'}

{f.apply ? 'Deploying restarts the stack’s containers. On something that carries DNS or ingress, pick an hour you are asleep.' : 'Pulling only means the new image is on the machine and the list shows it as available — nothing restarts until you deploy.'}
{err &&
{err}
}
) }