Public source validation / validate (push) Failing after 3m8s
145 lines
15 KiB
TypeScript
145 lines
15 KiB
TypeScript
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>
|
||
</>;
|
||
}
|