Alerts tab, Docker tab, nine new skins including HUD, a colour for every machine
Alerts: rules over sensors, watched fleet-wide from the hub; ntfy and webhook channels; firing count on the rail. Docker: containers by stack with live use, start/stop/restart, following logs, inspect, images/volumes/networks. Skins: HUD, Nord, Gruvbox, Catppuccin (+Latte), Dracula, Tokyo Night, Solarized (+Light). Accents: per-machine colour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
63fcba6077
commit
cb1617c2b8
@@ -0,0 +1,143 @@
|
||||
// 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 } from './api'
|
||||
import { Sensor, fmtValue, useSensorStream } from './sensors'
|
||||
import { useHashSel } from './hashsel'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { Btn, Loading, toast } from './ui'
|
||||
|
||||
type Tab = 'containers' | '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') => 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">
|
||||
<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" 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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Docker() {
|
||||
const [d, setD] = useState<DockerInfo | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [tab, setTab] = useState<Tab>('containers')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [sel, setSel] = useHashSel()
|
||||
const rate = useRefreshMs()
|
||||
const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [])
|
||||
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])
|
||||
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 cur = d.containers.find((c) => c.name === sel)
|
||||
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'>, [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 && tab === 'containers' ? ' has-sel' : '')}>
|
||||
<div className="svc-list">
|
||||
<div className="svc-tools">
|
||||
<div className="chips">{(['containers', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (tab === t ? ' on' : '')} onClick={() => setTab(t)}>{t} · {t === 'containers' ? `${up}/${d.containers.length}` : d[t].length}</button>)}</div>
|
||||
{tab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
||||
</div>
|
||||
<div className="svc-rows">
|
||||
{tab === '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>
|
||||
))}
|
||||
{tab === 'containers' && byStack.size === 0 && <div className="muted small" style={{ padding: 12 }}>No container matches.</div>}
|
||||
{tab !== 'containers' && (
|
||||
<table className="dtable"><thead><tr>{cols[tab].map(([, h]) => <th key={h}>{h}</th>)}</tr></thead>
|
||||
<tbody>{d[tab].map((row, i) => <tr key={i}>{cols[tab].map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}</tr>)}</tbody></table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{tab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : <div className="svc-empty muted">Select a container.</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user