Files
ITWorx-Pulse-Public/apps/web/src/ContainerPage.tsx
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

145 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : tone === 'attention' ? '!' : '?'}</span>{label}</span>;
}
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<ContainerSnapshot | null>(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<string[]>([]);
const pageStatus = useRef<HTMLSpanElement>(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<ContainerSnapshot>;
}).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 <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.containers.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.containers.errorTitle}</h1><p>{copy.containers.errorDetail}</p><button className="button" type="button" onClick={() => setReload((value) => value + 1)}>{copy.containers.retry}</button></section>;
const available = snapshot.source?.state === 'healthy';
return <>
<header className="page-intro"><p className="eyebrow">{copy.containers.eyebrow}</p><h1>{copy.containers.title}</h1><p className="intro">{copy.containers.intro}</p></header>
<section className="card container-summary" aria-labelledby="container-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.containers.source}</p><h2 id="container-summary-title">{snapshot.source?.id || copy.containers.unknown}</h2><p className="card-copy">{snapshot.source?.reason ? presentReason(snapshot.source.reason) : copy.containers.readOnly}</p></div><Badge label={available ? copy.containers.available : copy.containers.unknown} tone={available ? 'ready' : 'unknown'} /></div><p className="container-count">{snapshot.total} {copy.containers.rows} · {copy.containers.limitNote}</p></section>
<section className="card container-panel" aria-labelledby="container-list-title">
<div className="card-heading"><div><p className="card-kicker">{copy.containers.list}</p><h2 id="container-list-title">{copy.containers.top}</h2></div></div>
<form className="list-filters" onSubmit={(event) => event.preventDefault()}>
<label>{copy.containers.search}<input type="search" value={query} placeholder={copy.containers.searchPlaceholder} onChange={(event) => { setQuery(event.target.value); resetPage(); }} /></label>
<label>{copy.containers.filterState}<select value={stateFilter} onChange={(event) => { setStateFilter(event.target.value); resetPage(); }}><option value="">{copy.containers.all}</option><option value="running">{presentStatus('running')}</option><option value="exited">{presentStatus('exited')}</option><option value="restarting">{presentStatus('restarting')}</option><option value="paused">{presentStatus('paused')}</option><option value="unknown">{presentStatus('unknown')}</option></select></label>
<label>{copy.containers.filterHealth}<select value={healthFilter} onChange={(event) => { setHealthFilter(event.target.value); resetPage(); }}><option value="">{copy.containers.all}</option><option value="healthy">{presentStatus('healthy')}</option><option value="unhealthy">{presentStatus('unhealthy')}</option><option value="starting">{presentStatus('starting')}</option><option value="unknown">{presentStatus('unknown')}</option></select></label>
<label>{copy.containers.sort}<select value={sort} onChange={(event) => { setSort(event.target.value); resetPage(); }}><option value="name">{copy.containers.sortName}</option><option value="cpu">{copy.containers.sortCPU}</option><option value="memory">{copy.containers.sortMemory}</option><option value="state">{copy.containers.sortState}</option></select></label>
</form>
{snapshot.containers.length === 0 ? <p className="card-copy">{copy.containers.empty}</p> : <><div className="host-table-wrap desktop-data-view"><table className="host-table container-table"><thead><tr><th>{copy.containers.name}</th><th>{copy.containers.state}</th><th>{copy.containers.health}</th><th>{copy.containers.resources}</th><th>{copy.containers.image}</th></tr></thead><tbody>{snapshot.containers.map((item) => <tr key={item.id}><th scope="row"><a className="entity-link" href={"/containers/" + encodeURIComponent(item.id)}>{item.name}</a><small>{item.project || copy.containers.noProject} · {item.lifecycleAvailable ? duration(item.uptimeSeconds) : copy.containers.unknown}</small></th><td><Badge label={available ? presentStatus(item.state) : copy.containers.unknown} tone={runtimeTone(item.state, available)} /></td><td><Badge label={available ? presentStatus(item.health) : copy.containers.unknown} tone={healthTone(item.health, available)} /></td><td>{item.metricsAvailable ? item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : copy.containers.unknown}<small>{item.metricsAvailable ? `${bytes(item.memoryBytes)} / ${bytes(item.memoryLimitBytes)}` : copy.containers.metricsUnavailable}</small></td><td>{item.image || copy.containers.unknown}</td></tr>)}</tbody></table></div><ul className="mobile-data-list" aria-label={copy.containers.mobileList}>{snapshot.containers.map((item) => <li key={item.id}><a href={"/containers/" + encodeURIComponent(item.id)}><strong>{item.name}</strong></a><div><Badge label={available ? presentStatus(item.state) : copy.containers.unknown} tone={runtimeTone(item.state, available)} /><Badge label={available ? presentStatus(item.health) : copy.containers.unknown} tone={healthTone(item.health, available)} /></div><dl><div><dt>{copy.containers.resources}</dt><dd>{item.metricsAvailable ? `${item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% · ${bytes(item.memoryBytes)}` : copy.containers.unknown}</dd></div><div><dt>{copy.containers.image}</dt><dd>{item.image || copy.containers.unknown}</dd></div></dl></li>)}</ul></>}
<nav className="list-pager" aria-label={copy.containers.page}><button className="button button--secondary" type="button" disabled={!cursor} onClick={previous}>{copy.containers.previous}</button><span ref={pageStatus} role="status" tabIndex={-1}>{copy.containers.page} {history.length + 1}</span><button className="button button--secondary" type="button" disabled={!snapshot.nextCursor} onClick={next}>{copy.containers.next}</button></nav>
</section>
</>;
}
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<ContainerDetailResponse | null>(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<ContainerDetailResponse>;
}).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 <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.containers.loading}</h1></section>;
if (state === 'error' || !detail) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.containers.errorTitle}</h1><p>{copy.containers.errorDetail}</p><a className="button" href="/containers">{copy.containers.backToList}</a></section>;
const item = detail.container;
const fresh = detail.source?.freshness === 'fresh';
return <>
<header className="page-intro"><p className="eyebrow">{copy.containers.detailEyebrow}</p><h1>{item.name}</h1><p className="intro">{copy.containers.detailIntro}</p><div className="detail-actions"><a className="button button--secondary" href="/containers">{copy.containers.backToList}</a><a className="button button--secondary" href="/dashboards?filterKey=sourceType&filterValue=inventory">{copy.containers.openDashboard}</a></div></header>
<section className="card container-summary" aria-labelledby="container-detail-summary"><div className="card-heading"><div><p className="card-kicker">{copy.containers.source}</p><h2 id="container-detail-summary">{detail.source?.id || copy.containers.unknown}</h2><p className="card-copy">{detail.source?.reason ? presentReason(detail.source.reason) : (fresh ? copy.containers.fresh : copy.containers.stale)}</p></div><Badge label={detail.source?.state === 'healthy' ? copy.containers.available : copy.containers.unknown} tone={detail.source?.state === 'healthy' ? 'ready' : 'unknown'} /></div><p className="container-provenance">{copy.containers.observed}: {detail.source?.observedAt ? formatDateTime(detail.source.observedAt) : '—'} · {fresh ? copy.containers.fresh : copy.containers.stale}</p><div className="container-detail-status"><Badge label={fresh ? presentStatus(item.state) : copy.containers.unknown} tone={runtimeTone(item.state, fresh)} /><Badge label={fresh ? presentStatus(item.health) : copy.containers.unknown} tone={healthTone(item.health, fresh)} />{item.intentionalStop && <span className="status-note">{copy.containers.intentionalStop}</span>}</div></section>
<section className="card-grid container-detail-grid" aria-label={copy.containers.metrics}><article className="card"><p className="card-kicker">{copy.containers.resources}</p><h2>{item.metricsAvailable ? item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : copy.containers.unknown}</h2><p className="card-copy">{item.metricsAvailable ? `${bytes(item.memoryBytes)} / ${bytes(item.memoryLimitBytes)}` : copy.containers.metricsUnavailable} · {copy.containers.restarts}: {item.lifecycleAvailable ? item.restartCount : copy.containers.unknown}</p></article><article className="card"><p className="card-kicker">{copy.containers.image}</p><h2>{item.image || copy.containers.unknown}</h2><p className="card-copy">{item.project || copy.containers.noProject}</p></article></section>
<details className="card technical-details"><summary>{copy.containers.technical}</summary><dl className="technical-grid"><dt>ID</dt><dd>{item.id}</dd><dt>{copy.containers.exitCode}</dt><dd>{item.lifecycleAvailable ? item.exitCode : copy.containers.unknown}</dd><dt>{copy.containers.network}</dt><dd>{item.metricsAvailable ? `${bytes(item.networkRxBytes)} RX / ${bytes(item.networkTxBytes)} TX` : copy.containers.unknown}</dd><dt>{copy.containers.blockIO}</dt><dd>{item.metricsAvailable ? `${bytes(item.blockReadBytes)} read / ${bytes(item.blockWriteBytes)} write` : copy.containers.unknown}</dd><dt>{copy.containers.digest}</dt><dd>{item.imageDigest || copy.containers.noDigest}</dd></dl><h2>{copy.containers.ports}</h2>{item.ports?.length ? <ul className="technical-list">{item.ports.map((port) => <li key={port.containerPort + '-' + port.protocol}>{port.hostPort || '—'} {port.containerPort}/{port.protocol}</li>)}</ul> : <p className="card-copy">{copy.containers.noData}</p>}</details>
</>;
}