import { formatDateTime } from './locale'; import { useDeferredValue, useEffect, useRef, useState } from 'react'; import { copy } from './copy'; import { queryValue, replaceListQuery } from './listQuery'; import { presentReason, presentStatus } from './presentation'; type ContainerItem = { id: string; name: string; image?: string; imageDigest?: string; state: string; health: string; intentionalStop: boolean; metricsAvailable?: boolean; lifecycleAvailable?: boolean; uptimeSeconds: number; restartCount: number; exitCode: number; cpuPercent: number; memoryBytes: number; memoryLimitBytes: number; networkRxBytes: number; networkTxBytes: number; blockReadBytes: number; blockWriteBytes: number; project?: string; ports?: Array<{ containerPort: number; hostPort?: number; protocol: string }>; }; type ContainerSnapshot = { source: { id: string; state: string; reason?: string }; containers: ContainerItem[]; total: number; nextCursor?: string }; function bytes(value: number): string { if (!Number.isFinite(value) || value < 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let scaled = value; let index = 0; while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; } return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index]; } function duration(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return '—'; const hours = Math.floor(seconds / 3600); return hours >= 24 ? Math.floor(hours / 24) + ' d ' + (hours % 24) + ' u' : hours + ' u'; } type BadgeTone = 'ready' | 'attention' | 'unknown'; function Badge({ label, tone }: { label: string; tone: BadgeTone }) { return {label}; } function runtimeTone(state: string, sourceHealthy = true): BadgeTone { if (!sourceHealthy) return 'unknown'; switch (state.toLowerCase()) { case 'running': return 'ready'; case 'restarting': case 'paused': case 'exited': case 'dead': case 'stopped': return 'attention'; default: return 'unknown'; } } function healthTone(health: string, sourceHealthy = true): BadgeTone { if (!sourceHealthy) return 'unknown'; switch (health.toLowerCase()) { case 'healthy': return 'ready'; case 'unhealthy': return 'attention'; default: return 'unknown'; } } export function ContainerPage() { const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); const [snapshot, setSnapshot] = useState(null); const [query, setQuery] = useState(() => queryValue('q')); const deferredQuery = useDeferredValue(query); const [stateFilter, setStateFilter] = useState(() => queryValue('state')); const [healthFilter, setHealthFilter] = useState(() => queryValue('health')); const [sort, setSort] = useState(() => queryValue('sort', ['name', 'cpu', 'memory', 'state'], 'name')); const [cursor, setCursor] = useState(() => queryValue('after')); const [history, setHistory] = useState([]); const pageStatus = useRef(null); const [reload, setReload] = useState(0); useEffect(() => { const controller = new AbortController(); setState((current) => current === 'ready' ? 'ready' : 'loading'); const params = new URLSearchParams({ limit: '25', sort }); if (deferredQuery.trim()) params.set('q', deferredQuery.trim()); if (stateFilter) params.set('state', stateFilter); if (healthFilter) params.set('health', healthFilter); if (cursor) params.set('after', cursor); replaceListQuery({ q: deferredQuery.trim(), state: stateFilter, health: healthFilter, sort: sort === 'name' ? '' : sort, after: cursor }); fetch('/api/v1/containers?' + params, { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('containers'); return response.json() as Promise; }).then((data) => { setSnapshot(data); setState('ready'); if (cursor) requestAnimationFrame(() => pageStatus.current?.focus()); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, [deferredQuery, stateFilter, healthFilter, sort, cursor, reload]); const resetPage = () => { setCursor(''); setHistory([]); }; const previous = () => { const prior = [...history]; setCursor(prior.pop() ?? ''); setHistory(prior); }; const next = () => { if (!snapshot?.nextCursor) return; setHistory((values) => [...values, cursor]); setCursor(snapshot.nextCursor ?? ''); }; if (state === 'loading') return
; if (state === 'error' || !snapshot) return

{copy.containers.errorTitle}

{copy.containers.errorDetail}

; const available = snapshot.source?.state === 'healthy'; return <>

{copy.containers.eyebrow}

{copy.containers.title}

{copy.containers.intro}

{copy.containers.source}

{snapshot.source?.id || copy.containers.unknown}

{snapshot.source?.reason ? presentReason(snapshot.source.reason) : copy.containers.readOnly}

{snapshot.total} {copy.containers.rows} · {copy.containers.limitNote}

{copy.containers.list}

{copy.containers.top}

event.preventDefault()}>
{snapshot.containers.length === 0 ?

{copy.containers.empty}

: <>
{snapshot.containers.map((item) => )}
{copy.containers.name}{copy.containers.state}{copy.containers.health}{copy.containers.resources}{copy.containers.image}
{item.name}{item.project || copy.containers.noProject} · {item.lifecycleAvailable ? duration(item.uptimeSeconds) : copy.containers.unknown}{item.metricsAvailable ? item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : copy.containers.unknown}{item.metricsAvailable ? `${bytes(item.memoryBytes)} / ${bytes(item.memoryLimitBytes)}` : copy.containers.metricsUnavailable}{item.image || copy.containers.unknown}
    {snapshot.containers.map((item) =>
  • {item.name}
    {copy.containers.resources}
    {item.metricsAvailable ? `${item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% · ${bytes(item.memoryBytes)}` : copy.containers.unknown}
    {copy.containers.image}
    {item.image || copy.containers.unknown}
  • )}
}
; } type ContainerDetailResponse = { source: { id: string; state: string; freshness: string; observedAt?: string; receivedAt?: string; reason?: string }; container: ContainerItem }; export function ContainerDetailPage({ id }: { id: string }) { const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); const [detail, setDetail] = useState(null); useEffect(() => { const controller = new AbortController(); fetch('/api/v1/containers/' + encodeURIComponent(id), { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('container-detail'); return response.json() as Promise; }).then((data) => { setDetail(data); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, [id]); if (state === 'loading') return
; if (state === 'error' || !detail) return

{copy.containers.errorTitle}

{copy.containers.errorDetail}

{copy.containers.backToList}
; const item = detail.container; const fresh = detail.source?.freshness === 'fresh'; return <>

{copy.containers.detailEyebrow}

{item.name}

{copy.containers.detailIntro}

{copy.containers.source}

{detail.source?.id || copy.containers.unknown}

{detail.source?.reason ? presentReason(detail.source.reason) : (fresh ? copy.containers.fresh : copy.containers.stale)}

{copy.containers.observed}: {detail.source?.observedAt ? formatDateTime(detail.source.observedAt) : '—'} · {fresh ? copy.containers.fresh : copy.containers.stale}

{item.intentionalStop && {copy.containers.intentionalStop}}

{copy.containers.resources}

{item.metricsAvailable ? item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : copy.containers.unknown}

{item.metricsAvailable ? `${bytes(item.memoryBytes)} / ${bytes(item.memoryLimitBytes)}` : copy.containers.metricsUnavailable} · {copy.containers.restarts}: {item.lifecycleAvailable ? item.restartCount : copy.containers.unknown}

{copy.containers.image}

{item.image || copy.containers.unknown}

{item.project || copy.containers.noProject}

{copy.containers.technical}
ID
{item.id}
{copy.containers.exitCode}
{item.lifecycleAvailable ? item.exitCode : copy.containers.unknown}
{copy.containers.network}
{item.metricsAvailable ? `${bytes(item.networkRxBytes)} RX / ${bytes(item.networkTxBytes)} TX` : copy.containers.unknown}
{copy.containers.blockIO}
{item.metricsAvailable ? `${bytes(item.blockReadBytes)} read / ${bytes(item.blockWriteBytes)} write` : copy.containers.unknown}
{copy.containers.digest}
{item.imageDigest || copy.containers.noDigest}

{copy.containers.ports}

{item.ports?.length ?
    {item.ports.map((port) =>
  • {port.hostPort || '—'} → {port.containerPort}/{port.protocol}
  • )}
:

{copy.containers.noData}

}
; }