Files
hamlab/web/src/Docker.tsx
T
Mitchell-JohnsonandClaude Fable 5.1 7e3e916f25 Arrange the rail: drag pages into your own order, kept with the account
The close cross on a detail pane is drawn, not a glyph, and sits in no box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 19:59:25 -07:00

276 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 TaskOutput from './TaskOutput'
import StackNew, { DupFrom } from './StackNew'
import { Sensor, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs'
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast } 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')
const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
function LogView({ id }: { id: string }) {
const [lines, setLines] = useState<{ t: string; m: string }[]>([])
const [err, setErr] = useState('')
const [follow, setFollow] = useState(true)
const box = useRef<HTMLDivElement>(null)
const last = useRef('')
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') }
}
window.setTimeout(tick, 2000)
}
tick()
return () => { stop = true }
}, [id])
useEffect(() => { if (follow && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow])
return (
<div className="logs">
<div className="logs-bar"><span className="muted">{lines.length} lines</span><span className="spacer" /><button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button><button className="chip" onClick={() => setLines([])}>Clear</button></div>
{err && <div className="error">{err}</div>}
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
{lines.map((l, i) => <div className="log" key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
</div>
</div>
)
}
function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record<string, Sensor>; onBack: () => void; onChanged: () => void }) {
const [tab, setTab] = useState<'logs' | 'inspect'>('logs')
const [info, setInfo] = useState<Record<string, unknown> | 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 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 mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
return (
<div className="svc-detail">
<CloseBtn onClick={onBack} />
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span></div>
<div className="props">
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
<div><span className="k">Status</span><span>{c.status}</span></div>
{c.stack && <div><span className="k">Stack</span><span>{c.stack}{c.service && c.service !== c.name ? ` · ${c.service}` : ''}</span></div>}
{running && <div><span className="k">Using</span><span>{fmtValue(s('cpu')?.value, '%')} of a core · {fmtValue(s('mem')?.value ?? c.mem, 'B')}</span></div>}
{ports(c.ports) && <div><span className="k">Ports</span><span className="mono small">{ports(c.ports)}</span></div>}
</div>
<div className="svc-actions">
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
{!running && <Btn className="btn danger" confirm={`Remove the container ${c.name}? Its image, volumes and bind-mounted data stay; anything written inside the container itself is lost.${c.stack ? ` Deploying the ${c.stack} stack makes it again.` : ''}`} onClick={async () => { await act('remove')(); onBack() }}>Remove</Btn>}
{running && <Btn className="btn" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>}
{running && <Btn className="btn danger" confirm={`Stop ${c.name}? It stays stopped until something starts it.`} onClick={act('stop')}>Stop</Btn>}
</div>
<div className="tabs"><button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button><button className={tab === 'inspect' ? 'on' : ''} onClick={() => setTab('inspect')}>Inspect</button></div>
{tab === 'logs' && <LogView id={c.name} />}
{tab === 'inspect' && (!info ? <Loading label="Inspecting…" /> : (
<div className="props">
<div><span className="k">Restarts</span><span>{String(info.restart || 'no')} policy · restarted {String(info.restarts ?? 0)}×</span></div>
<div><span className="k">Network</span><span className="mono small">{Object.keys((info.networks as object) ?? {}).join(', ') || String(info.network_mode)}</span></div>
{Number(info.memory_limit) > 0 && <div><span className="k">Mem limit</span><span>{fmtValue(Number(info.memory_limit), 'B')}</span></div>}
{!!info.error && <div><span className="k">Error</span><span className="warn-text">{String(info.error)}</span></div>}
{!!info.compose_file && <div><span className="k">Compose</span><span className="mono small">{String(info.compose_file)}</span></div>}
{mounts.map((m) => <div key={m.Destination}><span className="k">Mount</span><span className="mono small">{m.Source} → {m.Destination}{m.RW ? '' : ' (ro)'}</span></div>)}
<div><span className="k">Env</span><span className="mono small muted">{((info.env_names as string[]) ?? []).join(' ')} <span className="muted">(names only)</span></span></div>
</div>
))}
</div>
)
}
// 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))
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 <div className="muted small">Nothing to compare with yet: this console hasn't deployed or saved this stack before.</div>
if (changed.length === 0) return <div className="muted small">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.</div>
return <>{changed.map((f) => { const d = lineDiff(f.old, f.new); const keep = new Set<number>(); d.forEach((l, i) => { if (l.s !== ' ') for (let k = i - 2; k <= i + 2; k++) keep.add(k) })
return <div key={f.name}><div className="muted small">{f.name} · against {f.base === 'deployed' ? 'what was last deployed from here' : 'the copy the last save replaced'}</div>
<pre className="diff">{d.map((l, i) => (keep.has(i) ? <div key={i} className={l.s === '+' ? 'add' : l.s === '-' ? 'del' : ''}>{l.s} {l.t}</div> : keep.has(i - 1) ? <div key={i} className="muted"> ⋯</div> : null))}</pre></div> })}</>
}
// 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<Stack | null>(null)
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
const [err, setErr] = useState(''); const [task, setTask] = useState('')
const [file, setFile] = useState<number | 'env'>(0); const [sum, setSum] = useState('')
const [reveal, setReveal] = useState(false); const [diff, setDiff] = useState<StackDiff[] | null>(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' | '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 <div className="svc-detail"><CloseBtn onClick={onBack} /><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
return (
<div className="svc-detail">
<CloseBtn onClick={onBack} />
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
{((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
{file === 'env' && !reveal ? <div className="env-masked"><pre className="editor stack-editor">{maskEnv(text)}</pre><button className="btn" onClick={() => setReveal(true)}>Reveal and edit</button></div> :
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}{/changed on disk/.test(err) && <> <button className="link" onClick={async () => { if (await askYes('Reload the file from disk? Your text in the box is replaced.', 'Reload')) load() }}>reload it</button></>}</div>}
<div className="svc-actions">
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
{dirty && <button className="btn" onClick={() => { setText(orig); setErr('') }}>Revert</button>}
<span className="spacer" />
<Btn className="btn" disabled={dirty} title={dirty ? 'save first' : 'recreate what changed: docker compose up -d'} confirm={`Deploy ${st.name}? Containers whose definition changed are recreated (a few seconds down each).`} onClick={run('deploy')}>Deploy</Btn>
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</Btn>}
{st.containers.length > 0 && <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>}
{st.running > 0 && <Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>}
</div>
<div className="svc-actions">
<span className="muted small">{st.containers.length === 0 ? 'Not deployed: no containers exist for this stack.' : ''}</span><span className="spacer" />
<button className="btn" disabled={dirty} title={dirty ? 'save first' : 'a new stack that starts as a copy of this one'} onClick={duplicate}>Duplicate…</button>
{st.containers.length > 0 && <Btn className="btn danger" title="docker compose down" confirm={`Take ${st.name} down? Its ${st.containers.length} container${st.containers.length === 1 ? ' is' : 's are'} removed (not just stopped). The compose file, volumes and bind-mounted data stay, and Deploy brings it back.`} onClick={run('down')}>Down</Btn>}
<button className="btn danger" onClick={remove}>Delete…</button>
</div>
{task && <TaskOutput id={task} onDone={() => { onChanged(); load(); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }} />}
<div className="stack-extra">
<div className="sub-head"><span className="k">What Deploy would change</span><span className="spacer" /><button className="link" onClick={async () => { try { setDiff((await api.stackDiff(name)).files) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>{diff ? 'refresh' : 'show'}</button></div>
{diff && <DiffView files={diff} />}
<div className="sub-head"><span className="k">Images</span><span className="spacer" /><span className="muted small">{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked'}</span> <Btn className="link" busy={checking} onClick={async () => { 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</Btn></div>
{(st.images ?? []).map((img) => { const c = upd?.images.find((x) => x.image === img); return <div className="img-row" key={img}><span className="mono small">{img}</span><span className="spacer" />{c ? <span className={'badge' + (c.state === 'update' ? ' attn' : '')} title={c.detail}>{c.state === 'update' ? 'update available' : c.state === 'current' ? 'up to date' : 'unknown'}</span> : <span className="muted small">—</span>}</div> })}
</div>
<div className="muted small">{st.containers.join(' · ')}</div>
</div>
)
}
export default function Docker() {
const [d, setD] = useState<DockerInfo | null>(null)
const [stacks, setStacks] = useState<Stack[] | null>(null)
const [err, setErr] = useState('')
const [tab, setTab] = useState<Tab>('containers')
const [filter, setFilter] = useState('')
const [sel, setSel] = useHashSel('docker')
const [making, setMaking] = useState<'' | 'stack' | 'container'>('')
const [dup, setDup] = useState<DupFrom | null>(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<string, Sensor> }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) })
const sensors = live.data?.c ?? {}
const byStack = useMemo(() => {
const f = filter.trim().toLowerCase(); const out = new Map<string, DockerContainer[]>()
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<string, string> = {}; 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])
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
if (!d.available) return <div className="dash"><div className="notice">{d.message}</div></div>
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 cols: Record<Exclude<Tab, 'containers' | 'stacks'>, [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']] }
return (
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '')}>
<div className="svc-list">
<div className="svc-tools">
<div className="chips">{(['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t} · {t === 'containers' ? `${up}/${d.containers.length}` : t === 'stacks' ? (stacks?.length ?? '…') : d[t].length}</button>)}</div>
{(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
{shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
<button className="btn on" disabled={!pull.trim()}>Pull</button>
{(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && <Btn className="btn danger" title="remove every image no container uses" confirm={`Remove all ${unused} image${unused === 1 ? '' : 's'} that no container uses? They can be pulled again, but a stack that is down will have to download its images before it next starts.`} onClick={async () => { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused}</Btn> })()}
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
</form>}
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
</div>
<div className="svc-rows">
{shownTab === 'stacks' && (stacks ?? []).map((st) => (
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
<span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div>
<div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div>
</div>
))}
{shownTab === 'stacks' && stacks?.length === 0 && <div className="muted small" style={{ padding: 12 }}>No compose stacks here.</div>}
{shownTab === 'containers' && [...byStack].map(([stack, list]) => (
<div key={stack}>
<h3 className="stack-head">{stack} <span className="muted">· {list.filter((c) => c.state === 'running').length}/{list.length}</span></h3>
{list.map((c) => (
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
<span className={'sdot ' + dot(c)} />
<div className="svc-name"><div className="n">{c.name}</div><div className="d">{c.image}</div></div>
<div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
</div>
))}
</div>
))}
{shownTab === 'containers' && byStack.size === 0 && <div className="muted small" style={{ padding: 12 }}>No container matches.</div>}
{shownTab === 'volumes' && (
<table className="dtable"><thead><tr><th>Volume</th><th>Used by</th><th /></tr></thead>
<tbody>{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 <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
)}
{shownTab !== 'containers' && shownTab !== 'stacks' && shownTab !== 'volumes' && (
<table className="dtable"><thead><tr>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([, h]) => <th key={h}>{h}</th>)}{shownTab === 'images' && <th />}</tr></thead>
<tbody>{d[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map((row, i) => <tr key={i}>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}{shownTab === 'images' && <td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${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') } }}>✕</Btn>}</td>}</tr>)}</tbody></table>
)}
</div>
</div>
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { 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 === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
</div>
)
}