Graphs stepped once a history point (1 s at a 5 min window) while the numbers changed every tick; they now slide a fraction of a column each tick. The process table (and the GPU boxes, MHz and watts it carries) was polled on its own timer; it is now read per sample and committed with it. GPU clients are walked every sample, and the 1–2 s floors on the gallery, Network, Docker and ZFS ARC live views are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
686 lines
59 KiB
TypeScript
686 lines
59 KiB
TypeScript
// 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<DockerLogLine[]>([])
|
||
const [err, setErr] = useState('')
|
||
const [live, setLive] = useState(false)
|
||
const [follow, setFollow] = useState(true)
|
||
const [paused, setPaused] = useState(false)
|
||
const box = useRef<HTMLDivElement>(null)
|
||
const last = useRef('')
|
||
const pausedRef = useRef(false)
|
||
pausedRef.current = paused
|
||
const pending = useRef<DockerLogLine[]>([])
|
||
// 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<string>; 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 (
|
||
<div className="logs">
|
||
<div className="logs-bar">
|
||
<span className={'dot ' + (live ? 'ok' : 'bad')} /> <span className="muted">{live ? 'following' : 'reconnecting…'} · {lines.length} lines{err && ` · ${err}`}</span>
|
||
<span className="spacer" />
|
||
<button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button>
|
||
<button className="chip" onClick={() => setPaused(!paused)}>{paused ? `Resume${pending.current.length ? ` (${pending.current.length})` : ''}` : 'Pause'}</button>
|
||
<button className="chip" onClick={() => setLines([])}>Clear</button>
|
||
</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' + (l.err ? ' err' : '')} 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>
|
||
)
|
||
}
|
||
|
||
// 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' | 'stats' | 'health' | 'events' | 'inspect'>('logs')
|
||
const [info, setInfo] = useState<Record<string, unknown> | 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 (
|
||
<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>{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>
|
||
{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" 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 === '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 stacked">
|
||
<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.length > 0 && <div><span className="k">Mounts</span><span className="mono small">{mounts.map((m) => <div key={m.Destination}>{m.Source} → {m.Destination}{m.RW ? '' : ' (read only)'}</div>)}</span></div>}
|
||
<div><span className="k">Env <span className="muted">names only</span></span><span className="mono small muted">{((info.env_names as string[]) ?? []).join(' ') || '—'}</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 [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 (
|
||
<>
|
||
<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 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.</div>}
|
||
{ev && ev.length > 0 && (
|
||
<div className="snap-list">
|
||
{ev.map((e, i) => (
|
||
<div className="snap" key={i}>
|
||
<span className={bad(e.action, e.health) ? 'warn-text' : ''}>{e.action}{e.health ? `: ${e.health}` : ''}</span>
|
||
<span className="muted">{new Date(e.t).toLocaleString()}</span>
|
||
<span className="muted small">{e.exit_code ? `exit ${e.exit_code}` : ''}{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>
|
||
)
|
||
}
|
||
|
||
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' | '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 <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.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> :
|
||
<CodeEditor className="stack-editor" language={file === 'env' ? 'env' : 'yaml'} value={text} onChange={setText} />}
|
||
{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.containers.length > 0 && <Btn className="btn" disabled={dirty} title="build the containers again from scratch: docker compose up -d --force-recreate" confirm={`Recreate every container in ${st.name}?\n\nThis is the way out of a container Docker refuses to start — one pinned to a network that no longer exists, say, which answers "network … not found" to an ordinary Deploy. Nothing in a volume or a bind mount is touched.`} onClick={run('recreate')}>Recreate</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: 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])
|
||
// 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<MachineUpdates | null>(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 <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 imageCols: [string, string][] = [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']]
|
||
return (
|
||
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '') + (shownTab === 'updates' ? ' wide' : '')}>
|
||
<div className="svc-list">
|
||
<div className="svc-tools">
|
||
<div className="chips">{(['containers', 'stacks', 'updates', '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 ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${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 === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {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.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}<UpdateDot state={imgState[c.image]} /></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 === 'networks' && (
|
||
<table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead>
|
||
<tbody>{d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined
|
||
return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })}
|
||
{d.networks.length === 0 && <tr><td colSpan={4} className="muted small">No networks.</td></tr>}</tbody></table>
|
||
)}
|
||
{shownTab === 'images' && (
|
||
<table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
|
||
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<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 === 'updates' && <Updates upd={upd} onChecked={setUpd} />}
|
||
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={stackChanged} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 <span className={'updot' + (known ? ' behind' : ' unknown')} title={known ? 'a newer image has been published' : 'the registry could not say'}>{known ? '↑' : '?'}</span>
|
||
}
|
||
|
||
// 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<UpdateJobRow[] | null>(null)
|
||
const [file, setFile] = useState('')
|
||
const [editing, setEditing] = useState<Partial<UpdateJob> | null>(null)
|
||
const [stacks, setStacks] = useState<string[]>([])
|
||
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<string[]>([])
|
||
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 (
|
||
<div className="svc-detail">
|
||
<div className="dash-bar"><h2 className="section">What is out of date</h2><span className="spacer" />
|
||
<span className="muted small">{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked yet'}</span>
|
||
{behindStacks.length > 0 && <Btn className="btn on" disabled={!!running} onClick={() => 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}</Btn>}
|
||
<Btn className="btn" busy={busy} onClick={check}>Check now</Btn>
|
||
</div>
|
||
<div className="muted small">Each check is one request per image, which registries count — so it happens when asked, and the answer is kept for six hours.</div>
|
||
{imgs.length === 0 && <div className="notice" style={{ marginTop: 8 }}>Nothing checked yet on this machine.</div>}
|
||
{imgs.length > 0 && (
|
||
<section className="card" style={{ marginTop: 8 }}>
|
||
{behind.length === 0 && <div className="muted">Every image that could be checked is current.</div>}
|
||
<div className="upd-list">
|
||
{imgs.filter((i) => i.state !== 'current').map((i) => (
|
||
<div className="row" key={i.image}>
|
||
<span className="k mono small">{i.image}</span>
|
||
<span className={i.state === 'update' ? 'warn-text' : 'muted small'}>{i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'}</span>
|
||
<span className="muted small">{stacksFor(i.image).join(' ')}</span>
|
||
{i.state === 'update' && (
|
||
<Btn className="btn" disabled={!!running || stacksFor(i.image).length === 0} title={stacksFor(i.image).length ? `pull it and recreate ${stacksFor(i.image).join(', ')}` : 'no stack here uses this image'}
|
||
onClick={() => updateStacks(stacksFor(i.image), `${i.image}`)}>Update</Btn>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{behind.length === 0 && imgs.some((i) => i.state === 'current') && <div className="muted small" style={{ marginTop: 6 }}>{imgs.filter((i) => i.state === 'current').length} current.</div>}
|
||
{running && (
|
||
<div style={{ marginTop: 8 }}>
|
||
<div className="sub-head"><span className="dname">Updating {running.stack}</span><span className="spacer" /><span className="muted small">{queue.length > 0 ? `${queue.length} to follow` : 'last one'}</span></div>
|
||
<TaskOutput id={running.id} onDone={(t) => {
|
||
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)
|
||
}} />
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
<div className="dash-bar" style={{ marginTop: 14 }}><h2 className="section">Schedules</h2><span className="spacer" />
|
||
<Btn className="btn on" onClick={() => setEditing({ cron: '0 3 * * *', apply: true, enabled: true })}>+ New schedule</Btn>
|
||
</div>
|
||
<div className="muted small">Written to <span className="mono">{file || '/etc/cron.d/hamlab-docker'}</span>, which Ham Lab owns — so they keep running whether or not this console is up.</div>
|
||
{jobs === null && <Loading label="Reading…" />}
|
||
{jobs?.length === 0 && <div className="notice" style={{ marginTop: 8 }}>No schedule yet. Nothing on this machine updates itself.</div>}
|
||
<div className="cards" style={{ marginTop: 8 }}>
|
||
{(jobs ?? []).map(({ job, next, last }) => (
|
||
<section className="card" key={job.id}>
|
||
<div className="sub-head">
|
||
<span className={'dot ' + (last?.error ? 'bad' : job.enabled ? 'ok' : '')} />
|
||
<span className="dname">{job.name}</span>
|
||
{!job.enabled && <span className="badge">paused</span>}
|
||
<span className="spacer" />
|
||
<span className="muted small">{job.enabled && next ? new Date(next).toLocaleString() : 'not scheduled'}</span>
|
||
</div>
|
||
<div className="props" style={{ marginTop: 6 }}>
|
||
<div><span className="k">Stack</span><span>{job.stack}{job.services?.length ? ` · ${job.services.join(' ')}` : ''}</span></div>
|
||
<div><span className="k">Runs</span><span>{describeCron(job.cron) || <span className="mono small">{job.cron}</span>}{describeCron(job.cron) && <span className="muted mono small"> · {job.cron}</span>}</span></div>
|
||
<div><span className="k">Does</span><span>{job.apply ? 'pulls and deploys' : 'pulls only — you deploy'}</span></div>
|
||
<div><span className="k">Last run</span><span>{last?.at ? new Date(last.at).toLocaleString() : 'never'}</span></div>
|
||
</div>
|
||
{last?.error && <div className="error" style={{ marginTop: 6 }}>{last.error}</div>}
|
||
<div className="svc-actions">
|
||
<Btn className="btn on" onClick={() => run(job)}>Run now</Btn>
|
||
<button className="btn" onClick={() => setEditing(job)}>Edit</button>
|
||
<Btn className="btn" onClick={async () => { await api.saveUpdateJob({ ...job, enabled: !job.enabled }); loadJobs() }}>{job.enabled ? 'Pause' : 'Resume'}</Btn>
|
||
<Btn className="btn danger" onClick={() => remove(job)}>Remove</Btn>
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
{editing && <JobDialog j={editing} stacks={stacks} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); loadJobs() }} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function JobDialog({ j, stacks, onClose, onSaved }: { j: Partial<UpdateJob>; stacks: string[]; onClose: () => void; onSaved: () => void }) {
|
||
const [f, setF] = useState<Partial<UpdateJob>>(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 (
|
||
<div className="modal-back" onClick={onClose}>
|
||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||
<h3>{f.id ? 'Edit schedule' : 'Keep a stack up to date'}</h3>
|
||
<label className="muted">Stack
|
||
<select value={f.stack ?? ''} onChange={(e) => set('stack', e.target.value)} required>
|
||
<option value="">choose…</option>
|
||
{stacks.map((s) => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</label>
|
||
<label className="muted">Name — optional
|
||
<input value={f.name ?? ''} onChange={(e) => set('name', e.target.value)} placeholder={f.stack ?? 'nightly'} maxLength={64} />
|
||
</label>
|
||
<label className="muted">Only these services — optional, space separated; blank means the whole stack
|
||
<input className="mono" value={svc} onChange={(e) => setSvc(e.target.value)} placeholder="all of them" spellCheck={false} />
|
||
</label>
|
||
<label className="muted">When — a cron expression
|
||
<input className="mono" value={f.cron ?? ''} onChange={(e) => set('cron', e.target.value)} placeholder="0 3 * * *" spellCheck={false} required />
|
||
</label>
|
||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||
<input type="checkbox" checked={!!f.apply} onChange={(e) => set('apply', e.target.checked)} style={{ width: 'auto' }} />
|
||
Deploy the new image, not just fetch it
|
||
</label>
|
||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||
<input type="checkbox" checked={!!f.enabled} onChange={(e) => set('enabled', e.target.checked)} style={{ width: 'auto' }} />
|
||
Enabled
|
||
</label>
|
||
<div className="notice">
|
||
{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.'}
|
||
</div>
|
||
{err && <div className="error">{err}</div>}
|
||
<div className="svc-actions">
|
||
<button type="submit" className="btn on" disabled={busy || !f.stack || !f.cron}>Save</button>
|
||
<span className="spacer" />
|
||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|