Docker: a container's stats, health, events, shell and limits
Five things the tab could not do, all on the container detail. Stats draws CPU and memory over time from the container sensors the sampler already keeps, so there is nothing new to collect. Health shows the check's verdict, the failing streak and what the last five probes actually printed, and says plainly when an image defines no health check at all rather than leaving a blank panel. Events asks Docker what has happened to the container over the last day, week or month — started, died, killed, health going bad, with exit codes — rather than keeping a copy that would only go stale. Shell opens a session inside the container as an ordinary terminal, so it reattaches, resizes and scrolls back like the rest and works on a managed machine through the same proxy. The terminal manager grew a CreateCmd for it; every session is the same thing afterwards. Limits changes memory, CPUs and restart policy on the running container with docker update, live and without recreating it. That is also the catch, and the dialog says so: it does not edit the compose file, so a deploy puts back whatever the file says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c129f655a7
commit
b3e1c495f0
+175
-11
@@ -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<string, Sensor>; 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<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 [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 (
|
||||
<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="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span>{c.health && c.health !== 'none' && <span className={'badge' + (c.health === 'healthy' ? '' : ' bad')}>{c.health}</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>
|
||||
@@ -72,11 +82,22 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
|
||||
<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" onClick={shell}>Shell</Btn>}
|
||||
{running && <Btn className="btn" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>}
|
||||
<Btn className="btn" onClick={() => setLimits(true)}>Limits</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>
|
||||
<div className="tabs">
|
||||
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button>
|
||||
<button className={tab === 'stats' ? 'on' : ''} onClick={() => setTab('stats')}>Stats</button>
|
||||
<button className={tab === 'health' ? 'on' : ''} onClick={() => setTab('health')}>Health</button>
|
||||
<button className={tab === 'events' ? 'on' : ''} onClick={() => setTab('events')}>Events</button>
|
||||
<button className={tab === 'inspect' ? 'on' : ''} onClick={() => setTab('inspect')}>Inspect</button>
|
||||
</div>
|
||||
{tab === 'logs' && <LogView id={c.name} />}
|
||||
{tab === 'stats' && <Stats seg={seg} live={live} />}
|
||||
{tab === 'health' && <Health id={c.name} />}
|
||||
{tab === 'events' && <Events 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>
|
||||
@@ -88,11 +109,153 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
|
||||
<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>
|
||||
))}
|
||||
{limits && <LimitsDialog c={c} info={info} onClose={() => setLimits(false)} onSaved={() => { setLimits(false); onChanged() }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// CPU and memory over time, from the container sensors the sampler already keeps.
|
||||
function Stats({ seg, live }: { seg: string; live: Record<string, Sensor> }) {
|
||||
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 <Loading label="Reading the history…" />
|
||||
if (cpu.length < 2 && mem.length < 2) return <div className="notice">No history yet for this container — it is sampled while it runs, so give it a moment.</div>
|
||||
return (
|
||||
<div className="cards">
|
||||
<section className="card">
|
||||
<div className="sub-head"><span className="dname">CPU</span><span className="spacer" /><span className="num">{fmtValue(live[`container.${seg}.cpu`]?.value, '%')} of a core</span></div>
|
||||
<Sparkline series={[{ name: 'cpu', values: cpu, color: css('--s1') }]} format={(v) => `${v.toFixed(1)}%`} height={64} />
|
||||
<div className="muted small">the last {fmtWindowSec(historySec)}</div>
|
||||
</section>
|
||||
<section className="card">
|
||||
<div className="sub-head"><span className="dname">Memory</span><span className="spacer" /><span className="num">{fmtValue(live[`container.${seg}.mem`]?.value, 'B')}</span></div>
|
||||
<Sparkline series={[{ name: 'mem', values: mem, color: css('--s2') }]} format={(v) => fmtBytes(v)} height={64} />
|
||||
<div className="muted small">the last {fmtWindowSec(historySec)}</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The health check's verdict, and what its last few probes actually said.
|
||||
function Health({ id }: { id: string }) {
|
||||
const [h, setH] = useState<DockerHealth | null>(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 <div className="error">{err}</div>
|
||||
if (!h) return <Loading label="Asking…" />
|
||||
if (!h.configured) return <div className="notice">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 <span className="mono">healthcheck:</span> block in the compose file.</div>
|
||||
return (
|
||||
<>
|
||||
<div className="props">
|
||||
<div><span className="k">Health</span><span className={h.status === 'healthy' ? '' : 'warn-text'}>{h.status}</span></div>
|
||||
<div><span className="k">State</span><span>{h.state}</span></div>
|
||||
{!!h.failing_streak && <div><span className="k">Failing</span><span className="warn-text">{h.failing_streak} probe{h.failing_streak === 1 ? '' : 's'} in a row</span></div>}
|
||||
</div>
|
||||
<div className="muted small" style={{ marginTop: 8 }}>The last {h.log?.length ?? 0} probes — Docker keeps five.</div>
|
||||
{(h.log ?? []).slice().reverse().map((p, i) => (
|
||||
<section className="card" key={i} style={{ marginTop: 6 }}>
|
||||
<div className="sub-head">
|
||||
<span className={'sdot ' + (p.ExitCode === 0 ? 'ok' : 'bad')} />
|
||||
<span>{new Date(p.Start).toLocaleString()}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted small">exit {p.ExitCode} · {Math.max(0, new Date(p.End).getTime() - new Date(p.Start).getTime())} ms</span>
|
||||
</div>
|
||||
{p.Output.trim() && <pre className="run-out" style={{ maxHeight: 140 }}>{p.Output.trim()}</pre>}
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// What has happened to this container lately, asked of Docker rather than kept here.
|
||||
function Events({ id }: { id: string }) {
|
||||
const [ev, setEv] = useState<DockerEvent[] | null>(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 (
|
||||
<>
|
||||
<div className="chips" style={{ marginBottom: 8 }}>
|
||||
{['24h', '7d', '30d'].map((x) => <button key={x} className={'chip' + (since === x ? ' on' : '')} onClick={() => setSince(x)}>{x}</button>)}
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
{!ev && !err && <Loading label="Asking Docker…" />}
|
||||
{ev && ev.length === 0 && <div className="notice">Nothing in the last {since}. Docker keeps events only while the daemon has been up.</div>}
|
||||
{ev && ev.length > 0 && (
|
||||
<div className="snap-list">
|
||||
{ev.map((e, i) => (
|
||||
<div className="snap" key={i}>
|
||||
<span className={bad(e.action) ? 'warn-text' : ''}>{e.action}</span>
|
||||
<span className="muted">{new Date(e.t).toLocaleString()}</span>
|
||||
<span className="muted small">{e.exitCode !== undefined ? `exit ${e.exitCode}` : ''}{e.signal ? ` signal ${e.signal}` : ''}</span>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<string, unknown> | 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 (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||
<h3>Limits for {c.name}</h3>
|
||||
<label className="muted">Memory — 512m, 2g, or 0 for no limit{curMem > 0 ? ` (now ${fmtBytes(curMem)})` : ' (no limit now)'}
|
||||
<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder="leave blank to keep" spellCheck={false} />
|
||||
</label>
|
||||
<label className="muted">CPUs — 1.5 means one and a half cores, 0 for no limit
|
||||
<input className="mono" value={cpus} onChange={(e) => setCpus(e.target.value)} placeholder="leave blank to keep" spellCheck={false} />
|
||||
</label>
|
||||
<label className="muted">Restart policy
|
||||
<select value={restart} onChange={(e) => setRestart(e.target.value)}>
|
||||
<option value="">leave as it is</option>
|
||||
{['no', 'on-failure', 'unless-stopped', 'always'].map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="notice attn">
|
||||
This changes the running container now, without recreating it. It does <b>not</b> edit the
|
||||
compose file{c.stack ? <> — so deploying <b>{c.stack}</b> puts back whatever the file says</> : ''}.
|
||||
Edit the stack to make it stick.
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button type="submit" className="btn on" disabled={busy || (!mem && !cpus && !restart)}>Apply</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</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))
|
||||
@@ -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<Stack | null>(null)
|
||||
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
|
||||
|
||||
Reference in New Issue
Block a user