Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="nl-BE">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/pulse-icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#0b1220" />
<title>ITWorx Pulse</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@itworx/pulse-web",
"private": true,
"version": "1.5.0",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.3",
"@types/node": "^26.2.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.0.5",
"@vitest/coverage-v8": "^4.1.10",
"jsdom": "^30.0.1",
"typescript": "7.0.2",
"vite": "8.2.0",
"vitest": "^4.1.10"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test';
const realStackBaseURL = process.env.PULSE_E2E_REAL_BASE_URL;
const localWebPort = process.env.PULSE_E2E_WEB_PORT || '4173';
const localBaseURL = `http://127.0.0.1:${localWebPort}`;
export default defineConfig({
testDir: './tests/e2e',
outputDir: '../../test-results/playwright',
reporter: [['list'], ['html', { outputFolder: '../../playwright-report', open: 'never' }]],
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
use: {
baseURL: realStackBaseURL || localBaseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: realStackBaseURL ? undefined : {
command: `pnpm dev --host 127.0.0.1 --port ${localWebPort} --strictPort`,
url: `${localBaseURL}/healthz`,
reuseExistingServer: false,
timeout: 120_000,
},
projects: [
{ name: 'desktop-chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } },
{ name: 'tablet-chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1024, height: 768 } } },
{ name: 'mobile-chromium', use: { ...devices['Pixel 7'], viewport: { width: 390, height: 844 } } },
{ name: 'wallboard-chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 } } },
],
});
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title description">
<title id="title">ITWorx Pulse</title>
<desc id="description">Een blauwe Pulse-letter P met een operationele signaalgolf.</desc>
<defs>
<linearGradient id="surface" x1="64" y1="48" x2="448" y2="464" gradientUnits="userSpaceOnUse">
<stop stop-color="#13263f"/>
<stop offset="1" stop-color="#07111f"/>
</linearGradient>
<linearGradient id="accent" x1="118" y1="128" x2="398" y2="382" gradientUnits="userSpaceOnUse">
<stop stop-color="#79b8ff"/>
<stop offset="1" stop-color="#408cff"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="18" stdDeviation="24" flood-color="#020817" flood-opacity=".55"/>
</filter>
</defs>
<rect x="24" y="24" width="464" height="464" rx="112" fill="url(#surface)" stroke="#263b57" stroke-width="8" filter="url(#shadow)"/>
<path d="M154 374V138h108c76 0 126 39 126 104 0 67-50 108-126 108h-42v24h-66Zm66-82h39c40 0 62-17 62-49 0-30-22-47-62-47h-39v96Z" fill="url(#accent)"/>
<path d="M100 326h48l24-54 34 92 34-74 24 36h148" fill="none" stroke="#d9ebff" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="412" cy="326" r="12" fill="#79b8ff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
type Matcher = { ruleIds?: string[]; entityIds?: string[]; entityTypes?: string[]; severities?: string[]; labels?: Record<string, string> };
type Silence = { id: string; name: string; reason: string; owner: string; matchers: Matcher; startsAt: string; expiresAt: string; state: string; revision: number };
type Maintenance = { id: string; name: string; reason: string; selector: Matcher; startsAt: string; endsAt: string; state: string; revision: number };
type ControlForm = { name: string; reason: string; matcher: string; startsAt: string; expiresAt: string };
function localDate(offsetHours: number) { const date = new Date(Date.now() + offsetHours * 3600000); const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000); return local.toISOString().slice(0, 16); }
function toUTC(value: string) { return new Date(value).toISOString(); }
function matcherFor(value: string, key: 'severity' | 'entityType'): Matcher { const values = value.split(',').map((item) => item.trim()).filter(Boolean); return key === 'severity' ? { severities: values } : { entityTypes: values }; }
function stateLabel(value: string) { return value === 'active' ? copy.alerts.controlActive : value === 'scheduled' ? copy.alerts.controlScheduled : value === 'expired' ? copy.alerts.controlExpired : copy.alerts.controlRevoked; }
export function AlertControlsPanel() {
const [silences, setSilences] = useState<Silence[]>([]);
const [maintenance, setMaintenance] = useState<Maintenance[]>([]);
const [silenceForm, setSilenceForm] = useState<ControlForm>({ name: '', reason: '', matcher: 'critical', startsAt: localDate(0), expiresAt: localDate(1) });
const [maintenanceForm, setMaintenanceForm] = useState<ControlForm>({ name: '', reason: '', matcher: 'host', startsAt: localDate(0), expiresAt: localDate(1) });
const [preview, setPreview] = useState('');
const [message, setMessage] = useState('');
async function load() {
const [silenceResponse, maintenanceResponse] = await Promise.all([fetch('/api/v1/alert-silences?limit=100'), fetch('/api/v1/maintenance-windows?limit=100')]);
if (silenceResponse.ok) setSilences(((await silenceResponse.json()) as { items?: Silence[] }).items ?? []);
if (maintenanceResponse.ok) setMaintenance(((await maintenanceResponse.json()) as { items?: Maintenance[] }).items ?? []);
}
useEffect(() => { void load(); }, []);
async function createSilence() {
setMessage('');
const response = await fetch('/api/v1/alert-silences', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: silenceForm.name, reason: silenceForm.reason, owner: 'current-user', matchers: matcherFor(silenceForm.matcher, 'severity'), startsAt: toUTC(silenceForm.startsAt), expiresAt: toUTC(silenceForm.expiresAt) }) });
if (!response.ok) { setMessage(copy.alerts.controlSaveError); return; }
setSilenceForm({ ...silenceForm, name: '', reason: '' }); setMessage(copy.alerts.controlSaved); await load();
}
async function createMaintenance() {
setMessage('');
const response = await fetch('/api/v1/maintenance-windows', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: maintenanceForm.name, reason: maintenanceForm.reason, selector: matcherFor(maintenanceForm.matcher, 'entityType'), startsAt: toUTC(maintenanceForm.startsAt), endsAt: toUTC(maintenanceForm.expiresAt) }) });
if (!response.ok) { setMessage(copy.alerts.controlSaveError); return; }
setMaintenanceForm({ ...maintenanceForm, name: '', reason: '' }); setMessage(copy.alerts.controlSaved); await load();
}
async function revoke(kind: 'silence' | 'maintenance', id: string, revision: number) {
if (!window.confirm(copy.alerts.confirmRevoke)) return;
const endpoint = kind === 'silence' ? '/api/v1/alert-silences/' : '/api/v1/maintenance-windows/';
const response = await fetch(endpoint + encodeURIComponent(id) + '/revoke?revision=' + revision, { method: 'POST' });
if (!response.ok) { setMessage(copy.alerts.controlSaveError); return; }
await load();
}
async function previewMatcher() {
const response = await fetch('/api/v1/alert-silences/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ matcher: matcherFor(silenceForm.matcher, 'severity'), signals: [{ instanceId: 'preview-signal', severity: silenceForm.matcher.split(',')[0].trim() }] }) });
if (!response.ok) { setPreview(copy.alerts.controlPreviewError); return; }
const data = await response.json() as { preview?: { matchedCount?: number } };
setPreview(copy.alerts.controlPreview + ': ' + String(data.preview?.matchedCount ?? 0));
}
return <section className="card alert-controls" aria-labelledby="alert-controls-title">
<div className="card-heading"><div><p className="card-kicker">{copy.alerts.controls}</p><h2 id="alert-controls-title">{copy.alerts.controlsTitle}</h2></div><span className="status-badge status-badge--unknown">{copy.alerts.controlHistory}</span></div>
<p className="card-copy">{copy.alerts.controlsIntro}</p>
<div className="alert-control-grid">
<form className="control-form" onSubmit={(event) => { event.preventDefault(); void createSilence(); }}><h3>{copy.alerts.silenceTitle}</h3><label>{copy.alerts.controlName}<input required id="silence-name" name="silenceName" maxLength={160} value={silenceForm.name} onChange={(event) => setSilenceForm({ ...silenceForm, name: event.target.value })} /></label><label>{copy.alerts.controlReason}<input required id="silence-reason" name="silenceReason" maxLength={500} value={silenceForm.reason} onChange={(event) => setSilenceForm({ ...silenceForm, reason: event.target.value })} /></label><label>{copy.alerts.silenceSeverity}<select required id="silence-matcher" name="silenceMatcher" value={silenceForm.matcher} onChange={(event) => setSilenceForm({ ...silenceForm, matcher: event.target.value })}><option value="attention">{copy.presentation.status.attention}</option><option value="degraded">{copy.presentation.status.degraded}</option><option value="critical">{copy.presentation.status.critical}</option></select><small>{copy.alerts.matcherHelp}</small></label><label>{copy.alerts.startsAt}<input required id="silence-starts-at" name="silenceStartsAt" type="datetime-local" value={silenceForm.startsAt} onChange={(event) => setSilenceForm({ ...silenceForm, startsAt: event.target.value })} /></label><label>{copy.alerts.expiresAt}<input required id="silence-expires-at" name="silenceExpiresAt" type="datetime-local" value={silenceForm.expiresAt} onChange={(event) => setSilenceForm({ ...silenceForm, expiresAt: event.target.value })} /></label><div className="detail-actions"><button className="button" type="submit">{copy.alerts.createControl}</button><button className="button button--secondary" type="button" onClick={() => void previewMatcher()}>{copy.alerts.previewMatcher}</button></div>{preview && <small role="status">{preview}</small>}</form>
<form className="control-form" onSubmit={(event) => { event.preventDefault(); void createMaintenance(); }}><h3>{copy.alerts.maintenanceTitle}</h3><label>{copy.alerts.controlName}<input required id="maintenance-name" name="maintenanceName" maxLength={160} value={maintenanceForm.name} onChange={(event) => setMaintenanceForm({ ...maintenanceForm, name: event.target.value })} /></label><label>{copy.alerts.controlReason}<input required id="maintenance-reason" name="maintenanceReason" maxLength={500} value={maintenanceForm.reason} onChange={(event) => setMaintenanceForm({ ...maintenanceForm, reason: event.target.value })} /></label><label>{copy.alerts.entityType}<select required id="maintenance-selector" name="maintenanceSelector" value={maintenanceForm.matcher} onChange={(event) => setMaintenanceForm({ ...maintenanceForm, matcher: event.target.value })}><option value="host">{copy.alerts.entityHost}</option><option value="container">{copy.alerts.entityContainer}</option><option value="service">{copy.alerts.entityService}</option><option value="disk">{copy.alerts.entityDisk}</option><option value="pool">{copy.alerts.entityPool}</option></select><small>{copy.alerts.selectorHelp}</small></label><label>{copy.alerts.startsAt}<input required id="maintenance-starts-at" name="maintenanceStartsAt" type="datetime-local" value={maintenanceForm.startsAt} onChange={(event) => setMaintenanceForm({ ...maintenanceForm, startsAt: event.target.value })} /></label><label>{copy.alerts.endsAt}<input required id="maintenance-ends-at" name="maintenanceEndsAt" type="datetime-local" value={maintenanceForm.expiresAt} onChange={(event) => setMaintenanceForm({ ...maintenanceForm, expiresAt: event.target.value })} /></label><button className="button" type="submit">{copy.alerts.createControl}</button></form>
</div>
{message && <p className="form-message" role="status">{message}</p>}
<div className="alert-control-lists"><div><h3>{copy.alerts.silenceHistory}</h3>{silences.length === 0 ? <p className="card-copy">{copy.alerts.noControls}</p> : <ul className="inventory-list">{silences.map((item) => <li key={item.id}><span><strong>{item.name}</strong><small>{stateLabel(item.state)} · {item.reason}</small></span>{item.state === 'active' && <button className="button button--secondary" type="button" onClick={() => void revoke('silence', item.id, item.revision)}>{copy.alerts.revoke}</button>}</li>)}</ul>}</div><div><h3>{copy.alerts.maintenanceHistory}</h3>{maintenance.length === 0 ? <p className="card-copy">{copy.alerts.noControls}</p> : <ul className="inventory-list">{maintenance.map((item) => <li key={item.id}><span><strong>{item.name}</strong><small>{stateLabel(item.state)} · {item.reason}</small></span>{item.state === 'active' && <button className="button button--secondary" type="button" onClick={() => void revoke('maintenance', item.id, item.revision)}>{copy.alerts.revoke}</button>}</li>)}</ul>}</div></div>
</section>;
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
import { plural, presentReason, presentStatus } from './presentation';
type AlertItem = { id: string; state: string; retainedState: string; ruleName: string; severity: string; entityName?: string; reason: string; revision: number; acknowledgedBy?: string; updatedAt?: string; occurrences?: Array<{ eventType: string; from: string; to: string; observedAt: string; reason: string }> };
type AlertView = 'active' | 'critical' | 'acknowledged';
const stateOrder: Record<string, number> = { firing: 0, pending: 1, acknowledged: 2, silenced: 3, suppressed: 4, resolved: 5 };
const severityOrder: Record<string, number> = { critical: 0, degraded: 1, attention: 2, warning: 3, info: 4, unknown: 5 };
function alertTone(severity: string): 'critical' | 'attention' | 'ready' | 'unknown' {
if (severity === 'critical') return 'critical';
if (severity === 'degraded' || severity === 'attention' || severity === 'warning') return 'attention';
if (severity === 'info') return 'ready';
return 'unknown';
}
function isActive(item: AlertItem): boolean { return item.state !== 'resolved'; }
export function AlertOperationsPanel() {
const [items, setItems] = useState<AlertItem[]>([]);
const [selected, setSelected] = useState<AlertItem | null>(null);
const [message, setMessage] = useState('');
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [view, setView] = useState<AlertView>('active');
async function load(signal?: AbortSignal) {
try {
const response = await fetch('/api/v1/alerts?limit=100', { signal });
if (!response.ok) throw new Error('alerts');
setItems(((await response.json()) as { items?: AlertItem[] }).items ?? []);
setState('ready');
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
setState('error');
}
}
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, []);
const ordered = useMemo(() => items.slice().sort((left, right) =>
(severityOrder[left.severity] ?? 9) - (severityOrder[right.severity] ?? 9)
|| (stateOrder[left.state] ?? 9) - (stateOrder[right.state] ?? 9)
|| (right.updatedAt ?? '').localeCompare(left.updatedAt ?? '')
|| left.ruleName.localeCompare(right.ruleName, 'nl-BE')
|| left.id.localeCompare(right.id)), [items]);
const activeCount = items.filter(isActive).length;
const criticalCount = items.filter((item) => isActive(item) && item.severity === 'critical').length;
const acknowledgedCount = items.filter((item) => item.state === 'acknowledged').length;
const filtered = ordered.filter((item) => view === 'critical' ? isActive(item) && item.severity === 'critical' : view === 'acknowledged' ? item.state === 'acknowledged' : isActive(item));
const visible = filtered.slice(0, 20);
async function choose(item: AlertItem) {
const response = await fetch('/api/v1/alerts/' + encodeURIComponent(item.id) + '?occurrenceLimit=50');
if (response.ok) setSelected(((await response.json()) as { alert: AlertItem }).alert);
}
async function operate(item: AlertItem, acknowledge: boolean) {
if (!window.confirm(acknowledge ? copy.alerts.confirmAcknowledge : copy.alerts.confirmUnacknowledge)) return;
setMessage('');
const response = await fetch('/api/v1/alerts/' + encodeURIComponent(item.id) + '/' + (acknowledge ? 'acknowledge' : 'unacknowledge'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'If-Match': String(item.revision), 'Idempotency-Key': (globalThis.crypto?.randomUUID?.() ?? String(Date.now())) },
body: '{}',
});
if (!response.ok) { setMessage(copy.alerts.operationError); return; }
setMessage(copy.alerts.operationSaved);
await load();
if (selected?.id === item.id) await choose(item);
}
return <section className="alert-operations" aria-labelledby="alert-operations-title">
<div className="alert-operation-summary" aria-label={copy.alerts.operationSummary}>
<button type="button" aria-pressed={view === 'active'} onClick={() => setView('active')}><span>{copy.alerts.activeAlerts}</span><strong>{activeCount}</strong><small>{copy.alerts.activeAlertsDetail}</small></button>
<button className="alert-summary-critical" type="button" aria-pressed={view === 'critical'} onClick={() => setView('critical')}><span>{copy.alerts.criticalAlerts}</span><strong>{criticalCount}</strong><small>{copy.alerts.criticalAlertsDetail}</small></button>
<button type="button" aria-pressed={view === 'acknowledged'} onClick={() => setView('acknowledged')}><span>{copy.alerts.acknowledgedAlerts}</span><strong>{acknowledgedCount}</strong><small>{copy.alerts.acknowledgedAlertsDetail}</small></button>
</div>
<div className="card alert-operation-list">
<div className="card-heading"><div><p className="card-kicker">{copy.alerts.operations}</p><h2 id="alert-operations-title">{copy.alerts.alertList}</h2></div><span className="status-badge status-badge--unknown">{copy.alerts.historyPreserved}</span></div>
<p className="card-copy">{copy.alerts.operationsIntro}</p>
{state === 'loading' ? <p className="card-copy" role="status">{copy.alerts.operationsLoading}</p> : state === 'error' ? <p className="card-copy" role="alert">{copy.alerts.operationsError}</p> : visible.length === 0 ? <p className="card-copy">{copy.alerts.noActiveAlerts}</p> : <ul className="alert-operation-list-items">{visible.map((item) => <li key={item.id}>
<span className={'status-badge status-badge--' + alertTone(item.severity)}><span className="status-icon" aria-hidden="true">{item.severity === 'critical' ? '!' : '•'}</span>{presentStatus(item.severity)}</span>
<button className="alert-operation-button" type="button" onClick={() => void choose(item)}><strong>{item.ruleName || copy.alerts.unnamed}</strong><small>{presentStatus(item.state)}{item.entityName ? ' · ' + item.entityName : ''}</small></button>
<button className="button button--secondary alert-operation-action" type="button" onClick={() => void operate(item, item.state !== 'acknowledged')}>{item.state === 'acknowledged' ? copy.alerts.unacknowledge : copy.alerts.acknowledge}</button>
</li>)}</ul>}
{filtered.length > visible.length && <p className="card-copy">{copy.alerts.resultLimit.replace('{count}', String(visible.length)).replace('{total}', String(filtered.length))}</p>}
{selected && <div className="alert-detail" aria-live="polite"><h3>{selected.ruleName}</h3><p>{presentStatus(selected.state)} · {presentReason(selected.reason)}</p><small>{selected.occurrences?.length ?? 0} {plural(selected.occurrences?.length ?? 0, copy.alerts.occurrence, copy.alerts.occurrences)} · {copy.alerts.revision}: {selected.revision}</small></div>}
{message && <p className="form-message" role="status">{message}</p>}
</div>
</section>;
}
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
import { AlertControlsPanel } from './AlertControlsPanel';
import { AlertOperationsPanel } from './AlertOperationsPanel';
import { queryValue, replaceListQuery } from './listQuery';
import { presentMetric, presentReason, presentStatus, presentUnit } from './presentation';
type InputType = 'metric' | 'entity-status' | 'event' | 'datasource-health';
type Condition = { inputType: InputType; metric?: string; operator: string; threshold: number | string | null; recoveryThreshold?: number | null; aggregation?: string; windowSeconds?: number };
type Rule = {
id: string; schemaVersion: number; name: string; enabled: boolean; severity: string; scope: Record<string, unknown>;
condition: Condition; evaluationIntervalSeconds: number; pendingSeconds: number; resolveSeconds: number; cooldownSeconds?: number;
unknownBehavior: string; groupBy: string[]; suppressWhen: string[]; message: { titleKey: string; bodyKey: string };
revision: number; currentVersion: number; updatedAt?: string;
};
type DraftRule = Omit<Rule, 'revision' | 'currentVersion' | 'updatedAt'>;
type Preview = { wouldFire: boolean; state: string; reason: string };
type MetricDefinition = { semanticName: string; unit: string; defaultAggregation?: string };
const causes = [
{ value: 'host.unreachable', label: copy.alerts.causeHost },
{ value: 'dns.failure', label: copy.alerts.causeDns },
{ value: 'source.unavailable', label: copy.alerts.causeSource },
];
const orderedOperators = new Set(['>', '>=', '<', '<=']);
const supportedOperators = new Set(['>', '>=', '<', '<=', '==', '!=', 'matches', 'absent']);
const alertSections = ['operations', 'rules', 'controls'] as const;
type AlertSection = typeof alertSections[number];
function newRule(): DraftRule {
return {
id: globalThis.crypto?.randomUUID?.() ?? '00000000-0000-4000-8000-000000000000',
schemaVersion: 1, name: '', enabled: false, severity: 'attention', scope: {},
condition: { inputType: 'metric', metric: '', operator: '>', threshold: 80, aggregation: 'avg', windowSeconds: 60 },
evaluationIntervalSeconds: 30, pendingSeconds: 60, resolveSeconds: 120, cooldownSeconds: 300,
unknownBehavior: 'retain-firing-as-unknown', groupBy: [], suppressWhen: [],
message: { titleKey: 'alerts.rule.title', bodyKey: 'alerts.rule.body' },
};
}
export function AlertRulesPage() {
const [section, setSection] = useState<AlertSection>(() => queryValue('section', alertSections, 'operations') as AlertSection);
const [rules, setRules] = useState<Rule[]>([]);
const [draft, setDraft] = useState<DraftRule>(newRule);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'error' | 'unauthorized'>('loading');
const [message, setMessage] = useState('');
const [preview, setPreview] = useState<Preview | null>(null);
const [previewValue, setPreviewValue] = useState('90');
const [busy, setBusy] = useState(false);
const [metrics, setMetrics] = useState<MetricDefinition[]>([]);
const [metricState, setMetricState] = useState<'loading' | 'ready' | 'error'>('loading');
const selected = useMemo(() => rules.find((rule) => rule.id === selectedId), [rules, selectedId]);
useEffect(() => {
const controller = new AbortController();
fetch('/api/v1/alert-rules?limit=100', { signal: controller.signal })
.then((response) => {
if (response.status === 401 || response.status === 403) { setState('unauthorized'); throw new Error('unauthorized'); }
if (!response.ok) throw new Error('alert-rules');
return response.json() as Promise<{ items?: Rule[] }>;
})
.then((data) => {
const items = data.items ?? [];
setRules(items);
if (items[0]) { setSelectedId(items[0].id); setDraft(toDraft(items[0])); }
setState('ready');
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (error instanceof Error && error.message === 'unauthorized') return;
setState('error');
});
return () => controller.abort();
}, []);
useEffect(() => {
replaceListQuery({ section: section === 'operations' ? '' : section });
}, [section]);
useEffect(() => {
const controller = new AbortController();
fetch('/api/v1/metrics/catalog', { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error('metric-catalog');
return response.json() as Promise<{ metrics?: MetricDefinition[] }>;
})
.then((data) => {
setMetrics((data.metrics ?? []).slice().sort((a, b) => presentMetric(a.semanticName).localeCompare(presentMetric(b.semanticName), 'nl-BE')));
setMetricState('ready');
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
setMetricState('error');
});
return () => controller.abort();
}, []);
function chooseRule(rule: Rule) {
setSelectedId(rule.id);
setDraft(toDraft(rule));
setPreview(null);
setMessage('');
}
function updateField<K extends keyof DraftRule>(key: K, value: DraftRule[K]) {
setDraft((current) => ({ ...current, [key]: value }));
}
function updateCondition<K extends keyof Condition>(key: K, value: Condition[K]) {
setDraft((current) => ({ ...current, condition: { ...current.condition, [key]: value } }));
}
function chooseInputType(inputType: InputType) {
const presets: Record<InputType, Condition> = {
metric: { inputType, metric: '', operator: '>', threshold: 80, recoveryThreshold: null, aggregation: 'avg', windowSeconds: 60 },
event: { inputType, operator: '>=', threshold: 3, recoveryThreshold: null, aggregation: 'count', windowSeconds: 900 },
'entity-status': { inputType, operator: '==', threshold: 'degraded', recoveryThreshold: null, aggregation: 'none', windowSeconds: 60 },
'datasource-health': { inputType, operator: '==', threshold: 'stale', recoveryThreshold: null, aggregation: 'none', windowSeconds: 120 },
};
setDraft((current) => ({ ...current, condition: presets[inputType] }));
}
function chooseOperator(operator: string) {
setDraft((current) => ({
...current,
condition: {
...current.condition,
operator,
threshold: operator === 'absent' ? null : operator === 'matches' ? String(current.condition.threshold ?? '') : current.condition.threshold,
recoveryThreshold: orderedOperators.has(operator) ? current.condition.recoveryThreshold : null,
},
}));
}
async function save() {
const validation = validateDraft(draft, metrics);
if (validation) { setMessage(validation); return; }
setBusy(true); setMessage('');
try {
const selectedRule = selectedId ? rules.find((rule) => rule.id === selectedId) : undefined;
const response = await fetch(selectedRule ? '/api/v1/alert-rules/' + encodeURIComponent(selectedRule.id) : '/api/v1/alert-rules', {
method: selectedRule ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json', ...(selectedRule ? { 'If-Match': String(selectedRule.revision) } : {}) },
body: JSON.stringify(draft),
});
if (!response.ok) throw new Error(response.status === 409 ? copy.alerts.conflict : copy.alerts.saveError);
const data = await response.json() as { rule: Rule };
setRules((current) => selectedRule ? current.map((rule) => rule.id === data.rule.id ? data.rule : rule) : [data.rule, ...current]);
setSelectedId(data.rule.id);
setDraft(toDraft(data.rule));
setMessage(copy.alerts.saved);
} catch (error) { setMessage(error instanceof Error ? error.message : copy.alerts.saveError); }
finally { setBusy(false); }
}
function toggleCause(value: string, checked: boolean) {
updateField('suppressWhen', checked
? [...new Set([...draft.suppressWhen, value])]
: draft.suppressWhen.filter((cause) => cause !== value));
}
async function toggle(enabled: boolean) {
if (!selected) return;
if (!window.confirm(enabled ? copy.alerts.confirmRuleEnable : copy.alerts.confirmRuleDisable)) return;
setBusy(true); setMessage('');
try {
const response = await fetch('/api/v1/alert-rules/' + encodeURIComponent(selected.id) + '/' + (enabled ? 'enable' : 'disable'), {
method: 'POST', headers: { 'If-Match': String(selected.revision) },
});
if (!response.ok) throw new Error(response.status === 409 ? copy.alerts.conflict : copy.alerts.toggleError);
const data = await response.json() as { rule: Rule };
setRules((current) => current.map((rule) => rule.id === data.rule.id ? data.rule : rule));
setDraft(toDraft(data.rule));
setMessage(copy.alerts.stateSaved);
} catch (error) { setMessage(error instanceof Error ? error.message : copy.alerts.toggleError); }
finally { setBusy(false); }
}
async function testPreview() {
setBusy(true); setMessage('');
try {
const response = await fetch('/api/v1/alert-rules/' + encodeURIComponent(draft.id) + '/test', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rule: draft, value: parsePreviewValue(previewValue) }),
});
if (!response.ok) throw new Error(copy.alerts.previewError);
const data = await response.json() as { preview: Preview };
setPreview(data.preview);
} catch (error) { setMessage(error instanceof Error ? error.message : copy.alerts.previewError); }
finally { setBusy(false); }
}
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.alerts.loading}</h1></section>;
if (state === 'unauthorized') return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">!</span><h1>{copy.alerts.unauthorizedTitle}</h1><p>{copy.alerts.unauthorizedDetail}</p></section>;
if (state === 'error') return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.alerts.errorTitle}</h1><p>{copy.alerts.errorDetail}</p></section>;
return <><header className="page-intro"><p className="eyebrow">{copy.alerts.eyebrow}</p><h1>{copy.alerts.title}</h1><p className="intro">{copy.alerts.intro}</p></header>
<nav className="alert-section-nav" aria-label={copy.alerts.sectionNavigation}>
<button type="button" aria-current={section === 'operations' ? 'page' : undefined} onClick={() => setSection('operations')}><span>{copy.alerts.sectionOperations}</span><small>{copy.alerts.sectionOperationsDetail}</small></button>
<button type="button" aria-current={section === 'rules' ? 'page' : undefined} onClick={() => setSection('rules')}><span>{copy.alerts.sectionRules}</span><small>{copy.alerts.sectionRulesDetail}</small></button>
<button type="button" aria-current={section === 'controls' ? 'page' : undefined} onClick={() => setSection('controls')}><span>{copy.alerts.sectionControls}</span><small>{copy.alerts.sectionControlsDetail}</small></button>
</nav>
{section === 'operations' && <AlertOperationsPanel />}
{section === 'rules' && <section className="alert-layout">
<div className="card" aria-labelledby="alert-rules-title">
<div className="card-heading"><div><p className="card-kicker">{copy.alerts.rules}</p><h2 id="alert-rules-title">{copy.alerts.ruleList}</h2></div><button className="button button--secondary" type="button" onClick={() => { setSelectedId(null); setDraft(newRule()); setPreview(null); }}>{copy.alerts.newRule}</button></div>
{rules.length === 0 ? <p className="card-copy">{copy.alerts.noRules}</p> : <ul className="inventory-list">{rules.map((rule) => <li key={rule.id}><button className="alert-rule-button" type="button" onClick={() => chooseRule(rule)}><strong>{rule.name || copy.alerts.unnamed}</strong><small>{presentStatus(rule.severity)} · v{rule.currentVersion} · {rule.enabled ? copy.alerts.enabled : copy.alerts.disabled}</small></button><span className={rule.enabled ? 'rule-state rule-state--enabled' : 'rule-state'}>{rule.enabled ? copy.alerts.enabled : copy.alerts.disabled}</span></li>)}</ul>}
</div>
<div className="card" aria-labelledby="alert-editor-title">
<div className="card-heading"><div><p className="card-kicker">{copy.alerts.editor}</p><h2 id="alert-editor-title">{selected ? copy.alerts.editRule : copy.alerts.createRule}</h2></div>{selected && <span className="status-badge status-badge--ready">v{selected.currentVersion}</span>}</div>
<div className="form-grid">
<label>{copy.alerts.name}<input autoComplete="off" id="alert-rule-name" name="name" value={draft.name} onChange={(event) => updateField('name', event.target.value)} maxLength={160} /></label>
<label>{copy.alerts.severity}<select id="alert-rule-severity" name="severity" value={draft.severity} onChange={(event) => updateField('severity', event.target.value)}><option value="attention">{copy.presentation.status.attention}</option><option value="degraded">{copy.presentation.status.degraded}</option><option value="critical">{copy.presentation.status.critical}</option></select></label>
<label className="form-grid__wide">{copy.alerts.inputType}<select id="alert-rule-input-type" name="inputType" value={draft.condition.inputType} onChange={(event) => chooseInputType(event.target.value as InputType)}><option value="metric">{copy.alerts.inputMetric}</option><option value="event">{copy.alerts.inputEvent}</option><option value="entity-status">{copy.alerts.inputEntityStatus}</option><option value="datasource-health">{copy.alerts.inputDatasourceHealth}</option></select><small>{copy.alerts.inputTypeHelp}</small></label>
{draft.condition.inputType === 'metric' && <label className="form-grid__wide">{copy.alerts.metric}<select required id="alert-rule-metric" name="metric" value={draft.condition.metric ?? ''} disabled={metricState !== 'ready'} onChange={(event) => { const metric = metrics.find((item) => item.semanticName === event.target.value); updateCondition('metric', event.target.value); if (metric?.defaultAggregation) updateCondition('aggregation', metric.defaultAggregation); }}><option value="">{metricState === 'loading' ? copy.alerts.metricLoading : metricState === 'error' ? copy.alerts.metricUnavailable : copy.alerts.chooseMetric}</option>{metrics.map((metric) => <option key={metric.semanticName} value={metric.semanticName}>{presentMetric(metric.semanticName)} ({presentUnit(metric.unit)})</option>)}</select><small>{copy.alerts.metricHelp}</small></label>}
<label>{copy.alerts.operator}<select id="alert-rule-operator" name="operator" value={draft.condition.operator} onChange={(event) => chooseOperator(event.target.value)}><option value=">">{copy.alerts.greaterThan}</option><option value=">=">{copy.alerts.greaterThanOrEqual}</option><option value="<">{copy.alerts.lessThan}</option><option value="<=">{copy.alerts.lessThanOrEqual}</option><option value="==">{copy.alerts.equalTo}</option><option value="!=">{copy.alerts.notEqualTo}</option><option value="matches">{copy.alerts.matches}</option><option value="absent">{copy.alerts.absent}</option></select></label>
<label>{copy.alerts.threshold}<input autoComplete="off" id="alert-rule-threshold" name="threshold" type={draft.condition.inputType === 'metric' || orderedOperators.has(draft.condition.operator) ? 'number' : 'text'} disabled={draft.condition.operator === 'absent'} value={String(draft.condition.threshold ?? '')} onChange={(event) => updateCondition('threshold', event.target.type === 'number' ? Number(event.target.value) : event.target.value)} /></label>{orderedOperators.has(draft.condition.operator) && <label>{copy.alerts.recoveryThreshold}<input autoComplete="off" id="alert-rule-recovery-threshold" name="recoveryThreshold" type="number" value={String(draft.condition.recoveryThreshold ?? '')} onChange={(event) => updateCondition('recoveryThreshold', event.target.value === '' ? null : Number(event.target.value))} /><small>{copy.alerts.recoveryThresholdHelp}</small></label>}
<label>{copy.alerts.interval}<input autoComplete="off" id="alert-rule-interval" name="evaluationIntervalSeconds" type="number" min={5} max={3600} value={draft.evaluationIntervalSeconds} onChange={(event) => updateField('evaluationIntervalSeconds', Number(event.target.value))} /></label>
<label>{copy.alerts.pending}<input autoComplete="off" id="alert-rule-pending" name="pendingSeconds" type="number" min={0} value={draft.pendingSeconds} onChange={(event) => updateField('pendingSeconds', Number(event.target.value))} /></label>
<label>{copy.alerts.resolve}<input autoComplete="off" id="alert-rule-resolve" name="resolveSeconds" type="number" min={0} value={draft.resolveSeconds} onChange={(event) => updateField('resolveSeconds', Number(event.target.value))} /></label><label>{copy.alerts.cooldown}<input autoComplete="off" id="alert-rule-cooldown" name="cooldownSeconds" type="number" min={0} value={draft.cooldownSeconds ?? 0} onChange={(event) => updateField('cooldownSeconds', Number(event.target.value))} /><small>{copy.alerts.cooldownHelp}</small></label>
<fieldset className="form-grid__wide guided-options"><legend>{copy.alerts.suppressWhen}</legend>{causes.map((cause) => <label key={cause.value}><input type="checkbox" checked={draft.suppressWhen.includes(cause.value)} onChange={(event) => toggleCause(cause.value, event.target.checked)} />{cause.label}</label>)}<small>{copy.alerts.suppressWhenHelp}</small></fieldset>
<label>{copy.alerts.unknownBehavior}<select id="alert-rule-unknown-behavior" name="unknownBehavior" value={draft.unknownBehavior} onChange={(event) => updateField('unknownBehavior', event.target.value)}><option value="retain-firing-as-unknown">{copy.alerts.unknownRetain}</option><option value="become-unknown">{copy.alerts.unknownBecome}</option><option value="ignore-short-gap">{copy.alerts.unknownIgnore}</option></select></label>
<details className="form-grid__wide technical-details"><summary>{copy.alerts.technicalDetails}</summary><dl><div><dt>{copy.alerts.titleKey}</dt><dd><code>{draft.message.titleKey}</code></dd></div><div><dt>{copy.alerts.bodyKey}</dt><dd><code>{draft.message.bodyKey}</code></dd></div>{draft.suppressWhen.map((cause) => <div key={cause}><dt>{copy.alerts.suppressWhen}</dt><dd><code>{cause}</code></dd></div>)}</dl></details>
</div>
<div className="detail-actions"><button className="button" type="button" disabled={busy || (draft.condition.inputType === 'metric' && metricState !== 'ready') || Boolean(validateDraft(draft, metrics))} onClick={() => void save()}>{copy.alerts.save}</button>{selected && <button className="button button--secondary" type="button" disabled={busy} onClick={() => void toggle(!selected.enabled)}>{selected.enabled ? copy.alerts.disable : copy.alerts.enable}</button>}</div>
{message && <p className="form-message" role="status">{message}</p>}
<div className="preview-panel"><div className="card-heading"><div><p className="card-kicker">{copy.alerts.preview}</p><h3>{copy.alerts.previewTitle}</h3></div></div><p className="card-copy">{copy.alerts.previewDetail}</p><label>{copy.alerts.sampleValue}<input autoComplete="off" id="alert-rule-preview-value" name="previewValue" type={draft.condition.inputType === 'metric' || orderedOperators.has(draft.condition.operator) ? 'number' : 'text'} value={previewValue} onChange={(event) => setPreviewValue(event.target.value)} /></label><button className="button button--secondary" type="button" disabled={busy || Boolean(validateDraft(draft, metrics))} onClick={() => void testPreview()}>{copy.alerts.runPreview}</button>{preview && <p className="preview-result" role="status">{presentStatus(preview.state)}: {presentReason(preview.reason)}</p>}</div>
</div>
</section>}
{section === 'controls' && <AlertControlsPanel />}
</>;
}
function toDraft(rule: Rule): DraftRule {
const { revision: _revision, currentVersion: _version, updatedAt: _updatedAt, ...draft } = rule;
return { ...draft, cooldownSeconds: rule.cooldownSeconds ?? 0, condition: { ...draft.condition, recoveryThreshold: rule.condition.recoveryThreshold ?? null } };
}
export function validateDraft(draft: DraftRule, metrics: MetricDefinition[]): string {
if (!draft.name.trim()) return copy.alerts.invalidName;
if (draft.condition.inputType === 'metric' && (!draft.condition.metric || !metrics.some((metric) => metric.semanticName === draft.condition.metric))) return copy.alerts.invalidMetric;
if (draft.condition.inputType !== 'metric' && draft.condition.metric) return copy.alerts.invalidMetric;
if (!supportedOperators.has(draft.condition.operator)) return copy.alerts.invalidThreshold;
const threshold = draft.condition.threshold;
if (draft.condition.operator === 'absent' ? threshold !== null : orderedOperators.has(draft.condition.operator) ? typeof threshold !== 'number' || !Number.isFinite(threshold) : (typeof threshold !== 'number' || !Number.isFinite(threshold)) && (typeof threshold !== 'string' || !threshold.trim() || threshold.length > 160)) return copy.alerts.invalidThreshold;
if (draft.condition.operator === 'matches') {
try { new RegExp(String(threshold)); } catch { return copy.alerts.invalidThreshold; }
}
const recovery = draft.condition.recoveryThreshold;
if (recovery != null && (typeof threshold !== 'number' || !Number.isFinite(recovery) || !orderedOperators.has(draft.condition.operator) || ((draft.condition.operator === '>' || draft.condition.operator === '>=') && recovery >= threshold) || ((draft.condition.operator === '<' || draft.condition.operator === '<=') && recovery <= threshold))) return copy.alerts.invalidThreshold;
const times = [draft.evaluationIntervalSeconds, draft.pendingSeconds, draft.resolveSeconds, draft.cooldownSeconds ?? 0];
if (draft.evaluationIntervalSeconds < 5 || draft.evaluationIntervalSeconds > 3600 || times.some((value) => !Number.isInteger(value) || value < 0 || value > 2_592_000)) return copy.alerts.invalidTiming;
return '';
}
function parsePreviewValue(value: string): number | string {
const numeric = Number(value);
return value.trim() !== '' && Number.isFinite(numeric) ? numeric : value;
}
+807
View File
@@ -0,0 +1,807 @@
import { Component, Suspense, lazy, type CSSProperties, type ErrorInfo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MetricWidget, metricStatus, RankedListWidget, StatusGridWidget, type MetricWidgetProps, type RankedListItem, type StatusGridItem } from './MetricWidgets';
import { DashboardRuntimeWidget, type RuntimeState } from './DashboardRuntimeWidget';
import { copy } from './copy';
import { routeFromLocation, type RoutePath } from './routes';
import { HostPage } from './HostPage';
import { ArrayPage } from './ArrayPage';
import { DiskDetailPage, DiskPage } from './DiskPage';
import { PoolPage } from './PoolPage';
import { SharePage } from './SharePage';
import { StoragePage } from './StoragePage';
import { CapacityPage } from './CapacityPage';
import { StorageMapWidget, TemperatureHeatmap, type HeatmapPoint, type StorageMapNode } from './StorageVisuals';
import { ContainerDetailPage, ContainerPage } from './ContainerPage';
import { ApplicationPage } from './ApplicationPage';
import { ServicePage } from './ServicePage';
import type { TopologyData } from './TopologyPage';
import { NetworkPage, NetworkHealthWidget, type NetworkData } from './NetworkPage';
import { IncidentPage } from './IncidentPage';
import { OnboardingPage } from './OnboardingPage';
import { SystemStatusPage } from './SystemStatusPage';
import { InventoryPage } from './InventoryPage';
import { EventsPage } from './EventsPage';
import { NotFoundPage } from './NotFoundPage';
import { formatDateTime } from './locale';
import { installSessionWatcher, onUnauthenticated } from './auth';
import { AuthNoticeBanner, SignInButton } from './SignIn';
import { aggregateStatus, refreshSystemStatus, statusProblems, useSystemStatus } from './systemStatus';
import { operationalStorageState, presentComponent, presentReason, presentStatus } from './presentation';
import { wallboardColumns, wallboardPlacement, wallboardSlideIndex } from './wallboardLayout';
import { OperationalSignalPath, type OperationalSignalStage } from './OperationalSignalPath';
import { containerSignalTone, signalToneFromState, signalToneRank, sourceSignalTone, worstSignalTone, type SignalTone } from './overviewSignals';
// Heavy, rarely-used surfaces. The wallboard and mobile personas never execute
// the editor stack, the alert-rule editor, the topology graph or the process
// explorer, so those stay out of the initial chunk (FRONTEND_STANDARDS "Charts").
const DashboardEditor = lazy(() => import('./DashboardEditor').then((module) => ({ default: module.DashboardEditor })));
const AlertRulesPage = lazy(() => import('./AlertRulesPage').then((module) => ({ default: module.AlertRulesPage })));
const TopologyPage = lazy(() => import('./TopologyPage').then((module) => ({ default: module.TopologyPage })));
const TopologyWidget = lazy(() => import('./TopologyPage').then((module) => ({ default: module.TopologyWidget })));
const ProcessPage = lazy(() => import('./ProcessPage').then((module) => ({ default: module.ProcessPage })));
const navigation = [
{ path: '/', label: copy.navigation.overview, icon: '⌂', group: copy.navigation.groups.command },
{ path: '/dashboards', label: copy.navigation.dashboards, icon: '▦', group: copy.navigation.groups.command },
{ path: '/host', label: copy.navigation.host, icon: '▣', group: copy.navigation.groups.infrastructure },
{ path: '/array', label: copy.navigation.array, icon: '▥', group: copy.navigation.groups.infrastructure },
{ path: '/disks', label: copy.navigation.disks, icon: '◉', group: copy.navigation.groups.infrastructure },
{ path: '/pools', label: copy.navigation.pools, icon: '◫', group: copy.navigation.groups.infrastructure },
{ path: '/shares', label: copy.navigation.shares, icon: '⇄', group: copy.navigation.groups.infrastructure },
{ path: '/storage', label: copy.navigation.storage, icon: '▤', group: copy.navigation.groups.infrastructure },
{ path: '/capacity', label: copy.navigation.capacity, icon: '⌁', group: copy.navigation.groups.infrastructure },
{ path: '/network', label: copy.navigation.network, icon: '⌘', group: copy.navigation.groups.infrastructure },
{ path: '/processes', label: copy.navigation.processes, icon: '≋', group: copy.navigation.groups.workloads },
{ path: '/containers', label: copy.navigation.containers, icon: '⬡', group: copy.navigation.groups.workloads },
{ path: '/applications', label: copy.navigation.applications, icon: '◆', group: copy.navigation.groups.workloads },
{ path: '/services', label: copy.navigation.services, icon: '◉', group: copy.navigation.groups.services },
{ path: '/topology', label: copy.navigation.topology, icon: '⌬', group: copy.navigation.groups.services },
{ path: '/alerts', label: copy.navigation.alerts, icon: '!', group: copy.navigation.groups.response },
{ path: '/events', label: copy.navigation.events, icon: '≡', group: copy.navigation.groups.response },
{ path: '/incidents', label: copy.navigation.incidents, icon: '△', group: copy.navigation.groups.response },
{ path: '/inventory', label: copy.navigation.inventory, icon: '▥', group: copy.navigation.groups.manage },
{ path: '/wallboard', label: copy.navigation.wallboard, icon: '▰', group: copy.navigation.groups.manage },
{ path: '/settings', label: copy.navigation.settings, icon: '⚙', group: copy.navigation.groups.manage },
{ path: '/status', label: copy.navigation.status, icon: '♥', group: copy.navigation.groups.manage },
{ path: '/onboarding', label: copy.navigation.onboarding, icon: '→', group: copy.navigation.groups.manage },
] as const satisfies ReadonlyArray<{ path: RoutePath; label: string; icon: string; group: string }>;
const mobilePrimaryPaths = new Set<RoutePath>(['/', '/incidents', '/containers', '/storage']);
const mobilePrimaryNavigation = ['/', '/incidents', '/containers', '/storage'].map((path) => navigation.find((item) => item.path === path)).filter((item): item is NavigationItem => Boolean(item));
const mobileMoreNavigation = navigation.filter((item) => !mobilePrimaryPaths.has(item.path));
type NavigationItem = (typeof navigation)[number];
const navigationGroups = Array.from(new Set(navigation.map((item) => item.group)));
function NavigationLink({ item, label = item.label }: { item: NavigationItem; label?: string }) {
const active = routeFromLocation(window.location.pathname) === item.path;
return <li><a className={active ? 'nav-link nav-link--active' : 'nav-link'} aria-current={active ? 'page' : undefined} href={item.path} title={label} onClick={(event) => { event.preventDefault(); navigate(item.path); }}><span className="nav-icon" aria-hidden="true">{item.icon}</span><span>{label}</span></a></li>;
}
function DesktopNavigation({ route }: { route: RoutePath }) {
const activeGroup = navigation.find((item) => route === item.path || (item.path !== '/' && route.startsWith(item.path + '/')))?.group ?? navigationGroups[0];
const [openGroups, setOpenGroups] = useState<Set<string>>(() => new Set([navigationGroups[0], activeGroup]));
useEffect(() => setOpenGroups((current) => current.has(activeGroup) ? current : new Set([...current, activeGroup])), [activeGroup]);
return <nav className="desktop-navigation" aria-label={copy.navigation.label}>{navigationGroups.map((group) => {
const items = navigation.filter((item) => item.group === group);
return <details className="nav-group" key={group} open={openGroups.has(group)} onToggle={(event) => { const open = event.currentTarget.open; setOpenGroups((current) => { if (current.has(group) === open) return current; const next = new Set(current); if (open) next.add(group); else next.delete(group); return next; }); }}><summary><span>{group}</span><span aria-hidden="true"></span></summary><ul className="nav-list">{items.map((item) => <NavigationLink key={item.path} item={item} />)}</ul></details>;
})}</nav>;
}
function navigate(path: string) {
window.history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function StatusBadge({ label, tone = 'unknown' }: { label: string; tone?: 'unknown' | 'ready' }) {
return <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : '?'}</span>{label}</span>;
}
function PageIntro({ eyebrow, title, intro }: { eyebrow: string; title: string; intro: string }) {
return <header className="page-intro"><p className="eyebrow">{eyebrow}</p><h1>{title}</h1><p className="intro">{intro}</p></header>;
}
type OverviewSource = { state?: string; freshness?: string; reason?: string };
type OverviewHost = { identity?: { name?: string }; cpu?: { totalPercent?: number }; memory?: { utilizationPercent?: number }; source?: OverviewSource };
type OverviewContainer = { id?: string; state?: string; health?: string; intentionalStop?: boolean };
type OverviewPool = { id: string; name: string; state: string; capacitySeverity?: string; utilizationPercent: number };
type OverviewService = { id: string; name: string; state: string };
type OverviewIncident = { id: string; title: string; severity: string; startedAt: string };
type OverviewContainerSnapshot = { source?: OverviewSource; containers?: OverviewContainer[]; total?: number; nextCursor?: string };
type OverviewPoolSnapshot = { source?: OverviewSource; pools?: OverviewPool[]; total?: number };
type OverviewServiceSnapshot = { capabilityState?: string; configurationState?: string; reason?: string; services?: OverviewService[]; total?: number };
type OverviewResourceState = 'loading' | 'ready' | 'unavailable' | 'unauthorized' | 'forbidden';
type OverviewResource = 'host' | 'containers' | 'pools' | 'services' | 'incidents';
type OverviewData = {
host?: OverviewHost;
containers: OverviewContainer[];
containerSource?: OverviewSource;
containerTotal: number;
containersPartial: boolean;
pools: OverviewPool[];
poolSource?: OverviewSource;
poolTotal: number;
poolsPartial: boolean;
services: OverviewService[];
serviceTotal: number;
servicesPartial: boolean;
serviceCapability?: string;
serviceConfiguration?: string;
incidents: OverviewIncident[];
incidentsPartial: boolean;
resources: Record<OverviewResource, OverviewResourceState>;
};
const loadingOverviewResources: Record<OverviewResource, OverviewResourceState> = {
host: 'loading', containers: 'loading', pools: 'loading', services: 'loading', incidents: 'loading',
};
const overviewInitialData: OverviewData = {
containers: [], containerTotal: 0, containersPartial: false,
pools: [], poolTotal: 0, poolsPartial: false,
services: [], serviceTotal: 0, servicesPartial: false,
incidents: [], incidentsPartial: false,
resources: loadingOverviewResources,
};
const OVERVIEW_REFRESH_MS = 30_000;
const OVERVIEW_REQUEST_TIMEOUT_MS = 10_000;
const CONTAINER_PAGE_LIMIT = 100;
const CONTAINER_MAX_PAGES = 3;
type ReadResult<T> = { state: OverviewResourceState; data?: T };
function collectionExtent(reported: number | undefined, count: number): { total: number; partial: boolean } {
const valid = Number.isInteger(reported) && (reported ?? -1) >= count;
const total = valid ? reported as number : count;
return { total, partial: !valid || total > count };
}
function useOverviewData(): { data: OverviewData; refresh: () => void } {
const [data, setData] = useState<OverviewData>(overviewInitialData);
const [generation, setGeneration] = useState(0);
const refresh = useCallback(() => {
setData((current) => ({ ...current, resources: { ...loadingOverviewResources } }));
setGeneration((current) => current + 1);
}, []);
useEffect(() => {
const controller = new AbortController();
const read = async <T,>(url: string): Promise<ReadResult<T>> => {
const requestController = new AbortController();
const abortRequest = () => requestController.abort();
if (controller.signal.aborted) abortRequest();
else controller.signal.addEventListener('abort', abortRequest, { once: true });
const timeout = window.setTimeout(abortRequest, OVERVIEW_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: requestController.signal, cache: 'no-store' });
if (requestController.signal.aborted) return { state: 'unavailable' };
if (response.status === 401) return { state: 'unauthorized' };
if (response.status === 403) return { state: 'forbidden' };
return response.ok ? { state: 'ready', data: await response.json() as T } : { state: 'unavailable' };
} catch {
return { state: 'unavailable' };
} finally {
window.clearTimeout(timeout);
controller.signal.removeEventListener('abort', abortRequest);
}
};
const readContainers = async (): Promise<ReadResult<{ source?: OverviewSource; items: OverviewContainer[]; total: number; partial: boolean }>> => {
const items: OverviewContainer[] = [];
const seen = new Set<string>();
let source: OverviewSource | undefined;
let expectedTotal: number | undefined;
let after = '';
let complete = false;
let inconsistent = false;
for (let page = 0; page < CONTAINER_MAX_PAGES; page += 1) {
const params = new URLSearchParams({ limit: String(CONTAINER_PAGE_LIMIT), sort: 'name' });
if (after) params.set('after', after);
const result = await read<OverviewContainerSnapshot>('/api/v1/containers?' + params);
if (result.state !== 'ready' || !result.data) return { state: result.state };
const pageItems = result.data.containers ?? [];
const reportedTotal = result.data.total;
if (!Number.isInteger(reportedTotal) || (reportedTotal ?? -1) < pageItems.length) {
inconsistent = true;
} else if (expectedTotal == null) {
expectedTotal = reportedTotal as number;
} else {
if (reportedTotal !== expectedTotal) inconsistent = true;
expectedTotal = Math.max(expectedTotal, reportedTotal as number);
}
if (!source || signalToneRank[sourceSignalTone(result.data.source)] < signalToneRank[sourceSignalTone(source)]) source = result.data.source;
for (const item of pageItems) {
const id = item.id?.trim();
if (!id) {
inconsistent = true;
items.push(item);
} else if (seen.has(id)) {
inconsistent = true;
} else {
seen.add(id);
items.push(item);
}
}
const next = result.data.nextCursor?.trim() ?? '';
if (!next) {
complete = true;
break;
}
if (next === after) {
inconsistent = true;
break;
}
after = next;
}
const total = Math.max(expectedTotal ?? 0, items.length);
return { state: 'ready', data: { source, items, total, partial: !complete || inconsistent || items.length < total } };
};
void read<OverviewHost>('/api/v1/host').then((result) => {
if (controller.signal.aborted) return;
setData((current) => ({ ...current, host: result.data, resources: { ...current.resources, host: result.state } }));
});
void readContainers().then((result) => {
if (controller.signal.aborted) return;
setData((current) => ({ ...current, containers: result.data?.items ?? [], containerSource: result.data?.source, containerTotal: result.data?.total ?? 0, containersPartial: result.data?.partial ?? false, resources: { ...current.resources, containers: result.state } }));
});
void read<OverviewPoolSnapshot>('/api/v1/pools?limit=64').then((result) => {
if (controller.signal.aborted) return;
const items = result.data?.pools ?? [];
const extent = collectionExtent(result.data?.total, items.length);
setData((current) => ({ ...current, pools: items, poolSource: result.data?.source, poolTotal: extent.total, poolsPartial: result.state === 'ready' && extent.partial, resources: { ...current.resources, pools: result.state } }));
});
void read<OverviewServiceSnapshot>('/api/v1/services?limit=100').then((result) => {
if (controller.signal.aborted) return;
const items = result.data?.services ?? [];
const extent = collectionExtent(result.data?.total, items.length);
setData((current) => ({ ...current, services: items, serviceTotal: extent.total, servicesPartial: result.state === 'ready' && extent.partial, serviceCapability: result.data?.capabilityState, serviceConfiguration: result.data?.configurationState, resources: { ...current.resources, services: result.state } }));
});
void read<{ items?: OverviewIncident[] }>('/api/v1/incidents?limit=100&status=open').then((result) => {
if (controller.signal.aborted) return;
const items = result.data?.items ?? [];
setData((current) => ({ ...current, incidents: items, incidentsPartial: result.state === 'ready' && items.length >= 100, resources: { ...current.resources, incidents: result.state } }));
});
return () => controller.abort();
}, [generation]);
useEffect(() => {
const timer = window.setInterval(() => {
if (document.visibilityState === 'visible') setGeneration((current) => current + 1);
}, OVERVIEW_REFRESH_MS);
return () => window.clearInterval(timer);
}, []);
return { data, refresh };
}
function signalResourceLabel(state: OverviewResourceState, tone: SignalTone): string {
if (state === 'loading') return copy.overview.signalPathLoading;
if (state === 'unauthorized') return copy.overview.signalPathUnauthorized;
if (state === 'forbidden') return copy.overview.signalPathForbidden;
if (state === 'unavailable') return copy.overview.signalPathUnavailable;
return presentStatus(tone === 'attention' ? 'attention' : tone);
}
function resourceStateDetail(state: OverviewResourceState): string | undefined {
if (state === 'loading') return copy.overview.resourceLoadingDetail;
if (state === 'unauthorized') return copy.overview.resourceUnauthorizedDetail;
if (state === 'forbidden') return copy.overview.resourceForbiddenDetail;
if (state === 'unavailable') return copy.overview.resourceUnavailableDetail;
return undefined;
}
function thresholdTone(values: Array<number | undefined>): SignalTone {
const usable = values.filter((value): value is number => value != null && Number.isFinite(value));
if (usable.length !== values.length) return 'unknown';
if (usable.some((value) => value >= 95)) return 'critical';
if (usable.some((value) => value >= 85)) return 'attention';
return 'healthy';
}
function metric(value: number | undefined, suffix = '%'): string {
return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + suffix;
}
function boundedRatio(known: number, total: number, partial: boolean): string {
return `${partial ? '≥' : ''}${known}/${total}`;
}
function signalCollectionLabel(state: OverviewResourceState, tone: SignalTone, options: { partial?: boolean; notConfigured?: boolean; emptyLabel?: string } = {}): string {
if (state !== 'ready') return signalResourceLabel(state, tone);
if (tone === 'critical' || tone === 'attention' || tone === 'stale') return presentStatus(tone);
if (options.notConfigured) return copy.overview.signalPathNotConfigured;
if (options.partial) return copy.overview.signalPathPartial;
if (options.emptyLabel) return options.emptyLabel;
return presentStatus(tone);
}
function OverviewPage() {
const snapshot = useSystemStatus();
const status = aggregateStatus(snapshot);
const { data: overview, refresh: refreshOverview } = useOverviewData();
const resourcesLoading = snapshot.state === 'loading' || Object.values(overview.resources).some((state) => state === 'loading');
const overviewNeedsAuthentication = snapshot.state === 'unauthorized' || Object.values(overview.resources).some((state) => state === 'unauthorized');
const systemResourceState: OverviewResourceState = snapshot.state === 'ready' ? 'ready' : snapshot.state === 'loading' ? 'loading' : snapshot.state === 'unauthorized' ? 'unauthorized' : snapshot.state === 'forbidden' ? 'forbidden' : 'unavailable';
const poolRank: Record<string, number> = { critical: 0, faulted: 0, degraded: 1, attention: 2, unknown: 3 };
const poolProblems = (overview.resources.pools === 'ready' && sourceSignalTone(overview.poolSource) === 'healthy' ? overview.pools : [])
.map((pool) => ({ pool, state: operationalStorageState(pool.state, pool.capacitySeverity) }))
.filter(({ state }) => state !== 'healthy' && state !== 'normal')
.sort((a, b) => (poolRank[a.state] ?? 4) - (poolRank[b.state] ?? 4))
.map(({ pool, state }) => ({ id: 'pool:' + pool.id, label: `${pool.name}: ${presentStatus(state)}`, reason: state === 'critical' || state === 'faulted' ? copy.overview.poolCapacityCritical : state === 'attention' ? copy.overview.poolCapacityAttention : presentReason('source_health_unknown') }));
const resourceLabels: Record<OverviewResource, string> = { host: copy.navigation.host, containers: copy.overview.signalWorkloads, pools: copy.overview.signalStorage, services: copy.navigation.services, incidents: copy.overview.signalIncidents };
const resourceProblems = (Object.keys(overview.resources) as OverviewResource[]).flatMap((resource) => {
const state = overview.resources[resource];
if (state !== 'unavailable' && state !== 'unauthorized' && state !== 'forbidden') return [];
return [{ id: `resource:${resource}`, label: `${resourceLabels[resource]}: ${signalResourceLabel(state, 'unknown')}`, reason: resourceStateDetail(state) ?? copy.overview.resourceUnavailableDetail }];
});
const systemProblems = systemResourceState === 'ready' || systemResourceState === 'loading' ? [] : [{ id: 'resource:system-status', label: `${copy.overview.sources}: ${signalResourceLabel(systemResourceState, 'unknown')}`, reason: resourceStateDetail(systemResourceState) ?? copy.overview.resourceUnavailableDetail }];
const partialProblems: Array<{ id: string; label: string; reason: string }> = [];
if (overview.containersPartial) partialProblems.push({ id: 'partial:containers', label: `${copy.overview.signalWorkloads}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
if (overview.poolsPartial) partialProblems.push({ id: 'partial:pools', label: `${copy.overview.signalStorage}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
if (overview.servicesPartial) partialProblems.push({ id: 'partial:services', label: `${copy.navigation.services}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
if (overview.incidentsPartial) partialProblems.push({ id: 'partial:incidents', label: `${copy.overview.signalIncidents}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
const problems = [...systemProblems, ...resourceProblems, ...partialProblems, ...poolProblems, ...statusProblems(snapshot.status)].slice(0, 10);
const poolUsage = overview.pools.length ? Math.max(...overview.pools.map((pool) => pool.utilizationPercent)) : undefined;
const runningContainers = overview.containers.filter((item) => item.state?.toLowerCase() === 'running').length;
const availableServices = overview.services.filter((item) => item.state?.toLowerCase() === 'up').length;
const sourceLags = snapshot.status?.sourceLag ?? [];
const sourceTones = sourceLags.map((lag) => signalToneFromState(lag.state));
const healthySources = sourceTones.filter((tone) => tone === 'healthy').length;
const sourceTone = snapshot.state !== 'ready' || sourceTones.length === 0 ? 'unknown' : status.stale ? 'stale' : worstSignalTone(sourceTones);
const hostSourceTone = sourceSignalTone(overview.host?.source);
const hostTone = overview.resources.host !== 'ready' || !overview.host ? 'unknown' : hostSourceTone !== 'healthy' ? hostSourceTone : thresholdTone([overview.host.cpu?.totalPercent, overview.host.memory?.utilizationPercent]);
const poolSourceTone = sourceSignalTone(overview.poolSource);
const knownStorageTone = overview.pools.length ? worstSignalTone(overview.pools.map((pool) => signalToneFromState(operationalStorageState(pool.state, pool.capacitySeverity)))) : 'unknown';
const storageTone = overview.resources.pools !== 'ready' ? 'unknown' : poolSourceTone !== 'healthy' ? poolSourceTone : overview.poolsPartial ? worstSignalTone([knownStorageTone, 'unknown']) : overview.poolTotal === 0 ? 'unknown' : knownStorageTone;
const containerSourceTone = sourceSignalTone(overview.containerSource);
const knownWorkloadTone = overview.containerTotal === 0 ? 'healthy' : worstSignalTone(overview.containers.map(containerSignalTone));
const workloadTone = overview.resources.containers !== 'ready' ? 'unknown' : containerSourceTone !== 'healthy' ? containerSourceTone : overview.containersPartial ? worstSignalTone([knownWorkloadTone, 'unknown']) : knownWorkloadTone;
const serviceConfigured = overview.serviceCapability === 'available' && overview.serviceConfiguration === 'configured';
const knownServiceTone = overview.services.length ? worstSignalTone(overview.services.map((service) => signalToneFromState(service.state))) : 'unknown';
const serviceTone = overview.resources.services !== 'ready' || !serviceConfigured ? 'unknown' : overview.servicesPartial ? worstSignalTone([knownServiceTone, 'unknown']) : overview.serviceTotal === 0 ? 'unknown' : knownServiceTone;
const incidentTone = overview.resources.incidents !== 'ready' ? 'unknown' : overview.incidents.length === 0 ? 'healthy' : overview.incidents.some((incident) => signalToneFromState(incident.severity) === 'critical') ? 'critical' : 'attention';
const orderedIncidents = [...overview.incidents].sort((left, right) => signalToneRank[signalToneFromState(left.severity)] - signalToneRank[signalToneFromState(right.severity)] || right.startedAt.localeCompare(left.startedAt) || left.id.localeCompare(right.id));
const highestIncident = orderedIncidents[0];
const incidentSeverity = highestIncident ? presentStatus(highestIncident.severity) : copy.overview.noOpenIncidents;
const hostUsable = overview.resources.host === 'ready' && hostSourceTone === 'healthy';
const poolsUsable = overview.resources.pools === 'ready' && poolSourceTone === 'healthy';
const containersUsable = overview.resources.containers === 'ready' && containerSourceTone === 'healthy';
const servicesUsable = overview.resources.services === 'ready' && serviceConfigured;
const signalStages: OperationalSignalStage[] = [
{ id: 'sources', label: copy.overview.sources, icon: '◉', tone: sourceTone, statusLabel: signalResourceLabel(systemResourceState, sourceTone), primaryLabel: copy.overview.connectedSources, primaryValue: snapshot.state === 'ready' ? `${healthySources}/${sourceTones.length || '—'}` : '—', secondaryLabel: copy.overview.freshness, secondaryValue: signalResourceLabel(systemResourceState, sourceTone), detail: copy.overview.signalPathSourcesDetail, route: '/status' },
{ id: 'host', label: copy.navigation.host, icon: '▣', tone: hostTone, statusLabel: signalResourceLabel(overview.resources.host, hostTone), primaryLabel: copy.overview.cpu, primaryValue: hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—', secondaryLabel: copy.overview.memoryShort, secondaryValue: hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—', detail: copy.overview.signalPathHostDetail, route: '/host' },
{ id: 'storage', label: copy.overview.signalStorage, icon: '▤', tone: storageTone, statusLabel: signalCollectionLabel(overview.resources.pools, storageTone, { partial: overview.poolsPartial }), primaryLabel: copy.overview.storage, primaryValue: poolsUsable ? (overview.poolsPartial && poolUsage != null ? `${metric(poolUsage)}` : metric(poolUsage)) : '—', secondaryLabel: copy.navigation.pools, secondaryValue: poolsUsable ? String(overview.poolTotal) : '—', detail: copy.overview.signalPathStorageDetail, route: '/storage' },
{ id: 'workloads', label: copy.overview.signalWorkloads, icon: '⬡', tone: workloadTone, statusLabel: signalCollectionLabel(overview.resources.containers, workloadTone, { partial: overview.containersPartial, emptyLabel: overview.containerTotal === 0 ? copy.overview.signalPathNoWorkloads : undefined }), primaryLabel: copy.overview.activeContainers, primaryValue: containersUsable ? boundedRatio(runningContainers, overview.containerTotal, overview.containersPartial) : '—', secondaryLabel: copy.overview.total, secondaryValue: containersUsable ? String(overview.containerTotal) : '—', detail: copy.overview.signalPathWorkloadsDetail, route: '/containers' },
{ id: 'services', label: copy.navigation.services, icon: '◇', tone: serviceTone, statusLabel: signalCollectionLabel(overview.resources.services, serviceTone, { partial: overview.servicesPartial, notConfigured: overview.serviceConfiguration === 'not_configured' }), primaryLabel: copy.overview.available, primaryValue: servicesUsable ? boundedRatio(availableServices, overview.serviceTotal, overview.servicesPartial) : '—', secondaryLabel: copy.overview.total, secondaryValue: servicesUsable ? String(overview.serviceTotal) : '—', detail: copy.overview.signalPathServicesDetail, route: '/services' },
{ id: 'incidents', label: copy.overview.signalIncidents, icon: '△', tone: incidentTone, statusLabel: signalCollectionLabel(overview.resources.incidents, incidentTone, { partial: overview.incidentsPartial }), primaryLabel: copy.overview.openIncidents, primaryValue: overview.resources.incidents === 'ready' ? `${overview.incidents.length}${overview.incidentsPartial ? '+' : ''}` : '—', secondaryLabel: copy.overview.highestSeverity, secondaryValue: overview.resources.incidents === 'ready' ? incidentSeverity : '—', detail: copy.overview.signalPathIncidentsDetail, route: '/incidents' },
];
const hasSignalAttention = signalStages.some((stage) => stage.tone === 'critical' || stage.tone === 'attention');
const hasSignalUncertainty = signalStages.some((stage) => stage.tone === 'stale' || stage.tone === 'unknown');
const heading = hasSignalAttention || problems.length > 0 || (poolUsage != null && poolUsage >= 90) ? copy.overview.attentionTitle : hasSignalUncertainty ? copy.overview.unknownTitle : copy.overview.title;
return <div className="command-overview">
<header className="overview-heading"><div><p className="eyebrow">{copy.overview.eyebrow}</p><h1>{heading}</h1><p className="intro">{copy.overview.intro}</p></div><div className="overview-heading-status"><StatusBadge label={status.label} tone={status.tone} /><small>{snapshot.status ? formatDateTime(snapshot.status.generatedAt) : copy.overview.statusLoading}</small></div></header>
<section className="source-health-strip" aria-label={copy.overview.sourceLag} tabIndex={0}>
{sourceLags.slice(0, 6).map((source) => <span className={'health-chip health-chip--' + source.state} key={source.sourceId}><span aria-hidden="true">{source.state === 'healthy' ? '✓' : source.state === 'degraded' ? '!' : '?'}</span><strong>{presentComponent(source.sourceId)}</strong><small>{presentStatus(source.state)}</small></span>)}
{sourceLags.length === 0 && <span className="health-chip health-chip--unknown"><span aria-hidden="true">?</span><strong>{copy.overview.sourceLag}</strong><small>{copy.overview.unknown}</small></span>}
</section>
<section className="overview-kpi-grid instrument-band" aria-label={copy.overview.metrics}>
<article className="overview-kpi instrument-cell"><p>{copy.overview.cpu}</p><strong>{hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—'}</strong><small>{hostUsable ? presentStatus(overview.host?.source?.freshness ?? 'unknown') : signalResourceLabel(overview.resources.host, hostTone)}</small></article>
<article className="overview-kpi instrument-cell"><p>{copy.overview.memory}</p><strong>{hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—'}</strong><small>{hostUsable ? overview.host?.identity?.name ?? copy.overview.unknown : signalResourceLabel(overview.resources.host, hostTone)}</small></article>
<article className="overview-kpi instrument-cell"><p>{copy.overview.storage}</p><strong>{poolsUsable ? (overview.poolsPartial && poolUsage != null ? `${metric(poolUsage)}` : metric(poolUsage)) : '—'}</strong><small>{poolsUsable ? `${overview.poolTotal} ${copy.navigation.pools.toLowerCase()}` : signalResourceLabel(overview.resources.pools, storageTone)}</small></article>
<article className="overview-kpi instrument-cell"><p>{copy.overview.services}</p><strong>{servicesUsable ? boundedRatio(availableServices, overview.serviceTotal, overview.servicesPartial) : '—'}</strong><small>{overview.resources.services !== 'ready' ? signalResourceLabel(overview.resources.services, serviceTone) : !serviceConfigured ? copy.overview.signalPathNotConfigured : overview.servicesPartial ? copy.overview.signalPathPartial : copy.overview.available}</small></article>
</section>
<section className="overview-layout">
<article className="card focus-panel action-queue" aria-labelledby="overview-actions-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.actionQueue}</p><h2 id="overview-actions-title">{copy.overview.problems}</h2></div><span className="queue-count">{problems.length}</span></div>{problems.length ? <ol>{problems.map((problem, index) => <li key={problem.id}><span className="queue-severity">{index === 0 ? '!' : '?'}</span><span><strong>{problem.label}</strong><small>{problem.reason}</small></span></li>)}</ol> : <p className="card-copy">{resourcesLoading ? copy.overview.loadingResources : copy.overview.noProblems}</p>}<div className="overview-status-actions">{overviewNeedsAuthentication && <SignInButton />}<button className="button button--secondary" type="button" onClick={() => { refreshSystemStatus(); refreshOverview(); }}>{copy.overview.retry}</button><button className="button button--secondary" type="button" onClick={() => navigate('/status')}>{copy.overview.openStatus}</button></div></article>
<OperationalSignalPath stages={signalStages} onNavigate={navigate} />
<article className="card overview-table-card capacity-plane" aria-labelledby="pool-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.storagePools}</p><h2 id="pool-overview-title">{copy.overview.capacity}</h2></div><button className="text-action" type="button" onClick={() => navigate('/pools')}>{copy.overview.viewAll}</button></div>{overview.resources.pools !== 'ready' ? <p className="card-copy">{resourceStateDetail(overview.resources.pools)}</p> : poolSourceTone !== 'healthy' ? <p className="card-copy">{copy.overview.resourceStaleDetail}</p> : overview.pools.length ? <ul className="overview-data-list">{overview.pools.slice(0, 5).map((pool) => { const state = operationalStorageState(pool.state, pool.capacitySeverity); return <li key={pool.id}><span><strong>{pool.name}</strong><small>{presentStatus(state)} · device-health {presentStatus(pool.state).toLowerCase()}</small></span><span className="mono-value">{metric(pool.utilizationPercent)}</span></li>; })}</ul> : <p className="card-copy">{copy.overview.noPools}</p>}</article>
<article className="card context-inspector overview-table-card workload-inspector" aria-labelledby="workload-overview-title"><div className="card-heading"><div><p className="card-kicker">Nu</p><h2 id="workload-overview-title">{copy.navigation.containers}</h2></div><button className="text-action" type="button" onClick={() => navigate('/containers')}>{copy.overview.viewAll}</button></div><div className="workload-summary"><strong>{containersUsable ? `${overview.containersPartial ? '≥' : ''}${runningContainers}` : '—'}</strong><span>{copy.overview.running}</span><strong>{containersUsable && !overview.containersPartial ? Math.max(0, overview.containerTotal - runningContainers) : '—'}</strong><span>{copy.overview.other}</span></div><p className="card-copy">{overview.resources.containers !== 'ready' ? resourceStateDetail(overview.resources.containers) : containerSourceTone !== 'healthy' ? copy.overview.resourceStaleDetail : overview.containersPartial ? copy.overview.resourcePartialDetail : overview.containerTotal > 0 ? `${runningContainers} van ${overview.containerTotal} ${copy.overview.containersRunning}.` : copy.overview.signalPathNoWorkloads}</p><dl className="overview-now-list"><div><dt>{copy.overview.cpu}</dt><dd>{hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—'}</dd></div><div><dt>{copy.overview.memory}</dt><dd>{hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—'}</dd></div><div><dt>Bron</dt><dd>{hostUsable ? presentStatus(overview.host?.source?.freshness ?? 'unknown') : signalResourceLabel(overview.resources.host, hostTone)}</dd></div></dl></article>
<article className="card overview-table-card incident-queue" aria-labelledby="incident-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.recentIncidents}</p><h2 id="incident-overview-title">{copy.navigation.incidents}</h2></div><button className="text-action" type="button" onClick={() => navigate('/incidents')}>{copy.overview.viewAll}</button></div>{overview.resources.incidents !== 'ready' ? <p className="card-copy">{resourceStateDetail(overview.resources.incidents)}</p> : orderedIncidents.length ? <ul className="overview-data-list">{orderedIncidents.slice(0, 5).map((incident) => <li key={incident.id}><span className={'incident-dot incident-dot--' + incident.severity} aria-hidden="true" /><span><strong>{incident.title}</strong><small>{formatDateTime(incident.startedAt)}</small></span></li>)}</ul> : <p className="card-copy">{copy.overview.noIncidents}</p>}</article>
</section>
</div>;
}
type ApiRecord = Record<string, unknown>;
type DashboardSummary = { id: string; slug: string; name: string; description: string; scope: string; revision: number; currentVersion: number };
type DashboardWidget = { id: string; type: string; title: string; description?: string; data?: ApiRecord; visualization?: ApiRecord; behavior?: ApiRecord; layouts?: ApiRecord };
type DashboardViewport = 'desktop' | 'tablet' | 'mobile' | 'wallboard';
type CrossFilter = { key: string; value: string; label: string; sourceWidgetId: string };
class ApiError extends Error { constructor(readonly status: number) { super('api request failed'); } }
function field<T>(record: ApiRecord | undefined, name: string): T | undefined {
if (!record) return undefined;
const upper = name.charAt(0).toUpperCase() + name.slice(1);
return (record[name] ?? record[upper] ?? record[name.toUpperCase()]) as T | undefined;
}
function summaryFromApi(record: ApiRecord): DashboardSummary {
return { id: String(field(record, 'id') ?? ''), slug: String(field(record, 'slug') ?? ''), name: String(field(record, 'name') ?? copy.dashboards.unnamed), description: String(field(record, 'description') ?? ''), scope: String(field(record, 'scope') ?? 'unknown'), revision: Number(field(record, 'revision') ?? 0), currentVersion: Number(field(record, 'currentVersion') ?? 0) };
}
async function getJSON<T>(url: string, signal: AbortSignal): Promise<T> {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok) throw new ApiError(response.status);
return response.json() as Promise<T>;
}
function DashboardsPage() {
const [state, setState] = useState<'loading' | 'error' | 'unauthorized' | 'empty' | 'ready'>('loading');
const [items, setItems] = useState<DashboardSummary[]>([]);
const [reload, setReload] = useState(0);
useEffect(() => {
const controller = new AbortController();
// Rotation is a background replacement once a dashboard is visible. Keep
// the current view (and any identical shared live subscription) mounted
// until the next document arrives instead of bouncing through `loading`.
setState((current) => current === 'ready' ? current : 'loading');
getJSON<{ items?: ApiRecord[] }>('/api/v1/dashboards?limit=100', controller.signal).then((data) => {
const next = (data.items ?? []).map(summaryFromApi).filter((item) => item.id !== '');
setItems(next); setState(next.length === 0 ? 'empty' : 'ready');
}).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
setState(error instanceof ApiError && error.status === 401 ? 'unauthorized' : 'error');
});
return () => controller.abort();
}, [reload]);
const stateContent = state === 'loading' ? <p className="card-copy" role="status">{copy.dashboards.loading}</p> :
state === 'unauthorized' ? <div className="dashboard-message" role="alert"><h2>{copy.dashboards.unauthorizedTitle}</h2><p>{copy.dashboards.unauthorizedDetail}</p><SignInButton /></div> :
state === 'error' ? <div className="dashboard-message" role="alert"><h2>{copy.dashboards.errorTitle}</h2><p>{copy.dashboards.errorDetail}</p><button className="button button--secondary" type="button" onClick={() => setReload((value) => value + 1)}>{copy.dashboards.retry}</button></div> :
state === 'empty' ? <div className="dashboard-message dashboard-message--empty"><span className="empty-state-icon" aria-hidden="true"></span><h2>{copy.dashboards.empty}</h2><p>{copy.dashboards.emptyDetail}</p></div> :
<ul className="dashboard-list">{items.map((item) => <li key={item.id}><button type="button" className="dashboard-list-item" onClick={() => navigate('/dashboards/' + encodeURIComponent(item.id))}><span><strong>{item.name}</strong><small>{item.description || item.slug}</small></span><span className="dashboard-list-meta"><span>{copy.dashboards.version} {item.currentVersion}</span><StatusBadge label={item.scope === 'personal' ? copy.dashboards.personal : copy.dashboards.shared} tone="ready" /></span></button></li>)}</ul>;
return <><PageIntro eyebrow={copy.dashboards.eyebrow} title={copy.dashboards.title} intro={copy.dashboards.intro} /><section className="card dashboard-list-panel" aria-labelledby="dashboard-list-title"><div className="card-heading"><div><p className="card-kicker">{copy.dashboards.catalog}</p><h2 id="dashboard-list-title">{copy.dashboards.listTitle}</h2></div><StatusBadge label={state === 'ready' ? copy.dashboards.ready : copy.dashboards.unknown} tone={state === 'ready' ? 'ready' : 'unknown'} /></div>{stateContent}</section>{state === 'ready' && <p className="dashboard-count">{items.length} {copy.dashboards.available}</p>}</>;
}
class WidgetBoundary extends Component<{ title: string; children: ReactNode }, { failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError(): { failed: boolean } { return { failed: true }; }
componentDidCatch(_error: Error, _info: ErrorInfo): void {}
render() { return this.state.failed ? <article className="widget-card widget-card--error" role="alert"><p className="card-kicker">{copy.dashboards.widgetError}</p><h3>{this.props.title}</h3><p>{copy.dashboards.widgetErrorDetail}</p></article> : this.props.children; }
}
const widgetLabels: Record<string, string> = { stat: copy.widgets.stat, timeseries: copy.widgets.timeseries, gauge: copy.widgets.gauge, 'ranked-list': copy.widgets.rankedList, 'status-grid': copy.widgets.statusGrid, table: copy.widgets.table, heatmap: copy.widgets.heatmap, 'event-timeline': copy.widgets.eventTimeline, 'storage-map': copy.widgets.storageMap, topology: copy.widgets.topology, 'service-matrix': copy.widgets.serviceMatrix, 'alert-summary': copy.widgets.alertSummary, text: copy.widgets.text, 'query-inspector': copy.widgets.queryInspector };
// Installed before any client captures the global `fetch`, so every API call in
// the app funnels its 401s through one place.
installSessionWatcher();
function responsiveViewport(): 'desktop' | 'tablet' | 'mobile' { return window.innerWidth <= 700 ? 'mobile' : window.innerWidth <= 900 ? 'tablet' : 'desktop'; }
function widgetFilter(widget: DashboardWidget): { key: string; value: string; label: string } {
const data = widget.data ?? {};
const scope = field<ApiRecord>(data, 'scope') ?? {};
const entityType = field<string>(scope, 'entityType');
const sourceType = String(field(data, 'sourceType') ?? 'unknown');
return entityType ? { key: 'entityType', value: entityType, label: entityType } : { key: 'sourceType', value: sourceType, label: sourceType };
}
function filterAllowed(document: ApiRecord, key: string, value: string): boolean {
const safeSources = ['semantic-metric', 'inventory', 'events', 'alerts', 'incidents', 'text'];
if (key === 'sourceType') return safeSources.includes(value);
const variables = (field<unknown[]>(document, 'variables') ?? []) as ApiRecord[];
return variables.some((variable) => { const options = field<unknown[]>(variable, 'options') ?? []; return options.includes(value); });
}
function filterFromURL(document: ApiRecord): CrossFilter | null {
const params = new URLSearchParams(window.location.search);
const key = params.get('filterKey');
const value = params.get('filterValue');
if (!key || !value || !filterAllowed(document, key, value)) return null;
return { key, value, label: value, sourceWidgetId: '' };
}
function compatibleWithFilter(widget: DashboardWidget, filter: CrossFilter | null): boolean {
if (!filter || widget.id === filter.sourceWidgetId) return true;
const next = widgetFilter(widget);
return next.key === filter.key && next.value === filter.value;
}
function wallboardSlideFor(widget: DashboardWidget): number {
const layout = field<ApiRecord>(widget.layouts ?? {}, 'wallboard') ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {};
return wallboardSlideIndex(field(layout, 'y'));
}
function widgetLayoutStyle(layout: ApiRecord, viewport: DashboardViewport): CSSProperties {
const columns = viewport === 'wallboard' ? wallboardColumns : viewport === 'tablet' ? 8 : viewport === 'mobile' ? 1 : 18;
const width = viewport === 'mobile' ? 1 : Math.min(columns, Math.max(1, Number(field<number>(layout, 'w') ?? 6)));
if (viewport !== 'wallboard') return { '--widget-span': String(width) } as CSSProperties;
const placement = wallboardPlacement(layout);
return { '--widget-span': String(placement.columnSpan), gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`, gridRow: `${placement.rowStart} / span ${placement.rowSpan}` } as CSSProperties;
}
function DashboardWidgetView({ widget, viewport, onFilter, metric }: { widget: DashboardWidget; viewport: DashboardViewport; onFilter: (widget: DashboardWidget) => void; metric?: MetricWidgetProps }) {
if (!widget.id || !widget.title) return <article className="widget-card widget-card--error" role="alert"><p className="card-kicker">{copy.dashboards.widgetError}</p><h3>{copy.widgets.unknown}</h3><p>{copy.dashboards.widgetErrorDetail}</p></article>;
const behavior = widget.behavior ?? {};
if (field<boolean>(behavior, 'hidden')) return null;
const activeLayout = field<ApiRecord>(widget.layouts ?? {}, viewport) ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {};
if (field<boolean>(activeLayout, 'visible') === false) return null;
const typeLabel = widgetLabels[widget.type] ?? copy.widgets.unknown;
const source = String(field(widget.data ?? {}, 'sourceType') ?? copy.dashboards.unknown);
const status = metric ? metricStatus(metric) : { label: copy.dashboards.unknown, tone: 'unknown' as const };
const metricKind = metric && (widget.type === 'stat' || widget.type === 'timeseries' || widget.type === 'gauge' || widget.type === 'query-inspector');
const widgetItems = (field<unknown[]>(widget.data ?? {}, 'items') ?? []) as ApiRecord[];
const rankedItems: RankedListItem[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), value: String(field(item, 'value') ?? ''), detail: field<string>(item, 'detail') } )).filter((item) => item.id !== '' && item.label !== '');
const statusItems: StatusGridItem[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), status: String(field(item, 'status') ?? 'unknown'), reason: field<string>(item, 'reason') } )).filter((item) => item.id !== '' && item.label !== '');
const storageNodes: StorageMapNode[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), kind: String(field(item, 'kind') ?? 'storage'), state: String(field(item, 'status') ?? 'unknown'), detail: field<string>(item, 'detail'), href: field<string>(item, 'href') })).filter((item) => item.id !== '' && item.label !== '');
const topologyData = field<TopologyData>(widget.data ?? {}, 'topology');
const networkData = field<NetworkData>(widget.data ?? {}, 'network');
const heatmapPoints: HeatmapPoint[] = widgetItems.map((item) => { const value = Number(field(item, 'value')); return { id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), observedAt: String(field(item, 'observedAt') ?? new Date(0).toISOString()), value: Number.isFinite(value) ? value : null, status: String(field(item, 'status') ?? 'unknown'), href: field<string>(item, 'href') }; }).filter((item) => item.id !== '' && item.label !== '');
return <article className="widget-card" data-viewport={viewport} style={widgetLayoutStyle(activeLayout, viewport)} aria-labelledby={'widget-' + widget.id}><div className="widget-card-heading"><div><p className="card-kicker">{typeLabel}</p><h3 id={'widget-' + widget.id}>{widget.title}</h3></div><StatusBadge label={status.label} tone={status.tone} /></div>{widget.description && <p className="widget-description">{widget.description}</p>}{metricKind ? <MetricWidget {...metric!} /> : widget.type === 'ranked-list' && rankedItems.length > 0 ? <RankedListWidget items={rankedItems} onSelect={() => onFilter(widget)} /> : widget.type === 'status-grid' && statusItems.length > 0 ? <StatusGridWidget items={statusItems} onSelect={() => onFilter(widget)} /> : widget.type === 'storage-map' && storageNodes.length > 0 ? <StorageMapWidget nodes={storageNodes} title={widget.title} description={widget.description || copy.storage.mapDescription} idPrefix={'storage-map-' + widget.id} /> : widget.type === 'topology' && topologyData ? <Suspense fallback={<p className="card-copy" role="status">{copy.topology.loading}</p>}><TopologyWidget topology={topologyData} compact /></Suspense> : widget.type === 'network' && networkData ? <NetworkHealthWidget snapshot={networkData} compact /> : widget.type === 'heatmap' && heatmapPoints.length > 0 ? <TemperatureHeatmap points={heatmapPoints} title={widget.title} description={widget.description || copy.storage.heatmapDescription} idPrefix={'heatmap-' + widget.id} /> : <button className="widget-placeholder" type="button" data-widget-type={widget.type} aria-label={typeLabel + ': ' + widget.title + ' filteren'} onClick={() => onFilter(widget)}><span className="widget-placeholder-icon" aria-hidden="true">{widget.type === 'text' ? 'T' : '◌'}</span><strong>{copy.dashboards.noData}</strong><small>{source} · {copy.dashboards.dataPending}</small></button>}</article>;
}
function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0, onWallboardSlideCount, onRuntimeState }: { dashboardId: string; wallboard?: boolean; wallboardSlide?: number; onWallboardSlideCount?: (count: number) => void; onRuntimeState?: (state: RuntimeState) => void }) {
const [state, setState] = useState<'loading' | 'error' | 'unauthorized' | 'ready'>('loading');
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [document, setDocument] = useState<ApiRecord>({});
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
const [editing, setEditing] = useState(false);
const [crossFilter, setCrossFilter] = useState<CrossFilter | null>(null);
const [runtimeStates, setRuntimeStates] = useState<Record<string, RuntimeState>>({});
const hasWallboardContent = useRef(false);
const systemSnapshot = useSystemStatus();
const storageKey = 'pulse.dashboard.view.' + dashboardId;
const [timeRange, setTimeRange] = useState(() => wallboard ? 'live' : window.localStorage.getItem(storageKey + '.range') ?? '1h');
const [filter, setFilter] = useState(() => window.localStorage.getItem(storageKey + '.filter') ?? '');
const updateRuntimeState = useCallback((id: string, next: RuntimeState) => setRuntimeStates((current) => current[id] === next ? current : { ...current, [id]: next }), []);
useEffect(() => {
const controller = new AbortController();
const replacingVisibleWallboard = wallboard && hasWallboardContent.current;
if (!replacingVisibleWallboard) setState('loading');
getJSON<{ dashboard: ApiRecord; version: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(dashboardId), controller.signal).then((data) => {
const rawDocument = field<ApiRecord>(data.version, 'document') ?? {};
setDocument(rawDocument);
setCrossFilter(filterFromURL(rawDocument));
setSummary(summaryFromApi(data.dashboard));
setWidgets((field<unknown[]>(rawDocument, 'widgets') ?? []) as DashboardWidget[]);
setRuntimeStates({});
if (wallboard) hasWallboardContent.current = true;
setState('ready');
}).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (error instanceof ApiError && error.status === 401) {
setState('unauthorized');
return;
}
// A wallboard is a continuous operational display. During a transient
// failed rotation, keep the last verified document visible and let the
// next bounded rotation/refresh retry. Never apply this to first load or
// authentication failure.
if (!replacingVisibleWallboard) setState('error');
});
return () => controller.abort();
}, [dashboardId, wallboard]);
useEffect(() => { window.localStorage.setItem(storageKey + '.range', timeRange); }, [storageKey, timeRange]);
useEffect(() => { window.localStorage.setItem(storageKey + '.filter', filter); }, [storageKey, filter]);
useEffect(() => {
const values = Object.values(runtimeStates);
const aggregate: RuntimeState = values.includes('usable') ? 'usable' : values.includes('error') ? 'error' : values.length > 0 && values.every((value) => value === 'empty') ? 'empty' : 'loading';
onRuntimeState?.(aggregate);
}, [onRuntimeState, runtimeStates]);
useEffect(() => { if (wallboard) setRuntimeStates({}); }, [wallboard, wallboardSlide]);
const wallboardWidgets = widgets.filter((widget) => {
const layout = field<ApiRecord>(widget.layouts ?? {}, 'wallboard') ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {};
return field<boolean>(widget.behavior ?? {}, 'hidden') !== true && field<boolean>(layout, 'visible') !== false;
});
const wallboardSlideCount = Math.max(1, ...wallboardWidgets.map((widget) => wallboardSlideFor(widget) + 1));
useEffect(() => { if (wallboard) onWallboardSlideCount?.(wallboardSlideCount); }, [onWallboardSlideCount, wallboard, wallboardSlideCount]);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.dashboards.loading}</h1></section>;
if (state === 'unauthorized') return <StatePage kind="unauthorized" />;
if (state === 'error' || !summary) return <StatePage kind="error" />;
if (editing) return <Suspense fallback={<StatePage kind="loading" />}><DashboardEditor dashboardId={dashboardId} revision={summary.revision} document={document} onExit={() => setEditing(false)} onSaved={(revision, nextDocument) => { setDocument(nextDocument); setWidgets((field<unknown[]>(nextDocument, 'widgets') ?? []) as DashboardWidget[]); setSummary({ ...summary, revision, currentVersion: summary.currentVersion + 1 }); setEditing(false); }} /></Suspense>;
const viewport: DashboardViewport = wallboard ? 'wallboard' : responsiveViewport();
const normalized = filter.trim().toLowerCase();
const shown = widgets.filter((widget) => { const activeLayout = field<ApiRecord>(widget.layouts ?? {}, viewport) ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {}; return field<boolean>(widget.behavior ?? {}, 'hidden') !== true && field<boolean>(activeLayout, 'visible') !== false && (!wallboard || wallboardSlideFor(widget) === Math.min(wallboardSlide, wallboardSlideCount - 1)) && (!normalized || widget.title.toLowerCase().includes(normalized)) && compatibleWithFilter(widget, crossFilter); });
const systemStatus = aggregateStatus(systemSnapshot);
const applyCrossFilter = (widget: DashboardWidget) => { const next = widgetFilter(widget); const filter = { ...next, sourceWidgetId: widget.id }; setCrossFilter(filter); const params = new URLSearchParams(window.location.search); params.set('filterKey', filter.key); params.set('filterValue', filter.value); window.history.replaceState({}, '', window.location.pathname + '?' + params.toString()); };
const clearCrossFilter = () => { setCrossFilter(null); const params = new URLSearchParams(window.location.search); params.delete('filterKey'); params.delete('filterValue'); const query = params.toString(); window.history.replaceState({}, '', window.location.pathname + (query ? '?' + query : '')); };
const usableCount = Object.values(runtimeStates).filter((value) => value === 'usable').length;
return <section className={wallboard ? 'dashboard-view wallboard-view' : 'dashboard-view'} aria-labelledby="dashboard-view-title">{!wallboard && <button className="back-link" type="button" onClick={() => navigate('/dashboards')}> {copy.dashboards.back}</button>}<header className="dashboard-view-header"><div><p className="eyebrow">{copy.dashboards.viewMode}</p>{wallboard ? <h2 id="dashboard-view-title">{summary.name}</h2> : <h1 id="dashboard-view-title">{summary.name}</h1>}<p className="intro">{summary.description || copy.dashboards.noDescription}</p></div><div className="dashboard-view-meta">{wallboard && <span className="wallboard-read-only">{copy.wallboard.readOnly}</span>}<StatusBadge label={systemStatus.label} tone={systemStatus.tone} /><span>{copy.dashboards.version} {summary.currentVersion}</span>{!wallboard && <button className="button button--secondary" type="button" onClick={() => setEditing(true)}>{copy.dashboards.edit}</button>}</div></header>{!wallboard && <div className="dashboard-controls" aria-label={copy.dashboards.controls}><label>{copy.dashboards.timeRange}<select value={timeRange} onChange={(event) => setTimeRange(event.target.value)}><option value="live">{copy.dashboards.live}</option><option value="15m">15 {copy.dashboards.minutes}</option><option value="1h">1 {copy.dashboards.hour}</option><option value="6h">6 {copy.dashboards.hours}</option><option value="24h">24 {copy.dashboards.hours}</option><option value="7d">7 {copy.dashboards.days}</option></select></label><label>{copy.dashboards.filter}<input value={filter} onChange={(event) => setFilter(event.target.value)} placeholder={copy.dashboards.filterPlaceholder} /></label><span className="view-mode-note">{crossFilter ? copy.dashboards.filterActive + ': ' + crossFilter.label : copy.dashboards.fixedView}</span><span className="metric-query-status" role="status">{usableCount} van {shown.length} {copy.dashboards.widgetsWithData}</span>{crossFilter && <button className="button button--secondary clear-cross-filter" type="button" onClick={clearCrossFilter}>{copy.dashboards.clearFilter}</button>}</div>}{shown.length === 0 ? <div className="card dashboard-message"><h2>{copy.dashboards.noMatchingWidgets}</h2><p>{copy.dashboards.clearFilterHint}</p></div> : <><h2 className="sr-only" id="dashboard-widgets-title">{copy.dashboards.widgetCollection}</h2><div className="dashboard-grid" aria-labelledby="dashboard-widgets-title">{shown.map((widget) => <WidgetBoundary key={widget.id} title={widget.title || copy.widgets.unknown}>{['semantic-metric', 'inventory', 'events'].includes(String(field(widget.data, 'sourceType') ?? '')) ? <DashboardRuntimeWidget widget={widget} viewport={viewport} document={document} timeRange={timeRange} onState={updateRuntimeState} onFilter={() => applyCrossFilter(widget)} /> : <DashboardWidgetView widget={widget} viewport={viewport} onFilter={applyCrossFilter} />}</WidgetBoundary>)}</div></>}</section>;
}
type WallboardPriorityData = { serviceProblems: number; openIncidents: number; loading: boolean; unavailable: boolean };
function useWallboardPriorityData(): WallboardPriorityData {
const [value, setValue] = useState<WallboardPriorityData>({ serviceProblems: 0, openIncidents: 0, loading: true, unavailable: false });
useEffect(() => {
let active = true;
let controller: AbortController | null = null;
const load = async () => {
controller?.abort();
const current = new AbortController();
controller = current;
try {
const [servicesResponse, incidentsResponse] = await Promise.all([
fetch('/api/v1/services?limit=100', { signal: current.signal, cache: 'no-store' }),
fetch('/api/v1/incidents?limit=100&status=open', { signal: current.signal, cache: 'no-store' }),
]);
if (!servicesResponse.ok || !incidentsResponse.ok) throw new Error('priority');
const services = await servicesResponse.json() as { services?: Array<{ state?: string }> };
const incidents = await incidentsResponse.json() as { items?: unknown[] };
if (active) setValue({ serviceProblems: (services.services ?? []).filter((item) => item.state !== 'up').length, openIncidents: (incidents.items ?? []).length, loading: false, unavailable: false });
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (active) setValue((currentValue) => ({ ...currentValue, loading: false, unavailable: true }));
}
};
void load();
const timer = window.setInterval(load, 30000);
return () => { active = false; controller?.abort(); window.clearInterval(timer); };
}, []);
return value;
}
function WallboardPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error' | 'unauthorized' | 'empty'>('loading');
const [items, setItems] = useState<DashboardSummary[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [activeSlide, setActiveSlide] = useState(0);
const [slideCount, setSlideCount] = useState(1);
const [lastUpdated, setLastUpdated] = useState<string | undefined>();
const [transport, setTransport] = useState<'connected' | 'reconnecting' | 'unavailable'>('reconnecting');
const [dataState, setDataState] = useState<RuntimeState>('loading');
const [fullscreen, setFullscreen] = useState(false);
const [shift, setShift] = useState(0);
const systemSnapshot = useSystemStatus();
const system = aggregateStatus(systemSnapshot);
const storage = systemSnapshot.status?.components.find((component) => component.id === 'storage');
const priority = useWallboardPriorityData();
const params = new URLSearchParams(window.location.search);
const intervalSeconds = Math.min(300, Math.max(10, Number(params.get('interval') ?? 30) || 30));
const refreshSeconds = Math.min(300, Math.max(10, Number(params.get('refresh') ?? 30) || 30));
useEffect(() => onUnauthenticated(() => { setTransport('unavailable'); setState('unauthorized'); }), []);
useEffect(() => {
let active = true;
let inFlight: AbortController | null = null;
const load = async () => {
inFlight?.abort();
const controller = new AbortController();
inFlight = controller;
try {
const response = await fetch('/api/v1/dashboards?limit=100', { signal: controller.signal, cache: 'no-store' });
if (!response.ok) throw new Error('wallboard');
const data = await response.json() as { items?: ApiRecord[] };
const next = (data.items ?? []).map(summaryFromApi).filter((item) => item.id !== '').sort((a, b) => a.id.localeCompare(b.id));
if (!active) return;
setItems(next);
setActiveIndex((value) => next.length === 0 ? 0 : Math.min(value, next.length - 1));
setLastUpdated(new Date().toISOString());
setTransport('connected');
setState(next.length === 0 ? 'empty' : 'ready');
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (!active) return;
setTransport('unavailable');
setState((value) => value === 'ready' || value === 'unauthorized' ? value : 'error');
} finally {
if (inFlight === controller) inFlight = null;
}
};
void load();
const refresh = window.setInterval(() => { setTransport('reconnecting'); void load(); }, refreshSeconds * 1000);
return () => { active = false; inFlight?.abort(); window.clearInterval(refresh); };
}, []);
useEffect(() => {
if (items.length === 0) return undefined;
const rotation = window.setInterval(() => setActiveSlide((value) => {
if (value + 1 < slideCount) return value + 1;
if (items.length > 1) setActiveIndex((dashboard) => (dashboard + 1) % items.length);
return 0;
}), intervalSeconds * 1000);
return () => window.clearInterval(rotation);
}, [items.length, intervalSeconds, slideCount]);
useEffect(() => {
const timer = window.setInterval(() => setShift((value) => (value + 1) % 2), 60000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
const update = () => setFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('fullscreenchange', update);
update();
return () => document.removeEventListener('fullscreenchange', update);
}, []);
const toggleFullscreen = async () => {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else if (document.documentElement.requestFullscreen) await document.documentElement.requestFullscreen();
} catch { /* Fullscreen is optional; transport and data state remain truthful. */ }
};
const handleRuntimeState = useCallback((runtime: RuntimeState) => setDataState(runtime), []);
const handleSlideCount = useCallback((count: number) => { setSlideCount(Math.max(1, count)); setActiveSlide((value) => Math.min(value, Math.max(1, count) - 1)); }, []);
if (state === 'loading') return <section className="wallboard-shell wallboard-shell--state" aria-live="polite"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.wallboard.loading}</h1></section>;
if (state === 'unauthorized') return <section className="wallboard-shell wallboard-shell--state" role="alert"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.states.unauthorizedTitle}</h1><p>{copy.states.unauthorizedDetail}</p><SignInButton /></section>;
if (state === 'error') return <section className="wallboard-shell wallboard-shell--state" role="alert"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.wallboard.errorTitle}</h1><p>{copy.wallboard.errorDetail}</p></section>;
if (state === 'empty') return <section className="wallboard-shell wallboard-shell--state"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.wallboard.noDashboards}</h1></section>;
const current = items[activeIndex];
return <section className={'wallboard-shell wallboard-shell--shift-' + shift} aria-labelledby="wallboard-title">
<header className="wallboard-header"><div><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1 id="wallboard-title">{copy.wallboard.title}</h1><p className="intro">{copy.wallboard.intro}</p></div><div className="wallboard-actions"><span className={'wallboard-connection wallboard-connection--' + transport} role="status">{copy.wallboard.transport}: {transport === 'connected' ? copy.wallboard.connected : transport === 'reconnecting' ? copy.wallboard.reconnecting : copy.wallboard.unavailable}</span><span className={'wallboard-connection wallboard-connection--' + (dataState === 'usable' ? 'connected' : dataState === 'loading' ? 'reconnecting' : 'unavailable')} role="status">{copy.wallboard.data}: {dataState === 'usable' ? copy.wallboard.dataUsable : dataState === 'loading' ? copy.wallboard.dataLoading : dataState === 'empty' ? copy.wallboard.dataEmpty : copy.wallboard.unavailable}</span><button className="button button--secondary" type="button" onClick={toggleFullscreen}>{fullscreen ? copy.wallboard.exitFullscreen : copy.wallboard.enterFullscreen}</button></div></header>
<div className="wallboard-priority" aria-label={copy.wallboard.priority}><span className={'wallboard-priority-item wallboard-priority-item--' + (system.state === 'healthy' ? 'ready' : 'attention')}><strong>{copy.wallboard.overall}</strong><small>{system.label}</small></span><span className={'wallboard-priority-item wallboard-priority-item--' + (storage?.state === 'healthy' ? 'ready' : 'attention')}><strong>{copy.wallboard.storage}</strong><small>{storage ? presentStatus(storage.state) : copy.wallboard.unknown}</small></span><span className={'wallboard-priority-item wallboard-priority-item--' + (priority.serviceProblems === 0 && !priority.unavailable ? 'ready' : 'attention')}><strong>{copy.wallboard.services}</strong><small>{priority.loading ? copy.wallboard.dataLoading : priority.unavailable ? copy.wallboard.unknown : `${priority.serviceProblems} ${copy.wallboard.problems}`}</small></span><span className={'wallboard-priority-item wallboard-priority-item--' + (priority.openIncidents === 0 && !priority.unavailable ? 'ready' : 'attention')}><strong>{copy.wallboard.incidents}</strong><small>{priority.loading ? copy.wallboard.dataLoading : priority.unavailable ? copy.wallboard.unknown : `${priority.openIncidents} ${copy.wallboard.open}`}</small></span></div>
<div className="wallboard-status"><span>{copy.wallboard.lastUpdated}: {lastUpdated ? formatDateTime(lastUpdated) : copy.wallboard.reconnecting}</span><span>{copy.wallboard.rotate} {copy.wallboard.every} {intervalSeconds} {copy.wallboard.seconds}</span><span>{copy.wallboard.slide} {activeSlide + 1} / {slideCount}</span><span>{copy.wallboard.dashboard} {activeIndex + 1} / {items.length}</span></div>
<div className="wallboard-frame"><DashboardViewPage dashboardId={current.id} wallboard wallboardSlide={activeSlide} onWallboardSlideCount={handleSlideCount} onRuntimeState={handleRuntimeState} /></div>
</section>;
}
const AlertsPage = AlertRulesPage;
function SettingsPage() {
const snapshot = useSystemStatus();
const status = aggregateStatus(snapshot);
const connected = snapshot.status?.sourceLag.filter((source) => source.state === 'healthy').length ?? 0;
const total = snapshot.status?.sourceLag.length ?? 0;
const sourceDetail = snapshot.state === 'ready' && total > 0 ? `${connected} van ${total} ${copy.settings.sourcesCurrent}` : status.detail;
const groups = [
{ title: copy.settings.healthTitle, detail: copy.settings.healthDetail, links: [
{ href: '/status', title: copy.settings.systemStatus, detail: copy.settings.systemStatusDetail, access: copy.settings.adminActions },
{ href: '/onboarding', title: copy.settings.onboarding, detail: copy.settings.onboardingDetail, access: copy.settings.adminChanges },
] },
{ title: copy.settings.alertingTitle, detail: copy.settings.alertingDetail, links: [
{ href: '/alerts?section=rules', title: copy.settings.alertRules, detail: copy.settings.alertRulesDetail, access: copy.settings.editorChanges },
{ href: '/alerts?section=controls', title: copy.settings.alertControls, detail: copy.settings.alertControlsDetail, access: copy.settings.operatorChanges },
] },
{ title: copy.settings.presentationTitle, detail: copy.settings.presentationDetail, links: [
{ href: '/dashboards', title: copy.settings.dashboards, detail: copy.settings.dashboardsDetail, access: copy.settings.editorChanges },
{ href: '/inventory', title: copy.settings.inventory, detail: copy.settings.inventoryDetail, access: copy.settings.viewAccess },
] },
];
return <><PageIntro eyebrow={copy.settings.eyebrow} title={copy.settings.title} intro={copy.settings.intro} />
<section className="settings-overview card" aria-labelledby="settings-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.settings.current}</p><h2 id="settings-overview-title">{copy.settings.environment}</h2></div><StatusBadge label={status.label} tone={status.tone} /></div><div className="setting-row"><span><strong>{copy.settings.source}</strong><small>{sourceDetail}</small></span><span>{connected}/{total || '—'}</span></div><div className="setting-row"><span><strong>{copy.settings.language}</strong><small>{copy.settings.languageDetail}</small></span><strong>{copy.settings.languageValue}</strong></div></section>
<section className="settings-hub" aria-label={copy.settings.management}><h2 className="sr-only">{copy.settings.management}</h2>{groups.map((group) => <article className="card settings-hub-card" key={group.title}><p className="card-kicker">{copy.settings.management}</p><h3>{group.title}</h3><p className="card-copy">{group.detail}</p><ul>{group.links.map((link) => <li key={link.href}><a href={link.href} onClick={(event) => { event.preventDefault(); navigate(link.href); }}><span><strong>{link.title}</strong><small>{link.detail}</small></span><span className="settings-access">{link.access}<span aria-hidden="true"></span></span></a></li>)}</ul></article>)}</section>
</>;
}
function StatePage({ kind }: { kind: 'loading' | 'error' | 'unauthorized' }) {
if (kind === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.states.loading}</h1></section>;
if (kind === 'unauthorized') {
// The API answered 401, so the visitor is not signed in: offer the real
// sign-in entry point and come back to the page they asked for.
return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">!</span><h1>{copy.states.unauthorizedTitle}</h1><p>{copy.states.unauthorizedDetail}</p><p>{copy.auth.signInHint}</p><div className="state-page-actions"><SignInButton /><button className="button button--secondary" type="button" onClick={() => navigate('/')}>{copy.states.returnHome}</button></div></section>;
}
return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.states.errorTitle}</h1><p>{copy.states.errorDetail}</p><button className="button" type="button" onClick={() => navigate(routeFromLocation(window.location.pathname))}>{copy.states.retry}</button></section>;
}
/** Wraps lazily loaded routes in the same loading state the rest of the app uses. */
function RouteSuspense({ children }: { children: ReactNode }) {
return <Suspense fallback={<StatePage kind="loading" />}>{children}</Suspense>;
}
function Page({ route }: { route: RoutePath }) {
if (route.startsWith('/inventory/')) return <InventoryPage id={decodeURIComponent(route.slice('/inventory/'.length))} />;
if (route.startsWith('/dashboards/')) return <DashboardViewPage dashboardId={decodeURIComponent(route.slice('/dashboards/'.length))} />;
if (route.startsWith('/services/')) return <ServicePage id={decodeURIComponent(route.slice('/services/'.length))} />;
if (route.startsWith('/incidents/')) return <IncidentPage id={decodeURIComponent(route.slice('/incidents/'.length))} />;
if (route.startsWith('/containers/')) return <ContainerDetailPage id={decodeURIComponent(route.slice('/containers/'.length))} />;
if (route.startsWith('/disks/')) return <DiskDetailPage id={decodeURIComponent(route.slice('/disks/'.length))} />;
if (route.startsWith('/pools/')) return <PoolPage id={decodeURIComponent(route.slice('/pools/'.length))} />;
if (route.startsWith('/shares/')) return <SharePage id={decodeURIComponent(route.slice('/shares/'.length))} />;
if (route.startsWith('/applications/')) return <ApplicationPage id={decodeURIComponent(route.slice('/applications/'.length))} />;
switch (route) {
case '/': return <OverviewPage />;
case '/processes': return <RouteSuspense><ProcessPage /></RouteSuspense>;
case '/containers': return <ContainerPage />;
case '/services': return <ServicePage />;
case '/topology': return <RouteSuspense><TopologyPage /></RouteSuspense>;
case '/network': return <NetworkPage />;
case '/applications': return <ApplicationPage />;
case '/host': return <HostPage />;
case '/array': return <ArrayPage />;
case '/disks': return <DiskPage />;
case '/pools': return <PoolPage />;
case '/shares': return <SharePage />;
case '/storage': return <StoragePage />;
case '/capacity': return <CapacityPage />;
case '/inventory': return <InventoryPage />;
case '/dashboards': return <DashboardsPage />;
case '/wallboard': return <WallboardPage />;
case '/alerts': return <RouteSuspense><AlertsPage /></RouteSuspense>;
case '/events': return <EventsPage />;
case '/incidents': return <IncidentPage />;
case '/settings': return <SettingsPage />;
case '/status': return <SystemStatusPage />;
case '/onboarding': return <OnboardingPage />;
case '/loading': return <StatePage kind="loading" />;
case '/error': return <StatePage kind="error" />;
case '/unauthorized': return <StatePage kind="unauthorized" />;
case '/404': return <NotFoundPage />;
default: return <NotFoundPage />;
}
}
function App() {
const [route, setRoute] = useState<RoutePath>(() => routeFromLocation(window.location.pathname));
const shellStatus = aggregateStatus(useSystemStatus());
useEffect(() => { const handleNavigation = () => setRoute(routeFromLocation(window.location.pathname)); window.addEventListener('popstate', handleNavigation); return () => window.removeEventListener('popstate', handleNavigation); }, []);
useEffect(() => {
const mobileMenu = document.querySelector<HTMLDetailsElement>('.mobile-more');
if (mobileMenu?.open) mobileMenu.open = false;
}, [route]);
const currentNavigation = navigation.find((item) => item.path === route)
?? navigation.find((item) => item.path !== '/' && route.startsWith(item.path + '/'))
?? navigation[0];
if (route === '/wallboard') return <div className="app-shell app-shell--wallboard"><a className="skip-link" href="#main-content">{copy.accessibility.skipToContent}</a><main id="main-content" className="content"><Page route={route} /></main></div>;
return <div className="app-shell"><a className="skip-link" href="#main-content">{copy.accessibility.skipToContent}</a><aside className="sidebar"><a className="brand" href="/" title={copy.brand.name} onClick={(event) => { event.preventDefault(); navigate('/'); }}><span className="brand-mark" aria-hidden="true"><span>P</span></span><span className="brand-copy"><strong>{copy.brand.name}</strong><small>{copy.brand.context}</small></span></a><DesktopNavigation route={route} /><nav className="mobile-navigation" aria-label={copy.navigation.label}><ul className="nav-list mobile-primary-list">{mobilePrimaryNavigation.map((item) => <NavigationLink key={item.path} item={item} label={item.path === '/storage' ? copy.navigation.mobileStorage : item.label} />)}</ul><details className="mobile-more" open={mobileMoreNavigation.some((item) => route === item.path)}><summary>{copy.navigation.more}</summary><ul className="nav-list">{mobileMoreNavigation.map((item) => <NavigationLink key={item.path} item={item} />)}</ul></details></nav><div className="sidebar-status" title={shellStatus.detail}><StatusBadge label={shellStatus.label} tone={shellStatus.tone} /><span>{shellStatus.detail}</span></div></aside><div className="app-workspace"><header className="context-bar"><div className="context-location"><span className="context-server"><span className="context-server-mark" aria-hidden="true">T</span><span><small>Server</small><strong>Tower</strong></span></span><span className="context-divider" aria-hidden="true" /><span className="context-product">Pulse</span><span aria-hidden="true">/</span><strong>{route === '/404' ? copy.notFound.context : currentNavigation.label}</strong></div><div className="context-actions"><span className="context-live"><span aria-hidden="true"></span> Live verbonden</span><span className="context-read-only">Alle bronnen · alleen-lezen</span><StatusBadge label={shellStatus.label} tone={shellStatus.tone} /></div></header><main id="main-content" className="content"><AuthNoticeBanner /><Page route={route} /></main></div></div>;
}
export default App;
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentReason } from './presentation';
import { SourceStatusDetails } from './SourceStatusDetails';
type ComponentItem = { id: string; name: string; kind: string; critical: boolean; containerState: string; serviceState: string; status: string; reason?: string };
type ApplicationItem = { id: string; name: string; status: string; overridden: boolean; components: ComponentItem[]; reasons?: Array<{ code: string; message: string; componentId?: string; critical: boolean }> };
type ApplicationSnapshot = { source: { id: string; state: string; freshness: string; observedAt?: string; reason?: string }; applications: ApplicationItem[]; total: number };
type ApplicationDetail = { source: ApplicationSnapshot['source']; application: ApplicationItem };
function Badge({ state }: { state: string }) {
const normalized = state.toLowerCase();
const tone = normalized === 'healthy' ? 'ready' : normalized === 'degraded' || normalized === 'down' ? 'attention' : 'unknown';
return <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : tone === 'attention' ? '!' : '?'}</span>{normalized === 'healthy' ? copy.applications.healthy : normalized === 'degraded' || normalized === 'down' ? copy.applications.degraded : copy.applications.unknown}</span>;
}
function Source({ source }: { source: ApplicationSnapshot['source'] }) {
return <SourceStatusDetails source={source ?? {}} fallbackReason={source?.freshness === 'fresh' ? copy.applications.fresh : copy.applications.stale} />;
}
function Components({ items }: { items: ComponentItem[] }) {
return <details className="card technical-details"><summary>{copy.applications.components} ({items.length})</summary><div className="application-components">{items.map((item) => <article className="application-component" key={item.id}><div><strong>{item.name}</strong><small>{item.kind} · {item.critical ? copy.applications.critical : copy.applications.optional}</small></div><div><Badge state={item.status} /><small>{item.reason || item.id}</small></div></article>)}</div></details>;
}
export function ApplicationPage({ id }: { id?: string }) {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [data, setData] = useState<ApplicationSnapshot | ApplicationDetail | null>(null);
useEffect(() => {
const controller = new AbortController();
const url = id ? '/api/v1/applications/' + encodeURIComponent(id) : '/api/v1/applications';
fetch(url, { signal: controller.signal }).then((response) => {
if (!response.ok) throw new Error('applications');
return response.json() as Promise<ApplicationSnapshot | ApplicationDetail>;
}).then((value) => { setData(value); 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.applications.loading}</h1></section>;
if (state === 'error' || !data) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.applications.errorTitle}</h1><p>{copy.applications.errorDetail}</p><a className="button" href="/applications">{copy.applications.backToList}</a></section>;
if (id) {
const detail = data as ApplicationDetail;
const app = detail.application;
return <><header className="page-intro"><p className="eyebrow">{copy.applications.detailEyebrow}</p><h1>{app.name}</h1><p className="intro">{copy.applications.detailIntro}</p><div className="detail-actions"><a className="button button--secondary" href="/applications">{copy.applications.backToList}</a><a className="button button--secondary" href="/dashboards?filterKey=sourceType&filterValue=inventory">{copy.applications.openDashboard}</a></div></header><section className="card container-summary" aria-labelledby="application-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.applications.source}</p><h2 id="application-summary-title">{detail.source?.id || copy.applications.unknown}</h2><Source source={detail.source} /></div><Badge state={app.status} /></div>{app.reasons?.map((reason) => <p className="host-reason" key={reason.componentId + '-' + reason.code}>{presentReason(reason.code)}</p>)}</section><Components items={app.components} /></>;
}
const snapshot = data as ApplicationSnapshot;
return <><header className="page-intro"><p className="eyebrow">{copy.applications.eyebrow}</p><h1>{copy.applications.title}</h1><p className="intro">{copy.applications.intro}</p></header><section className="card container-summary" aria-labelledby="applications-source-title"><div className="card-heading"><div><p className="card-kicker">{copy.applications.source}</p><h2 id="applications-source-title">{snapshot.source?.id || copy.applications.unknown}</h2><Source source={snapshot.source} /></div><Badge state={snapshot.source?.state || 'unknown'} /></div><p className="container-count">{snapshot.total} {copy.applications.rows}</p></section><section className="card container-panel" aria-labelledby="application-list-title"><div className="card-heading"><div><p className="card-kicker">{copy.applications.list}</p><h2 id="application-list-title">{copy.applications.overview}</h2></div></div>{snapshot.applications.length === 0 ? <p className="card-copy">{copy.applications.empty}</p> : <ul className="inventory-list">{snapshot.applications.map((item) => <li key={item.id}><a className="entity-link" href={"/applications/" + encodeURIComponent(item.id)}><strong>{item.name}</strong><small>{item.components.length} {copy.applications.components} · {item.overridden ? copy.applications.overridden : copy.applications.discovered}</small></a><Badge state={item.status} /></li>)}</ul>}</section></>;
}
+32
View File
@@ -0,0 +1,32 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentArrayRole, presentStatus } from './presentation';
import { SourceStatusDetails } from './SourceStatusDetails';
type Member = { id: string; name: string; role: string; state: string; capacityBytes: number; readBytes: number; writeBytes: number };
type Check = { id: string; state: string; progressPercent: number; speedBytesPerSecond: number; errors: number; startedAt?: string; completedAt?: string };
type ArraySnapshot = { contractVersion: string; source: { id: string; state: string; freshness: string; observedAt?: string; receivedAt?: string; reason?: string }; state: string; parity: { present: boolean; state: string; errors: number }; members?: Member[] | null; currentCheck?: Check; history?: Check[] | null };
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 date(value?: string): string { return formatDateTime(value); }
function Badge({ label, ready }: { label: string; ready: boolean }) { return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{label}</span>; }
export function ArrayPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [snapshot, setSnapshot] = useState<ArraySnapshot | null>(null);
useEffect(() => { const controller = new AbortController(); fetch('/api/v1/array', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('array'); return response.json() as Promise<ArraySnapshot>; }).then((data) => { setSnapshot(data); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.array.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.array.errorTitle}</h1><p>{copy.array.errorDetail}</p></section>;
const available = snapshot.source?.state !== 'unknown' && snapshot.source?.freshness === 'fresh';
const stateLabel = snapshot.state === 'operational' ? copy.array.operational : snapshot.state === 'degraded' ? copy.array.degraded : snapshot.state === 'missing' ? copy.array.missing : copy.array.unknown;
const members = Array.isArray(snapshot.members) ? snapshot.members : [];
const history = Array.isArray(snapshot.history) ? snapshot.history : [];
return <>
<header className="page-intro"><p className="eyebrow">{copy.array.eyebrow}</p><h1>{copy.array.title}</h1><p className="intro">{copy.array.intro}</p></header>
<section className="card container-summary" aria-labelledby="array-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.array.source}</p><h2 id="array-summary-title">{snapshot.source?.id || copy.array.unknown}</h2></div><Badge label={stateLabel} ready={snapshot.state === 'operational'} /></div><SourceStatusDetails source={snapshot.source ?? {}} fallbackReason={available ? copy.array.fresh : copy.array.stale} /><p className="card-copy">{copy.array.readOnly}</p></section>
<section className="card-grid" aria-label={copy.array.metrics}><article className="card"><p className="card-kicker">{copy.array.parity}</p><h2>{snapshot.parity.present ? copy.array.present : copy.array.notPresent}</h2><p className="card-copy">{presentStatus(snapshot.parity.state)} · {copy.array.errors}: {snapshot.parity.errors}</p></article><article className="card"><p className="card-kicker">{copy.array.members}</p><h2>{members.length}</h2><p className="card-copy">{members.filter((member) => member.state !== 'online').length} {copy.array.notOperational}</p></article>{snapshot.currentCheck && <article className="card"><p className="card-kicker">{copy.array.currentCheck}</p><h2>{snapshot.currentCheck.progressPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%</h2><p className="card-copy">{presentStatus(snapshot.currentCheck.state)} · {bytes(snapshot.currentCheck.speedBytesPerSecond)}/s · {copy.array.errors}: {snapshot.currentCheck.errors}</p></article>}</section>
<section className="card container-panel" aria-labelledby="array-members-title"><div className="card-heading"><div><p className="card-kicker">{copy.array.members}</p><h2 id="array-members-title">{copy.array.membersTitle}</h2></div></div>{members.length === 0 ? <p className="card-copy">{copy.array.noMembers}</p> : <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.array.name}</th><th>{copy.array.role}</th><th>{copy.array.state}</th><th>{copy.array.capacity}</th><th>{copy.array.io}</th></tr></thead><tbody>{members.map((member) => <tr key={member.id}><th scope="row">{member.name}</th><td>{presentArrayRole(member.role)}</td><td><Badge label={presentStatus(member.state)} ready={member.state === 'online'} /></td><td>{bytes(member.capacityBytes)}</td><td>{bytes(member.readBytes)} gelezen / {bytes(member.writeBytes)} geschreven</td></tr>)}</tbody></table></div>}</section>
<details className="card technical-details"><summary>{copy.array.history}</summary>{history.length === 0 ? <p className="card-copy">{copy.array.noHistory}</p> : <div className="host-table-wrap"><table className="host-table"><thead><tr><th>ID</th><th>{copy.array.state}</th><th>{copy.array.progress}</th><th>{copy.array.speed}</th><th>{copy.array.completed}</th></tr></thead><tbody>{history.map((check) => <tr key={check.id}><th scope="row">{check.id}</th><td>{presentStatus(check.state)}</td><td>{check.progressPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%</td><td>{bytes(check.speedBytesPerSecond)}/s</td><td>{date(check.completedAt)}</td></tr>)}</tbody></table></div>}</details>
</>;
}
+52
View File
@@ -0,0 +1,52 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
type Forecast = {
entityId: string;
name: string;
kind: string;
enabled: boolean;
method: string;
windowSeconds: number;
dataPoints: number;
confidence: string;
currentUsedBytes: number;
capacityBytes: number;
rateBytesPerDay: number;
daysToCapacity?: number;
projectedAt?: string;
reason?: string;
};
type Snapshot = { contractVersion: string; generatedAt: string; policy: { enabled: boolean; windowSeconds: number; minPoints: number; method: string }; items: Forecast[]; qualifiedCount: number; reason?: string };
function formatBytes(value: number): string { if (!Number.isFinite(value) || value < 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let amount = value; let index = 0; while (amount >= 1024 && index < units.length - 1) { amount /= 1024; index += 1; } return `${amount.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} ${units[index]}`; }
function formatWindow(seconds: number): string { const days = Math.round(seconds / 86400); return `${days} ${days === 1 ? copy.capacity.day : copy.capacity.days}`; }
function formatDate(value?: string): string { return formatDateTime(value); }
function label(value: string): string {
const labels: Record<string, string> = {
linear_median_rate: copy.capacity.linearMedian, insufficient_data: copy.capacity.insufficient,
disabled: copy.capacity.disabled, high: copy.capacity.high, medium: copy.capacity.medium,
low: copy.capacity.low, none: copy.capacity.none, insufficient_points: copy.capacity.insufficientPoints,
insufficient_time_span: copy.capacity.insufficientSpan, history_stale: copy.capacity.historyStale,
history_unavailable: copy.capacity.historyUnavailable, source_unavailable: copy.capacity.sourceUnavailable,
no_capacity_entities: copy.capacity.noEntities, capacity_unknown_or_reached: copy.capacity.capacityUnknown,
no_positive_growth: copy.capacity.noGrowth, bulk_import_detected: copy.capacity.bulkImport,
irregular_intervals: copy.capacity.irregular, disabled_by_policy: copy.capacity.disabledByPolicy,
};
return labels[value] ?? copy.capacity.unknown;
}
export function CapacityPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
useEffect(() => { const controller = new AbortController(); fetch('/api/v1/forecasts', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('forecasts'); return response.json() as Promise<Snapshot>; }).then((data) => { setSnapshot(data); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.capacity.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.capacity.errorTitle}</h1><p>{copy.capacity.errorDetail}</p></section>;
return <><header className="page-intro"><p className="eyebrow">{copy.capacity.eyebrow}</p><h1>{copy.capacity.title}</h1><p className="intro">{copy.capacity.intro}</p></header><section className="card container-summary" aria-labelledby="capacity-policy-title"><div className="card-heading"><div><p className="card-kicker">{copy.capacity.policy}</p><h2 id="capacity-policy-title">{snapshot.policy.enabled ? copy.capacity.enabled : copy.capacity.disabled}</h2><p className="card-copy">{copy.capacity.method}: {label(snapshot.policy.method)} · {copy.capacity.window}: {formatWindow(snapshot.policy.windowSeconds)} · {copy.capacity.minimum}: {snapshot.policy.minPoints}</p></div><span className="status-badge status-badge--unknown"><span className="status-icon" aria-hidden="true">i</span>{copy.capacity.readOnly}</span></div><p className="container-provenance">{copy.capacity.observed}: {formatDate(snapshot.generatedAt)} · {snapshot.qualifiedCount ?? 0} {copy.capacity.items} · {snapshot.items.length} {copy.capacity.assessments}</p></section><section className="card-grid" aria-label={copy.capacity.cards}>{snapshot.items.length ? snapshot.items.map((item) => <ForecastCard key={item.entityId + item.kind} item={item} />) : <article className="card empty-state"><h2>{copy.capacity.emptyTitle}</h2><p className="card-copy">{snapshot.reason ? label(snapshot.reason) : copy.capacity.empty}</p><a className="button" href="/shares">{copy.capacity.openShares}</a></article>}</section></>;
}
function ForecastCard({ item }: { item: Forecast }) {
const qualified = item.confidence === 'high' || item.confidence === 'medium';
return <article className="card forecast-card"><div className="card-heading"><div><p className="card-kicker">{item.kind === 'share' ? copy.capacity.share : item.kind}</p><h2>{item.name || item.entityId || copy.capacity.unknown}</h2></div><span className={'status-badge status-badge--' + (qualified ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{qualified ? '✓' : '?'}</span>{label(item.confidence)}</span></div><dl className="metric-list"><div><dt>{copy.capacity.method}</dt><dd>{label(item.method)}</dd></div><div><dt>{copy.capacity.window}</dt><dd>{formatWindow(item.windowSeconds)}</dd></div><div><dt>{copy.capacity.points}</dt><dd>{item.dataPoints}</dd></div><div><dt>{copy.capacity.current}</dt><dd>{formatBytes(item.currentUsedBytes)} / {item.capacityBytes > 0 ? formatBytes(item.capacityBytes) : '—'}</dd></div><div><dt>{copy.capacity.rate}</dt><dd>{qualified && item.rateBytesPerDay > 0 ? `${formatBytes(item.rateBytesPerDay)} / ${copy.capacity.day}` : '—'}</dd></div>{qualified && item.daysToCapacity !== undefined && <div><dt>{copy.capacity.projected}</dt><dd>{Math.round(item.daysToCapacity)} {copy.capacity.days} · {formatDate(item.projectedAt)}</dd></div>}</dl><p className="card-copy">{item.reason ? label(item.reason) : copy.capacity.qualified}</p></article>;
}
+144
View File
@@ -0,0 +1,144 @@
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>
</>;
}
+244
View File
@@ -0,0 +1,244 @@
import { useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react';
import { copy } from './copy';
import { WidgetConfigDrawer, type PreviewResult, type ValidationErrors } from './WidgetConfigDrawer';
import { DashboardVariablesEditor, validateVariables, type DashboardVariable } from './DashboardVariablesEditor';
import { DashboardTransfer } from './DashboardTransfer';
export type RecordValue = Record<string, unknown>;
export type EditorWidget = { id: string; type: string; title: string; description?: string; data?: RecordValue; visualization?: RecordValue; behavior?: RecordValue; layouts?: RecordValue };
type EditorProps = { dashboardId: string; revision: number; document: RecordValue; onExit: () => void; onSaved: (revision: number, document: RecordValue) => void };
const shell = copy.editor.shell;
const cardCopy = copy.editor.card;
const messages = copy.editor.messages;
const validation = copy.editor.validation;
const widgetTypeCopy = copy.editor.widgetTypes;
export const types = ['stat', 'timeseries', 'gauge', 'ranked-list', 'status-grid', 'table', 'heatmap', 'event-timeline', 'storage-map', 'topology', 'service-matrix', 'alert-summary', 'text', 'query-inspector'];
export const labels: Record<string, string> = { stat: widgetTypeCopy.stat, timeseries: widgetTypeCopy.timeseries, gauge: widgetTypeCopy.gauge, 'ranked-list': widgetTypeCopy.rankedList, 'status-grid': widgetTypeCopy.statusGrid, table: widgetTypeCopy.table, heatmap: widgetTypeCopy.heatmap, 'event-timeline': widgetTypeCopy.eventTimeline, 'storage-map': widgetTypeCopy.storageMap, topology: widgetTypeCopy.topology, 'service-matrix': widgetTypeCopy.serviceMatrix, 'alert-summary': widgetTypeCopy.alertSummary, text: widgetTypeCopy.text };
const sourceTypes = ['semantic-metric', 'inventory', 'events', 'alerts', 'incidents', 'text'];
const ranges = ['live', '15m', '1h', '6h', '24h', '7d'];
const aggregations = ['avg', 'min', 'max', 'sum', 'last'];
type Viewport = 'desktop' | 'tablet' | 'mobile' | 'wallboard';
const viewports: Viewport[] = ['desktop', 'tablet', 'mobile', 'wallboard'];
const viewportLabels: Record<Viewport, string> = { desktop: copy.editor.viewports.desktop, tablet: copy.editor.viewports.tablet, mobile: copy.editor.viewports.mobile, wallboard: copy.editor.viewports.wallboard };
const viewportColumns: Record<Viewport, number> = { desktop: 18, tablet: 8, mobile: 1, wallboard: 24 };
function read<T>(value: RecordValue | undefined, name: string): T | undefined {
if (!value) return undefined;
const upper = name.charAt(0).toUpperCase() + name.slice(1);
return (value[name] ?? value[upper] ?? value[name.toUpperCase()]) as T | undefined;
}
function copyWidget(value: EditorWidget): EditorWidget { return JSON.parse(JSON.stringify(value)) as EditorWidget; }
function widgetId(): string { return typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : 'widget-' + Date.now().toString(36); }
function layout(widget: EditorWidget, viewport: Viewport = 'desktop'): RecordValue {
const layouts = widget.layouts ?? {};
const fallback = read<RecordValue>(layouts, 'desktop') ?? {};
return { ...fallback, ...(read<RecordValue>(layouts, viewport) ?? {}) };
}
function withLayout(widget: EditorWidget, viewport: Viewport, nextLayout: RecordValue): EditorWidget {
return { ...widget, layouts: { ...(widget.layouts ?? {}), [viewport]: nextLayout } };
}
function layoutWidth(widget: EditorWidget, viewport: Viewport): number {
return Math.min(viewportColumns[viewport], Math.max(1, Number(layout(widget, viewport).w ?? 6) || 1));
}
function normalizeWidget(widget: EditorWidget, index: number): EditorWidget {
const base = { x: 0, y: index * 4, w: widget.type === 'timeseries' ? 9 : 6, h: 4, visible: true };
const layouts = widget.layouts ?? {};
const desktop = { ...base, ...(read<RecordValue>(layouts, 'desktop') ?? {}) };
const tablet = { ...desktop, ...(read<RecordValue>(layouts, 'tablet') ?? {}) };
const mobile = { ...desktop, ...(read<RecordValue>(layouts, 'mobile') ?? {}), w: 1 };
const wallboard = { ...desktop, ...(read<RecordValue>(layouts, 'wallboard') ?? {}) };
return { ...widget, data: { sourceType: 'inventory', limit: 100, ...(widget.data ?? {}) }, visualization: { decimals: 0, thresholds: [], ...(widget.visualization ?? {}) }, behavior: { locked: false, hidden: false, liveIntervalSeconds: 30, ...(widget.behavior ?? {}) }, layouts: { desktop, tablet, mobile, wallboard } };
}
function newWidget(type: string, index: number): EditorWidget {
const baseLayout = { x: 0, y: index * 4, w: type === 'timeseries' ? 9 : 6, h: 4, visible: true };
return { id: widgetId(), type, title: labels[type] ?? widgetTypeCopy.newWidget, data: { sourceType: type === 'text' ? 'text' : 'inventory', limit: 100 }, visualization: { decimals: 0, thresholds: [] }, behavior: { locked: false, hidden: false, hideWhenEmpty: false, showOnlyOnProblem: false, liveIntervalSeconds: 30 }, layouts: { desktop: baseLayout, tablet: baseLayout, mobile: { ...baseLayout, w: 1 }, wallboard: baseLayout } };
}
function numberValue(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
export function validateWidget(widget: EditorWidget): ValidationErrors {
const errors: ValidationErrors = {};
if (!widget.id.trim()) errors.id = validation.idRequired;
if (!types.includes(widget.type)) errors.type = validation.typeUnsupported;
if (!widget.title.trim()) errors.title = validation.titleRequired;
if (widget.title.length > 120) errors.title = validation.titleTooLong;
const data = widget.data ?? {};
const source = typeof data.sourceType === 'string' ? data.sourceType : '';
if (!sourceTypes.includes(source)) errors['data.sourceType'] = validation.sourceUnsupported;
if (source === 'semantic-metric') {
const metric = typeof data.metric === 'string' ? data.metric.trim() : '';
if (!metric) errors['data.metric'] = validation.metricRequired;
if (/promql|query=/i.test(metric)) errors['data.metric'] = validation.metricNoPromql;
}
if (typeof data.range === 'string' && data.range && !ranges.includes(data.range) && !data.range.startsWith('$')) errors['data.range'] = validation.rangeUnsupported;
if (typeof data.aggregation === 'string' && data.aggregation && !aggregations.includes(data.aggregation)) errors['data.aggregation'] = validation.aggregationUnsupported;
const limit = numberValue(data.limit);
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 1000)) errors['data.limit'] = validation.limitRange;
const visualization = widget.visualization ?? {};
const decimals = numberValue(visualization.decimals);
if (decimals !== undefined && (!Number.isInteger(decimals) || decimals < 0 || decimals > 6)) errors['visualization.decimals'] = validation.decimalsRange;
const minimum = numberValue(visualization.min);
const maximum = numberValue(visualization.max);
if (minimum !== undefined && maximum !== undefined && minimum > maximum) errors['visualization.max'] = validation.maxBelowMin;
const interval = numberValue((widget.behavior ?? {}).liveIntervalSeconds);
if (interval !== undefined && (!Number.isInteger(interval) || interval < 1 || interval > 300)) errors['behavior.liveIntervalSeconds'] = validation.intervalRange;
return errors;
}
export function DashboardEditor({ dashboardId, revision, document, onExit, onSaved }: EditorProps) {
const initial = (read<unknown[]>(document, 'widgets') ?? []) as EditorWidget[];
const initialVariables = (read<unknown[]>(document, 'variables') ?? []) as DashboardVariable[];
const [draft, setDraft] = useState<EditorWidget[]>(initial.map((widget, index) => normalizeWidget(copyWidget(widget), index)));
const [variables, setVariables] = useState<DashboardVariable[]>(initialVariables.map((variable) => ({ ...variable, options: variable.options ? [...variable.options] : undefined })));
const [history, setHistory] = useState<EditorWidget[][]>([]);
const [future, setFuture] = useState<EditorWidget[][]>([]);
const [savedSnapshot, setSavedSnapshot] = useState(() => JSON.stringify({ widgets: initial.map((widget, index) => normalizeWidget(copyWidget(widget), index)), variables: initialVariables }));
const [confirmExit, setConfirmExit] = useState(false);
const [conflict, setConflict] = useState(false);
const [selected, setSelected] = useState<string | null>(initial[0]?.id ?? null);
const [newType, setNewType] = useState(types[0]);
const [activeViewport, setActiveViewport] = useState<Viewport>(() => window.innerWidth <= 700 ? 'mobile' : 'desktop');
const [dragged, setDragged] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [previewFor, setPreviewFor] = useState<string | null>(null);
const [previewState, setPreviewState] = useState('loading');
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState('');
const resize = useRef<{ id: string; x: number; w: number } | null>(null);
const drag = useRef<{ id: string; y: number } | null>(null);
const update = (id: string, change: (widget: EditorWidget) => EditorWidget) => applyDraft((items) => items.map((item) => item.id === id ? change(item) : item));
const selectedWidget = draft.find((item) => item.id === selected);
const dirty = JSON.stringify({ widgets: draft, variables }) !== savedSnapshot;
const applyDraft = (change: (items: EditorWidget[]) => EditorWidget[]): void => setDraft((items) => {
const next = change(items);
if (next === items) return items;
setHistory((entries) => [...entries, items.map(copyWidget)].slice(-50));
setFuture([]);
return next;
});
const selectedErrors = selectedWidget ? validateWidget(selectedWidget) : {};
const updateSelected = (next: EditorWidget) => { setPreview(null); setPreviewFor(null); setPreviewError(''); if (selected) update(selected, () => next); };
const move = (id: string, direction: -1 | 1) => applyDraft((items) => {
const index = items.findIndex((item) => item.id === id);
const next = index + direction;
if (index < 0 || next < 0 || next >= items.length || read<boolean>(items[index].behavior, 'locked')) return items;
const copy = [...items]; const [item] = copy.splice(index, 1); copy.splice(next, 0, item); return copy;
});
const onWidgetPointerDown = (event: ReactPointerEvent<HTMLElement>, id: string, locked: boolean) => {
if (locked || (event.target as HTMLElement).closest('button, input, select, textarea')) return;
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { id, y: event.clientY };
setDragged(id);
};
const onWidgetPointerMove = (event: ReactPointerEvent<HTMLElement>, id: string) => {
const active = drag.current;
if (!active || active.id !== id) return;
const delta = event.clientY - active.y;
if (Math.abs(delta) < 28) return;
move(id, delta > 0 ? 1 : -1);
drag.current = { id, y: event.clientY };
};
const onWidgetPointerUp = (event: ReactPointerEvent<HTMLElement>) => {
drag.current = null;
setDragged(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
const duplicate = (id: string) => applyDraft((items) => {
const source = items.find((item) => item.id === id); if (!source) return items;
const copy = copyWidget(source); copy.id = widgetId(); copy.title = copy.title + widgetTypeCopy.copySuffix; copy.behavior = { ...(copy.behavior ?? {}), locked: false, hidden: false }; return [...items, copy];
});
const remove = (id: string) => { applyDraft((items) => items.filter((item) => item.id !== id)); setSelected((value) => value === id ? null : value); };
const add = () => { const item = newWidget(newType, draft.length); applyDraft((items) => [...items, item]); setSelected(item.id); };
// Single width mutation used by the pointer handle, the arrow keys and the
// width field in the configuration drawer, so all three stay in step.
const setWidgetWidth = (id: string, next: number) => {
if (!Number.isFinite(next)) return;
const width = Math.min(viewportColumns[activeViewport], Math.max(1, Math.round(next)));
update(id, (item) => withLayout(item, activeViewport, { ...layout(item, activeViewport), w: width }));
};
const onResizeMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
const active = resize.current; if (!active) return;
setWidgetWidth(active.id, active.w + Math.round((event.clientX - active.x) / 48));
};
// Keyboard alternative for the pointer-only resize handle (UX_SPEC 5 and 12).
const onResizeKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>, id: string, current: number) => {
const max = viewportColumns[activeViewport];
const step = event.key === 'ArrowRight' || event.key === 'ArrowUp' ? 1 : event.key === 'ArrowLeft' || event.key === 'ArrowDown' ? -1 : 0;
if (step === 0 && event.key !== 'Home' && event.key !== 'End') return;
event.preventDefault();
event.stopPropagation();
setWidgetWidth(id, event.key === 'Home' ? 1 : event.key === 'End' ? max : current + step);
};
const undo = () => {
const previous = history[history.length - 1];
if (!previous) return;
setHistory((entries) => entries.slice(0, -1));
setFuture((entries) => [draft.map(copyWidget), ...entries].slice(0, 50));
setDraft(previous.map(copyWidget));
};
const redo = () => {
const next = future[0];
if (!next) return;
setFuture((entries) => entries.slice(1));
setHistory((entries) => [...entries, draft.map(copyWidget)].slice(-50));
setDraft(next.map(copyWidget));
};
const requestExit = () => { if (dirty) setConfirmExit(true); else onExit(); };
const importDocument = (nextDocument: RecordValue) => {
const importedWidgets = (read<unknown[]>(nextDocument, 'widgets') ?? []) as EditorWidget[];
const importedVariables = (read<unknown[]>(nextDocument, 'variables') ?? []) as DashboardVariable[];
setDraft(importedWidgets.map((widget, index) => normalizeWidget(copyWidget(widget), index)));
setVariables(importedVariables);
setHistory([]); setFuture([]); setMessage(messages.importLoaded);
};
const reloadServer = async () => {
try {
const response = await fetch('/api/v1/dashboards/' + encodeURIComponent(dashboardId));
if (!response.ok) throw new Error('reload');
const data = await response.json() as { version?: RecordValue };
const serverDocument = read<RecordValue>(data.version, 'document') ?? {};
const serverWidgets = (read<unknown[]>(serverDocument, 'widgets') ?? []) as EditorWidget[];
const next = serverWidgets.map((widget, index) => normalizeWidget(copyWidget(widget), index));
const nextVariables = (read<unknown[]>(serverDocument, 'variables') ?? []) as DashboardVariable[];
setDraft(next); setVariables(nextVariables); setSavedSnapshot(JSON.stringify({ widgets: next, variables: nextVariables })); setHistory([]); setFuture([]); setConflict(false); setMessage(messages.serverLoaded);
} catch { setMessage(messages.serverLoadFailed); }
};
const previewSelected = async () => {
if (!selectedWidget || Object.keys(selectedErrors).length > 0) { setPreviewError(messages.previewBlocked); return; }
setPreviewing(true); setPreviewError('');
try {
const response = await fetch('/api/v1/dashboards/' + encodeURIComponent(dashboardId) + '/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ widget: selectedWidget, state: previewState }) });
const data = await response.json() as { preview?: PreviewResult; detail?: string };
if (!response.ok || !data.preview) { setPreviewError(data.detail ?? messages.previewFailed); return; }
setPreview(data.preview); setPreviewFor(selectedWidget.id);
} catch { setPreviewError(messages.previewFailedConnection); } finally { setPreviewing(false); }
};
const save = async () => {
const invalid = draft.map((widget) => ({ widget, errors: validateWidget(widget) })).find((item) => Object.keys(item.errors).length > 0);
if (invalid) { setSelected(invalid.widget.id); setMessage(messages.saveBlockedWidgets); return; }
if (Object.keys(validateVariables(variables)).length > 0) { setMessage(messages.saveBlockedVariables); return; }
setSaving(true); setMessage('');
const body = { ...document, widgets: draft, variables };
try {
const response = await fetch('/api/v1/dashboards/' + encodeURIComponent(dashboardId) + '/document', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'If-Match': String(revision) }, body: JSON.stringify(body) });
if (!response.ok) { if (response.status === 409) { setConflict(true); setMessage(messages.saveConflict); } else setMessage(messages.saveFailed); return; }
const data = await response.json() as RecordValue;
const summary = read<RecordValue>(data, 'dashboard') ?? {};
setSavedSnapshot(JSON.stringify({ widgets: body.widgets, variables: body.variables })); setConflict(false); onSaved(Number(read(summary, 'revision') ?? revision + 1), body);
} catch { setMessage(messages.saveFailedConnection); } finally { setSaving(false); }
};
return <section className="dashboard-editor" aria-labelledby="dashboard-editor-title">
<header className="editor-header"><div><p className="eyebrow">{shell.eyebrow}</p><h1 id="dashboard-editor-title">{shell.title}</h1><p className="intro">{shell.intro}</p>{dirty && <p className="editor-dirty" role="status">{shell.dirty}</p>}</div><div className="editor-actions"><button className="button button--secondary" type="button" onClick={undo} disabled={!history.length} aria-label={shell.undo}>{shell.undo}</button><button className="button button--secondary" type="button" onClick={redo} disabled={!future.length} aria-label={shell.redoLabel}>{shell.redo}</button><button className="button button--secondary" type="button" onClick={requestExit}>{shell.cancel}</button><button className="button" type="button" onClick={save} disabled={saving}>{saving ? shell.saving : shell.save}</button></div></header>
{conflict && <div className="editor-banner editor-banner--conflict" role="alert"><strong>{shell.conflictTitle}</strong><span>{shell.conflictDetail}</span><button className="button button--secondary" type="button" onClick={reloadServer}>{shell.reloadServer}</button></div>}{confirmExit && <div className="editor-banner" role="alert"><strong>{shell.confirmExitTitle}</strong><span>{shell.confirmExitDetail}</span><button className="button button--secondary" type="button" onClick={() => setConfirmExit(false)}>{shell.keepEditing}</button><button className="button button--danger" type="button" onClick={onExit}>{shell.leaveWithoutSaving}</button></div>}<details className="editor-advanced"><summary>{shell.advanced}</summary><DashboardTransfer document={{ ...document, widgets: draft, variables }} onImport={importDocument} /><DashboardVariablesEditor variables={variables} onChange={setVariables} /></details><div className="editor-toolbar" aria-label={shell.library}><label>{shell.layout}<select value={activeViewport} onChange={(event) => setActiveViewport(event.target.value as Viewport)}>{viewports.map((viewport) => <option key={viewport} value={viewport}>{viewportLabels[viewport]}</option>)}</select></label><label>{shell.addWidget}<select value={newType} onChange={(event) => setNewType(event.target.value)}>{types.map((type) => <option key={type} value={type}>{labels[type]}</option>)}</select></label><button className="button button--secondary" type="button" onClick={add}>{shell.addWidget}</button>{message && <p className="editor-message" role="alert">{message}</p>}</div>
<h2 className="sr-only" id="editor-canvas-title">{shell.canvas}</h2><div className="editor-layout"><div className="dashboard-grid dashboard-grid--edit" data-viewport={activeViewport} aria-labelledby="editor-canvas-title">
{draft.map((widget) => { const activeLayout = layout(widget, activeViewport); const locked = read<boolean>(widget.behavior, 'locked') === true; const viewportHidden = read<boolean>(activeLayout, 'visible') === false; const hidden = read<boolean>(widget.behavior, 'hidden') === true || viewportHidden; const errors = validateWidget(widget); const width = layoutWidth(widget, activeViewport); return <article key={widget.id} className={'widget-card editor-widget' + (selected === widget.id ? ' editor-widget--selected' : '') + (locked ? ' editor-widget--locked' : '') + (hidden ? ' editor-widget--hidden' : '')} style={{ '--widget-span': String(width) } as CSSProperties} data-dragging={dragged === widget.id ? 'true' : undefined} onPointerDown={(event) => onWidgetPointerDown(event, widget.id, locked)} onPointerMove={(event) => onWidgetPointerMove(event, widget.id)} onPointerUp={onWidgetPointerUp} onPointerCancel={onWidgetPointerUp} onClick={() => { setSelected(widget.id); setPreview(null); setPreviewFor(null); }}><div className="widget-card-heading"><div><p className="card-kicker">{labels[widget.type] ?? widgetTypeCopy.fallback}</p><h3>{widget.title || widgetTypeCopy.untitled}</h3></div><span className="editor-lock-state">{locked ? cardCopy.locked : cardCopy.movable}</span></div><div className="editor-widget-actions"><button type="button" aria-label={cardCopy.moveUp + widget.title} onClick={(event) => { event.stopPropagation(); move(widget.id, -1); }}>{cardCopy.moveUpVisible}</button><button type="button" aria-label={cardCopy.moveDown + widget.title} onClick={(event) => { event.stopPropagation(); move(widget.id, 1); }}>{cardCopy.moveDownVisible}</button><button type="button" aria-label={(locked ? cardCopy.unlockLabel : cardCopy.lockLabel) + widget.title} onClick={(event) => { event.stopPropagation(); update(widget.id, (item) => ({ ...item, behavior: { ...(item.behavior ?? {}), locked: !locked } })); }}>{locked ? cardCopy.unlock : cardCopy.lock}</button><button type="button" aria-label={(viewportHidden ? cardCopy.show : cardCopy.hide) + cardCopy.inViewport + viewportLabels[activeViewport]} onClick={(event) => { event.stopPropagation(); update(widget.id, (item) => withLayout(item, activeViewport, { ...layout(item, activeViewport), visible: viewportHidden })); }}>{viewportHidden ? cardCopy.show : cardCopy.hide}</button><button type="button" onClick={(event) => { event.stopPropagation(); duplicate(widget.id); }}>{cardCopy.duplicate}</button><button type="button" onClick={(event) => { event.stopPropagation(); remove(widget.id); }}>{cardCopy.remove}</button><button className="editor-resize-handle" type="button" role="slider" tabIndex={0} aria-label={cardCopy.resizeLabel + widget.title} aria-orientation="horizontal" aria-valuemin={1} aria-valuemax={viewportColumns[activeViewport]} aria-valuenow={width} aria-valuetext={width + ' ' + cardCopy.columns} aria-describedby={'resize-hint-' + widget.id} onKeyDown={(event) => onResizeKeyDown(event, widget.id, width)} onPointerDown={(event) => { event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); resize.current = { id: widget.id, x: event.clientX, w: width }; }} onPointerMove={onResizeMove} onPointerUp={() => { resize.current = null; }}>{cardCopy.resizeVisible}</button></div><p className="sr-only" id={'resize-hint-' + widget.id}>{cardCopy.resizeHint}</p>{Object.keys(errors).length > 0 && <p className="editor-drag-hint editor-widget-warning" role="status">{cardCopy.incompleteConfig}</p>}<p className="editor-drag-hint">{locked ? cardCopy.lockedHint : cardCopy.dragHint}</p></article>; })}
</div><div className="editor-inspector">{selectedWidget ? <WidgetConfigDrawer widget={selectedWidget} errors={selectedErrors} preview={previewFor === selected ? preview : null} previewState={previewState} previewing={previewing} previewError={previewError} viewportLabel={viewportLabels[activeViewport]} width={layoutWidth(selectedWidget, activeViewport)} maxWidth={viewportColumns[activeViewport]} onChange={updateSelected} onWidthChange={(value) => setWidgetWidth(selectedWidget.id, value)} onPreviewStateChange={setPreviewState} onPreview={previewSelected} /> : <aside className="editor-inspector card" aria-label={shell.selectedWidget}><p className="card-copy">{shell.selectWidgetHint}</p></aside>}</div></div>
</section>;
}
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { resolveDashboardScope } from './dashboardScope';
import { LiveChartAdapter, historicalSamplesFromData, type LiveFreshness } from './liveBuffer';
import { LiveClient } from './liveClient';
import { MetricClient, rangeForPreset, type MetricQueryRequest, type MetricRangePreset } from './metricClient';
import { MetricWidget, StatusGridWidget, type MetricWidgetProps, type StatusGridItem } from './MetricWidgets';
import { buildStorageNodes, type StorageData } from './StoragePage';
import { StorageMapWidget } from './StorageVisuals';
import { aggregateStatus, useSystemStatus } from './systemStatus';
import { useLiveMetric } from './useLiveMetric';
import { useMetricQuery } from './useMetricQuery';
import { formatDateTime } from './locale';
import { copy } from './copy';
import { presentEventSummary, presentEventType, presentReason, presentStatus } from './presentation';
import { wallboardColumns, wallboardPlacement } from './wallboardLayout';
type RecordValue = Record<string, unknown>;
export type RuntimeWidget = { id: string; type: string; title: string; description?: string; data?: RecordValue; visualization?: RecordValue; behavior?: RecordValue; layouts?: RecordValue };
export type RuntimeState = 'loading' | 'usable' | 'empty' | 'error';
const metricClient = new MetricClient();
const liveClient = new LiveClient();
function field<T>(record: RecordValue | undefined, name: string): T | undefined {
if (!record) return undefined;
const upper = name.charAt(0).toUpperCase() + name.slice(1);
return (record[name] ?? record[upper] ?? record[name.toUpperCase()]) as T | undefined;
}
function Badge({ state }: { state: RuntimeState }) {
const usable = state === 'usable';
const label = usable ? copy.dashboards.runtime.current : state === 'loading' ? copy.dashboards.runtime.loading : state === 'empty' ? copy.dashboards.runtime.empty : copy.dashboards.runtime.error;
return <span className={'status-badge status-badge--' + (usable ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{usable ? '✓' : '?'}</span>{label}</span>;
}
type Resource = { state: RuntimeState; items?: StatusGridItem[]; storage?: StorageData; events?: EventItem[]; error?: string };
type EventItem = { id: string; type: string; severity: string; summary: string; occurredAt: string; sourceId?: string };
function useInventoryResource(widget: RuntimeWidget): Resource {
const [resource, setResource] = useState<Resource>({ state: 'loading' });
const sourceType = String(field(widget.data, 'sourceType') ?? '');
const scope = field<RecordValue>(widget.data, 'scope') ?? {};
const entityType = String(field(scope, 'entityType') ?? '');
const isApplications = sourceType === 'inventory' && entityType === 'application';
const isStorage = sourceType === 'inventory' && Array.isArray(field(scope, 'entityTypes'));
const isEvents = sourceType === 'events';
const interval = Math.min(300, Math.max(5, Number(field(widget.behavior, 'liveIntervalSeconds') ?? 15))) * 1000;
useEffect(() => {
if (!isApplications && !isStorage && !isEvents) {
setResource({ state: 'empty' });
return undefined;
}
let active = true;
let controller: AbortController | null = null;
const read = async <T,>(url: string, signal: AbortSignal): Promise<T> => {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
};
const load = async () => {
controller?.abort();
const current = new AbortController();
controller = current;
try {
if (isApplications) {
const value = await read<{ applications?: Array<{ id: string; name: string; status: string; reasons?: Array<{ message?: string }> }> }>('/api/v1/applications', current.signal);
const items = (value.applications ?? []).slice(0, 50).map((item) => ({ id: item.id, label: item.name, status: presentStatus(item.status), reason: presentReason(item.reasons?.[0]?.message) }));
if (active) setResource({ state: items.length ? 'usable' : 'empty', items });
} else if (isStorage) {
const [array, disks, pools] = await Promise.all([
read<StorageData['array']>('/api/v1/array', current.signal),
read<StorageData['disks']>('/api/v1/disks?limit=100', current.signal),
read<StorageData['pools']>('/api/v1/pools?limit=100', current.signal),
]);
const storage = { array, disks, pools };
if (active) setResource({ state: buildStorageNodes(storage).length ? 'usable' : 'empty', storage });
} else {
const limit = Math.min(100, Math.max(1, Number(field(widget.data, 'limit') ?? 100)));
const value = await read<{ items?: EventItem[] }>('/api/v1/events?limit=' + limit, current.signal);
const events = (value.items ?? []).slice(0, limit);
if (active) setResource({ state: events.length ? 'usable' : 'empty', events });
}
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (active) setResource({ state: 'error', error: error instanceof Error ? error.message : 'bron niet beschikbaar' });
}
};
void load();
const timer = window.setInterval(load, interval);
return () => { active = false; controller?.abort(); window.clearInterval(timer); };
}, [isApplications, isStorage, isEvents, interval, widget.data]);
return resource;
}
export function DashboardRuntimeWidget({ widget, viewport, document, timeRange, onState, onFilter }: { widget: RuntimeWidget; viewport: 'desktop' | 'tablet' | 'mobile' | 'wallboard'; document: RecordValue; timeRange: string; onState: (id: string, state: RuntimeState) => void; onFilter: () => void }) {
const sourceType = String(field(widget.data, 'sourceType') ?? '');
const semantic = sourceType === 'semantic-metric';
const system = useSystemStatus();
const inventory = useInventoryResource(widget);
const range = useMemo(() => rangeForPreset(timeRange as MetricRangePreset), [timeRange]);
const request = useMemo<MetricQueryRequest | null>(() => {
if (!semantic) return null;
const metric = String(field(widget.data, 'metric') ?? '');
if (!metric) return null;
const scope = resolveDashboardScope(field<RecordValue>(widget.data, 'scope') ?? {}, field<unknown[]>(document, 'variables') ?? []);
return { metric, scope, range, aggregation: String(field(widget.data, 'aggregation') ?? 'avg') };
}, [semantic, widget.data, document, range]);
const metricState = useMetricQuery(metricClient, request);
const historicalSamples = useMemo(() => metricState.status === 'success' ? historicalSamplesFromData(metricState.response?.data) : [], [metricState.status, metricState.response?.data]);
const freshness: LiveFreshness = metricState.response?.freshness ?? 'unavailable';
const historicalSeries = useMemo(() => { const adapter = new LiveChartAdapter(4000); adapter.append(historicalSamples.map((sample) => ({ ...sample, freshness }))); return adapter.snapshot(); }, [historicalSamples, freshness]);
const live = useLiveMetric(liveClient, semantic && timeRange === 'live' ? request : null, historicalSamples);
const systemWidget = sourceType === 'inventory' && String(field(field<RecordValue>(widget.data, 'scope'), 'entityType') ?? '') === 'server';
const systemStatus = aggregateStatus(system);
const metricRuntime: RuntimeState = metricState.status === 'error' || (timeRange === 'live' && live.state === 'error') ? 'error' : (timeRange === 'live' ? live.series.length : historicalSeries.length) > 0 ? 'usable' : metricState.status === 'loading' || (timeRange === 'live' && live.state === 'connecting') ? 'loading' : 'empty';
const runtime = semantic ? metricRuntime : systemWidget ? (system.state === 'loading' ? 'loading' : system.state === 'ready' ? 'usable' : 'error') : inventory.state;
useEffect(() => { onState(widget.id, runtime); }, [onState, runtime, widget.id]);
const activeLayout = field<RecordValue>(widget.layouts, viewport) ?? field<RecordValue>(widget.layouts, 'desktop') ?? {};
const columns = viewport === 'wallboard' ? wallboardColumns : viewport === 'tablet' ? 8 : viewport === 'mobile' ? 1 : 18;
const width = viewport === 'mobile' ? 1 : Math.min(columns, Math.max(1, Number(field(activeLayout, 'w') ?? 6)));
const placement = wallboardPlacement(activeLayout);
const layoutStyle = viewport === 'wallboard' ? { '--widget-span': String(placement.columnSpan), gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`, gridRow: `${placement.rowStart} / span ${placement.rowSpan}` } as CSSProperties : { '--widget-span': String(width) } as CSSProperties;
let content;
if (semantic) {
const metric: MetricWidgetProps = { kind: widget.type as MetricWidgetProps['kind'], series: timeRange === 'live' ? live.series : historicalSeries, freshness, expectedStepSeconds: request?.range.stepSeconds ?? 15, availability: timeRange === 'live' ? live.state : metricState.status, error: timeRange === 'live' ? live.error : metricState.error?.actionable ?? null, visualization: widget.visualization, metricName: request?.metric, sourceObservedAt: metricState.response?.sourceObservedAt, receivedAt: metricState.response?.receivedAt, warnings: metricState.response?.warnings, inspector: metricState.response?.inspector };
content = <MetricWidget {...metric} />;
} else if (systemWidget && system.state === 'ready') {
content = <div className="dashboard-runtime-stat"><strong>{systemStatus.label}</strong><span>{systemStatus.detail}</span><small>{system.status?.components.length ?? 0} {copy.dashboards.runtime.checkedComponents}</small></div>;
} else if (inventory.items?.length) {
content = <StatusGridWidget items={inventory.items} onSelect={onFilter} />;
} else if (inventory.storage) {
content = <StorageMapWidget nodes={buildStorageNodes(inventory.storage)} title={widget.title} description={widget.description ?? ''} idPrefix={'dashboard-storage-' + widget.id} />;
} else if (inventory.events?.length) {
content = <ol className="dashboard-event-list">{inventory.events.slice(0, viewport === 'mobile' ? 6 : 12).map((event) => <li key={event.id}><span className={'event-severity event-severity--' + event.severity} aria-hidden="true" /><span><strong>{presentEventSummary(event.type, event.summary)}</strong><small>{presentEventType(event.type)} · {formatDateTime(event.occurredAt)}</small></span></li>)}</ol>;
} else {
content = <div className={'dashboard-runtime-state dashboard-runtime-state--' + runtime} role={runtime === 'error' ? 'alert' : 'status'}><strong>{runtime === 'error' ? copy.dashboards.runtime.sourceUnavailable : runtime === 'loading' ? copy.dashboards.runtime.telemetryLoading : copy.dashboards.runtime.noCurrentData}</strong><span>{runtime === 'error' ? copy.dashboards.runtime.unavailableDetail : copy.dashboards.runtime.retryDetail}</span></div>;
}
return <article className="widget-card widget-card--runtime" data-runtime-state={runtime} data-viewport={viewport} style={layoutStyle} aria-labelledby={'widget-' + widget.id}><div className="widget-card-heading"><div><p className="card-kicker">{sourceType === 'semantic-metric' ? copy.dashboards.runtime.semanticMetric : sourceType === 'events' ? copy.dashboards.runtime.events : copy.dashboards.runtime.inventory}</p><h3 id={'widget-' + widget.id}>{widget.title}</h3></div><Badge state={runtime} /></div>{widget.description && <p className="widget-description">{widget.description}</p>}{content}</article>;
}
+50
View File
@@ -0,0 +1,50 @@
import { copy } from './copy';
import type { RecordValue } from './DashboardEditor';
export type TransferResult = { document: RecordValue; errors: string[] };
const transferCopy = copy.editor.transfer;
function hasUnsafeText(value: unknown): boolean {
if (typeof value === 'string') return /<\s*script|<\s*iframe|javascript:/i.test(value);
if (Array.isArray(value)) return value.some(hasUnsafeText);
if (value && typeof value === 'object') return Object.values(value as RecordValue).some(hasUnsafeText);
return false;
}
function depth(value: unknown, current = 0): number {
if (!value || typeof value !== 'object') return current;
const children = Array.isArray(value) ? value : Object.values(value as RecordValue);
return children.reduce((max, child) => Math.max(max, depth(child, current + 1)), current);
}
export function validatePortableDashboard(value: unknown): TransferResult {
const errors: string[] = [];
if (!value || typeof value !== 'object' || Array.isArray(value)) return { document: {}, errors: [transferCopy.errors.notAnObject] };
const document = value as RecordValue;
const schemaVersion = document.schemaVersion;
if (schemaVersion !== 1 && schemaVersion !== 2) errors.push(transferCopy.errors.schemaVersion);
const widgets = Array.isArray(document.widgets) ? document.widgets : [];
const variables = Array.isArray(document.variables) ? document.variables : [];
if (!Array.isArray(document.widgets)) errors.push(transferCopy.errors.widgetsArray);
if (!Array.isArray(document.variables)) errors.push(transferCopy.errors.variablesArray);
if (widgets.length > 200) errors.push(transferCopy.errors.tooManyWidgets);
if (variables.length > 30) errors.push(transferCopy.errors.tooManyVariables);
if (depth(value) > 12) errors.push(transferCopy.errors.tooDeep);
if (hasUnsafeText(value)) errors.push(transferCopy.errors.unsafeContent);
return { document, errors };
}
export function parsePortableDashboard(text: string): TransferResult {
if (text.length > 2 * 1024 * 1024) return { document: {}, errors: [transferCopy.errors.tooLarge] };
try { return validatePortableDashboard(JSON.parse(text)); } catch { return { document: {}, errors: [transferCopy.errors.invalidJson] }; }
}
type Props = { document: RecordValue; onImport: (document: RecordValue) => void };
const templateLabels: Record<string, string> = { empty: transferCopy.templateEmpty, operations: transferCopy.templateOperations };
const templates: Record<string, RecordValue> = {
empty: { schemaVersion: 2, id: '00000000-0000-0000-0000-000000000000', slug: 'nieuw-dashboard', name: transferCopy.templateEmptyName, scope: 'personal', variables: [], widgets: [], settings: { defaultTimeRange: '1h' } },
operations: { schemaVersion: 2, id: '00000000-0000-0000-0000-000000000000', slug: 'operations-template', name: transferCopy.templateOperations, scope: 'personal', variables: [{ name: 'timeRange', type: 'time-range', label: copy.editor.variables.periodLabel, default: '1h', options: ['live', '15m', '1h', '6h', '24h', '7d'] }], widgets: [], settings: { defaultTimeRange: '1h' } }
};
export function DashboardTransfer({ document, onImport }: Props) {
const exportDocument = () => { const blob = new Blob([JSON.stringify(document, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = window.document.createElement('a'); link.href = url; link.download = 'pulse-dashboard.json'; link.click(); URL.revokeObjectURL(url); };
const importDocument = async (file: File) => { const result = parsePortableDashboard(await file.text()); if (result.errors.length) { window.alert(result.errors.join(' ')); return; } onImport(result.document); };
return <section className="dashboard-transfer card" aria-labelledby="dashboard-transfer-title"><div className="card-heading"><div><p className="card-kicker">{transferCopy.kicker}</p><h2 id="dashboard-transfer-title">{transferCopy.title}</h2></div><button className="button button--secondary" type="button" onClick={exportDocument}>{transferCopy.export}</button></div><label className="transfer-import">{transferCopy.import}<input type="file" accept="application/json,.json" onChange={(event) => { const file = event.target.files?.[0]; if (file) void importDocument(file); event.currentTarget.value = ''; }} /></label><div className="template-list" aria-label={transferCopy.templates}>{Object.entries(templates).map(([key, template]) => <button className="button button--secondary" type="button" key={key} onClick={() => onImport(JSON.parse(JSON.stringify(template)))}>{templateLabels[key] ?? key}</button>)}</div></section>;
}
+41
View File
@@ -0,0 +1,41 @@
import { copy } from './copy';
export type DashboardVariable = { name: string; type: string; label: string; default: string; options?: string[] };
export type VariableErrors = Record<string, string>;
const variableTypes = ['server', 'entity', 'container', 'application', 'disk', 'pool', 'service', 'time-range', 'enum'];
const text = copy.editor.variables;
export function validateVariables(variables: DashboardVariable[]): VariableErrors {
const errors: VariableErrors = {};
if (variables.length > 30) errors.form = text.errors.tooMany;
const names = new Set<string>();
variables.forEach((variable, index) => {
const prefix = 'variables.' + index;
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(variable.name)) errors[prefix + '.name'] = text.errors.nameFormat;
if (names.has(variable.name)) errors[prefix + '.name'] = text.errors.nameUnique;
names.add(variable.name);
if (!variableTypes.includes(variable.type)) errors[prefix + '.type'] = text.errors.typeUnsupported;
if (!variable.label.trim()) errors[prefix + '.label'] = text.errors.labelRequired;
if (variable.options && variable.options.length > 1000) errors[prefix + '.options'] = text.errors.tooManyOptions;
if ((variable.type === 'entity' || variable.type === 'server') && variable.options?.length && !variable.options.includes(variable.default)) errors[prefix + '.default'] = text.errors.defaultNotAllowed;
});
return errors;
}
type Props = { variables: DashboardVariable[]; onChange: (variables: DashboardVariable[]) => void };
export function DashboardVariablesEditor({ variables, onChange }: Props) {
const errors = validateVariables(variables);
const error = (key: string) => errors[key] ? <p className="field-error">{errors[key]}</p> : null;
const update = (index: number, change: (variable: DashboardVariable) => DashboardVariable) => onChange(variables.map((variable, current) => current === index ? change(variable) : variable));
const add = () => onChange([...variables, { name: 'variable' + (variables.length + 1), type: 'time-range', label: text.newLabel, default: '1h', options: ['live', '15m', '1h', '6h', '24h', '7d'] }]);
const remove = (index: number) => onChange(variables.filter((_, current) => current !== index));
return <section className="variable-editor card" aria-labelledby="variable-editor-title">
<div className="card-heading"><div><p className="card-kicker">{text.kicker}</p><h2 id="variable-editor-title">{text.title}</h2></div><button className="button button--secondary" type="button" onClick={add}>{text.add}</button></div>
<p className="card-copy">{text.intro}</p>
{errors.form && <p className="field-error" role="alert">{errors.form}</p>}
{variables.length === 0 && <p className="card-copy">{text.empty}</p>}
<div className="variable-list">{variables.map((variable, index) => { const prefix = 'variables.' + index; const options = variable.options ?? []; return <article className="variable-row" key={index}><div className="config-fields"><label htmlFor={prefix + '-name'}>{text.name}<input id={prefix + '-name'} value={variable.name} onChange={(event) => update(index, (item) => ({ ...item, name: event.target.value }))} /></label><label htmlFor={prefix + '-label'}>{text.label}<input id={prefix + '-label'} value={variable.label} onChange={(event) => update(index, (item) => ({ ...item, label: event.target.value }))} /></label></div>{error(prefix + '.name')}{error(prefix + '.label')}<div className="config-fields"><label htmlFor={prefix + '-type'}>{text.type}<select id={prefix + '-type'} value={variable.type} onChange={(event) => update(index, (item) => ({ ...item, type: event.target.value }))}>{variableTypes.map((type) => <option key={type} value={type}>{type}</option>)}</select></label><label htmlFor={prefix + '-default'}>{text.default}{(variable.type === 'entity' || variable.type === 'server') && options.length > 0 ? <select id={prefix + '-default'} value={variable.default} onChange={(event) => update(index, (item) => ({ ...item, default: event.target.value }))}>{options.map((option) => <option key={option} value={option}>{option}</option>)}</select> : <input id={prefix + '-default'} value={variable.default} disabled={variable.type === 'entity' || variable.type === 'server'} onChange={(event) => update(index, (item) => ({ ...item, default: event.target.value }))} />}</label></div>{error(prefix + '.type')}{error(prefix + '.default')}<label htmlFor={prefix + '-options'}>{text.options}<input id={prefix + '-options'} value={options.join(',')} placeholder={text.optionsPlaceholder} onChange={(event) => update(index, (item) => ({ ...item, options: event.target.value.split(',').map((option) => option.trim()).filter(Boolean) }))} /></label>{error(prefix + '.options')}<button className="button button--secondary variable-remove" type="button" onClick={() => remove(index)}>{text.remove}</button></article>; })}</div>
</section>;
}
File diff suppressed because one or more lines are too long
+110
View File
@@ -0,0 +1,110 @@
import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { copy } from './copy';
import { formatDateTime, hasReceivedTimestamp } from './locale';
import { queryValue, replaceListQuery } from './listQuery';
import { presentEventSummary, presentEventType, presentStatus } from './presentation';
type EventItem = { id: string; type: string; severity: string; entityId?: string; sourceId?: string; occurredAt: string; receivedAt: string; summary: string };
type EventResponse = { items?: EventItem[] };
type LoadState = 'loading' | 'ready' | 'error';
const pageSize = 20;
const severities = ['critical', 'warning', 'attention', 'info', 'unknown'] as const;
function severityTone(value: string): 'ready' | 'attention' | 'critical' | 'unknown' {
if (value === 'critical') return 'critical';
if (value === 'warning' || value === 'attention' || value === 'error') return 'attention';
if (value === 'info') return 'ready';
return 'unknown';
}
function initialPage(): number {
const value = Number.parseInt(queryValue('page'), 10);
return Number.isFinite(value) && value > 1 ? value - 1 : 0;
}
function searchableText(item: EventItem): string {
return [item.summary, presentEventSummary(item.type, item.summary), item.type, presentEventType(item.type), item.entityId, item.sourceId]
.filter(Boolean)
.join(' ')
.toLocaleLowerCase('nl-BE');
}
export function EventsPage() {
const [state, setState] = useState<LoadState>('loading');
const [items, setItems] = useState<EventItem[]>([]);
const [search, setSearch] = useState(() => queryValue('q'));
const deferredSearch = useDeferredValue(search);
const [severity, setSeverity] = useState(() => queryValue('severity', severities));
const [type, setType] = useState(() => queryValue('type'));
const [entity, setEntity] = useState(() => queryValue('entity'));
const [page, setPage] = useState(initialPage);
const [revision, setRevision] = useState(0);
const pageStatus = useRef<HTMLSpanElement>(null);
useEffect(() => {
const controller = new AbortController();
setState('loading');
fetch('/api/v1/events?limit=100', { signal: controller.signal })
.then((response) => { if (!response.ok) throw new Error('events'); return response.json() as Promise<EventResponse>; })
.then((value) => { setItems((value.items ?? []).slice(0, 100)); setState('ready'); })
.catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setState('error'); });
return () => controller.abort();
}, [revision]);
const types = useMemo(() => [...new Set(items.map((item) => item.type))].sort((a, b) => presentEventType(a).localeCompare(presentEventType(b), 'nl-BE')), [items]);
const entities = useMemo(() => [...new Set(items.map((item) => item.entityId).filter((value): value is string => Boolean(value)))].sort((a, b) => a.localeCompare(b)), [items]);
const normalizedSearch = deferredSearch.trim().toLocaleLowerCase('nl-BE');
const shown = useMemo(() => items.filter((item) => (!severity || item.severity === severity)
&& (!type || item.type === type)
&& (!entity || item.entityId === entity)
&& (!normalizedSearch || searchableText(item).includes(normalizedSearch))), [entity, items, normalizedSearch, severity, type]);
const criticalCount = items.filter((item) => item.severity === 'critical').length;
const pageCount = Math.max(1, Math.ceil(shown.length / pageSize));
const currentPage = Math.min(page, pageCount - 1);
const pageItems = shown.slice(currentPage * pageSize, (currentPage + 1) * pageSize);
const firstResult = shown.length === 0 ? 0 : currentPage * pageSize + 1;
const lastResult = Math.min((currentPage + 1) * pageSize, shown.length);
useEffect(() => {
if (page !== currentPage) setPage(currentPage);
}, [currentPage, page]);
useEffect(() => {
replaceListQuery({ q: search.trim(), severity, type, entity, page: currentPage > 0 ? String(currentPage + 1) : '' });
}, [currentPage, entity, search, severity, type]);
const resetPage = () => setPage(0);
const movePage = (next: number) => {
setPage(next);
requestAnimationFrame(() => pageStatus.current?.focus());
};
const clearFilters = () => { setSearch(''); setSeverity(''); setType(''); setEntity(''); setPage(0); };
return <>
<header className="page-intro"><p className="eyebrow">{copy.events.eyebrow}</p><h1>{copy.events.title}</h1><p className="intro">{copy.events.intro}</p></header>
<section className="event-summary" aria-label={copy.events.summary}>
<article className="card"><p className="card-kicker">{copy.events.loaded}</p><h2>{items.length}</h2><p className="card-copy">{copy.events.loadedDetail}</p></article>
<button className="card event-summary-action" type="button" onClick={() => { setSeverity('critical'); setPage(0); }}><span className="card-kicker">{copy.events.criticalSummary}</span><strong>{criticalCount}</strong><small>{criticalCount ? copy.events.showCritical : copy.events.noCritical}</small></button>
<article className="card"><p className="card-kicker">{copy.events.results}</p><h2>{shown.length}</h2><p className="card-copy">{firstResult}{lastResult} {copy.events.of} {shown.length}</p></article>
</section>
<section className="card event-timeline" aria-labelledby="event-timeline-title">
<div className="card-heading"><div><p className="card-kicker">{copy.events.timeline}</p><h2 id="event-timeline-title">{copy.events.latest}</h2></div><button className="button button--secondary" type="button" onClick={() => setRevision((value) => value + 1)}>{copy.events.refresh}</button></div>
<form className="list-filters event-filters" onSubmit={(event) => event.preventDefault()}>
<label>{copy.events.search}<input type="search" value={search} placeholder={copy.events.searchPlaceholder} onChange={(event) => { setSearch(event.target.value); resetPage(); }} /></label>
<label>{copy.events.severity}<select value={severity} onChange={(event) => { setSeverity(event.target.value); resetPage(); }}><option value="">{copy.events.allSeverities}</option><option value="critical">{copy.events.critical}</option><option value="warning">{copy.events.warning}</option><option value="attention">{copy.events.attention}</option><option value="info">{copy.events.info}</option><option value="unknown">{copy.events.unknown}</option></select></label>
<label>{copy.events.type}<select value={type} onChange={(event) => { setType(event.target.value); resetPage(); }}><option value="">{copy.events.allTypes}</option>{types.map((value) => <option key={value} value={value}>{presentEventType(value)}</option>)}</select></label>
<label>{copy.events.entity}<select value={entity} onChange={(event) => { setEntity(event.target.value); resetPage(); }}><option value="">{copy.events.allEntities}</option>{entities.map((value) => <option key={value} value={value}>{value}</option>)}</select></label>
<button className="button button--secondary" type="button" onClick={clearFilters} disabled={!search && !severity && !type && !entity}>{copy.events.clearFilters}</button>
</form>
{state === 'loading' ? <p className="card-copy" role="status">{copy.events.loading}</p> : state === 'error' ? <div role="alert"><h3>{copy.events.errorTitle}</h3><p className="card-copy">{copy.events.errorDetail}</p></div> : shown.length === 0 ? <div className="event-empty"><p className="card-copy">{copy.events.empty}</p><button className="button button--secondary" type="button" onClick={clearFilters}>{copy.events.clearEmpty}</button></div> : <ol className="event-list" aria-label={copy.events.results}>{pageItems.map((item) => <li key={item.id}>
<span className={'status-badge status-badge--' + severityTone(item.severity)}><span className="status-icon" aria-hidden="true">{item.severity === 'critical' ? '!' : '•'}</span>{presentStatus(item.severity)}</span>
<div className="event-row-content"><div className="event-row-heading"><h3>{presentEventType(item.type)}</h3>{hasReceivedTimestamp(item.occurredAt) ? <time dateTime={item.occurredAt}>{formatDateTime(item.occurredAt)}</time> : <span>{formatDateTime(item.occurredAt)}</span>}</div><p>{presentEventSummary(item.type, item.summary)}</p><small>{item.entityId ? `${copy.events.entity}: ${item.entityId}` : copy.events.noEntity}</small>
<details className="inline-technical"><summary>{copy.events.technical}</summary><dl className="technical-grid"><dt>ID</dt><dd>{item.id}</dd><dt>{copy.events.type}</dt><dd>{item.type}</dd><dt>{copy.events.source}</dt><dd>{item.sourceId || copy.events.noSource}</dd><dt>{copy.events.entity}</dt><dd>{item.entityId || copy.events.noEntity}</dd><dt>{copy.events.received}</dt><dd>{formatDateTime(item.receivedAt)}</dd></dl></details>
</div>
</li>)}</ol>}
{state === 'ready' && shown.length > 0 && <nav className="list-pager" aria-label={copy.events.pagination}><button className="button button--secondary" type="button" disabled={currentPage === 0} onClick={() => movePage(currentPage - 1)}>{copy.events.previous}</button><span ref={pageStatus} role="status" tabIndex={-1}>{copy.events.page} {currentPage + 1} {copy.events.of} {pageCount} · {firstResult}{lastResult}</span><button className="button button--secondary" type="button" disabled={currentPage + 1 >= pageCount} onClick={() => movePage(currentPage + 1)}>{copy.events.next}</button></nav>}
<p className="card-copy event-limit-note">{items.length} {copy.events.rows}</p>
</section>
</>;
}
+90
View File
@@ -0,0 +1,90 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentReason, presentStatus } from './presentation';
import { SourceStatusDetails } from './SourceStatusDetails';
type HostSnapshot = {
source: { id: string; type: string; observedAt?: string; freshness: string; state: string; reason?: string };
identity: { name: string; version?: string; kernel?: string; architecture?: string };
uptimeSeconds: number;
bootTime?: string;
cpu: { totalPercent?: number; perCore?: number[]; iowaitPercent?: number };
load: { one: number; five: number; fifteen: number };
memory: { totalBytes: number; availableBytes: number; usedBytes: number; utilizationPercent: number; swapTotalBytes: number; swapUsedBytes: number; swapUtilizationPercent: number };
filesystems: Array<{ mount: string; filesystem?: string; capacityBytes: number; usedBytes: number; utilizationPercent: number; inodes?: { total: number; used: number } }>;
network: Array<{ name: string; state?: string; rxBytes: number; txBytes: number; rxErrors: number; txErrors: number; rxDrops: number; txDrops: number }>;
time: { synchronized: boolean; offsetSeconds: number; stratum?: number; state: string };
hardware?: { capabilities: Array<{ id: string; version: string; state: string; reason?: string }>; temperatures: Array<{ id: string; name: string; celsius: number }>; fans: Array<{ id: string; name: string; rpm: number }>; gpus: Array<{ id: string; name: string; vendor?: string; utilizationPercent?: number; memoryUsedBytes: number; memoryTotalBytes: number; memoryUtilizationPercent?: number; temperatureCelsius?: number }>; status: { state: string; reasons?: Array<{ code: string; message: string }> } };
status: { state: string; reasons?: Array<{ code: string; message: string }> };
observedAt?: string;
receivedAt: string;
warnings?: string[];
};
function getStatusLabel(state: string): string {
return state === 'healthy' ? 'Gezond' : state === 'degraded' ? 'Aandacht' : 'Onbekend';
}
function Badge({ state }: { state: string }) {
const ready = state === 'healthy';
return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{getStatusLabel(state)}</span>;
}
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 days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return days > 0 ? days + ' d ' + hours + ' u' : hours + ' u ' + minutes + ' min';
}
function when(value?: string): string { return formatDateTime(value); }
export function HostPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [snapshot, setSnapshot] = useState<HostSnapshot | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch('/api/v1/host', { signal: controller.signal }).then((response) => {
if (!response.ok) throw new Error('host');
return response.json() as Promise<HostSnapshot>;
}).then((data) => { setSnapshot(data); setState('ready'); }).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
setState('error');
});
return () => controller.abort();
}, []);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.host.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.host.errorTitle}</h1><p>{copy.host.errorDetail}</p><button className="button" type="button" onClick={() => window.location.reload()}>{copy.host.retry}</button></section>;
const status = snapshot.status?.state ?? 'unknown';
const observed = snapshot.observedAt ?? snapshot.source?.observedAt;
return <>
<header className="page-intro"><p className="eyebrow">{copy.host.eyebrow}</p><h1>{copy.host.title}</h1><p className="intro">{copy.host.intro}</p></header>
<section className="host-summary card" aria-labelledby="host-summary-title">
<div className="card-heading"><div><p className="card-kicker">{copy.host.identity}</p><h2 id="host-summary-title">{snapshot.identity?.name || copy.host.unknown}</h2><p className="card-copy">{[snapshot.identity?.version, snapshot.identity?.kernel, snapshot.identity?.architecture].filter(Boolean).join(' · ') || copy.host.noIdentityDetails}</p></div><Badge state={status} /></div>
<SourceStatusDetails source={{ ...snapshot.source, observedAt: observed }} fallbackReason={snapshot.source?.freshness === 'fresh' ? copy.host.fresh : copy.host.stale} />
{snapshot.status?.reasons?.map((reason) => <p className="host-reason" key={reason.code}>{presentReason(reason.code)}</p>)}
</section>
<section className="host-metric-grid" aria-label={copy.host.metrics}>
<article className="card"><p className="card-kicker">{copy.host.uptime}</p><h2>{duration(snapshot.uptimeSeconds)}</h2><p className="card-copy">{snapshot.bootTime ? copy.host.boot + ' ' + when(snapshot.bootTime) : copy.host.noBoot}</p></article>
<article className="card"><p className="card-kicker">{copy.host.cpu}</p><h2>{snapshot.cpu?.totalPercent == null ? '—' : snapshot.cpu.totalPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'}</h2><p className="card-copy">{snapshot.cpu?.perCore?.length ?? 0} {copy.host.cores} · iowait {snapshot.cpu?.iowaitPercent == null ? '—' : snapshot.cpu.iowaitPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'}</p></article>
<article className="card"><p className="card-kicker">{copy.host.load}</p><h2>{snapshot.load?.one?.toLocaleString('nl-BE', { maximumFractionDigits: 2 }) ?? '—'}</h2><p className="card-copy">1 / 5 / 15 min: {snapshot.load?.one ?? '—'} · {snapshot.load?.five ?? '—'} · {snapshot.load?.fifteen ?? '—'}</p></article>
<article className="card"><p className="card-kicker">{copy.host.memory}</p><h2>{snapshot.memory?.utilizationPercent?.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) ?? '—'}%</h2><p className="card-copy">{bytes(snapshot.memory?.usedBytes ?? 0)} / {bytes(snapshot.memory?.totalBytes ?? 0)} · {copy.host.available}: {bytes(snapshot.memory?.availableBytes ?? 0)}</p></article>
<article className="card"><p className="card-kicker">{copy.host.time}</p><h2>{snapshot.time?.synchronized ? copy.host.synchronized : copy.host.notSynchronized}</h2><p className="card-copy">Offset {snapshot.time?.offsetSeconds?.toLocaleString('nl-BE', { maximumFractionDigits: 3 }) ?? '—'} s · stratum {snapshot.time?.stratum ?? '—'}</p></article>
</section>
<section className="host-detail-grid">
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.filesystems}</p><h2>{snapshot.filesystems?.length ?? 0}</h2></div></div>{snapshot.filesystems?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.mount}</th><th>{copy.host.used}</th><th>{copy.host.inodes}</th></tr></thead><tbody>{snapshot.filesystems.map((item) => <tr key={item.mount}><th scope="row">{item.mount}</th><td>{item.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%<small>{bytes(item.usedBytes)} / {bytes(item.capacityBytes)}</small></td><td>{item.inodes ? ((item.inodes.used / item.inodes.total) * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : '—'}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noData}</p>}</article>
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.network}</p><h2>{snapshot.network?.length ?? 0}</h2></div></div>{snapshot.network?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.interface}</th><th>RX / TX</th><th>{copy.host.errors}</th></tr></thead><tbody>{snapshot.network.map((item) => <tr key={item.name}><th scope="row">{item.name}<small>{item.state || copy.host.unknown}</small></th><td>{bytes(item.rxBytes)} / {bytes(item.txBytes)}</td><td>{item.rxErrors + item.txErrors + item.rxDrops + item.txDrops}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noData}</p>}</article>
</section>
<section className="host-detail-grid">
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.hardware}</p><h2>{snapshot.hardware?.temperatures?.length ?? 0} · {snapshot.hardware?.fans?.length ?? 0} {copy.host.sensors}</h2></div><Badge state={snapshot.hardware?.status?.state ?? 'unknown'} /></div>{snapshot.hardware?.status?.reasons?.map((reason) => <p className="host-reason" key={reason.code}>{presentReason(reason.code)}</p>)}{snapshot.hardware?.capabilities?.length ? <ul className="host-capabilities">{snapshot.hardware.capabilities.map((capability) => <li key={capability.id}><span>{capability.id}</span><small>{presentStatus(capability.state)} · {presentReason(capability.reason)}</small></li>)}</ul> : null}{snapshot.hardware?.temperatures?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.sensor}</th><th>{copy.host.temperature}</th></tr></thead><tbody>{snapshot.hardware.temperatures.map((item) => <tr key={item.id}><th scope="row">{item.name}<small>{item.id}</small></th><td>{item.celsius.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} °C</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noSensors}</p>}</article>
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.gpu}</p><h2>{snapshot.hardware?.gpus?.length ?? 0}</h2></div></div>{snapshot.hardware?.gpus?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.device}</th><th>{copy.host.gpuUsage}</th><th>{copy.host.gpuMemory}</th></tr></thead><tbody>{snapshot.hardware.gpus.map((item) => <tr key={item.id}><th scope="row">{item.name}<small>{item.vendor || copy.host.unknown}</small></th><td>{item.utilizationPercent == null ? '—' : item.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'}</td><td>{item.memoryTotalBytes ? item.memoryUtilizationPercent?.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : '—'}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noGpu}</p>}</article>
</section> {snapshot.warnings?.length ? <section className="card host-warnings" aria-label={copy.host.warnings}><p className="card-kicker">{copy.host.warnings}</p>{snapshot.warnings.map((warning) => <p key={warning}>{presentReason(warning)}</p>)}</section> : null}
</>;
}
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
import { formatDateTime } from './locale';
type Association = { alertId: string; rationale: string; confidence: number; correlationMethod: string; manual: boolean; addedBy?: string; createdAt: string };
type Note = { id: string; incidentId: string; author: string; body: string; createdAt: string };
type Incident = { id: string; correlationKey: string; title: string; summary: string; severity: string; status: string; startedAt: string; resolvedAt?: string; ownerUserId?: string; correlationMethod: string; confidence: number; revision: number; updatedAt: string; alerts?: Association[]; notes?: Note[] };
function navigate(path: string) { window.history.pushState({}, '', path); window.dispatchEvent(new PopStateEvent('popstate')); }
function formatTime(value: string) { return formatDateTime(value); }
function severityLabel(value: string) { return value === 'critical' ? 'Kritiek' : value === 'degraded' ? 'Aandacht' : 'Opmerking'; }
function statusLabel(value: string) { return value === 'resolved' ? 'Opgelost' : value === 'acknowledged' ? 'Erkend' : 'Open'; }
export function IncidentPage({ id }: { id?: string }) {
const [state, setState] = useState<'loading' | 'ready' | 'empty' | 'error' | 'unauthorized'>('loading');
const [items, setItems] = useState<Incident[]>([]);
const [incident, setIncident] = useState<Incident | null>(null);
const [note, setNote] = useState('');
const [owner, setOwner] = useState('');
const [message, setMessage] = useState('');
const [reload, setReload] = useState(0);
useEffect(() => {
const controller = new AbortController();
setState('loading');
const url = id ? '/api/v1/incidents/' + encodeURIComponent(id) : '/api/v1/incidents?limit=100&status=open';
fetch(url, { signal: controller.signal }).then((response) => { if (response.status === 401) throw new Error('unauthorized'); if (!response.ok) throw new Error('error'); return response.json() as Promise<{ incident?: Incident; items?: Incident[] }>; }).then((data) => {
if (id) { const next = data.incident ?? null; setIncident(next); setOwner(next?.ownerUserId ?? ''); setState(next ? 'ready' : 'empty'); } else { const next = data.items ?? []; setItems(next); setState(next.length ? 'ready' : 'empty'); }
}).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState(error instanceof Error && error.message === 'unauthorized' ? 'unauthorized' : 'error'); });
return () => controller.abort();
}, [id, reload]);
const timeline = useMemo(() => {
if (!incident) return [] as Array<{ id: string; label: string; detail: string; at: string }>;
const entries = (incident.alerts ?? []).map((alert) => ({ id: 'alert-' + alert.alertId, label: alert.manual ? 'Handmatig gekoppeld alert' : 'Gecorreleerd alert', detail: alert.rationale + ' · confidence ' + Math.round(alert.confidence * 100) + '%', at: alert.createdAt }));
entries.push(...(incident.notes ?? []).map((item) => ({ id: item.id, label: 'Notitie van ' + item.author, detail: item.body, at: item.createdAt })));
return entries.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
}, [incident]);
const submitNote = async () => { if (!id || !note.trim()) return; setMessage('Notitie wordt opgeslagen…'); const response = await fetch('/api/v1/incidents/' + encodeURIComponent(id) + '/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: note }) }); if (!response.ok) { setMessage('De notitie kon niet worden opgeslagen.'); return; } setNote(''); setMessage('Notitie opgeslagen.'); setReload((value) => value + 1); };
const saveOwner = async () => { if (!id || !incident) return; setMessage('Eigenaar wordt opgeslagen…'); const response = await fetch('/api/v1/incidents/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ownerUserId: owner.trim(), revision: incident.revision }) }); if (!response.ok) { setMessage('De eigenaar kon niet worden opgeslagen.'); return; } setMessage('Eigenaar opgeslagen.'); setReload((value) => value + 1); };
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>Incidenten laden</h1></section>;
if (state === 'unauthorized') return <section className="state-page" role="alert"><h1>Geen toegang tot incidenten</h1><p>Je hebt geen rechten om incidentgegevens te bekijken.</p></section>;
if (state === 'error') return <section className="state-page" role="alert"><h1>Incidenten niet beschikbaar</h1><p>De incidentgegevens konden niet veilig worden geladen.</p><button className="button" type="button" onClick={() => setReload((value) => value + 1)}>Opnieuw laden</button></section>;
if (!id && state === 'empty') return <><header className="page-intro"><p className="eyebrow">{copy.incidents.listEyebrow}</p><h1>{copy.incidents.listEyebrow}</h1><p className="intro">{copy.incidents.listIntro}</p></header><section className="card empty-state" aria-live="polite"><span className="empty-state-icon" aria-hidden="true"></span><h2>Geen open incidenten</h2><p>Er zijn momenteel geen open incidenten geregistreerd.</p></section></>;
if (!id) return <><header className="page-intro"><p className="eyebrow">{copy.incidents.listEyebrow}</p><h1>{copy.incidents.listEyebrow}</h1><p className="intro">{copy.incidents.listIntro}</p></header><section className="card incident-list-panel" aria-labelledby="incident-list-title"><div className="card-heading"><div><p className="card-kicker">{copy.incidents.openIncidents}</p><h2 id="incident-list-title">{copy.incidents.listTitle}</h2></div><span className="status-badge status-badge--unknown"><span className="status-icon" aria-hidden="true">?</span>{items.length} incidenten</span></div><ul className="incident-list">{items.map((item) => <li className={'incident-list-row incident-list-row--' + item.severity} key={item.id}><button className="incident-list-item" type="button" onClick={() => navigate('/incidents/' + encodeURIComponent(item.id))}><span><strong>{item.title}</strong><small>{severityLabel(item.severity)} · {item.summary}</small></span><span className="incident-list-meta"><span>{statusLabel(item.status)}</span><span>{copy.incidents.confidence}: {Math.round(item.confidence * 100)}%</span></span></button></li>)}</ul></section></>;
if (!incident) return null;
return <><button className="back-link" type="button" onClick={() => navigate('/incidents')}> Terug naar incidenten</button><header className="page-intro incident-page-intro"><p className="eyebrow">{copy.incidents.detailEyebrow}</p><h1>{incident.title}</h1><p className="intro">{incident.summary}</p></header><section className={'incident-command-strip incident-command-strip--' + incident.severity} aria-label={copy.incidents.statusAndConfidence}><span><small>Ernst</small><strong>{severityLabel(incident.severity)}</strong></span><span><small>Status</small><strong>{statusLabel(incident.status)}</strong></span><span><small>{copy.incidents.started}</small><strong>{formatTime(incident.startedAt)}</strong></span><span><small>{copy.incidents.confidence}</small><strong>{Math.round(incident.confidence * 100)}%</strong></span></section><section className="incident-detail-grid"><article className="card incident-rationale"><div className="card-heading"><div><p className="card-kicker">{copy.incidents.statusAndConfidence}</p><h2>{statusLabel(incident.status)}</h2></div><span className="status-badge status-badge--unknown"><span className="status-icon" aria-hidden="true">?</span>{severityLabel(incident.severity)}</span></div><dl className="incident-facts"><div><dt>{copy.incidents.started}</dt><dd>{formatTime(incident.startedAt)}</dd></div><div><dt>{copy.incidents.correlationMethod}</dt><dd>{incident.correlationMethod}</dd></div><div><dt>{copy.incidents.confidence}</dt><dd>{Math.round(incident.confidence * 100)}%</dd></div><div><dt>{copy.incidents.revision}</dt><dd>{incident.revision}</dd></div></dl><p className="uncertainty-note">Correlatie is een onderbouwde aanwijzing, geen bewezen causaliteit. Betrouwbaarheid beschrijft de correlatieregel, niet de zekerheid van de oorzaak.</p></article><article className="card incident-follow-up"><p className="card-kicker">{copy.incidents.ownership}</p><h2>{copy.incidents.followUp}</h2><label className="incident-field">{copy.incidents.ownerLabel}<input id="incident-owner" name="ownerUserId" value={owner} onChange={(event) => setOwner(event.target.value)} placeholder={copy.incidents.ownerPlaceholder} aria-label={copy.incidents.ownerLabel} /></label><button className="button" type="button" onClick={saveOwner}>{copy.incidents.saveOwner}</button><p className="card-copy">{copy.incidents.ownerNote}</p></article></section><section className="card incident-timeline-panel" aria-labelledby="incident-timeline-title"><div className="card-heading"><div><p className="card-kicker">{copy.incidents.timeline}</p><h2 id="incident-timeline-title">{copy.incidents.signalsAndNotes}</h2></div><span className="card-copy">{timeline.length} {copy.incidents.timelineItems}</span></div>{timeline.length === 0 ? <p className="card-copy">{copy.incidents.noTimeline}</p> : <ol className="incident-timeline">{timeline.map((entry) => <li key={entry.id}><time dateTime={entry.at}>{formatTime(entry.at)}</time><div><strong>{entry.label}</strong><p>{entry.detail}</p></div></li>)}</ol>}</section><section className="card incident-notes-panel" aria-labelledby="incident-notes-title"><div className="card-heading"><div><p className="card-kicker">{copy.incidents.notes}</p><h2 id="incident-notes-title">{copy.incidents.operatorContext}</h2></div></div><label className="incident-field">{copy.incidents.newNote}<textarea id="incident-note" name="note" value={note} onChange={(event) => setNote(event.target.value)} maxLength={2000} rows={4} aria-describedby="incident-note-help" /></label><p id="incident-note-help" className="card-copy">{copy.incidents.noteHelp}</p><button className="button" type="button" onClick={submitNote} disabled={!note.trim()}>{copy.incidents.addNote}</button>{message && <p className="editor-message" role="status">{message}</p>}</section><section className="card incident-workflow-placeholder" aria-labelledby="incident-workflow-title"><p className="card-kicker">{copy.incidents.externalWorkflow}</p><h2 id="incident-workflow-title">{copy.incidents.workflowReady}</h2><p className="card-copy">{copy.incidents.workflowIntro}</p><input aria-label={copy.incidents.workflowPlaceholder} placeholder={copy.incidents.workflowPlaceholder} disabled /></section></>;
}
+140
View File
@@ -0,0 +1,140 @@
import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { copy } from './copy';
import { formatDateTime } from './locale';
import { queryValue, replaceListQuery } from './listQuery';
import { presentEntityType, presentInventoryField, presentRelationType, presentStatus } from './presentation';
type Entity = {
id: string; entityType: string; canonicalName: string; displayName: string; status: string;
firstSeenAt?: string; lastSeenAt?: string; factCount: number; overrideCount: number;
relationCount: number; sourceCount: number; staleFactCount: number;
};
type Fact = { fieldName: string; sourceId: string; sourceName: string; value: unknown; observedAt: string; confidence: number; validUntil?: string; stale: boolean };
type Override = { fieldName: string; value: unknown; updatedAt: string };
type Effective = { fieldName: string; value: unknown; origin: 'override' | 'discovered'; sourceName?: string; observedAt?: string; confidence?: number; stale: boolean; overriddenAt?: string };
type Alias = { sourceName: string; externalType: string; externalId: string };
type Relation = { id: string; direction: 'incoming' | 'outgoing'; relationType: string; peerId: string; peerType: string; peerName: string; peerStatus: string; peerTombstonedAt?: string; sourceName: string; confidence: number; confirmed: boolean; tombstonedAt?: string };
type Detail = { entity: Entity; aliases: Alias[]; facts: Fact[]; overrides: Override[]; effectiveValues: Effective[]; relations: Relation[] };
type Page = { items: Entity[]; nextCursor: string; hasMore: boolean };
type LoadState = 'loading' | 'ready' | 'error';
function valueText(value: unknown): string {
if (value == null) return copy.inventory.missingValue;
if (typeof value === 'string') return value;
if (typeof value === 'boolean') return value ? copy.inventory.yes : copy.inventory.no;
if (typeof value === 'number') return value.toLocaleString('nl-BE');
return JSON.stringify(value);
}
function effectiveValueText(fieldName: string, value: unknown): string {
return /(?:status|state|health)$/i.test(fieldName) && typeof value === 'string' ? presentStatus(value) : valueText(value);
}
function statusTone(status: string, stale = false): string {
if (stale || !status || status.toLowerCase() === 'unknown') return 'unknown';
if (['healthy', 'operational', 'running', 'up', 'online', 'ready', 'gereed'].includes(status.toLowerCase())) return 'ready';
return 'attention';
}
function Badge({ label, stale = false }: { label: string; stale?: boolean }) {
const tone = statusTone(label, stale);
const rawStatus = /^(healthy|operational|running|up|online|ready|degraded|attention|critical|down|offline|missing|faulted|unknown)$/i.test(label);
return <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : tone === 'attention' ? '!' : '?'}</span>{stale ? copy.inventory.stale : rawStatus ? presentStatus(label) : label || copy.inventory.unknown}</span>;
}
function ErrorState({ retry }: { retry: () => void }) {
return <div role="alert"><p className="card-copy">{copy.inventory.error}</p><button className="button button--secondary" type="button" onClick={retry}>{copy.inventory.retry}</button></div>;
}
export function InventoryPage({ id }: { id?: string }) {
return id ? <InventoryDetail id={id} /> : <InventoryList />;
}
function InventoryList() {
const [state, setState] = useState<LoadState>('loading');
const [items, setItems] = useState<Entity[]>([]);
const [cursor, setCursor] = useState(() => queryValue('after'));
const [nextCursor, setNextCursor] = useState('');
const [history, setHistory] = useState<string[]>([]);
const pageStatus = useRef<HTMLSpanElement>(null);
const [hasMore, setHasMore] = useState(false);
const [query, setQuery] = useState(() => queryValue('q'));
const deferredQuery = useDeferredValue(query);
const [type, setType] = useState(() => queryValue('type'));
const [status, setStatus] = useState(() => queryValue('status'));
const [order, setOrder] = useState(() => queryValue('order', ['asc', 'desc'], 'asc'));
const [revision, setRevision] = useState(0);
const request = useMemo(() => {
const params = new URLSearchParams({ limit: '25', order });
if (deferredQuery.trim()) params.set('q', deferredQuery.trim());
if (type) params.set('type', type);
if (status) params.set('status', status);
if (cursor) params.set('after', cursor);
return params;
}, [deferredQuery, type, status, order, cursor]);
useEffect(() => {
const controller = new AbortController();
setState((current) => current === 'ready' ? 'ready' : 'loading');
replaceListQuery({ q: deferredQuery.trim(), type, status, order: order === 'asc' ? '' : order, after: cursor });
fetch('/api/v1/entities?' + request, { signal: controller.signal })
.then((response) => { if (!response.ok) throw new Error('inventory'); return response.json() as Promise<Page>; })
.then((page) => { setItems(page.items ?? []); setNextCursor(page.nextCursor ?? ''); setHasMore(Boolean(page.hasMore)); setState('ready'); if (cursor) requestAnimationFrame(() => pageStatus.current?.focus()); })
.catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setState('error'); });
return () => controller.abort();
}, [request, deferredQuery, type, status, order, cursor, revision]);
const resetPage = () => { setCursor(''); setHistory([]); };
const previous = () => { const prior = [...history]; setCursor(prior.pop() ?? ''); setHistory(prior); };
const next = () => { if (!nextCursor) return; setHistory((values) => [...values, cursor]); setCursor(nextCursor); };
const sourceTotal = items.filter((item) => item.sourceCount > 0).length;
return <>
<header className="page-intro"><p className="eyebrow">{copy.inventory.eyebrow}</p><h1>{copy.inventory.title}</h1><p className="intro">{copy.inventory.intro}</p></header>
<section className="inventory-summary" aria-label={copy.inventory.summary}>
<article className="card"><p className="card-kicker">{copy.inventory.entities}</p><h2>{state === 'ready' ? items.length + (hasMore ? '+' : '') : '—'}</h2><p className="card-copy">{copy.inventory.visibleEntities}</p></article>
<article className="card"><p className="card-kicker">{copy.inventory.provenance}</p><h2>{state === 'ready' ? sourceTotal : '—'}</h2><p className="card-copy">{copy.inventory.sourceCoverage}</p></article>
<article className="card"><p className="card-kicker">{copy.inventory.manualCorrections}</p><h2>{state === 'ready' ? items.reduce((sum, item) => sum + item.overrideCount, 0) : '—'}</h2><p className="card-copy">{copy.inventory.overrideProtection}</p></article>
</section>
<section className="inventory-panel card" aria-labelledby="inventory-list-title">
<div className="card-heading"><div><p className="card-kicker">{copy.inventory.entities}</p><h2 id="inventory-list-title">{copy.inventory.searchTitle}</h2></div>{state === 'ready' && <Badge label={copy.inventory.ready} />}</div>
<form className="inventory-filters" onSubmit={(event) => event.preventDefault()}>
<label>{copy.inventory.search}<input type="search" value={query} onChange={(event) => { setQuery(event.target.value); resetPage(); }} placeholder={copy.inventory.searchPlaceholder} /></label>
<label>{copy.inventory.type}<input value={type} onChange={(event) => { setType(event.target.value); resetPage(); }} placeholder={copy.inventory.allTypes} /></label>
<label>{copy.inventory.status}<input value={status} onChange={(event) => { setStatus(event.target.value); resetPage(); }} placeholder={copy.inventory.allStatuses} /></label>
<label>{copy.inventory.sort}<select value={order} onChange={(event) => { setOrder(event.target.value); resetPage(); }}><option value="asc">AZ</option><option value="desc">ZA</option></select></label>
</form>
{state === 'error' ? <ErrorState retry={() => setRevision((value) => value + 1)} /> : state === 'loading' ? <p className="card-copy" role="status">{copy.inventory.loading}</p> : items.length === 0 ? <p className="card-copy">{copy.inventory.empty}</p> : <ul className="inventory-list inventory-entity-list">{items.map((entity) => <li key={entity.id}><a className="entity-link" href={'/inventory/' + encodeURIComponent(entity.id)}><strong>{entity.displayName}</strong><small>{presentEntityType(entity.entityType)} · {entity.canonicalName}</small><span className="inventory-row-meta">{entity.sourceCount} {copy.inventory.sourcesShort} · {entity.factCount} {copy.inventory.factsShort} · {entity.relationCount} {copy.inventory.relationsShort}{entity.overrideCount > 0 ? ' · ' + entity.overrideCount + ' ' + copy.inventory.overridesShort : ''}</span></a><Badge label={entity.status} stale={entity.staleFactCount > 0 && entity.staleFactCount === entity.factCount} /></li>)}</ul>}
{state === 'ready' && <nav className="list-pager" aria-label={copy.inventory.page}><button className="button button--secondary" type="button" disabled={!cursor} onClick={previous}>{copy.inventory.previous}</button><span ref={pageStatus} role="status" tabIndex={-1}>{copy.inventory.page} {history.length + 1}</span><button className="button button--secondary" type="button" disabled={!hasMore} onClick={next}>{copy.inventory.next}</button></nav>}
</section>
</>;
}
function InventoryDetail({ id }: { id: string }) {
const [state, setState] = useState<LoadState>('loading');
const [detail, setDetail] = useState<Detail | null>(null);
const [revision, setRevision] = useState(0);
useEffect(() => {
const controller = new AbortController(); setState('loading');
fetch('/api/v1/entities/' + encodeURIComponent(id), { signal: controller.signal })
.then((response) => { if (!response.ok) throw new Error('inventory'); return response.json() as Promise<Detail>; })
.then((value) => { setDetail(value); setState('ready'); })
.catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setState('error'); });
return () => controller.abort();
}, [id, revision]);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.inventory.loadingDetail}</h1></section>;
if (state === 'error' || !detail) return <section className="state-page"><h1>{copy.inventory.detailUnavailable}</h1><ErrorState retry={() => setRevision((value) => value + 1)} /></section>;
const entity = detail.entity;
return <>
<a className="back-link inventory-back" href="/inventory"> {copy.inventory.back}</a>
<header className="inventory-detail-header"><div><p className="eyebrow">{presentEntityType(entity.entityType)}</p><h1>{entity.displayName}</h1><p className="intro">{entity.canonicalName}</p></div><Badge label={entity.status} /></header>
<section className="inventory-summary" aria-label={copy.inventory.summary}><article className="card"><p className="card-kicker">{copy.inventory.sources}</p><h2>{entity.sourceCount}</h2><p className="card-copy">{copy.inventory.firstSeen}: {entity.firstSeenAt ? formatDateTime(entity.firstSeenAt) : copy.inventory.unknown}</p></article><article className="card"><p className="card-kicker">{copy.inventory.facts}</p><h2>{entity.factCount}</h2><p className="card-copy">{entity.staleFactCount} {copy.inventory.staleFacts}</p></article><article className="card"><p className="card-kicker">{copy.inventory.relations}</p><h2>{entity.relationCount}</h2><p className="card-copy">{detail.aliases.length} {copy.inventory.aliases.toLowerCase()}</p></article></section>
<section className="card inventory-effective" aria-labelledby="effective-title"><div className="card-heading"><div><p className="card-kicker">{copy.inventory.effective}</p><h2 id="effective-title">{copy.inventory.effectiveTitle}</h2></div></div>{detail.effectiveValues.length === 0 ? <p className="card-copy">{copy.inventory.noEffective}</p> : <dl className="inventory-value-grid">{detail.effectiveValues.map((item) => <div key={item.fieldName}><dt>{presentInventoryField(item.fieldName)}</dt><dd><strong>{effectiveValueText(item.fieldName, item.value)}</strong><span className={'provenance-chip provenance-chip--' + item.origin}>{item.origin === 'override' ? copy.inventory.manualOverride : item.sourceName || copy.inventory.discovered}</span>{item.stale && <span className="provenance-chip provenance-chip--stale">{copy.inventory.stale}</span>}<small>{item.origin === 'override' ? copy.inventory.overrideWins : `${copy.inventory.observed} ${item.observedAt ? formatDateTime(item.observedAt) : copy.inventory.unknown} · ${Math.round((item.confidence ?? 0) * 100)}%`}</small></dd></div>)}</dl>}</section>
<div className="inventory-detail-grid">
<section className="card" aria-labelledby="relations-title"><p className="card-kicker">{copy.inventory.topology}</p><h2 id="relations-title">{copy.inventory.relations}</h2>{detail.relations.length === 0 ? <p className="card-copy">{copy.inventory.noRelations}</p> : <ul className="inventory-list">{detail.relations.map((relation) => <li key={relation.id}><a className="entity-link" href={'/inventory/' + encodeURIComponent(relation.peerId)}><strong>{relation.peerName}</strong><small>{relation.direction === 'outgoing' ? '→' : '←'} {presentRelationType(relation.relationType)} · {presentEntityType(relation.peerType)}</small><span className="inventory-row-meta">{relation.sourceName} · {relation.confirmed ? copy.inventory.confirmed : copy.inventory.inferred}</span></a><Badge label={relation.tombstonedAt ? copy.inventory.missingRelation : relation.peerStatus} stale={Boolean(relation.tombstonedAt)} /></li>)}</ul>}</section>
<section className="card" aria-labelledby="aliases-title"><p className="card-kicker">{copy.inventory.identity}</p><h2 id="aliases-title">{copy.inventory.aliases}</h2>{detail.aliases.length === 0 ? <p className="card-copy">{copy.inventory.noAliases}</p> : <ul className="compact-list">{detail.aliases.map((alias) => <li key={alias.sourceName + alias.externalType + alias.externalId}><strong>{alias.sourceName}</strong>: {presentEntityType(alias.externalType)}</li>)}</ul>}</section>
</div>
<details className="card technical-details inventory-technical"><summary>{copy.inventory.allEvidence}</summary><div className="inventory-evidence"><section><h2>{copy.inventory.facts}</h2>{detail.facts.length === 0 ? <p className="card-copy">{copy.inventory.noFacts}</p> : <ul className="compact-list">{detail.facts.map((fact) => <li key={fact.fieldName + fact.sourceId}><strong>{fact.fieldName}</strong>: {valueText(fact.value)} · {fact.sourceName} · {formatDateTime(fact.observedAt)} {fact.stale ? '· ' + copy.inventory.stale : ''}</li>)}</ul>}</section><section><h2>{copy.inventory.overrides}</h2>{detail.overrides.length === 0 ? <p className="card-copy">{copy.inventory.noOverrides}</p> : <ul className="compact-list">{detail.overrides.map((override) => <li key={override.fieldName}><strong>{override.fieldName}</strong>: {valueText(override.value)} · {formatDateTime(override.updatedAt)}</li>)}</ul>}</section></div></details>
</>;
}
+264
View File
@@ -0,0 +1,264 @@
import { useId, type CSSProperties, type ReactNode } from 'react';
import type { BufferedPoint, ChartSeries, LiveFreshness } from './liveBuffer';
import type { MetricInspector } from './metricClient';
import { copy } from './copy';
import { presentMetric, presentStatus } from './presentation';
export type MetricWidgetKind = 'stat' | 'timeseries' | 'gauge' | 'query-inspector';
export type MetricWidgetAvailability = 'idle' | 'loading' | 'success' | 'error' | 'connecting' | 'live';
export type MetricWidgetProps = {
kind: MetricWidgetKind;
series: ChartSeries[];
freshness: LiveFreshness;
expectedStepSeconds: number;
availability: MetricWidgetAvailability;
error?: string | null;
visualization?: Record<string, unknown>;
metricName?: string;
sourceObservedAt?: string;
receivedAt?: string;
warnings?: string[];
inspector?: MetricInspector;
};
type LimitedSeries = { key: string; points: BufferedPoint[] };
type NumericPoint = Omit<BufferedPoint, 'value'> & { value: number };
type Summary = { min: number; max: number; average: number; count: number };
export function seriesDisplayName(series: LimitedSeries, metricName?: string): string {
const pointLabels = series.points.at(-1)?.labels ?? {};
const labels = Object.entries(pointLabels)
.filter(([key, value]) => key !== '__name__' && String(value).trim() !== '')
.sort(([left], [right]) => left.localeCompare(right, 'nl-BE'));
if (labels.length > 0) return labels.map(([, value]) => String(value)).join(' · ');
if (series.key.startsWith('{')) {
try {
const parsed = JSON.parse(series.key) as Record<string, unknown>;
const values = Object.entries(parsed)
.filter(([key, value]) => key !== '__name__' && String(value).trim() !== '')
.sort(([left], [right]) => left.localeCompare(right, 'nl-BE'))
.map(([, value]) => String(value));
if (values.length > 0) return values.join(' · ');
} catch { /* A non-JSON series key is handled by the readable fallback below. */ }
}
if (series.key === metricName) return copy.metrics.totalSeries;
return series.key.replaceAll('.', ' ').replaceAll('_', ' ');
}
const freshnessLabels: Record<LiveFreshness, string> = {
fresh: copy.metrics.fresh,
delayed: copy.metrics.delayed,
stale: copy.metrics.stale,
unavailable: copy.metrics.unavailable,
};
const unitLabels: Record<string, string> = {
percent: '%',
percentage: '%',
seconds: 's',
milliseconds: 'ms',
bytes: 'bytes',
bytesPerSecond: 'bytes/s',
count: '',
};
function numberSetting(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function booleanSetting(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback; }
function decimalsSetting(value: unknown): number { const decimals = numberSetting(value); return decimals === undefined ? 2 : Math.min(6, Math.max(0, Math.trunc(decimals))); }
function formatNumber(value: number, decimals: number): string { return new Intl.NumberFormat('nl-BE', { maximumFractionDigits: decimals, minimumFractionDigits: decimals }).format(value); }
function unitText(value: unknown): string { const unit = typeof value === 'string' ? value : ''; return unitLabels[unit] ?? unit; }
function formatValue(value: number, visualization: Record<string, unknown>): string {
const decimals = decimalsSetting(visualization.decimals);
const unit = unitText(visualization.unit);
const formatted = formatNumber(value, decimals);
return unit ? formatted + ' ' + unit : formatted;
}
function validPoints(series: readonly LimitedSeries[]): NumericPoint[] { return series.flatMap((item) => item.points).filter((point): point is NumericPoint => point.value !== null && Number.isFinite(point.value)); }
function latestPoint(series: readonly LimitedSeries[]): BufferedPoint | null {
return validPoints(series).sort((a, b) => b.timestamp - a.timestamp || String(a.labels?.__name__ ?? '').localeCompare(String(b.labels?.__name__ ?? '')))[0] ?? null;
}
function summaryFor(points: readonly BufferedPoint[]): Summary | null {
const values = points.map((point) => point.value).filter((value): value is number => value !== null && Number.isFinite(value));
if (values.length === 0) return null;
return { min: Math.min(...values), max: Math.max(...values), average: values.reduce((sum, value) => sum + value, 0) / values.length, count: values.length };
}
function seriesSummary(series: LimitedSeries): Summary | null { return summaryFor(series.points); }
/**
* UX_SPEC section 8 asks for "readable charts with reduced series" on mobile.
* Narrow viewports get a smaller series/point budget than the documented
* desktop bounds; desktop keeps 20 series x 4000 points unchanged.
*/
export function reducedMetricLimits(width = typeof window === 'undefined' ? 1280 : window.innerWidth): { maxSeries: number; maxPoints: number } | null {
if (width <= 700) return { maxSeries: 4, maxPoints: 600 };
if (width <= 900) return { maxSeries: 8, maxPoints: 1500 };
return null;
}
export function limitMetricSeries(series: readonly ChartSeries[], maxSeries = 20, maxPoints = 4000): LimitedSeries[] {
const safeSeries = Math.max(1, Math.min(100, Math.trunc(maxSeries)));
const safePoints = Math.max(1, Math.min(10000, Math.trunc(maxPoints)));
const selected = [...series].sort((a, b) => a.key.localeCompare(b.key)).slice(0, safeSeries);
if (selected.length === 0) return [];
const perSeries = Math.max(1, Math.floor(safePoints / selected.length));
let remainder = safePoints - perSeries * selected.length;
return selected.map((item) => {
const allowance = perSeries + (remainder > 0 ? 1 : 0);
remainder = Math.max(0, remainder - 1);
return { key: item.key, points: [...item.points].sort((a, b) => a.timestamp - b.timestamp).slice(-allowance) };
});
}
function freshnessFor(props: MetricWidgetProps, point: BufferedPoint | null): LiveFreshness {
if (props.freshness === 'stale' || props.freshness === 'unavailable') return props.freshness;
return point?.freshness ?? props.freshness;
}
function freshnessLabel(freshness: LiveFreshness): string { return freshnessLabels[freshness]; }
function availabilityText(props: MetricWidgetProps): string {
if (props.availability === 'loading') return copy.metrics.loading;
if (props.availability === 'connecting') return copy.metrics.connecting;
if (props.availability === 'error') return props.error || 'De metric is niet beschikbaar.';
if (props.availability === 'idle') return copy.metrics.noMetric;
return copy.metrics.noReliableData;
}
function hasDataGap(series: readonly LimitedSeries[], expectedStepSeconds: number): boolean {
const threshold = Math.max(1, expectedStepSeconds) * 2000;
return series.some((item) => item.points.some((point, index) => point.value === null || (index > 0 && point.timestamp - item.points[index - 1].timestamp > threshold)));
}
function MetricNotice({ children, tone = 'unknown' }: { children: ReactNode; tone?: 'unknown' | 'warning' }) { return <p className={'metric-notice metric-notice--' + tone} role="status">{children}</p>; }
function FreshnessPill({ freshness }: { freshness: LiveFreshness }) { return <span className={'metric-freshness metric-freshness--' + freshness}>{freshnessLabel(freshness)}</span>; }
function Sparkline({ points, label }: { points: BufferedPoint[]; label: string }) {
const values = points.filter((point): point is BufferedPoint & { value: number } => point.value !== null && Number.isFinite(point.value));
if (values.length < 2) return null;
const min = Math.min(...values.map((point) => point.value));
const max = Math.max(...values.map((point) => point.value));
const spread = max - min || 1;
const path = values.map((point, index) => `${index === 0 ? 'M' : 'L'} ${(index / (values.length - 1)) * 100} ${100 - ((point.value - min) / spread) * 100}`).join(' ');
return <svg className="metric-sparkline" viewBox="0 0 100 100" role="img" aria-label={label} preserveAspectRatio="none"><path d={path} vectorEffect="non-scaling-stroke" /></svg>;
}
function sourceAgeText(iso?: string): string {
if (!iso) return '';
const timestamp = Date.parse(iso);
if (!Number.isFinite(timestamp)) return '';
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (seconds < 60) return copy.metrics.sourceAge + ': ' + seconds + ' ' + copy.metrics.secondsAgo;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return copy.metrics.sourceAge + ': ' + minutes + ' ' + copy.metrics.minutesAgo;
return copy.metrics.sourceAge + ': ' + Math.floor(minutes / 60) + ' ' + copy.metrics.hoursAgo;
}
function SourceMetadata({ freshness, sourceObservedAt }: { freshness: LiveFreshness; sourceObservedAt?: string }) {
const age = sourceAgeText(sourceObservedAt);
return <div className="metric-value-meta"><FreshnessPill freshness={freshness} />{age && <time dateTime={sourceObservedAt}>{age}</time>}</div>;
}
function safeInspectorQuery(value: string): string {
let safe = value;
['authorization', 'cookie', 'password', 'passwd', 'secret', 'token', 'api_key', 'client_secret'].forEach((key) => {
safe = safe.replace(new RegExp(key + '[^,; ]*', 'gi'), key + '=<redacted>');
});
return safe;
}
function QueryInspectorWidget({ props }: { props: MetricWidgetProps }) {
const inspector = props.inspector;
if (!inspector) return <MetricNotice>{copy.metrics.inspectorForbidden}</MetricNotice>;
return <section className="metric-inspector" aria-label={copy.metrics.inspectorTitle}><h4>{copy.metrics.inspectorTitle}</h4><dl><div><dt>{copy.metrics.semanticMetric}</dt><dd>{inspector.semanticMetric}</dd></div><div><dt>{copy.metrics.estimatedSamples}</dt><dd>{inspector.cost.estimatedSamples.toLocaleString('nl-BE')}</dd></div><div><dt>{copy.metrics.seriesLimit}</dt><dd>{inspector.cost.series} / {inspector.limits.maxSeries}</dd></div><div><dt>{copy.metrics.pointLimit}</dt><dd>{inspector.cost.points} / {inspector.limits.maxPoints}</dd></div></dl><p className="metric-inspector-label">{copy.metrics.generatedQuery}</p><pre className="metric-inspector-query"><code>{safeInspectorQuery(inspector.generatedQuery)}</code></pre></section>;
}
function StatWidget({ props, series }: { props: MetricWidgetProps; series: LimitedSeries[] }) {
const point = latestPoint(series);
const freshness = freshnessFor(props, point);
if (!point || point.value === null || props.availability === 'error' || props.availability === 'idle') return <MetricNotice>{availabilityText(props)}</MetricNotice>;
const visualization = props.visualization ?? {};
const primary = series[0];
return <div className="metric-stat" data-freshness={freshness}><div className="metric-value" title={new Date(point.timestamp).toISOString()}>{formatValue(point.value, visualization)}</div><SourceMetadata freshness={freshness} sourceObservedAt={props.sourceObservedAt} /><div className="metric-value-meta"><time dateTime={new Date(point.timestamp).toISOString()}>{new Date(point.timestamp).toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit' })}</time></div>{booleanSetting(visualization.showSparkline, false) && primary && <Sparkline points={primary.points} label={copy.metrics.trend} />}</div>;
}
function GaugeWidget({ props, series }: { props: MetricWidgetProps; series: LimitedSeries[] }) {
const point = latestPoint(series);
const visualization = props.visualization ?? {};
const freshness = freshnessFor(props, point);
if (!point || point.value === null || props.availability === 'error' || props.availability === 'idle') return <MetricNotice>{availabilityText(props)}</MetricNotice>;
const values = validPoints(series).map((item) => item.value);
const configuredMin = numberSetting(visualization.min);
const configuredMax = numberSetting(visualization.max);
const min = configuredMin ?? Math.min(0, ...values);
const max = configuredMax ?? Math.max(min + 1, ...values);
const ratio = Math.min(1, Math.max(0, (point.value - min) / (max - min || 1)));
const style = { '--gauge-ratio': `${ratio * 100}%` } as CSSProperties;
return <div className="metric-gauge" data-freshness={freshness}><div className="metric-gauge-visual" style={style} aria-hidden="true"><meter min={min} max={max} value={Math.min(max, Math.max(min, point.value))} /></div><div className="metric-gauge-value">{formatValue(point.value, visualization)}</div><div className="metric-gauge-range"><span>{formatValue(min, visualization)}</span><span>{formatValue(max, visualization)}</span></div><SourceMetadata freshness={freshness} sourceObservedAt={props.sourceObservedAt} /><div className="metric-value-meta"><span>{configuredMin !== undefined && configuredMax !== undefined ? copy.metrics.fixedRange : copy.metrics.derivedRange}</span></div></div>;
}
function chartPath(points: readonly BufferedPoint[], minTime: number, timeSpread: number, minValue: number, valueSpread: number, gapThreshold: number): string {
let path = '';
points.forEach((point, index) => {
if (point.value === null || !Number.isFinite(point.value)) return;
const x = 28 + ((point.timestamp - minTime) / timeSpread) * 600;
const y = 192 - ((point.value - minValue) / valueSpread) * 180;
const previous = points[index - 1];
const command = !previous || previous.value === null || point.timestamp - previous.timestamp > gapThreshold ? 'M' : 'L';
path += `${command} ${Math.min(628, Math.max(28, x)).toFixed(3)} ${Math.min(192, Math.max(12, y)).toFixed(3)} `;
});
return path.trim();
}
function exportCsv(series: readonly LimitedSeries[], visualization: Record<string, unknown>, metricName?: string): void {
if (typeof document === 'undefined' || typeof URL === 'undefined') return;
const rows = [['series', 'timestamp', 'value', 'freshness']];
series.forEach((item) => item.points.forEach((point) => rows.push([item.key, new Date(point.timestamp).toISOString(), point.value === null ? '' : String(point.value), point.freshness])));
const csv = rows.map((row) => row.map((cell) => '"' + cell.replaceAll('"', '""') + '"').join(',')).join('\n');
const link = document.createElement('a');
const objectURL = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
link.href = objectURL;
link.download = (metricName || 'metric').replace(/[^a-z0-9_-]+/gi, '-') + '.csv';
link.click();
window.setTimeout(() => URL.revokeObjectURL(objectURL), 0);
void visualization;
}
function TimeSeriesWidget({ props, series }: { props: MetricWidgetProps; series: LimitedSeries[] }) {
const chartId = useId();
const chartTitleId = chartId + '-title';
const chartDescriptionId = chartId + '-description';
const visualization = props.visualization ?? {};
const point = latestPoint(series);
const freshness = freshnessFor(props, point);
if (props.availability === 'error' || props.availability === 'idle' || series.length === 0 || validPoints(series).length === 0) return <MetricNotice>{availabilityText(props)}</MetricNotice>;
const points = validPoints(series);
const configuredMin = numberSetting(visualization.min);
const configuredMax = numberSetting(visualization.max);
const minValue = configuredMin ?? Math.min(...points.map((item) => item.value));
const maxValue = configuredMax ?? Math.max(...points.map((item) => item.value));
const valueSpread = maxValue - minValue || 1;
const minTime = Math.min(...series.flatMap((item) => item.points.map((pointItem) => pointItem.timestamp)));
const maxTime = Math.max(...series.flatMap((item) => item.points.map((pointItem) => pointItem.timestamp)));
const timeSpread = maxTime - minTime || 1;
const gap = hasDataGap(series, props.expectedStepSeconds);
const metricLabel = props.metricName ? presentMetric(props.metricName) : copy.metrics.metric;
const summaryText = `${series.length} reeks, ${points.length} meetpunten, bereik ${formatValue(minValue, visualization)} tot ${formatValue(maxValue, visualization)}.`;
return <div className="metric-timeseries"><div className="metric-chart-toolbar"><div className="metric-toolbar-status"><FreshnessPill freshness={freshness} />{gap && <span className="metric-gap-label">{copy.metrics.dataGap}</span>}</div><button className="button button--secondary metric-export" type="button" onClick={() => exportCsv(series, visualization, props.metricName)}>{copy.metrics.exportCsv}</button></div><figure className="metric-chart-figure"><svg className="metric-chart" viewBox="0 0 640 220" role="img" aria-labelledby={`${chartTitleId} ${chartDescriptionId}`}><title id={chartTitleId}>{copy.metrics.chartTitle} {metricLabel}</title><desc id={chartDescriptionId}>{summaryText}{gap ? ' ' + copy.metrics.gapDescription : ''}</desc><line className="metric-chart-axis" x1="28" y1="192" x2="628" y2="192" /><line className="metric-chart-axis" x1="28" y1="12" x2="28" y2="192" />{series.map((item, index) => { const label = seriesDisplayName(item, props.metricName); return <path key={item.key} className={'metric-chart-line metric-chart-line--' + (index % 6)} d={chartPath(item.points, minTime, timeSpread, minValue, valueSpread, Math.max(1, props.expectedStepSeconds) * 2000)} aria-label={label}><title>{label}</title></path>; })}</svg><figcaption className="metric-chart-caption">{summaryText}</figcaption></figure>{booleanSetting(visualization.legend, true) && <ul className="metric-legend" aria-label={copy.metrics.legend}>{series.map((item, index) => <li key={item.key}><span className={'metric-legend-swatch metric-legend-swatch--' + (index % 6)} aria-hidden="true" />{seriesDisplayName(item, props.metricName)}</li>)}</ul>}<div className="metric-summary"><span>{copy.metrics.minimum}: {formatValue(Math.min(...points.map((item) => item.value)), visualization)}</span><span>{copy.metrics.maximum}: {formatValue(Math.max(...points.map((item) => item.value)), visualization)}</span><span>{copy.metrics.average}: {formatValue(points.reduce((sum, item) => sum + item.value, 0) / points.length, visualization)}</span></div><details className="metric-accessible-summary"><summary>{copy.metrics.summary}</summary><table><thead><tr><th scope="col">{copy.metrics.series}</th><th scope="col">{copy.metrics.latest}</th><th scope="col">{copy.metrics.count}</th></tr></thead><tbody>{series.map((item) => { const latest = latestPoint([item]); const itemSummary = seriesSummary(item); return <tr key={item.key}><th scope="row">{seriesDisplayName(item, props.metricName)}</th><td>{ !latest || latest.value === null ? copy.dashboards.unknown : formatValue(latest.value, visualization)}</td><td>{itemSummary?.count ?? 0}</td></tr>; })}</tbody></table></details></div>;
}
export function metricStatus(props: MetricWidgetProps): { label: string; tone: 'unknown' | 'ready' } {
if (props.availability === 'loading' || props.availability === 'connecting') return { label: copy.metrics.connecting, tone: 'unknown' };
if (props.availability === 'error' || props.availability === 'idle' || props.series.length === 0) return { label: copy.dashboards.unknown, tone: 'unknown' };
const freshness = freshnessFor(props, latestPoint(limitMetricSeries(props.series)));
return { label: freshnessLabel(freshness), tone: freshness === 'fresh' ? 'ready' : 'unknown' };
}
export function MetricWidget(props: MetricWidgetProps) {
const reduced = reducedMetricLimits();
const series = reduced ? limitMetricSeries(props.series, reduced.maxSeries, reduced.maxPoints) : limitMetricSeries(props.series, 20, 4000);
const gap = hasDataGap(series, props.expectedStepSeconds);
const content = props.kind === 'stat' ? <StatWidget props={props} series={series} /> : props.kind === 'gauge' ? <GaugeWidget props={props} series={series} /> : props.kind === 'query-inspector' ? <QueryInspectorWidget props={props} /> : <TimeSeriesWidget props={props} series={series} />;
return <div className="metric-widget" data-widget-kind={props.kind}>{props.warnings && props.warnings.slice(0, 10).map((warning) => <p className="metric-notice metric-notice--warning" role="status" key={warning}>{copy.metrics.sourceWarning}: {warning.slice(0, 240)}</p>)}{content}{gap && props.kind !== 'timeseries' && <MetricNotice tone="warning">{copy.metrics.gapNotice}</MetricNotice>}{(props.freshness === 'stale' || props.freshness === 'unavailable') && <MetricNotice tone="warning">{copy.metrics.sourceStatus}: {freshnessLabel(props.freshness)}. {copy.metrics.notHealthy}</MetricNotice>}</div>;
}
export type RankedListItem = { id: string; label: string; value: string; detail?: string };
export type StatusGridItem = { id: string; label: string; status: string; reason?: string };
export function RankedListWidget({ items, onSelect }: { items: RankedListItem[]; onSelect: (id: string) => void }) {
return <ol className="ranked-list" aria-label={copy.widgets.rankedList}>{items.slice(0, 100).map((item, index) => <li key={item.id}><button type="button" className="ranked-list-item" onClick={() => onSelect(item.id)}><span className="ranked-list-rank">{index + 1}</span><span><strong>{item.label}</strong><small>{item.detail || item.id}</small></span><span className="ranked-list-value">{item.value}</span></button></li>)}</ol>;
}
export function StatusGridWidget({ items, onSelect }: { items: StatusGridItem[]; onSelect: (id: string) => void }) {
return <ul className="status-grid-widget" aria-label={copy.widgets.statusGrid}>{items.slice(0, 100).map((item) => <li key={item.id}><button type="button" className="status-grid-item" onClick={() => onSelect(item.id)}><span className={'status-dot status-dot--' + item.status} aria-hidden="true" /><span><strong>{item.label}</strong><small>{item.reason || presentStatus(item.status)}</small></span></button></li>)}</ul>;
}
+35
View File
@@ -0,0 +1,35 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentReason, presentStatus } from './presentation';
export type NetworkData = {
contractVersion: string;
observedAt: string;
source: { id: string; freshness: string; observedAt: string; state: string; reason?: string };
health: Array<{ scope: string; state: string; capabilityState?: string; configurationState?: string; reason?: string; sourceId?: string; freshness: string; observedAt: string; latencyMs?: number }>;
interfaces: Array<{ name: string; state: string; rxBytes: number; txBytes: number; rxErrors: number; txErrors: number; rxDrops: number; txDrops: number }>;
certificates: Array<{ id: string; serviceId: string; observedAt: string; expiresAt?: string; issuer?: string; subject?: string; hostnameValid?: boolean; verificationState: string }>;
events: Array<{ id: string; scope: string; state: string; reason?: string; occurredAt: string }>;
};
function stateLabel(state: string): string { if (state === 'up') return copy.network.up; if (state === 'degraded') return copy.network.degraded; if (state === 'down') return copy.network.down; return copy.network.unknown; }
function scopeLabel(scope: string): string { if (scope === 'internal') return copy.network.internal; if (scope === 'gateway') return copy.network.gateway; if (scope === 'dns') return copy.network.dns; return copy.network.internet; }
function reasonLabel(reason?: string): string { if (reason === 'not_configured') return copy.network.notConfiguredDetail; if (reason === 'stale_probe' || reason === 'source_stale') return copy.network.staleDetail; if (reason === 'source_unavailable') return copy.network.unavailableDetail; if (reason === 'unsupported') return copy.network.unsupportedDetail; return reason ? presentReason(reason) : copy.network.noReason; }
function when(value?: string): string { return value ? formatDateTime(value) : copy.network.notAvailable; }
function bytes(value: number): string { if (!Number.isFinite(value) || value < 0) return copy.network.notAvailable; 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 NetworkState({ state, capabilityState, configurationState }: { state: string; capabilityState?: string; configurationState?: string }) { const label = configurationState === 'not_configured' ? copy.network.notConfigured : capabilityState === 'unsupported' ? copy.network.unsupported : capabilityState === 'unavailable' ? copy.network.unavailable : stateLabel(state); return <span className={'network-state network-state--' + state}><span aria-hidden="true">{state === 'up' ? '✓' : state === 'unknown' ? '?' : '!'}</span>{label}</span>; }
export function NetworkHealthWidget({ snapshot, compact = false }: { snapshot: NetworkData; compact?: boolean }) {
const health = [...snapshot.health].sort((left, right) => left.scope.localeCompare(right.scope));
return <section className={'network-health-widget' + (compact ? ' network-health-widget--compact' : '')} aria-labelledby={compact ? undefined : 'network-health-title'}>{!compact && <div className="card-heading"><div><p className="card-kicker">{copy.network.widgetKicker}</p><h2 id="network-health-title">{copy.network.widgetTitle}</h2></div><span className="network-source">{snapshot.source.id} · {presentStatus(snapshot.source.freshness)}</span></div>}<div className="network-health-grid">{health.map((item) => <article className="network-health-item" key={item.scope}><div className="card-heading"><h3>{scopeLabel(item.scope)}</h3><NetworkState state={item.state} capabilityState={item.capabilityState} configurationState={item.configurationState} /></div><p>{reasonLabel(item.reason)}</p>{item.latencyMs != null && <small>{item.latencyMs} ms</small>}<small>{presentStatus(item.freshness)} · {when(item.observedAt)}</small></article>)}</div>{!compact && <p className="network-disclaimer">{copy.network.separateSignals}</p>}</section>;
}
export function NetworkPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [snapshot, setSnapshot] = useState<NetworkData | null>(null);
useEffect(() => { const controller = new AbortController(); fetch('/api/v1/network', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('network'); return response.json() as Promise<NetworkData>; }).then((data) => { setSnapshot({ ...data, health: data.health ?? [], interfaces: data.interfaces ?? [], certificates: data.certificates ?? [], events: data.events ?? [] }); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.network.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.network.errorTitle}</h1><p>{copy.network.errorDetail}</p><button className="button" type="button" onClick={() => window.location.reload()}>{copy.network.retry}</button></section>;
return <><header className="page-intro"><p className="eyebrow">{copy.network.eyebrow}</p><h1>{copy.network.title}</h1><p className="intro">{copy.network.intro}</p></header><NetworkHealthWidget snapshot={snapshot} /><section className="network-detail-grid"><article className="card network-panel" aria-labelledby="network-interface-title"><div className="card-heading"><div><p className="card-kicker">{copy.network.interfaces}</p><h2 id="network-interface-title">{snapshot.interfaces.length}</h2></div></div>{snapshot.interfaces.length === 0 ? <p className="card-copy">{copy.network.noInterfaces}</p> : <div className="network-table-wrap"><table className="network-table"><caption>{copy.network.interfaceCaption}</caption><thead><tr><th>{copy.network.interface}</th><th>RX / TX</th><th>{copy.network.errors}</th><th>{copy.network.drops}</th></tr></thead><tbody>{snapshot.interfaces.map((item) => <tr key={item.name}><th scope="row">{item.name}<small><NetworkState state={item.state} /></small></th><td>{bytes(item.rxBytes)} / {bytes(item.txBytes)}</td><td>{item.rxErrors} / {item.txErrors}</td><td>{item.rxDrops} / {item.txDrops}</td></tr>)}</tbody></table></div>}</article><article className="card network-panel" aria-labelledby="network-certificate-title"><div className="card-heading"><div><p className="card-kicker">{copy.network.certificates}</p><h2 id="network-certificate-title">{snapshot.certificates.length}</h2></div></div>{snapshot.certificates.length === 0 ? <p className="card-copy">{copy.network.noCertificates}</p> : <ul className="network-certificate-list">{snapshot.certificates.map((certificate) => <li key={certificate.id}><strong>{certificate.serviceId}</strong><span>{presentStatus(certificate.verificationState)} · {when(certificate.expiresAt)}</span><small>{certificate.issuer || copy.network.notAvailable} · {certificate.hostnameValid === false ? copy.network.hostnameInvalid : copy.network.hostnameValid}</small></li>)}</ul>}</article></section><section className="card network-panel" aria-labelledby="network-events-title"><div className="card-heading"><div><p className="card-kicker">{copy.network.events}</p><h2 id="network-events-title">{snapshot.events.length}</h2></div></div>{snapshot.events.length === 0 ? <p className="card-copy">{copy.network.noEvents}</p> : <ul className="network-event-list">{snapshot.events.map((event) => <li key={event.id}><strong>{scopeLabel(event.scope)} · {stateLabel(event.state)}</strong><span>{reasonLabel(event.reason)} · {when(event.occurredAt)}</span></li>)}</ul>}</section></>;
}
+11
View File
@@ -0,0 +1,11 @@
import { copy } from './copy';
export function NotFoundPage() {
return <section className="state-page not-found" role="status">
<span className="state-icon" aria-hidden="true">404</span>
<p className="eyebrow">{copy.notFound.eyebrow}</p>
<h1>{copy.notFound.title}</h1>
<p>{copy.notFound.detail}</p>
<div className="state-page-actions"><a className="button" href="/">{copy.notFound.home}</a><a className="button button--secondary" href="/inventory">{copy.notFound.inventory}</a></div>
</section>;
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { componentStatus, useSystemStatus } from './systemStatus';
type Capability = { id: string; state: string; detail: string };
type OnboardingStatus = { state: { completed: boolean; step: string; dashboardChoice?: string; rulesChoice?: string; dashboardId?: string; rulesReady: boolean }; capabilities: Capability[]; resume: boolean };
function capabilityLabel(state: string): string {
if (state === 'ready' || state === 'configured' || state === 'development') return copy.onboarding.ready;
if (state === 'incomplete' || state === 'not-ready') return copy.onboarding.action;
return copy.onboarding.unknown;
}
export function OnboardingPage() {
const runtime = useSystemStatus();
const [status, setStatus] = useState<OnboardingStatus | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [dashboard, setDashboard] = useState('default');
const [rules, setRules] = useState('default');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const [reconfigure, setReconfigure] = useState(false);
const load = (signal?: AbortSignal) => {
setState('loading');
fetch('/api/v1/onboarding', { signal }).then((response) => { if (!response.ok) throw new Error('onboarding'); return response.json() as Promise<OnboardingStatus>; }).then((data) => {
setStatus(data); setDashboard(data.state.dashboardChoice || 'default'); setRules(data.state.rulesChoice || 'default'); setState('ready');
}).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); });
};
useEffect(() => { const controller = new AbortController(); load(controller.signal); return () => controller.abort(); }, []);
const complete = async () => {
if (status?.state.completed && !window.confirm(copy.onboarding.confirmReconfigure)) return;
setSaving(true); setMessage('');
try {
const response = await fetch('/api/v1/onboarding', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dashboard, rules }) });
if (!response.ok) throw new Error(response.status === 403 ? 'admin' : 'save');
setStatus(await response.json() as OnboardingStatus);
setReconfigure(false);
setMessage(copy.onboarding.saved);
} catch (error) { setMessage(error instanceof Error && error.message === 'admin' ? copy.onboarding.adminRequired : copy.onboarding.saveError); } finally { setSaving(false); }
};
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.onboarding.loading}</h1></section>;
if (state === 'error' || !status) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.onboarding.errorTitle}</h1><p>{copy.onboarding.errorDetail}</p><button className="button" type="button" onClick={() => load()}>{copy.onboarding.retry}</button></section>;
const capabilities = status.capabilities.map((capability) => {
if (capability.id !== 'prometheus' && capability.id !== 'unraid') return capability;
const observed = componentStatus(runtime.status, capability.id);
if (!observed || observed.state !== 'healthy') return capability;
return { ...capability, state: 'ready', detail: copy.onboarding.runtimeReady };
});
return <><header className="page-intro"><p className="eyebrow">{copy.onboarding.eyebrow}</p><h1>{copy.onboarding.title}</h1><p className="intro">{status.state.completed ? copy.onboarding.completedIntro : status.resume ? copy.onboarding.resumeIntro : copy.onboarding.intro}</p></header><section className="onboarding-grid" aria-label={copy.onboarding.capabilities}>
<article className="card onboarding-card"><div className="card-heading"><div><p className="card-kicker">{copy.onboarding.capabilities}</p><h2>{copy.onboarding.readiness}</h2></div><span className="status-badge status-badge--unknown"><span className="status-icon" aria-hidden="true">?</span>{status.state.completed ? copy.onboarding.completed : copy.onboarding.inProgress}</span></div><ul className="onboarding-capabilities">{capabilities.map((capability) => <li key={capability.id}><span><strong>{copy.onboarding.capabilityNames[capability.id as keyof typeof copy.onboarding.capabilityNames] ?? capability.id}</strong><small>{capability.detail}</small></span><span className={'onboarding-state onboarding-state--' + capability.state}>{capabilityLabel(capability.state)}</span></li>)}</ul></article>
{status.state.completed && !reconfigure ? <article className="card onboarding-card onboarding-complete"><p className="card-kicker">{copy.onboarding.completedSummary}</p><h2>{copy.onboarding.configurationActive}</h2><p className="card-copy">{copy.onboarding.configurationActiveDetail}</p><dl className="onboarding-summary"><div><dt>{copy.onboarding.dashboardChoice}</dt><dd>{dashboard === 'default' ? copy.onboarding.dashboardInstalled : copy.onboarding.skipped}</dd></div><div><dt>{copy.onboarding.rulesChoice}</dt><dd>{rules === 'default' ? copy.onboarding.rulesInstalled : copy.onboarding.skipped}</dd></div></dl><p className="onboarding-safe-note">{copy.onboarding.reconfigureNote}</p><button className="button button--secondary" type="button" onClick={() => { setMessage(''); setReconfigure(true); }}>{copy.onboarding.reconfigure}</button>{message && <p className="form-message" role="status">{message}</p>}</article> : <article className="card onboarding-card"><p className="card-kicker">{status.state.completed ? copy.onboarding.reconfigure : copy.onboarding.choices}</p><h2>{copy.onboarding.defaultsTitle}</h2><p className="card-copy">{copy.onboarding.defaultsIntro}</p><fieldset className="onboarding-choice"><legend>{copy.onboarding.dashboardChoice}</legend><label><input type="radio" name="dashboard" value="default" checked={dashboard === 'default'} onChange={() => setDashboard('default')} /> {copy.onboarding.installDefault}</label><label><input type="radio" name="dashboard" value="skip" checked={dashboard === 'skip'} onChange={() => setDashboard('skip')} /> {copy.onboarding.skip}</label></fieldset><fieldset className="onboarding-choice"><legend>{copy.onboarding.rulesChoice}</legend><label><input type="radio" name="rules" value="default" checked={rules === 'default'} onChange={() => setRules('default')} /> {copy.onboarding.keepDefaults}</label><label><input type="radio" name="rules" value="skip" checked={rules === 'skip'} onChange={() => setRules('skip')} /> {copy.onboarding.skip}</label></fieldset><p className="onboarding-safe-note">{copy.onboarding.safeNote}</p><div className="detail-actions"><button className="button" type="button" disabled={saving} onClick={() => void complete()}>{saving ? copy.onboarding.saving : status.state.completed ? copy.onboarding.saveReconfiguration : copy.onboarding.complete}</button>{status.state.completed && <button className="button button--secondary" type="button" disabled={saving} onClick={() => setReconfigure(false)}>{copy.onboarding.cancel}</button>}</div>{message && <p className="form-message" role="status">{message}</p>}</article>}
</section></>;
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
import { signalToneRank, type SignalTone } from './overviewSignals';
export type OperationalSignalStage = {
id: string;
label: string;
icon: string;
tone: SignalTone;
statusLabel: string;
primaryLabel: string;
primaryValue: string;
secondaryLabel: string;
secondaryValue: string;
detail: string;
route: string;
};
const toneIcon: Record<SignalTone, string> = {
healthy: '✓',
attention: '!',
critical: '×',
stale: '◷',
unknown: '?',
};
function initialStage(stages: OperationalSignalStage[]): string {
return [...stages].sort((left, right) => signalToneRank[left.tone] - signalToneRank[right.tone])[0]?.id ?? '';
}
export function OperationalSignalPath({ stages, onNavigate }: { stages: OperationalSignalStage[]; onNavigate: (route: string) => void }) {
const preferredStage = useMemo(() => initialStage(stages), [stages]);
const [selectedID, setSelectedID] = useState(preferredStage);
const [userSelected, setUserSelected] = useState(false);
useEffect(() => {
if (!stages.some((stage) => stage.id === selectedID)) {
setSelectedID(preferredStage);
setUserSelected(false);
return;
}
if (!userSelected && selectedID !== preferredStage) setSelectedID(preferredStage);
}, [preferredStage, selectedID, stages, userSelected]);
const selected = stages.find((stage) => stage.id === selectedID) ?? stages[0];
const overall = [...stages].sort((left, right) => signalToneRank[left.tone] - signalToneRank[right.tone])[0];
return <article className="card data-plane signal-path-panel" aria-labelledby="signal-path-title">
<div className="card-heading signal-path-heading">
<div><p className="card-kicker">{copy.overview.signalPathKicker}</p><h2 id="signal-path-title">{copy.overview.signalPathTitle}</h2></div>
{overall && <span className={'signal-path-overall signal-tone--' + overall.tone}><span aria-hidden="true">{toneIcon[overall.tone]}</span>{overall.statusLabel}</span>}
</div>
<p className="signal-path-intro">{copy.overview.signalPathIntro}</p>
<ol className="signal-path" aria-label={copy.overview.signalPathStages}>
{stages.map((stage, index) => <li className={'signal-path-stage signal-path-stage--' + stage.tone + (selected?.id === stage.id ? ' signal-path-stage--selected' : '')} data-tone={stage.tone} key={stage.id}>
<button type="button" aria-pressed={selected?.id === stage.id} aria-label={`${stage.label}: ${stage.statusLabel}. ${stage.primaryLabel}: ${stage.primaryValue}. ${stage.secondaryLabel}: ${stage.secondaryValue}.`} onClick={() => { setSelectedID(stage.id); setUserSelected(true); }}>
<span className="signal-stage-index" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
<span className="signal-stage-icon" aria-hidden="true">{stage.icon}</span>
<span className="signal-stage-copy"><strong>{stage.label}</strong><small><span aria-hidden="true">{toneIcon[stage.tone]}</span>{stage.statusLabel}</small></span>
<span className="signal-stage-metric"><small>{stage.primaryLabel}</small><strong>{stage.primaryValue}</strong></span>
</button>
</li>)}
</ol>
{selected && <section className={'signal-path-inspector signal-path-inspector--' + selected.tone} aria-labelledby="signal-path-selection" aria-live="polite">
<div className="signal-inspector-state"><span className="signal-inspector-icon" aria-hidden="true">{selected.icon}</span><span><small>{copy.overview.signalPathSelected}</small><strong id="signal-path-selection">{selected.label}</strong></span></div>
<dl>
<div><dt>{selected.primaryLabel}</dt><dd>{selected.primaryValue}</dd></div>
<div><dt>{selected.secondaryLabel}</dt><dd>{selected.secondaryValue}</dd></div>
<div><dt>{copy.overview.signalPathState}</dt><dd><span className={'signal-state-text signal-tone--' + selected.tone}><span aria-hidden="true">{toneIcon[selected.tone]}</span>{selected.statusLabel}</span></dd></div>
</dl>
<p>{selected.detail}</p>
<button className="button button--secondary signal-path-open" type="button" onClick={() => onNavigate(selected.route)}>{copy.overview.signalPathOpen} {selected.label.toLowerCase()}</button>
</section>}
<p className="signal-path-disclaimer">{copy.overview.signalPathDisclaimer}</p>
</article>;
}
+21
View File
@@ -0,0 +1,21 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { operationalStorageState, presentArrayRole, presentStatus } from './presentation';
import { SourceStatusDetails } from './SourceStatusDetails';
type Capability = 'available' | 'unsupported' | 'unavailable';
type Member = { id: string; name: string; role: string; state: string; capacityBytes: number; errors: number };
type Scrub = { id: string; state: string; progressPercent: number; errors: number; bytesChecked: number; startedAt?: string; completedAt?: string; result?: string };
type Pool = { id: string; name: string; filesystem: string; state: string; usableBytes: number; usedBytes: number; freeBytes: number; utilizationPercent: number; capacitySeverity?: string; profile?: string; redundancy?: string; capabilities: { members: Capability; capacity: Capability; redundancy: Capability; scrub: Capability; filesystemErrors: Capability; performance: Capability; ssdWear: Capability; moverSignals: Capability }; members?: Member[]; errors?: Array<{ id: string; kind: string; message: string; count: number; observedAt?: string }>; scrub?: Scrub; scrubHistory?: Scrub[] };
type Snapshot = { source: { id: string; state: string; freshness: string; observedAt?: string; reason?: string }; pools: Pool[]; total: number };
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 date(value?: string): string { return formatDateTime(value); }
function Badge({ label, ready }: { label: string; ready: boolean }) { return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{label}</span>; }
function stateLabel(state: string): string { if (state === 'healthy') return copy.pools.healthy; if (state === 'degraded') return copy.pools.degraded; if (state === 'faulted') return copy.pools.faulted; return copy.pools.unknown; }
function capabilityLabel(value: Capability): string { return value === 'available' ? copy.pools.available : value === 'unsupported' ? copy.pools.unsupported : copy.pools.unknown; }
function severityLabel(value?: string): string { return value === 'normal' ? 'Normaal' : value === 'attention' ? 'Aandacht' : value === 'critical' ? 'Kritiek' : 'Onbekend'; }
function PoolCard({ pool }: { pool: Pool }) { const operational = operationalStorageState(pool.state, pool.capacitySeverity); return <article className="card"><div className="card-heading"><div><p className="card-kicker">{pool.filesystem}</p><h2><a className="entity-link" href={'/pools/' + encodeURIComponent(pool.id)}>{pool.name}</a></h2></div><Badge label={presentStatus(operational)} ready={operational === 'healthy' || operational === 'normal'} /></div><p className="card-copy">{bytes(pool.usedBytes)} {copy.pools.usedOf} {bytes(pool.usableBytes)} · {pool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% {copy.pools.used}</p><p className="card-copy">Device-health: {stateLabel(pool.state)} · capaciteit: {severityLabel(pool.capacitySeverity)}</p><p className="card-copy">{pool.profile || copy.pools.noProfile} · {pool.redundancy || copy.pools.noRedundancy}</p></article>; }
function PoolDetail({ pool, source }: { pool: Pool; source: Snapshot['source'] }) { const members = pool.members ?? []; const errors = pool.errors ?? []; const operational = operationalStorageState(pool.state, pool.capacitySeverity); return <><header className="page-intro"><p className="eyebrow">{copy.pools.detailEyebrow}</p><h1>{pool.name}</h1><p className="intro">{copy.pools.detailIntro}</p><div className="detail-actions"><a className="button button--secondary" href="/pools">{copy.pools.back}</a></div></header><section className="card container-summary" aria-labelledby="pool-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.pools.source}</p><h2 id="pool-summary-title">{source.id}</h2></div><Badge label={presentStatus(operational)} ready={operational === 'healthy' || operational === 'normal'} /></div><SourceStatusDetails source={source} fallbackReason={source.freshness === 'fresh' ? copy.pools.fresh : copy.pools.stale} /><p className="container-provenance">{pool.filesystem}</p></section><section className="card-grid" aria-label={copy.pools.metrics}><article className="card"><p className="card-kicker">{copy.pools.capacity}</p><h2>{pool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%</h2><p className="card-copy">{bytes(pool.freeBytes)} {copy.pools.free}</p></article><article className="card"><p className="card-kicker">{copy.pools.profile}</p><h2>{pool.profile || copy.pools.noProfile}</h2><p className="card-copy">{pool.redundancy || copy.pools.noRedundancy}</p></article><article className="card"><p className="card-kicker">{copy.pools.scrub}</p><h2>{pool.scrub ? presentStatus(pool.scrub.state) : capabilityLabel(pool.capabilities.scrub)}</h2><p className="card-copy">{pool.scrub ? pool.scrub.progressPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '% · ' + copy.pools.errors + ': ' + pool.scrub.errors : copy.pools.noScrub}</p></article></section><section className="card" aria-labelledby="pool-capabilities-title"><div className="card-heading"><div><p className="card-kicker">{copy.pools.capabilities}</p><h2 id="pool-capabilities-title">{copy.pools.capabilityTitle}</h2></div></div><p className="card-copy">{copy.pools.members}: {capabilityLabel(pool.capabilities.members)} · {copy.pools.redundancy}: {capabilityLabel(pool.capabilities.redundancy)} · {copy.pools.filesystemErrors}: {capabilityLabel(pool.capabilities.filesystemErrors)} · {copy.pools.performance}: {capabilityLabel(pool.capabilities.performance)}</p><p className="card-copy">{copy.pools.ssdWear}: {capabilityLabel(pool.capabilities.ssdWear)} · {copy.pools.moverSignals}: {capabilityLabel(pool.capabilities.moverSignals)}</p></section><section className="card" aria-labelledby="pool-members-title"><div className="card-heading"><div><p className="card-kicker">{copy.pools.members}</p><h2 id="pool-members-title">{copy.pools.memberTitle}</h2></div></div>{members.length === 0 ? <p className="card-copy">{copy.pools.noMembers}</p> : <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.pools.name}</th><th>{copy.pools.role}</th><th>{copy.pools.state}</th><th>{copy.pools.errors}</th></tr></thead><tbody>{members.map((member) => <tr key={member.id}><th scope="row">{member.name}</th><td>{presentArrayRole(member.role)}</td><td><Badge label={presentStatus(member.state)} ready={member.state === 'online'} /></td><td>{member.errors}</td></tr>)}</tbody></table></div>}</section><section className="card" aria-labelledby="pool-errors-title"><div className="card-heading"><div><p className="card-kicker">{copy.pools.errors}</p><h2 id="pool-errors-title">{copy.pools.poolErrorTitle}</h2></div></div>{errors.length === 0 ? <p className="card-copy">{copy.pools.noErrors}</p> : <ul className="reason-list">{errors.map((error) => <li key={error.id}><strong>{error.kind}</strong>: {error.message} ({error.count})</li>)}</ul>}</section><details className="card technical-details"><summary>{copy.pools.history}</summary>{pool.scrubHistory?.length ? <ul className="inventory-list">{pool.scrubHistory.map((scrub) => <li key={scrub.id}><span><strong>{presentStatus(scrub.state)}</strong><small>{scrub.result || copy.pools.noResult} · {date(scrub.completedAt || scrub.startedAt)}</small></span><Badge label={scrub.errors > 0 ? copy.pools.attention : copy.pools.completed} ready={scrub.errors === 0} /></li>)}</ul> : <p className="card-copy">{copy.pools.noHistory}</p>}</details><p className="card-copy">{copy.pools.readOnly}</p></>; }
export function PoolPage({ id }: { id?: string }) { const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); const [snapshot, setSnapshot] = useState<Snapshot | null>(null); const [selected, setSelected] = useState<Pool | null>(null); useEffect(() => { const controller = new AbortController(); fetch(id ? '/api/v1/pools/' + encodeURIComponent(id) : '/api/v1/pools?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('pools'); return response.json() as Promise<Snapshot & { pool?: Pool }>; }).then((data) => { setSnapshot(data); if (data.pool) setSelected(data.pool); 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.pools.loading}</h1></section>; if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.pools.errorTitle}</h1><p>{copy.pools.errorDetail}</p></section>; if (selected) return <PoolDetail pool={selected} source={snapshot.source} />; const fresh = snapshot.source?.freshness === 'fresh' && snapshot.source?.state !== 'unknown'; return <><header className="page-intro"><p className="eyebrow">{copy.pools.eyebrow}</p><h1>{copy.pools.title}</h1><p className="intro">{copy.pools.intro}</p></header><section className="card container-summary" aria-labelledby="pools-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.pools.source}</p><h2 id="pools-summary-title">{snapshot.source?.id || copy.pools.unknown}</h2></div><Badge label={fresh ? copy.pools.available : copy.pools.unknown} ready={fresh} /></div><SourceStatusDetails source={snapshot.source ?? {}} fallbackReason={fresh ? copy.pools.fresh : copy.pools.stale} /><p className="container-provenance">{snapshot.total} {copy.pools.rows}</p></section><section className="card-grid" aria-label={copy.pools.cards}>{snapshot.pools.length ? snapshot.pools.map((pool) => <PoolCard key={pool.id} pool={pool} />) : <article className="card"><p className="card-copy">{copy.pools.empty}</p></article>}</section></>; }
+35
View File
@@ -0,0 +1,35 @@
import { useDeferredValue, useEffect, useRef, useState } from 'react';
import { copy } from './copy';
import { queryValue, replaceListQuery } from './listQuery';
import { presentReason, presentStatus } from './presentation';
type ProcessItem = { pid: number; name: string; state: string; runtimeSeconds: number; cpuPercent: number; memoryBytes: number; containerId?: string; containerName?: string };
type ProcessSnapshot = { source: { id: string; state: string; reason?: string }; processes: ProcessItem[]; total: number; nextCursor?: string };
function bytes(value: number): string { if (!Number.isFinite(value)) return '—'; const units = ['B', 'KB', 'MB', 'GB']; 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 Badge({ state }: { state: string }) { const ready = state === 'healthy'; return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{ready ? 'Gereed' : 'Onbekend'}</span>; }
export function ProcessPage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [snapshot, setSnapshot] = useState<ProcessSnapshot | null>(null);
const [sort, setSort] = useState<'cpu' | 'memory'>(() => queryValue('sort', ['cpu', 'memory'], 'cpu') as 'cpu' | 'memory');
const [query, setQuery] = useState(() => queryValue('q'));
const [containerFilter, setContainerFilter] = useState(() => queryValue('container'));
const deferredQuery = useDeferredValue(query);
const deferredContainer = useDeferredValue(containerFilter);
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 (deferredContainer.trim()) params.set('container', deferredContainer.trim()); if (cursor) params.set('after', cursor); replaceListQuery({ q: deferredQuery.trim(), container: deferredContainer.trim(), sort: sort === 'cpu' ? '' : sort, after: cursor }); fetch('/api/v1/processes?' + params, { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('processes'); return response.json() as Promise<ProcessSnapshot>; }).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(); }, [sort, deferredQuery, deferredContainer, 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.processes.loading}</h1></section>;
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.processes.errorTitle}</h1><p>{copy.processes.errorDetail}</p><button className="button" type="button" onClick={() => setReload((value) => value + 1)}>{copy.processes.retry}</button></section>;
return <>
<header className="page-intro"><p className="eyebrow">{copy.processes.eyebrow}</p><h1>{copy.processes.title}</h1><p className="intro">{copy.processes.intro}</p></header>
<section className="card process-summary" aria-labelledby="process-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.processes.source}</p><h2 id="process-summary-title">{snapshot.source?.id || copy.processes.unknown}</h2><p className="card-copy">{snapshot.source?.reason ? presentReason(snapshot.source.reason) : copy.processes.privacy}</p></div><Badge state={snapshot.source?.state || 'unknown'} /></div><p className="process-count">{snapshot.total} {copy.processes.rows} · {copy.processes.limitNote}</p></section>
<section className="card process-panel" aria-labelledby="process-list-title"><div className="card-heading"><div><p className="card-kicker">{copy.processes.list}</p><h2 id="process-list-title">{copy.processes.top}</h2></div></div><form className="list-filters" onSubmit={(event) => event.preventDefault()}><label>{copy.processes.search}<input type="search" value={query} placeholder={copy.processes.searchPlaceholder} onChange={(event) => { setQuery(event.target.value); resetPage(); }} /></label><label>{copy.processes.containerFilter}<input value={containerFilter} placeholder={copy.processes.containerPlaceholder} onChange={(event) => { setContainerFilter(event.target.value); resetPage(); }} /></label><label>{copy.processes.sort}<select value={sort} onChange={(event) => { setSort(event.target.value as 'cpu' | 'memory'); resetPage(); }}><option value="cpu">{copy.processes.cpu}</option><option value="memory">{copy.processes.memory}</option></select></label></form>{snapshot.processes.length === 0 ? <p className="card-copy">{copy.processes.empty}</p> : <><div className="host-table-wrap desktop-data-view"><table className="host-table process-table"><thead><tr><th>PID</th><th>{copy.processes.name}</th><th>{copy.processes.cpu}</th><th>{copy.processes.memory}</th><th>{copy.processes.container}</th></tr></thead><tbody>{snapshot.processes.map((item) => <tr key={item.pid}><th scope="row">{item.pid}</th><td>{item.name}<small>{presentStatus(item.state)} · {Math.floor(item.runtimeSeconds / 60)} min</small></td><td>{item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%</td><td>{bytes(item.memoryBytes)}</td><td>{item.containerName || copy.processes.unknown}</td></tr>)}</tbody></table></div><ul className="mobile-data-list" aria-label={copy.processes.mobileList}>{snapshot.processes.map((item) => <li key={item.pid}><strong>{item.name}</strong><span>PID {item.pid} · {presentStatus(item.state)}</span><dl><div><dt>{copy.processes.cpu}</dt><dd>{item.cpuPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%</dd></div><div><dt>{copy.processes.memory}</dt><dd>{bytes(item.memoryBytes)}</dd></div><div><dt>{copy.processes.container}</dt><dd>{item.containerName || copy.processes.unknown}</dd></div></dl></li>)}</ul></>}<nav className="list-pager" aria-label={copy.processes.page}><button className="button button--secondary" type="button" disabled={!cursor} onClick={previous}>{copy.processes.previous}</button><span ref={pageStatus} role="status" tabIndex={-1}>{copy.processes.page} {history.length + 1}</span><button className="button button--secondary" type="button" disabled={!snapshot.nextCursor} onClick={next}>{copy.processes.next}</button></nav></section>
</>;
}
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
import { formatDateTime } from './locale';
type ProbeCertificate = { expiresAt?: string; issuer?: string; subject?: string; hostnameValid?: boolean; verificationState?: string };
type ProbeResult = { id?: string; probeId: string; observedAt: string; completedAt?: string; state: string; responseTimeMs?: number; statusCode?: number; errorClass?: string; errorMessage?: string; certificate?: ProbeCertificate };
type ProbeConfig = { id: string; name: string; type: string; intervalSeconds: number; timeoutSeconds: number; enabled: boolean; followRedirects: boolean; verifyTls: boolean; revision: number };
type ServiceStatus = { id: string; entityId?: string; sourceId?: string; name: string; description?: string; state: string; reason?: string; lastResultAt?: string; lastSuccessAt?: string; lastFailureAt?: string; responseTimeMs?: number; availabilityPercent?: number; sampleCount: number; successfulSampleCount: number; history?: ProbeResult[]; probes?: ProbeConfig[]; certificate?: ProbeCertificate };
type ServiceSnapshot = { contractVersion: string; observedAt: string; capabilityState?: string; configurationState?: string; reason?: string; services: ServiceStatus[]; total: number };
type ServiceDetailResponse = { service: ServiceStatus };
type Dependency = { id: string; serviceId: string; dependsOnServiceId: string; sourceId?: string; relationType: string; confidence: number; confirmed: boolean };
type DependencyResponse = { serviceId: string; dependencies: Dependency[] };
type DependencyLoad = DependencyResponse & { available: boolean };
type ViewState = 'loading' | 'ready' | 'empty' | 'unauthorized' | 'error';
function navigate(path: string) {
window.history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function statusLabel(state: string): string {
switch (state) {
case 'up': return copy.services.up;
case 'degraded': return copy.services.degraded;
case 'down': return copy.services.down;
default: return copy.services.unknown;
}
}
function statusIcon(state: string): string {
switch (state) {
case 'up': return '✓';
case 'down': return '!';
case 'degraded': return '△';
default: return '?';
}
}
function StatusPill({ state }: { state: string }) {
const normalized = ['up', 'degraded', 'down', 'unknown'].includes(state) ? state : 'unknown';
return <span className={'service-status service-status--' + normalized}><span className="status-icon" aria-hidden="true">{statusIcon(normalized)}</span>{statusLabel(normalized)}</span>;
}
function formatDate(value?: string): string {
return formatDateTime(value);
}
function formatLatency(value?: number): string {
return value === undefined || !Number.isFinite(value) ? copy.services.notAvailable : `${Math.max(0, Math.round(value))} ms`;
}
function formatAvailability(value?: number): string {
return value === undefined || !Number.isFinite(value) ? copy.services.notAvailable : `${value.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`;
}
function reasonLabel(reason?: string): string {
switch (reason) {
case 'stale_probe': return copy.services.reasons.stale;
case 'no_probe_result': return copy.services.reasons.noResult;
case 'no_probe_configured': return copy.services.reasons.noProbe;
case 'probes_disabled': return copy.services.reasons.disabled;
case 'no_services_configured': return copy.services.reasons.notConfigured;
case 'source_unavailable': return copy.services.reasons.unavailable;
case 'status_not_expected': return copy.services.reasons.status;
case 'transport_error': return copy.services.reasons.transport;
case 'response_too_large': return copy.services.reasons.bodyLimit;
case 'unsupported': return copy.services.reasons.unsupported;
case 'none': return copy.services.reasons.none;
default: return copy.services.reasons.none;
}
}
function errorLabel(result: ProbeResult): string {
return result.errorClass ? reasonLabel(result.errorClass) : result.state === 'up' ? copy.services.success : copy.services.reasons.none;
}
function latestCertificate(history: ProbeResult[] = []): ProbeCertificate | undefined {
return history.find((result) => result.certificate)?.certificate;
}
function certificateStateLabel(state?: string): string {
switch (state) {
case 'valid': return copy.services.certificate.valid;
case 'attention': return copy.services.certificate.attention;
case 'invalid': return copy.services.certificate.invalid;
default: return copy.services.unknown;
}
}
function daysUntil(value?: string): number | undefined {
if (!value) return undefined;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return undefined;
return Math.ceil((time - Date.now()) / 86400000);
}
function ServiceLoading() { return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.services.loading}</h1></section>; }
function ServiceError({ unauthorized, retry }: { unauthorized: boolean; retry: () => void }) { return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">{unauthorized ? '!' : '×'}</span><h1>{unauthorized ? copy.services.unauthorizedTitle : copy.services.errorTitle}</h1><p>{unauthorized ? copy.services.unauthorizedDetail : copy.services.errorDetail}</p><button className="button" type="button" onClick={retry}>{copy.services.retry}</button></section>; }
function ServiceSummary({ snapshot }: { snapshot: ServiceSnapshot }) {
const state = snapshot.services.length === 0 || snapshot.services.every((item) => item.state === 'unknown') ? 'unknown' : snapshot.services.some((item) => item.state === 'down') ? 'down' : snapshot.services.some((item) => item.state === 'degraded') ? 'degraded' : 'up';
return <section className="card service-source" aria-labelledby="service-source-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.source}</p><h2 id="service-source-title">{snapshot.contractVersion || copy.services.unknown}</h2></div><StatusPill state={state} /></div><p className="card-copy">{snapshot.reason ? reasonLabel(snapshot.reason) : copy.services.sourceDetail}</p><p className="service-provenance">{copy.services.observed}: {formatDate(snapshot.observedAt)} · {snapshot.total} {copy.services.rows}</p></section>;
}
function ServiceMatrix({ services }: { services: ServiceStatus[] }) {
return <section className="card service-panel" aria-labelledby="service-matrix-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.matrix}</p><h2 id="service-matrix-title">{copy.services.matrixTitle}</h2></div><span className="service-count">{services.length} {copy.services.rows}</span></div><div className="service-table-wrap"><table className="service-table"><caption className="sr-only">{copy.services.matrixCaption}</caption><thead><tr><th scope="col">{copy.services.name}</th><th scope="col">{copy.services.state}</th><th scope="col">{copy.services.latency}</th><th scope="col">{copy.services.availability}</th><th scope="col">{copy.services.lastSuccess}</th><th scope="col">{copy.services.reason}</th></tr></thead><tbody>{services.map((item) => <tr key={item.id}><th scope="row"><a className="service-link" href={'/services/' + encodeURIComponent(item.id)} onClick={(event) => { event.preventDefault(); navigate('/services/' + encodeURIComponent(item.id)); }}>{item.name}<small>{item.id}</small></a></th><td><StatusPill state={item.state} /></td><td>{formatLatency(item.responseTimeMs)}</td><td>{formatAvailability(item.availabilityPercent)}</td><td>{formatDate(item.lastSuccessAt)}</td><td>{reasonLabel(item.reason)}</td></tr>)}</tbody></table></div><p className="card-copy service-read-only">{copy.services.readOnly}</p></section>;
}
function ServiceListPage({ snapshot, retry }: { snapshot: ServiceSnapshot; retry: () => void }) {
const unavailable = snapshot.capabilityState === 'unavailable';
return <><header className="page-intro"><p className="eyebrow">{copy.services.eyebrow}</p><h1>{copy.services.title}</h1><p className="intro">{copy.services.intro}</p></header><ServiceSummary snapshot={snapshot} />{snapshot.services.length === 0 ? <section className="card empty-state service-panel" aria-labelledby="service-empty-title"><h2 id="service-empty-title">{unavailable ? copy.services.unavailable : copy.services.empty}</h2><p className="card-copy">{unavailable ? copy.services.unavailableDetail : copy.services.emptyDetail}</p>{unavailable ? <button className="button button--secondary" type="button" onClick={retry}>{copy.services.retry}</button> : <a className="button" href="/onboarding" onClick={(event) => { event.preventDefault(); navigate('/onboarding'); }}>{copy.services.configure}</a>}</section> : <ServiceMatrix services={snapshot.services} />}</>;
}
function CertificateCard({ certificate }: { certificate?: ProbeCertificate }) {
if (!certificate) return <section className="card service-certificate" aria-labelledby="certificate-title"><div className="card-heading"><h2 id="certificate-title">{copy.services.certificate.title}</h2><StatusPill state="unknown" /></div><p className="card-copy">{copy.services.certificate.none}</p></section>;
const days = daysUntil(certificate.expiresAt);
const expiry = days === undefined ? copy.services.notAvailable : days < 0 ? copy.services.certificate.expired : `${days} ${days === 1 ? copy.services.certificate.day : copy.services.certificate.days}`;
return <section className="card service-certificate" aria-labelledby="certificate-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.certificate.kicker}</p><h2 id="certificate-title">{copy.services.certificate.title}</h2></div><span className="service-certificate-state">{certificateStateLabel(certificate.verificationState)}</span></div><dl className="service-details-grid"><div><dt>{copy.services.certificate.expires}</dt><dd>{formatDate(certificate.expiresAt)}<small>{expiry}</small></dd></div><div><dt>{copy.services.certificate.hostname}</dt><dd>{certificate.hostnameValid === true ? copy.services.certificate.valid : copy.services.certificate.invalid}</dd></div><div><dt>{copy.services.certificate.issuer}</dt><dd>{certificate.issuer || copy.services.notAvailable}</dd></div><div><dt>{copy.services.certificate.subject}</dt><dd>{certificate.subject || copy.services.notAvailable}</dd></div></dl></section>;
}
function ProbeConfigCard({ probes }: { probes: ProbeConfig[] }) {
return <section className="card service-panel" aria-labelledby="probe-config-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.config}</p><h2 id="probe-config-title">{copy.services.configTitle}</h2></div><span className="service-count">{probes.length} {copy.services.probes}</span></div><p className="card-copy">{copy.services.configCaption}</p>{probes.length === 0 ? <p className="card-copy">{copy.services.noProbes}</p> : <div className="service-table-wrap"><table className="service-table"><caption className="sr-only">{copy.services.configCaption}</caption><thead><tr><th scope="col">{copy.services.probe}</th><th scope="col">{copy.services.probeType}</th><th scope="col">{copy.services.interval}</th><th scope="col">{copy.services.timeout}</th><th scope="col">{copy.services.tls}</th><th scope="col">{copy.services.redirects}</th><th scope="col">{copy.services.state}</th></tr></thead><tbody>{probes.map((probe) => <tr key={probe.id}><th scope="row">{probe.name}<small>{probe.id}</small></th><td>{copy.services.probeTypes[probe.type as keyof typeof copy.services.probeTypes] ?? probe.type}</td><td>{probe.intervalSeconds} s</td><td>{probe.timeoutSeconds} s</td><td>{probe.verifyTls ? copy.services.yes : copy.services.no}</td><td>{probe.followRedirects ? copy.services.yes : copy.services.no}</td><td>{probe.enabled ? copy.services.enabled : copy.services.disabled}</td></tr>)}</tbody></table></div>}</section>;
}
function relationLabel(value: string): string {
switch (value) { case 'depends_on': return copy.services.dependsOn; case 'backs': return copy.services.backs; case 'exposes': return copy.services.exposes; default: return copy.services.relation; }
}
function DependencyCard({ dependencies, available }: { dependencies: Dependency[]; available: boolean }) {
const ordered = [...dependencies].sort((left, right) => left.dependsOnServiceId.localeCompare(right.dependsOnServiceId) || left.id.localeCompare(right.id));
return <section className="card service-panel" aria-labelledby="service-dependencies-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.relations}</p><h2 id="service-dependencies-title">{copy.services.relationTitle}</h2></div><span className="service-count">{available ? ordered.length : '—'} {copy.services.relationRows}</span></div><p className="card-copy">{copy.services.relationCaption}</p>{!available ? <p className="card-copy" role="status">{copy.services.relationsUnavailable}</p> : ordered.length === 0 ? <p className="card-copy">{copy.services.noRelations}</p> : <div className="service-table-wrap"><table className="service-table"><caption className="sr-only">{copy.services.relationCaption}</caption><thead><tr><th scope="col">{copy.services.relation}</th><th scope="col">{copy.services.upstream}</th><th scope="col">{copy.services.source}</th><th scope="col">{copy.services.confidence}</th><th scope="col">{copy.services.confirmation}</th></tr></thead><tbody>{ordered.map((dependency) => <tr key={dependency.id}><th scope="row">{relationLabel(dependency.relationType)}<small>{dependency.id}</small></th><td>{dependency.dependsOnServiceId}</td><td>{dependency.sourceId || copy.services.manual}</td><td>{Math.round(Math.max(0, Math.min(1, dependency.confidence)) * 100)}%</td><td>{dependency.confirmed ? copy.services.confirmed : copy.services.inferred}</td></tr>)}</tbody></table></div>}</section>;
}
function ServiceHistory({ history }: { history: ProbeResult[] }) {
return <section className="card service-panel" aria-labelledby="service-history-title"><div className="card-heading"><div><p className="card-kicker">{copy.services.history}</p><h2 id="service-history-title">{copy.services.historyTitle}</h2></div><span className="service-count">{history.length} {copy.services.samples}</span></div>{history.length === 0 ? <p className="card-copy">{copy.services.noHistory}</p> : <div className="service-table-wrap"><table className="service-table service-history-table"><caption className="sr-only">{copy.services.historyCaption}</caption><thead><tr><th scope="col">{copy.services.observed}</th><th scope="col">{copy.services.probe}</th><th scope="col">{copy.services.state}</th><th scope="col">{copy.services.latency}</th><th scope="col">{copy.services.reason}</th></tr></thead><tbody>{history.map((result, index) => <tr key={(result.id || result.probeId) + '-' + index}><th scope="row">{formatDate(result.observedAt)}</th><td>{result.probeId}</td><td><StatusPill state={result.state} /></td><td>{formatLatency(result.responseTimeMs)}</td><td>{errorLabel(result)}</td></tr>)}</tbody></table></div>}</section>;
}
function ServiceDetailPage({ service, dependencies, dependenciesAvailable, retry }: { service: ServiceStatus; dependencies: Dependency[]; dependenciesAvailable: boolean; retry: () => void }) {
const certificate = useMemo(() => service.certificate ?? latestCertificate(service.history), [service.certificate, service.history]);
return <><header className="page-intro"><a className="back-link" href="/services" onClick={(event) => { event.preventDefault(); navigate('/services'); }}> {copy.services.back}</a><p className="eyebrow">{copy.services.detailEyebrow}</p><h1>{service.name}</h1><p className="intro">{service.description || copy.services.detailIntro}</p></header><section className="card service-detail-summary" aria-labelledby="service-detail-status"><div className="card-heading"><div><p className="card-kicker">{copy.services.currentState}</p><h2 id="service-detail-status">{statusLabel(service.state)}</h2></div><StatusPill state={service.state} /></div><p className="service-reason"><strong>{copy.services.reason}:</strong> {reasonLabel(service.reason)}</p><dl className="service-details-grid"><div><dt>{copy.services.latency}</dt><dd>{formatLatency(service.responseTimeMs)}</dd></div><div><dt>{copy.services.availability}</dt><dd>{formatAvailability(service.availabilityPercent)}</dd></div><div><dt>{copy.services.lastSuccess}</dt><dd>{formatDate(service.lastSuccessAt)}</dd></div><div><dt>{copy.services.lastFailure}</dt><dd>{formatDate(service.lastFailureAt)}</dd></div></dl><button className="button button--secondary" type="button" onClick={retry}>{copy.services.retry}</button></section><ProbeConfigCard probes={service.probes ?? []} /><DependencyCard dependencies={dependencies} available={dependenciesAvailable} /><ServiceHistory history={service.history ?? []} /><CertificateCard certificate={certificate} /><details className="card technical-details"><summary>{copy.services.technical}</summary><dl className="technical-grid"><dt>{copy.services.serviceId}</dt><dd>{service.id}</dd><dt>{copy.services.entityId}</dt><dd>{service.entityId || copy.services.notAvailable}</dd><dt>{copy.services.sourceId}</dt><dd>{service.sourceId || copy.services.notAvailable}</dd><dt>{copy.services.samples}</dt><dd>{service.sampleCount} · {service.successfulSampleCount} {copy.services.successSamples}</dd></dl><p className="card-copy">{copy.services.secretSafe}</p></details></>;
}
export function ServicePage({ id }: { id?: string }) {
const [state, setState] = useState<ViewState>('loading');
const [snapshot, setSnapshot] = useState<ServiceSnapshot | null>(null);
const [service, setService] = useState<ServiceStatus | null>(null);
const [dependencies, setDependencies] = useState<Dependency[]>([]);
const [dependenciesAvailable, setDependenciesAvailable] = useState(true);
const [reload, setReload] = useState(0);
useEffect(() => {
const controller = new AbortController();
setState('loading');
const endpoint = id ? '/api/v1/services/' + encodeURIComponent(id) : '/api/v1/services?limit=100';
const serviceRequest = fetch(endpoint, { signal: controller.signal }).then((response) => {
if (response.status === 401) throw new Error('unauthorized');
if (!response.ok) throw new Error('services');
return response.json() as Promise<ServiceSnapshot | ServiceDetailResponse>;
});
const dependencyRequest: Promise<DependencyLoad> = id ? fetch('/api/v1/services/' + encodeURIComponent(id) + '/dependencies?limit=100', { signal: controller.signal }).then(async (response) => response.ok ? { ...(await response.json() as DependencyResponse), available: true } : { serviceId: id, dependencies: [], available: false }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') throw error; return { serviceId: id, dependencies: [], available: false }; }) : Promise.resolve({ serviceId: '', dependencies: [], available: true });
Promise.all([serviceRequest, dependencyRequest]).then(([data, dependencyData]) => {
if (id) {
const detail = data as ServiceDetailResponse;
if (!detail.service) throw new Error('services');
setService(detail.service);
setDependencies(dependencyData.dependencies ?? []);
setDependenciesAvailable(dependencyData.available);
setState('ready');
} else {
const list = data as ServiceSnapshot;
const normalized = { ...list, services: list.services ?? [] };
setSnapshot(normalized);
setState(normalized.services.length ? 'ready' : 'empty');
}
}).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
setState(error instanceof Error && error.message === 'unauthorized' ? 'unauthorized' : 'error');
});
return () => controller.abort();
}, [id, reload]);
const retry = () => setReload((value) => value + 1);
if (state === 'loading') return <ServiceLoading />;
if (state === 'unauthorized' || state === 'error') return <ServiceError unauthorized={state === 'unauthorized'} retry={retry} />;
if (id && service) return <ServiceDetailPage service={service} dependencies={dependencies} dependenciesAvailable={dependenciesAvailable} retry={retry} />;
if (!id && snapshot) return <ServiceListPage snapshot={snapshot} retry={retry} />;
return <ServiceError unauthorized={false} retry={retry} />;
}
+14
View File
@@ -0,0 +1,14 @@
import { formatDateTime } from './locale';
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentStoragePolicy } from './presentation';
import { SourceStatusDetails } from './SourceStatusDetails';
type Share = { id: string; name: string; storagePolicy: { allocation?: string; cachePolicy?: string; primaryPool?: string; cachePool?: string }; usedBytes: number; sizeObservedAt?: string; sizeState: string; placements?: Array<{ poolId: string; bytes: number }>; growthHistory?: Array<{ observedAt: string; usedBytes: number; deltaBytes: number; rateBytesPerDay: number }> };
type Snapshot = { source: { id: string; state: string; freshness: string; observedAt?: string; reason?: string }; shares: Share[]; total: number; scan: { maxSharesPerRun: number; dueShareIds?: string[]; deferredCount: number; cacheTtlSeconds: number } };
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 date(value?: string): string { return formatDateTime(value); }
function Badge({ label, ready }: { label: string; ready: boolean }) { return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{label}</span>; }
function ShareCard({ share }: { share: Share }) { return <article className="card"><div className="card-heading"><div><p className="card-kicker">{presentStoragePolicy(share.storagePolicy.allocation, copy.shares.unknownPolicy)}</p><h2><a className="entity-link" href={'/shares/' + encodeURIComponent(share.id)}>{share.name}</a></h2></div><Badge label={share.sizeState === 'available' || share.sizeState === 'cached' ? copy.shares.available : copy.shares.unknown} ready={share.sizeState === 'available' || share.sizeState === 'cached'} /></div><p className="card-copy">{bytes(share.usedBytes)} · {share.storagePolicy.cachePool || copy.shares.noCachePool} · {share.storagePolicy.primaryPool || copy.shares.noPrimaryPool}</p><p className="card-copy">{copy.shares.observed}: {date(share.sizeObservedAt)} · {share.growthHistory?.length || 0} {copy.shares.growthPoints}</p></article>; }
function ShareDetail({ share, source }: { share: Share; source: Snapshot['source'] }) { const latest = share.growthHistory?.[share.growthHistory.length - 1]; return <><header className="page-intro"><p className="eyebrow">{copy.shares.detailEyebrow}</p><h1>{share.name}</h1><p className="intro">{copy.shares.detailIntro}</p><div className="detail-actions"><a className="button button--secondary" href="/shares">{copy.shares.back}</a></div></header><section className="card container-summary" aria-labelledby="share-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.shares.source}</p><h2 id="share-summary-title">{source.id}</h2></div><Badge label={share.sizeState === 'stale' ? copy.shares.unknown : copy.shares.available} ready={share.sizeState !== 'stale' && share.sizeState !== 'unknown'} /></div><SourceStatusDetails source={source} fallbackReason={source.freshness === 'fresh' ? copy.shares.fresh : copy.shares.stale} /><p className="container-provenance">{bytes(share.usedBytes)}</p></section><section className="card-grid" aria-label={copy.shares.metrics}><article className="card"><p className="card-kicker">{copy.shares.policy}</p><h2>{presentStoragePolicy(share.storagePolicy.allocation, copy.shares.unknownPolicy)}</h2><p className="card-copy">{presentStoragePolicy(share.storagePolicy.cachePolicy, copy.shares.unknownCachePolicy)}</p></article><article className="card"><p className="card-kicker">{copy.shares.relation}</p><h2>{share.storagePolicy.cachePool || copy.shares.noCachePool}</h2><p className="card-copy">{share.storagePolicy.primaryPool || copy.shares.noPrimaryPool}</p></article><article className="card"><p className="card-kicker">{copy.shares.growth}</p><h2>{latest ? (latest.rateBytesPerDay >= 0 ? '+' : '') + bytes(Math.abs(latest.rateBytesPerDay)) + '/d' : '—'}</h2><p className="card-copy">{share.growthHistory?.length || 0} {copy.shares.growthPoints}</p></article></section><section className="card" aria-labelledby="share-placements-title"><div className="card-heading"><div><p className="card-kicker">{copy.shares.placement}</p><h2 id="share-placements-title">{copy.shares.placementTitle}</h2></div></div>{share.placements?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.shares.pool}</th><th>{copy.shares.size}</th></tr></thead><tbody>{share.placements.map((placement) => <tr key={placement.poolId}><th scope="row">{placement.poolId}</th><td>{bytes(placement.bytes)}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.shares.noPlacements}</p>}</section><details className="card technical-details"><summary>{copy.shares.growthHistory}</summary>{share.growthHistory?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.shares.observed}</th><th>{copy.shares.size}</th><th>{copy.shares.change}</th></tr></thead><tbody>{share.growthHistory.map((point) => <tr key={point.observedAt}><th scope="row">{date(point.observedAt)}</th><td>{bytes(point.usedBytes)}</td><td>{point.deltaBytes >= 0 ? '+' : ''}{bytes(Math.abs(point.deltaBytes))}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.shares.noGrowth}</p>}</details><p className="card-copy">{copy.shares.readOnly}</p></>; }
export function SharePage({ id }: { id?: string }) { const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); const [snapshot, setSnapshot] = useState<Snapshot | null>(null); const [selected, setSelected] = useState<Share | null>(null); useEffect(() => { const controller = new AbortController(); fetch(id ? '/api/v1/shares/' + encodeURIComponent(id) : '/api/v1/shares?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('shares'); return response.json() as Promise<Snapshot & { share?: Share }>; }).then((data) => { setSnapshot(data); if (data.share) setSelected(data.share); 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.shares.loading}</h1></section>; if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.shares.errorTitle}</h1><p>{copy.shares.errorDetail}</p></section>; if (selected) return <ShareDetail share={selected} source={snapshot.source} />; const fresh = snapshot.source?.freshness === 'fresh' && snapshot.source?.state !== 'unknown'; return <><header className="page-intro"><p className="eyebrow">{copy.shares.eyebrow}</p><h1>{copy.shares.title}</h1><p className="intro">{copy.shares.intro}</p></header><section className="card container-summary" aria-labelledby="shares-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.shares.source}</p><h2 id="shares-summary-title">{snapshot.source?.id || copy.shares.unknown}</h2></div><Badge label={fresh ? copy.shares.available : copy.shares.unknown} ready={fresh} /></div><SourceStatusDetails source={snapshot.source ?? {}} fallbackReason={fresh ? copy.shares.fresh : copy.shares.stale} /><p className="container-provenance">{snapshot.total} {copy.shares.rows}</p><p className="card-copy">{copy.shares.scan}: {snapshot.scan.deferredCount} {copy.shares.deferred}; {snapshot.scan.maxSharesPerRun} {copy.shares.perRun}</p></section><section className="card-grid" aria-label={copy.shares.cards}>{snapshot.shares.length ? snapshot.shares.map((share) => <ShareCard key={share.id} share={share} />) : <article className="card"><p className="card-copy">{copy.shares.empty}</p></article>}</section></>; }
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { consumeAuthNoticeFromUrl, currentReturnPath, loginHref, onUnauthenticated, startLogin, type AuthNotice, type AuthNoticeKind } from './auth';
const noticeCopy: Record<AuthNoticeKind, { title: string; detail: string; action: string }> = {
required: { title: copy.auth.requiredTitle, detail: copy.auth.requiredDetail, action: copy.auth.signIn },
expired: { title: copy.auth.expiredTitle, detail: copy.auth.expiredDetail, action: copy.auth.signInAgain },
failed: { title: copy.auth.failedTitle, detail: copy.auth.failedDetail, action: copy.auth.signInAgain },
cancelled: { title: copy.auth.cancelledTitle, detail: copy.auth.cancelledDetail, action: copy.auth.signIn },
};
// A login that came back broken outranks a plain 401 observed afterwards, so the
// banner keeps explaining the actual failure instead of flipping to a generic
// "please sign in".
const noticeRank: Record<AuthNoticeKind, number> = { required: 0, expired: 1, cancelled: 2, failed: 2 };
/**
* Primary sign-in affordance. Rendered as a real anchor so that the browser's
* own keyboard, focus and "open in new tab" behaviour applies, while the click
* handler keeps the return path in sync with the page the user is actually on.
*/
export function SignInButton({ returnPath, label = copy.auth.signIn, variant = 'primary' }: { returnPath?: string; label?: string; variant?: 'primary' | 'secondary' }) {
const target = returnPath ?? currentReturnPath();
return <a className={variant === 'primary' ? 'button' : 'button button--secondary'} href={loginHref(target)} onClick={(event) => { event.preventDefault(); startLogin(target); }}>{label}</a>;
}
/**
* Shell-level banner for authentication events that are not tied to one page: a
* 401 on any route, a session that expired mid-session, and a failed or
* cancelled login redirecting back into the app.
*/
export function AuthNoticeBanner() {
const [notice, setNotice] = useState<AuthNotice | null>(() => consumeAuthNoticeFromUrl());
const [dismissedKind, setDismissedKind] = useState<AuthNoticeKind | null>(null);
useEffect(() => onUnauthenticated((next) => setNotice((current) => (current && noticeRank[current.kind] >= noticeRank[next.kind]) ? current : next)), []);
if (!notice || dismissedKind === notice.kind) return null;
const text = noticeCopy[notice.kind];
return <div className="auth-banner" role="alert" aria-label={copy.auth.noticeLabel}>
<span className="auth-banner-icon" aria-hidden="true">!</span>
<div className="auth-banner-text"><strong>{text.title}</strong><span>{text.detail}</span></div>
<SignInButton returnPath={notice.returnPath} label={text.action} />
<button className="button button--secondary" type="button" onClick={() => setDismissedKind(notice.kind)}>{copy.auth.dismiss}</button>
</div>;
}
+29
View File
@@ -0,0 +1,29 @@
import { copy } from './copy';
import { formatDateTime, hasReceivedTimestamp } from './locale';
import { presentReason, presentStatus } from './presentation';
export type SourceStatus = {
id?: string;
state?: string;
freshness?: string;
observedAt?: string;
reason?: string;
};
function dataLabel(source: SourceStatus): string {
if (!hasReceivedTimestamp(source.observedAt)) return copy.sourceStatus.neverReceived;
if (source.freshness === 'fresh' && source.state !== 'unknown' && source.state !== 'unavailable') return copy.sourceStatus.fresh;
if (source.freshness === 'stale') return copy.sourceStatus.stale;
return copy.sourceStatus.unavailable;
}
export function SourceStatusDetails({ source, fallbackReason, className = '' }: { source: SourceStatus; fallbackReason?: string; className?: string }) {
const reasonCode = source.reason?.trim();
const observed = formatDateTime(source.observedAt);
return <div className={`source-status-details ${className}`.trim()}>
<p className="source-status-line"><span><strong>{copy.sourceStatus.status}:</strong> {presentStatus(source.state)}</span><span><strong>{copy.sourceStatus.data}:</strong> {dataLabel(source)}</span></p>
<p className="source-status-reason">{reasonCode ? presentReason(reasonCode) : fallbackReason || copy.presentation.reason.noDetail}</p>
<p className="source-status-observed"><strong>{copy.sourceStatus.observed}:</strong> {hasReceivedTimestamp(source.observedAt) ? <time dateTime={source.observedAt}>{observed}</time> : observed}</p>
{reasonCode && <details className="source-status-technical"><summary>{copy.sourceStatus.technical}</summary><dl><dt>{copy.sourceStatus.sourceId}</dt><dd>{source.id || '—'}</dd><dt>{copy.sourceStatus.reasonCode}</dt><dd><code>{reasonCode}</code></dd></dl></details>}
</div>;
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { StorageMapWidget, TemperatureHeatmap, type HeatmapPoint, type StorageMapNode } from './StorageVisuals';
import { SourceStatusDetails, type SourceStatus } from './SourceStatusDetails';
type Severity = 'normal' | 'attention' | 'critical' | 'unknown';
type Disk = { id: string; name: string; role: string; state: string; utilizationPercent: number; capacitySeverity?: Severity; thermalSeverity?: Severity; temperature?: { celsius?: number; status: string; observedAt?: string } };
type DiskSnapshot = { source: SourceStatus; disks: Disk[] };
type Pool = { id: string; name: string; filesystem: string; state: string; utilizationPercent: number; capacitySeverity?: Severity };
type PoolSnapshot = { source: SourceStatus; pools: Pool[] };
type ArrayMember = { id: string; name: string; role: string; state: string };
type ArraySnapshot = { source: SourceStatus; state: string; members: ArrayMember[] };
export type StorageData = { array: ArraySnapshot; disks: DiskSnapshot; pools: PoolSnapshot };
function availability(value: string): string { return value === 'online' || value === 'healthy' || value === 'operational' ? 'healthy' : value === 'unknown' ? 'unknown' : 'degraded'; }
function visualSeverity(...values: Array<string | undefined>): string {
if (values.includes('critical')) return 'critical';
if (values.some((value) => value === 'attention' || value === 'degraded' || value === 'faulted')) return 'degraded';
if (values.includes('unknown')) return 'unknown';
return 'healthy';
}
function signalLabel(value: string): string { return value === 'normal' || value === 'healthy' || value === 'online' ? 'normaal' : value === 'critical' ? 'kritiek' : value === 'attention' || value === 'degraded' ? 'aandacht' : 'onbekend'; }
export function buildStorageNodes(data: StorageData): StorageMapNode[] {
const members = new Map((data.array.members ?? []).map((member) => [member.id.toLowerCase(), member]));
const diskNodes = (data.disks.disks ?? []).slice(0, 64).map((disk) => {
const member = members.get(disk.id.toLowerCase());
if (member) members.delete(disk.id.toLowerCase());
const capacity = disk.capacitySeverity ?? 'unknown';
const thermal = disk.thermalSeverity ?? disk.temperature?.status ?? 'unknown';
return { id: 'disk-' + disk.id, label: disk.name, kind: member?.role ?? disk.role, state: visualSeverity(availability(disk.state), capacity, thermal), detail: `Beschikbaarheid ${signalLabel(disk.state)} · capaciteit ${signalLabel(capacity)} · temperatuur ${signalLabel(thermal)}`, href: '/disks/' + encodeURIComponent(disk.id) };
});
const unmatchedMembers = [...members.values()].slice(0, 64).map((member) => ({ id: 'array-' + member.id, label: member.name, kind: member.role, state: availability(member.state), detail: `Beschikbaarheid ${signalLabel(member.state)} · disktelemetrie onbekend`, href: '/array' }));
const poolNodes = (data.pools.pools ?? []).slice(0, 64).map((pool) => {
const capacity = pool.capacitySeverity ?? 'unknown';
return { id: 'pool-' + pool.id, label: pool.name, kind: `pool · ${pool.filesystem}`, state: visualSeverity(availability(pool.state), capacity), detail: `Device-health ${signalLabel(pool.state)} · capaciteit ${signalLabel(capacity)} (${pool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% gebruikt)`, href: '/pools/' + encodeURIComponent(pool.id) };
});
return [...unmatchedMembers, ...poolNodes, ...diskNodes];
}
export function StoragePage() {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [data, setData] = useState<StorageData | null>(null);
useEffect(() => { const controller = new AbortController(); Promise.all([fetch('/api/v1/array', { signal: controller.signal }).then((response) => response.json() as Promise<ArraySnapshot>), fetch('/api/v1/disks?limit=100', { signal: controller.signal }).then((response) => response.json() as Promise<DiskSnapshot>), fetch('/api/v1/pools?limit=100', { signal: controller.signal }).then((response) => response.json() as Promise<PoolSnapshot>)]).then(([array, disks, pools]) => { setData({ array, disks, pools }); setState('ready'); }).catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') return; setState('error'); }); return () => controller.abort(); }, []);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.storage.loading}</h1></section>;
if (state === 'error' || !data) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.storage.errorTitle}</h1><p>{copy.storage.errorDetail}</p></section>;
const nodes = buildStorageNodes(data);
const heatmap: HeatmapPoint[] = (data.disks.disks ?? []).slice(0, 64).map((disk) => ({ id: disk.id, label: disk.name, observedAt: disk.temperature?.observedAt || '', value: disk.temperature?.celsius ?? null, status: visualSeverity(disk.thermalSeverity ?? disk.temperature?.status ?? 'unknown'), href: '/disks/' + encodeURIComponent(disk.id) }));
return <><header className="page-intro"><p className="eyebrow">{copy.storage.eyebrow}</p><h1>{copy.storage.title}</h1><p className="intro">{copy.storage.intro}</p></header><section className="card container-summary" aria-labelledby="storage-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.storage.source}</p><h2 id="storage-summary-title">{copy.storage.sourceTitle}</h2><p className="card-copy">{copy.storage.accessible}</p></div></div><div className="storage-source-grid"><article><h3>{copy.storage.arraySource}</h3><SourceStatusDetails source={{ ...data.array.source, id: data.array.source?.id || 'array' }} /></article><article><h3>{copy.storage.diskSource}</h3><SourceStatusDetails source={{ ...data.disks.source, id: data.disks.source?.id || 'disks' }} /></article><article><h3>{copy.storage.poolSource}</h3><SourceStatusDetails source={{ ...data.pools.source, id: data.pools.source?.id || 'pools' }} /></article></div><p className="container-provenance">{nodes.length} {copy.storage.mapNodes} · {heatmap.length} {copy.storage.heatmapPoints}</p></section><StorageMapWidget nodes={nodes} title={copy.storage.mapTitle} description={copy.storage.mapDescription} /><TemperatureHeatmap points={heatmap} title={copy.storage.heatmapTitle} description={copy.storage.heatmapDescription} /></>;
}
+17
View File
@@ -0,0 +1,17 @@
import { copy } from './copy';
import { formatDateTime } from './locale';
import { presentStatus } from './presentation';
export type StorageMapNode = { id: string; label: string; kind: string; state: string; detail?: string; href?: string };
export type HeatmapPoint = { id: string; label: string; observedAt: string; value: number | null; status: string; href?: string };
function StateText({ state }: { state: string }) { return <span className="storage-visual-state"><span className={'status-dot status-dot--' + state} aria-hidden="true" />{presentStatus(state)}</span>; }
export function StorageMapWidget({ nodes, title, description, idPrefix = 'storage-map' }: { nodes: StorageMapNode[]; title: string; description: string; idPrefix?: string }) {
const bounded = nodes.slice(0, 128);
return <section className="card storage-visual" aria-labelledby={idPrefix + '-title'}><p className="card-kicker">{copy.storage.mapKicker}</p><h2 id={idPrefix + '-title'}>{title}</h2><p className="card-copy">{description}</p><ul className="storage-map-grid" aria-label={copy.storage.mapNodesLabel}>{bounded.map((node) => <li key={node.id} className="storage-map-node"><a href={node.href || '#'} aria-label={node.label + ': ' + presentStatus(node.state)}>{node.href ? <strong>{node.label}</strong> : <strong>{node.label}</strong>}<span>{node.kind}</span><StateText state={node.state} />{node.detail && <small>{node.detail}</small>}</a></li>)}</ul><details className="storage-accessible-summary"><summary>{copy.storage.accessibleSummary} ({bounded.length} items)</summary><div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.storage.entity}</th><th>{copy.storage.type}</th><th>{copy.storage.status}</th><th>{copy.storage.detail}</th></tr></thead><tbody>{bounded.map((node) => <tr key={node.id}><th scope="row">{node.href ? <a className="entity-link" href={node.href}>{node.label}</a> : node.label}</th><td>{node.kind}</td><td>{presentStatus(node.state)}</td><td>{node.detail || '—'}</td></tr>)}</tbody></table></div></details></section>;
}
export function TemperatureHeatmap({ points, title, description, idPrefix = 'temperature-heatmap' }: { points: HeatmapPoint[]; title: string; description: string; idPrefix?: string }) {
const bounded = points.slice(0, 256);
return <section className="card storage-visual" aria-labelledby={idPrefix + '-title'}><p className="card-kicker">{copy.storage.heatmap}</p><h2 id={idPrefix + '-title'}>{title}</h2><p className="card-copy">{description}</p><div className="storage-heatmap" aria-hidden="true">{bounded.map((point) => <span key={point.id + point.observedAt} className={'storage-heatmap-cell storage-heatmap-cell--' + point.status} title={point.label + ': ' + (point.value == null ? 'Onbekend' : point.value + ' °C')}>{point.value == null ? '?' : Math.round(point.value)}</span>)}</div><details className="storage-accessible-summary" open><summary>{copy.storage.accessibleSummary} en tabelalternatief ({bounded.length} metingen)</summary><div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.storage.disk}</th><th>{copy.storage.observed}</th><th>{copy.storage.temperature}</th><th>{copy.storage.status}</th></tr></thead><tbody>{bounded.map((point) => <tr key={point.id + point.observedAt}><th scope="row">{point.href ? <a className="entity-link" href={point.href}>{point.label}</a> : point.label}</th><td>{formatDateTime(point.observedAt)}</td><td>{point.value == null ? 'Onbekend' : point.value + ' °C'}</td><td>{presentStatus(point.status)}</td></tr>)}</tbody></table></div></details></section>;
}
+47
View File
@@ -0,0 +1,47 @@
import { useState } from 'react';
import { copy } from './copy';
import { plural, presentComponent, presentReason } from './presentation';
import { formatDateTime } from './locale';
import { backupPresentation, refreshSystemStatus, systemStateLabel as label, useSystemStatus } from './systemStatus';
function Badge({ state }: { state: string }) {
const ready = state === 'healthy';
return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{label(state)}</span>;
}
function age(seconds?: number): string {
if (seconds == null || !Number.isFinite(seconds)) return copy.systemStatus.notAvailable;
if (seconds < 60) return `${Math.max(0, Math.round(seconds))} ${copy.systemStatus.seconds}`;
if (seconds < 3600) return `${Math.round(seconds / 60)} ${copy.systemStatus.minutes}`;
if (seconds < 86400) { const hours = Math.round(seconds / 3600); return `${hours} ${plural(hours, copy.systemStatus.hourAgo, copy.systemStatus.hoursAgo)}`; }
const days = Math.round(seconds / 86400);
return `${days} ${plural(days, copy.systemStatus.dayAgo, copy.systemStatus.daysAgo)}`;
}
export function SystemStatusPage() {
const { state, status } = useSystemStatus();
const [backupAction, setBackupAction] = useState<'idle' | 'creating' | 'created' | 'error'>('idle');
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.systemStatus.loading}</h1></section>;
if (state === 'unauthorized') return <section className="state-page" role="alert"><h1>{copy.systemStatus.unauthorized}</h1><p>{copy.systemStatus.unauthorizedDetail}</p></section>;
if (state === 'forbidden') return <section className="state-page" role="alert"><h1>{copy.systemStatus.forbidden}</h1><p>{copy.systemStatus.forbiddenDetail}</p></section>;
if (state === 'error' || !status) return <section className="state-page" role="alert"><h1>{copy.systemStatus.errorTitle}</h1><p>{copy.systemStatus.errorDetail}</p></section>;
const release = status.release;
const backup = backupPresentation(status.backup);
const createBackup = async () => {
setBackupAction('creating');
try {
const response = await fetch('/api/v1/system/backups', { method: 'POST' });
if (!response.ok) throw new Error('backup create failed');
setBackupAction('created');
refreshSystemStatus();
} catch {
setBackupAction('error');
}
};
return <>
<header className="page-intro"><p className="eyebrow">{copy.systemStatus.eyebrow}</p><h1>{copy.systemStatus.title}</h1><p className="intro">{copy.systemStatus.intro}</p></header>
<section className="card system-status-summary" aria-labelledby="system-status-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.systemStatus.current}</p><h2 id="system-status-summary-title">{label(status.overallState)}</h2></div><Badge state={status.overallState} /></div><p className="card-copy">{copy.systemStatus.version}: {release?.version || status.version} · {copy.systemStatus.commit}: {release?.commit || copy.systemStatus.notAvailable} · {copy.systemStatus.migration}: {release?.migrationVersion || copy.systemStatus.notAvailable}</p><p className="card-copy">{copy.systemStatus.built}: {release?.builtAt ? formatDateTime(release.builtAt) : copy.systemStatus.notAvailable} · {copy.systemStatus.generated}: {formatDateTime(status.generatedAt)}</p></section>
<section className="card" aria-labelledby="system-components-title"><div className="card-heading"><div><p className="card-kicker">{copy.systemStatus.components}</p><h2 id="system-components-title">{copy.systemStatus.componentTitle}</h2></div></div><ul className="inventory-list">{status.components.map((component) => <li key={component.id}><span><strong>{presentComponent(component.id)}</strong><small>{presentReason(component.reason)}</small></span><Badge state={component.state} /></li>)}</ul></section>
<section className="card-grid"><article className="card" aria-labelledby="backup-status-title"><p className="card-kicker">{copy.systemStatus.backup}</p><h2 id="backup-status-title"><Badge state={backup.state} /></h2><p className="card-copy">{presentReason(backup.reason)} · {age(backup.ageSeconds)}{backup.verifiedAt ? ` · ${copy.systemStatus.verified}: ${formatDateTime(backup.verifiedAt)}` : ''}</p><p className="card-copy">{copy.systemStatus.backupFreshness}</p><button className="button button--secondary" type="button" disabled={backupAction === 'creating'} onClick={() => void createBackup()}>{backupAction === 'creating' ? copy.systemStatus.creatingBackup : copy.systemStatus.createBackup}</button>{backupAction === 'created' && <p className="card-copy" role="status">{copy.systemStatus.backupCreated}</p>}{backupAction === 'error' && <p className="card-copy" role="alert">{copy.systemStatus.backupCreateFailed}</p>}</article><article className="card" aria-labelledby="source-lag-title"><p className="card-kicker">{copy.systemStatus.sourceLag}</p><h2 id="source-lag-title">{status.sourceLag.length} {plural(status.sourceLag.length, copy.systemStatus.source, copy.systemStatus.sources)}</h2>{status.sourceLag.length === 0 ? <p className="card-copy">{copy.systemStatus.noSources}</p> : <ul className="compact-list">{status.sourceLag.map((source) => <li key={source.sourceId}><strong>{presentComponent(source.sourceId)}</strong>: {label(source.state)} · {age(source.ageSeconds)}</li>)}</ul>}</article></section>
</>;
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useMemo, useState } from 'react';
import { copy } from './copy';
type TopologyNode = { id: string; label: string; state: string; reason?: string; known: boolean; kind?: string; sourceId?: string };
type TopologyEdge = { id: string; from: string; to: string; relationType: string; sourceId?: string; confidence: number; confirmed: boolean; inferred: boolean };
export type TopologyData = { contractVersion: string; observedAt: string; capabilityState?: string; configurationState?: string; reason?: string; nodes: TopologyNode[]; edges: TopologyEdge[]; totalNodes: number; totalEdges: number; truncated: boolean };
type ViewState = 'loading' | 'error' | 'unauthorized' | 'ready';
function statusLabel(state: string): string {
if (state === 'up') return copy.services.up;
if (state === 'degraded') return copy.services.degraded;
if (state === 'down') return copy.services.down;
return copy.services.unknown;
}
function relationLabel(relation: string): string {
if (relation === 'backs') return copy.services.backs;
if (relation === 'exposes') return copy.services.exposes;
return copy.services.dependsOn;
}
function edgeClass(edge: TopologyEdge): string {
return edge.confirmed ? 'topology-edge topology-edge--confirmed' : 'topology-edge topology-edge--inferred';
}
function nodeClass(node: TopologyNode): string {
return node.known ? 'topology-node topology-node--known' : 'topology-node topology-node--unknown';
}
function ServiceLink({ node }: { node: TopologyNode }) {
if (!node.known) return <span className={nodeClass(node)}><strong>{node.label}</strong><small>{copy.topology.nodeUnknown}</small></span>;
if (node.kind === 'reverse_proxy') return <span className={nodeClass(node)}><strong>{node.label}</strong><small>{copy.topology.reverseProxy} · {node.sourceId || copy.topology.sourceUnknown}</small></span>;
return <a className={nodeClass(node)} href={'/services/' + encodeURIComponent(node.id)}><strong>{node.label}</strong><small>{statusLabel(node.state)} · {node.id}</small></a>;
}
export function TopologyWidget({ topology, compact = false }: { topology: TopologyData; compact?: boolean }) {
const nodes = [...topology.nodes].sort((left, right) => left.id.localeCompare(right.id));
const edges = [...topology.edges].sort((left, right) => left.id.localeCompare(right.id));
const nodeByID = new Map(nodes.map((node) => [node.id, node]));
return <section className={'topology-widget' + (compact ? ' topology-widget--compact' : '')} aria-labelledby={compact ? undefined : 'topology-widget-title'}>
{!compact && <div className="card-heading"><div><p className="card-kicker">{copy.topology.widgetKicker}</p><h2 id="topology-widget-title">{copy.topology.widgetTitle}</h2></div><span className="topology-count">{nodes.length} {copy.topology.nodes} · {edges.length} {copy.topology.edges}</span></div>}
{!compact && <p className="topology-disclaimer">{copy.topology.noCausality}</p>}
<div className="topology-layout">
<section aria-labelledby={compact ? 'topology-widget-nodes-compact' : 'topology-widget-nodes'}>
<h3 id={compact ? 'topology-widget-nodes-compact' : 'topology-widget-nodes'}>{copy.topology.nodesTitle}</h3>
{nodes.length === 0 ? <p className="card-copy">{copy.topology.noNodes}</p> : <ul className="topology-node-list">{nodes.map((node) => <li key={node.id}><ServiceLink node={node} /></li>)}</ul>}
</section>
<section aria-labelledby={compact ? 'topology-widget-edges-compact' : 'topology-widget-edges'}>
<h3 id={compact ? 'topology-widget-edges-compact' : 'topology-widget-edges'}>{copy.topology.edgesTitle}</h3>
{edges.length === 0 ? <p className="card-copy">{copy.topology.noEdges}</p> : <ol className="topology-edge-list">{edges.map((edge) => {
const from = nodeByID.get(edge.from);
const to = nodeByID.get(edge.to);
return <li key={edge.id} className={edgeClass(edge)}><div className="topology-edge-route"><span>{from?.label ?? edge.from}</span><span aria-hidden="true"></span><span>{to?.label ?? edge.to}</span></div><div className="topology-edge-meta"><span>{relationLabel(edge.relationType)}</span><span>{Math.round(edge.confidence * 100)}% {edge.confirmed ? copy.services.confirmed : copy.services.inferred}</span><span>{edge.sourceId || copy.services.manual}</span></div></li>;
})}</ol>}
</section>
</div>
</section>;
}
export function TopologyPage() {
const [state, setState] = useState<ViewState>('loading');
const [topology, setTopology] = useState<TopologyData | null>(null);
const [reload, setReload] = useState(0);
const [query, setQuery] = useState('');
const [stateFilter, setStateFilter] = useState('all');
const [relationFilter, setRelationFilter] = useState('all');
useEffect(() => {
const controller = new AbortController();
setState('loading');
fetch('/api/v1/topology?limit=100', { signal: controller.signal }).then((response) => {
if (!response.ok) throw new Error(String(response.status));
return response.json() as Promise<TopologyData>;
}).then((data) => { setTopology({ ...data, nodes: data.nodes ?? [], edges: data.edges ?? [] }); setState('ready'); }).catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
const status = error instanceof Error ? Number(error.message) : 0;
setState(status === 401 ? 'unauthorized' : 'error');
});
return () => controller.abort();
}, [reload]);
const filtered = useMemo(() => {
if (!topology) return null;
const normalized = query.trim().toLowerCase();
const nodes = topology.nodes.filter((node) => {
const textMatch = !normalized || (node.label + ' ' + node.id).toLowerCase().includes(normalized);
return textMatch && (stateFilter === 'all' || node.state === stateFilter);
});
const nodeIDs = new Set(nodes.map((node) => node.id));
const edges = topology.edges.filter((edge) => nodeIDs.has(edge.from) && nodeIDs.has(edge.to) && (relationFilter === 'all' || edge.relationType === relationFilter));
return { ...topology, nodes, edges };
}, [topology, query, stateFilter, relationFilter]);
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.topology.loading}</h1></section>;
if (state === 'unauthorized') return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">!</span><h1>{copy.topology.unauthorizedTitle}</h1><p>{copy.topology.unauthorizedDetail}</p></section>;
if (state === 'error' || !topology || !filtered) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.topology.errorTitle}</h1><p>{copy.topology.errorDetail}</p><button className="button" type="button" onClick={() => setReload((value) => value + 1)}>{copy.topology.retry}</button></section>;
if (topology.nodes.length === 0) {
const unavailable = topology.capabilityState === 'unavailable';
return <><header className="page-intro"><p className="eyebrow">{copy.topology.eyebrow}</p><h1>{copy.topology.title}</h1><p className="intro">{copy.topology.intro}</p></header><section className="card empty-state" aria-labelledby="topology-empty-title"><h2 id="topology-empty-title">{unavailable ? copy.topology.unavailable : copy.topology.notConfigured}</h2><p className="card-copy">{unavailable ? copy.topology.unavailableDetail : copy.topology.notConfiguredDetail}</p>{unavailable ? <button className="button button--secondary" type="button" onClick={() => setReload((value) => value + 1)}>{copy.topology.retry}</button> : <a className="button" href="/onboarding">{copy.topology.configure}</a>}</section></>;
}
return <><header className="page-intro"><p className="eyebrow">{copy.topology.eyebrow}</p><h1>{copy.topology.title}</h1><p className="intro">{copy.topology.intro}</p></header><section className="card topology-controls" aria-label={copy.topology.filters}><label>{copy.topology.search}<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={copy.topology.searchPlaceholder} /></label><label>{copy.topology.stateFilter}<select value={stateFilter} onChange={(event) => setStateFilter(event.target.value)}><option value="all">{copy.topology.allStates}</option><option value="up">{copy.services.up}</option><option value="degraded">{copy.services.degraded}</option><option value="down">{copy.services.down}</option><option value="unknown">{copy.services.unknown}</option></select></label><label>{copy.topology.relationFilter}<select value={relationFilter} onChange={(event) => setRelationFilter(event.target.value)}><option value="all">{copy.topology.allRelations}</option><option value="depends_on">{copy.services.dependsOn}</option><option value="backs">{copy.services.backs}</option><option value="exposes">{copy.services.exposes}</option></select></label><span className="topology-filter-count" role="status">{filtered.nodes.length} {copy.topology.nodes} · {filtered.edges.length} {copy.topology.edges}</span></section><TopologyWidget topology={filtered} />{topology.truncated && <p className="topology-limit-note" role="status">{copy.topology.truncated}</p>}</>;
}
+103
View File
@@ -0,0 +1,103 @@
import { copy } from './copy';
import type { EditorWidget } from './DashboardEditor';
export type ValidationErrors = Record<string, string>;
export type PreviewResult = {
type: string;
title: string;
state: string;
message: string;
limits?: { maxSeries: number; maxPoints: number; maxRows: number; requestedRows: number; appliedRows: number };
sample?: Record<string, unknown>;
};
type Props = {
widget: EditorWidget;
errors: ValidationErrors;
preview: PreviewResult | null;
previewState: string;
previewing: boolean;
previewError: string;
viewportLabel: string;
width: number;
maxWidth: number;
onChange: (widget: EditorWidget) => void;
onWidthChange: (width: number) => void;
onPreviewStateChange: (state: string) => void;
onPreview: () => void;
};
const configCopy = copy.editor.config;
function record(value: Record<string, unknown> | undefined): Record<string, unknown> {
return value ?? {};
}
function text(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function number(value: unknown, fallback = ''): string {
return typeof value === 'number' ? String(value) : fallback;
}
function fieldId(name: string): string {
return 'widget-config-' + name.replaceAll('.', '-');
}
export function WidgetConfigDrawer({ widget, errors, preview, previewState, previewing, previewError, viewportLabel, width, maxWidth, onChange, onWidthChange, onPreviewStateChange, onPreview }: Props) {
const data = record(widget.data);
const visualization = record(widget.visualization);
const behavior = record(widget.behavior);
const sourceType = text(data.sourceType, 'inventory');
const error = (name: string) => errors[name] ? <p className="field-error" id={fieldId(name) + '-error'}>{errors[name]}</p> : null;
const update = (section: 'root' | 'data' | 'visualization' | 'behavior', name: string, value: unknown) => {
if (section === 'root') onChange({ ...widget, [name]: value });
else onChange({ ...widget, [section]: { ...record(widget[section]), [name]: value } });
};
const invalid = Object.keys(errors).length > 0;
return <aside className="editor-config-drawer card" aria-labelledby="widget-config-heading">
<p className="card-kicker">{configCopy.kicker}</p>
<h2 id="widget-config-heading">{widget.title || configCopy.fallbackTitle}</h2>
<p className="card-copy">{configCopy.intro}</p>
<div className="config-section">
<h3>{configCopy.general}</h3>
<label htmlFor={fieldId('title')}>{configCopy.title}<input id={fieldId('title')} value={text(widget.title)} maxLength={120} aria-invalid={Boolean(errors.title)} aria-describedby={errors.title ? fieldId('title') + '-error' : undefined} onChange={(event) => update('root', 'title', event.target.value)} /></label>
{error('title')}
<label htmlFor={fieldId('description')}>{configCopy.description}<textarea id={fieldId('description')} value={text(widget.description)} maxLength={500} rows={3} onChange={(event) => update('root', 'description', event.target.value)} /></label>
</div>
<div className="config-section">
<h3>{configCopy.data}</h3>
<label htmlFor={fieldId('data-sourceType')}>{configCopy.source}<select id={fieldId('data-sourceType')} value={sourceType} onChange={(event) => update('data', 'sourceType', event.target.value)}><option value="semantic-metric">{configCopy.sourceSemanticMetric}</option><option value="inventory">{configCopy.sourceInventory}</option><option value="events">{configCopy.sourceEvents}</option><option value="alerts">{configCopy.sourceAlerts}</option><option value="incidents">{configCopy.sourceIncidents}</option><option value="text">{configCopy.sourceText}</option></select></label>
{error('data.sourceType')}
{sourceType === 'semantic-metric' && <><label htmlFor={fieldId('data-metric')}>{configCopy.metric}<input id={fieldId('data-metric')} value={text(data.metric)} placeholder={configCopy.metricPlaceholder} aria-invalid={Boolean(errors['data.metric'])} aria-describedby={errors['data.metric'] ? fieldId('data-metric') + '-error' : undefined} onChange={(event) => update('data', 'metric', event.target.value)} /></label>{error('data.metric')}<div className="config-fields"><label htmlFor={fieldId('data-range')}>{configCopy.range}<select id={fieldId('data-range')} value={text(data.range, '1h')} onChange={(event) => update('data', 'range', event.target.value)}><option value="live">{configCopy.rangeLive}</option><option value="15m">{configCopy.range15m}</option><option value="1h">{configCopy.range1h}</option><option value="6h">{configCopy.range6h}</option><option value="24h">{configCopy.range24h}</option><option value="7d">{configCopy.range7d}</option></select></label><label htmlFor={fieldId('data-aggregation')}>{configCopy.aggregation}<select id={fieldId('data-aggregation')} value={text(data.aggregation, 'avg')} onChange={(event) => update('data', 'aggregation', event.target.value)}><option value="avg">{configCopy.aggregationAvg}</option><option value="min">{configCopy.aggregationMin}</option><option value="max">{configCopy.aggregationMax}</option><option value="sum">{configCopy.aggregationSum}</option><option value="last">{configCopy.aggregationLast}</option></select></label></div>{error('data.range')}{error('data.aggregation')}</>}
<label htmlFor={fieldId('data-limit')}>{configCopy.limit}<input id={fieldId('data-limit')} type="number" min={1} max={1000} step={1} value={number(data.limit, '100')} aria-invalid={Boolean(errors['data.limit'])} aria-describedby={errors['data.limit'] ? fieldId('data-limit') + '-error' : undefined} onChange={(event) => update('data', 'limit', Number(event.target.value))} /></label>
{error('data.limit')}
</div>
<div className="config-section">
<h3>{configCopy.visualization}</h3>
<div className="config-fields"><label htmlFor={fieldId('visualization-unit')}>{configCopy.unit}<input id={fieldId('visualization-unit')} value={text(visualization.unit)} maxLength={40} onChange={(event) => update('visualization', 'unit', event.target.value)} /></label><label htmlFor={fieldId('visualization-decimals')}>{configCopy.decimals}<input id={fieldId('visualization-decimals')} type="number" min={0} max={6} step={1} value={number(visualization.decimals, '0')} aria-invalid={Boolean(errors['visualization.decimals'])} onChange={(event) => update('visualization', 'decimals', Number(event.target.value))} /></label></div>{error('visualization.decimals')}
<div className="config-fields"><label htmlFor={fieldId('visualization-min')}>{configCopy.minimum}<input id={fieldId('visualization-min')} type="number" value={number(visualization.min)} onChange={(event) => update('visualization', 'min', event.target.value === '' ? null : Number(event.target.value))} /></label><label htmlFor={fieldId('visualization-max')}>{configCopy.maximum}<input id={fieldId('visualization-max')} type="number" value={number(visualization.max)} aria-invalid={Boolean(errors['visualization.max'])} onChange={(event) => update('visualization', 'max', event.target.value === '' ? null : Number(event.target.value))} /></label></div>{error('visualization.max')}
</div>
<div className="config-section">
<h3>{configCopy.layout}</h3>
<label htmlFor={fieldId('layout-width')}>{configCopy.width} ({viewportLabel})<input id={fieldId('layout-width')} type="number" min={1} max={maxWidth} step={1} value={String(width)} onChange={(event) => onWidthChange(Number(event.target.value))} /></label>
<p className="card-copy">{configCopy.widthHint}</p>
</div>
<div className="config-section">
<h3>{configCopy.behavior}</h3>
<div className="config-fields"><label htmlFor={fieldId('behavior-liveIntervalSeconds')}>{configCopy.refresh}<input id={fieldId('behavior-liveIntervalSeconds')} type="number" min={1} max={300} step={1} value={number(behavior.liveIntervalSeconds, '30')} aria-invalid={Boolean(errors['behavior.liveIntervalSeconds'])} onChange={(event) => update('behavior', 'liveIntervalSeconds', Number(event.target.value))} /></label><label className="checkbox-field" htmlFor={fieldId('behavior-hideWhenEmpty')}><input id={fieldId('behavior-hideWhenEmpty')} type="checkbox" checked={behavior.hideWhenEmpty === true} onChange={(event) => update('behavior', 'hideWhenEmpty', event.target.checked)} /> {configCopy.hideWhenEmpty}</label></div>{error('behavior.liveIntervalSeconds')}
</div>
<div className="config-preview">
<h3>{configCopy.preview}</h3>
<label htmlFor={fieldId('preview-state')}>{configCopy.previewState}<select id={fieldId('preview-state')} value={previewState} onChange={(event) => onPreviewStateChange(event.target.value)}><option value="loading">{configCopy.previewStateLoading}</option><option value="empty">{configCopy.previewStateEmpty}</option><option value="error">{configCopy.previewStateError}</option><option value="stale">{configCopy.previewStateStale}</option></select></label>
<button className="button button--secondary" type="button" disabled={invalid || previewing} onClick={onPreview}>{previewing ? configCopy.previewLoading : configCopy.previewRefresh}</button>
{previewError && <p className="field-error" role="alert">{previewError}</p>}
{preview && <div className={'preview-state preview-state--' + preview.state} role="status" aria-live="polite"><strong>{preview.message}</strong><span>{configCopy.limitsPrefix}{preview.limits?.maxSeries ?? 0}{configCopy.limitsSeries}{preview.limits?.maxPoints ?? 0}{configCopy.limitsPoints}{preview.limits?.appliedRows ?? 0}{configCopy.limitsRows}</span></div>}
</div>
</aside>;
}
+130
View File
@@ -0,0 +1,130 @@
// Frontend half of the OIDC sign-in flow.
//
// The backend exposes `GET /auth/login` (302 to the identity provider) and
// `GET /auth/callback` (302 back into the app). Sign-in is therefore a full
// document navigation, never a fetch: a redirect to a third-party identity
// provider cannot be followed by XHR.
export const LOGIN_PATH = '/auth/login';
// Query parameter used to hand the backend a relative in-app path to return to.
export const RETURN_PARAM = 'redirect';
// Query parameters the backend may set when it redirects back after a failed or
// cancelled authorization. `error` is the OAuth 2.0 / OIDC standard name.
export const ERROR_PARAMS = ['reason', 'error'] as const;
export type AuthNoticeKind = 'required' | 'expired' | 'failed' | 'cancelled';
export type AuthNotice = { kind: AuthNoticeKind; returnPath: string };
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
/**
* Reduces an arbitrary value to a safe, relative, in-app path. Anything that
* could leave the origin (absolute URLs, protocol-relative `//host`, backslash
* variants, control characters) collapses to the overview route.
*/
export function safeReturnPath(value: string | null | undefined): string {
if (typeof value !== 'string' || value === '') return '/';
const candidate = value.trim();
if (!candidate.startsWith('/')) return '/';
if (candidate.startsWith('//') || candidate.startsWith('/\\')) return '/';
if (CONTROL_CHARACTERS.test(candidate)) return '/';
return candidate;
}
/** The current in-app location, including query and hash, as a safe return path. */
export function currentReturnPath(): string {
if (typeof window === 'undefined') return '/';
return safeReturnPath(window.location.pathname + window.location.search + window.location.hash);
}
/** Absolute-path href for the backend login entry point. */
export function loginHref(returnPath: string = currentReturnPath()): string {
const target = safeReturnPath(returnPath);
return target === '/' ? LOGIN_PATH : LOGIN_PATH + '?' + RETURN_PARAM + '=' + encodeURIComponent(target);
}
/** Leaves the SPA and hands control to the backend login endpoint. */
export function startLogin(returnPath: string = currentReturnPath()): void {
if (typeof window === 'undefined') return;
window.location.assign(loginHref(returnPath));
}
function noticeKind(raw: string): AuthNoticeKind {
const value = raw.toLowerCase();
if (value === 'access_denied' || value === 'cancelled' || value === 'canceled' || value === 'user_cancelled') return 'cancelled';
return 'failed';
}
/**
* Reads a failed/cancelled-login marker left in the URL by `/auth/callback` and
* removes it again, so a refresh or a shared link does not resurrect the notice.
*/
export function consumeAuthNoticeFromUrl(): AuthNotice | null {
if (typeof window === 'undefined') return null;
const params = new URLSearchParams(window.location.search);
const present = ERROR_PARAMS.find((name) => (params.get(name) ?? '') !== '');
if (!present) return null;
const kind = noticeKind(params.get(present) ?? '');
ERROR_PARAMS.forEach((name) => params.delete(name));
const query = params.toString();
window.history.replaceState({}, '', window.location.pathname + (query ? '?' + query : '') + window.location.hash);
return { kind, returnPath: currentReturnPath() };
}
type SessionListener = (notice: AuthNotice) => void;
const sessionListeners = new Set<SessionListener>();
let sawAuthenticatedResponse = false;
let watcherInstalled = false;
let sessionRevoked = false;
const sessionController = new AbortController();
/** Subscribe to authentication failures observed on any API call. */
export function onUnauthenticated(listener: SessionListener): () => void {
sessionListeners.add(listener);
return () => { sessionListeners.delete(listener); };
}
function emitUnauthenticated(): void {
// A 401 before any successful API call is an unauthenticated first visit; a
// 401 after one is a session that expired while the user was working.
const notice: AuthNotice = { kind: sawAuthenticatedResponse ? 'expired' : 'required', returnPath: currentReturnPath() };
[...sessionListeners].forEach((listener) => listener(notice));
}
function isSameOriginAPIRequest(input: RequestInfo | URL): boolean {
if (typeof window === 'undefined') return false;
const raw = typeof input === 'string' || input instanceof URL ? String(input) : input.url;
const url = new URL(raw, window.location.href);
return url.origin === window.location.origin && (url.pathname === '/api' || url.pathname.startsWith('/api/'));
}
function revokeSession(): void {
if (sessionRevoked) return;
sessionRevoked = true;
sessionController.abort();
emitUnauthenticated();
}
export function apiRequestInit(input: RequestInfo | URL, init?: RequestInit): RequestInit | undefined {
if (!isSameOriginAPIRequest(input)) return init;
const signal = init?.signal ? AbortSignal.any([init.signal, sessionController.signal]) : sessionController.signal;
return { ...init, cache: 'no-store', signal };
}
/**
* Observes API responses in one place so that a 401 on any page can offer a
* sign-in affordance without every page having to know about authentication.
* Responses are passed through untouched; only the notification is added.
*/
export function installSessionWatcher(): void {
if (watcherInstalled || typeof window === 'undefined' || typeof window.fetch !== 'function') return;
watcherInstalled = true;
const original = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
if (!isSameOriginAPIRequest(input)) return original(input, init);
if (sessionRevoked) return new Response(null, { status: 401, statusText: 'Unauthorized' });
const response = await original(input, apiRequestInit(input, init));
if (response.status === 401) revokeSession();
else if (response.ok) sawAuthenticatedResponse = true;
return response;
};
}
File diff suppressed because one or more lines are too long
+24
View File
@@ -0,0 +1,24 @@
type DashboardVariable = { name?: unknown; default?: unknown };
// Resolve only exact `$name` references declared by the dashboard. The query
// planner remains the authority for accepted scope keys and values; unresolved
// references are preserved so they fail visibly instead of widening a query.
export function resolveDashboardScope(
rawScope: Record<string, unknown>,
variables: unknown[],
): Record<string, string> {
const defaults = new Map<string, string>();
for (const candidate of variables) {
if (!candidate || typeof candidate !== 'object') continue;
const variable = candidate as DashboardVariable;
if (typeof variable.name === 'string' && variable.name !== '' && typeof variable.default === 'string') {
defaults.set(variable.name, variable.default);
}
}
return Object.fromEntries(Object.entries(rawScope).flatMap(([key, value]) => {
if (typeof value !== 'string') return [];
const reference = /^\$([A-Za-z][A-Za-z0-9_-]{0,63})$/.exec(value);
if (!reference) return [[key, value]];
return [[key, defaults.get(reference[1]) ?? value]];
}));
}
+15
View File
@@ -0,0 +1,15 @@
export function queryValue(name: string, allowed?: readonly string[], fallback = ''): string {
if (typeof window === 'undefined') return fallback;
const value = new URLSearchParams(window.location.search).get(name) ?? fallback;
return !allowed || allowed.includes(value) ? value : fallback;
}
export function replaceListQuery(values: Record<string, string>): void {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
for (const [key, value] of Object.entries(values)) {
if (value) params.set(key, value); else params.delete(key);
}
const query = params.toString();
window.history.replaceState({}, '', window.location.pathname + (query ? `?${query}` : ''));
}
+168
View File
@@ -0,0 +1,168 @@
export type LiveFreshness = 'fresh' | 'delayed' | 'stale' | 'unavailable';
export type LiveSample = { series: string; timestamp: string; value: number | null; freshness: LiveFreshness; labels?: Record<string, string> };
export type BufferedPoint = { timestamp: number; value: number | null; freshness: LiveFreshness; labels?: Record<string, string> };
function clonePoint(point: BufferedPoint): BufferedPoint {
return { ...point, labels: point.labels ? { ...point.labels } : undefined };
}
export class SeriesRingBuffer {
private readonly values: Array<BufferedPoint | undefined>;
private head = 0;
private count = 0;
readonly capacity: number;
constructor(capacity: number) {
this.capacity = capacity;
if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError('Ring buffer capacity must be a positive integer.');
this.values = new Array<BufferedPoint | undefined>(capacity);
}
get length(): number { return this.count; }
append(point: BufferedPoint): void {
const index = this.count < this.capacity ? (this.head + this.count) % this.capacity : this.head;
this.values[index] = clonePoint(point);
if (this.count < this.capacity) this.count += 1;
else this.head = (this.head + 1) % this.capacity;
}
appendMany(points: readonly BufferedPoint[]): void { points.forEach((point) => this.append(point)); }
snapshot(): BufferedPoint[] {
const result: BufferedPoint[] = [];
for (let index = 0; index < this.count; index += 1) {
const point = this.values[(this.head + index) % this.capacity];
if (point) result.push(clonePoint(point));
}
return result;
}
clear(): void {
this.values.fill(undefined);
this.head = 0;
this.count = 0;
}
}
export function historicalSamplesFromData(data: unknown): LiveSample[] {
if (!data || typeof data !== 'object' || !Array.isArray((data as { result?: unknown[] }).result)) return [];
const result: LiveSample[] = [];
((data as { result: unknown[] }).result).forEach((item) => {
if (!item || typeof item !== 'object') return;
const record = item as { metric?: Record<string, string>; values?: unknown[] };
const labels = record.metric ?? {};
const series = labels.__name__ || JSON.stringify(Object.fromEntries(Object.entries(labels).sort(([a], [b]) => a.localeCompare(b))));
if (!series || !Array.isArray(record.values)) return;
record.values.forEach((raw) => {
if (!Array.isArray(raw) || raw.length < 2) return;
const seconds = Number(raw[0]);
const value = Number(raw[1]);
if (!Number.isFinite(seconds)) return;
result.push({ series, timestamp: new Date(seconds * 1000).toISOString(), value: Number.isFinite(value) ? value : null, freshness: 'fresh', labels });
});
});
return result;
}
/** Series that stopped reporting for this long are dropped by `evictStale`. */
export const DEFAULT_SERIES_TTL_MS = 900_000;
/** Hard ceiling on distinct series keys; the least recently updated is dropped first. */
export const DEFAULT_MAX_SERIES = 64;
export class LiveSeriesStore {
private readonly series = new Map<string, SeriesRingBuffer>();
private readonly lastSeen = new Map<string, number>();
readonly capacity: number;
readonly maxSeries: number;
constructor(capacity = 240, maxSeries = DEFAULT_MAX_SERIES) {
this.capacity = capacity;
this.maxSeries = maxSeries;
if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError('Series capacity must be a positive integer.');
if (!Number.isInteger(maxSeries) || maxSeries < 1) throw new RangeError('Series count limit must be a positive integer.');
}
get seriesCount(): number { return this.series.size; }
append(samples: readonly LiveSample[]): void {
samples.forEach((sample) => {
const timestamp = Date.parse(sample.timestamp);
if (!sample.series || Number.isNaN(timestamp)) return;
let buffer = this.series.get(sample.series);
if (!buffer) {
buffer = new SeriesRingBuffer(this.capacity);
this.series.set(sample.series, buffer);
}
buffer.append({ timestamp, value: sample.value, freshness: sample.freshness, labels: sample.labels });
this.lastSeen.set(sample.series, timestamp);
});
this.enforceSeriesLimit();
}
/**
* Drops series whose most recent point is older than `ttlMs`. Only the ring
* buffers were bounded before, so a long-running wallboard accumulated map
* keys for every series name it ever saw.
*/
evictStale(ttlMs = DEFAULT_SERIES_TTL_MS, now = Date.now()): number {
let removed = 0;
[...this.lastSeen.entries()].forEach(([key, seen]) => {
if (now - seen <= ttlMs) return;
this.series.delete(key);
this.lastSeen.delete(key);
removed += 1;
});
return removed;
}
private enforceSeriesLimit(): void {
if (this.series.size <= this.maxSeries) return;
const ordered = [...this.lastSeen.entries()].sort((a, b) => a[1] - b[1]);
for (const [key] of ordered) {
if (this.series.size <= this.maxSeries) break;
this.series.delete(key);
this.lastSeen.delete(key);
}
}
snapshot(): Record<string, BufferedPoint[]> {
const result: Record<string, BufferedPoint[]> = {};
this.series.forEach((buffer, key) => { result[key] = buffer.snapshot(); });
return result;
}
pointCount(): number {
let count = 0;
this.series.forEach((buffer) => { count += buffer.length; });
return count;
}
clear(): void { this.series.clear(); this.lastSeen.clear(); }
}
export interface ChartSeries {
key: string;
points: BufferedPoint[];
}
export class LiveChartAdapter {
private readonly store: LiveSeriesStore;
constructor(capacity = 240, maxSeries = DEFAULT_MAX_SERIES) { this.store = new LiveSeriesStore(capacity, maxSeries); }
append(samples: readonly LiveSample[]): void { this.store.append(samples); }
/** Drops series that stopped reporting; see `LiveSeriesStore.evictStale`. */
evictStale(ttlMs = DEFAULT_SERIES_TTL_MS, now = Date.now()): number { return this.store.evictStale(ttlMs, now); }
get seriesCount(): number { return this.store.seriesCount; }
snapshot(): ChartSeries[] {
return Object.entries(this.store.snapshot()).map(([key, points]) => ({ key, points }));
}
get pointCount(): number { return this.store.pointCount(); }
clear(): void { this.store.clear(); }
}
+308
View File
@@ -0,0 +1,308 @@
import type { MetricQueryRequest } from './metricClient';
import type { LiveSample } from './liveBuffer';
export type LiveStatus = 'subscribed' | 'resync-required' | 'paused' | 'unsubscribed';
export type LiveEvent =
| { type: 'samples'; subscriptionId: string; sequence: number; samples: LiveSample[] }
| { type: 'status'; subscriptionId: string; state: LiveStatus; detail?: string }
| { type: 'error'; subscriptionId?: string; code: string; message: string };
type SocketLike = {
readyState: number;
onopen: (() => void) | null;
onmessage: ((event: { data: unknown }) => void) | null;
onerror: (() => void) | null;
onclose: (() => void) | null;
send: (payload: string) => void;
close: () => void;
};
export type SocketFactory = (url: string) => SocketLike;
export type LiveListener = (event: LiveEvent) => void;
export type LiveSubscription = { key: string; unsubscribe: () => void };
const defaultOpenTimeoutMs = 5000;
const maxReconnectDelayMs = 10000;
const subscriptionReleaseGraceMs = 250;
const socketIdleCloseGraceMs = 10000;
const heartbeatIntervalMs = 30000;
export class LiveClientError extends Error {
readonly code: string;
constructor(code: string, message: string) { super(message); this.code = code; }
}
type SharedSubscription = {
key: string;
id: string;
request: MetricQueryRequest;
listeners: Set<LiveListener>;
releaseTimer: ReturnType<typeof setTimeout> | null;
sent: boolean;
lastSequence: number;
};
function sortedValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortedValue);
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, sortedValue(item)]));
}
return value;
}
export function liveQueryKey(request: MetricQueryRequest): string {
// `from` and `to` seed the historical query only. Once subscribed, the live
// sampler continuously evaluates the semantic metric at `stepSeconds`;
// rotating to a dashboard with the same metric must therefore share the
// existing stream instead of opening a new socket for a newer seed window.
const { range, ...semantic } = request;
return JSON.stringify(sortedValue({ ...semantic, range: { stepSeconds: range.stepSeconds } }));
}
function defaultSocketFactory(url: string): SocketLike {
const parsed = new URL(url, window.location.href);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
return new WebSocket(parsed.toString()) as unknown as SocketLike;
}
export class LiveClient {
private readonly url: string;
private readonly factory: SocketFactory;
private socket: SocketLike | null = null;
private opening: Promise<void> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private idleCloseTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private reconnectAttempt = 0;
private heartbeatSequence = 0;
private sequence = 0;
private hidden = false;
private readonly subscriptions = new Map<string, SharedSubscription>();
private readonly byId = new Map<string, SharedSubscription>();
private readonly visibilityHandler: (() => void) | null;
constructor(url = '/api/v1/live', factory: SocketFactory = defaultSocketFactory) {
this.url = url;
this.factory = factory;
if (typeof document !== 'undefined') {
this.hidden = document.visibilityState === 'hidden';
this.visibilityHandler = () => {
this.hidden = document.visibilityState === 'hidden';
this.resubscribeAll();
};
document.addEventListener('visibilitychange', this.visibilityHandler);
} else {
this.visibilityHandler = null;
}
}
subscribe(request: MetricQueryRequest, listener: LiveListener): LiveSubscription {
this.cancelIdleClose();
const key = liveQueryKey(request);
let shared = this.subscriptions.get(key);
if (!shared) {
shared = { key, id: 'browser-' + (++this.sequence), request, listeners: new Set(), releaseTimer: null, sent: false, lastSequence: 0 };
this.subscriptions.set(key, shared);
this.byId.set(shared.id, shared);
}
if (shared.releaseTimer) clearTimeout(shared.releaseTimer);
shared.releaseTimer = null;
shared.listeners.add(listener);
let active = true;
void this.ensureOpen().then(() => {
if (active && this.subscriptions.get(key) === shared) this.sendSubscribe(shared);
}).catch((error: unknown) => {
this.notify(shared as SharedSubscription, { type: 'error', subscriptionId: shared?.id, code: error instanceof LiveClientError ? error.code : 'LIVE_CONNECTION_UNAVAILABLE', message: error instanceof Error ? error.message : 'Live verbinding is niet beschikbaar.' });
this.scheduleReconnect();
});
return { key, unsubscribe: () => {
if (!active) return;
active = false;
this.removeListener(shared as SharedSubscription, listener);
}};
}
/**
* Drops shared subscriptions that no longer have listeners. `removeListener`
* already does this on the happy path; this is the safety net for a component
* tree that unmounts without a matching unsubscribe (dashboard rotation on a
* wallboard, a widget that threw), so neither map grows for the lifetime of
* the process.
*/
releaseUnused(): void {
[...this.subscriptions.values()].forEach((shared) => {
if (shared.listeners.size > 0) return;
this.scheduleRelease(shared);
});
}
close(): void {
this.stopHeartbeat();
this.cancelIdleClose();
this.subscriptions.forEach((shared) => {
if (shared.releaseTimer) clearTimeout(shared.releaseTimer);
shared.releaseTimer = null;
});
this.subscriptions.clear();
this.byId.clear();
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
this.reconnectAttempt = 0;
const socket = this.socket;
this.socket = null;
this.opening = null;
socket?.close();
}
private removeListener(shared: SharedSubscription, listener: LiveListener): void {
shared.listeners.delete(listener);
if (shared.listeners.size > 0) return;
this.scheduleRelease(shared);
}
private scheduleRelease(shared: SharedSubscription): void {
if (shared.releaseTimer) return;
shared.releaseTimer = setTimeout(() => {
shared.releaseTimer = null;
if (shared.listeners.size > 0 || this.subscriptions.get(shared.key) !== shared) return;
this.subscriptions.delete(shared.key);
this.byId.delete(shared.id);
if (shared.sent && this.socket?.readyState === 1) {
this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'unsubscribe', subscriptionId: shared.id }));
}
if (this.subscriptions.size === 0 && this.socket) this.scheduleIdleClose();
}, subscriptionReleaseGraceMs);
}
private scheduleIdleClose(): void {
if (this.idleCloseTimer || !this.socket) return;
this.idleCloseTimer = setTimeout(() => {
this.idleCloseTimer = null;
if (this.subscriptions.size === 0) this.close();
}, socketIdleCloseGraceMs);
}
private cancelIdleClose(): void {
if (this.idleCloseTimer) clearTimeout(this.idleCloseTimer);
this.idleCloseTimer = null;
}
private ensureOpen(): Promise<void> {
this.cancelIdleClose();
if (this.socket?.readyState === 1) return Promise.resolve();
if (this.opening) return this.opening;
const socket = this.factory(this.url);
this.socket = socket;
this.opening = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
socket.close();
reject(new LiveClientError('LIVE_CONNECTION_TIMEOUT', 'Live verbinding reageert niet.'));
}, defaultOpenTimeoutMs);
socket.onopen = () => {
clearTimeout(timer);
this.reconnectAttempt = 0;
this.startHeartbeat();
resolve();
this.byId.forEach((shared) => this.sendSubscribe(shared));
};
socket.onerror = () => { clearTimeout(timer); reject(new LiveClientError('LIVE_CONNECTION_UNAVAILABLE', 'Live verbinding is niet beschikbaar.')); };
socket.onclose = () => {
clearTimeout(timer);
this.stopHeartbeat();
if (this.socket === socket) this.socket = null;
this.byId.forEach((shared) => {
shared.sent = false;
shared.lastSequence = 0;
this.notify(shared, { type: 'status', subscriptionId: shared.id, state: 'resync-required', detail: 'Live verbinding wordt hersteld.' });
});
if (this.subscriptions.size > 0) this.scheduleReconnect();
};
socket.onmessage = (event) => this.handleMessage(event.data);
}).finally(() => {
if (this.opening) this.opening = null;
});
return this.opening;
}
private scheduleReconnect(): void {
if (this.reconnectTimer || this.subscriptions.size === 0) return;
const delay = Math.min(maxReconnectDelayMs, 1000 * (2 ** this.reconnectAttempt));
this.reconnectAttempt += 1;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.ensureOpen().catch(() => this.scheduleReconnect());
}, delay);
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.socket?.readyState !== 1 || this.subscriptions.size === 0) return;
this.heartbeatSequence += 1;
this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'ping', nonce: 'browser-' + this.heartbeatSequence }));
}, heartbeatIntervalMs);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
private effectiveInterval(shared: SharedSubscription): number {
const requested = Math.max(1, Math.min(300, Math.round(shared.request.range.stepSeconds)));
return this.hidden ? Math.min(300, Math.max(requested, requested * 5)) : requested;
}
private sendSubscribe(shared: SharedSubscription): void {
if (shared.sent || this.socket?.readyState !== 1) return;
shared.sent = true;
this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'subscribe', subscriptionId: shared.id, query: shared.request, intervalSeconds: this.effectiveInterval(shared) }));
}
private resubscribeAll(): void {
if (this.socket?.readyState !== 1) return;
this.byId.forEach((shared) => {
if (shared.sent) {
this.socket?.send(JSON.stringify({ schemaVersion: 1, type: 'unsubscribe', subscriptionId: shared.id }));
shared.sent = false;
shared.lastSequence = 0;
}
this.sendSubscribe(shared);
});
}
private requestResync(shared: SharedSubscription): void {
this.notify(shared, { type: 'status', subscriptionId: shared.id, state: 'resync-required', detail: 'Live data bevat een gat; synchronisatie wordt herhaald.' });
if (this.socket?.readyState === 1 && shared.sent) {
this.socket.send(JSON.stringify({ schemaVersion: 1, type: 'unsubscribe', subscriptionId: shared.id }));
shared.sent = false;
}
shared.lastSequence = 0;
this.sendSubscribe(shared);
}
private handleMessage(data: unknown): void {
if (typeof data !== 'string') return;
let message: Record<string, unknown>;
try { message = JSON.parse(data) as Record<string, unknown>; } catch { return; }
const id = typeof message.subscriptionId === 'string' ? message.subscriptionId : undefined;
const shared = id ? this.byId.get(id) : undefined;
if (message.type === 'samples' && shared && Array.isArray(message.samples) && typeof message.sequence === 'number') {
if (message.sequence <= shared.lastSequence) return;
if (shared.lastSequence > 0 && message.sequence > shared.lastSequence + 1) {
this.requestResync(shared);
return;
}
shared.lastSequence = message.sequence;
this.notify(shared, { type: 'samples', subscriptionId: shared.id, sequence: message.sequence, samples: message.samples as LiveSample[] });
} else if (message.type === 'status' && shared && typeof message.state === 'string') {
this.notify(shared, { type: 'status', subscriptionId: shared.id, state: message.state as LiveStatus, detail: typeof message.detail === 'string' ? message.detail : undefined });
} else if (message.type === 'error' && shared) {
this.notify(shared, { type: 'error', subscriptionId: shared.id, code: String(message.code ?? 'LIVE_ERROR'), message: String(message.message ?? 'Live fout.') });
}
}
private notify(shared: SharedSubscription, event: LiveEvent): void {
[...shared.listeners].forEach((listener) => listener(event));
}
}
+33
View File
@@ -0,0 +1,33 @@
export const UI_LOCALE = 'nl-BE';
export const UI_TIME_ZONE = 'Europe/Brussels';
const numberFormatter = new Intl.NumberFormat(UI_LOCALE);
const decimalFormatter = new Intl.NumberFormat(UI_LOCALE, { maximumFractionDigits: 1 });
const dateTimeFormatter = new Intl.DateTimeFormat(UI_LOCALE, { dateStyle: 'medium', timeStyle: 'short', timeZone: UI_TIME_ZONE });
export const NEVER_RECEIVED = 'Nooit ontvangen';
export function formatNumber(value: number, maximumFractionDigits?: number): string {
if (!Number.isFinite(value)) return '—';
return (maximumFractionDigits === undefined ? numberFormatter : new Intl.NumberFormat(UI_LOCALE, { maximumFractionDigits })).format(value);
}
export function formatDecimal(value: number): string {
return Number.isFinite(value) ? decimalFormatter.format(value) : '—';
}
export function formatDateTime(value?: string): string {
if (!hasReceivedTimestamp(value)) return NEVER_RECEIVED;
const date = new Date(value);
return dateTimeFormatter.format(date);
}
/** Rejects transport zero-values and invalid input before they reach a visible <time>. */
export function hasReceivedTimestamp(value?: string): value is string {
if (!value?.trim()) return false;
const date = new Date(value);
return Number.isFinite(date.valueOf()) && date.valueOf() > 0 && date.getUTCFullYear() > 1;
}
export function formatPercent(value?: number, maximumFractionDigits = 1): string {
return value === undefined || !Number.isFinite(value) ? '—' : new Intl.NumberFormat(UI_LOCALE, { maximumFractionDigits, style: 'percent' }).format(value / 100);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+40
View File
@@ -0,0 +1,40 @@
import { UI_LOCALE, UI_TIME_ZONE } from './locale';
export type MetricFreshness = 'fresh' | 'delayed' | 'stale' | 'unavailable';
export type MetricRangePreset = 'live' | '15m' | '1h' | '6h' | '24h' | '7d';
export type MetricRange = { from: string; to: string; stepSeconds: number };
export type MetricQueryRequest = { metric: string; scope?: Record<string, string>; range: MetricRange; aggregation?: string; groupBy?: string[]; maxSeries?: number; maxPoints?: number };
export type MetricInspector = { semanticMetric: string; generatedQuery: string; cost: { series: number; points: number; estimatedSamples: number }; limits: { maxSeries: number; maxPoints: number } };
export type MetricQueryResponse = { status: string; data: unknown; warnings?: string[]; provenance: { source: string; metric: string; catalogVersion: string; cacheKey: string }; sourceObservedAt: string; receivedAt: string; freshness: MetricFreshness; cacheHit: boolean; inspector?: MetricInspector };
export type MetricProblem = { code: string; detail: string; fields?: Record<string, string> };
export class MetricApiError extends Error {
constructor(readonly status: number, readonly problem?: MetricProblem) { super(problem?.detail ?? ((status >= 500 || status === 404 || status === 0) ? 'De metricbron is tijdelijk niet beschikbaar.' : 'Metricquery mislukt.')); }
get actionable(): string { if (this.status >= 500 || this.status === 404) return 'De metricbron is tijdelijk niet beschikbaar.'; if (this.problem?.code === 'QUERY_POINT_LIMIT' || this.problem?.code === 'QUERY_SERIES_LIMIT' || this.problem?.code === 'QUERY_COST_LIMIT') return 'Verklein de periode of beperk het aantal reeksen.'; return this.message; }
}
export class MetricClient {
// Resolve the ambient fetch at request time. Runtime widget modules are
// evaluated before App installs the shared session watcher; capturing the
// native function here would bypass that wrapper and can also invoke an
// unbound browser fetch implementation.
constructor(private readonly fetcher: typeof fetch = (input, init) => fetch(input, init)) {}
async queryRange(request: MetricQueryRequest, signal?: AbortSignal): Promise<MetricQueryResponse> {
const response = await this.fetcher('/api/v1/metrics/query-range', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), signal });
if (!response.ok) {
let problem: MetricProblem | undefined;
try { problem = await response.json() as MetricProblem; } catch { /* safe fallback below */ }
throw new MetricApiError(response.status, problem);
}
return await response.json() as MetricQueryResponse;
}
}
export function rangeForPreset(preset: MetricRangePreset, now = new Date()): MetricRange {
const seconds: Record<MetricRangePreset, number> = { live: 300, '15m': 900, '1h': 3600, '6h': 21600, '24h': 86400, '7d': 604800 };
const to = new Date(now.getTime());
const from = new Date(to.getTime() - seconds[preset] * 1000);
const stepSeconds = preset === 'live' ? 15 : Math.max(15, Math.ceil(seconds[preset] / 4000));
return { from: from.toISOString(), to: to.toISOString(), stepSeconds };
}
export function displayTime(iso: string, locale = UI_LOCALE): string { const date = new Date(iso); return Number.isNaN(date.getTime()) ? 'Onbekende tijd' : new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeStyle: 'short', timeZone: UI_TIME_ZONE }).format(date); }
+57
View File
@@ -0,0 +1,57 @@
export type SignalTone = 'healthy' | 'attention' | 'critical' | 'stale' | 'unknown';
export type SignalSource = {
state?: string;
freshness?: string;
};
export type SignalContainer = {
state?: string;
health?: string;
intentionalStop?: boolean;
};
export const signalToneRank: Record<SignalTone, number> = {
critical: 0,
attention: 1,
stale: 2,
unknown: 3,
healthy: 4,
};
export function signalToneFromState(state: string | undefined): SignalTone {
const normalized = state?.trim().toLowerCase();
if (normalized === 'critical' || normalized === 'faulted' || normalized === 'down' || normalized === 'failed' || normalized === 'error') return 'critical';
if (normalized === 'attention' || normalized === 'degraded' || normalized === 'warning' || normalized === 'restarting' || normalized === 'paused' || normalized === 'removing') return 'attention';
if (normalized === 'stale') return 'stale';
if (normalized === 'healthy' || normalized === 'normal' || normalized === 'up' || normalized === 'running' || normalized === 'fresh') return 'healthy';
return 'unknown';
}
export function worstSignalTone(tones: SignalTone[]): SignalTone {
if (tones.length === 0) return 'unknown';
return tones.reduce<SignalTone>((worst, tone) => signalToneRank[tone] < signalToneRank[worst] ? tone : worst, 'healthy');
}
/** A successful HTTP response is not healthy when its provenance is stale or unknown. */
export function sourceSignalTone(source: SignalSource | undefined): SignalTone {
if (!source) return 'unknown';
const freshness = source.freshness?.trim().toLowerCase();
if (freshness === 'stale') return 'stale';
if (freshness !== 'fresh') return 'unknown';
return signalToneFromState(source.state);
}
/** Mirrors the backend application-state projection without treating an intentional stop as a failure. */
export function containerSignalTone(container: SignalContainer): SignalTone {
const state = container.state?.trim().toLowerCase();
const health = container.health?.trim().toLowerCase();
if (state === 'running') {
if (health === 'healthy') return 'healthy';
if (health === 'unhealthy') return 'attention';
return 'unknown';
}
if (state === 'restarting' || state === 'paused' || state === 'removing') return 'attention';
if (state === 'exited' || state === 'dead' || state === 'stopped') return container.intentionalStop ? 'unknown' : 'critical';
return 'unknown';
}
+181
View File
@@ -0,0 +1,181 @@
import { copy } from './copy';
const statusLabels: Record<string, string> = {
healthy: copy.presentation.status.healthy,
operational: copy.presentation.status.operational,
available: copy.presentation.status.available,
running: copy.presentation.status.running,
paused: copy.presentation.status.paused,
starting: copy.presentation.status.starting,
exited: copy.presentation.status.stopped,
stopped: copy.presentation.status.stopped,
restarting: copy.presentation.status.restarting,
down: copy.presentation.status.down,
up: copy.presentation.status.up,
online: copy.presentation.status.online,
offline: copy.presentation.status.offline,
missing: copy.presentation.status.missing,
faulted: copy.presentation.status.faulted,
sleeping: copy.presentation.status.sleeping,
idle: copy.presentation.status.idle,
completed: copy.presentation.status.completed,
failed: copy.presentation.status.failed,
cached: copy.presentation.status.cached,
passed: 'Geslaagd',
unsupported: 'Niet ondersteund',
valid: 'Geldig',
invalid: 'Ongeldig',
unhealthy: copy.presentation.status.unhealthy,
attention: copy.presentation.status.attention,
degraded: copy.presentation.status.degraded,
warning: copy.presentation.status.warning,
info: copy.presentation.status.info,
error: copy.presentation.status.error,
critical: copy.presentation.status.critical,
unknown: copy.presentation.status.unknown,
unavailable: copy.presentation.status.unavailable,
disabled: copy.presentation.status.disabled,
normal: copy.presentation.status.healthy,
stale: copy.presentation.status.stale,
fresh: copy.presentation.status.fresh,
pending: copy.presentation.status.pending,
firing: copy.presentation.status.firing,
acknowledged: copy.presentation.status.acknowledged,
resolved: copy.presentation.status.resolved,
silenced: copy.presentation.status.silenced,
suppressed: copy.presentation.status.suppressed,
maintenance: copy.presentation.status.maintenance,
active: copy.presentation.status.active,
scheduled: copy.presentation.status.scheduled,
expired: copy.presentation.status.expired,
revoked: copy.presentation.status.revoked,
};
const reasonLabels: Record<string, string> = {
source_health_unknown: copy.presentation.reason.sourceHealthUnknown,
source_unavailable: copy.presentation.reason.sourceUnavailable,
not_configured: copy.presentation.reason.notConfigured,
last_run_failed: copy.presentation.reason.lastRunFailed,
last_run_succeeded: copy.presentation.reason.lastRunSucceeded,
no_recent_sample: copy.presentation.reason.noRecentSample,
source_stale: copy.presentation.reason.stale,
stale_probe: copy.presentation.reason.stale,
stale: copy.presentation.reason.stale,
stale_source: copy.presentation.reason.stale,
filesystem_root_not_configured: copy.presentation.reason.filesystemRootNotConfigured,
unavailable: copy.presentation.reason.sourceUnavailable,
insufficient_history: copy.presentation.reason.insufficientHistory,
host_unreachable: copy.presentation.reason.hostUnreachable,
dns_failure: copy.presentation.reason.dnsFailure,
source_unavailable_dependency: copy.presentation.reason.sourceUnavailable,
backup_verified: copy.presentation.reason.backupVerified,
backup_stale: copy.presentation.reason.backupStale,
backup_verification_failed: copy.presentation.reason.backupVerificationFailed,
authenticated_session: copy.presentation.reason.authenticatedSession,
authenticated_session_not_observed: copy.presentation.reason.authenticatedSessionNotObserved,
database_ready: copy.presentation.reason.databaseReady,
source_sampled: copy.presentation.reason.sourceSampled,
provider_health_not_sampled: copy.presentation.reason.providerNotSampled,
source_health_not_sampled: copy.presentation.reason.sourceNotSampled,
source_not_configured: copy.presentation.reason.notConfigured,
delivery_health_not_sampled: copy.presentation.reason.notificationsNotSampled,
probe_heartbeat_not_recorded: copy.presentation.reason.probesNotRecorded,
heartbeat_not_recorded: copy.presentation.reason.workerNotRecorded,
no_verified_backup: copy.presentation.reason.noVerifiedBackup,
none: copy.presentation.reason.noDetail,
};
const componentLabels: Record<string, string> = {
api: 'API', database: copy.presentation.component.database, worker: copy.presentation.component.worker,
prometheus: 'Prometheus', query: copy.presentation.component.query, unraid: 'Unraid',
storage: copy.presentation.component.storage, backup: copy.presentation.component.backup,
notifications: copy.presentation.component.notifications, oidc: copy.presentation.component.oidc,
probes: copy.presentation.component.probes,
};
const metricLabels: Record<string, string> = {
'host.cpu.utilization': copy.presentation.metric.hostCpu,
'host.memory.utilization': copy.presentation.metric.hostMemory,
'container.cpu.utilization': copy.presentation.metric.containerCpu,
'container.memory.used': copy.presentation.metric.containerMemory,
'storage.disk.temperature': copy.presentation.metric.diskTemperature,
'storage.disk.temperature.maximum': copy.presentation.metric.maximumDiskTemperature,
'storage.pool.utilization': copy.presentation.metric.poolUtilization,
'service.response_time': copy.presentation.metric.serviceResponseTime,
'service.availability': copy.presentation.metric.serviceAvailability,
'service.availability.minimum': copy.presentation.metric.minimumServiceAvailability,
};
export function presentStatus(value?: string): string {
const normalized = value?.trim().toLowerCase() ?? '';
return statusLabels[normalized] ?? copy.presentation.status.unknown;
}
const operationalRank: Record<string, number> = { healthy: 0, normal: 0, attention: 1, degraded: 2, unknown: 3, faulted: 4, critical: 4 };
/** Keeps device health and capacity separate while making the worst signal primary. */
export function operationalStorageState(deviceState?: string, capacityState?: string): string {
const device = deviceState?.trim().toLowerCase() || 'unknown';
const capacity = capacityState?.trim().toLowerCase() || 'unknown';
return (operationalRank[capacity] ?? operationalRank.unknown) > (operationalRank[device] ?? operationalRank.unknown) ? capacity : device;
}
export function presentReason(value?: string): string {
const normalized = value?.trim().toLowerCase() ?? '';
if (!normalized) return copy.presentation.reason.noDetail;
if (reasonLabels[normalized]) return reasonLabels[normalized];
if (normalized.startsWith('container_')) return `${copy.presentation.reason.containerPrefix} ${presentStatus(normalized.slice(10)).toLowerCase()}.`;
if (normalized.startsWith('service_')) return `${copy.presentation.reason.servicePrefix} ${presentStatus(normalized.slice(8)).toLowerCase()}.`;
if (/^[a-z0-9]+(?:[._-][a-z0-9]+)+$/.test(normalized)) return copy.presentation.reason.technical;
return value?.trim() || copy.presentation.reason.noDetail;
}
export function presentComponent(value: string): string {
return componentLabels[value.trim().toLowerCase()] ?? copy.presentation.component.other;
}
export function presentMetric(value: string): string {
return metricLabels[value] ?? copy.presentation.metric.other;
}
const entityLabels: Record<string, string> = { host: 'Host', container: 'Container', 'container-service': 'Compose-service', 'container-instance': 'Containerinstantie', application: 'Applicatie', 'application-project': 'Compose-project', 'application-instance': 'Zelfstandige applicatie', service: 'Service', probe: 'Servicecontrole', disk: 'Schijf', pool: 'Pool', share: 'Share', array: 'Array', process: 'Proces', network: 'Netwerk' };
export function presentEntityType(value?: string): string { return entityLabels[value?.trim().toLowerCase() ?? ''] ?? 'Onderdeel'; }
const inventoryFieldLabels: Record<string, string> = { runtimeState: 'Runtime-status', health: 'Gezondheid', restartCount: 'Herstarts', intentionalStop: 'Bewust gestopt', metricsAvailable: 'Metrics beschikbaar', lifecycleAvailable: 'Lifecycle beschikbaar', image: 'Image', project: 'Compose-project', composeService: 'Compose-service', groupingMode: 'Groepering', componentCount: 'Componenten' };
export function presentInventoryField(value: string): string { return inventoryFieldLabels[value] ?? 'Bronkenmerk'; }
const relationLabels: Record<string, string> = { depends_on: 'is afhankelijk van', depends: 'is afhankelijk van', backs: 'ondersteunt', exposes: 'biedt aan', contains: 'bevat', member_of: 'is lid van', runs_on: 'draait op', connected_to: 'is verbonden met' };
export function presentRelationType(value?: string): string { return relationLabels[value?.trim().toLowerCase() ?? ''] ?? 'heeft een relatie met'; }
const arrayRoleLabels: Record<string, string> = { data: 'Gegevensschijf', parity: 'Pariteit', cache: 'Cache', member: 'Lid' };
export function presentArrayRole(value?: string): string { return arrayRoleLabels[value?.trim().toLowerCase() ?? ''] ?? 'Schijf'; }
const storagePolicyLabels: Record<string, string> = { highwater: 'Hoogwater', 'high-water': 'Hoogwater', fillup: 'Opvullen', 'fill-up': 'Opvullen', mostfree: 'Meeste vrije ruimte', 'most-free': 'Meeste vrije ruimte', yes: 'Voorkeur voor cache', no: 'Alleen primaire opslag', prefer: 'Cache heeft voorkeur', only: 'Alleen cache' };
export function presentStoragePolicy(value?: string, fallback = 'Onbekend beleid'): string { return storagePolicyLabels[value?.trim().toLowerCase() ?? ''] ?? fallback; }
const eventLabels: Record<string, string> = {
'container.state_changed': 'Containerstatus gewijzigd', 'container.health_changed': 'Containergezondheid gewijzigd', 'container.restart': 'Container herstart', 'container.intentional_stop_changed': 'Bewuste stop gewijzigd',
'array.degraded': 'Array vraagt aandacht', 'array.missing': 'Arraylid ontbreekt', 'array.recovered': 'Array hersteld', 'array.parity_changed': 'Paritystatus gewijzigd',
'pool.degraded': 'Pool vraagt aandacht', 'pool.faulted': 'Pool defect', 'pool.recovered': 'Pool hersteld', 'pool.scrub_failed': 'Poolscrub mislukt',
};
export function presentEventType(value?: string): string { return eventLabels[value?.trim().toLowerCase() ?? ''] ?? 'Operationele wijziging'; }
const eventSummaries: Record<string, string> = {
'container.state_changed': 'De runtime-status van de container is gewijzigd.', 'container.health_changed': 'De gerapporteerde containergezondheid is gewijzigd.', 'container.restart': 'De container is opnieuw gestart.', 'container.intentional_stop_changed': 'De markering voor een bewuste stop is gewijzigd.',
'array.degraded': 'De array meldt een toestand die aandacht vereist.', 'array.missing': 'De array meldt een ontbrekend lid.', 'array.recovered': 'De array is terug operationeel.', 'array.parity_changed': 'De paritystatus of het aantal parityfouten is gewijzigd.',
'pool.degraded': 'De pool meldt een toestand die aandacht vereist.', 'pool.faulted': 'De pool meldt een defecte toestand.', 'pool.recovered': 'De pool is hersteld.', 'pool.scrub_failed': 'De laatste poolscrub is mislukt.',
};
export function presentEventSummary(type?: string, _summary?: string): string { return eventSummaries[type?.trim().toLowerCase() ?? ''] ?? 'Een bron heeft een operationele wijziging gemeld.'; }
export function presentUnit(value: string): string {
if (value === 'percent') return '%';
if (value === 'bytes') return copy.presentation.unit.bytes;
if (value === 'celsius') return '°C';
if (value === 'seconds') return copy.presentation.unit.seconds;
if (value === 'ratio') return copy.presentation.unit.ratio;
return copy.presentation.unit.value;
}
export function plural(count: number, singular: string, pluralForm: string): string {
return count === 1 ? singular : pluralForm;
}
+15
View File
@@ -0,0 +1,15 @@
export type RoutePath = string;
export const routeFromLocation = (pathname: string): RoutePath => {
if (pathname.startsWith('/dashboards/') && pathname.length > '/dashboards/'.length) return pathname;
if (pathname.startsWith('/containers/') && pathname.length > '/containers/'.length) return pathname;
if (pathname.startsWith('/services/') && pathname.length > '/services/'.length) return pathname;
if (pathname.startsWith('/disks/') && pathname.length > '/disks/'.length) return pathname;
if (pathname.startsWith('/pools/') && pathname.length > '/pools/'.length) return pathname;
if (pathname.startsWith('/shares/') && pathname.length > '/shares/'.length) return pathname;
if (pathname.startsWith('/applications/') && pathname.length > '/applications/'.length) return pathname;
if (pathname.startsWith('/incidents/') && pathname.length > '/incidents/'.length) return pathname;
if (pathname.startsWith('/inventory/') && pathname.length > '/inventory/'.length) return pathname;
const supported = ['/', '/topology', '/network', '/host', '/array', '/disks', '/pools', '/shares', '/storage', '/capacity', '/processes', '/containers', '/services', '/applications', '/inventory', '/dashboards', '/wallboard', '/alerts', '/events', '/incidents', '/settings', '/status', '/onboarding', '/loading', '/error', '/unauthorized', '/404'];
return supported.includes(pathname) ? pathname : '/404';
};
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
import { useEffect, useState } from 'react';
import { copy } from './copy';
import { presentComponent, presentReason } from './presentation';
export type ComponentStatus = { id: string; state: string; reason: string; lastSuccessAt?: string };
export type SourceLag = { sourceId: string; state: string; reason: string; ageSeconds?: number };
export type BackupStatus = { state: string; reason: string; ageSeconds?: number; verifiedAt?: string; lastSuccessAt?: string };
export type SystemStatus = {
version: string;
release?: { version: string; commit: string; builtAt?: string; migrationVersion: string };
generatedAt: string;
overallState: string;
components: ComponentStatus[];
backup: BackupStatus;
sourceLag: SourceLag[];
};
export type SystemStatusState = 'loading' | 'ready' | 'error' | 'unauthorized' | 'forbidden';
export type SystemStatusSnapshot = { state: SystemStatusState; status: SystemStatus | null; fetchedAt: number };
/**
* ADR-0008: a snapshot older than this is treated as missing telemetry. It is
* generous relative to the refresh interval so that a single skipped refresh
* does not flip the badge, but it guarantees a wallboard that lost its backend
* degrades to Unknown instead of freezing on a green badge.
*/
export const STALE_AFTER_MS = 180_000;
export const BACKUP_STALE_AFTER_SECONDS = 24 * 60 * 60;
const REFRESH_MS = 30_000;
const listeners = new Set<(snapshot: SystemStatusSnapshot) => void>();
let snapshot: SystemStatusSnapshot = { state: 'loading', status: null, fetchedAt: 0 };
let controller: AbortController | null = null;
let timer: ReturnType<typeof setInterval> | null = null;
function publish(next: SystemStatusSnapshot): void {
snapshot = next;
[...listeners].forEach((listener) => listener(snapshot));
}
async function load(): Promise<void> {
// A newer request always wins; the older one is aborted and its result
// discarded even if it happened to resolve first.
controller?.abort();
const active = new AbortController();
controller = active;
try {
const response = await fetch('/api/v1/system/status', { signal: active.signal });
if (controller !== active) return;
if (response.status === 401) {
publish({ state: 'unauthorized', status: null, fetchedAt: Date.now() });
return;
}
if (response.status === 403) {
publish({ state: 'forbidden', status: null, fetchedAt: Date.now() });
return;
}
if (!response.ok) throw new Error('status');
const value = await response.json() as SystemStatus;
if (controller !== active) return;
publish({ state: 'ready', status: value, fetchedAt: Date.now() });
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (controller !== active) return;
// ADR-0008: a failed refresh must never leave a previously healthy snapshot
// behind as if it were current.
publish({ state: 'error', status: null, fetchedAt: Date.now() });
} finally {
if (controller === active) controller = null;
}
}
let requestedAt = 0;
/** Starts a request only when nothing recent is in flight or cached. */
function loadIfStale(): void {
const now = Date.now();
if (controller && now - requestedAt < REFRESH_MS) return;
if (snapshot.fetchedAt !== 0 && now - snapshot.fetchedAt < REFRESH_MS) return;
requestedAt = now;
void load();
}
/** Forces a refresh, e.g. from a retry button. */
export function refreshSystemStatus(): void {
publish({ state: 'loading', status: null, fetchedAt: 0 });
requestedAt = Date.now();
void load();
}
/** Clears the singleton between isolated DOM tests; production code never calls this. */
export function resetSystemStatusForTests(): void {
if (timer) clearInterval(timer);
timer = null;
controller?.abort();
controller = null;
listeners.clear();
snapshot = { state: 'loading', status: null, fetchedAt: 0 };
requestedAt = 0;
}
/**
* One shared `/api/v1/system/status` reader. Several surfaces (overview,
* sidebar, dashboard header, status page) need the same aggregate, and a
* rotating wallboard remounts them constantly; a single polled store keeps that
* to one bounded request per interval and cleans up when the last consumer goes.
*/
export function useSystemStatus(): SystemStatusSnapshot {
const [value, setValue] = useState<SystemStatusSnapshot>(snapshot);
useEffect(() => {
listeners.add(setValue);
setValue(snapshot);
loadIfStale();
if (!timer) timer = setInterval(() => { requestedAt = Date.now(); void load(); }, REFRESH_MS);
return () => {
listeners.delete(setValue);
if (listeners.size > 0) return;
if (timer) clearInterval(timer);
timer = null;
controller?.abort();
controller = null;
};
}, []);
return value;
}
export function systemStateLabel(state: string): string {
if (state === 'healthy') return copy.systemStatus.healthy;
if (state === 'disabled') return copy.systemStatus.disabled;
if (state === 'degraded') return copy.systemStatus.degraded;
return copy.systemStatus.unknown;
}
export function componentStatus(status: SystemStatus | null, id: string): ComponentStatus | null {
return status?.components.find((component) => component.id === id) ?? null;
}
/** Fails closed when an allegedly healthy backup is old or has no usable age. */
export function backupPresentation(backup: BackupStatus | undefined, now = Date.now()): BackupStatus {
if (!backup) return { state: 'unknown', reason: 'no_verified_backup' };
let ageSeconds = backup.ageSeconds;
if (ageSeconds == null || !Number.isFinite(ageSeconds)) {
const observed = Date.parse(backup.lastSuccessAt ?? backup.verifiedAt ?? '');
ageSeconds = Number.isNaN(observed) ? undefined : Math.max(0, (now - observed) / 1000);
}
if (backup.state !== 'healthy') return { ...backup, ageSeconds };
if (ageSeconds == null) return { ...backup, state: 'unknown', reason: 'no_verified_backup', ageSeconds };
if (ageSeconds > BACKUP_STALE_AFTER_SECONDS) return { ...backup, state: 'degraded', reason: 'backup_stale', ageSeconds };
return { ...backup, ageSeconds };
}
function isStale(snapshotValue: SystemStatusSnapshot, now: number): boolean {
const generated = Date.parse(snapshotValue.status?.generatedAt ?? '');
if (Number.isNaN(generated)) return true;
return now - generated > STALE_AFTER_MS || now - snapshotValue.fetchedAt > STALE_AFTER_MS;
}
export type AggregateStatus = { state: string; label: string; tone: 'ready' | 'unknown'; detail: string; stale: boolean };
/**
* Maps a snapshot to what the UI may claim. ADR-0008: only a `ready` snapshot
* whose payload says `healthy` and whose observation is fresh may render as
* healthy. Loading, error, unauthorized, forbidden, unknown, missing and stale
* all render as Unknown, and `degraded`/`disabled` keep the Unknown tone because
* neither is a healthy system.
*/
export function aggregateStatus(snapshotValue: SystemStatusSnapshot, now = Date.now()): AggregateStatus {
if (snapshotValue.state === 'loading') {
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.statusLoadingDetail, stale: false };
}
if (snapshotValue.state === 'unauthorized') {
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.unauthorizedDetail, stale: false };
}
if (snapshotValue.state === 'forbidden') {
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.forbiddenDetail, stale: false };
}
if (snapshotValue.state === 'error' || !snapshotValue.status) {
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.unavailableDetail, stale: false };
}
const stale = isStale(snapshotValue, now);
if (stale) {
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: copy.overview.staleDetail, stale: true };
}
const state = snapshotValue.status.overallState;
if (state === 'healthy') {
return { state, label: copy.systemStatus.healthy, tone: 'ready', detail: copy.overview.healthyDetail, stale: false };
}
if (state === 'degraded') {
return { state, label: copy.systemStatus.degraded, tone: 'unknown', detail: copy.overview.degradedDetail, stale: false };
}
if (state === 'disabled') {
return { state, label: copy.systemStatus.disabled, tone: 'unknown', detail: copy.overview.disabledDetail, stale: false };
}
const connectedSources = snapshotValue.status.sourceLag?.length ?? 0;
return { state: 'unknown', label: copy.systemStatus.unknown, tone: 'unknown', detail: connectedSources > 0 ? copy.overview.partialDetail : copy.overview.unknownDetail, stale: false };
}
/** The optimistic overview heading is reserved for a fully healthy, issue-free view. */
export function overviewTitle(status: AggregateStatus, problemCount: number, operationalAttention = false): string {
if (status.state === 'healthy' && problemCount === 0 && !operationalAttention) return copy.overview.title;
if (status.state === 'degraded' || problemCount > 0 || operationalAttention) return copy.overview.attentionTitle;
return copy.overview.unknownTitle;
}
/** Bounded list of non-healthy signals, used for the degraded overview summary. */
export function statusProblems(status: SystemStatus | null): Array<{ id: string; label: string; reason: string }> {
if (!status) return [];
const required = new Set(['database', 'worker', 'prometheus', 'query', 'unraid', 'storage']);
const actionableComponents = (status.components ?? []).filter((component) => component.state !== 'healthy' && (component.state !== 'disabled' || required.has(component.id)));
const componentIDs = new Set(actionableComponents.map((component) => component.id));
const components = actionableComponents
.map((component) => ({ id: 'component:' + component.id, label: presentComponent(component.id), reason: presentReason(component.reason) }));
const sources = (status.sourceLag ?? []).filter((source) => source.state !== 'healthy' && !componentIDs.has(source.sourceId))
.map((source) => ({ id: 'source:' + source.sourceId, label: presentComponent(source.sourceId), reason: presentReason(source.reason) }));
const backupStatus = backupPresentation(status.backup);
const backup = backupStatus.state !== 'healthy' && backupStatus.state !== 'disabled'
? [{ id: 'backup', label: copy.systemStatus.backup, reason: presentReason(backupStatus.reason) }]
: [];
return [...components, ...sources, ...backup].slice(0, 10);
}
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { MetricQueryRequest } from './metricClient';
import { LiveClient, liveQueryKey, type LiveEvent, type LiveSubscription } from './liveClient';
import { LiveChartAdapter, type LiveSample } from './liveBuffer';
type LiveState = 'idle' | 'connecting' | 'live' | 'error';
type LiveMetricResult = { state: LiveState; pointCount: number; series: ReturnType<LiveChartAdapter['snapshot']>; error: string | null };
const noSamples: LiveSample[] = [];
/**
* Cheap identity for a sample batch. The previous implementation serialised the
* whole array into the effect dependency list, which ran on every render — and
* the hook re-renders every 16 ms while streaming, over arrays of up to
* 20 series x 4000 points. Sampling the ends is O(1) and recomputed only when
* the array reference actually changes.
*/
function samplesKey(samples: readonly LiveSample[]): string {
if (samples.length === 0) return '0';
const first = samples[0];
const last = samples[samples.length - 1];
return samples.length + '|' + first.series + '|' + first.timestamp + '|' + first.value + '|' + last.series + '|' + last.timestamp + '|' + last.value;
}
export function useLiveMetric(client: LiveClient, request: MetricQueryRequest | null, initialSamples: LiveSample[] = noSamples, capacity = 240): LiveMetricResult {
const adapter = useRef<LiveChartAdapter | null>(null);
const [state, setState] = useState<LiveState>('idle');
const [error, setError] = useState<string | null>(null);
const [, render] = useState(0);
const frame = useRef<ReturnType<typeof setTimeout> | null>(null);
if (!adapter.current) adapter.current = new LiveChartAdapter(capacity);
const requestKey = request ? liveQueryKey(request) : '';
const initialKey = useMemo(() => samplesKey(initialSamples), [initialSamples]);
// Always seed from the latest array, so an unchanged fingerprint can never
// reintroduce a stale batch.
const latestSamples = useRef(initialSamples);
latestSamples.current = initialSamples;
// Historical seed data can arrive after the live subscription has opened.
// Refreshing that seed must not tear down and recreate the WebSocket: on a
// rotating wallboard that produced a close/open race for every dashboard.
useEffect(() => {
const buffer = adapter.current;
if (!buffer) return undefined;
buffer.clear();
buffer.append(latestSamples.current);
return undefined;
}, [requestKey, initialKey, capacity]);
useEffect(() => {
const buffer = adapter.current;
if (!buffer) return undefined;
setError(null);
if (!request) {
setState('idle');
return undefined;
}
setState('connecting');
let subscription: LiveSubscription | null = null;
const handle = (event: LiveEvent) => {
if (event.type === 'samples') {
buffer.append(event.samples);
setState('live');
if (!frame.current) frame.current = setTimeout(() => { frame.current = null; render((value) => value + 1); }, 16);
} else if (event.type === 'status') {
if (event.state === 'resync-required') buffer.clear();
setState(event.state === 'subscribed' ? 'live' : event.state === 'unsubscribed' ? 'idle' : 'connecting');
if (event.state === 'resync-required') setError(null);
} else {
setState('error');
setError(event.message);
}
};
subscription = client.subscribe(request, handle);
// Series keys that stopped reporting must not accumulate for the lifetime of
// a wallboard session.
const sweep = setInterval(() => { buffer.evictStale(); }, 60000);
return () => {
subscription?.unsubscribe();
clearInterval(sweep);
if (frame.current) clearTimeout(frame.current);
frame.current = null;
buffer.clear();
client.releaseUnused();
};
}, [client, requestKey, capacity]);
const current = adapter.current;
return { state, pointCount: current?.pointCount ?? 0, series: current?.snapshot() ?? [], error };
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { MetricApiError, MetricClient, type MetricQueryRequest, type MetricQueryResponse } from './metricClient';
type MetricQueryState = { status: 'idle' | 'loading' | 'success' | 'error'; response: MetricQueryResponse | null; error: MetricApiError | null };
const idle: MetricQueryState = { status: 'idle', response: null, error: null };
export function useMetricQuery(client: MetricClient, request: MetricQueryRequest | null): MetricQueryState {
const [state, setState] = useState<MetricQueryState>(idle);
const requestKey = request ? JSON.stringify(request) : '';
useEffect(() => {
if (!request) { setState(idle); return undefined; }
const controller = new AbortController();
setState({ status: 'loading', response: null, error: null });
client.queryRange(request, controller.signal).then((response) => {
if (!controller.signal.aborted) setState({ status: 'success', response, error: null });
}).catch((error: unknown) => {
if (controller.signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) return;
setState({ status: 'error', response: null, error: error instanceof MetricApiError ? error : new MetricApiError(0) });
});
return () => controller.abort();
}, [client, requestKey]);
return state;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+28
View File
@@ -0,0 +1,28 @@
export const wallboardColumns = 24;
export const wallboardRowsPerSlide = 13;
export type WallboardPlacement = { columnStart: number; columnSpan: number; rowStart: number; rowSpan: number };
function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number): number {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(maximum, Math.max(minimum, Math.floor(number)));
}
export function wallboardSlideIndex(y: unknown): number {
const row = boundedInteger(y, 0, 0, Number.MAX_SAFE_INTEGER);
return Math.floor(row / wallboardRowsPerSlide);
}
export function wallboardPlacement(layout: Record<string, unknown>): WallboardPlacement {
const columnStart = boundedInteger(layout.x, 0, 0, wallboardColumns - 1) + 1;
const row = boundedInteger(layout.y, 0, 0, Number.MAX_SAFE_INTEGER) % wallboardRowsPerSlide;
const requestedWidth = boundedInteger(layout.w, 6, 1, wallboardColumns);
const requestedHeight = boundedInteger(layout.h, 4, 1, wallboardRowsPerSlide);
return {
columnStart,
columnSpan: Math.min(requestedWidth, wallboardColumns - columnStart + 1),
rowStart: row + 1,
rowSpan: Math.min(requestedHeight, wallboardRowsPerSlide - row),
};
}
+129
View File
@@ -0,0 +1,129 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';
import path from 'node:path';
const generatedAt = new Date().toISOString();
async function mockAPI(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/api/v1/system/status') {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
version: '1', generatedAt, overallState: 'degraded',
components: [{ id: 'database', state: 'healthy', reason: 'ok' }, { id: 'prometheus', state: 'unknown', reason: 'source_stale' }],
backup: { state: 'disabled', reason: 'not_configured' },
sourceLag: [{ sourceId: 'reverse-proxy', state: 'unknown', reason: 'source_unavailable', ageSeconds: 180 }],
}),
});
return;
}
if (path === '/api/v1/dashboards') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
return;
}
if (path === '/api/v1/host') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({
identity: { name: 'mobile-fixture' },
cpu: { totalPercent: 42, perCore: Array.from({ length: 16 }, (_, index) => index + 1) },
memory: { utilizationPercent: 61 }, source: { state: 'healthy', freshness: 'fresh' },
}) });
return;
}
if (path === '/api/v1/containers') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { state: 'healthy', freshness: 'fresh' }, containers: [{ id: 'proxy', state: 'running', health: 'healthy' }], total: 1 }) });
return;
}
if (path === '/api/v1/pools') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { state: 'healthy', freshness: 'fresh' }, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', utilizationPercent: 63 }], total: 1 }) });
return;
}
if (path === '/api/v1/services') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'proxy', name: 'Proxy', state: 'up' }], total: 1 }) });
return;
}
if (path === '/api/v1/incidents') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [{ id: 'incident-1', title: 'Bronvertraging', severity: 'warning', startedAt: generatedAt }] }) });
return;
}
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: JSON.stringify({ code: 'NOT_FOUND' }) });
});
}
async function expectNoCriticalA11yViolations(page: Page): Promise<void> {
const results = await new AxeBuilder({ page }).analyze();
const severe = results.violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
expect(severe, severe.map((violation) => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]);
}
test.beforeEach(async ({ page }) => {
await mockAPI(page);
});
test('overview exposes degraded/unknown state and passes axe', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Wallboard has a dedicated route test.');
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByText(/Onbekend|Verminderd/).first()).toBeVisible();
if (testInfo.project.name === 'mobile-chromium') {
await expect(page.locator('.desktop-navigation')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toHaveCSS('position', 'fixed');
await expect(page.locator('.mobile-primary-list .nav-link')).toHaveCount(4);
const mobileTargets = await page.locator('.mobile-primary-list .nav-link').evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
expect(mobileTargets.every((height) => height >= 44)).toBe(true);
await expect(page.locator('.signal-path-stage')).toHaveCount(6);
await expect(page.locator('.signal-path-inspector')).toBeVisible();
const incidentBox = await page.locator('.incident-queue').boundingBox();
const signalBox = await page.locator('.signal-path-panel').boundingBox();
expect(incidentBox?.y).toBeLessThan(signalBox?.y ?? 0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
} else {
await expect(page.locator('.desktop-navigation')).toBeVisible();
const rail = await page.locator('.sidebar').boundingBox();
const commandHeader = await page.locator('.context-bar').boundingBox();
expect(rail?.width).toBeLessThanOrEqual(72);
expect(commandHeader?.height).toBe(56);
await expect(page.locator('.overview-kpi')).toHaveCount(4);
await expect(page.locator('.source-health-strip')).toHaveAttribute('tabindex', '0');
await expect(page.locator('.nav-group')).toHaveCount(6);
await expect(page.locator('.nav-group').first()).toHaveAttribute('open', '');
await expect(page.locator('.nav-group').filter({ hasText: 'Infrastructuur' })).not.toHaveAttribute('open', '');
}
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toBeVisible();
await expectNoCriticalA11yViolations(page);
if (process.env.PULSE_E2E_REAL_BASE_URL || process.env.PULSE_CAPTURE_VISUALS) {
const evidenceDirectory = process.env.PULSE_CAPTURE_VISUALS ? 'M14-02' : 'M11-10';
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
await page.screenshot({ path: path.resolve('../../artifacts/evidence', evidenceDirectory, `overview-${testInfo.project.name}.png`), fullPage: true });
}
});
test('grouped navigation reaches infrastructure in two actions', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Wallboard heeft geen productnavigatie.');
await page.goto('/');
if (testInfo.project.name === 'mobile-chromium') {
await page.getByText('Meer', { exact: true }).click();
await page.getByRole('link', { name: /Disks/ }).click();
} else {
await page.locator('.nav-group').filter({ hasText: 'Infrastructuur' }).locator('summary').click();
await page.getByRole('link', { name: /Disks/ }).click();
}
await expect(page).toHaveURL(/\/disks$/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
});
test('wallboard remains read-only, bounded and accessible', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'wallboard-chromium', 'Wallboard is verified at 1920x1080.');
await page.goto('/wallboard');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByText(/Geen dashboards|Wallboard/).first()).toBeVisible();
await expect(page.locator('.sidebar')).toHaveCount(0);
await expect(page.getByRole('button', { name: /Bewerken|Exporteren/ })).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1)).toBe(true);
await expectNoCriticalA11yViolations(page);
});
@@ -0,0 +1,94 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const alerts = Array.from({ length: 25 }, (_, index) => ({
id: `alert-${String(index + 1).padStart(2, '0')}`,
state: index === 1 ? 'acknowledged' : 'firing',
retainedState: 'firing',
ruleName: `Melding ${String(index + 1).padStart(2, '0')}`,
severity: index % 5 === 0 ? 'critical' : 'attention',
entityName: index % 2 === 0 ? 'Tower' : 'Database',
reason: 'threshold_exceeded',
revision: index + 1,
updatedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
}));
test('alertwerkruimte prioriteert operatie en opent configuratie doelgericht', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'De alertwerkruimte gebruikt de desktop-, tablet- en mobiele shell.');
let operationHeaders: Record<string, string> | undefined;
await page.route('**/api/v1/**', async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/system/status') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: 'test', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) });
return;
}
if (pathname === '/api/v1/alert-rules') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
return;
}
if (pathname === '/api/v1/metrics/catalog') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ metrics: [{ semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' }] }) });
return;
}
if (pathname === '/api/v1/alerts' && request.method() === 'GET') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: alerts }) });
return;
}
if (pathname.startsWith('/api/v1/alerts/') && request.method() === 'POST') {
operationHeaders = request.headers();
await route.fulfill({ contentType: 'application/json', body: '{}' });
return;
}
if (pathname === '/api/v1/alert-silences' || pathname === '/api/v1/maintenance-windows') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
return;
}
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
});
await page.goto('/alerts');
await expect(page.getByRole('heading', { name: 'Meldingen en incidenten' })).toBeVisible();
await expect(page.getByRole('button', { name: /Actief 25/ })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: /Kritiek actief 5/ })).toBeVisible();
const rows = page.locator('.alert-operation-list-items > li');
await expect(rows).toHaveCount(20);
await expect(rows.first()).toContainText('Kritiek');
await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toHaveCount(0);
const acknowledge = page.getByRole('button', { name: 'Erkennen' }).first();
page.once('dialog', (dialog) => dialog.dismiss());
await acknowledge.click();
expect(operationHeaders).toBeUndefined();
page.once('dialog', (dialog) => dialog.accept());
await acknowledge.click();
await expect.poll(() => operationHeaders?.['if-match']).toBe('1');
expect(operationHeaders?.['idempotency-key']).toBeTruthy();
await page.getByRole('button', { name: /Alertregels Detectie en drempels/ }).click();
await expect(page).toHaveURL(/section=rules/);
await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Actieve en recente meldingen' })).toHaveCount(0);
await page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ }).click();
await expect(page).toHaveURL(/section=controls/);
await expect(page.getByRole('heading', { name: 'Tijdelijke onderdrukking en onderhoud' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toHaveCount(0);
await page.reload();
await expect(page.getByRole('heading', { name: 'Tijdelijke onderdrukking en onderhoud' })).toBeVisible();
await expect(page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ })).toHaveAttribute('aria-current', 'page');
await page.getByRole('button', { name: /Actieve meldingen Prioriteiten en erkenning/ }).click();
await expect(page).not.toHaveURL(/section=/);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollHeight / window.innerHeight)).toBeLessThan(10);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
if (testInfo.project.name === 'mobile-chromium') {
const heights = await page.locator('.alert-section-nav button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
expect(heights.every((height) => height >= 44)).toBe(true);
}
if (process.env.PULSE_CAPTURE_VISUALS && (testInfo.project.name === 'desktop-chromium' || testInfo.project.name === 'mobile-chromium')) {
await page.screenshot({ path: path.resolve('../../artifacts/evidence/M14-03', `alerts-${testInfo.project.name}.png`), fullPage: true });
}
});
+26
View File
@@ -0,0 +1,26 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test('persisted capacity history produces one qualified explainable forecast', async ({ page }) => {
test.skip(!enabled, 'Requires an isolated real Pulse stack.');
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
const response = await page.request.get('/api/v1/forecasts');
expect(response.ok()).toBeTruthy();
const snapshot = await response.json() as { qualifiedCount: number; items: Array<{ entityId: string; dataPoints: number; confidence: string; projectedAt?: string }> };
expect(snapshot.qualifiedCount).toBe(1);
expect(snapshot.items[0]).toMatchObject({ dataPoints: 3, confidence: 'medium' });
expect(snapshot.items[0].projectedAt).toBeTruthy();
await page.goto('/capacity');
await expect(page.getByText(/1 gekwalificeerde prognoses/)).toBeVisible();
await expect(page.getByRole('heading', { name: 'Media forecast' })).toBeVisible();
await expect(page.getByText('Mediane dagelijkse groei', { exact: true })).toBeVisible();
await expect(page.getByText('Gemiddelde betrouwbaarheid')).toBeVisible();
await expect(page.getByText('42 dagen', { exact: false })).toBeVisible();
await expect(page.getByText('0 B / 0 B')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
});
@@ -0,0 +1,29 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
const coreRoutes = ['/', '/host', '/containers', '/storage', '/services', '/alerts', '/incidents', '/inventory'];
test('core routes remain accessible, bounded and visually stable', async ({ page }, testInfo) => {
test.skip(!enabled, 'Requires the isolated server-built Pulse stack.');
const errors: string[] = [];
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', (error) => errors.push(error.message));
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
const routes = testInfo.project.name === 'wallboard-chromium' ? ['/wallboard'] : coreRoutes;
for (const route of routes) {
await page.goto(route);
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), `${route} has document overflow`).toBe(true);
if (route === '/wallboard') expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1), `${route} has vertical overflow`).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious'), `${route} axe findings`).toEqual([]);
if (process.env.PULSE_CAPTURE_VISUALS && (route === '/' || route === '/services' || route === '/wallboard')) {
const name = route === '/' ? 'overview' : route.slice(1);
await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-10', `${name}-${testInfo.project.name}.png`), fullPage: true });
}
}
expect(errors, errors.join('\n')).toEqual([]);
});
@@ -0,0 +1,91 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';
import path from 'node:path';
const dashboard = { id: 'polish-dashboard', slug: 'operations', name: 'Netwerkoperaties', description: 'Actuele netwerkbelasting en operationele wijzigingen.', scope: 'system', revision: 4, currentVersion: 7 };
const widgets = [
{
id: 'network', title: 'Netwerkbelasting', type: 'timeseries',
data: { sourceType: 'semantic-metric', metric: 'host.network.receive', aggregation: 'avg' },
visualization: { unit: 'bytesPerSecond', decimals: 0, legend: true },
behavior: { locked: false, hidden: false, liveIntervalSeconds: 30 },
layouts: { desktop: { x: 0, y: 0, w: 9, h: 5, visible: true } },
},
{
id: 'events', title: 'Recente wijzigingen', type: 'event-timeline',
data: { sourceType: 'events', limit: 12 }, behavior: { locked: false, hidden: false, liveIntervalSeconds: 30 },
layouts: { desktop: { x: 9, y: 0, w: 9, h: 5, visible: true } },
},
];
async function mockDashboard(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const requestPath = new URL(route.request().url()).pathname;
const body = requestPath === '/api/v1/dashboards/polish-dashboard'
? { dashboard, version: { document: { schemaVersion: 2, widgets, variables: [], settings: { defaultTimeRange: '1h' } } } }
: requestPath === '/api/v1/metrics/query-range'
? { status: 'success', data: { result: [{ metric: { __name__: 'host_network_receive', host: 'tower', interface: 'eth0' }, values: [[1786420800, '1200'], [1786420815, '1500']] }] }, provenance: { source: 'prometheus', metric: 'host.network.receive', catalogVersion: '1', cacheKey: 'test' }, sourceObservedAt: '2026-08-11T04:01:00Z', receivedAt: '2026-08-11T04:01:01Z', freshness: 'fresh', cacheHit: false }
: requestPath === '/api/v1/events'
? { items: [{ id: 'event-1', type: 'container.restart', severity: 'warning', summary: 'container.restart', occurredAt: '2026-08-11T04:00:00Z' }] }
: requestPath === '/api/v1/system/status'
? { version: '1', generatedAt: '2026-08-11T04:01:00Z', overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'database_ready' }], backup: { state: 'healthy', reason: 'backup_verified' }, sourceLag: [] }
: {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
}
async function expectNoSeriousAxeViolations(page: Page): Promise<void> {
const results = await new AxeBuilder({ page }).analyze();
const violations = results.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious');
expect(violations, violations.map((item) => `${item.id}: ${item.help}`).join('\n')).toEqual([]);
}
test('dashboard and editor use human labels, safe modes and accessible keyboard controls', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'The editor acceptance proof uses the desktop canvas.');
await mockDashboard(page);
await page.goto('/dashboards/polish-dashboard');
await expect(page.getByRole('heading', { level: 1, name: 'Netwerkoperaties' })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: 'Dashboardwidgets' })).toBeAttached();
await expect(page.getByRole('heading', { level: 3, name: 'Netwerkbelasting' })).toBeVisible();
await expect(page.getByRole('list', { name: 'Legenda' })).toContainText('tower · eth0');
await expect(page.locator('.metric-chart-line')).toHaveAttribute('d', 'M 28.000 192.000 L 628.000 12.000');
await expect(page.getByText('Container herstart', { exact: false })).toBeVisible();
await expect(page.getByText('container.restart', { exact: true })).toHaveCount(0);
await expect(page.locator('body')).not.toContainText('{"__name__"');
await expectNoSeriousAxeViolations(page);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/dashboard-human-labels.png'), fullPage: true });
await page.getByRole('button', { name: 'Bewerken' }).click();
await expect(page.getByRole('heading', { level: 1, name: 'Dashboard aanpassen' })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: 'Dashboardindeling' })).toBeAttached();
const advanced = page.getByText('Dashboardvariabelen, sjablonen en gegevensoverdracht', { exact: true });
await expect(advanced).toBeVisible();
await expect(page.getByRole('heading', { name: 'Import, export en templates' })).toBeHidden();
await expect(page.locator('.editor-widget-actions').first()).toContainText('Omhoog');
await expect(page.locator('.editor-widget-actions').first()).toContainText('Omlaag');
await expect(page.locator('.editor-widget-actions').first()).toContainText('Breedte');
await expect(page.getByText('Weergavemodus')).toHaveCount(0);
const editorWidgets = page.locator('.editor-widget');
await expect(editorWidgets.locator('h3')).toHaveText(['Netwerkbelasting', 'Recente wijzigingen']);
const firstWidget = await editorWidgets.first().boundingBox();
expect(firstWidget).not.toBeNull();
await page.mouse.move(firstWidget!.x + 30, firstWidget!.y + 30);
await page.mouse.down();
await page.mouse.move(firstWidget!.x + 30, firstWidget!.y + 75, { steps: 4 });
await page.mouse.up();
await expect(editorWidgets.locator('h3')).toHaveText(['Recente wijzigingen', 'Netwerkbelasting']);
const resize = page.getByRole('slider', { name: 'Breedte aanpassen: Netwerkbelasting' });
await expect(resize).toHaveAttribute('aria-valuenow', '9');
await resize.focus();
await page.keyboard.press('ArrowRight');
await expect(resize).toHaveAttribute('aria-valuenow', '10');
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/editor-keyboard-and-disclosure.png'), fullPage: true });
await advanced.click();
await expect(page.getByRole('heading', { level: 2, name: 'Import, export en templates' })).toBeVisible();
await expectNoSeriousAxeViolations(page);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/editor-advanced-open.png'), fullPage: true });
});
@@ -0,0 +1,40 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test('default dashboard and wallboard expose usable real sources', async ({ page }) => {
test.skip(!enabled, 'Run against an isolated server smoke stack.');
test.setTimeout(90_000);
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
const failures: string[] = [];
page.on('pageerror', (error) => failures.push(error.message));
page.on('response', (response) => {
const path = new URL(response.url()).pathname;
if (path.startsWith('/api/') && response.status() >= 400) failures.push(`${response.status()} ${path}`);
});
await page.goto('/dashboards/11111111-1111-4111-8111-111111111111');
await expect(page.getByRole('heading', { level: 1, name: 'Overzicht' })).toBeVisible();
await expect(page.locator('.widget-card--runtime')).toHaveCount(5);
await expect(page.locator('.widget-placeholder')).toHaveCount(0);
await expect.poll(() => failures, { timeout: 5_000 }).toEqual([]);
await expect(page.locator('.metric-chart')).toBeVisible();
await expect(page.getByText('Disk 1').first()).toBeVisible();
await expect(page.getByText('pulse', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Inventaris succesvol bijgewerkt')).toBeVisible();
await expect(page.locator('.widget-card--runtime[data-runtime-state="usable"]')).toHaveCount(5);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
expect((await new AxeBuilder({ page }).analyze()).violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
await page.goto('/wallboard?refresh=300&interval=300');
await expect(page.getByRole('heading', { name: 'Operationeel wallboard' })).toBeVisible();
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden');
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1)).toBe(true);
await expect(page.locator('.metric-chart')).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
expect((await new AxeBuilder({ page }).analyze()).violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
expect(failures).toEqual([]);
});
+74
View File
@@ -0,0 +1,74 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const items = Array.from({ length: 100 }, (_, index) => ({
id: `event-${String(index + 1).padStart(3, '0')}`,
type: index % 2 === 0 ? 'service.down' : 'container.restart',
severity: index % 10 === 0 ? 'critical' : index % 3 === 0 ? 'warning' : 'info',
entityId: `entity-${String(index % 5).padStart(2, '0')}`,
sourceId: 'source-unraid',
occurredAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
receivedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 5) - index * 60_000).toISOString(),
summary: `Gebeurtenis ${String(index + 1).padStart(3, '0')}`,
}));
test('100 events blijven compact, filterbaar en toetsenbordnavigeerbaar', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'De eventwerkruimte gebruikt de desktop-, tablet- en mobiele shell.');
let eventRequests = 0;
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname === '/api/v1/events') {
eventRequests += 1;
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items }) });
return;
}
if (pathname === '/api/v1/system/status') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: '1', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) });
return;
}
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
});
await page.goto('/events');
await expect(page.getByRole('heading', { name: 'Gebeurtenissen' })).toBeVisible();
const rows = page.locator('.event-list > li');
await expect(rows).toHaveCount(20);
const baselineRequests = eventRequests;
expect(baselineRequests).toBeLessThanOrEqual(2);
const criticalSummary = page.getByRole('button', { name: /Kritieke gebeurtenissen/i });
await expect(criticalSummary).toBeVisible();
await expect(criticalSummary).toContainText('10');
expect(await page.evaluate(() => document.documentElement.scrollHeight / window.innerHeight)).toBeLessThan(10);
await page.getByLabel('Ernst').selectOption('critical');
await expect(rows).toHaveCount(10);
await page.getByLabel('Soort').selectOption('service.down');
await expect(rows).toHaveCount(10);
await page.getByLabel('Onderdeel').selectOption('entity-00');
await expect(rows).toHaveCount(10);
await page.getByLabel('Zoeken').fill('Gebeurtenis 091');
await expect(rows).toHaveCount(1);
expect(eventRequests).toBe(baselineRequests);
await page.getByRole('button', { name: 'Filters wissen' }).click();
const next = page.getByRole('button', { name: 'Volgende pagina' });
await next.focus();
await page.keyboard.press('Enter');
await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
await expect(page).toHaveURL(/page=2/);
await expect(rows).toHaveCount(20);
await expect(rows.first()).toContainText('event-021');
expect(eventRequests).toBe(baselineRequests);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
if (testInfo.project.name === 'mobile-chromium') {
const pagerHeights = await page.locator('.list-pager .button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
expect(pagerHeights.every((height) => height >= 44)).toBe(true);
}
if (process.env.PULSE_CAPTURE_VISUALS && (testInfo.project.name === 'desktop-chromium' || testInfo.project.name === 'mobile-chromium')) {
await page.screenshot({ path: path.resolve('../../artifacts/evidence/M14-03', `events-${testInfo.project.name}.png`), fullPage: true });
}
});
+30
View File
@@ -0,0 +1,30 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test('real inventory exposes effective overrides, provenance and relations', async ({ page }, testInfo) => {
test.skip(!enabled, 'Requires the isolated real PostgreSQL stack and inventory fixture.');
const login = await page.request.get('/auth/test-login');
expect(login.ok()).toBeTruthy();
await page.goto('/inventory');
await page.getByRole('searchbox', { name: 'Zoeken' }).fill('pulse-api');
const entity = page.getByRole('link', { name: /Pulse API · handmatig/ });
await expect(entity).toBeVisible();
await expect(entity).toContainText('1 bronnen · 2 feiten · 1 relaties · 2 correcties');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
await entity.click();
await expect(page.getByRole('heading', { level: 1, name: 'Pulse API · handmatig' })).toBeVisible();
await expect(page.getByText('itworx/pulse:pinned', { exact: true })).toBeVisible();
await expect(page.getByText('Handmatige correctie').first()).toBeVisible();
await expect(page.getByText('Verouderd', { exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: /PostgreSQL.*depends_on.*bevestigd/ })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
const violations = await new AxeBuilder({ page }).analyze();
expect(violations.violations.filter((item) => ['serious', 'critical'].includes(item.impact ?? ''))).toEqual([]);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-05', `inventory-${testInfo.project.name}.png`), fullPage: true });
});
+104
View File
@@ -0,0 +1,104 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const containers = Array.from({ length: 150 }, (_, index) => ({
id: `container-${String(index + 1).padStart(3, '0')}`,
name: `container-${String(index + 1).padStart(3, '0')}`,
image: 'example/pulse:read-only', state: index % 17 === 0 ? 'exited' : 'running', health: index % 13 === 0 ? 'unhealthy' : 'healthy',
intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 3600, restartCount: 0, exitCode: 0,
cpuPercent: index / 10, memoryBytes: 1024 * (index + 1), memoryLimitBytes: 1024 * 1024,
networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0,
}));
const processes = Array.from({ length: 60 }, (_, index) => ({ pid: index + 1, name: `worker-${String(index + 1).padStart(2, '0')}`, state: 'running', runtimeSeconds: 300, cpuPercent: index, memoryBytes: 2048 + index, containerName: index % 2 ? 'pulse' : 'database' }));
const entities = Array.from({ length: 60 }, (_, index) => ({ id: `entity-${index + 1}`, entityType: 'container', canonicalName: `container.${index + 1}`, displayName: `Entity ${String(index + 1).padStart(2, '0')}`, status: 'operational', factCount: 2, overrideCount: 0, relationCount: 1, sourceCount: 1, staleFactCount: 0 }));
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/containers?**', async (route) => {
const url = new URL(route.request().url());
const query = (url.searchParams.get('q') ?? '').toLowerCase();
const state = url.searchParams.get('state') ?? '';
const health = url.searchParams.get('health') ?? '';
const after = Number(url.searchParams.get('after') ?? '0');
const limit = Number(url.searchParams.get('limit') ?? '25');
const filtered = containers.filter((item) => (!query || item.name.includes(query)) && (!state || item.state === state) && (!health || item.health === health));
const items = filtered.slice(after, after + limit);
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { id: 'target-scale', state: 'healthy' }, total: filtered.length, containers: items, nextCursor: after + limit < filtered.length ? String(after + limit) : '' }) });
});
await page.route('**/api/v1/processes?**', async (route) => {
const url = new URL(route.request().url());
const query = (url.searchParams.get('q') ?? '').toLowerCase();
const container = (url.searchParams.get('container') ?? '').toLowerCase();
const after = Number(url.searchParams.get('after') ?? '0');
const filtered = processes.filter((item) => (!query || item.name.includes(query)) && (!container || item.containerName.includes(container)));
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { id: 'target-scale', state: 'healthy' }, total: filtered.length, processes: filtered.slice(after, after + 25), nextCursor: after + 25 < filtered.length ? String(after + 25) : '' }) });
});
await page.route('**/api/v1/entities?**', async (route) => {
const url = new URL(route.request().url());
const query = (url.searchParams.get('q') ?? '').toLowerCase();
const after = Number(url.searchParams.get('after') ?? '0');
const filtered = entities.filter((item) => !query || (item.displayName + item.canonicalName).toLowerCase().includes(query));
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: filtered.slice(after, after + 25), hasMore: after + 25 < filtered.length, nextCursor: after + 25 < filtered.length ? String(after + 25) : '' }) });
});
});
test('process and inventory filters survive navigation with mobile-first cards', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Large lists target desktop and mobile routes.');
await page.goto('/processes?q=worker&container=pulse&sort=memory');
await expect(page.getByRole('heading', { name: 'Topprocessen' })).toBeVisible();
await expect(page.getByLabel('Zoeken')).toHaveValue('worker');
await expect(page.getByLabel('Container')).toHaveValue('pulse');
await expect(page).toHaveURL(/sort=memory/);
if (testInfo.project.name === 'mobile-chromium') await expect(page.locator('.mobile-data-list > li:visible')).toHaveCount(25);
else await expect(page.locator('.desktop-data-view tbody tr:visible')).toHaveCount(25);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
await page.goto('/inventory?q=Entity&type=container&status=operational&order=desc');
await expect(page.getByRole('heading', { name: 'Wat Pulse kan zien' })).toBeVisible();
await expect(page.getByLabel('Zoeken')).toHaveValue('Entity');
await expect(page.getByLabel('Type')).toHaveValue('container');
await expect(page.getByRole('textbox', { name: 'Status' })).toHaveValue('operational');
await expect(page.locator('.inventory-entity-list > li')).toHaveCount(25);
await page.getByRole('button', { name: 'Volgende pagina' }).click();
await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
await expect(page).toHaveURL(/after=25/);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
});
test('all 150 containers remain reachable with shareable filters and bounded mobile cards', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Large lists target desktop and mobile routes.');
const started = Date.now();
await page.goto('/containers');
await expect(page.getByRole('heading', { name: 'Containers' })).toBeVisible();
const visibleRows = testInfo.project.name === 'mobile-chromium' ? page.locator('.mobile-data-list > li:visible') : page.locator('.desktop-data-view tbody tr:visible');
await expect(visibleRows).toHaveCount(25);
expect(Date.now() - started).toBeLessThan(3000);
const seen = new Set<string>();
for (let pageNumber = 1; pageNumber <= 6; pageNumber += 1) {
await expect(visibleRows).toHaveCount(25);
for (const value of await visibleRows.locator('a').allTextContents()) seen.add(value.trim());
if (pageNumber < 6) {
const next = page.getByRole('button', { name: 'Volgende pagina' });
await next.click();
await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
}
}
expect(seen.size).toBe(150);
expect(seen.has('container-150')).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
if (testInfo.project.name === 'mobile-chromium') {
const controls = await page.locator('.list-pager button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
expect(controls.every((height) => height >= 44)).toBe(true);
}
await page.getByLabel('Zoeken').fill('container-150');
await expect(visibleRows).toHaveCount(1);
await expect(page).toHaveURL(/q=container-150/);
await page.reload();
await expect(visibleRows).toHaveCount(1);
await expect(visibleRows.getByText('container-150')).toBeVisible();
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-08', `large-containers-${testInfo.project.name}.png`), fullPage: true });
});
@@ -0,0 +1,55 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const rule = {
id: '20000000-0000-4000-8000-000000000001', schemaVersion: 1, name: 'Hoge hostbelasting', enabled: true, severity: 'critical', scope: {},
condition: { inputType: 'metric', metric: 'host.cpu.utilization', operator: '>', threshold: 90, recoveryThreshold: 80, aggregation: 'avg', windowSeconds: 60 },
evaluationIntervalSeconds: 30, pendingSeconds: 60, resolveSeconds: 120, cooldownSeconds: 300,
unknownBehavior: 'retain-firing-as-unknown', groupBy: [], suppressWhen: ['host.unreachable'],
message: { titleKey: 'alerts.rule.title', bodyKey: 'alerts.rule.body' }, revision: 1, currentVersion: 1,
};
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/system/status', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: 'test', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) }));
await page.route('**/api/v1/alert-rules?**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [rule] }) }));
await page.route('**/api/v1/metrics/catalog', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ metrics: [{ semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' }] }) }));
await page.route('**/api/v1/alert-silences**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
await page.route('**/api/v1/maintenance-windows**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
await page.route('**/api/v1/alerts?**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
});
test('alert editor uses Dutch guided choices and rejects an invalid draft', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'De alert-editor is geen wallboardroute.');
await page.goto('/alerts');
await expect(page.getByRole('heading', { name: 'Meldingen en incidenten' })).toBeVisible();
await page.getByRole('button', { name: /Alertregels Detectie en drempels/ }).click();
await expect(page.getByText('Kritiek · v1')).toBeVisible();
await expect(page.getByRole('combobox', { name: /Meting/ })).toHaveValue('host.cpu.utilization');
await expect(page.getByRole('option', { name: 'CPU-gebruik van de host (%)' })).toBeAttached();
await expect(page.getByText('host.cpu.utilization')).toHaveCount(0);
await expect(page.getByText('host.unreachable')).not.toBeVisible();
await page.getByRole('button', { name: 'Nieuwe regel' }).click();
const save = page.getByRole('button', { name: 'Regel opslaan' });
await expect(save).toBeDisabled();
await page.locator('#alert-rule-name').fill('CPU-waarschuwing');
await page.getByRole('combobox', { name: /Meting/ }).selectOption('host.cpu.utilization');
await expect(save).toBeEnabled();
await page.locator('#alert-rule-recovery-threshold').fill('90');
await expect(save).toBeDisabled();
await page.getByRole('combobox', { name: /Signaalbron/ }).selectOption('event');
await expect(page.getByRole('combobox', { name: /Meting/ })).toHaveCount(0);
await expect(page.locator('#alert-rule-threshold')).toHaveValue('3');
await expect(save).toBeEnabled();
await page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ }).click();
await expect(page.locator('#silence-matcher')).toHaveValue('critical');
await expect(page.locator('#silence-matcher')).toContainText('Kritiek');
await expect(page.locator('#maintenance-selector')).toContainText('Host');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-09', `alert-editor-${testInfo.project.name}.png`), fullPage: true });
});
@@ -0,0 +1,85 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import path from 'node:path';
const systemStatus = {
version: '1.3.0',
release: { version: '1.3.0', commit: 'abc1234', builtAt: '2026-08-21T12:00:00Z', migrationVersion: '0024' },
generatedAt: new Date().toISOString(),
overallState: 'healthy',
components: [],
backup: { state: 'healthy', reason: 'backup_verified', ageSeconds: 30 * 60 * 60, verifiedAt: '2026-08-20T06:00:00Z' },
sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'fresh', ageSeconds: 5 }],
};
const onboarding = {
state: { completed: true, step: 'completed', dashboardChoice: 'default', rulesChoice: 'default', dashboardId: 'overview', rulesReady: true },
capabilities: [
{ id: 'auth', state: 'ready', detail: 'Aanmelding geconfigureerd.' },
{ id: 'database', state: 'ready', detail: 'Database beschikbaar.' },
{ id: 'prometheus', state: 'ready', detail: 'Meetgegevens beschikbaar.' },
{ id: 'unraid', state: 'ready', detail: 'Unraid-bron beschikbaar.' },
],
resume: false,
};
test('beheerhub, afgeronde onboarding en backupouderdom blijven taakgericht en waarheidsgetrouw', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Beheerflows gebruiken de desktop-, tablet- en mobiele shell.');
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname === '/api/v1/system/status') {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 403, contentType: 'application/problem+json', body: '{}' });
return;
}
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ...systemStatus, generatedAt: new Date().toISOString() }) });
return;
}
if (pathname === '/api/v1/onboarding') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(onboarding) });
return;
}
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
});
await page.goto('/settings');
await expect(page.getByRole('heading', { name: 'Pulse configureren' })).toBeVisible();
const hub = page.getByRole('region', { name: 'Beheerfuncties' });
await expect(hub.getByRole('link')).toHaveCount(6);
await expect(hub.getByRole('link', { name: /Systeemstatus en backup/ })).toHaveAttribute('href', '/status');
await expect(hub.getByRole('link', { name: /Eerste configuratie/ })).toHaveAttribute('href', '/onboarding');
await expect(hub.getByRole('link', { name: /Alertregels/ })).toHaveAttribute('href', '/alerts?section=rules');
await expect(hub.getByRole('link', { name: /Stiltes en onderhoud/ })).toHaveAttribute('href', '/alerts?section=controls');
await expect(hub.getByText('Backupactie: beheerder')).toBeVisible();
await expect(hub.getByText('Wijzigen: operator')).toBeVisible();
if (process.env.PULSE_CAPTURE_VISUALS && (testInfo.project.name === 'desktop-chromium' || testInfo.project.name === 'mobile-chromium')) {
await page.screenshot({ path: path.resolve('../../artifacts/evidence/M14-03', `settings-${testInfo.project.name}.png`), fullPage: true });
}
await hub.getByRole('link', { name: /Systeemstatus en backup/ }).click();
const backup = page.getByRole('article').filter({ has: page.getByText('Backupstatus', { exact: true }) });
await expect(page.getByRole('heading', { name: 'Pulse-systeemstatus' })).toBeVisible();
await expect(backup.locator('.status-badge')).toContainText('Aandacht');
await expect(backup.getByText(/backup is verlopen/i)).toBeVisible();
await expect(backup.getByText(/1 dag geleden/)).toBeVisible();
await expect(backup.getByText(/ouder dan 24 uur/)).toBeVisible();
await backup.getByRole('button', { name: 'Maak geverifieerde backup' }).click();
await expect(backup.getByRole('alert')).toContainText('Alleen beheerders');
await page.goto('/settings');
await page.getByRole('region', { name: 'Beheerfuncties' }).locator('a[href="/onboarding"]').click();
await expect(page.getByRole('heading', { name: 'Pulse is geconfigureerd' })).toBeVisible();
await expect(page.getByRole('radio')).toHaveCount(0);
await page.getByRole('button', { name: 'Herconfiguratie openen' }).click();
await expect(page.getByRole('radio')).toHaveCount(4);
await page.getByRole('button', { name: 'Annuleren' }).click();
await expect(page.getByRole('radio')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
if (testInfo.project.name === 'mobile-chromium') {
const linkHeights = await page.locator('.settings-hub-card a').evaluateAll((links) => links.map((link) => link.getBoundingClientRect().height));
expect(linkHeights.every((height) => height >= 44)).toBe(true);
}
});
@@ -0,0 +1,39 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const now = new Date().toISOString();
test('mobile incident command mode is prioritized, bounded and accessible', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-chromium', 'Mobile incident mode uses the supported 390x844 viewport.');
await page.route('**/api/v1/**', async (route) => {
const path = new URL(route.request().url()).pathname;
const body = path === '/api/v1/system/status' ? {
version: '1', generatedAt: now, overallState: 'degraded', components: [],
backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [],
} : path === '/api/v1/incidents/incident-1' ? {
incident: {
id: 'incident-1', correlationKey: 'cachepool', title: 'Cachepool bijna vol',
summary: 'Cachepool is 92% gebruikt en groeit sneller dan verwacht.', severity: 'critical',
status: 'open', startedAt: now, correlationMethod: 'temporal-window', confidence: .84,
revision: 2, updatedAt: now, ownerUserId: '', alerts: [
{ alertId: 'alert-1', rationale: 'Capaciteitsdrempel van 90% overschreden', confidence: .84, correlationMethod: 'temporal-window', manual: false, createdAt: now },
], notes: [{ id: 'note-1', incidentId: 'incident-1', author: 'Pulse', body: 'Mover is niet actief.', createdAt: now }],
},
} : {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/incidents/incident-1');
await expect(page.getByRole('heading', { level: 1, name: 'Cachepool bijna vol' })).toBeVisible();
await expect(page.locator('.incident-command-strip')).toContainText('Kritiek');
await expect(page.locator('.incident-command-strip')).toContainText('84%');
await expect(page.locator('.incident-timeline')).toContainText('Capaciteitsdrempel van 90% overschreden');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
const targets = await page.locator('button, .mobile-navigation a, .mobile-more summary').evaluateAll((items) => items.filter((item) => {
const style = getComputedStyle(item); return style.display !== 'none' && style.visibility !== 'hidden';
}).map((item) => item.getBoundingClientRect().height));
expect(targets.every((height) => height >= 44)).toBe(true);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: '../../artifacts/evidence/M14-04/mobile-incident-command.png', fullPage: true });
});
+198
View File
@@ -0,0 +1,198 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test('real stack serves collector data through database, API and UI', async ({ page }) => {
test.skip(!enabled, 'Run through scripts/integration-smoke.ps1 with an isolated real stack.');
test.setTimeout(210_000);
const login = await page.request.get('/auth/test-login');
expect(login.ok()).toBeTruthy();
const issuedSession = (await page.context().cookies()).find((cookie) => cookie.name === 'pulse_session');
expect(issuedSession, 'mock login issues the same HttpOnly session contract as OIDC').toBeDefined();
expect(issuedSession?.httpOnly).toBe(true);
for (const endpoint of ['/healthz', '/readyz']) {
const response = await page.request.get(endpoint);
expect(response.status(), endpoint).toBe(200);
expect(response.headers()['content-type'], endpoint).toContain('text/plain');
expect((await response.text()).toLowerCase(), endpoint).not.toContain('<!doctype html>');
}
for (const endpoint of ['/metrics', '/debug/pprof/']) {
const response = await page.request.get(endpoint);
expect(response.status(), endpoint).toBe(404);
expect((await response.text()).toLowerCase(), endpoint).not.toContain('<!doctype html>');
}
const endpointChecks = [
'/api/v1/system/status', '/api/v1/host', '/api/v1/processes?limit=10',
'/api/v1/containers?limit=10', '/api/v1/array', '/api/v1/disks?limit=10',
'/api/v1/pools?limit=10', '/api/v1/shares?limit=10', '/api/v1/services?limit=10',
'/api/v1/network', '/api/v1/topology?limit=10', '/api/v1/applications?limit=10', '/api/v1/events?limit=10',
'/api/v1/entities?limit=10', '/api/v1/dashboards?limit=10', '/api/v1/alert-rules?limit=10',
'/api/v1/alerts?limit=10', '/api/v1/incidents?limit=10', '/api/v1/onboarding',
];
for (const endpoint of endpointChecks) {
const response = await page.request.get(endpoint);
expect(response.status(), endpoint).toBe(200);
}
const hostResponse = await page.request.get('/api/v1/host');
const host = await hostResponse.json() as Record<string, unknown>;
expect(JSON.stringify(host)).toContain('smoke-host');
expect(JSON.stringify(host)).toContain('fresh');
const containers = await (await page.request.get('/api/v1/containers?limit=10')).json() as { containers?: Array<{ name: string; state: string; health: string; metricsAvailable?: boolean; lifecycleAvailable?: boolean }> };
const smokeContainer = containers.containers?.find((item) => item.name === 'smoke-api');
expect(smokeContainer).toMatchObject({ state: 'running', health: 'healthy', metricsAvailable: false, lifecycleAvailable: false });
const applications = await (await page.request.get('/api/v1/applications?limit=10')).json() as { applications?: Array<{ name: string; status: string }> };
expect(applications.applications?.find((item) => item.name === 'smoke')).toMatchObject({ status: 'healthy' });
await page.goto('/containers');
await expect(page.getByText(/metingen niet beschikbaar/).first()).toBeVisible();
const systemStatus = await (await page.request.get('/api/v1/system/status')).json() as {
components?: Array<{ id: string; state: string; reason: string }>;
sourceLag?: Array<{ sourceId: string; state: string }>;
};
const unraidComponent = systemStatus.components?.find((item) => item.id === 'unraid');
expect(unraidComponent, 'unraid runtime component').toBeDefined();
expect(unraidComponent?.state, 'unraid is derived from every fresh bounded agent capability').toBe('healthy');
expect(unraidComponent?.reason, 'unraid no longer uses API-local configuration').toBe('source_sampled');
const storageComponent = systemStatus.components?.find((item) => item.id === 'storage');
expect(storageComponent, 'storage runtime component').toMatchObject({ state: 'healthy', reason: 'source_sampled' });
expect(systemStatus.sourceLag?.find((source) => source.sourceId === 'unraid')?.state).toBe('healthy');
const onboarding = await (await page.request.get('/api/v1/onboarding')).json() as { capabilities?: Array<{ id: string; state: string }> };
expect(onboarding.capabilities?.find((capability) => capability.id === 'unraid')?.state).toBe('ready');
const dashboardId = '61000000-0000-4000-8000-000000000001';
const dashboard = {
schemaVersion: 2, id: dashboardId, slug: 'real-stack-metric', name: 'Real-stack CPU', description: 'Metric planner browser gate.', scope: 'system',
variables: [{ name: 'server', type: 'server', label: 'Server', default: 'smoke-host' }],
widgets: [{
id: '62000000-0000-4000-8000-000000000001', type: 'timeseries', title: 'CPU live', description: 'Catalog-bounded metric.',
data: { sourceType: 'semantic-metric', metric: 'host.cpu.utilization', scope: { serverId: '$server' }, aggregation: 'avg', transformations: [] },
visualization: { unit: 'percent', decimals: 1, legend: true, showSparkline: false, min: 0, max: 100, thresholds: [] },
behavior: { locked: true, hidden: false, hideWhenEmpty: false, showOnlyOnProblem: false, liveIntervalSeconds: 2, independentTimeRange: null },
layouts: { desktop: { x: 0, y: 0, w: 18, h: 8, visible: true }, tablet: { x: 0, y: 0, w: 8, h: 8, visible: true }, mobile: { x: 0, y: 0, w: 1, h: 8, visible: true }, wallboard: { x: 0, y: 0, w: 24, h: 12, visible: true } },
}],
settings: { defaultTimeRange: 'live', live: true, refreshSeconds: 10, rotationSeconds: 30 },
};
const dashboardResponse = await page.request.post('/api/v1/dashboards', { data: dashboard });
expect([201, 409], await dashboardResponse.text()).toContain(dashboardResponse.status());
const peerDashboard = {
...dashboard,
id: '61000000-0000-4000-8000-000000000002',
slug: 'real-stack-memory',
name: 'Real-stack geheugen',
widgets: dashboard.widgets.map((widget) => ({
...widget,
id: '62000000-0000-4000-8000-000000000002',
title: 'Geheugen live',
data: { ...widget.data, metric: 'host.memory.utilization' },
})),
};
const peerDashboardResponse = await page.request.post('/api/v1/dashboards', { data: peerDashboard });
expect([201, 409], await peerDashboardResponse.text()).toContain(peerDashboardResponse.status());
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
const websocketOpened = await page.evaluate(() => new Promise<boolean>((resolve) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${window.location.host}/api/v1/live`);
const timeout = window.setTimeout(() => { socket.close(); resolve(false); }, 5_000);
socket.addEventListener('open', () => { window.clearTimeout(timeout); socket.close(); resolve(true); }, { once: true });
socket.addEventListener('error', () => { window.clearTimeout(timeout); resolve(false); }, { once: true });
}));
expect(websocketOpened, 'same-origin WebSocket upgrade through nginx').toBe(true);
const runtimeFailures: string[] = [];
page.on('pageerror', (error) => runtimeFailures.push(error.message));
page.on('response', (response) => {
if (new URL(response.url()).pathname.startsWith('/api/') && response.status() >= 400) {
runtimeFailures.push(`${response.status()} ${response.url()}`);
}
});
const routes = ['/', '/host', '/pools', '/shares', '/storage', '/capacity', '/processes', '/containers', '/services', '/topology', '/network', '/applications', '/inventory', '/dashboards', '/alerts', '/incidents', '/settings', '/status', '/onboarding'];
for (const route of routes) {
await page.goto(route);
await expect(page.getByRole('heading', { level: 1 }).first(), route).toBeVisible();
await expect(page.locator('.state-page[role="alert"]'), route).toHaveCount(0);
}
expect(runtimeFailures).toEqual([]);
await page.addInitScript((id) => window.localStorage.setItem(`pulse.dashboard.view.${id}.range`, 'live'), dashboardId);
const metricResponse = page.waitForResponse((response) => new URL(response.url()).pathname === '/api/v1/metrics/query-range');
await page.goto('/dashboards/' + dashboardId);
await expect(page.getByRole('heading', { name: 'Real-stack CPU' })).toBeVisible();
expect((await metricResponse).status(), 'catalog-bounded query through deployed UI').toBe(200);
await expect(page.getByRole('heading', { name: 'CPU live' })).toBeVisible();
expect(runtimeFailures).toEqual([]);
// A rotating wallboard fetches the next dashboard document before its live
// subscription is ready. Exercise a response well beyond the 250 ms
// subscription-release grace and prove the bounded idle transport is reused
// across multiple rotations instead of closing and reopening.
const dashboardList = await (await page.request.get('/api/v1/dashboards?limit=100')).json() as { items?: Record<string, unknown>[] };
const wallboardDashboardIds = (dashboardList.items ?? []).map((item) => String(item.id ?? item.ID ?? '')).filter(Boolean);
expect(wallboardDashboardIds.length).toBeGreaterThanOrEqual(2);
await page.addInitScript(() => {
const NativeWebSocket = window.WebSocket;
const counters = { opened: 0, closed: 0 };
Object.defineProperty(window, '__pulseSocketLifecycle', { value: counters, configurable: true });
class TrackedWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
super(url, protocols);
counters.opened += 1;
this.addEventListener('close', () => { counters.closed += 1; }, { once: true });
}
}
window.WebSocket = TrackedWebSocket;
});
await page.addInitScript((ids) => {
ids.forEach((id) => window.localStorage.setItem(`pulse.dashboard.view.${id}.range`, 'live'));
}, wallboardDashboardIds);
let delayedDashboardLoads = 0;
await page.route('**/api/v1/dashboards/**', async (route) => {
delayedDashboardLoads += 1;
await new Promise((resolve) => setTimeout(resolve, 2_000));
try {
await route.continue();
} catch (error) {
// Going offline can settle an intentionally delayed request before the
// handler resumes. That is the failure mode under test, not a harness
// failure; every other routing error must still fail the flow.
if (!(error instanceof Error) || !error.message.includes('Route is already handled')) throw error;
}
});
await page.goto('/wallboard?interval=10&refresh=300');
await expect(page.getByRole('heading', { name: 'Operationeel wallboard' })).toBeVisible();
const visibleDashboard = page.locator('#dashboard-view-title');
await expect(visibleDashboard).toBeVisible();
await page.context().setOffline(true);
// Cross a rotation while both HTTP and WebSocket transport are unavailable.
// The wallboard must retain its last verified document instead of replacing
// operational context with a blank loading/error page.
await page.waitForTimeout(12_000);
await expect(visibleDashboard).toBeVisible();
await expect(visibleDashboard).not.toHaveText('');
await page.context().setOffline(false);
// The integration API has a 30-second idle session TTL. Staying on this page
// for 65 seconds after recovery crosses it more than twice. Dashboard refreshes must renew
// the HttpOnly cookie server-side without reopening the live transport.
await page.waitForTimeout(65_000);
expect(delayedDashboardLoads, 'initial document plus at least five rotations').toBeGreaterThanOrEqual(6);
const renewedSession = (await page.context().cookies()).find((cookie) => cookie.name === 'pulse_session');
expect(renewedSession, 'active wallboard retains its server session').toBeDefined();
expect(renewedSession?.value).toBe(issuedSession?.value);
expect(renewedSession?.expires ?? 0, 'idle deadline was renewed beyond its initial expiry').toBeGreaterThan(issuedSession?.expires ?? 0);
const statusAfterMultipleTTLs = await page.request.get('/api/v1/system/status');
expect(statusAfterMultipleTTLs.status(), 'authenticated API after multiple idle TTLs').toBe(200);
const socketLifecycle = await page.evaluate(() => (window as unknown as { __pulseSocketLifecycle: { opened: number; closed: number } }).__pulseSocketLifecycle);
expect(socketLifecycle).toEqual({ opened: 1, closed: 0 });
expect(runtimeFailures).toEqual([]);
await page.unroute('**/api/v1/dashboards/**');
await page.goto('/host');
await expect(page.getByText('smoke-host').first()).toBeVisible();
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious')).toEqual([]);
});
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
import path from 'node:path';
test('release metadata and verified backup are production truthful', async ({ page }, testInfo) => {
test.skip(!process.env.PULSE_E2E_REAL_BASE_URL, 'Requires the isolated server-built Pulse stack.');
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
const created = await page.request.post('/api/v1/system/backups');
expect(created.ok()).toBeTruthy();
const response = await page.request.get('/api/v1/system/status');
expect(response.ok()).toBeTruthy();
const status = await response.json() as {
version: string;
release: { version: string; commit: string; builtAt?: string; migrationVersion: string };
backup: { state: string; reason: string; verifiedAt?: string; ageSeconds?: number };
};
expect(status.version).not.toBe('development');
expect(status.release).toMatchObject({ version: 'm11.11-test', commit: 'm1111testcommit' });
expect(status.release.builtAt).toBe('2026-08-12T03:00:00Z');
expect(status.release.migrationVersion).toMatch(/^\d{4}_.+/);
expect(status.backup).toMatchObject({ state: 'healthy', reason: 'backup_verified' });
expect(status.backup.verifiedAt).toBeTruthy();
expect(status.backup.ageSeconds).toBeGreaterThanOrEqual(0);
await page.goto('/status');
await expect(page.getByText(/m11\.11-test/)).toBeVisible();
await expect(page.getByText(/De backup is geverifieerd/)).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({
path: path.resolve('../../artifacts/evidence/M11-11', `release-backup-${testInfo.project.name}.png`),
fullPage: true,
});
});
@@ -0,0 +1,69 @@
import { expect, test, type Page } from '@playwright/test';
import path from 'node:path';
const observedAt = new Date().toISOString();
async function mockResponsiveData(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
const body = pathname === '/api/v1/system/status' ? {
version: '1', generatedAt: observedAt, overallState: 'degraded',
components: [
{ id: 'database', state: 'healthy', reason: 'ok' },
{ id: 'prometheus', state: 'unknown', reason: 'source_stale' },
],
backup: { state: 'disabled', reason: 'not_configured' },
sourceLag: [{ sourceId: 'prometheus', state: 'unknown', reason: 'source_stale', ageSeconds: 900 }],
} : pathname === '/api/v1/host' ? {
identity: { name: 'responsive-fixture' }, cpu: { totalPercent: 42, perCore: [42, 38] },
memory: { utilizationPercent: 61 }, source: { state: 'healthy', freshness: 'fresh' },
} : pathname === '/api/v1/containers' ? { source: { state: 'healthy', freshness: 'fresh' }, containers: [], total: 0 }
: pathname === '/api/v1/pools' ? { source: { state: 'healthy', freshness: 'fresh' }, pools: [], total: 0 }
: pathname === '/api/v1/services' ? { capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 }
: pathname === '/api/v1/incidents' ? { items: [] }
: {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
}
test.beforeEach(async ({ page }) => mockResponsiveData(page));
test('mobile primary navigation and operational reasons remain readable at 390px', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-chromium', 'Mobile evidence uses the supported 390px viewport.');
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
const labels = page.locator('.mobile-primary-list .nav-link span:last-child');
await expect(labels).toHaveCount(4);
const metrics = await labels.evaluateAll((nodes) => nodes.map((node) => {
const box = node.getBoundingClientRect();
return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, font: Number.parseFloat(getComputedStyle(node).fontSize) };
}));
for (let index = 1; index < metrics.length; index += 1) expect(metrics[index - 1].right).toBeLessThanOrEqual(metrics[index].left);
expect(metrics.every((metric) => metric.font >= 12 && metric.bottom - metric.top <= 16)).toBe(true);
const reason = page.locator('.action-queue small').first();
await expect(reason).toBeVisible();
await expect(reason).toHaveCSS('white-space', 'normal');
const overflow = await page.evaluate(() => [...document.querySelectorAll<HTMLElement>('body *')]
.map((element) => ({ tag: element.tagName, className: element.className, right: Math.round(element.getBoundingClientRect().right), scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }))
.filter((item) => item.right > innerWidth + 1 || item.scrollWidth > item.clientWidth + 1)
.slice(0, 12));
expect(overflow).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-06/responsive-mobile-390.png'), fullPage: true });
});
test('tablet sidebar stays compact and content uses the remaining viewport', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'tablet-chromium', 'Tablet evidence uses the supported 1024px viewport.');
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
const sidebar = await page.locator('.sidebar').boundingBox();
const workspace = await page.locator('.app-workspace').boundingBox();
const queue = await page.locator('.action-queue').boundingBox();
const dataPlane = await page.locator('.signal-path-panel').boundingBox();
expect(sidebar?.width).toBeLessThanOrEqual(208);
expect(workspace?.width).toBeGreaterThanOrEqual(816);
expect(queue?.width).toBeGreaterThan(300);
expect(dataPlane?.width).toBeGreaterThan(500);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1024);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-06/responsive-tablet-1024.png'), fullPage: true });
});
@@ -0,0 +1,30 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test.beforeEach(async ({ page }) => {
test.skip(!enabled, 'Requires an isolated real Pulse stack.');
const login = await page.request.get('/auth/test-login');
expect(login.ok()).toBeTruthy();
});
test('real services and inventory topology retain explicit production states', async ({ page }) => {
await page.goto('/services');
await expect(page.getByRole('heading', { name: 'Bereikbaarheid en historie' })).toBeVisible();
await expect(page.getByText('Voor deze service is nog geen probe geconfigureerd.').first()).toBeVisible();
await page.goto('/topology');
await expect(page.getByRole('heading', { name: 'Relaties en services' })).toBeVisible();
await expect(page.getByRole('region', { name: 'Relaties', exact: true }).getByText('Ondersteunt')).toBeVisible();
await expect(page.getByText(/dependency-test-a-/).first()).toBeVisible();
await page.goto('/network');
const dns = page.getByRole('heading', { name: 'DNS' }).locator('../..');
await expect(dns.getByText('Niet geconfigureerd')).toBeVisible();
await expect(dns.getByText('Voor dit signaal is nog geen veilige probe geconfigureerd.')).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations.filter((violation) => ['serious', 'critical'].includes(violation.impact ?? ''))).toEqual([]);
});
@@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test';
test('expired wallboard session is revoked once without periodic 401 churn', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'wallboard-chromium', 'The long-running wallboard owns this session boundary.');
let apiRequests = 0;
await page.route('**/api/v1/**', async (route) => {
apiRequests += 1;
await route.fulfill({ status: 401, contentType: 'application/problem+json', body: JSON.stringify({ code: 'UNAUTHENTICATED' }) });
});
await page.goto('/wallboard?interval=10&refresh=10');
await expect(page.getByRole('heading', { level: 1, name: 'Geen toegang' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Aanmelden' })).toBeVisible();
await expect.poll(() => apiRequests).toBeGreaterThan(0);
const boundaryRequests = apiRequests;
// Development StrictMode mounts the four bootstrap readers twice. The
// production bundle issues one batch; neither mode may start a second one.
expect(boundaryRequests).toBeLessThanOrEqual(8);
await page.waitForTimeout(12_000);
expect(apiRequests).toBe(boundaryRequests);
await expect(page.getByRole('heading', { level: 1, name: 'Geen toegang' })).toBeVisible();
});
+130
View File
@@ -0,0 +1,130 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';
import path from 'node:path';
const observedAt = new Date().toISOString();
async function mockSignalFlow(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
const body = pathname === '/api/v1/system/status' ? {
version: '1', generatedAt: observedAt, overallState: 'degraded',
components: [
{ id: 'database', state: 'healthy', reason: 'ok' },
{ id: 'worker', state: 'healthy', reason: 'ok' },
{ id: 'prometheus', state: 'degraded', reason: 'source_stale' },
{ id: 'unraid', state: 'healthy', reason: 'ok' },
{ id: 'storage', state: 'healthy', reason: 'ok' },
],
backup: { state: 'healthy', reason: 'ok', ageSeconds: 3600 },
sourceLag: [{ sourceId: 'prometheus', state: 'degraded', reason: 'source_stale', ageSeconds: 420 }],
} : pathname === '/api/v1/host' ? {
identity: { name: 'tower' }, cpu: { totalPercent: 68.4, perCore: [72, 64, 66, 71] },
memory: { utilizationPercent: 71.2 }, source: { state: 'healthy', freshness: 'fresh' },
} : pathname === '/api/v1/containers' ? {
source: { state: 'healthy', freshness: 'fresh' }, total: 6,
containers: [
{ id: 'one', state: 'running', health: 'healthy' }, { id: 'two', state: 'running', health: 'healthy' },
{ id: 'three', state: 'running', health: 'healthy' }, { id: 'four', state: 'running', health: 'healthy' },
{ id: 'five', state: 'running', health: 'healthy' }, { id: 'six', state: 'restarting', health: 'unknown' },
],
} : pathname === '/api/v1/pools' ? {
source: { state: 'healthy', freshness: 'fresh' }, total: 2,
pools: [
{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 63.8 },
{ id: 'array', name: 'Array', state: 'healthy', capacitySeverity: 'attention', utilizationPercent: 86.1 },
],
} : pathname === '/api/v1/services' ? {
capabilityState: 'available', configurationState: 'configured', total: 3,
services: [
{ id: 'proxy', name: 'Reverse proxy', state: 'up' },
{ id: 'auth', name: 'Authentik', state: 'up' },
{ id: 'media', name: 'Media', state: 'degraded' },
],
} : pathname === '/api/v1/incidents' ? {
items: [{ id: 'incident-1', title: 'Prometheus-bron loopt achter', severity: 'warning', startedAt: observedAt }],
} : pathname === '/api/v1/dashboards' ? { items: [] } : {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
}
test.beforeEach(async ({ page }) => mockSignalFlow(page));
test('operational signal path is diagnostic, keyboard reachable and responsive', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'The wallboard keeps its dedicated bounded composition.');
const runtimeErrors: string[] = [];
page.on('pageerror', (error) => runtimeErrors.push(error.message));
page.on('console', (message) => { if (message.type() === 'error') runtimeErrors.push(message.text()); });
await page.goto('/');
const panel = page.locator('.signal-path-panel');
await expect(panel).toBeVisible();
await expect(panel.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
const stages = panel.locator('.signal-path-stage button');
await expect(stages).toHaveCount(6);
await expect(stages.first()).toHaveAttribute('aria-pressed', 'true');
await stages.nth(1).focus();
await page.keyboard.press('Enter');
await expect(stages.nth(1)).toHaveAttribute('aria-pressed', 'true');
await expect(panel.locator('.signal-path-inspector')).toContainText('68,4%');
await expect(panel.locator('.signal-path-inspector')).toContainText('71,2%');
await expect(panel.getByRole('button', { name: 'Open host' })).toBeVisible();
const stageHeights = await stages.evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
expect(stageHeights.every((height) => height >= 44)).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
if (testInfo.project.name === 'desktop-chromium') {
const severe = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
expect(severe, severe.map((violation) => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]);
}
if (process.env.PULSE_SOL_ULTRA_CAPTURE) {
await page.evaluate(() => { window.scrollTo(0, 0); (document.activeElement as HTMLElement | null)?.blur(); });
const mobile = testInfo.project.name === 'mobile-chromium';
await page.screenshot({ path: path.resolve('../../artifacts/evidence/SOL-ULTRA/visual', `overview-${testInfo.project.name}.png`), fullPage: !mobile });
if (mobile) {
await page.addStyleTag({ content: '.context-bar, .mobile-navigation { display: none !important; }' });
await panel.screenshot({ path: path.resolve('../../artifacts/evidence/SOL-ULTRA/visual/overview-mobile-chromium-signal.png') });
}
}
expect(runtimeErrors).toEqual([]);
if (testInfo.project.name === 'desktop-chromium') {
await panel.getByRole('button', { name: 'Open host' }).click();
await expect(page).toHaveURL(/\/host$/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
}
});
test('failed overview resources remain unknown and recover through the shared retry', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'The data-state contract is viewport independent.');
let poolCalls = 0;
let allowPoolRecovery = false;
await page.route('**/api/v1/pools?**', async (route) => {
poolCalls += 1;
if (!allowPoolRecovery) {
await route.fulfill({ status: 503, contentType: 'application/problem+json', body: JSON.stringify({ code: 'SOURCE_UNAVAILABLE' }) });
return;
}
await route.fallback();
});
await page.goto('/');
const storage = page.locator('.signal-path-stage').filter({ hasText: 'Opslag' });
await expect(storage).toHaveAttribute('data-tone', 'unknown');
await expect(storage).toContainText('Niet beschikbaar');
await expect(page.locator('.overview-kpi').filter({ hasText: 'Hoogste poolgebruik' }).locator('strong')).toHaveText('—');
await expect(page.locator('.capacity-plane')).toContainText('Deze overzichtsbron kon niet worden geladen');
await expect(page.getByRole('heading', { level: 1, name: 'Aandacht vereist' })).toBeVisible();
allowPoolRecovery = true;
await page.getByRole('button', { name: 'Opnieuw laden' }).click();
await expect(storage).toContainText('Aandacht');
await expect(storage).not.toContainText('Niet beschikbaar');
expect(poolCalls).toBeGreaterThanOrEqual(2);
});
test('signal animation yields to reduced-motion preference', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'Reduced-motion CSS is viewport independent.');
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/');
const animation = await page.locator('.signal-path-stage--healthy').first().evaluate((element) => getComputedStyle(element, '::before').animationDuration);
expect(Number.parseFloat(animation)).toBeLessThanOrEqual(0.001);
});
@@ -0,0 +1,55 @@
import { expect, test, type Page } from '@playwright/test';
const zeroTimestamp = '0001-01-01T00:00:00Z';
async function mockSourceStatusData(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
const source = { id: 'unraid', state: 'healthy', freshness: 'stale', observedAt: zeroTimestamp, reason: 'source_stale' };
const body = pathname === '/api/v1/host' ? {
source,
identity: { name: 'Tower', version: '7.2.0' },
uptimeSeconds: 3600,
cpu: { totalPercent: 12, perCore: [12], iowaitPercent: 0 },
load: { one: 0.1, five: 0.2, fifteen: 0.3 },
memory: { totalBytes: 1024, availableBytes: 512, usedBytes: 512, utilizationPercent: 50, swapTotalBytes: 0, swapUsedBytes: 0, swapUtilizationPercent: 0 },
filesystems: [], network: [], time: { synchronized: true, offsetSeconds: 0, state: 'healthy' },
status: { state: 'unknown', reasons: [{ code: 'filesystem_root_not_configured', message: 'technical' }] },
observedAt: zeroTimestamp, receivedAt: zeroTimestamp,
warnings: ['filesystem_root_not_configured'],
} : pathname === '/api/v1/array' ? {
source, state: 'unknown', parity: { present: false, state: 'unknown', errors: 0 }, members: [],
} : pathname === '/api/v1/disks' ? {
source: { ...source, id: 'unraid-disks', reason: 'filesystem_root_not_configured' }, total: 0, disks: [],
} : pathname === '/api/v1/pools' ? {
source: { ...source, id: 'unraid-pools' }, total: 0, pools: [],
} : pathname === '/api/v1/applications' ? {
source, total: 0, applications: [],
} : pathname === '/api/v1/system/status' ? {
version: '1', generatedAt: zeroTimestamp, overallState: 'unknown', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [],
} : {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
}
test.beforeEach(async ({ page }) => mockSourceStatusData(page));
test('source status is human-readable and technically progressive on core routes', async ({ page }) => {
for (const route of ['/host', '/storage', '/applications']) {
await page.goto(route);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByText('Nooit ontvangen').first()).toBeVisible();
await expect(page.getByText(/laatste meting is verouderd/i).first()).toBeVisible();
const visibleText = await page.locator('body').innerText();
expect(visibleText).not.toContain('source_stale');
expect(visibleText).not.toContain('filesystem_root_not_configured');
expect(visibleText).not.toMatch(/1 jan(?:uari)? 1/i);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
}
await page.goto('/host');
const technical = page.locator('.source-status-technical').first();
await expect(technical).not.toHaveAttribute('open');
await technical.getByText('Technische broninformatie').click();
await expect(technical.getByText('source_stale')).toBeVisible();
});
@@ -0,0 +1,32 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
test('storage map keeps physical identity and signal severities truthful', async ({ page }) => {
test.skip(!enabled, 'Run against an isolated real stack with the M11-03 storage fixture.');
const login = await page.request.get('/auth/test-login');
expect(login.ok()).toBeTruthy();
const disks = await (await page.request.get('/api/v1/disks?limit=100')).json() as { disks: Array<{ id: string; state: string; capacitySeverity: string; thermalSeverity: string }> };
expect(disks.disks.find((disk) => disk.id === 'disk-10')).toMatchObject({ state: 'online', capacitySeverity: 'critical', thermalSeverity: 'normal' });
expect(disks.disks.find((disk) => disk.id === 'cache')).toMatchObject({ state: 'online', capacitySeverity: 'attention', thermalSeverity: 'critical' });
const pools = await (await page.request.get('/api/v1/pools?limit=100')).json() as { pools: Array<{ id: string; state: string; capacitySeverity: string }> };
expect(pools.pools.find((pool) => pool.id === 'cache')).toMatchObject({ state: 'healthy', capacitySeverity: 'attention' });
await page.goto('/storage');
await expect(page.getByRole('heading', { level: 1, name: 'Opslagoverzicht' })).toBeVisible();
const visualNodes = page.locator('.storage-map-node');
await expect(visualNodes).toHaveCount(3);
await expect(visualNodes.filter({ hasText: 'disk10' })).toHaveCount(1);
await expect(visualNodes.filter({ hasText: 'disk10' })).toContainText('capaciteit kritiek');
await expect(visualNodes.filter({ hasText: 'cache' })).toHaveCount(2);
await expect(page.locator('.storage-heatmap-cell')).toHaveCount(2);
await expect(page.locator('.storage-heatmap-cell--critical')).toHaveCount(1);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations.filter((violation) => ['serious', 'critical'].includes(violation.impact ?? ''))).toEqual([]);
});
@@ -0,0 +1,62 @@
import { expect, test } from '@playwright/test';
const dashboard = { id: 'wallboard-dashboard', slug: 'operations', name: 'Operaties', description: 'Kritieke infrastructuur en recente gebeurtenissen.', scope: 'system', revision: 1, currentVersion: 1 };
const widgets = [
['system', 'Serverstatus', 0, 0, 6, 4, 'inventory', { entityType: 'server' }],
['cpu', 'CPU en belasting', 6, 0, 10, 6, 'text', {}],
['storage', 'Array en pools', 0, 6, 12, 7, 'text', {}],
['apps', 'Applicaties', 12, 6, 12, 7, 'text', {}],
['events', 'Recente gebeurtenissen', 0, 13, 24, 6, 'events', {}],
].map(([id, title, x, y, w, h, sourceType, scope]) => ({
id, title, type: id === 'events' ? 'event-timeline' : 'stat',
data: { sourceType, scope, limit: 12 }, behavior: { liveIntervalSeconds: 30 },
layouts: { wallboard: { x, y, w, h, visible: true }, desktop: { x: 0, y: 0, w: 6, h: 4, visible: true } },
}));
test('wallboard rotates bounded 1080p slides with truthful transport and data status', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'wallboard-chromium', 'Requires the 1920x1080 wallboard viewport.');
let dashboardReads = 0;
await page.route('**/api/v1/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/api/v1/dashboards') {
dashboardReads += 1;
// React development mode performs an initial StrictMode re-read. Fail the
// first scheduled refresh, not either of the initial bootstrap reads.
if (dashboardReads === 3) {
await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary_unavailable' }) });
return;
}
}
const body = path === '/api/v1/dashboards' ? { items: [dashboard] }
: path === '/api/v1/dashboards/wallboard-dashboard' ? { dashboard, version: { document: { widgets, variables: [] } } }
: path === '/api/v1/system/status' ? { version: '1', generatedAt: new Date().toISOString(), overallState: 'degraded', components: [{ id: 'storage', state: 'degraded', reason: 'capacity_critical' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'fresh' }] }
: path === '/api/v1/services' ? { services: [{ id: 'up', state: 'up' }, { id: 'down', state: 'down' }] }
: path === '/api/v1/incidents' ? { items: [{ id: 'incident-1' }] }
: path === '/api/v1/events' ? { items: [{ id: 'event-1', type: 'service.down', severity: 'critical', summary: 'Service niet beschikbaar', occurredAt: new Date().toISOString() }] }
: {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
const startedAt = Date.now();
await page.goto('/wallboard?interval=15&refresh=10');
await expect(page.getByRole('heading', { level: 1, name: 'Operationeel wallboard' })).toBeVisible();
expect(Date.now() - startedAt).toBeLessThan(3_000);
await expect(page.getByText('Slide 1 / 2')).toBeVisible();
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden');
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
await expect(page.locator('.wallboard-priority')).toContainText('OpslagVerstoord');
await expect(page.locator('.wallboard-priority')).toContainText('Services1 problemen');
await expect(page.locator('.wallboard-priority')).toContainText('Incidenten1 open');
await expect(page.getByRole('button', { name: /Bewerken|Exporteren/ })).toHaveCount(0);
expect(await page.evaluate(() => ({ horizontal: document.documentElement.scrollWidth - innerWidth, vertical: document.documentElement.scrollHeight - innerHeight }))).toEqual({ horizontal: 0, vertical: 0 });
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: `../../artifacts/evidence/M14-04/wallboard-slide-1-${testInfo.project.name}.png` });
await expect(page.getByText('Slide 2 / 2')).toBeVisible({ timeout: 18_000 });
await expect(page.getByRole('heading', { level: 3, name: 'Recente gebeurtenissen' })).toBeVisible();
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Bron niet beschikbaar', { timeout: 12_000 });
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden', { timeout: 12_000 });
await expect(page.getByText('Slide 2 / 2')).toBeVisible();
expect(await page.evaluate(() => ({ horizontal: document.documentElement.scrollWidth - innerWidth, vertical: document.documentElement.scrollHeight - innerHeight }))).toEqual({ horizontal: 0, vertical: 0 });
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: `../../artifacts/evidence/M14-04/wallboard-slide-2-${testInfo.project.name}.png` });
});
+15
View File
@@ -0,0 +1,15 @@
import '@testing-library/jest-dom/vitest';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
});
+126
View File
@@ -0,0 +1,126 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AlertRulesPage } from '../../src/AlertRulesPage';
afterEach(() => { cleanup(); vi.unstubAllGlobals(); window.history.replaceState({}, '', '/alerts'); });
describe('begeleide alertregelbewerking', () => {
it('houdt de werkruimte gesloten wanneer het regelscontract toegang weigert', async () => {
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
if (String(input).includes('/alert-rules?')) return Promise.resolve(new Response('{}', { status: 403 }));
return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}));
render(<AlertRulesPage />);
expect(await screen.findByRole('heading', { name: 'Geen toegang tot alertregels' })).toBeVisible();
expect(screen.queryByRole('navigation', { name: 'Werkruimte voor meldingen' })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Actieve en recente meldingen' })).not.toBeInTheDocument();
});
it('laadt de metriccatalogus en blokkeert een ongeldige regel', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [
{ semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' },
] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
if (url.includes('/alert-silences') || url.includes('/maintenance-windows') || url.includes('/alerts?')) return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
});
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
render(<AlertRulesPage />);
await user.click((await screen.findByText('Alertregels')).closest('button')!);
const save = await screen.findByRole('button', { name: 'Regel opslaan' });
expect(save).toBeDisabled();
const metric = screen.getByRole('combobox', { name: /Meting/ }) as HTMLSelectElement;
expect(metric).toHaveTextContent('CPU-gebruik van de host (%)');
expect(screen.queryByText('host.cpu.utilization')).not.toBeInTheDocument();
await user.type(document.querySelector<HTMLInputElement>('#alert-rule-name')!, 'Hoge hostbelasting');
await user.selectOptions(metric, 'host.cpu.utilization');
expect(save).toBeEnabled();
const technical = screen.getByText('Technische regelgegevens').closest('details');
expect(technical).not.toHaveAttribute('open');
expect(screen.getByLabelText('Host niet bereikbaar')).not.toBeChecked();
await user.click(screen.getByLabelText('Host niet bereikbaar'));
expect(screen.getByLabelText('Host niet bereikbaar')).toBeChecked();
await user.click(screen.getByText('Stiltes en onderhoud').closest('button')!);
expect(document.querySelector('#silence-matcher')).toHaveTextContent('Kritiek');
expect(document.querySelector('#maintenance-selector')).toHaveTextContent('Host');
expect(window.location.search).toBe('?section=controls');
});
it('laat bestaande niet-metrische regels met typeafhankelijke validatie bewerken', async () => {
const eventRule = {
id: '81111111-1111-4111-8111-111111111111', schemaVersion: 1, name: 'Container bevindt zich in een herstartlus', enabled: true, severity: 'degraded', scope: {},
condition: { inputType: 'event', operator: '>=', threshold: 3, aggregation: 'count', windowSeconds: 900 }, evaluationIntervalSeconds: 30, pendingSeconds: 0, resolveSeconds: 900, cooldownSeconds: 0,
unknownBehavior: 'become-unknown', groupBy: [], suppressWhen: ['host.unreachable'], message: { titleKey: 'alerts.restart.title', bodyKey: 'alerts.restart.body' }, revision: 1, currentVersion: 1,
};
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
if (url.includes('/alert-rules?')) return Promise.resolve(new Response(JSON.stringify({ items: [eventRule] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}));
render(<AlertRulesPage />);
await userEvent.setup().click((await screen.findByText('Alertregels')).closest('button')!);
expect(await screen.findByDisplayValue('Container bevindt zich in een herstartlus')).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /Signaalbron/ })).toHaveValue('event');
expect(screen.queryByRole('combobox', { name: /Meting/ })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Regel opslaan' })).toBeEnabled();
});
it('zet kritieke actieve meldingen vooraan en bewaart confirmatie, revisie en idempotentie', async () => {
const alerts = Array.from({ length: 25 }, (_, index) => ({
id: `alert-${String(index + 1).padStart(2, '0')}`,
state: index === 1 ? 'acknowledged' : 'firing',
retainedState: 'firing',
ruleName: `Melding ${String(index + 1).padStart(2, '0')}`,
severity: index % 5 === 0 ? 'critical' : 'attention',
entityName: 'Tower',
reason: 'threshold_exceeded',
revision: index + 1,
updatedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
}));
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
if (url.includes('/alert-rules?')) return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
if (url.includes('/alerts?')) return Promise.resolve(new Response(JSON.stringify({ items: alerts }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
return Promise.resolve(new Response(JSON.stringify({ alert: alerts[0] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
});
vi.stubGlobal('fetch', fetchMock);
const confirm = vi.fn(() => false);
vi.stubGlobal('confirm', confirm);
const user = userEvent.setup();
render(<AlertRulesPage />);
const critical = (await screen.findByText('Kritiek actief')).closest('button')!;
expect(screen.getByText('Actief').closest('button')).toHaveAttribute('aria-pressed', 'true');
await waitFor(() => expect(document.querySelectorAll('.alert-operation-list-items li')).toHaveLength(20));
expect(document.querySelector('.alert-operation-list-items li')).toHaveTextContent('Kritiek');
await user.click(critical);
await waitFor(() => expect(document.querySelectorAll('.alert-operation-list-items li')).toHaveLength(5));
const acknowledge = screen.getAllByRole('button', { name: 'Erkennen' })[0];
await user.click(acknowledge);
expect(confirm).toHaveBeenCalledWith('Deze melding erkennen? De evaluatie en geschiedenis blijven behouden.');
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false);
confirm.mockReturnValue(true);
await user.click(acknowledge);
const operation = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(operation?.[1]?.headers).toMatchObject({ 'If-Match': '1' });
expect((operation?.[1]?.headers as Record<string, string>)['Idempotency-Key']).toBeTruthy();
});
});
+269
View File
@@ -0,0 +1,269 @@
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import App from '../../src/App';
import { resetSystemStatusForTests } from '../../src/systemStatus';
afterEach(() => {
cleanup();
resetSystemStatusForTests();
vi.unstubAllGlobals();
window.history.replaceState({}, '', '/');
});
function json(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } });
}
const healthySource = { state: 'healthy', freshness: 'fresh' };
describe('Stitch command overview', () => {
it('shows real source values while failed sources remain explicitly unavailable', async () => {
window.history.replaceState({}, '', '/');
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({
version: '1', generatedAt: now, overallState: 'healthy', components: [
{ id: 'database', state: 'healthy', reason: 'ok' },
{ id: 'prometheus', state: 'healthy', reason: 'ok' },
], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'prometheus', state: 'degraded', reason: 'source_stale', ageSeconds: 420 }],
});
if (url === '/api/v1/host') return json({
identity: { name: 'tower-lab' },
cpu: { totalPercent: 37.5, perCore: [25, 50] },
memory: { utilizationPercent: 62.25 },
source: healthySource,
});
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [{ id: 'running', state: 'RUNNING', health: 'healthy' }, { id: 'stopped', state: 'stopped', health: 'unknown' }], total: 2 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'svc-1', name: 'API', state: 'up' }], total: 1 });
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
if (url.startsWith('/api/v1/pools')) return json({ error: 'source unavailable' }, 503);
return json({ error: 'unexpected request' }, 404);
}));
render(<App />);
expect((await screen.findAllByText('37,5%'))[0]).toBeVisible();
expect(document.querySelector('.instrument-band')).toBeInTheDocument();
expect(document.querySelector('.data-plane')).toBeInTheDocument();
expect(document.querySelector('.focus-panel')).toBeInTheDocument();
expect(document.querySelector('.context-inspector')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
const unavailableStorage = screen.getByRole('button', { name: /Opslag: Niet beschikbaar/ });
expect(unavailableStorage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
const staleSource = screen.getByRole('button', { name: /Bronnen: Aandacht.*0\/1/ });
expect(staleSource.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'attention');
expect(screen.getAllByText('62,3%')[0]).toBeVisible();
expect(screen.getByText('tower-lab')).toBeVisible();
expect(screen.getByRole('button', { name: /Workloads: Kritiek.*1\/2/ })).toBeVisible();
expect(screen.queryByText('0 pools')).not.toBeInTheDocument();
const storageCard = screen.getByRole('heading', { name: 'Capaciteit en toestand' }).closest('article');
expect(storageCard).not.toBeNull();
expect(within(storageCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
expect(screen.getByText('Opslag: Niet beschikbaar')).toBeVisible();
expect(screen.getByRole('heading', { name: 'Aandacht vereist' })).toBeVisible();
});
it('suppresses current workload claims when retained container data is stale', async () => {
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.startsWith('/api/v1/containers')) return json({ source: { state: 'unknown', freshness: 'stale' }, containers: [{ id: 'retained', state: 'running', health: 'healthy' }], total: 1 });
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [], total: 0 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
return json({}, 404);
}));
render(<App />);
expect(await screen.findByRole('button', { name: /Workloads: Verouderd.*actieve containers: —/ })).toBeVisible();
const workloadCard = screen.getByRole('heading', { name: 'Containers' }).closest('article');
expect(workloadCard).not.toBeNull();
expect(within(workloadCard!).getByText(/laatst bekende waarden worden niet als actueel getoond/i)).toBeVisible();
expect(within(workloadCard!).queryByText(/1 van 1 containers zijn actief/)).not.toBeInTheDocument();
});
it('settles independent resources while a single endpoint is still pending', async () => {
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'api', name: 'API', state: 'up' }], total: 1 });
if (url.startsWith('/api/v1/incidents')) return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
});
return json({}, 404);
}));
render(<App />);
expect(await screen.findByRole('button', { name: /Host: Gezond.*30%.*40%/ })).toBeVisible();
expect(screen.getByRole('button', { name: /Incidenten: Wordt geladen/ })).toBeVisible();
expect(screen.getByRole('heading', { level: 1, name: 'Status nog niet bevestigd' })).toBeVisible();
const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
expect(incidentCard).not.toBeNull();
expect(within(incidentCard!).getByText(/wordt geladen; er wordt nog geen toestand verondersteld/)).toBeVisible();
});
it('does not present an unavailable incident feed as an empty healthy feed', async () => {
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [], total: 0 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.startsWith('/api/v1/incidents')) return json({ code: 'UNAVAILABLE' }, 503);
return json({}, 404);
}));
render(<App />);
expect(await screen.findByRole('button', { name: /Incidenten: Niet beschikbaar/ })).toBeVisible();
const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
expect(incidentCard).not.toBeNull();
expect(within(incidentCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
expect(within(incidentCard!).queryByText('Er zijn geen open incidenten geregistreerd.')).not.toBeInTheDocument();
});
it('distinguishes forbidden resources from an expired session', async () => {
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ code: 'FORBIDDEN' }, 403);
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
if (url.startsWith('/api/v1/pools')) return json({ code: 'FORBIDDEN' }, 403);
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
return json({}, 404);
}));
render(<App />);
expect(await screen.findByRole('button', { name: /Bronnen: Geen toegang/ })).toBeVisible();
expect(await screen.findByRole('button', { name: /Opslag: Geen toegang/ })).toBeVisible();
expect(screen.getByText('Opslag: Geen toegang')).toBeVisible();
expect(screen.getAllByText(/account heeft geen toegang tot deze overzichtsbron/).length).toBeGreaterThan(0);
expect(screen.queryByRole('link', { name: /Aanmelden/ })).not.toBeInTheDocument();
});
it('prioriteert kritieke poolcapaciteit boven gezonde device-health', async () => {
const now = new Date().toISOString();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [{ id: 'ssd', name: 'ssd', state: 'healthy', capacitySeverity: 'critical', utilizationPercent: 98.8 }], total: 1 });
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
if (url === '/api/v1/host') return json({ source: healthySource });
return json({}, 404);
}));
render(<App />);
expect(await screen.findByText('ssd: Kritiek')).toBeVisible();
expect(screen.getByText(/kritieke capaciteitsgrens is overschreden/)).toBeVisible();
expect(screen.getByText('Kritiek · device-health gezond')).toBeVisible();
expect(await screen.findByRole('heading', { name: 'Aandacht vereist' })).toBeVisible();
await waitFor(() => expect(screen.getByRole('button', { name: /Opslag: Kritiek/ })).toHaveAttribute('aria-pressed', 'true'));
});
it('retries every overview resource from the shared retry action', async () => {
const user = userEvent.setup();
const now = new Date().toISOString();
let poolCalls = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'ok' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
if (url.startsWith('/api/v1/pools')) {
poolCalls += 1;
if (poolCalls === 1) return json({ code: 'POOLS_UNAVAILABLE' }, 503);
return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
}
return json({}, 404);
}));
render(<App />);
expect(await screen.findByText('Opslag: Niet beschikbaar')).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Opnieuw laden' }));
expect(await screen.findByRole('button', { name: /Opslag: Gezond/ })).toBeVisible();
expect(poolCalls).toBe(2);
expect(screen.queryByText('Opslag: Niet beschikbaar')).not.toBeInTheDocument();
});
it('reads the bounded container target scale and marks truncated services partial', async () => {
const now = new Date().toISOString();
const containers = Array.from({ length: 150 }, (_, index) => ({ id: `container-${String(index).padStart(3, '0')}`, state: 'running', health: 'healthy' }));
const services = Array.from({ length: 100 }, (_, index) => ({ id: `service-${String(index).padStart(3, '0')}`, name: `Service ${index}`, state: 'up' }));
let containerCalls = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const raw = String(input);
const url = new URL(raw, 'http://pulse.test');
if (raw === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'ok' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
if (url.pathname === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.pathname === '/api/v1/containers') {
containerCalls += 1;
const offset = Number(url.searchParams.get('after') ?? 0);
const page = containers.slice(offset, offset + 100);
return json({ source: healthySource, containers: page, total: containers.length, nextCursor: offset + page.length < containers.length ? String(offset + page.length) : undefined });
}
if (url.pathname === '/api/v1/pools') return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
if (url.pathname === '/api/v1/services') return json({ capabilityState: 'available', configurationState: 'configured', services, total: 150 });
if (url.pathname === '/api/v1/incidents') return json({ items: [] });
return json({}, 404);
}));
render(<App />);
expect(await screen.findByRole('button', { name: /Workloads: Gezond.*150\/150/ })).toBeVisible();
const serviceStage = screen.getByRole('button', { name: /Services: Gedeeltelijke data.*≥100\/150/ });
expect(serviceStage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
expect(screen.getByText('Services: Gedeeltelijke data')).toBeVisible();
expect(containerCalls).toBe(2);
});
it('bounds container paging and does not inflate totals with overlapping rows', async () => {
const now = new Date().toISOString();
let containerCalls = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const raw = String(input);
const url = new URL(raw, 'http://pulse.test');
if (raw === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
if (url.pathname === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
if (url.pathname === '/api/v1/containers') {
containerCalls += 1;
const start = url.searchParams.get('after') === '100' ? 99 : url.searchParams.get('after') === '200' ? 199 : 0;
const items = Array.from({ length: 100 }, (_, index) => ({ id: `container-${start + index}`, state: 'running', health: 'healthy' }));
const nextCursor = containerCalls === 1 ? '100' : containerCalls === 2 ? '200' : '300';
return json({ source: healthySource, containers: items, total: 400, nextCursor });
}
if (url.pathname === '/api/v1/pools') return json({ source: healthySource, pools: [], total: 0 });
if (url.pathname === '/api/v1/services') return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
if (url.pathname === '/api/v1/incidents') return json({ items: [] });
return json({}, 404);
}));
render(<App />);
const workloadStage = await screen.findByRole('button', { name: /Workloads: Gedeeltelijke data.*≥299\/400/ });
expect(workloadStage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
expect(screen.getByText('Workloads: Gedeeltelijke data')).toBeVisible();
expect(containerCalls).toBe(3);
});
});
@@ -0,0 +1,43 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApplicationPage } from '../../src/ApplicationPage';
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
describe('application status projection', () => {
it('uses attention for failures and unknown for incomplete evidence', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
source: { id: 'agent+services', state: 'healthy', freshness: 'fresh' },
total: 2,
applications: [
{ id: 'a', name: 'attention-app', status: 'DOWN', overridden: false, components: [] },
{ id: 'b', name: 'unknown-app', status: 'unknown', overridden: false, components: [] },
],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ApplicationPage />);
const attention = (await screen.findByText('attention-app')).closest('li');
const unknown = screen.getByText('unknown-app').closest('li');
expect(attention).not.toBeNull();
expect(unknown).not.toBeNull();
expect(within(attention!).getByText('Aandacht').closest('.status-badge')).toHaveClass('status-badge--attention');
expect(within(unknown!).getByText('Onbekend').closest('.status-badge')).toHaveClass('status-badge--unknown');
});
it('vertaalt staleness en toont nooit een jaar-1-waarneming als echte tijd', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
source: { id: 'agent+services', state: 'healthy', freshness: 'stale', observedAt: '0001-01-01T00:00:00Z', reason: 'source_stale' },
total: 0,
applications: [],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ApplicationPage />);
expect(await screen.findByText(/laatste meting is verouderd/i)).toBeVisible();
expect(screen.getByText('Nooit ontvangen')).toBeVisible();
expect(screen.queryByText(/1 jan 1/i)).not.toBeInTheDocument();
expect(screen.getByText('source_stale')).not.toBeVisible();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ArrayPage } from '../../src/ArrayPage';
afterEach(() => vi.unstubAllGlobals());
describe('ArrayPage', () => {
it('renders a bounded empty state when an older snapshot contains null collections', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
contractVersion: '1',
source: { id: 'array', state: 'unknown', freshness: 'unavailable' },
state: 'unknown',
parity: { present: false, state: 'unknown', errors: 0 },
members: null,
history: null,
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ArrayPage />);
expect(await screen.findByRole('heading', { level: 1, name: 'Array en parity' })).toBeVisible();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CapacityPage } from '../../src/CapacityPage';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('CapacityPage forecast qualification', () => {
it('does not count an insufficient assessment as a forecast', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
contractVersion: 'v1', generatedAt: '2026-08-12T01:00:00Z',
policy: { enabled: true, windowSeconds: 2592000, minPoints: 3, method: 'linear_median_rate' },
qualifiedCount: 0,
items: [{ entityId: 'media', name: 'Media', kind: 'share', enabled: true, method: 'insufficient_data', windowSeconds: 2592000, dataPoints: 1, confidence: 'none', currentUsedBytes: 100, capacityBytes: 0, rateBytesPerDay: 0, reason: 'insufficient_points' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<CapacityPage />);
expect(await screen.findByText(/0 gekwalificeerde prognoses/)).toBeVisible();
expect(screen.getByRole('heading', { name: 'Media' })).toBeVisible();
expect(screen.getByText('Er zijn minder historische metingen dan het ingestelde minimum.')).toBeVisible();
expect(screen.queryByText('0 B / 0 B')).not.toBeInTheDocument();
});
it('renders a concrete empty state without a synthetic entity', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
contractVersion: 'v1', generatedAt: '2026-08-12T01:00:00Z', reason: 'source_unavailable',
policy: { enabled: true, windowSeconds: 2592000, minPoints: 3, method: 'linear_median_rate' },
qualifiedCount: 0, items: [],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<CapacityPage />);
expect(await screen.findByRole('heading', { name: 'Nog geen bruikbare capaciteitsprognose' })).toBeVisible();
expect(screen.getByRole('link', { name: 'Bekijk shares en groeihistorie' })).toHaveAttribute('href', '/shares');
});
});
@@ -0,0 +1,48 @@
import { render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ContainerPage } from '../../src/ContainerPage';
afterEach(() => vi.unstubAllGlobals());
describe('container status projection', () => {
it('normalizes presentation and never fabricates missing health or metrics', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
source: { id: 'unraid', state: 'healthy', freshness: 'fresh' },
total: 2,
containers: [
{ id: 'a', name: 'alpha', state: 'RUNNING', health: 'unknown', intentionalStop: false, metricsAvailable: false, lifecycleAvailable: false, uptimeSeconds: 0, restartCount: 0, exitCode: 0, cpuPercent: 0, memoryBytes: 0, memoryLimitBytes: 0, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 },
{ id: 'b', name: 'beta', state: 'restarting', health: 'unhealthy', intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 60, restartCount: 4, exitCode: 137, cpuPercent: 12.5, memoryBytes: 1024, memoryLimitBytes: 2048, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 },
],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ContainerPage />);
const alpha = (await screen.findAllByRole('link', { name: 'alpha' })).map((item) => item.closest('tr')).find(Boolean);
const beta = screen.getAllByText('beta').map((item) => item.closest('tr')).find(Boolean);
expect(alpha).not.toBeNull();
expect(beta).not.toBeNull();
expect(within(alpha!).getByText('Actief').closest('.status-badge')).toHaveClass('status-badge--ready');
expect(within(alpha!).getAllByText('Onbekend').length).toBeGreaterThanOrEqual(2);
expect(within(alpha!).getByText(/metingen niet beschikbaar/)).toBeVisible();
expect(within(beta!).getByText('Wordt herstart').closest('.status-badge')).toHaveClass('status-badge--attention');
expect(within(beta!).getByText('Ongezond').closest('.status-badge')).toHaveClass('status-badge--attention');
expect(within(beta!).getByText('12,5%')).toBeVisible();
});
it('does not render stale item states as ready', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
source: { id: 'unraid', state: 'unknown', freshness: 'stale', reason: 'stale_source' },
total: 1,
containers: [{ id: 'a', name: 'stale-alpha', state: 'running', health: 'healthy', intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 60, restartCount: 0, exitCode: 0, cpuPercent: 10, memoryBytes: 1024, memoryLimitBytes: 2048, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ContainerPage />);
const row = (await screen.findAllByRole('link', { name: 'stale-alpha' })).map((item) => item.closest('tr')).find(Boolean);
expect(row).not.toBeNull();
expect(row!.querySelectorAll('.status-badge--unknown')).toHaveLength(2);
expect(within(row!).queryByText('running')).not.toBeInTheDocument();
expect(within(row!).queryByText('healthy')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import App from '../../src/App';
afterEach(() => {
vi.unstubAllGlobals();
window.history.replaceState({}, '', '/');
});
describe('dashboard request caching', () => {
it('does not reuse dashboard responses across authentication or onboarding changes', async () => {
window.history.replaceState({}, '', '/dashboards');
const requests: Array<{ url: string; init?: RequestInit }> = [];
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
requests.push({ url, init });
if (url.startsWith('/api/v1/dashboards')) {
return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ error: 'not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
}));
render(<App />);
expect(await screen.findByRole('heading', { level: 1, name: 'Jouw dashboards' })).toBeVisible();
const dashboardRequest = requests.find((request) => request.url.startsWith('/api/v1/dashboards'));
expect(dashboardRequest?.init?.cache).toBe('no-store');
});
});
+81
View File
@@ -0,0 +1,81 @@
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EventsPage } from '../../src/EventsPage';
const items = Array.from({ length: 100 }, (_, index) => ({
id: `event-${String(index + 1).padStart(3, '0')}`,
type: index % 2 === 0 ? 'service.down' : 'container.restart',
severity: index % 10 === 0 ? 'critical' : index % 3 === 0 ? 'warning' : 'info',
entityId: `entity-${String(index % 5).padStart(2, '0')}`,
sourceId: 'source-unraid',
occurredAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
receivedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 5) - index * 60_000).toISOString(),
summary: `Gebeurtenis ${String(index + 1).padStart(3, '0')}`,
}));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
window.history.replaceState({}, '', '/events');
});
function mockEvents(payload = items) {
const fetch = vi.fn(async () => new Response(JSON.stringify({ items: payload }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
vi.stubGlobal('fetch', fetch);
return fetch;
}
describe('compacte eventtijdlijn', () => {
it('houdt honderd events begrensd en pagineert deterministisch met focus', async () => {
const fetch = mockEvents();
const user = userEvent.setup();
render(<EventsPage />);
const list = await screen.findByRole('list', { name: 'Resultaten' });
expect(within(list).getAllByRole('listitem')).toHaveLength(20);
expect(within(list).getByText('event-001')).toBeInTheDocument();
expect(within(list).queryByText('event-021')).not.toBeInTheDocument();
const next = screen.getByRole('button', { name: 'Volgende pagina' });
await user.click(next);
await waitFor(() => expect(screen.getByText(/Pagina 2 van 5/)).toHaveFocus());
expect(within(list).getByText('event-021')).toBeInTheDocument();
expect(window.location.search).toContain('page=2');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('filtert ernst, soort, onderdeel en vrije tekst zonder nieuwe request', async () => {
const fetch = mockEvents();
const user = userEvent.setup();
render(<EventsPage />);
const list = await screen.findByRole('list', { name: 'Resultaten' });
await user.selectOptions(screen.getByLabelText('Ernst'), 'critical');
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
await user.selectOptions(screen.getByLabelText('Soort'), 'service.down');
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
await user.selectOptions(screen.getByLabelText('Onderdeel'), 'entity-00');
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
await user.clear(screen.getByLabelText('Zoeken'));
await user.type(screen.getByLabelText('Zoeken'), 'Gebeurtenis 091');
expect(within(list).getAllByRole('listitem')).toHaveLength(1);
expect(within(list).getByText('event-091')).toBeInTheDocument();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('houdt het kritieke totaal zichtbaar en biedt een herstelbare lege state', async () => {
mockEvents();
const user = userEvent.setup();
render(<EventsPage />);
const critical = await screen.findByRole('button', { name: /Kritieke gebeurtenissen/i });
expect(critical).toHaveTextContent('10');
await user.type(screen.getByLabelText('Zoeken'), 'bestaat-niet');
expect(screen.getByText('Geen gebeurtenissen binnen de huidige filters.')).toBeVisible();
expect(critical).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Alle filters wissen' }));
expect(await screen.findByRole('list', { name: 'Resultaten' })).toBeVisible();
});
});
@@ -0,0 +1,52 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InventoryPage } from '../../src/InventoryPage';
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
const json = (value: unknown) => new Response(JSON.stringify(value), { status: 200, headers: { 'Content-Type': 'application/json' } });
describe('InventoryPage', () => {
it('renders effective status and sends bounded filters and pagination', async () => {
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('after=')) return json({ items: [{ id: 'b', entityType: 'probe', canonicalName: 'probe.b', displayName: 'Probe B', status: 'unknown', factCount: 0, overrideCount: 0, relationCount: 0, sourceCount: 0, staleFactCount: 0 }], hasMore: false, nextCursor: '' });
return json({ items: [{ id: 'a', entityType: 'container', canonicalName: 'container.a', displayName: 'Handmatige API', status: 'operational', factCount: 2, overrideCount: 1, relationCount: 1, sourceCount: 2, staleFactCount: 0 }], hasMore: true, nextCursor: 'next' });
});
vi.stubGlobal('fetch', fetch);
render(<InventoryPage />);
expect(await screen.findByText('Handmatige API')).toBeVisible();
expect(screen.getByText('2 bronnen · 2 feiten · 1 relaties · 1 correcties')).toBeVisible();
fireEvent.change(screen.getByLabelText('Zoeken'), { target: { value: 'api' } });
expect(await screen.findByText('Handmatige API')).toBeVisible();
expect(fetch.mock.calls.some(([url]) => String(url).includes('q=api'))).toBe(true);
expect(window.location.search).toContain('q=api');
fireEvent.click(screen.getByRole('button', { name: 'Volgende pagina' }));
expect(await screen.findByText('Probe B')).toBeVisible();
expect(window.location.search).toContain('after=next');
});
it('shows override priority while preserving discovered facts and stale relations', async () => {
vi.stubGlobal('fetch', vi.fn(async () => json({
entity: { id: 'a', entityType: 'container', canonicalName: 'container.a', displayName: 'Handmatige API', status: 'operational', firstSeenAt: '2026-08-12T00:00:00Z', factCount: 2, overrideCount: 1, relationCount: 1, sourceCount: 2, staleFactCount: 1 },
aliases: [{ sourceName: 'Unraid', externalType: 'container', externalId: 'api' }],
facts: [{ fieldName: 'image', sourceId: 'unraid', sourceName: 'Unraid', value: 'discovered:latest', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }, { fieldName: 'runtimeState', sourceId: 'unraid', sourceName: 'Unraid', value: 'running', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }],
overrides: [{ fieldName: 'image', value: 'manual:pinned', updatedAt: '2026-08-12T01:00:00Z' }],
effectiveValues: [{ fieldName: 'image', value: 'manual:pinned', origin: 'override', stale: false, overriddenAt: '2026-08-12T01:00:00Z' }, { fieldName: 'runtimeState', value: 'running', origin: 'discovered', sourceName: 'Unraid', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }],
relations: [{ id: 'r', direction: 'outgoing', relationType: 'depends_on', peerId: 'b', peerType: 'service', peerName: 'Database', peerStatus: 'Ontbrekende entiteit', peerTombstonedAt: '2026-08-11T00:00:00Z', sourceName: 'Agent', confidence: .8, confirmed: false }],
})));
render(<InventoryPage id="a" />);
const effective = await screen.findByRole('heading', { name: 'Wat Pulse momenteel gebruikt' });
expect(within(effective.closest('section')!).getByText('manual:pinned')).toBeVisible();
expect(screen.getByText('Handmatige correctie')).toBeVisible();
expect(screen.getByText('Runtime-status')).toBeVisible();
expect(screen.getByText('Actief')).toBeVisible();
fireEvent.click(screen.getByText('Alle bronfeiten en correcties'));
expect(screen.getByText(/discovered:latest/)).toBeVisible();
expect(screen.getByText(/afgeleid/)).toBeVisible();
expect(screen.getByText('Ontbrekende entiteit')).toBeVisible();
});
});
@@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { MetricWidget, RankedListWidget, StatusGridWidget, metricStatus, type MetricWidgetProps } from '../../src/MetricWidgets';
const now = Date.parse('2026-08-10T04:00:00Z');
const freshPoint = { timestamp: now, value: 24, freshness: 'fresh' as const, labels: { host: 'tower' } };
function props(overrides: Partial<MetricWidgetProps> = {}): MetricWidgetProps {
return {
kind: 'stat',
series: [{ key: 'tower', points: [freshPoint] }],
freshness: 'fresh',
expectedStepSeconds: 15,
availability: 'success',
metricName: 'host.cpu.utilization',
visualization: { unit: 'percent', decimals: 0 },
...overrides,
};
}
describe('metricStatus ADR-0008 invariant', () => {
it('marks only fresh, available data as ready', () => {
expect(metricStatus(props())).toMatchObject({ tone: 'ready' });
});
it.each([
props({ availability: 'idle' }),
props({ availability: 'loading' }),
props({ availability: 'connecting' }),
props({ availability: 'error' }),
props({ series: [] }),
props({ freshness: 'stale' }),
props({ freshness: 'unavailable' }),
props({ series: [{ key: 'tower', points: [{ ...freshPoint, freshness: 'delayed' }] }] }),
])('never presents missing, stale or unavailable telemetry as ready', (value) => {
expect(metricStatus(value).tone).toBe('unknown');
});
it('renders an explicit unavailable notice without a numeric value', () => {
render(<MetricWidget {...props({ freshness: 'unavailable', series: [], availability: 'error', error: 'Bron tijdelijk onbereikbaar.' })} />);
expect(screen.getByText('Bron tijdelijk onbereikbaar.')).toBeVisible();
expect(screen.getByText(/De waarde wordt niet als gezond geïnterpreteerd/)).toBeVisible();
expect(screen.queryByText('24 %')).not.toBeInTheDocument();
});
});
describe('interactive widgets', () => {
it('presents metric series labels as human-readable values without raw JSON', () => {
const rawKey = '{"interface":"eth0","host":"tower"}';
render(<MetricWidget {...props({
kind: 'timeseries',
series: [{ key: rawKey, points: [
{ ...freshPoint, timestamp: now - 15_000, labels: { __name__: 'host.network.receive', host: 'tower', interface: 'eth0' } },
{ ...freshPoint, labels: { __name__: 'host.network.receive', host: 'tower', interface: 'eth0' } },
] }],
metricName: 'host.network.receive',
})} />);
expect(screen.getByRole('list', { name: 'Legenda' })).toHaveTextContent('tower · eth0');
expect(screen.getByRole('img', { name: /Tijdreeks voor Goedgekeurde meting/ })).toBeVisible();
expect(document.querySelector('.metric-chart-line')).toHaveAttribute('d', 'M 28.000 192.000 L 628.000 192.000');
expect(document.body).not.toHaveTextContent(rawKey);
});
it('supports keyboard activation of ranked rows', async () => {
const onSelect = vi.fn();
render(<RankedListWidget items={[{ id: 'disk-1', label: 'Disk 1', value: '78%' }]} onSelect={onSelect} />);
const row = screen.getByRole('button', { name: /Disk 1/ });
row.focus();
await userEvent.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith('disk-1');
});
it('localizes status codes in status-grid fallbacks', () => {
render(<StatusGridWidget items={[{ id: 'pool-1', label: 'Cachepool', status: 'degraded' }]} onSelect={vi.fn()} />);
expect(screen.getByText('Verstoord')).toBeVisible();
expect(screen.queryByText('degraded')).not.toBeInTheDocument();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { NetworkPage } from '../../src/NetworkPage';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('NetworkPage API boundary', () => {
it('normalizes nullable PostgreSQL collections before rendering', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
contractVersion: 'v1',
observedAt: '2026-08-10T04:41:55Z',
source: { id: 'agent', freshness: 'fresh', observedAt: '2026-08-10T04:41:55Z', state: 'unknown' },
health: null,
interfaces: null,
certificates: null,
events: null,
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<NetworkPage />);
expect(await screen.findByRole('heading', { level: 1, name: 'Netwerk' })).toBeVisible();
expect(screen.getByText('Geen betrouwbare interfacegegevens beschikbaar.')).toBeVisible();
});
it('distinguishes an unconfigured DNS signal from generic unknown', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
contractVersion: 'v1', observedAt: '2026-08-10T04:41:55Z',
source: { id: 'network-aggregate', freshness: 'unknown', observedAt: '2026-08-10T04:41:55Z', state: 'unknown' },
health: [{ scope: 'dns', state: 'unknown', capabilityState: 'available', configurationState: 'not_configured', reason: 'not_configured', freshness: 'unavailable', observedAt: '2026-08-10T04:41:55Z' }],
interfaces: [], certificates: [], events: [],
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<NetworkPage />);
expect(await screen.findByText('Niet geconfigureerd')).toBeVisible();
expect(screen.getByText('Voor dit signaal is nog geen veilige probe geconfigureerd.')).toBeVisible();
});
});
@@ -0,0 +1,51 @@
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { OnboardingPage } from '../../src/OnboardingPage';
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
const completed = {
state: { completed: true, step: 'completed', dashboardChoice: 'default', rulesChoice: 'default', dashboardId: 'overview', rulesReady: true },
capabilities: [
{ id: 'auth', state: 'ready', detail: 'Aanmelding geconfigureerd.' },
{ id: 'database', state: 'ready', detail: 'Database beschikbaar.' },
],
resume: false,
};
describe('afgeronde onboarding', () => {
it('toont eerst alleen de samenvatting en maakt herconfiguratie expliciet en rolbewust', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/system/status')) return Promise.resolve(new Response(JSON.stringify({ version: '1', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
if (init?.method === 'POST') return Promise.resolve(new Response('{}', { status: 403 }));
return Promise.resolve(new Response(JSON.stringify(completed), { status: 200, headers: { 'Content-Type': 'application/json' } }));
});
vi.stubGlobal('fetch', fetchMock);
const confirm = vi.fn(() => false);
vi.stubGlobal('confirm', confirm);
const user = userEvent.setup();
render(<OnboardingPage />);
expect(await screen.findByRole('heading', { name: 'Pulse is geconfigureerd' })).toBeVisible();
expect(screen.getByText('Overzicht actief')).toBeVisible();
expect(screen.getByText('Standaardmeldingen actief')).toBeVisible();
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Configuratie afronden' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Herconfiguratie openen' }));
expect(screen.getAllByRole('radio')).toHaveLength(4);
const save = screen.getByRole('button', { name: 'Herconfiguratie opslaan' });
await user.click(save);
expect(confirm).toHaveBeenCalled();
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false);
confirm.mockReturnValue(true);
await user.click(save);
expect(await screen.findByText('Alleen een beheerder kan onboardingkeuzes wijzigen.')).toBeVisible();
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true);
});
});
@@ -0,0 +1,56 @@
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { OperationalSignalPath, type OperationalSignalStage } from '../../src/OperationalSignalPath';
afterEach(cleanup);
const stages: OperationalSignalStage[] = [
{
id: 'host', label: 'Host', icon: '▣', tone: 'healthy', statusLabel: 'Gezond',
primaryLabel: 'CPU-belasting', primaryValue: '42%', secondaryLabel: 'Geheugen', secondaryValue: '61%',
detail: 'Actuele hostmeting.', route: '/host',
},
{
id: 'storage', label: 'Opslag', icon: '▤', tone: 'critical', statusLabel: 'Kritiek',
primaryLabel: 'Poolgebruik', primaryValue: '98%', secondaryLabel: 'Pools', secondaryValue: '2',
detail: 'De capaciteitsgrens is overschreden.', route: '/storage',
},
];
describe('OperationalSignalPath', () => {
it('selects the most urgent stage and supports explicit drill-down', async () => {
const onNavigate = vi.fn();
const user = userEvent.setup();
render(<OperationalSignalPath stages={stages} onNavigate={onNavigate} />);
const storage = screen.getByRole('button', { name: /Opslag: Kritiek/ });
expect(storage).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('region', { name: 'Opslag' })).toHaveTextContent('98%');
const host = screen.getByRole('button', { name: /Host: Gezond/ });
await user.click(host);
expect(host).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('region', { name: 'Host' })).toHaveTextContent('Actuele hostmeting.');
await user.click(screen.getByRole('button', { name: 'Open host' }));
expect(onNavigate).toHaveBeenCalledWith('/host');
});
it('preserves an explicit keyboard selection across urgency updates', async () => {
const user = userEvent.setup();
const { rerender } = render(<OperationalSignalPath stages={stages} onNavigate={vi.fn()} />);
const host = screen.getByRole('button', { name: /Host: Gezond/ });
host.focus();
await user.keyboard('{Enter}');
expect(host).toHaveAttribute('aria-pressed', 'true');
const updated = stages.map((stage) => stage.id === 'storage' ? { ...stage, tone: 'attention' as const, statusLabel: 'Aandacht' } : stage);
rerender(<OperationalSignalPath stages={updated} onNavigate={vi.fn()} />);
expect(screen.getByRole('button', { name: /Host: Gezond/ })).toHaveAttribute('aria-pressed', 'true');
rerender(<OperationalSignalPath stages={updated.filter((stage) => stage.id !== 'host')} onNavigate={vi.fn()} />);
expect(screen.getByRole('button', { name: /Opslag: Aandacht/ })).toHaveAttribute('aria-pressed', 'true');
});
});
+25
View File
@@ -0,0 +1,25 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PoolPage } from '../../src/PoolPage';
const criticalPool = {
id: 'ssd', name: 'ssd', filesystem: 'zfs', state: 'healthy', usableBytes: 1000,
usedBytes: 988, freeBytes: 12, utilizationPercent: 98.8, capacitySeverity: 'critical',
capabilities: { members: 'available', capacity: 'available', redundancy: 'available', scrub: 'available', filesystemErrors: 'available', performance: 'available', ssdWear: 'available', moverSignals: 'available' },
};
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
describe('operationele poolstatus', () => {
it.each([
{ id: undefined, payload: { source: { id: 'unraid', state: 'healthy', freshness: 'fresh' }, pools: [criticalPool], total: 1 } },
{ id: 'ssd', payload: { source: { id: 'unraid', state: 'healthy', freshness: 'fresh' }, pools: [criticalPool], total: 1, pool: criticalPool } },
])('toont kritieke capaciteit niet primair als gezond voor $id', async ({ id, payload }) => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<PoolPage id={id} />);
expect(await screen.findByText('Kritiek')).toBeVisible();
expect(screen.queryByText('Gezond', { selector: '.status-badge' })).not.toBeInTheDocument();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ServicePage } from '../../src/ServicePage';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('ServicePage API boundary', () => {
it('renders a stable empty state when PostgreSQL serializes an empty service list as null', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
contractVersion: 'v1',
observedAt: '2026-08-10T04:38:27Z',
services: null,
total: 0,
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ServicePage />);
expect(await screen.findByRole('heading', { level: 1, name: 'Services' })).toBeVisible();
expect(screen.getByRole('heading', { level: 2, name: 'Nog geen services geconfigureerd' })).toBeVisible();
expect(screen.getByRole('link', { name: 'Open eerste configuratie' })).toHaveAttribute('href', '/onboarding');
});
it('does not present an unavailable source as an empty configuration', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
contractVersion: 'v1', observedAt: '2026-08-10T04:38:27Z', capabilityState: 'unavailable',
configurationState: 'unknown', reason: 'source_unavailable', services: [], total: 0,
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
render(<ServicePage />);
expect(await screen.findByRole('heading', { level: 2, name: 'Servicebron niet beschikbaar' })).toBeVisible();
expect(screen.queryByRole('link', { name: 'Open eerste configuratie' })).not.toBeInTheDocument();
});
it('renders the service-level TLS certificate when bounded probe history no longer contains the TLS sample', async () => {
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith('/dependencies?limit=100')) {
return Promise.resolve(new Response(JSON.stringify({ serviceId: 'service-1', dependencies: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
return Promise.resolve(new Response(JSON.stringify({
service: {
id: 'service-1', name: 'Pulse', state: 'up', sampleCount: 5, successfulSampleCount: 5,
history: [{ probeId: 'http-probe', observedAt: '2026-08-12T10:20:00Z', state: 'up' }],
certificate: { expiresAt: '2026-11-01T00:00:00Z', issuer: 'Pulse test issuer', subject: 'CN=pulse.test', hostnameValid: true, verificationState: 'valid' },
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}));
render(<ServicePage id="service-1" />);
expect(await screen.findByRole('heading', { level: 2, name: 'TLS-certificaat' })).toBeVisible();
expect(screen.getByText('Pulse test issuer')).toBeVisible();
expect(screen.queryByText('Geen TLS-certificaat waargenomen.')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { SourceStatusDetails } from '../../src/SourceStatusDetails';
describe('SourceStatusDetails', () => {
it('scheidt bronstatus van databruikbaarheid en houdt de code ingeklapt', () => {
render(<SourceStatusDetails source={{ id: 'unraid', state: 'healthy', freshness: 'stale', observedAt: '2026-08-21T13:00:00Z', reason: 'source_stale' }} />);
expect(screen.getByText(/Bronstatus:/).closest('span')).toHaveTextContent('Gezond');
expect(screen.getByText(/Data:/).closest('span')).toHaveTextContent('Verouderd');
expect(screen.getByText(/laatste meting is verouderd/i)).toBeVisible();
const code = screen.getByText('source_stale');
expect(code.closest('details')).not.toHaveAttribute('open');
expect(code).not.toBeVisible();
});
it('toont nooit ontvangen zonder een misleidend time-element', () => {
const { container } = render(<SourceStatusDetails source={{ id: 'host', state: 'unknown', freshness: 'unavailable', observedAt: '0001-01-01T00:00:00Z' }} />);
expect(screen.getByText('Nooit ontvangen')).toBeVisible();
expect(container.querySelector('time')).toBeNull();
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { buildStorageNodes, type StorageData } from '../../src/StoragePage';
function data(): StorageData {
return {
array: { source: { state: 'operational', freshness: 'fresh' }, state: 'operational', members: [{ id: 'disk-10', name: 'disk10', role: 'data', state: 'online' }] },
disks: { source: { state: 'healthy', freshness: 'fresh' }, disks: [{ id: 'DISK-10', name: 'disk10', role: 'data', state: 'online', utilizationPercent: 99.99, capacitySeverity: 'critical', thermalSeverity: 'normal' }, { id: 'cache', name: 'cache', role: 'cache', state: 'online', utilizationPercent: 85.5, capacitySeverity: 'attention', thermalSeverity: 'critical' }] },
pools: { source: { state: 'healthy', freshness: 'fresh' }, pools: [{ id: 'cache', name: 'cache', filesystem: 'zfs', state: 'healthy', utilizationPercent: 85.5, capacitySeverity: 'attention' }] },
};
}
describe('buildStorageNodes', () => {
it('merges array and disk observations by canonical physical identity', () => {
const nodes = buildStorageNodes(data());
expect(nodes.filter((node) => node.label === 'disk10')).toHaveLength(1);
expect(nodes).toHaveLength(3);
});
it('keeps availability, capacity and thermal signals visible', () => {
const nodes = buildStorageNodes(data());
const disk = nodes.find((node) => node.label === 'disk10');
const cacheDisk = nodes.find((node) => node.id === 'disk-cache');
const cachePool = nodes.find((node) => node.id === 'pool-cache');
expect(disk).toMatchObject({ state: 'critical' });
expect(disk?.detail).toContain('Beschikbaarheid normaal · capaciteit kritiek · temperatuur normaal');
expect(cacheDisk).toMatchObject({ state: 'critical' });
expect(cachePool?.detail).toContain('Device-health normaal · capaciteit aandacht');
});
});
@@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { StorageMapWidget, TemperatureHeatmap } from '../../src/StorageVisuals';
describe('storagevisualisaties', () => {
it('localiseert statuscodes in de kaart en het toegankelijke tabelalternatief', () => {
render(<StorageMapWidget title="Opslag" description="Status per onderdeel" nodes={[{ id: 'pool-1', label: 'Cachepool', kind: 'pool', state: 'degraded' }]} />);
expect(screen.getAllByText('Verstoord')).toHaveLength(2);
expect(screen.getByRole('link', { name: 'Cachepool: Verstoord' })).toBeVisible();
expect(screen.queryByText('degraded')).not.toBeInTheDocument();
});
it('localiseert de status in het tabelalternatief van de temperatuurkaart', () => {
render(<TemperatureHeatmap title="Temperaturen" description="Recente metingen" points={[{ id: 'disk-1', label: 'Disk 1', observedAt: '2026-08-12T03:00:00Z', value: 48, status: 'critical' }]} />);
expect(screen.getByRole('cell', { name: 'Kritiek' })).toBeVisible();
expect(screen.queryByRole('cell', { name: 'critical' })).not.toBeInTheDocument();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { apiRequestInit, installSessionWatcher, onUnauthenticated } from '../../src/auth';
const nativeFetch = window.fetch;
afterEach(() => { window.fetch = nativeFetch; });
describe('authenticated API request caching', () => {
it('forces same-origin API reads past stale pre-login responses', () => {
expect(apiRequestInit('/api/v1/system/status')).toMatchObject({ cache: 'no-store' });
expect(apiRequestInit(new URL('/api/v1/dashboards', window.location.href), { method: 'GET', cache: 'force-cache' })).toMatchObject({ method: 'GET', cache: 'no-store' });
});
it('does not rewrite non-API or cross-origin requests', () => {
const init = { method: 'GET' } satisfies RequestInit;
expect(apiRequestInit('/assets/app.js', init)).toBe(init);
expect(apiRequestInit('https://example.com/api/v1/status', init)).toBe(init);
});
it('revokes the shared API context after one 401 and stops further network churn', async () => {
const transport = vi.fn(async () => new Response(null, { status: 401 }));
window.fetch = transport;
const notices = vi.fn();
const unsubscribe = onUnauthenticated(notices);
installSessionWatcher();
expect((await window.fetch('/api/v1/system/status')).status).toBe(401);
expect((await window.fetch('/api/v1/dashboards')).status).toBe(401);
expect((await window.fetch('/api/v1/events')).status).toBe(401);
expect(transport).toHaveBeenCalledTimes(1);
expect(notices).toHaveBeenCalledTimes(1);
unsubscribe();
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { resolveDashboardScope } from '../../src/dashboardScope';
describe('resolveDashboardScope', () => {
it('resolves exact declared defaults and drops non-string scope data', () => {
expect(resolveDashboardScope(
{ serverId: '$server', literal: 'disk-1', unsafe: 42 },
[{ name: 'server', default: 'primary' }],
)).toEqual({ serverId: 'primary', literal: 'disk-1' });
});
it('preserves unresolved and partial references for fail-closed API validation', () => {
expect(resolveDashboardScope(
{ serverId: '$missing', containerId: 'prefix-$server' },
[{ name: 'server', default: 'primary' }],
)).toEqual({ serverId: '$missing', containerId: 'prefix-$server' });
});
});
+121
View File
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { LiveClient, type SocketFactory } from '../../src/liveClient';
import type { MetricQueryRequest } from '../../src/metricClient';
class FakeSocket {
readyState = 0;
onopen: (() => void) | null = null;
onmessage: ((event: { data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: (() => void) | null = null;
readonly sent: string[] = [];
open(): void { this.readyState = 1; this.onopen?.(); }
send(payload: string): void { this.sent.push(payload); }
close(): void { this.readyState = 3; this.onclose?.(); }
}
const request: MetricQueryRequest = {
metric: 'host.cpu.utilization',
scope: { serverId: 'smoke-host' },
range: { from: '2026-08-10T06:00:00.000Z', to: '2026-08-10T06:05:00.000Z', stepSeconds: 15 },
aggregation: 'avg',
};
afterEach(() => vi.useRealTimers());
describe('LiveClient subscription lifecycle', () => {
it('reuses one socket when React replaces an equivalent listener inside the release grace', async () => {
vi.useFakeTimers();
const sockets: FakeSocket[] = [];
const factory = vi.fn(() => {
const socket = new FakeSocket();
sockets.push(socket);
return socket;
}) as unknown as SocketFactory;
const client = new LiveClient('/api/v1/live', factory);
const first = client.subscribe(request, () => undefined);
sockets[0].open();
await Promise.resolve();
first.unsubscribe();
const shifted = { ...request, range: { ...request.range, from: '2026-08-10T06:01:00.000Z', to: '2026-08-10T06:06:00.000Z' } };
const second = client.subscribe(shifted, () => undefined);
await vi.advanceTimersByTimeAsync(300);
expect(factory).toHaveBeenCalledTimes(1);
expect(sockets[0].readyState).toBe(1);
expect(sockets[0].sent.filter((payload) => payload.includes('unsubscribe'))).toHaveLength(0);
await vi.advanceTimersByTimeAsync(30_000);
expect(sockets[0].sent.some((payload) => payload.includes('"type":"ping"'))).toBe(true);
second.unsubscribe();
await vi.advanceTimersByTimeAsync(251);
expect(sockets[0].readyState).toBe(1);
await vi.advanceTimersByTimeAsync(10_000);
expect(sockets[0].readyState).toBe(3);
});
it('reuses an idle transport while a rotating dashboard loads its next query', async () => {
vi.useFakeTimers();
const sockets: FakeSocket[] = [];
const factory = vi.fn(() => {
const socket = new FakeSocket();
sockets.push(socket);
return socket;
}) as unknown as SocketFactory;
const client = new LiveClient('/api/v1/live', factory);
const first = client.subscribe(request, () => undefined);
sockets[0].open();
await Promise.resolve();
first.unsubscribe();
// Subscription state is released after 250 ms, but the bounded transport
// grace bridges a slower dashboard document fetch.
await vi.advanceTimersByTimeAsync(2_000);
expect(sockets[0].readyState).toBe(1);
const nextRequest = { ...request, metric: 'host.memory.utilization' };
const second = client.subscribe(nextRequest, () => undefined);
await Promise.resolve();
expect(factory).toHaveBeenCalledTimes(1);
expect(sockets[0].readyState).toBe(1);
expect(sockets[0].sent.some((payload) => payload.includes('host.memory.utilization'))).toBe(true);
second.unsubscribe();
await vi.advanceTimersByTimeAsync(10_251);
expect(sockets[0].readyState).toBe(3);
});
it('resubscribes once after reconnect and resumes samples on the bounded subscription', async () => {
vi.useFakeTimers();
const sockets: FakeSocket[] = [];
const factory = vi.fn(() => { const socket = new FakeSocket(); sockets.push(socket); return socket; }) as unknown as SocketFactory;
const events: string[] = [];
const client = new LiveClient('/api/v1/live', factory);
const subscription = client.subscribe(request, (event) => events.push(event.type));
sockets[0].open();
await Promise.resolve();
expect(sockets[0].sent.filter((payload) => payload.includes('"type":"subscribe"'))).toHaveLength(1);
sockets[0].close();
expect(events).toContain('status');
await vi.advanceTimersByTimeAsync(1_000);
expect(factory).toHaveBeenCalledTimes(2);
sockets[1].open();
await Promise.resolve();
const subscribe = JSON.parse(sockets[1].sent.find((payload) => payload.includes('"type":"subscribe"')) ?? '{}') as { subscriptionId?: string };
expect(sockets[1].sent.filter((payload) => payload.includes('"type":"subscribe"'))).toHaveLength(1);
sockets[1].onmessage?.({ data: JSON.stringify({ type: 'samples', subscriptionId: subscribe.subscriptionId, sequence: 1, samples: [{ timestamp: '2026-08-10T06:06:00.000Z', value: 42, labels: {} }] }) });
expect(events.at(-1)).toBe('samples');
expect(sockets).toHaveLength(2);
subscription.unsubscribe();
await vi.advanceTimersByTimeAsync(10_251);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { formatDateTime, hasReceivedTimestamp, NEVER_RECEIVED } from '../../src/locale';
describe('veilige tijdpresentatie', () => {
it('presenteert ontbrekende, ongeldige en nulwaarden als nooit ontvangen', () => {
expect(formatDateTime()).toBe(NEVER_RECEIVED);
expect(formatDateTime('ongeldig')).toBe(NEVER_RECEIVED);
expect(formatDateTime('0001-01-01T00:00:00Z')).toBe(NEVER_RECEIVED);
expect(formatDateTime('1970-01-01T00:00:00Z')).toBe(NEVER_RECEIVED);
});
it('behoudt een werkelijk ontvangen timestamp en de vaste Brusselse tijdzone', () => {
const value = '2026-08-21T14:05:00Z';
expect(hasReceivedTimestamp(value)).toBe(true);
expect(formatDateTime(value)).toContain('16:05');
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { containerSignalTone, signalToneFromState, sourceSignalTone, worstSignalTone } from '../../src/overviewSignals';
describe('overview signal semantics', () => {
it('fails source provenance closed when freshness is stale or unavailable', () => {
expect(sourceSignalTone({ state: 'healthy', freshness: 'stale' })).toBe('stale');
expect(sourceSignalTone({ state: 'healthy', freshness: 'unavailable' })).toBe('unknown');
expect(sourceSignalTone({ state: 'degraded', freshness: 'fresh' })).toBe('attention');
expect(sourceSignalTone({ state: 'healthy', freshness: 'fresh' })).toBe('healthy');
expect(sourceSignalTone(undefined)).toBe('unknown');
});
it('mirrors container domain state without escalating intentional stops', () => {
expect(containerSignalTone({ state: 'running', health: 'healthy' })).toBe('healthy');
expect(containerSignalTone({ state: 'running', health: 'unhealthy' })).toBe('attention');
expect(containerSignalTone({ state: 'restarting', health: 'unknown' })).toBe('attention');
expect(containerSignalTone({ state: 'stopped', health: 'unknown' })).toBe('critical');
expect(containerSignalTone({ state: 'stopped', health: 'unknown', intentionalStop: true })).toBe('unknown');
});
it('keeps the most severe state deterministically', () => {
expect(signalToneFromState('DOWN')).toBe('critical');
expect(worstSignalTone(['healthy', 'unknown', 'attention', 'stale'])).toBe('attention');
expect(worstSignalTone([])).toBe('unknown');
});
});

Some files were not shown because too many files have changed in this diff Show More