// 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(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 (
{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.
}
) } function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record; onBack: () => void; onChanged: () => void }) { const [tab, setTab] = useState<'logs' | '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 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 (
{c.name}
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 && Restart} {running && Stop}
{tab === 'logs' && } {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.map((m) =>
Mount{m.Source} → {m.Destination}{m.RW ? '' : ' (ro)'}
)}
Env{((info.env_names as string[]) ?? []).join(' ')} (names only)
))}
) } // 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
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' | '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.held_by && · kept on {st.held_by}, deployed to this machine over {st.remote}}{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)}
: