Publish curated ITWorx-Pulse source from 0e0633c8ac91cb71312a26837f791035a78d81b8
Public source validation / validate (push) Failing after 2m10s
Public source validation / validate (push) Failing after 2m10s
This commit is contained in:
1 parent
a4956c2af9
commit
db8093b608
88 files changed
+3685
-469
No files matched your search
@@ -24,6 +24,8 @@ export function AlertOperationsPanel() {
|
||||
const [message, setMessage] = useState('');
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [view, setView] = useState<AlertView>('active');
|
||||
const [query, setQuery] = useState(() => new URLSearchParams(window.location.search).get('q') ?? '');
|
||||
const requestedAlert = new URLSearchParams(window.location.search).get('alert') ?? '';
|
||||
|
||||
async function load(signal?: AbortSignal) {
|
||||
try {
|
||||
@@ -43,6 +45,17 @@ export function AlertOperationsPanel() {
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedAlert || state !== 'ready') return;
|
||||
const controller = new AbortController();
|
||||
fetch('/api/v1/alerts/' + encodeURIComponent(requestedAlert) + '?occurrenceLimit=50', { signal: controller.signal }).then(async (response) => {
|
||||
if (!response.ok) return;
|
||||
const value = await response.json() as { alert?: AlertItem };
|
||||
if (value.alert) setSelected(value.alert);
|
||||
}).catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setMessage(copy.alerts.operationsError); });
|
||||
return () => controller.abort();
|
||||
}, [requestedAlert, state]);
|
||||
|
||||
const ordered = useMemo(() => items.slice().sort((left, right) =>
|
||||
(severityOrder[left.severity] ?? 9) - (severityOrder[right.severity] ?? 9)
|
||||
|| (stateOrder[left.state] ?? 9) - (stateOrder[right.state] ?? 9)
|
||||
@@ -52,12 +65,25 @@ export function AlertOperationsPanel() {
|
||||
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 normalizedQuery = query.trim().toLocaleLowerCase('nl-BE');
|
||||
const filtered = ordered.filter((item) => {
|
||||
const inView = view === 'critical' ? isActive(item) && item.severity === 'critical' : view === 'acknowledged' ? item.state === 'acknowledged' : isActive(item);
|
||||
const matches = !normalizedQuery || `${item.ruleName} ${item.entityName ?? ''} ${item.reason} ${item.state} ${item.severity}`.toLocaleLowerCase('nl-BE').includes(normalizedQuery);
|
||||
return inView && matches;
|
||||
});
|
||||
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);
|
||||
if (response.ok) {
|
||||
const next = ((await response.json()) as { alert: AlertItem }).alert;
|
||||
setSelected(next);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('section', 'operations');
|
||||
params.set('alert', item.id);
|
||||
if (query.trim()) params.set('q', query.trim()); else params.delete('q');
|
||||
window.history.replaceState(window.history.state, '', window.location.pathname + '?' + params.toString());
|
||||
}
|
||||
}
|
||||
|
||||
async function operate(item: AlertItem, acknowledge: boolean) {
|
||||
@@ -83,6 +109,7 @@ export function AlertOperationsPanel() {
|
||||
<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>
|
||||
<label className="alert-operation-search">Zoek in meldingen<input type="search" value={query} placeholder="Regel, entiteit, reden of status" onChange={(event) => { const value = event.target.value; setQuery(value); const params = new URLSearchParams(window.location.search); if (value.trim()) params.set('q', value.trim()); else params.delete('q'); window.history.replaceState(window.history.state, '', window.location.pathname + (params.toString() ? '?' + params.toString() : '')); }} /></label>
|
||||
{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>
|
||||
|
||||
@@ -134,6 +134,37 @@ export function AlertRulesPage() {
|
||||
}));
|
||||
}
|
||||
|
||||
function metricByHints(...hints: string[]): MetricDefinition | undefined {
|
||||
return metrics.find((metric) => {
|
||||
const value = metric.semanticName.toLowerCase();
|
||||
return hints.every((hint) => value.includes(hint));
|
||||
});
|
||||
}
|
||||
|
||||
function applyTemplate(template: 'cpu' | 'storage' | 'service' | 'source' | 'events') {
|
||||
setSelectedId(null); setPreview(null); setMessage('');
|
||||
const next = newRule();
|
||||
if (template === 'cpu') {
|
||||
const metric = metricByHints('cpu') ?? metrics.find((item) => item.semanticName.toLowerCase().includes('processor'));
|
||||
next.name = 'Hoge CPU-belasting'; next.severity = 'attention'; next.pendingSeconds = 300; next.resolveSeconds = 180;
|
||||
next.condition = { inputType: 'metric', metric: metric?.semanticName ?? '', operator: '>', threshold: 85, recoveryThreshold: 75, aggregation: metric?.defaultAggregation ?? 'avg', windowSeconds: 300 };
|
||||
} else if (template === 'storage') {
|
||||
const metric = metricByHints('storage') ?? metricByHints('capacity') ?? metrics.find((item) => item.semanticName.toLowerCase().includes('utilization'));
|
||||
next.name = 'Storagecapaciteit hoog'; next.severity = 'critical'; next.pendingSeconds = 600; next.resolveSeconds = 300;
|
||||
next.condition = { inputType: 'metric', metric: metric?.semanticName ?? '', operator: '>', threshold: 90, recoveryThreshold: 85, aggregation: metric?.defaultAggregation ?? 'max', windowSeconds: 600 };
|
||||
} else if (template === 'service') {
|
||||
next.name = 'Service niet beschikbaar'; next.severity = 'critical'; next.pendingSeconds = 60; next.resolveSeconds = 120;
|
||||
next.condition = { inputType: 'entity-status', operator: '==', threshold: 'down', recoveryThreshold: null, aggregation: 'none', windowSeconds: 60 };
|
||||
} else if (template === 'source') {
|
||||
next.name = 'Databron verouderd'; next.severity = 'degraded'; next.pendingSeconds = 120; next.resolveSeconds = 120;
|
||||
next.condition = { inputType: 'datasource-health', operator: '==', threshold: 'stale', recoveryThreshold: null, aggregation: 'none', windowSeconds: 120 };
|
||||
} else {
|
||||
next.name = 'Herhaalde events'; next.severity = 'attention'; next.pendingSeconds = 0; next.resolveSeconds = 300;
|
||||
next.condition = { inputType: 'event', operator: '>=', threshold: 3, recoveryThreshold: null, aggregation: 'count', windowSeconds: 900 };
|
||||
}
|
||||
setDraft(next);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const validation = validateDraft(draft, metrics);
|
||||
if (validation) { setMessage(validation); return; }
|
||||
@@ -210,6 +241,8 @@ export function AlertRulesPage() {
|
||||
</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>
|
||||
{!selected && <section className="alert-template-assistant" aria-label="Regelassistent"><div><p className="card-kicker">Snel starten</p><h3>Kies een veilige basisregel</h3><p className="card-copy">De template vult alleen de regelconfiguratie in. Controleer metric, scope en drempels vóór opslaan.</p></div><div className="alert-template-grid"><button type="button" onClick={() => applyTemplate('cpu')}><strong>CPU hoog</strong><small>> 85% gedurende 5 min</small></button><button type="button" onClick={() => applyTemplate('storage')}><strong>Storage bijna vol</strong><small>> 90% gedurende 10 min</small></button><button type="button" onClick={() => applyTemplate('service')}><strong>Service down</strong><small>Down gedurende 1 min</small></button><button type="button" onClick={() => applyTemplate('source')}><strong>Bron stale</strong><small>Telemetrie 2 min verouderd</small></button><button type="button" onClick={() => applyTemplate('events')}><strong>Event burst</strong><small>3+ events in 15 min</small></button></div></section>}
|
||||
<ol className="alert-editor-steps" aria-label="Regelstappen"><li><strong>1</strong><span>Signaal</span></li><li><strong>2</strong><span>Voorwaarde</span></li><li><strong>3</strong><span>Timing</span></li><li><strong>4</strong><span>Veiligheid</span></li><li><strong>5</strong><span>Preview</span></li></ol>
|
||||
<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>
|
||||
@@ -226,7 +259,7 @@ export function AlertRulesPage() {
|
||||
</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 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 && <div className={'preview-result alert-preview-outcome' + (preview.wouldFire ? ' alert-preview-outcome--fire' : '')} role="status"><strong>{preview.wouldFire ? 'Deze voorbeeldwaarde zou de regel activeren.' : 'Deze voorbeeldwaarde activeert de regel niet.'}</strong><span>{presentStatus(preview.state)} · {presentReason(preview.reason)}</span></div>}</div>
|
||||
</div>
|
||||
</section>}
|
||||
{section === 'controls' && <AlertControlsPanel />}
|
||||
|
||||
+48
-112
@@ -1,6 +1,7 @@
|
||||
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 { widgetLabel } from './widgetCatalog';
|
||||
import { copy } from './copy';
|
||||
import { routeFromLocation, type RoutePath } from './routes';
|
||||
import { HostPage } from './HostPage';
|
||||
@@ -27,68 +28,28 @@ 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 { wallboardPlacement, wallboardSlideIndex } from './wallboardLayout';
|
||||
import { OperationalSignalPath, type OperationalSignalStage } from './OperationalSignalPath';
|
||||
import { operatorRangeSelection, setOperatorRangePreset, useOperatorContext, type OperatorRangePreset } from './operatorContext';
|
||||
import { containerSignalTone, signalToneFromState, signalToneRank, sourceSignalTone, worstSignalTone, type SignalTone } from './overviewSignals';
|
||||
import { spatialStyle } from './dashboardSpatial';
|
||||
import { readSessionCapabilities } from './sessionClient';
|
||||
import { navigation, navigate, OperatorAppShell } from './AppShell';
|
||||
import { SettingsPage } from './SettingsPage';
|
||||
import { ApiProblemError, requestJSON } from './apiClient';
|
||||
import { RouteErrorBoundary } from './RouteErrorBoundary';
|
||||
|
||||
// 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 DashboardManagerPage = lazy(() => import('./DashboardManagerPage').then((module) => ({ default: module.DashboardManagerPage })));
|
||||
const ExploreMetricsPage = lazy(() => import('./ExploreMetricsPage').then((module) => ({ default: module.ExploreMetricsPage })));
|
||||
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>;
|
||||
}
|
||||
@@ -322,20 +283,22 @@ function OverviewPage() {
|
||||
.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') }));
|
||||
.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'), route: '/pools/' + encodeURIComponent(pool.id) }));
|
||||
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 resourceRoutes: Record<OverviewResource, string> = { host: '/host', containers: '/containers', pools: '/storage', services: '/services', incidents: '/incidents' };
|
||||
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 }];
|
||||
return [{ id: `resource:${resource}`, label: `${resourceLabels[resource]}: ${signalResourceLabel(state, 'unknown')}`, reason: resourceStateDetail(state) ?? copy.overview.resourceUnavailableDetail, route: resourceRoutes[resource] }];
|
||||
});
|
||||
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 systemProblems = systemResourceState === 'ready' || systemResourceState === 'loading' ? [] : [{ id: 'resource:system-status', label: `${copy.overview.sources}: ${signalResourceLabel(systemResourceState, 'unknown')}`, reason: resourceStateDetail(systemResourceState) ?? copy.overview.resourceUnavailableDetail, route: '/status' }];
|
||||
const partialProblems: Array<{ id: string; label: string; reason: string; route: string }> = [];
|
||||
if (overview.containersPartial) partialProblems.push({ id: 'partial:containers', label: `${copy.overview.signalWorkloads}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail, route: '/containers' });
|
||||
if (overview.poolsPartial) partialProblems.push({ id: 'partial:pools', label: `${copy.overview.signalStorage}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail, route: '/storage' });
|
||||
if (overview.servicesPartial) partialProblems.push({ id: 'partial:services', label: `${copy.navigation.services}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail, route: '/services' });
|
||||
if (overview.incidentsPartial) partialProblems.push({ id: 'partial:incidents', label: `${copy.overview.signalIncidents}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail, route: '/incidents' });
|
||||
const sourceProblems = statusProblems(snapshot.status).map((problem) => ({ ...problem, route: '/status' }));
|
||||
const problems = [...systemProblems, ...resourceProblems, ...partialProblems, ...poolProblems, ...sourceProblems].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;
|
||||
@@ -386,11 +349,11 @@ function OverviewPage() {
|
||||
<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>
|
||||
<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}><button className="problem-link" type="button" onClick={() => navigate(problem.route)}><span className="queue-severity">{index === 0 ? '!' : '?'}</span><span><strong>{problem.label}</strong><small>{problem.reason}</small></span><span className="problem-link-arrow" aria-hidden="true">→</span></button></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 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}><button className="problem-link" type="button" onClick={() => navigate('/pools/' + encodeURIComponent(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><span className="problem-link-arrow" aria-hidden="true">→</span></button></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>
|
||||
<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}><button className="problem-link" type="button" onClick={() => navigate('/incidents/' + encodeURIComponent(incident.id))}><span className={'incident-dot incident-dot--' + incident.severity} aria-hidden="true" /><span><strong>{incident.title}</strong><small>{formatDateTime(incident.startedAt)}</small></span><span className="problem-link-arrow" aria-hidden="true">→</span></button></li>)}</ul> : <p className="card-copy">{copy.overview.noIncidents}</p>}</article>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -400,7 +363,6 @@ type DashboardSummary = { id: string; slug: string; name: string; description: s
|
||||
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);
|
||||
@@ -409,12 +371,6 @@ function field<T>(record: ApiRecord | undefined, name: string): 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[]>([]);
|
||||
@@ -425,12 +381,12 @@ function DashboardsPage() {
|
||||
// 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) => {
|
||||
requestJSON<{ items?: ApiRecord[] }>('/api/v1/dashboards?limit=100', { signal: controller.signal, retries: 1 }).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');
|
||||
setState(error instanceof ApiProblemError && error.status === 401 ? 'unauthorized' : 'error');
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [reload]);
|
||||
@@ -448,8 +404,6 @@ class WidgetBoundary extends Component<{ title: string; children: ReactNode }, {
|
||||
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();
|
||||
@@ -484,9 +438,7 @@ function wallboardSlideFor(widget: DashboardWidget): number {
|
||||
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;
|
||||
if (viewport !== 'wallboard') return spatialStyle(layout, viewport) 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;
|
||||
}
|
||||
@@ -496,7 +448,7 @@ function DashboardWidgetView({ widget, viewport, onFilter, metric }: { widget: D
|
||||
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 typeLabel = widgetLabel(widget.type);
|
||||
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');
|
||||
@@ -515,19 +467,28 @@ function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0,
|
||||
const [document, setDocument] = useState<ApiRecord>({});
|
||||
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [canEdit, setCanEdit] = 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');
|
||||
useEffect(() => {
|
||||
if (wallboard) return undefined;
|
||||
const controller = new AbortController();
|
||||
readSessionCapabilities(controller.signal).then((session) => setCanEdit(session.permissions.edit)).catch(() => setCanEdit(false));
|
||||
return () => controller.abort();
|
||||
}, [wallboard]);
|
||||
const operator = useOperatorContext();
|
||||
const timeRange = wallboard ? 'live' : operatorRangeSelection(operator);
|
||||
const setTimeRange = (value: string) => setOperatorRangePreset(value as OperatorRangePreset);
|
||||
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) => {
|
||||
requestJSON<{ dashboard: ApiRecord; version: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(dashboardId), { signal: controller.signal, retries: 1 }).then((data) => {
|
||||
const rawDocument = field<ApiRecord>(data.version, 'document') ?? {};
|
||||
setDocument(rawDocument);
|
||||
setCrossFilter(filterFromURL(rawDocument));
|
||||
@@ -538,7 +499,7 @@ function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0,
|
||||
setState('ready');
|
||||
}).catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
if (error instanceof ApiProblemError && error.status === 401) {
|
||||
setState('unauthorized');
|
||||
return;
|
||||
}
|
||||
@@ -550,11 +511,10 @@ function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0,
|
||||
});
|
||||
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';
|
||||
const aggregate: RuntimeState = values.includes('usable') || values.includes('partial') || values.includes('stale') ? 'usable' : values.includes('error') ? 'error' : values.length > 0 && values.every((value) => value === 'empty' || value === 'capability-disabled') ? 'empty' : 'loading';
|
||||
onRuntimeState?.(aggregate);
|
||||
}, [onRuntimeState, runtimeStates]);
|
||||
useEffect(() => { if (wallboard) setRuntimeStates({}); }, [wallboard, wallboardSlide]);
|
||||
@@ -575,8 +535,8 @@ function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0,
|
||||
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>;
|
||||
const usableCount = Object.values(runtimeStates).filter((value) => value === 'usable' || value === 'partial' || value === 'stale').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 && canEdit && <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', 'alerts', 'incidents', 'text'].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 };
|
||||
@@ -708,31 +668,6 @@ function WallboardPage() {
|
||||
</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') {
|
||||
@@ -774,7 +709,8 @@ function Page({ route }: { route: RoutePath }) {
|
||||
case '/storage': return <StoragePage />;
|
||||
case '/capacity': return <CapacityPage />;
|
||||
case '/inventory': return <InventoryPage />;
|
||||
case '/dashboards': return <DashboardsPage />;
|
||||
case '/dashboards': return <RouteSuspense><DashboardManagerPage /></RouteSuspense>;
|
||||
case '/explore': return <RouteSuspense><ExploreMetricsPage /></RouteSuspense>;
|
||||
case '/wallboard': return <WallboardPage />;
|
||||
case '/alerts': return <RouteSuspense><AlertsPage /></RouteSuspense>;
|
||||
case '/events': return <EventsPage />;
|
||||
@@ -801,7 +737,7 @@ function App() {
|
||||
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>;
|
||||
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"><RouteErrorBoundary resetKey={route}><Page route={route} /></RouteErrorBoundary></main></div>;
|
||||
return <OperatorAppShell route={route} shellStatus={shellStatus} currentNavigation={currentNavigation}><AuthNoticeBanner /><RouteErrorBoundary resetKey={route}><Page route={route} /></RouteErrorBoundary></OperatorAppShell>;
|
||||
}
|
||||
export default App;
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { copy } from './copy';
|
||||
import { OperatorCommandBar } from './OperatorCommandBar';
|
||||
import { routeFromLocation, type RoutePath } from './routes';
|
||||
import {
|
||||
WORKSPACE_EVENT,
|
||||
isFavorite,
|
||||
readWorkspaceState,
|
||||
recordRecent,
|
||||
removeSavedView,
|
||||
saveCurrentView,
|
||||
toggleFavorite,
|
||||
} from './operatorWorkspace';
|
||||
|
||||
type SectionId = 'command' | 'infrastructure' | 'workloads' | 'services' | 'response' | 'manage';
|
||||
type NavigationDefinition = {
|
||||
path: RoutePath;
|
||||
label: string;
|
||||
icon: string;
|
||||
group: string;
|
||||
section: SectionId;
|
||||
primary: boolean;
|
||||
};
|
||||
|
||||
export const navigation = [
|
||||
{ path: '/', label: copy.navigation.overview, icon: '⌂', group: copy.navigation.groups.command, section: 'command', primary: true },
|
||||
{ path: '/dashboards', label: copy.navigation.dashboards, icon: '▦', group: copy.navigation.groups.command, section: 'command', primary: true },
|
||||
{ path: '/explore', label: copy.navigation.explore, icon: '⌁', group: copy.navigation.groups.command, section: 'command', primary: true },
|
||||
|
||||
{ path: '/host', label: copy.navigation.host, icon: '▣', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: true },
|
||||
{ path: '/processes', label: copy.navigation.processes, icon: '≋', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
||||
{ path: '/storage', label: copy.navigation.storage, icon: '▤', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: true },
|
||||
{ path: '/array', label: copy.navigation.array, icon: '▥', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
||||
{ path: '/disks', label: copy.navigation.disks, icon: '◉', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
||||
{ path: '/pools', label: copy.navigation.pools, icon: '◫', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
||||
{ path: '/shares', label: copy.navigation.shares, icon: '⇄', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
||||
{ path: '/capacity', label: copy.navigation.capacity, icon: '⌁', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
||||
{ path: '/network', label: copy.navigation.network, icon: '⌘', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
||||
|
||||
{ path: '/applications', label: copy.navigation.applications, icon: '◆', group: copy.navigation.groups.workloads, section: 'workloads', primary: true },
|
||||
{ path: '/containers', label: copy.navigation.containers, icon: '⬡', group: copy.navigation.groups.workloads, section: 'workloads', primary: false },
|
||||
|
||||
{ path: '/services', label: copy.navigation.services, icon: '◉', group: copy.navigation.groups.services, section: 'services', primary: true },
|
||||
{ path: '/topology', label: copy.navigation.topology, icon: '⌬', group: copy.navigation.groups.services, section: 'services', primary: false },
|
||||
|
||||
{ path: '/incidents', label: copy.navigation.incidents, icon: '△', group: copy.navigation.groups.response, section: 'response', primary: true },
|
||||
{ path: '/alerts', label: copy.navigation.alerts, icon: '!', group: copy.navigation.groups.response, section: 'response', primary: false },
|
||||
{ path: '/events', label: copy.navigation.events, icon: '≡', group: copy.navigation.groups.response, section: 'response', primary: false },
|
||||
|
||||
{ path: '/inventory', label: copy.navigation.inventory, icon: '▥', group: copy.navigation.groups.manage, section: 'manage', primary: true },
|
||||
{ path: '/settings', label: copy.navigation.settings, icon: '⚙', group: copy.navigation.groups.manage, section: 'manage', primary: true },
|
||||
{ path: '/status', label: copy.navigation.status, icon: '♥', group: copy.navigation.groups.manage, section: 'manage', primary: false },
|
||||
{ path: '/onboarding', label: copy.navigation.onboarding, icon: '→', group: copy.navigation.groups.manage, section: 'manage', primary: false },
|
||||
{ path: '/wallboard', label: copy.navigation.wallboard, icon: '▰', group: copy.navigation.groups.manage, section: 'manage', primary: false },
|
||||
] as const satisfies ReadonlyArray<NavigationDefinition>;
|
||||
|
||||
export type NavigationItem = (typeof navigation)[number];
|
||||
|
||||
const sectionLabels: Record<SectionId, string> = {
|
||||
command: 'Operator',
|
||||
infrastructure: 'Infrastructuur',
|
||||
workloads: 'Workloads',
|
||||
services: 'Services',
|
||||
response: 'Response',
|
||||
manage: 'Beheer',
|
||||
};
|
||||
|
||||
const primaryNavigation = navigation.filter((item) => item.primary);
|
||||
const mobilePrimaryOrder = new Map<RoutePath, number>([['/', 0], ['/incidents', 1], ['/containers', 2], ['/storage', 3], ['/services', 4]]);
|
||||
const mobilePrimaryNavigation = navigation.filter((item) => mobilePrimaryOrder.has(item.path)).sort((left, right) => mobilePrimaryOrder.get(left.path)! - mobilePrimaryOrder.get(right.path)!);
|
||||
const mobileMoreNavigation = navigation.filter((item) => !mobilePrimaryOrder.has(item.path));
|
||||
const navigationGroups = Array.from(new Set(primaryNavigation.map((item) => item.group)));
|
||||
|
||||
function matchesRoute(route: RoutePath, path: RoutePath): boolean {
|
||||
return route === path || (path !== '/' && route.startsWith(path + '/'));
|
||||
}
|
||||
|
||||
function routeDefinition(route: RoutePath): NavigationItem {
|
||||
return [...navigation]
|
||||
.sort((left, right) => right.path.length - left.path.length)
|
||||
.find((item) => matchesRoute(route, item.path)) ?? navigation[0];
|
||||
}
|
||||
|
||||
function detailSegment(route: RoutePath, item: NavigationItem): string | null {
|
||||
if (route === item.path) return null;
|
||||
const raw = route.slice(item.path.length + 1).split('/')[0];
|
||||
if (!raw) return null;
|
||||
try { return decodeURIComponent(raw).slice(0, 80); } catch { return raw.slice(0, 80); }
|
||||
}
|
||||
|
||||
export function navigate(path: string): void {
|
||||
const from = window.location.pathname + window.location.search;
|
||||
if (path === from) return;
|
||||
window.history.pushState({ pulseFrom: from }, '', path);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
function NavigationLink({ item, label = item.label, domain = false }: { item: NavigationItem; label?: string; domain?: boolean }) {
|
||||
const active = matchesRoute(routeFromLocation(window.location.pathname), item.path);
|
||||
return <li><a className={(active ? 'nav-link nav-link--active' : 'nav-link') + (domain ? ' nav-link--domain' : '')} 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 activeItem = routeDefinition(route);
|
||||
const activeGroup = activeItem.group;
|
||||
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 = primaryNavigation.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} domain />)}</ul></details>;
|
||||
})}</nav>;
|
||||
}
|
||||
|
||||
function ShellStatusBadge({ label, tone }: { 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 WorkspaceContext({ route, currentNavigation }: { route: RoutePath; currentNavigation: NavigationItem }) {
|
||||
const [, setVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
const refresh = () => setVersion((value) => value + 1);
|
||||
window.addEventListener(WORKSPACE_EVENT, refresh);
|
||||
return () => window.removeEventListener(WORKSPACE_EVENT, refresh);
|
||||
}, []);
|
||||
|
||||
const item = routeDefinition(route);
|
||||
const sectionItems = navigation.filter((entry) => entry.section === item.section && entry.path !== '/wallboard');
|
||||
const detail = detailSegment(route, item);
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
const favorite = isFavorite(currentPath);
|
||||
const saved = readWorkspaceState().views.some((entry) => entry.path === currentPath);
|
||||
const from = typeof window.history.state?.pulseFrom === 'string' ? window.history.state.pulseFrom as string : '';
|
||||
const fallback = sectionItems.find((entry) => entry.primary)?.path ?? '/';
|
||||
const backTarget = from && from !== currentPath ? from : (route !== fallback ? fallback : '');
|
||||
const pageLabel = detail ? currentNavigation.label + ' · ' + detail : currentNavigation.label;
|
||||
|
||||
useEffect(() => { recordRecent(pageLabel, currentPath); }, [pageLabel, currentPath]);
|
||||
|
||||
return <div className="workspace-context" aria-label="Werkcontext">
|
||||
<div className="workspace-context-main">
|
||||
{backTarget && <button className="workspace-back" type="button" onClick={() => navigate(backTarget)} aria-label="Terug naar vorige context"><span aria-hidden="true">←</span><span>Terug</span></button>}
|
||||
<nav className="workspace-breadcrumbs" aria-label="Breadcrumb">
|
||||
<a href="/" onClick={(event) => { event.preventDefault(); navigate('/'); }}>Pulse</a><span aria-hidden="true">/</span>
|
||||
<button type="button" onClick={() => navigate(sectionItems.find((entry) => entry.primary)?.path ?? '/')}>{sectionLabels[item.section]}</button><span aria-hidden="true">/</span>
|
||||
<strong>{pageLabel}</strong>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="workspace-actions">
|
||||
<button className={'workspace-action' + (favorite ? ' workspace-action--active' : '')} type="button" aria-pressed={favorite} onClick={() => toggleFavorite(pageLabel, currentPath)} title={favorite ? 'Verwijder uit favorieten' : 'Voeg toe aan favorieten'}><span aria-hidden="true">☆</span><span>{favorite ? 'Favoriet' : 'Favoriet'}</span></button>
|
||||
<button className={'workspace-action' + (saved ? ' workspace-action--active' : '')} type="button" aria-pressed={saved} onClick={() => saved ? removeSavedView(currentPath) : saveCurrentView(pageLabel, currentPath)} title={saved ? 'Verwijder opgeslagen view' : 'Bewaar huidige filters en view'}><span aria-hidden="true">▣</span><span>{saved ? 'View bewaard' : 'Bewaar view'}</span></button>
|
||||
</div>
|
||||
<nav className="workspace-subnav" aria-label={sectionLabels[item.section] + ' navigatie'}>{sectionItems.map((entry) => {
|
||||
const active = matchesRoute(route, entry.path);
|
||||
return <a key={entry.path} href={entry.path} aria-current={active ? 'page' : undefined} onClick={(event) => { event.preventDefault(); navigate(entry.path); }}>{entry.label}</a>;
|
||||
})}</nav>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function OperatorAppShell({ route, shellStatus, currentNavigation, children }: { route: RoutePath; shellStatus: { label: string; tone: 'unknown' | 'ready'; detail: string }; currentNavigation: NavigationItem; children: ReactNode }) {
|
||||
const resolved = useMemo(() => routeDefinition(route), [route]);
|
||||
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) => matchesRoute(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}><ShellStatusBadge label={shellStatus.label} tone={shellStatus.tone} /><span>{shellStatus.detail}</span></div></aside><div className="app-workspace"><OperatorCommandBar routeLabel={route === '/404' ? copy.notFound.context : currentNavigation.label} navigation={navigation} onNavigate={navigate} /><WorkspaceContext route={route} currentNavigation={resolved} /><main id="main-content" className="content">{children}</main></div></div>;
|
||||
}
|
||||
@@ -16,8 +16,20 @@ function Badge({ state }: { state: string }) {
|
||||
function Source({ source }: { source: ApplicationSnapshot['source'] }) {
|
||||
return <SourceStatusDetails source={source ?? {}} fallbackReason={source?.freshness === 'fresh' ? copy.applications.fresh : copy.applications.stale} />;
|
||||
}
|
||||
|
||||
function componentRoute(item: ComponentItem): string {
|
||||
const kind = item.kind.toLowerCase();
|
||||
const id = encodeURIComponent(item.id);
|
||||
if (kind.includes('container')) return '/containers/' + id;
|
||||
if (kind.includes('service') || kind.includes('probe') || kind.includes('endpoint')) return '/services/' + id;
|
||||
return '/inventory/' + id;
|
||||
}
|
||||
function navigate(path: string): void {
|
||||
window.history.pushState({ pulseFrom: window.location.pathname + window.location.search }, '', path);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
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>;
|
||||
return <section className="card application-component-hub" aria-labelledby="application-components-title"><div className="card-heading"><div><p className="card-kicker">Workload context</p><h2 id="application-components-title">{copy.applications.components}</h2></div><span className="status-badge status-badge--unknown">{items.length}</span></div>{items.length === 0 ? <p className="card-copy">Voor deze applicatie zijn geen componentrelaties aangeleverd.</p> : <ul className="application-component-links">{items.map((item) => <li key={item.id}><button type="button" onClick={() => navigate(componentRoute(item))}><span><strong>{item.name}</strong><small>{item.kind} · {item.critical ? copy.applications.critical : copy.applications.optional}</small><small>Container: {item.containerState || '—'} · service: {item.serviceState || '—'}</small></span><span><Badge state={item.status} /><small>{item.reason || item.id}</small><span className="problem-link-arrow" aria-hidden="true">→</span></span></button></li>)}</ul>}<details className="technical-details"><summary>Technische componentidentiteiten</summary><dl className="metric-list">{items.map((item) => <div key={item.id}><dt>{item.name}</dt><dd><code>{item.id}</code></dd></div>)}</dl></details></section>;
|
||||
}
|
||||
|
||||
export function ApplicationPage({ id }: { id?: string }) {
|
||||
@@ -40,7 +52,7 @@ export function ApplicationPage({ id }: { id?: string }) {
|
||||
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} /></>;
|
||||
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="application-workload-band instrument-band" aria-label="Applicatiesamenvatting"><article className="instrument-cell"><p>Status</p><strong>{app.status}</strong><small>{app.overridden ? copy.applications.overridden : copy.applications.discovered}</small></article><article className="instrument-cell"><p>Componenten</p><strong>{app.components.length}</strong><small>{app.components.filter((item) => item.critical).length} kritisch</small></article><article className="instrument-cell"><p>Containers</p><strong>{app.components.filter((item) => item.kind.toLowerCase().includes('container')).length}</strong><small>Direct navigeerbaar</small></article><article className="instrument-cell"><p>Services</p><strong>{app.components.filter((item) => item.kind.toLowerCase().includes('service') || item.kind.toLowerCase().includes('probe') || item.kind.toLowerCase().includes('endpoint')).length}</strong><small>Healthcontext</small></article></section><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></>;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { copy } from './copy';
|
||||
import { WidgetConfigDrawer, type PreviewResult, type ValidationErrors } from './WidgetConfigDrawer';
|
||||
import { DashboardVariablesEditor, validateVariables, type DashboardVariable } from './DashboardVariablesEditor';
|
||||
import { DashboardTransfer } from './DashboardTransfer';
|
||||
import { movePlacement, normalizePlacement, overlaps, resizePlacement, resolvePlacement, spatialColumns, spatialStyle, type SpatialPlacement, type SpatialViewport } from './dashboardSpatial';
|
||||
import { isWidgetType, widgetLabel, widgetTypes } from './widgetCatalog';
|
||||
|
||||
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 };
|
||||
@@ -14,15 +16,26 @@ 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 };
|
||||
export const types: string[] = [...widgetTypes];
|
||||
export const labels: Record<string, string> = Object.fromEntries(widgetTypes.map((type) => [type, widgetLabel(type)]));
|
||||
|
||||
const widgetGroups = [
|
||||
{ id: 'metrics', label: 'Metrics', types: ['stat', 'gauge', 'timeseries', 'query-inspector'] },
|
||||
{ id: 'operations', label: 'Operations', types: ['status-grid', 'ranked-list', 'table', 'alert-summary', 'event-timeline'] },
|
||||
{ id: 'infrastructure', label: 'Infrastructure', types: ['storage-map', 'service-matrix', 'topology', 'heatmap'] },
|
||||
{ id: 'content', label: 'Content', types: ['text'] },
|
||||
] as const;
|
||||
const widgetGlyphs: Record<string, string> = {
|
||||
stat: '12', gauge: '◔', timeseries: '⌁', 'query-inspector': '⌕', 'status-grid': '▦',
|
||||
'ranked-list': '≣', table: '▤', 'alert-summary': '!', 'event-timeline': '⋮',
|
||||
'storage-map': '◫', 'service-matrix': '◇', topology: '⌬', heatmap: '▦', text: 'T',
|
||||
};
|
||||
const sourceTypes = ['semantic-metric', 'inventory', 'events', 'alerts', 'incidents', 'text'];
|
||||
const ranges = ['live', '15m', '1h', '6h', '24h', '7d'];
|
||||
const ranges = ['live', '15m', '1h', '6h', '24h', '7d', '30d'];
|
||||
const aggregations = ['avg', 'min', 'max', 'sum', 'last'];
|
||||
type Viewport = 'desktop' | 'tablet' | 'mobile' | 'wallboard';
|
||||
type Viewport = SpatialViewport;
|
||||
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;
|
||||
@@ -31,24 +44,24 @@ function read<T>(value: RecordValue | undefined, name: string): 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 {
|
||||
function layout(widget: EditorWidget, viewport: Viewport = 'desktop'): SpatialPlacement {
|
||||
const layouts = widget.layouts ?? {};
|
||||
const fallback = read<RecordValue>(layouts, 'desktop') ?? {};
|
||||
return { ...fallback, ...(read<RecordValue>(layouts, viewport) ?? {}) };
|
||||
return normalizePlacement({ ...fallback, ...(read<RecordValue>(layouts, viewport) ?? {}) }, viewport);
|
||||
}
|
||||
function withLayout(widget: EditorWidget, viewport: Viewport, nextLayout: RecordValue): EditorWidget {
|
||||
return { ...widget, layouts: { ...(widget.layouts ?? {}), [viewport]: nextLayout } };
|
||||
function withLayout(widget: EditorWidget, viewport: Viewport, nextLayout: SpatialPlacement): EditorWidget {
|
||||
return { ...widget, layouts: { ...(widget.layouts ?? {}), [viewport]: normalizePlacement(nextLayout as unknown as RecordValue, viewport) } };
|
||||
}
|
||||
function layoutWidth(widget: EditorWidget, viewport: Viewport): number {
|
||||
return Math.min(viewportColumns[viewport], Math.max(1, Number(layout(widget, viewport).w ?? 6) || 1));
|
||||
return layout(widget, viewport).w;
|
||||
}
|
||||
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') ?? {}) };
|
||||
const desktop = normalizePlacement({ ...base, ...(read<RecordValue>(layouts, 'desktop') ?? {}) }, 'desktop');
|
||||
const tablet = normalizePlacement({ ...desktop, ...(read<RecordValue>(layouts, 'tablet') ?? {}) }, 'tablet');
|
||||
const mobile = normalizePlacement({ ...desktop, ...(read<RecordValue>(layouts, 'mobile') ?? {}) }, 'mobile');
|
||||
const wallboard = normalizePlacement({ ...desktop, ...(read<RecordValue>(layouts, 'wallboard') ?? {}) }, '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 {
|
||||
@@ -56,13 +69,35 @@ function newWidget(type: string, index: number): EditorWidget {
|
||||
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 normalizeWidgets(widgets: EditorWidget[]): EditorWidget[] {
|
||||
const normalized = widgets.map((widget, index) => normalizeWidget(copyWidget(widget), index));
|
||||
for (const viewport of viewports) {
|
||||
const placed: Array<{ id: string; placement: SpatialPlacement }> = [];
|
||||
for (let index = 0; index < normalized.length; index += 1) {
|
||||
const current = layout(normalized[index], viewport);
|
||||
const next = viewport === 'mobile' ? current : resolvePlacement(current, placed, viewport);
|
||||
normalized[index] = withLayout(normalized[index], viewport, next);
|
||||
placed.push({ id: normalized[index].id, placement: next });
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function validateDashboardLayouts(widgets: EditorWidget[]): boolean {
|
||||
for (const viewport of viewports.filter((value) => value !== 'mobile')) {
|
||||
const visible = widgets.map((widget) => ({ id: widget.id, placement: layout(widget, viewport) })).filter((item) => item.placement.visible !== false);
|
||||
if (visible.some((item, index) => visible.slice(index + 1).some((other) => overlaps(item.placement, other.placement)))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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 (!isWidgetType(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 ?? {};
|
||||
@@ -83,7 +118,12 @@ export function validateWidget(widget: EditorWidget): ValidationErrors {
|
||||
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);
|
||||
const thresholds = Array.isArray(visualization.thresholds) ? visualization.thresholds as Array<Record<string, unknown>> : [];
|
||||
if (thresholds.length > 10 || thresholds.some((threshold) => numberValue(threshold.value) === undefined || typeof threshold.color !== 'string' || !/^#[0-9a-f]{6}$/i.test(threshold.color))) errors['visualization.thresholds'] = validation.thresholdsInvalid;
|
||||
const behavior = widget.behavior ?? {};
|
||||
const link = behavior.link;
|
||||
if (typeof link === 'string' && link !== '' && (link.length > 1000 || !link.startsWith('/') || link.startsWith('//') || /[\u0000-\u001f]/.test(link))) errors['behavior.link'] = validation.linkInvalid;
|
||||
const interval = numberValue(behavior.liveIntervalSeconds);
|
||||
if (interval !== undefined && (!Number.isInteger(interval) || interval < 1 || interval > 300)) errors['behavior.liveIntervalSeconds'] = validation.intervalRange;
|
||||
return errors;
|
||||
}
|
||||
@@ -91,15 +131,15 @@ export function validateWidget(widget: EditorWidget): ValidationErrors {
|
||||
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 normalizedInitial = normalizeWidgets(initial);
|
||||
const [draft, setDraft] = useState<EditorWidget[]>(normalizedInitial);
|
||||
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 [savedSnapshot, setSavedSnapshot] = useState(() => JSON.stringify({ widgets: normalizedInitial, 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);
|
||||
@@ -109,8 +149,8 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav
|
||||
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 resize = useRef<{ id: string; x: number; y: number; placement: SpatialPlacement } | null>(null);
|
||||
const drag = useRef<{ id: string; x: number; y: number; placement: SpatialPlacement } | 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);
|
||||
@@ -122,6 +162,13 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav
|
||||
setFuture([]);
|
||||
return next;
|
||||
});
|
||||
const mutateWidgetPlacement = (id: string, change: (current: SpatialPlacement, others: Array<{ id: string; placement: SpatialPlacement }>) => SpatialPlacement) => applyDraft((items) => {
|
||||
const target = items.find((item) => item.id === id);
|
||||
if (!target || read<boolean>(target.behavior, 'locked')) return items;
|
||||
const others = items.filter((item) => item.id !== id).map((item) => ({ id: item.id, placement: layout(item, activeViewport) }));
|
||||
const next = change(layout(target, activeViewport), others);
|
||||
return items.map((item) => item.id === id ? withLayout(item, activeViewport, next) : item);
|
||||
});
|
||||
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) => {
|
||||
@@ -133,43 +180,61 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav
|
||||
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 };
|
||||
const widget = draft.find((item) => item.id === id);
|
||||
if (!widget) return;
|
||||
drag.current = { id, x: event.clientX, y: event.clientY, placement: layout(widget, activeViewport) };
|
||||
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 deltaY = event.clientY - active.y;
|
||||
if (activeViewport === 'mobile') {
|
||||
if (Math.abs(deltaY) < 28) return;
|
||||
move(id, deltaY > 0 ? 1 : -1);
|
||||
drag.current = { ...active, x: event.clientX, y: event.clientY };
|
||||
return;
|
||||
}
|
||||
const grid = event.currentTarget.parentElement?.getBoundingClientRect();
|
||||
const columnWidth = grid ? grid.width / spatialColumns[activeViewport] : 48;
|
||||
const dx = Math.round((event.clientX - active.x) / Math.max(24, columnWidth));
|
||||
const dy = Math.round(deltaY / 48);
|
||||
if (dx === 0 && dy === 0) return;
|
||||
mutateWidgetPlacement(id, (current, others) => movePlacement(current, dx, dy, others, activeViewport));
|
||||
drag.current = { id, x: event.clientX, y: event.clientY, placement: { ...active.placement, x: active.placement.x + dx, y: Math.max(0, active.placement.y + dy) } };
|
||||
};
|
||||
const onWidgetPointerUp = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
drag.current = null;
|
||||
setDragged(null);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
if (typeof event.currentTarget.hasPointerCapture === 'function' && 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); };
|
||||
const add = (type: string) => { const item = newWidget(type, 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 width = Math.min(spatialColumns[activeViewport], Math.max(1, Math.round(next)));
|
||||
mutateWidgetPlacement(id, (current, others) => resolvePlacement({ ...current, w: width }, others, activeViewport));
|
||||
};
|
||||
const setWidgetLayoutField = (id: string, field: 'x' | 'y' | 'w' | 'h', value: number) => {
|
||||
if (!Number.isFinite(value)) return;
|
||||
mutateWidgetPlacement(id, (current, others) => resolvePlacement({ ...current, [field]: Math.round(value) }, others, activeViewport));
|
||||
};
|
||||
const onResizeMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
const active = resize.current; if (!active) return;
|
||||
setWidgetWidth(active.id, active.w + Math.round((event.clientX - active.x) / 48));
|
||||
const dw = Math.round((event.clientX - active.x) / 48);
|
||||
const dh = Math.round((event.clientY - active.y) / 48);
|
||||
mutateWidgetPlacement(active.id, (_current, others) => resizePlacement(active.placement, dw, dh, others, activeViewport));
|
||||
};
|
||||
// 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;
|
||||
const max = spatialColumns[activeViewport];
|
||||
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
||||
if (step === 0 && event.key !== 'Home' && event.key !== 'End') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -193,7 +258,7 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav
|
||||
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)));
|
||||
setDraft(normalizeWidgets(importedWidgets));
|
||||
setVariables(importedVariables);
|
||||
setHistory([]); setFuture([]); setMessage(messages.importLoaded);
|
||||
};
|
||||
@@ -204,7 +269,7 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav
|
||||
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 next = normalizeWidgets(serverWidgets);
|
||||
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); }
|
||||
@@ -222,6 +287,7 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav
|
||||
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 (!validateDashboardLayouts(draft)) { setMessage(messages.saveBlockedLayouts); return; }
|
||||
if (Object.keys(validateVariables(variables)).length > 0) { setMessage(messages.saveBlockedVariables); return; }
|
||||
setSaving(true); setMessage('');
|
||||
const body = { ...document, widgets: draft, variables };
|
||||
@@ -236,9 +302,9 @@ export function DashboardEditor({ dashboardId, revision, document, onExit, onSav
|
||||
|
||||
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>
|
||||
{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><details className="widget-picker"><summary className="button button--secondary">{shell.addWidget}</summary><div className="widget-picker-panel" aria-label="Widgetbibliotheek">{widgetGroups.map((group) => <section key={group.id}><p className="card-kicker">{group.label}</p><div className="widget-picker-grid">{group.types.map((type) => <button type="button" key={type} onClick={(event) => { add(type); const details = event.currentTarget.closest('details'); if (details) details.open = false; }}><span className="widget-picker-glyph" aria-hidden="true">{widgetGlyphs[type] ?? '□'}</span><span><strong>{labels[type]}</strong><small>{type === 'timeseries' ? 'Trend en historische vergelijking' : type === 'stat' ? 'Eén dominante waarde' : type === 'gauge' ? 'Waarde tegen bereik en drempels' : type === 'status-grid' ? 'Veel statussen in één scan' : type === 'ranked-list' ? 'Gesorteerde operationele lijst' : type === 'alert-summary' ? 'Actieve meldingen op prioriteit' : type === 'event-timeline' ? 'Gebeurtenissen in tijd' : type === 'storage-map' ? 'Array, pools en disks' : type === 'service-matrix' ? 'Servicebeschikbaarheid' : type === 'topology' ? 'Dependencyrelaties' : type === 'text' ? 'Context en toelichting' : 'Geavanceerde visualisatie'}</small></span></button>)}</div></section>)}</div></details>{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>
|
||||
{draft.map((widget) => { const activeLayout = layout(widget, activeViewport); const locked = read<boolean>(widget.behavior, 'locked') === true; const viewportHidden = 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={spatialStyle(activeLayout as unknown as RecordValue, activeViewport) 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" disabled={locked} aria-label={cardCopy.moveUp + widget.title} onClick={(event) => { event.stopPropagation(); if (activeViewport === 'mobile') move(widget.id, -1); else mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, 0, -1, others, activeViewport)); }}>{cardCopy.moveUpVisible}</button><button type="button" disabled={locked} aria-label={cardCopy.moveDown + widget.title} onClick={(event) => { event.stopPropagation(); if (activeViewport === 'mobile') move(widget.id, 1); else mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, 0, 1, others, activeViewport)); }}>{cardCopy.moveDownVisible}</button>{activeViewport !== 'mobile' && <><button type="button" disabled={locked} aria-label={cardCopy.moveLeft + widget.title} onClick={(event) => { event.stopPropagation(); mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, -1, 0, others, activeViewport)); }}>{cardCopy.moveLeftVisible}</button><button type="button" disabled={locked} aria-label={cardCopy.moveRight + widget.title} onClick={(event) => { event.stopPropagation(); mutateWidgetPlacement(widget.id, (current, others) => movePlacement(current, 1, 0, others, activeViewport)); }}>{cardCopy.moveRightVisible}</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} disabled={locked} aria-label={cardCopy.resizeLabel + widget.title} aria-orientation="horizontal" aria-valuemin={1} aria-valuemax={spatialColumns[activeViewport]} aria-valuenow={width} aria-valuetext={`${width} × ${activeLayout.h} ${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, y: event.clientY, placement: activeLayout }; }} 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]} placement={layout(selectedWidget, activeViewport)} maxWidth={spatialColumns[activeViewport]} locked={read<boolean>(selectedWidget.behavior, 'locked') === true} onChange={updateSelected} onPlacementChange={(field, value) => setWidgetLayoutField(selectedWidget.id, field, 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>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { formatDateTime } from './locale';
|
||||
import { readSessionCapabilities, type SessionCapabilities } from './sessionClient';
|
||||
import { archiveDashboard, cloneDashboard, createDashboard, DashboardApiError, listDashboards, listDashboardVersions, restoreDashboardVersion, updateDashboardMetadata, type DashboardSummary, type DashboardVersion } from './dashboardClient';
|
||||
import { buildDashboardDocument, dashboardSlug, type DashboardTemplateId } from './dashboardTemplates';
|
||||
import { dashboardManagerCopy as t } from './copy';
|
||||
import './dashboard-manager.css';
|
||||
|
||||
type LoadState = 'loading' | 'ready' | 'unauthorized' | 'error';
|
||||
function navigate(path: string) { window.history.pushState({}, '', path); window.dispatchEvent(new PopStateEvent('popstate')); }
|
||||
|
||||
export function DashboardManagerPage() {
|
||||
const [state, setState] = useState<LoadState>('loading');
|
||||
const [items, setItems] = useState<DashboardSummary[]>([]);
|
||||
const [session, setSession] = useState<SessionCapabilities | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [scope, setScope] = useState<'personal' | 'shared'>('personal');
|
||||
const [template, setTemplate] = useState<DashboardTemplateId>('operations');
|
||||
const [busy, setBusy] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [manageId, setManageId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
const [confirmArchive, setConfirmArchive] = useState<string | null>(null);
|
||||
const [versionDashboard, setVersionDashboard] = useState<string | null>(null);
|
||||
const [versions, setVersions] = useState<DashboardVersion[]>([]);
|
||||
const [versionsLoading, setVersionsLoading] = useState(false);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
const load = useCallback((signal?: AbortSignal) => {
|
||||
setState('loading');
|
||||
Promise.all([listDashboards(signal), readSessionCapabilities(signal)]).then(([dashboards, capabilities]) => {
|
||||
setItems(dashboards); setSession(capabilities); setState('ready');
|
||||
}).catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
setState(error && typeof error === 'object' && 'status' in error && (error as { status?: number }).status === 401 ? 'unauthorized' : 'error');
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { const controller = new AbortController(); load(controller.signal); return () => controller.abort(); }, [load, reload]);
|
||||
const canEdit = session?.permissions.edit === true;
|
||||
const active = useMemo(() => items.filter((item) => !item.archivedAt).sort((a, b) => a.name.localeCompare(b.name, 'nl-BE')), [items]);
|
||||
const archived = useMemo(() => items.filter((item) => Boolean(item.archivedAt)).sort((a, b) => a.name.localeCompare(b.name, 'nl-BE')), [items]);
|
||||
|
||||
const mutate = async (key: string, action: () => Promise<void>) => {
|
||||
setBusy(key); setMessage('');
|
||||
try { await action(); }
|
||||
catch (error: unknown) {
|
||||
if (error instanceof DashboardApiError && error.conflict) { setMessage(t.conflict); setReload((value) => value + 1); }
|
||||
else setMessage(error instanceof Error && error.message ? error.message : t.failed);
|
||||
} finally { setBusy(''); }
|
||||
};
|
||||
|
||||
const create = () => void mutate('create', async () => {
|
||||
if (!name.trim()) { setMessage(t.nameRequired); return; }
|
||||
const created = await createDashboard(buildDashboardDocument({ name, description, scope, template }));
|
||||
setItems((current) => [...current, created]); setName(''); setDescription(''); setCreateOpen(false); setMessage(t.created);
|
||||
});
|
||||
const beginManage = (item: DashboardSummary) => { setManageId((current) => current === item.id ? null : item.id); setEditName(item.name); setEditDescription(item.description); setConfirmArchive(null); setVersionDashboard(null); setVersions([]); };
|
||||
const saveMetadata = (item: DashboardSummary) => void mutate('metadata:' + item.id, async () => { if (!editName.trim()) { setMessage(t.nameRequired); return; } const updated = await updateDashboardMetadata(item, editName.trim(), editDescription.trim()); setItems((current) => current.map((value) => value.id === item.id ? updated : value)); setManageId(null); setMessage(t.updated); });
|
||||
const clone = (item: DashboardSummary) => void mutate('clone:' + item.id, async () => { const cloneName = `${item.name} (kopie)`; const created = await cloneDashboard(item, dashboardSlug(cloneName), cloneName); setItems((current) => [...current, created]); setMessage(t.cloned); });
|
||||
const archive = (item: DashboardSummary) => void mutate('archive:' + item.id, async () => { const updated = await archiveDashboard(item); setItems((current) => current.map((value) => value.id === item.id ? updated : value)); setConfirmArchive(null); setManageId(null); setMessage(t.archivedDone); });
|
||||
const openVersions = async (item: DashboardSummary) => { if (versionDashboard === item.id) { setVersionDashboard(null); setVersions([]); return; } setVersionDashboard(item.id); setVersionsLoading(true); setVersions([]); setMessage(''); try { setVersions(await listDashboardVersions(item)); } catch (error: unknown) { setMessage(error instanceof Error ? error.message : t.failed); } finally { setVersionsLoading(false); } };
|
||||
const restore = (item: DashboardSummary, version: number) => void mutate(`restore:${item.id}:${version}`, async () => { const updated = await restoreDashboardVersion(item, version); setItems((current) => current.map((value) => value.id === item.id ? updated : value)); setVersionDashboard(null); setVersions([]); setMessage(t.restored); });
|
||||
|
||||
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{t.loading}</h1></section>;
|
||||
if (state === 'unauthorized') return <section className="state-page" role="alert"><h1>{t.unauthorized}</h1></section>;
|
||||
if (state === 'error') return <section className="state-page" role="alert"><h1>{t.error}</h1><button className="button" type="button" onClick={() => setReload((value) => value + 1)}>{t.retry}</button></section>;
|
||||
|
||||
const renderDashboard = (item: DashboardSummary) => <li className="dashboard-manager-row" key={item.id}><div className="dashboard-manager-summary"><button className="dashboard-manager-open" type="button" onClick={() => navigate('/dashboards/' + encodeURIComponent(item.id))}><span><strong>{item.name}</strong><small>{item.description || item.slug}</small></span><span className="dashboard-manager-meta"><span>{item.scope === 'personal' ? t.personal : item.scope === 'shared' ? t.shared : t.system}</span><span>v{item.currentVersion} · r{item.revision}</span>{item.archivedAt && <span>{t.archivedBadge}</span>}</span></button>{canEdit && !item.archivedAt && <button className="button button--secondary dashboard-manager-manage" type="button" onClick={() => beginManage(item)}>{manageId === item.id ? t.closeManage : t.manage}</button>}</div>{canEdit && manageId === item.id && !item.archivedAt && <div className="dashboard-manager-actions"><section aria-label={t.rename}><label>{t.name}<input value={editName} maxLength={120} onChange={(event) => setEditName(event.target.value)} /></label><label>{t.description}<textarea value={editDescription} maxLength={500} rows={2} onChange={(event) => setEditDescription(event.target.value)} /></label><button className="button" type="button" disabled={busy !== ''} onClick={() => saveMetadata(item)}>{t.saveMetadata}</button></section><section className="dashboard-manager-compact-actions" aria-label={t.manage}><button className="button button--secondary" type="button" disabled={busy !== ''} onClick={() => clone(item)}>{t.clone}</button><button className="button button--secondary" type="button" disabled={busy !== ''} onClick={() => void openVersions(item)}>{t.loadVersions}</button>{item.scope === 'system' ? <p className="dashboard-manager-note">{t.systemProtected}</p> : <button className="button button--danger" type="button" disabled={busy !== ''} onClick={() => setConfirmArchive(item.id)}>{t.archive}</button>}</section>{confirmArchive === item.id && <div className="dashboard-manager-confirm" role="alert"><strong>{t.archiveConfirm}</strong><p>{t.archiveConfirmDetail}</p><button className="button button--secondary" type="button" onClick={() => setConfirmArchive(null)}>{t.cancel}</button><button className="button button--danger" type="button" disabled={busy !== ''} onClick={() => archive(item)}>{t.confirmArchive}</button></div>}{versionDashboard === item.id && <section className="dashboard-version-panel" aria-label={t.versions}>{versionsLoading ? <p role="status">{t.loading}</p> : versions.length === 0 ? <p>{t.noVersions}</p> : <ol>{versions.map((version) => <li key={version.id}><span><strong>v{version.number}{version.number === item.currentVersion ? ` · ${t.currentVersion}` : ''}</strong><small>{version.createdAt ? formatDateTime(version.createdAt) : ''} · {version.changeSummary || '—'} · {t.changedBy} {version.createdBy || t.unknownActor}</small></span>{version.number !== item.currentVersion && <button className="button button--secondary" type="button" disabled={busy !== ''} onClick={() => restore(item, version.number)}>{t.restore}</button>}</li>)}</ol>}</section>}</div>}</li>;
|
||||
|
||||
return <section className="dashboard-manager" aria-labelledby="dashboard-manager-title"><header className="page-intro dashboard-manager-header"><div><p className="eyebrow">{t.eyebrow}</p><h1 id="dashboard-manager-title">{t.title}</h1><p className="intro">{t.intro}</p></div>{canEdit && <button className="button" type="button" onClick={() => setCreateOpen((value) => !value)}>{createOpen ? t.cancel : t.create}</button>}</header>{!canEdit && <div className="card dashboard-manager-viewer" role="status"><strong>{t.viewerTitle}</strong><p>{t.viewerDetail}</p></div>}{canEdit && createOpen && <form className="card dashboard-create-panel" onSubmit={(event) => { event.preventDefault(); create(); }}><h2>{t.createTitle}</h2><div className="dashboard-create-grid"><label>{t.name}<input autoFocus value={name} maxLength={120} onChange={(event) => setName(event.target.value)} /></label><label>{t.scope}<select value={scope} onChange={(event) => setScope(event.target.value as 'personal' | 'shared')}><option value="personal">{t.personal}</option><option value="shared">{t.shared}</option></select></label><label>{t.template}<select value={template} onChange={(event) => setTemplate(event.target.value as DashboardTemplateId)}><option value="operations">{t.templateOperations}</option><option value="host">Host overview</option><option value="storage">{t.templateStorage}</option><option value="workloads">Workloads</option><option value="services">Services</option><option value="incidents">Incident response</option><option value="capacity">Capacity planning</option><option value="wallboard">NOC wallboard</option><option value="blank">{t.templateBlank}</option></select></label><label className="dashboard-create-description">{t.description}<textarea value={description} maxLength={500} rows={3} onChange={(event) => setDescription(event.target.value)} /></label></div><button className="button" type="submit" disabled={busy !== ''}>{busy === 'create' ? t.creating : t.createAction}</button></form>}{message && <p className="editor-message dashboard-manager-message" role="status">{message}</p>}<section className="card dashboard-manager-list" aria-labelledby="dashboard-active-title"><div className="card-heading"><div><p className="card-kicker">{t.active}</p><h2 id="dashboard-active-title">{active.length} {t.active.toLowerCase()}</h2></div>{archived.length > 0 && <button className="button button--secondary" type="button" onClick={() => setShowArchived((value) => !value)}>{showArchived ? t.hideArchived : `${t.showArchived} (${archived.length})`}</button>}</div>{active.length ? <ul>{active.map(renderDashboard)}</ul> : <p className="card-copy">{t.noActive}</p>}</section>{showArchived && <section className="card dashboard-manager-list dashboard-manager-list--archived" aria-labelledby="dashboard-archived-title"><div className="card-heading"><div><p className="card-kicker">{t.archived}</p><h2 id="dashboard-archived-title">{archived.length} {t.archived.toLowerCase()}</h2></div></div>{archived.length ? <ul>{archived.map(renderDashboard)}</ul> : <p className="card-copy">{t.noArchived}</p>}</section>}</section>;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } 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 { MetricClient, rangeForSelection, type MetricQueryRequest } from './metricClient';
|
||||
import { MetricWidget, RankedListWidget, StatusGridWidget, type MetricWidgetProps, type RankedListItem, type StatusGridItem } from './MetricWidgets';
|
||||
import { buildStorageNodes, type StorageData } from './StoragePage';
|
||||
import { StorageMapWidget } from './StorageVisuals';
|
||||
import { aggregateStatus, useSystemStatus } from './systemStatus';
|
||||
@@ -12,75 +12,115 @@ import { useMetricQuery } from './useMetricQuery';
|
||||
import { formatDateTime } from './locale';
|
||||
import { copy } from './copy';
|
||||
import { presentEventSummary, presentEventType, presentReason, presentStatus } from './presentation';
|
||||
import { wallboardColumns, wallboardPlacement } from './wallboardLayout';
|
||||
import { useOperatorContext } from './operatorContext';
|
||||
import { wallboardPlacement } from './wallboardLayout';
|
||||
import { spatialStyle } from './dashboardSpatial';
|
||||
import { isWidgetType, widgetLabel } from './widgetCatalog';
|
||||
|
||||
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';
|
||||
export type RuntimeState = 'loading' | 'usable' | 'empty' | 'stale' | 'partial' | 'error' | 'capability-disabled';
|
||||
type RuntimeRow = { id: string; label: string; status: string; value?: string; detail?: string; occurredAt?: string; severity?: string; acknowledged?: boolean };
|
||||
type EventItem = { id: string; type: string; severity: string; summary: string; occurredAt: string; sourceId?: string };
|
||||
type Resource = { state: RuntimeState; rows?: RuntimeRow[]; storage?: StorageData; events?: EventItem[]; text?: string; error?: string };
|
||||
|
||||
const metricClient = new MetricClient();
|
||||
const liveClient = new LiveClient();
|
||||
const metricTypes = new Set(['stat', 'timeseries', 'gauge', 'query-inspector']);
|
||||
|
||||
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 safeLimit(widget: RuntimeWidget): number { return Math.min(100, Math.max(1, Math.trunc(Number(field(widget.data, 'limit') ?? 100) || 100))); }
|
||||
function safeInterval(widget: RuntimeWidget): number { return Math.min(300, Math.max(5, Math.trunc(Number(field(widget.behavior, 'liveIntervalSeconds') ?? 30) || 30))) * 1000; }
|
||||
function stringValue(value: unknown, fallback = ''): string { return typeof value === 'string' ? value.slice(0, 500) : fallback; }
|
||||
|
||||
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>;
|
||||
const labels: Record<RuntimeState, string> = {
|
||||
usable: copy.dashboards.runtime.current, loading: copy.dashboards.runtime.loading, empty: copy.dashboards.runtime.empty,
|
||||
stale: copy.dashboards.runtime.stale, partial: copy.dashboards.runtime.partial, error: copy.dashboards.runtime.error,
|
||||
'capability-disabled': copy.dashboards.runtime.capabilityDisabled,
|
||||
};
|
||||
const ready = state === 'usable';
|
||||
return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{labels[state]}</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 };
|
||||
async function readJSON<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>;
|
||||
}
|
||||
|
||||
function useInventoryResource(widget: RuntimeWidget): Resource {
|
||||
const [resource, setResource] = useState<Resource>({ state: 'loading' });
|
||||
function useBoundedResource(widget: RuntimeWidget): Resource {
|
||||
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;
|
||||
const entityTypes = field<unknown[]>(scope, 'entityTypes');
|
||||
const limit = safeLimit(widget);
|
||||
const interval = safeInterval(widget);
|
||||
const rawText = field<unknown>(widget.data, 'text');
|
||||
const text = typeof rawText === 'string' ? rawText.slice(0, 5000) : '';
|
||||
const key = JSON.stringify({ sourceType, entityType, entityTypes, limit, interval, type: widget.type, text });
|
||||
const [resource, setResource] = useState<Resource>(() => sourceType === 'text' ? { state: text.trim() ? 'usable' : 'empty', text } : { state: 'loading' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isApplications && !isStorage && !isEvents) {
|
||||
setResource({ state: 'empty' });
|
||||
return undefined;
|
||||
}
|
||||
if (sourceType === 'semantic-metric' || (sourceType === 'inventory' && entityType === 'server')) return undefined;
|
||||
if (sourceType === 'text') { setResource({ state: text.trim() ? 'usable' : 'empty', text }); return undefined; }
|
||||
if (!['inventory', 'events', 'alerts', 'incidents'].includes(sourceType)) { setResource({ state: 'capability-disabled' }); 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) {
|
||||
if (sourceType === 'inventory' && Array.isArray(entityTypes)) {
|
||||
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),
|
||||
readJSON<StorageData['array']>('/api/v1/array', current.signal),
|
||||
readJSON<StorageData['disks']>('/api/v1/disks?limit=' + limit, current.signal),
|
||||
readJSON<StorageData['pools']>('/api/v1/pools?limit=' + limit, 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);
|
||||
return;
|
||||
}
|
||||
if (sourceType === 'inventory' && entityType === 'application') {
|
||||
const value = await readJSON<{ applications?: Array<RecordValue> }>('/api/v1/applications', current.signal);
|
||||
const rows = (value.applications ?? []).slice(0, limit).map((item) => ({ id: stringValue(item.id), label: stringValue(item.name, stringValue(item.id)), status: stringValue(item.status, 'unknown'), detail: presentReason(stringValue(field<Array<RecordValue>>(item, 'reasons')?.[0]?.message)) }));
|
||||
if (active) setResource({ state: rows.length ? 'usable' : 'empty', rows });
|
||||
return;
|
||||
}
|
||||
if (sourceType === 'inventory' && (entityType === 'service' || widget.type === 'service-matrix')) {
|
||||
const value = await readJSON<{ services?: Array<RecordValue>; total?: number }>('/api/v1/services?limit=' + limit, current.signal);
|
||||
const rows = (value.services ?? []).slice(0, limit).map((item) => ({ id: stringValue(item.id), label: stringValue(item.name, stringValue(item.id)), status: stringValue(item.state, 'unknown'), value: field<number>(item, 'responseTimeMs') == null ? '—' : `${field<number>(item, 'responseTimeMs')} ms`, detail: field<number>(item, 'availabilityPercent') == null ? stringValue(item.reason) : `${field<number>(item, 'availabilityPercent')?.toFixed(2)}%` }));
|
||||
if (active) setResource({ state: (value.total ?? rows.length) > rows.length ? 'partial' : rows.length ? 'usable' : 'empty', rows });
|
||||
return;
|
||||
}
|
||||
if (sourceType === 'inventory') {
|
||||
const query = new URLSearchParams({ limit: String(limit) });
|
||||
if (entityType) query.set('type', entityType);
|
||||
const value = await readJSON<{ items?: Array<RecordValue>; hasMore?: boolean }>('/api/v1/entities?' + query, current.signal);
|
||||
const rows = (value.items ?? []).slice(0, limit).map((item) => ({ id: stringValue(item.id), label: stringValue(item.displayName, stringValue(item.canonicalName, stringValue(item.id))), status: stringValue(item.status, 'unknown'), value: String(field<number>(item, 'factCount') ?? ''), detail: stringValue(item.entityType) }));
|
||||
if (active) setResource({ state: value.hasMore ? 'partial' : rows.length ? 'usable' : 'empty', rows });
|
||||
return;
|
||||
}
|
||||
if (sourceType === 'events') {
|
||||
const value = await readJSON<{ 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 });
|
||||
return;
|
||||
}
|
||||
if (sourceType === 'alerts') {
|
||||
const value = await readJSON<{ items?: Array<RecordValue> }>('/api/v1/alerts?limit=' + limit, current.signal);
|
||||
const rows = (value.items ?? []).slice(0, limit).map((item) => ({ id: stringValue(item.id), label: stringValue(item.ruleName, stringValue(item.id)), status: stringValue(item.state, 'unknown'), severity: stringValue(item.severity, 'unknown'), value: stringValue(item.entityName, '—'), detail: stringValue(item.reason), occurredAt: stringValue(item.updatedAt), acknowledged: Boolean(item.acknowledgedBy) }));
|
||||
if (active) setResource({ state: rows.length ? 'usable' : 'empty', rows });
|
||||
return;
|
||||
}
|
||||
const value = await readJSON<{ items?: Array<RecordValue> }>('/api/v1/incidents?limit=' + limit, current.signal);
|
||||
const rows = (value.items ?? []).slice(0, limit).map((item) => ({ id: stringValue(item.id), label: stringValue(item.title, stringValue(item.id)), status: stringValue(item.status, 'unknown'), severity: stringValue(item.severity, 'unknown'), value: stringValue(item.ownerUserId, '—'), detail: stringValue(item.summary), occurredAt: stringValue(item.updatedAt) }));
|
||||
if (active) setResource({ state: rows.length ? 'usable' : 'empty', rows });
|
||||
} 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' });
|
||||
@@ -89,23 +129,55 @@ function useInventoryResource(widget: RuntimeWidget): Resource {
|
||||
void load();
|
||||
const timer = window.setInterval(load, interval);
|
||||
return () => { active = false; controller?.abort(); window.clearInterval(timer); };
|
||||
}, [isApplications, isStorage, isEvents, interval, widget.data]);
|
||||
}, [key, sourceType, entityType, entityTypes, limit, interval, text, widget.type]);
|
||||
return resource;
|
||||
}
|
||||
|
||||
function RuntimeNotice({ state }: { state: RuntimeState }) {
|
||||
const title = state === 'error' ? copy.dashboards.runtime.sourceUnavailable : state === 'loading' ? copy.dashboards.runtime.telemetryLoading : state === 'capability-disabled' ? copy.dashboards.runtime.capabilityDisabled : copy.dashboards.runtime.noCurrentData;
|
||||
const detail = state === 'error' ? copy.dashboards.runtime.unavailableDetail : state === 'capability-disabled' ? 'Kies een passend widgettype of een andere datasource.' : copy.dashboards.runtime.retryDetail;
|
||||
return <div className={'dashboard-runtime-state dashboard-runtime-state--' + state} role={state === 'error' ? 'alert' : 'status'}><strong>{title}</strong><span>{detail}</span></div>;
|
||||
}
|
||||
function RuntimeTable({ rows, service = false }: { rows: RuntimeRow[]; service?: boolean }) {
|
||||
return <div className="dashboard-runtime-table-wrap" tabIndex={0} aria-label={`${rows.length} ${copy.dashboards.runtime.rows}`}><table className="dashboard-runtime-table"><caption className="sr-only">{rows.length} {copy.dashboards.runtime.rows}</caption><thead><tr><th scope="col">Naam</th><th scope="col">Status</th><th scope="col">{service ? 'Respons / beschikbaarheid' : 'Waarde'}</th></tr></thead><tbody>{rows.slice(0, 100).map((row) => <tr key={row.id}><th scope="row">{row.label}<small>{row.detail}</small></th><td>{presentStatus(row.status)}</td><td>{row.value || '—'}</td></tr>)}</tbody></table></div>;
|
||||
}
|
||||
function AlertSummary({ rows }: { rows: RuntimeRow[] }) {
|
||||
const count = (severity: string) => rows.filter((row) => row.severity === severity && row.status !== 'resolved').length;
|
||||
return <div className="dashboard-alert-summary"><dl><div><dt>Kritiek</dt><dd>{count('critical')}</dd></div><div><dt>Waarschuwing</dt><dd>{count('warning')}</dd></div><div><dt>Actief</dt><dd>{rows.filter((row) => row.status !== 'resolved').length}</dd></div><div><dt>Bevestigd</dt><dd>{rows.filter((row) => row.acknowledged).length}</dd></div></dl><RuntimeTable rows={rows.slice(0, 10)} /></div>;
|
||||
}
|
||||
function safeInline(value: string): ReactNode[] {
|
||||
const parts: ReactNode[] = [];
|
||||
const pattern = /\[([^\]]{1,120})\]\((\/[^)\s]*|https:\/\/[^)\s]+)\)/g;
|
||||
let cursor = 0; let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(value)) !== null) { if (match.index > cursor) parts.push(value.slice(cursor, match.index)); parts.push(<a key={match.index} href={match[2]} rel={match[2].startsWith('https://') ? 'noreferrer' : undefined}>{match[1]}</a>); cursor = match.index + match[0].length; }
|
||||
if (cursor < value.length) parts.push(value.slice(cursor));
|
||||
return parts;
|
||||
}
|
||||
function TextWidget({ text }: { text: string }) { return <div className="dashboard-text-widget">{text.slice(0, 5000).split(/\r?\n/).slice(0, 100).filter(Boolean).map((line, index) => <p key={index}>{safeInline(line.replace(/^#{1,3}\s+/, '').replace(/^[-*]\s+/, '• '))}</p>)}</div>; }
|
||||
|
||||
function sourceLabel(sourceType: string): string {
|
||||
if (sourceType === 'semantic-metric') return copy.dashboards.runtime.semanticMetric;
|
||||
if (sourceType === 'events') return copy.dashboards.runtime.events;
|
||||
if (sourceType === 'alerts') return copy.dashboards.runtime.alerts;
|
||||
if (sourceType === 'incidents') return copy.dashboards.runtime.incidents;
|
||||
if (sourceType === 'text') return copy.dashboards.runtime.text;
|
||||
return copy.dashboards.runtime.inventory;
|
||||
}
|
||||
|
||||
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 resource = useBoundedResource(widget);
|
||||
const operator = useOperatorContext();
|
||||
if (operator.paused && viewport !== 'wallboard' && timeRange === 'live') timeRange = 'paused';
|
||||
const range = useMemo(() => rangeForSelection(timeRange), [timeRange, operator.refreshVersion]);
|
||||
const request = useMemo<MetricQueryRequest | null>(() => {
|
||||
if (!semantic) return null;
|
||||
if (!semantic || !metricTypes.has(widget.type)) 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]);
|
||||
return { metric, scope: resolveDashboardScope(field<RecordValue>(widget.data, 'scope') ?? {}, field<unknown[]>(document, 'variables') ?? []), range, aggregation: String(field(widget.data, 'aggregation') ?? 'avg') };
|
||||
}, [semantic, widget.type, 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';
|
||||
@@ -113,29 +185,36 @@ export function DashboardRuntimeWidget({ widget, viewport, document, timeRange,
|
||||
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;
|
||||
const metricRuntime: RuntimeState = !metricTypes.has(widget.type) ? 'capability-disabled' : metricState.status === 'error' || (timeRange === 'live' && live.state === 'error') ? 'error' : (timeRange === 'live' ? live.series.length : historicalSeries.length) > 0 ? (freshness === 'stale' ? 'stale' : 'usable') : metricState.status === 'loading' || (timeRange === 'live' && live.state === 'connecting') ? 'loading' : 'empty';
|
||||
let runtime: RuntimeState = semantic ? metricRuntime : systemWidget ? (system.state === 'loading' ? 'loading' : system.state === 'ready' ? 'usable' : 'error') : resource.state;
|
||||
if (!isWidgetType(widget.type)) runtime = 'error';
|
||||
const rows = resource.rows ?? [];
|
||||
const compatible = semantic ? metricTypes.has(widget.type)
|
||||
: systemWidget ? ['stat', 'status-grid'].includes(widget.type)
|
||||
: sourceType === 'text' ? widget.type === 'text'
|
||||
: resource.storage ? widget.type === 'storage-map'
|
||||
: sourceType === 'events' ? widget.type === 'event-timeline'
|
||||
: ['stat', 'ranked-list', 'status-grid', 'table', 'event-timeline', 'service-matrix', 'alert-summary'].includes(widget.type);
|
||||
if (isWidgetType(widget.type) && !compatible && runtime !== 'error') runtime = 'capability-disabled';
|
||||
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>;
|
||||
const layoutStyle = viewport === 'wallboard' ? { '--widget-span': String(placement.columnSpan), gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`, gridRow: `${placement.rowStart} / span ${placement.rowSpan}` } as CSSProperties : spatialStyle(activeLayout, viewport) as CSSProperties;
|
||||
let content: ReactNode;
|
||||
if (!isWidgetType(widget.type)) content = <div className="dashboard-runtime-state dashboard-runtime-state--error" role="alert"><strong>{copy.dashboards.runtime.unknownType}</strong><span>{widget.type.slice(0, 80)}</span></div>;
|
||||
else if (semantic && metricTypes.has(widget.type)) content = <MetricWidget kind={widget.type as MetricWidgetProps['kind']} series={timeRange === 'live' ? live.series : historicalSeries} freshness={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} />;
|
||||
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 (resource.storage && widget.type === 'storage-map') content = <StorageMapWidget nodes={buildStorageNodes(resource.storage)} title={widget.title} description={widget.description ?? ''} idPrefix={'dashboard-storage-' + widget.id} />;
|
||||
else if (widget.type === 'text' && resource.text?.trim()) content = <TextWidget text={resource.text} />;
|
||||
else if (widget.type === 'alert-summary' && rows.length) content = <AlertSummary rows={rows} />;
|
||||
else if (widget.type === 'service-matrix' && rows.length) content = <RuntimeTable rows={rows} service />;
|
||||
else if (widget.type === 'table' && rows.length) content = <RuntimeTable rows={rows} />;
|
||||
else if (widget.type === 'ranked-list' && rows.length) content = <RankedListWidget items={rows.map((row): RankedListItem => ({ id: row.id, label: row.label, value: row.value || presentStatus(row.status), detail: row.detail }))} onSelect={onFilter} />;
|
||||
else if (widget.type === 'status-grid' && rows.length) content = <StatusGridWidget items={rows.map((row): StatusGridItem => ({ id: row.id, label: row.label, status: row.status, reason: row.detail }))} onSelect={onFilter} />;
|
||||
else if (widget.type === 'stat' && rows.length) content = <div className="dashboard-runtime-stat"><strong>{rows.length}</strong><span>{sourceLabel(sourceType)}</span><small>{rows.filter((row) => !['healthy', 'up', 'operational', 'resolved'].includes(row.status)).length} vragen aandacht</small></div>;
|
||||
else if (widget.type === 'event-timeline' && (resource.events?.length || rows.length)) { const events = resource.events ?? rows.map((row) => ({ id: row.id, type: sourceType, severity: row.severity ?? row.status, summary: row.label, occurredAt: row.occurredAt ?? '', sourceId: row.id })); content = <ol className="dashboard-event-list">{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>{sourceType === 'events' ? presentEventSummary(event.type, event.summary) : event.summary}</strong><small>{sourceType === 'events' ? presentEventType(event.type) : sourceLabel(sourceType)}{event.occurredAt ? ' · ' + formatDateTime(event.occurredAt) : ''}</small></span></li>)}</ol>; }
|
||||
else content = <RuntimeNotice state={runtime === 'usable' || runtime === 'partial' || runtime === 'stale' ? 'capability-disabled' : runtime} />;
|
||||
|
||||
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">{sourceLabel(sourceType)} · {widgetLabel(widget.type)}</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>;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function EventsPage() {
|
||||
const resetPage = () => setPage(0);
|
||||
const movePage = (next: number) => {
|
||||
setPage(next);
|
||||
requestAnimationFrame(() => pageStatus.current?.focus());
|
||||
pageStatus.current?.focus();
|
||||
};
|
||||
const clearFilters = () => { setSearch(''); setSeverity(''); setType(''); setEntity(''); setPage(0); };
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { LiveChartAdapter, historicalSamplesFromData, type ChartSeries } from './liveBuffer';
|
||||
import { MetricApiError, MetricClient, rangeForSelection, type MetricFreshness, type MetricQueryResponse } from './metricClient';
|
||||
import { MetricWidget } from './MetricWidgets';
|
||||
import { copy } from './copy';
|
||||
import { operatorCustomRangeValid, operatorRangeSelection, setOperatorCustomRange, setOperatorRangePreset, useOperatorContext, type OperatorRangePreset } from './operatorContext';
|
||||
|
||||
type Limits = { maxRangeSeconds: number; maxSeries: number; maxPoints: number; timeoutSeconds: number };
|
||||
type MetricDefinition = { semanticName: string; description: string; unit: string; valueKind: string; defaultAggregation?: string; allowedVisualizations: string[]; limits: Limits };
|
||||
type CatalogResponse = { version: string; metrics: MetricDefinition[]; bindings: Array<{ semanticName: string; state: string }> };
|
||||
type Result = { metric: MetricDefinition; status: 'loading' | 'success' | 'error'; series: ChartSeries[]; freshness: MetricFreshness; response?: MetricQueryResponse; error?: string };
|
||||
type Annotation = { id: string; label: string; timestamp: number; tone: 'warning' | 'critical' | 'info' };
|
||||
|
||||
const client = new MetricClient();
|
||||
const ranges: Array<{ value: OperatorRangePreset; label: string }> = [
|
||||
{ value: '15m', label: '15 min' }, { value: '1h', label: '1 uur' }, { value: '6h', label: '6 uur' },
|
||||
{ value: '24h', label: '24 uur' }, { value: '7d', label: '7 dagen' }, { value: '30d', label: '30 dagen' }, { value: 'custom', label: 'Aangepast' },
|
||||
];
|
||||
|
||||
function boundedCatalog(value: CatalogResponse): CatalogResponse {
|
||||
const metrics = Array.isArray(value.metrics) ? value.metrics.filter((metric) => typeof metric?.semanticName === 'string' && metric.semanticName.length <= 160 && typeof metric.description === 'string' && metric.description.length <= 1000 && metric.limits && Number.isFinite(metric.limits.maxRangeSeconds) && Number.isFinite(metric.limits.maxSeries) && Number.isFinite(metric.limits.maxPoints)).slice(0, 500).map((metric) => ({ ...metric, description: metric.description.slice(0, 1000), unit: String(metric.unit ?? '').slice(0, 40), allowedVisualizations: Array.isArray(metric.allowedVisualizations) ? metric.allowedVisualizations.filter((item) => typeof item === 'string').slice(0, 30) : [], limits: { maxRangeSeconds: Math.max(60, Math.min(31_536_000, Math.trunc(metric.limits.maxRangeSeconds))), maxSeries: Math.max(1, Math.min(10_000, Math.trunc(metric.limits.maxSeries))), maxPoints: Math.max(10, Math.min(1_000_000, Math.trunc(metric.limits.maxPoints))), timeoutSeconds: Math.max(1, Math.min(120, Math.trunc(metric.limits.timeoutSeconds || 5))) } })) : [];
|
||||
const bindings = Array.isArray(value.bindings) ? value.bindings.filter((binding) => typeof binding?.semanticName === 'string' && typeof binding.state === 'string').slice(0, 500) : [];
|
||||
return { version: String(value.version ?? '').slice(0, 128), metrics, bindings };
|
||||
}
|
||||
function seriesFrom(response: MetricQueryResponse): ChartSeries[] {
|
||||
const samples = historicalSamplesFromData(response.data);
|
||||
const adapter = new LiveChartAdapter(4000);
|
||||
adapter.append(samples.map((sample) => ({ ...sample, freshness: response.freshness })));
|
||||
return adapter.snapshot();
|
||||
}
|
||||
function rangeSeconds(from: string, to: string): number { return Math.max(0, (Date.parse(to) - Date.parse(from)) / 1000); }
|
||||
|
||||
function initialExploreMode(): 'explore' | 'compare' | 'history' {
|
||||
const value = new URLSearchParams(window.location.search).get('mode');
|
||||
return value === 'compare' || value === 'history' ? value : 'explore';
|
||||
}
|
||||
function initialExploreMetrics(): string[] {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const values = [
|
||||
...(params.get('metrics') ?? '').split(','),
|
||||
params.get('metric') ?? '',
|
||||
].map((value) => value.trim()).filter(Boolean);
|
||||
return [...new Set(values)].slice(0, 4);
|
||||
}
|
||||
|
||||
export function ExploreMetricsPage() {
|
||||
const [catalog, setCatalog] = useState<CatalogResponse | null>(null);
|
||||
const [catalogState, setCatalogState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [search, setSearch] = useState('');
|
||||
const [mode, setMode] = useState<'explore' | 'compare' | 'history'>(() => initialExploreMode());
|
||||
const [selected, setSelected] = useState<string[]>(() => initialExploreMetrics());
|
||||
const [results, setResults] = useState<Result[]>([]);
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||
const operator = useOperatorContext();
|
||||
const rangeSelection = operatorRangeSelection(operator);
|
||||
const range = useMemo(() => rangeForSelection(rangeSelection), [rangeSelection, operator.refreshVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('mode', mode);
|
||||
params.delete('metric');
|
||||
params.delete('metrics');
|
||||
if (selected.length === 1) params.set('metric', selected[0]);
|
||||
else if (selected.length > 1) params.set('metrics', selected.join(','));
|
||||
const query = params.toString();
|
||||
const next = window.location.pathname + (query ? '?' + query : '');
|
||||
if (next !== window.location.pathname + window.location.search) window.history.replaceState(window.history.state, '', next);
|
||||
}, [mode, selected.join('|')]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setCatalogState('loading');
|
||||
fetch('/api/v1/metrics/catalog', { signal: controller.signal, cache: 'no-store' }).then(async (response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return boundedCatalog(await response.json() as CatalogResponse);
|
||||
}).then((value) => { setCatalog(value); setCatalogState('ready'); }).catch((error: unknown) => {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) setCatalogState('error');
|
||||
});
|
||||
Promise.all([
|
||||
fetch('/api/v1/incidents?limit=20', { signal: controller.signal, cache: 'no-store' }).then((response) => response.ok ? response.json() : { items: [] }),
|
||||
fetch('/api/v1/alerts?limit=20', { signal: controller.signal, cache: 'no-store' }).then((response) => response.ok ? response.json() : { items: [] }),
|
||||
]).then(([incidentData, alertData]: Array<{ items?: Array<Record<string, unknown>> }>) => {
|
||||
const mapped = [...(incidentData.items ?? []).map((item) => ({ id: String(item.id), label: String(item.title ?? 'Incident'), timestamp: Date.parse(String(item.updatedAt ?? item.startedAt ?? '')), tone: item.severity === 'critical' ? 'critical' as const : 'warning' as const })), ...(alertData.items ?? []).map((item) => ({ id: String(item.id), label: String(item.ruleName ?? 'Melding'), timestamp: Date.parse(String(item.updatedAt ?? '')), tone: item.severity === 'critical' ? 'critical' as const : 'warning' as const }))].filter((item) => Number.isFinite(item.timestamp)).slice(0, 40);
|
||||
setAnnotations(mapped);
|
||||
}).catch(() => setAnnotations([]));
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const bindings = useMemo(() => new Map((catalog?.bindings ?? []).map((binding) => [binding.semanticName, binding.state])), [catalog]);
|
||||
const normalized = search.trim().toLocaleLowerCase('nl-BE');
|
||||
const visible = (catalog?.metrics ?? []).filter((metric) => !normalized || `${metric.semanticName} ${metric.description} ${metric.unit}`.toLocaleLowerCase('nl-BE').includes(normalized)).slice(0, 100);
|
||||
const selectedMetrics = selected.flatMap((name) => catalog?.metrics.find((metric) => metric.semanticName === name) ?? []);
|
||||
const invalidCustom = operator.range === 'custom' && !operatorCustomRangeValid(operator);
|
||||
const overBudget = selectedMetrics.find((metric) => rangeSeconds(range.from, range.to) > metric.limits.maxRangeSeconds);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedMetrics.length === 0 || invalidCustom || overBudget) { setResults([]); return undefined; }
|
||||
const controller = new AbortController();
|
||||
setResults(selectedMetrics.map((metric) => ({ metric, status: 'loading', series: [], freshness: 'unavailable' })));
|
||||
selectedMetrics.forEach((metric) => {
|
||||
client.queryRange({ metric: metric.semanticName, range, aggregation: metric.defaultAggregation || 'avg', maxSeries: Math.min(20, metric.limits.maxSeries), maxPoints: Math.min(4000, metric.limits.maxPoints) }, controller.signal).then((response) => {
|
||||
setResults((current) => current.map((item) => item.metric.semanticName === metric.semanticName ? { metric, status: 'success', series: seriesFrom(response), freshness: response.freshness, response } : item));
|
||||
}).catch((error: unknown) => {
|
||||
if (controller.signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) return;
|
||||
const message = error instanceof MetricApiError ? error.actionable : copy.explore.queryError;
|
||||
setResults((current) => current.map((item) => item.metric.semanticName === metric.semanticName ? { metric, status: 'error', series: [], freshness: 'unavailable', error: message } : item));
|
||||
});
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [selected.join('|'), range.from, range.to, range.stepSeconds, invalidCustom, overBudget?.semanticName]);
|
||||
|
||||
const choose = (metric: MetricDefinition) => {
|
||||
if (mode !== 'compare') { setSelected([metric.semanticName]); return; }
|
||||
setSelected((current) => current.includes(metric.semanticName) ? current.filter((name) => name !== metric.semanticName) : current.length >= 4 ? current : [...current, metric.semanticName]);
|
||||
};
|
||||
const changeMode = (next: typeof mode) => { setMode(next); if (next !== 'compare') setSelected((current) => current.slice(0, 1)); };
|
||||
|
||||
return <section className="explore-page" aria-labelledby="explore-title">
|
||||
<header className="page-intro"><p className="eyebrow">{copy.explore.eyebrow}</p><h1 id="explore-title">{copy.explore.title}</h1><p className="intro">{copy.explore.intro}</p></header>
|
||||
<div className="explore-mode-tabs" role="tablist" aria-label={copy.explore.workspaces}>{(['explore', 'compare', 'history'] as const).map((item) => <button key={item} type="button" role="tab" aria-selected={mode === item} onClick={() => changeMode(item)}>{copy.explore[item]}</button>)}</div>
|
||||
<div className="explore-layout">
|
||||
<aside className="card explore-catalog" aria-labelledby="metric-catalog-title"><div className="card-heading"><div><p className="card-kicker">{copy.explore.catalog}</p><h2 id="metric-catalog-title">{copy.explore.findMetric}</h2></div><span>{visible.length}/{catalog?.metrics.length ?? 0}</span></div><label>{copy.explore.search}<input value={search} maxLength={160} placeholder={copy.explore.searchPlaceholder} onChange={(event) => setSearch(event.target.value)} /></label>{catalogState === 'loading' ? <p role="status">{copy.explore.loading}</p> : catalogState === 'error' ? <p role="alert">{copy.explore.catalogError}</p> : visible.length === 0 ? <p>{copy.explore.empty}</p> : <ul className="explore-metric-list">{visible.map((metric) => { const state = bindings.get(metric.semanticName) ?? 'unavailable'; const active = selected.includes(metric.semanticName); return <li key={metric.semanticName}><button type="button" aria-pressed={active} disabled={state !== 'supported'} onClick={() => choose(metric)}><span><strong>{metric.semanticName}</strong><small>{metric.description}</small></span><span>{state === 'supported' ? metric.unit || metric.valueKind : copy.explore.unavailable}</span></button></li>; })}</ul>}</aside>
|
||||
<main className="explore-workspace"><section className="card explore-range" aria-labelledby="explore-range-title"><div><p className="card-kicker">{copy.explore.range}</p><h2 id="explore-range-title">{mode === 'history' ? copy.explore.historyRange : copy.explore.queryRange}</h2></div><label>{copy.explore.range}<select value={operator.range} onChange={(event) => setOperatorRangePreset(event.target.value as OperatorRangePreset)}>{ranges.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>{operator.range === 'custom' && <div className="explore-custom-range"><label>{copy.operator.customFrom}<input type="datetime-local" value={operator.customFrom} onChange={(event) => setOperatorCustomRange(event.target.value, operator.customTo)} /></label><label>{copy.operator.customTo}<input type="datetime-local" value={operator.customTo} onChange={(event) => setOperatorCustomRange(operator.customFrom, event.target.value)} /></label></div>}</section>
|
||||
{invalidCustom && <div className="editor-banner" role="alert"><strong>{copy.explore.invalidRange}</strong><span>{copy.operator.customInvalid}</span></div>}
|
||||
{overBudget && <div className="editor-banner" role="alert"><strong>{copy.explore.budgetExceeded}</strong><span>{copy.explore.budgetDetail.replace('{metric}', overBudget.semanticName).replace('{days}', Math.floor(overBudget.limits.maxRangeSeconds / 86400).toString())}</span></div>}
|
||||
{mode === 'compare' && selected.length >= 4 && <p className="metric-notice metric-notice--warning" role="status">{copy.explore.compareLimit}</p>}
|
||||
{selected.length === 0 ? <section className="card dashboard-message"><h2>{copy.explore.selectTitle}</h2><p>{copy.explore.selectDetail}</p></section> : <div className="explore-results" aria-live="polite">{results.map((result) => <article className="card explore-result" key={result.metric.semanticName}><div className="widget-card-heading"><div><p className="card-kicker">{result.metric.unit || result.metric.valueKind}</p><h2>{result.metric.semanticName}</h2></div><button type="button" className="text-action" onClick={() => setSelected((current) => current.filter((name) => name !== result.metric.semanticName))}>{copy.explore.closeMetric}</button></div><p className="card-copy">{result.metric.description}</p><MetricWidget kind="timeseries" series={result.series} freshness={result.freshness} expectedStepSeconds={range.stepSeconds} availability={result.status} error={result.error} visualization={{ unit: result.metric.unit, legend: true }} metricName={result.metric.semanticName} sourceObservedAt={result.response?.sourceObservedAt} receivedAt={result.response?.receivedAt} warnings={result.response?.warnings} inspector={result.response?.inspector} annotations={annotations} /></article>)}</div>}</main>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,13 +1,29 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { copy } from './copy';
|
||||
import { formatDateTime } from './locale';
|
||||
import { presentStatus } from './presentation';
|
||||
import { readSessionCapabilities } from './sessionClient';
|
||||
|
||||
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[] };
|
||||
type AlertDetail = { id: string; state: string; retainedState: string; ruleName: string; severity: string; entityName?: string; reason: string; revision: number; acknowledgedBy?: string; updatedAt?: string };
|
||||
|
||||
async function readAssociatedAlerts(associations: Association[], signal: AbortSignal): Promise<AlertDetail[]> {
|
||||
const alerts: AlertDetail[] = [];
|
||||
for (let offset = 0; offset < associations.length; offset += 4) {
|
||||
const batch = await Promise.all(associations.slice(offset, offset + 4).map(async (association) => {
|
||||
const response = await fetch('/api/v1/alerts/' + encodeURIComponent(association.alertId) + '?occurrenceLimit=1', { signal, cache: 'no-store' });
|
||||
if (!response.ok) return null;
|
||||
const value = await response.json() as { alert?: AlertDetail };
|
||||
return value.alert ?? null;
|
||||
}));
|
||||
alerts.push(...batch.filter((value): value is AlertDetail => Boolean(value)));
|
||||
}
|
||||
return alerts;
|
||||
}
|
||||
|
||||
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'; }
|
||||
|
||||
@@ -15,32 +31,96 @@ 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 [alerts, setAlerts] = useState<Record<string, AlertDetail>>({});
|
||||
const [canOperate, setCanOperate] = useState(false);
|
||||
const [sessionSubject, setSessionSubject] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [owner, setOwner] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [actingAlert, setActingAlert] = useState('');
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void readSessionCapabilities(controller.signal).then((session) => { setCanOperate(session.permissions.operate); setSessionSubject(session.subject ?? ''); }).catch(() => { setCanOperate(false); setSessionSubject(''); });
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
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'); }
|
||||
fetch(url, { signal: controller.signal, cache: 'no-store' }).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 ?? []).slice(0, 100); 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]);
|
||||
useEffect(() => {
|
||||
const associations = (incident?.alerts ?? []).slice(0, 100);
|
||||
if (!associations.length) { setAlerts({}); return undefined; }
|
||||
const controller = new AbortController();
|
||||
readAssociatedAlerts(associations, controller.signal).then((values) => setAlerts(Object.fromEntries(values.map((value) => [value.id, value])))).catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setAlerts({}); });
|
||||
return () => controller.abort();
|
||||
}, [incident]);
|
||||
|
||||
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 })));
|
||||
const entries = (incident.alerts ?? []).slice(0, 100).map((alert) => ({ id: 'alert-' + alert.alertId, label: alert.manual ? 'Handmatig gekoppelde melding' : 'Gecorreleerde melding', detail: alert.rationale + ' · betrouwbaarheid ' + Math.round(alert.confidence * 100) + '%', at: alert.createdAt }));
|
||||
entries.push({ id: 'incident-start', label: 'Incident gestart', detail: incident.summary, at: incident.startedAt });
|
||||
if (incident.resolvedAt) entries.push({ id: 'incident-resolved', label: 'Incident opgelost', detail: 'Het incident werd als opgelost geregistreerd.', at: incident.resolvedAt });
|
||||
entries.push(...(incident.notes ?? []).slice(0, 100).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); };
|
||||
|
||||
const submitNote = async () => { if (!id || !note.trim()) return; setMessage('Notitie wordt opgeslagen…'); try { 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); } catch { setMessage('De notitie kon niet worden opgeslagen.'); } };
|
||||
const saveOwner = async (nextOwner = owner.trim()) => { if (!id || !incident) return; setMessage('Eigenaar wordt opgeslagen…'); try { const response = await fetch('/api/v1/incidents/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ownerUserId: nextOwner, revision: incident.revision }) }); if (!response.ok) { setMessage(response.status === 409 ? 'Het incident is intussen gewijzigd. De actuele versie wordt geladen.' : 'De eigenaar kon niet worden opgeslagen.'); if (response.status === 409) setReload((value) => value + 1); return; } setOwner(nextOwner); setMessage('Eigenaar opgeslagen.'); setReload((value) => value + 1); } catch { setMessage('De eigenaar kon niet worden opgeslagen.'); } };
|
||||
const assignToMe = () => { if (sessionSubject && canOperate) void saveOwner(sessionSubject); };
|
||||
const openHistory = () => {
|
||||
if (!incident) return;
|
||||
const start = new Date(Math.max(0, Date.parse(incident.startedAt) - 15 * 60 * 1000));
|
||||
const end = incident.resolvedAt ? new Date(Date.parse(incident.resolvedAt) + 15 * 60 * 1000) : new Date();
|
||||
const local = (value: Date) => new Date(value.getTime() - value.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||
navigate('/explore?mode=history&range=custom&from=' + encodeURIComponent(local(start)) + '&to=' + encodeURIComponent(local(end)));
|
||||
};
|
||||
const acknowledge = async (alert: AlertDetail) => {
|
||||
if (!id || !canOperate || actingAlert) return;
|
||||
const evaluationKey = `incident-${id}-alert-${alert.id}-ack-${alert.revision}`.slice(0, 160);
|
||||
setActingAlert(alert.id); setMessage('Melding wordt erkend…');
|
||||
try {
|
||||
const response = await fetch('/api/v1/alerts/' + encodeURIComponent(alert.id) + '/acknowledge', { method: 'POST', headers: { 'Content-Type': 'application/json', 'If-Match': String(alert.revision), 'Idempotency-Key': evaluationKey }, body: JSON.stringify({ evaluationKey }) });
|
||||
if (!response.ok) { setMessage(response.status === 409 ? 'De melding is intussen gewijzigd. De actuele status wordt geladen.' : 'De melding kon niet worden erkend.'); setReload((value) => value + 1); return; }
|
||||
setMessage('Melding erkend. Dit lost de infrastructuur niet automatisch op.'); setReload((value) => value + 1);
|
||||
} catch { setMessage('De melding kon niet worden erkend.'); } finally { setActingAlert(''); }
|
||||
};
|
||||
const acknowledgeAll = async () => {
|
||||
if (!id || !canOperate || actingAlert) return;
|
||||
const actionable = Object.values(alerts).filter((alert) => !alert.acknowledgedBy && alert.state !== 'acknowledged' && alert.state !== 'resolved').slice(0, 20);
|
||||
if (!actionable.length) { setMessage('Alle gekoppelde meldingen zijn al erkend of opgelost.'); return; }
|
||||
setActingAlert('all'); setMessage(`${actionable.length} meldingen worden erkend…`);
|
||||
let changed = 0;
|
||||
try {
|
||||
for (const alert of actionable) {
|
||||
const evaluationKey = `incident-${id}-alert-${alert.id}-ack-${alert.revision}`.slice(0, 160);
|
||||
const response = await fetch('/api/v1/alerts/' + encodeURIComponent(alert.id) + '/acknowledge', { method: 'POST', headers: { 'Content-Type': 'application/json', 'If-Match': String(alert.revision), 'Idempotency-Key': evaluationKey }, body: JSON.stringify({ evaluationKey }) });
|
||||
if (response.ok) changed += 1;
|
||||
}
|
||||
setMessage(`${changed} van ${actionable.length} meldingen erkend. Dit wijzigt geen infrastructuur.`);
|
||||
setReload((value) => value + 1);
|
||||
} catch { setMessage('Niet alle meldingen konden worden erkend. De actuele status wordt opnieuw geladen.'); setReload((value) => value + 1); }
|
||||
finally { setActingAlert(''); }
|
||||
};
|
||||
|
||||
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></>;
|
||||
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>{formatDateTime(incident.startedAt)}</strong></span><span><small>{copy.incidents.confidence}</small><strong>{Math.round(incident.confidence * 100)}%</strong></span><div className="incident-command-actions">{canOperate && sessionSubject && incident.ownerUserId !== sessionSubject && <button className="button button--secondary" type="button" onClick={assignToMe}>Aan mij toewijzen</button>}{canOperate && Object.values(alerts).some((alert) => !alert.acknowledgedBy && alert.state !== 'acknowledged' && alert.state !== 'resolved') && <button className="button button--secondary" type="button" disabled={Boolean(actingAlert)} onClick={() => void acknowledgeAll()}>{actingAlert === 'all' ? 'Meldingen erkennen…' : 'Alle meldingen erkennen'}</button>}<button className="button button--secondary" type="button" onClick={openHistory}>Onderzoek in Explore</button></div></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>{formatDateTime(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={() => void saveOwner()} disabled={!canOperate}>{copy.incidents.saveOwner}</button><p className="card-copy">{canOperate ? copy.incidents.ownerNote : 'Je rol kan de opvolgcontext alleen bekijken.'}</p></article></section>
|
||||
<section className="card incident-alerts-panel" aria-labelledby="incident-alerts-title"><div className="card-heading"><div><p className="card-kicker">Bijdragende signalen</p><h2 id="incident-alerts-title">Meldingen in dit incident</h2></div><span className="card-copy">{incident.alerts?.length ?? 0} meldingen</span></div>{!incident.alerts?.length ? <p className="card-copy">Geen gekoppelde meldingen.</p> : <ul className="incident-alert-list">{incident.alerts.slice(0, 100).map((association) => { const alert = alerts[association.alertId]; const actionable = alert && !alert.acknowledgedBy && alert.state !== 'acknowledged' && alert.state !== 'resolved'; return <li key={association.alertId}><div><strong>{alert?.ruleName || association.alertId}</strong><small>{alert ? `${presentStatus(alert.state)} · ${alert.reason}` : association.rationale}</small><small>Relatie: {association.rationale} · {Math.round(association.confidence * 100)}%</small></div>{alert && <div className="incident-alert-action"><span className="status-badge status-badge--unknown">{presentStatus(alert.state)}</span>{actionable && canOperate && <button className="button button--secondary" type="button" disabled={actingAlert === alert.id} onClick={() => void acknowledge(alert)}>{actingAlert === alert.id ? 'Erkennen…' : 'Melding erkennen'}</button>}</div>}</li>; })}</ul>}<p className="card-copy incident-read-only-boundary">Erkennen bevestigt alleen de operationele opvolging. Pulse start, stopt of repareert geen Unraidresource.</p></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}>{formatDateTime(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() || !canOperate}>{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></>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
||||
import type { BufferedPoint, ChartSeries, LiveFreshness } from './liveBuffer';
|
||||
import type { MetricInspector } from './metricClient';
|
||||
import { copy } from './copy';
|
||||
@@ -19,6 +19,7 @@ export type MetricWidgetProps = {
|
||||
receivedAt?: string;
|
||||
warnings?: string[];
|
||||
inspector?: MetricInspector;
|
||||
annotations?: Array<{ id: string; label: string; timestamp: number; tone: 'warning' | 'critical' | 'info' }>;
|
||||
};
|
||||
|
||||
type LimitedSeries = { key: string; points: BufferedPoint[] };
|
||||
@@ -75,6 +76,17 @@ function formatValue(value: number, visualization: Record<string, unknown>): str
|
||||
const formatted = formatNumber(value, decimals);
|
||||
return unit ? formatted + ' ' + unit : formatted;
|
||||
}
|
||||
function openMetricInExplore(metricName: string): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('metric', metricName);
|
||||
params.delete('metrics');
|
||||
params.set('mode', 'explore');
|
||||
const query = params.toString();
|
||||
const path = '/explore' + (query ? '?' + query : '');
|
||||
window.history.pushState({ pulseFrom: window.location.pathname + window.location.search }, '', path);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -203,8 +215,11 @@ function chartPath(points: readonly BufferedPoint[], minTime: number, timeSpread
|
||||
}
|
||||
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 timestamps = series.flatMap((item) => item.points.map((point) => point.timestamp));
|
||||
const rangeFrom = timestamps.length ? new Date(Math.min(...timestamps)).toISOString() : '';
|
||||
const rangeTo = timestamps.length ? new Date(Math.max(...timestamps)).toISOString() : '';
|
||||
const rows = [['metric', 'series', 'timestamp', 'value', 'freshness', 'range_from', 'range_to']];
|
||||
series.forEach((item) => item.points.forEach((point) => rows.push([metricName || '', item.key, new Date(point.timestamp).toISOString(), point.value === null ? '' : String(point.value), point.freshness, rangeFrom, rangeTo])));
|
||||
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' }));
|
||||
@@ -248,9 +263,29 @@ export function metricStatus(props: MetricWidgetProps): { label: string; tone: '
|
||||
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>;
|
||||
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const [cursorRatio, setCursorRatio] = useState<number | null>(null);
|
||||
const cursorFrame = useRef<number | null>(null);
|
||||
const pendingCursor = useRef<number | null>(null);
|
||||
const visible = series.filter((item) => !hidden.has(item.key));
|
||||
const displaySeries = useMemo(() => {
|
||||
if (!zoomed) return visible;
|
||||
const timestamps = visible.flatMap((item) => item.points.map((point) => point.timestamp));
|
||||
const minimum = Math.min(...timestamps); const maximum = Math.max(...timestamps); const cutoff = minimum + (maximum - minimum) / 2;
|
||||
return visible.map((item) => ({ ...item, points: item.points.filter((point) => point.timestamp >= cutoff) }));
|
||||
}, [visible, zoomed]);
|
||||
const gap = hasDataGap(displaySeries, props.expectedStepSeconds);
|
||||
const cursorPoints = useMemo(() => validPoints(displaySeries).sort((left, right) => left.timestamp - right.timestamp), [displaySeries]);
|
||||
const cursor = cursorRatio == null || !cursorPoints.length ? null : cursorPoints[Math.min(cursorPoints.length - 1, Math.round(cursorRatio * (cursorPoints.length - 1)))];
|
||||
useEffect(() => () => { if (cursorFrame.current != null) cancelAnimationFrame(cursorFrame.current); }, []);
|
||||
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect(); pendingCursor.current = Math.min(1, Math.max(0, (event.clientX - rect.left) / Math.max(1, rect.width)));
|
||||
if (cursorFrame.current != null) return;
|
||||
cursorFrame.current = requestAnimationFrame(() => { cursorFrame.current = null; setCursorRatio(pendingCursor.current); });
|
||||
};
|
||||
const content = props.kind === 'stat' ? <StatWidget props={props} series={displaySeries} /> : props.kind === 'gauge' ? <GaugeWidget props={props} series={displaySeries} /> : props.kind === 'query-inspector' ? <QueryInspectorWidget props={props} /> : <TimeSeriesWidget props={props} series={displaySeries} />;
|
||||
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>)}{(props.metricName || (props.kind === 'timeseries' && series.length > 0)) && <div className="metric-interaction-toolbar">{props.kind === 'timeseries' && series.length > 0 && <div className="metric-series-toggles" aria-label={copy.metrics.seriesVisibility}>{series.map((item) => <button type="button" key={item.key} aria-pressed={!hidden.has(item.key)} onClick={() => setHidden((current) => { const next = new Set(current); if (next.has(item.key)) next.delete(item.key); else if (next.size < Math.max(0, series.length - 1)) next.add(item.key); return next; })}>{seriesDisplayName(item, props.metricName)}</button>)}</div>}<div className="metric-toolbar-actions">{props.kind === 'timeseries' && series.length > 0 && <button className="button button--secondary" type="button" aria-pressed={zoomed} onClick={() => setZoomed((value) => !value)}>{zoomed ? copy.metrics.resetZoom : copy.metrics.zoomRecent}</button>}{props.metricName && <button className="button button--secondary" type="button" onClick={() => openMetricInExplore(props.metricName!)}>Open in Explore</button>}</div></div>}<div className={props.kind === 'timeseries' ? 'metric-interactive-plane' : undefined} style={cursorRatio == null ? undefined : { '--metric-crosshair': `${cursorRatio * 100}%` } as CSSProperties} onPointerMove={props.kind === 'timeseries' ? onPointerMove : undefined} onPointerLeave={() => setCursorRatio(null)}>{content}{cursor && <output className="metric-crosshair-output">{new Date(cursor.timestamp).toLocaleString('nl-BE')} · {cursor.value == null ? '—' : cursor.value.toLocaleString('nl-BE')}</output>}</div>{props.kind === 'timeseries' && props.annotations && props.annotations.length > 0 && <ol className="metric-annotations" aria-label={copy.metrics.annotations}>{props.annotations.slice(0, 40).map((annotation) => <li key={annotation.id} data-tone={annotation.tone}><time dateTime={new Date(annotation.timestamp).toISOString()}>{new Date(annotation.timestamp).toLocaleString('nl-BE')}</time><span>{annotation.label.slice(0, 160)}</span></li>)}</ol>}{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 };
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { copy } from './copy';
|
||||
import { formatDateTime } from './locale';
|
||||
import { SignInButton } from './SignIn';
|
||||
import {
|
||||
operatorCustomRangeValid,
|
||||
refreshOperatorData,
|
||||
setOperatorCustomRange,
|
||||
setOperatorPaused,
|
||||
setOperatorRangePreset,
|
||||
useOperatorContext,
|
||||
type OperatorRangePreset,
|
||||
} from './operatorContext';
|
||||
import { readSessionCapabilities } from './sessionClient';
|
||||
import { aggregateStatus, refreshSystemStatus, useSystemStatus } from './systemStatus';
|
||||
import { WORKSPACE_EVENT, readWorkspaceState, type WorkspaceItem } from './operatorWorkspace';
|
||||
|
||||
type NavigationEntry = { path: string; label: string };
|
||||
type HostSnapshot = { identity?: { name?: string } };
|
||||
type AlertSummary = { state?: string };
|
||||
type IncidentSummary = { id?: string; title?: string; summary?: string; severity?: string; status?: string };
|
||||
type AlertSearchItem = { id: string; ruleName?: string; entityName?: string; reason?: string; state?: string; severity?: string };
|
||||
type OperationalSearchItem = { id: string; label: string; detail: string; kind: 'incident' | 'alert'; path: string };
|
||||
type EntitySearchItem = { id: string; name?: string; displayName?: string; canonicalName?: string; entityType?: string; status?: string };
|
||||
type ShellData = { hostName?: string; subject?: string; role?: string; environment?: string; alerts: number; incidents: number; loading: boolean; unavailable: boolean; updatedAt?: string };
|
||||
|
||||
const rangeOptions: Array<{ value: OperatorRangePreset; label: string }> = [
|
||||
{ value: 'live', label: copy.operator.rangeLive },
|
||||
{ value: '15m', label: copy.operator.range15m },
|
||||
{ value: '1h', label: copy.operator.range1h },
|
||||
{ value: '6h', label: copy.operator.range6h },
|
||||
{ value: '24h', label: copy.operator.range24h },
|
||||
{ value: '7d', label: copy.operator.range7d },
|
||||
{ value: '30d', label: copy.operator.range30d },
|
||||
{ value: 'custom', label: copy.operator.rangeCustom },
|
||||
];
|
||||
|
||||
function boundedCount(value: number): string { return value >= 100 ? copy.operator.countCapped : String(value); }
|
||||
|
||||
function entityRoute(item: EntitySearchItem): string {
|
||||
const kind = (item.entityType ?? '').toLocaleLowerCase('en-US');
|
||||
const id = encodeURIComponent(item.id);
|
||||
if (kind.includes('container')) return '/containers/' + id;
|
||||
if (kind.includes('application') || kind === 'app') return '/applications/' + id;
|
||||
if (kind.includes('service')) return '/services/' + id;
|
||||
if (kind.includes('disk')) return '/disks/' + id;
|
||||
if (kind.includes('pool')) return '/pools/' + id;
|
||||
if (kind.includes('share')) return '/shares/' + id;
|
||||
return '/inventory/' + id;
|
||||
}
|
||||
|
||||
function entityLabel(item: EntitySearchItem): string {
|
||||
return item.displayName?.trim() || item.name?.trim() || item.canonicalName?.trim() || item.id;
|
||||
}
|
||||
|
||||
function useWorkspaceItems(): { favorites: WorkspaceItem[]; views: WorkspaceItem[]; recent: WorkspaceItem[] } {
|
||||
const [state, setState] = useState(() => readWorkspaceState());
|
||||
useEffect(() => {
|
||||
const refresh = () => setState(readWorkspaceState());
|
||||
window.addEventListener(WORKSPACE_EVENT, refresh);
|
||||
window.addEventListener('storage', refresh);
|
||||
return () => { window.removeEventListener(WORKSPACE_EVENT, refresh); window.removeEventListener('storage', refresh); };
|
||||
}, []);
|
||||
return state;
|
||||
}
|
||||
|
||||
function useShellData(refreshVersion: number): ShellData {
|
||||
const [data, setData] = useState<ShellData>({ alerts: 0, incidents: 0, loading: true, unavailable: false });
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setData((current) => ({ ...current, loading: true }));
|
||||
const read = async <T,>(url: string): Promise<T> => {
|
||||
const response = await fetch(url, { signal: controller.signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
return response.json() as Promise<T>;
|
||||
};
|
||||
Promise.all([
|
||||
read<HostSnapshot>('/api/v1/host'),
|
||||
read<{ items?: AlertSummary[] }>('/api/v1/alerts?limit=100'),
|
||||
read<{ items?: IncidentSummary[] }>('/api/v1/incidents?limit=100&status=open'),
|
||||
readSessionCapabilities(controller.signal),
|
||||
]).then(([host, alerts, incidents, session]) => {
|
||||
const activeAlerts = (alerts.items ?? []).filter((item) => !['resolved', 'silenced', 'suppressed'].includes((item.state ?? '').toLowerCase())).length;
|
||||
setData({ hostName: host.identity?.name?.trim() || undefined, subject: session.subject || undefined, role: session.role || undefined, environment: session.environment || undefined, alerts: activeAlerts, incidents: (incidents.items ?? []).length, loading: false, unavailable: false, updatedAt: new Date().toISOString() });
|
||||
}).catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
setData((current) => ({ ...current, loading: false, unavailable: true }));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [refreshVersion]);
|
||||
return data;
|
||||
}
|
||||
|
||||
function useEntitySearch(query: string): { loading: boolean; items: EntitySearchItem[] } {
|
||||
const [state, setState] = useState<{ loading: boolean; items: EntitySearchItem[] }>({ loading: false, items: [] });
|
||||
useEffect(() => {
|
||||
const term = query.trim();
|
||||
if (term.length < 2) { setState({ loading: false, items: [] }); return undefined; }
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
setState((current) => ({ ...current, loading: true }));
|
||||
const params = new URLSearchParams({ q: term, limit: '8', order: 'asc' });
|
||||
fetch('/api/v1/entities?' + params, { signal: controller.signal, cache: 'no-store' }).then(async (response) => {
|
||||
if (!response.ok) throw new Error('search');
|
||||
return response.json() as Promise<{ items?: EntitySearchItem[] }>;
|
||||
}).then((value) => setState({ loading: false, items: (value.items ?? []).slice(0, 8) })).catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
setState({ loading: false, items: [] });
|
||||
});
|
||||
}, 180);
|
||||
return () => { window.clearTimeout(timer); controller.abort(); };
|
||||
}, [query]);
|
||||
return state;
|
||||
}
|
||||
|
||||
function useOperationalSearch(query: string): { loading: boolean; items: OperationalSearchItem[] } {
|
||||
const [state, setState] = useState<{ loading: boolean; items: OperationalSearchItem[] }>({ loading: false, items: [] });
|
||||
useEffect(() => {
|
||||
const term = query.trim();
|
||||
if (term.length < 2) { setState({ loading: false, items: [] }); return undefined; }
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
setState({ loading: true, items: [] });
|
||||
Promise.all([
|
||||
fetch('/api/v1/incidents?limit=100', { signal: controller.signal, cache: 'no-store' }).then((response) => response.ok ? response.json() as Promise<{ items?: IncidentSummary[] }> : { items: [] }),
|
||||
fetch('/api/v1/alerts?limit=100', { signal: controller.signal, cache: 'no-store' }).then((response) => response.ok ? response.json() as Promise<{ items?: AlertSearchItem[] }> : { items: [] }),
|
||||
]).then(([incidents, alerts]) => {
|
||||
const normalized = term.toLocaleLowerCase('nl-BE');
|
||||
const incidentResults = (incidents.items ?? []).filter((item) => `${item.title ?? ''} ${item.summary ?? ''} ${item.severity ?? ''} ${item.status ?? ''}`.toLocaleLowerCase('nl-BE').includes(normalized)).slice(0, 4).map((item): OperationalSearchItem => ({ id: 'incident:' + item.id, label: item.title || item.id || 'Incident', detail: `${item.severity ?? 'unknown'} · ${item.status ?? 'open'}`, kind: 'incident', path: '/incidents/' + encodeURIComponent(item.id ?? '') }));
|
||||
const alertResults = (alerts.items ?? []).filter((item) => `${item.ruleName ?? ''} ${item.entityName ?? ''} ${item.reason ?? ''} ${item.state ?? ''} ${item.severity ?? ''}`.toLocaleLowerCase('nl-BE').includes(normalized)).slice(0, 4).map((item): OperationalSearchItem => ({ id: 'alert:' + item.id, label: item.ruleName || item.entityName || item.id, detail: `${item.state ?? 'unknown'}${item.entityName ? ' · ' + item.entityName : ''}`, kind: 'alert', path: '/alerts?section=operations&alert=' + encodeURIComponent(item.id) + '&q=' + encodeURIComponent(term) }));
|
||||
setState({ loading: false, items: [...incidentResults, ...alertResults] });
|
||||
}).catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
setState({ loading: false, items: [] });
|
||||
});
|
||||
}, 180);
|
||||
return () => { window.clearTimeout(timer); controller.abort(); };
|
||||
}, [query]);
|
||||
return state;
|
||||
}
|
||||
|
||||
export function OperatorCommandBar({ routeLabel, navigation, onNavigate }: { routeLabel: string; navigation: readonly NavigationEntry[]; onNavigate: (path: string) => void }) {
|
||||
const operator = useOperatorContext();
|
||||
const snapshot = useSystemStatus();
|
||||
const aggregate = aggregateStatus(snapshot);
|
||||
const shell = useShellData(operator.refreshVersion);
|
||||
const [query, setQuery] = useState('');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const searchInput = useRef<HTMLInputElement>(null);
|
||||
const search = useEntitySearch(query);
|
||||
const operationalSearch = useOperationalSearch(query);
|
||||
const workspace = useWorkspaceItems();
|
||||
const currentParams = new URLSearchParams(window.location.search);
|
||||
const activeFilters = [...currentParams.keys()].filter((key) => !['range', 'from', 'to', 'interval', 'refresh'].includes(key)).length;
|
||||
const sourceTotal = snapshot.status?.sourceLag.length ?? 0;
|
||||
const healthySources = snapshot.status?.sourceLag.filter((source) => source.state === 'healthy').length ?? 0;
|
||||
const transportConnected = snapshot.state === 'ready' && !aggregate.stale;
|
||||
const transportReachable = snapshot.state === 'unauthorized' || snapshot.state === 'forbidden';
|
||||
const transportLabel = aggregate.stale ? copy.operator.stale : transportConnected ? copy.operator.connected : transportReachable ? copy.operator.reachable : snapshot.state === 'loading' ? copy.operator.connecting : copy.operator.unavailable;
|
||||
const sessionLabel = snapshot.state === 'ready' || snapshot.state === 'forbidden' ? copy.operator.sessionActive : snapshot.state === 'unauthorized' ? copy.operator.sessionRequired : copy.operator.sessionUnknown;
|
||||
const serverName = shell.hostName || copy.operator.serverUnknown;
|
||||
const staticResults = useMemo(() => {
|
||||
const term = query.trim().toLocaleLowerCase('nl-BE');
|
||||
if (!term) return [];
|
||||
return navigation.filter((item) => item.label.toLocaleLowerCase('nl-BE').includes(term)).slice(0, 6);
|
||||
}, [navigation, query]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
setSearchOpen(true);
|
||||
window.setTimeout(() => searchInput.current?.focus(), 0);
|
||||
}
|
||||
if (event.key === 'Escape') { setSearchOpen(false); setCustomOpen(false); }
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, []);
|
||||
|
||||
const selectRange = (value: OperatorRangePreset) => {
|
||||
if (value === 'custom') {
|
||||
const now = new Date();
|
||||
const to = operator.customTo || new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||
const fromDate = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
const from = operator.customFrom || new Date(fromDate.getTime() - fromDate.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||
setOperatorCustomRange(from, to);
|
||||
setCustomOpen(true);
|
||||
return;
|
||||
}
|
||||
setCustomOpen(false);
|
||||
setOperatorRangePreset(value);
|
||||
};
|
||||
const refreshAll = () => { refreshOperatorData(); refreshSystemStatus(); };
|
||||
const openResult = (path: string) => { setSearchOpen(false); setQuery(''); onNavigate(path); };
|
||||
const release = snapshot.status?.release?.version || snapshot.status?.version;
|
||||
|
||||
return <header className="operator-command-bar" aria-label={copy.operator.commandBar}>
|
||||
<div className="operator-identity"><span className="operator-server-mark" aria-hidden="true">P</span><span className="operator-server-copy"><small>{copy.operator.server}{shell.environment ? ` · ${shell.environment}` : ''}</small><strong>{serverName}</strong></span><span className="operator-divider" aria-hidden="true" /><span className="operator-route"><small>{copy.operator.currentPage}</small><strong>{routeLabel}</strong></span></div>
|
||||
<div className="operator-truth" aria-label={copy.operator.operationalTruth}><span className={'operator-truth-item ' + (transportConnected ? 'operator-truth-item--ready' : '')}><span aria-hidden="true">●</span><span><small>{copy.operator.transport}</small><strong>{transportLabel}</strong></span></span><span className="operator-truth-item"><span aria-hidden="true">◉</span><span><small>{copy.operator.sources}</small><strong>{snapshot.state === 'ready' && sourceTotal > 0 ? `${healthySources}/${sourceTotal} ${copy.operator.sourcesCurrent}` : copy.operator.sourcesUnknown}</strong></span></span></div>
|
||||
<div className="operator-controls">
|
||||
<label className="operator-range"><span>{copy.operator.range}</span><select value={operator.range} onChange={(event) => selectRange(event.target.value as OperatorRangePreset)}>{rangeOptions.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
|
||||
{operator.range === 'custom' && customOpen && <div className="operator-custom-range" role="group" aria-label={copy.operator.rangeCustom}><label><span>{copy.operator.customFrom}</span><input type="datetime-local" value={operator.customFrom} onChange={(event) => setOperatorCustomRange(event.target.value, operator.customTo)} /></label><label><span>{copy.operator.customTo}</span><input type="datetime-local" value={operator.customTo} onChange={(event) => setOperatorCustomRange(operator.customFrom, event.target.value)} /></label>{!operatorCustomRangeValid(operator) && <p role="alert">{copy.operator.customInvalid}</p>}<button type="button" className="operator-icon-button" onClick={() => setCustomOpen(false)} aria-label={copy.operator.close}>×</button></div>}
|
||||
<button type="button" className={'operator-icon-button' + (operator.paused ? ' operator-icon-button--active' : '')} onClick={() => setOperatorPaused(!operator.paused)} disabled={operator.range !== 'live'} aria-label={operator.paused ? copy.operator.resume : copy.operator.pause} title={operator.paused ? copy.operator.resume : copy.operator.pause}><span aria-hidden="true">{operator.paused ? '▶' : 'Ⅱ'}</span></button>
|
||||
<button type="button" className="operator-icon-button" onClick={refreshAll} aria-label={copy.operator.refresh} title={copy.operator.refresh}>↻</button>
|
||||
<button type="button" className="operator-search-trigger" onClick={() => { setSearchOpen(true); window.setTimeout(() => searchInput.current?.focus(), 0); }} aria-label={copy.operator.search}><span aria-hidden="true">⌕</span><span>{copy.operator.search}</span><kbd>{copy.operator.searchHint}</kbd></button>
|
||||
<button type="button" className="operator-counter" onClick={() => onNavigate('/alerts')} aria-label={`${copy.operator.alerts}: ${shell.unavailable ? copy.systemStatus.unknown : shell.alerts}`}><small>{copy.operator.alerts}</small><strong>{shell.unavailable ? '—' : boundedCount(shell.alerts)}</strong></button>
|
||||
<button type="button" className="operator-counter" onClick={() => onNavigate('/incidents')} aria-label={`${copy.operator.incidents}: ${shell.unavailable ? copy.systemStatus.unknown : shell.incidents}`}><small>{copy.operator.incidents}</small><strong>{shell.unavailable ? '—' : boundedCount(shell.incidents)}</strong></button>
|
||||
<span className="operator-filter-state" title={activeFilters ? `${activeFilters} ${copy.operator.filters.toLocaleLowerCase('nl-BE')}` : copy.operator.noFilters}><small>{copy.operator.filters}</small><strong>{activeFilters || '—'}</strong></span>
|
||||
<details className="operator-session"><summary><span aria-hidden="true">◎</span><span className="sr-only">{copy.operator.session}</span></summary><div className="operator-session-popover"><strong>{sessionLabel}</strong>{shell.subject && <small>{copy.operator.user}: {shell.subject}</small>}{shell.role && <small>{copy.operator.role}: {shell.role}</small>}{shell.environment && <small>{copy.operator.environment}: {shell.environment}</small>}{release && <small>{copy.operator.release} {release}</small>}{shell.updatedAt && <small>{copy.overview.generated}: {formatDateTime(shell.updatedAt)}</small>}<button type="button" onClick={() => onNavigate('/settings')}>{copy.operator.openSettings}</button>{snapshot.state === 'unauthorized' && <SignInButton />}</div></details>
|
||||
<span className="sr-only" aria-live="polite">{aggregate.label}</span>
|
||||
</div>
|
||||
{searchOpen && <div className="operator-search-panel" role="dialog" aria-modal="false" aria-label={copy.operator.search}>
|
||||
<div className="operator-search-input"><span aria-hidden="true">⌕</span><input ref={searchInput} value={query} onChange={(event) => setQuery(event.target.value)} placeholder={copy.operator.searchPlaceholder} aria-label={copy.operator.search} /><button type="button" onClick={() => { setSearchOpen(false); setQuery(''); }} aria-label={copy.operator.close}>×</button></div>
|
||||
<div className="operator-search-results" role="listbox" aria-label={copy.operator.searchResults}>
|
||||
{!query.trim() && workspace.favorites.length > 0 && <><p className="operator-search-section-title">Favorieten</p>{workspace.favorites.map((item) => <button className="operator-workspace-row" type="button" role="option" aria-selected="false" key={item.id} onClick={() => openResult(item.path)}><span><small className="operator-workspace-kind">Favoriet</small><strong>{item.label}</strong></span><span aria-hidden="true">→</span></button>)}</>}
|
||||
{!query.trim() && workspace.views.length > 0 && <><p className="operator-search-section-title">Opgeslagen views</p>{workspace.views.map((item) => <button className="operator-workspace-row" type="button" role="option" aria-selected="false" key={item.id} onClick={() => openResult(item.path)}><span><small className="operator-workspace-kind">View</small><strong>{item.label}</strong></span><span aria-hidden="true">→</span></button>)}</>}
|
||||
{!query.trim() && workspace.recent.length > 0 && <><p className="operator-search-section-title">Recent</p>{workspace.recent.slice(0, 8).map((item) => <button className="operator-workspace-row" type="button" role="option" aria-selected="false" key={item.id} onClick={() => openResult(item.path)}><span><small className="operator-workspace-kind">Recent</small><strong>{item.label}</strong></span><span aria-hidden="true">→</span></button>)}</>}
|
||||
{staticResults.map((item) => <button type="button" role="option" aria-selected="false" key={'route-' + item.path} onClick={() => openResult(item.path)}><span><small>{copy.operator.navigationResult}</small><strong>{item.label}</strong></span><span aria-hidden="true">→</span></button>)}
|
||||
{search.items.map((item) => <button type="button" role="option" aria-selected="false" key={'entity-' + item.id} onClick={() => openResult(entityRoute(item))}><span><small>{copy.operator.entityResult}{item.entityType ? ` · ${item.entityType}` : ''}</small><strong>{entityLabel(item)}</strong></span><span aria-hidden="true">→</span></button>)}
|
||||
{operationalSearch.items.map((item) => <button type="button" role="option" aria-selected="false" key={item.id} onClick={() => openResult(item.path)}><span><small>{item.kind === 'incident' ? 'Incident' : 'Melding'} · {item.detail}</small><strong>{item.label}</strong></span><span aria-hidden="true">→</span></button>)}
|
||||
{(search.loading || operationalSearch.loading) && <p role="status">{copy.operator.searchLoading}</p>}
|
||||
{!search.loading && !operationalSearch.loading && query.trim().length >= 2 && staticResults.length === 0 && search.items.length === 0 && operationalSearch.items.length === 0 && <p>{copy.operator.noSearchResults}</p>}
|
||||
{!query.trim() && workspace.favorites.length === 0 && workspace.views.length === 0 && workspace.recent.length === 0 && <p>Open een pagina om recente context op te bouwen, of bewaar een favoriete view vanuit de contextbalk.</p>}
|
||||
</div>
|
||||
</div>}
|
||||
</header>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import { ApiProblemError } from './apiClient';
|
||||
import { copy } from './copy';
|
||||
|
||||
type Props = { children: ReactNode; resetKey: string };
|
||||
type State = { error: Error | null };
|
||||
|
||||
export class RouteErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
static getDerivedStateFromError(error: Error): State { return { error }; }
|
||||
componentDidCatch(_error: Error, _info: ErrorInfo): void {}
|
||||
componentDidUpdate(previous: Props): void { if (previous.resetKey !== this.props.resetKey && this.state.error) this.setState({ error: null }); }
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
const correlationId = this.state.error instanceof ApiProblemError ? this.state.error.correlationId : undefined;
|
||||
return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.states.boundaryTitle}</h1><p>{copy.states.boundaryDetail}</p>{correlationId && <p className="inline-technical">{copy.states.correlationReference}: <code>{correlationId}</code></p>}<button className="button" type="button" onClick={() => window.location.reload()}>{copy.states.reloadPage}</button></section>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { copy } from './copy';
|
||||
import { presentComponent, presentReason, presentStatus } from './presentation';
|
||||
import { backupPresentation, useSystemStatus } from './systemStatus';
|
||||
import { requestJSON } from './apiClient';
|
||||
|
||||
type HostCapabilities = { hardware: 'available' | 'not-configured' | 'unsupported'; ups: 'available' | 'not-configured' | 'unsupported' };
|
||||
function capability(value: unknown): HostCapabilities[keyof HostCapabilities] {
|
||||
if (!value || typeof value !== 'object') return 'not-configured';
|
||||
const record = value as Record<string, unknown>;
|
||||
const state = String(record.state ?? (record.status as Record<string, unknown> | undefined)?.state ?? (record.capability as Record<string, unknown> | undefined)?.state ?? 'available');
|
||||
if (state === 'unsupported') return 'unsupported';
|
||||
if (state === 'not_configured' || state === 'disabled') return 'not-configured';
|
||||
return 'available';
|
||||
}
|
||||
|
||||
export function SettingsCapabilityPanel() {
|
||||
const snapshot = useSystemStatus();
|
||||
const [host, setHost] = useState<HostCapabilities>({ hardware: 'not-configured', ups: 'not-configured' });
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
requestJSON<Record<string, unknown>>('/api/v1/host', { signal: controller.signal, retries: 1 }).then((value) => setHost({ hardware: capability(value.hardware), ups: capability(value.ups) })).catch((error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) setHost({ hardware: 'not-configured', ups: 'not-configured' }); });
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
const backup = backupPresentation(snapshot.status?.backup);
|
||||
const capabilityLabel = (value: HostCapabilities[keyof HostCapabilities]) => value === 'available' ? 'Beschikbaar' : value === 'unsupported' ? 'Niet ondersteund' : 'Niet geconfigureerd';
|
||||
return <section className="settings-capabilities" aria-label="Capabilitystatus">
|
||||
<article className="card settings-capability-card"><p className="card-kicker">Databronnen</p><h2>Bronstatus</h2>{snapshot.status?.sourceLag.length ? <ul className="compact-list">{snapshot.status.sourceLag.slice(0, 50).map((source) => <li key={source.sourceId}><strong>{presentComponent(source.sourceId)}</strong><span>{presentStatus(source.state)} · {presentReason(source.reason)}</span></li>)}</ul> : <p className="card-copy">Geen bronnen geconfigureerd of bronstatus niet beschikbaar.</p>}</article>
|
||||
<article className="card settings-capability-card"><p className="card-kicker">Back-ups</p><h2>{presentStatus(backup.state)}</h2><p className="card-copy">{presentReason(backup.reason)}</p><a className="button button--secondary" href="/status">Open systeemstatus en back-up</a></article>
|
||||
<article className="card settings-capability-card"><p className="card-kicker">Hardware-capabilities</p><h2>Host en UPS</h2><dl className="settings-capability-list"><div><dt>Hardwaretelemetrie</dt><dd>{capabilityLabel(host.hardware)}</dd></div><div><dt>UPS</dt><dd>{capabilityLabel(host.ups)}</dd></div></dl><p className="card-copy">Ontbrekende hardwaredata wordt niet als gezond geïnterpreteerd. Configuratie blijft in onboarding en de veilige collectorgrens.</p></article>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { navigate } from './AppShell';
|
||||
import { copy } from './copy';
|
||||
import { SettingsCapabilityPanel } from './SettingsCapabilityPanel';
|
||||
import { aggregateStatus, useSystemStatus } from './systemStatus';
|
||||
import { readSessionCapabilities, type SessionCapabilities } from './sessionClient';
|
||||
|
||||
function StatusBadge({ label, tone }: { 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>; }
|
||||
|
||||
export function SettingsPage() {
|
||||
const snapshot = useSystemStatus();
|
||||
const [session, setSession] = useState<SessionCapabilities | null>(null);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void readSessionCapabilities(controller.signal).then(setSession).catch(() => setSession(null));
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
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: 'Sources & health', detail: 'Controleer freshness, componentstatus en bronconfiguratie.', links: [{ href: '/status', title: copy.settings.systemStatus, detail: copy.settings.systemStatusDetail, access: copy.settings.viewAccess }, { 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: 'Presentation', detail: 'Beheer dashboards en start wallboardweergaven vanuit dezelfde presentatielaag.', links: [{ href: '/dashboards', title: copy.settings.dashboards, detail: copy.settings.dashboardsDetail, access: copy.settings.editorChanges }, { href: '/wallboard', title: copy.navigation.wallboard, detail: 'Open de read-only wallboardweergave voor afstandsmonitoring.', access: copy.settings.viewAccess }] },
|
||||
{ title: 'Inventory & relations', detail: 'Inspecteer ontdekte entiteiten, provenance, overrides en relationele context.', links: [{ href: '/inventory', title: copy.settings.inventory, detail: copy.settings.inventoryDetail, access: copy.settings.viewAccess }, { href: '/topology', title: copy.navigation.topology, detail: 'Onderzoek dependencies en mogelijke impactrelaties.', access: copy.settings.viewAccess }] },
|
||||
];
|
||||
const release = snapshot.status?.release;
|
||||
const permissions = session?.permissions;
|
||||
return <><header className="page-intro"><p className="eyebrow">{copy.settings.eyebrow}</p><h1>{copy.settings.title}</h1><p className="intro">{copy.settings.intro}</p></header><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="settings-facts-grid"><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>Release</strong><small>{release?.commit ? release.commit.slice(0, 12) : 'Commit niet beschikbaar'}</small></span><strong>{release?.version || snapshot.status?.version || '—'}</strong></div><div className="setting-row"><span><strong>Sessie</strong><small>{session?.subject || 'Gebruiker niet beschikbaar'}</small></span><strong>{session?.role || 'Onbekend'}</strong></div><div className="setting-row"><span><strong>{copy.settings.language}</strong><small>{copy.settings.languageDetail}</small></span><strong>{copy.settings.languageValue}</strong></div></div></section><section className="card settings-access-panel" aria-labelledby="settings-access-title"><div className="card-heading"><div><p className="card-kicker">Access & RBAC</p><h2 id="settings-access-title">Jouw operationele rechten</h2></div><span className="status-badge status-badge--unknown">{session?.source === 'session' ? 'Session API' : 'Fallback'}</span></div><div className="settings-permission-grid"><span data-enabled={permissions?.view === true}>View <strong>{permissions?.view ? 'Ja' : 'Nee'}</strong></span><span data-enabled={permissions?.operate === true}>Operate <strong>{permissions?.operate ? 'Ja' : 'Nee'}</strong></span><span data-enabled={permissions?.edit === true}>Edit <strong>{permissions?.edit ? 'Ja' : 'Nee'}</strong></span><span data-enabled={permissions?.admin === true}>Admin <strong>{permissions?.admin ? 'Ja' : 'Nee'}</strong></span></div><p className="card-copy">Niet-beschikbare write-controls worden niet als werkende acties gepresenteerd. De Unraid-infrastructuurgrens blijft read-only.</p></section><section className="card settings-policy-panel" aria-labelledby="settings-policy-title"><div className="card-heading"><div><p className="card-kicker">Monitoring policy</p><h2 id="settings-policy-title">Serverbeheerde instellingen</h2></div></div><dl className="settings-capability-list"><div><dt>Retention</dt><dd>Serverbeheerd · geen veilige write-API</dd></div><div><dt>Collectorgrens</dt><dd>Read-only</dd></div><div><dt>Historische query's</dt><dd>Bounded semantic metrics</dd></div></dl></section><SettingsCapabilityPanel /><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></>;
|
||||
}
|
||||
@@ -10,7 +10,9 @@ type Pool = { id: string; name: string; filesystem: string; state: string; utili
|
||||
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 };
|
||||
type CapacityForecast = { entityId: string; name: string; kind: string; confidence: string; daysToCapacity?: number; projectedAt?: string; rateBytesPerDay: number };
|
||||
type ForecastSnapshot = { items?: CapacityForecast[]; qualifiedCount?: number; reason?: string };
|
||||
export type StorageData = { array: ArraySnapshot; disks: DiskSnapshot; pools: PoolSnapshot; forecasts?: ForecastSnapshot };
|
||||
|
||||
function availability(value: string): string { return value === 'online' || value === 'healthy' || value === 'operational' ? 'healthy' : value === 'unknown' ? 'unknown' : 'degraded'; }
|
||||
function visualSeverity(...values: Array<string | undefined>): string {
|
||||
@@ -41,10 +43,19 @@ export function buildStorageNodes(data: StorageData): StorageMapNode[] {
|
||||
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(); }, []);
|
||||
useEffect(() => { const controller = new AbortController(); Promise.all([
|
||||
fetch('/api/v1/array', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('array'); return response.json() as Promise<ArraySnapshot>; }),
|
||||
fetch('/api/v1/disks?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('disks'); return response.json() as Promise<DiskSnapshot>; }),
|
||||
fetch('/api/v1/pools?limit=100', { signal: controller.signal }).then((response) => { if (!response.ok) throw new Error('pools'); return response.json() as Promise<PoolSnapshot>; }),
|
||||
fetch('/api/v1/forecasts', { signal: controller.signal }).then((response) => response.ok ? response.json() as Promise<ForecastSnapshot> : null).catch(() => null),
|
||||
]).then(([array, disks, pools, forecasts]) => { setData({ array, disks, pools, forecasts: forecasts ?? undefined }); 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} /></>;
|
||||
const diskProblems = data.disks.disks.filter((disk) => visualSeverity(availability(disk.state), disk.capacitySeverity, disk.thermalSeverity ?? disk.temperature?.status) !== 'healthy');
|
||||
const maxPool = [...data.pools.pools].sort((left, right) => right.utilizationPercent - left.utilizationPercent)[0];
|
||||
const qualifiedForecasts = (data.forecasts?.items ?? []).filter((item) => (item.confidence === 'high' || item.confidence === 'medium') && Number.isFinite(item.daysToCapacity)).sort((left, right) => (left.daysToCapacity ?? Number.POSITIVE_INFINITY) - (right.daysToCapacity ?? Number.POSITIVE_INFINITY));
|
||||
const earliest = qualifiedForecasts[0];
|
||||
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="storage-cockpit instrument-band" aria-label="Storage cockpit"><a className="instrument-cell" href="/array"><p>Array</p><strong>{signalLabel(data.array.state)}</strong><small>{data.array.members.length} members</small></a><a className="instrument-cell" href="/disks"><p>Disks met aandacht</p><strong>{diskProblems.length}</strong><small>{data.disks.disks.length} disks totaal</small></a><a className="instrument-cell" href={maxPool ? '/pools/' + encodeURIComponent(maxPool.id) : '/pools'}><p>Hoogste poolvulling</p><strong>{maxPool ? maxPool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : '—'}</strong><small>{maxPool?.name ?? 'Geen pooldata'}</small></a><a className="instrument-cell" href="/capacity"><p>Capaciteitsprognose</p><strong>{earliest?.daysToCapacity !== undefined ? Math.max(0, Math.round(earliest.daysToCapacity)) + ' d' : '—'}</strong><small>{earliest ? earliest.name + ' · ' + earliest.confidence : 'Onvoldoende gekwalificeerde historie'}</small></a></section><nav className="storage-domain-actions" aria-label="Storage domeinen"><a href="/array">Array</a><a href="/disks">Disks</a><a href="/pools">Pools</a><a href="/shares">Shares</a><a href="/capacity">Capacity</a></nav><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} /></>;
|
||||
}
|
||||
@@ -34,7 +34,23 @@ function ServiceLink({ node }: { node: TopologyNode }) {
|
||||
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 }) {
|
||||
function nodeRoute(node: TopologyNode): string {
|
||||
const kind = (node.kind ?? '').toLowerCase();
|
||||
const id = encodeURIComponent(node.id);
|
||||
if (kind.includes('container')) return '/containers/' + id;
|
||||
if (kind.includes('application') || kind === 'app') return '/applications/' + id;
|
||||
if (kind.includes('disk')) return '/disks/' + id;
|
||||
if (kind.includes('pool')) return '/pools/' + id;
|
||||
if (kind.includes('share')) return '/shares/' + id;
|
||||
if (kind.includes('service') || node.known) return '/services/' + id;
|
||||
return '/inventory/' + id;
|
||||
}
|
||||
function navigate(path: string): void {
|
||||
window.history.pushState({ pulseFrom: window.location.pathname + window.location.search }, '', path);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
export function TopologyWidget({ topology, compact = false, selectedId, onSelectNode }: { topology: TopologyData; compact?: boolean; selectedId?: string; onSelectNode?: (node: TopologyNode) => void }) {
|
||||
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]));
|
||||
@@ -44,7 +60,7 @@ export function TopologyWidget({ topology, compact = false }: { topology: Topolo
|
||||
<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>}
|
||||
{nodes.length === 0 ? <p className="card-copy">{copy.topology.noNodes}</p> : <ul className="topology-node-list">{nodes.map((node) => <li key={node.id}>{onSelectNode ? <button type="button" className={nodeClass(node) + (selectedId === node.id ? ' topology-node--selected' : '')} aria-pressed={selectedId === node.id} onClick={() => onSelectNode(node)}><strong>{node.label}</strong><small>{node.known ? statusLabel(node.state) + ' · ' + node.id : copy.topology.nodeUnknown}</small></button> : <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>
|
||||
@@ -65,6 +81,7 @@ export function TopologyPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [stateFilter, setStateFilter] = useState('all');
|
||||
const [relationFilter, setRelationFilter] = useState('all');
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setState('loading');
|
||||
@@ -89,6 +106,9 @@ export function TopologyPage() {
|
||||
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]);
|
||||
const selected = topology?.nodes.find((node) => node.id === selectedId) ?? null;
|
||||
const selectedDependencies = selected && topology ? topology.edges.filter((edge) => edge.from === selected.id).map((edge) => ({ edge, node: topology.nodes.find((node) => node.id === edge.to) })).filter((item): item is { edge: TopologyEdge; node: TopologyNode } => Boolean(item.node)).slice(0, 20) : [];
|
||||
const selectedImpacted = selected && topology ? topology.edges.filter((edge) => edge.to === selected.id).map((edge) => ({ edge, node: topology.nodes.find((node) => node.id === edge.from) })).filter((item): item is { edge: TopologyEdge; node: TopologyNode } => Boolean(item.node)).slice(0, 20) : [];
|
||||
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>;
|
||||
@@ -96,5 +116,5 @@ export function TopologyPage() {
|
||||
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>}</>;
|
||||
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><div className="topology-investigation-layout"><TopologyWidget topology={filtered} selectedId={selectedId} onSelectNode={(node) => setSelectedId((current) => current === node.id ? '' : node.id)} />{selected && <aside className="card topology-inspector" aria-labelledby="topology-inspector-title"><div className="card-heading"><div><p className="card-kicker">Context inspector</p><h2 id="topology-inspector-title">{selected.label}</h2></div><span className={'status-badge status-badge--' + (selected.state === 'up' ? 'ready' : 'unknown')}>{statusLabel(selected.state)}</span></div><dl className="incident-facts"><div><dt>Type</dt><dd>{selected.kind || 'Onbekend'}</dd></div><div><dt>Bron</dt><dd>{selected.sourceId || copy.topology.sourceUnknown}</dd></div><div><dt>Identiteit</dt><dd>{selected.id}</dd></div><div><dt>Statusreden</dt><dd>{selected.reason || 'Geen extra reden aangeleverd'}</dd></div></dl><div className="topology-inspector-actions"><button className="button" type="button" onClick={() => navigate(nodeRoute(selected))}>Open detail</button><button className="button button--secondary" type="button" onClick={() => navigate('/inventory/' + encodeURIComponent(selected.id))}>Open inventory</button></div><section><p className="card-kicker">Dependencies</p><h3>Deze node gebruikt</h3>{selectedDependencies.length ? <ul className="topology-inspector-list">{selectedDependencies.map(({ edge, node }) => <li key={edge.id}><button type="button" onClick={() => setSelectedId(node.id)}><span><strong>{node.label}</strong><small>{relationLabel(edge.relationType)} · {Math.round(edge.confidence * 100)}%</small></span><span aria-hidden="true">→</span></button></li>)}</ul> : <p className="card-copy">Geen uitgaande dependencyrelaties in de huidige dataset.</p>}</section><section><p className="card-kicker">Impact context</p><h3>Mogelijk geraakt</h3><p className="card-copy">Dit is relationele context uit de topologie en bewijst geen causaliteit.</p>{selectedImpacted.length ? <ul className="topology-inspector-list">{selectedImpacted.map(({ edge, node }) => <li key={edge.id}><button type="button" onClick={() => setSelectedId(node.id)}><span><strong>{node.label}</strong><small>{relationLabel(edge.relationType)} · {Math.round(edge.confidence * 100)}%</small></span><span aria-hidden="true">→</span></button></li>)}</ul> : <p className="card-copy">Geen inkomende relaties gevonden.</p>}</section></aside>}</div>{topology.truncated && <p className="topology-limit-note" role="status">{copy.topology.truncated}</p>}</>;
|
||||
}
|
||||
@@ -19,10 +19,11 @@ type Props = {
|
||||
previewing: boolean;
|
||||
previewError: string;
|
||||
viewportLabel: string;
|
||||
width: number;
|
||||
placement: { x: number; y: number; w: number; h: number; visible?: boolean };
|
||||
maxWidth: number;
|
||||
locked: boolean;
|
||||
onChange: (widget: EditorWidget) => void;
|
||||
onWidthChange: (width: number) => void;
|
||||
onPlacementChange: (field: 'x' | 'y' | 'w' | 'h', value: number) => void;
|
||||
onPreviewStateChange: (state: string) => void;
|
||||
onPreview: () => void;
|
||||
};
|
||||
@@ -42,17 +43,19 @@ 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) {
|
||||
export function WidgetConfigDrawer({ widget, errors, preview, previewState, previewing, previewError, viewportLabel, placement, maxWidth, locked, onChange, onPlacementChange, 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 thresholds = Array.isArray(visualization.thresholds) ? visualization.thresholds as Array<Record<string, unknown>> : [];
|
||||
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;
|
||||
const updateThreshold = (index: number, name: 'value' | 'color' | 'label', value: unknown) => update('visualization', 'thresholds', thresholds.map((threshold, thresholdIndex) => thresholdIndex === index ? { ...threshold, [name]: value } : threshold));
|
||||
|
||||
return <aside className="editor-config-drawer card" aria-labelledby="widget-config-heading">
|
||||
<p className="card-kicker">{configCopy.kicker}</p>
|
||||
@@ -70,7 +73,8 @@ export function WidgetConfigDrawer({ widget, errors, preview, previewState, prev
|
||||
<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')}</>}
|
||||
{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><option value="30d">30 dagen</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')}</>}
|
||||
{sourceType === 'text' && <label htmlFor={fieldId('data-text')}>{configCopy.sourceText}<textarea id={fieldId('data-text')} value={text(data.text)} maxLength={5000} rows={8} onChange={(event) => update('data', 'text', event.target.value)} /></label>}
|
||||
<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>
|
||||
@@ -79,18 +83,20 @@ export function WidgetConfigDrawer({ widget, errors, preview, previewState, prev
|
||||
<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 className="config-thresholds" aria-label={configCopy.thresholds}><span>{configCopy.thresholds}</span>{thresholds.map((threshold, index) => <div className="config-threshold-row" key={index}><label>{configCopy.thresholdValue}<input type="number" value={number(threshold.value)} aria-invalid={Boolean(errors['visualization.thresholds'])} onChange={(event) => updateThreshold(index, 'value', Number(event.target.value))} /></label><label>{configCopy.thresholdColor}<input type="text" value={text(threshold.color, '#78a7ff')} pattern="#[0-9A-Fa-f]{6}" maxLength={7} aria-invalid={Boolean(errors['visualization.thresholds'])} onChange={(event) => updateThreshold(index, 'color', event.target.value)} /></label><label>{configCopy.thresholdLabel}<input value={text(threshold.label)} maxLength={80} onChange={(event) => updateThreshold(index, 'label', event.target.value)} /></label><button className="button button--secondary" type="button" onClick={() => update('visualization', 'thresholds', thresholds.filter((_, thresholdIndex) => thresholdIndex !== index))}>{configCopy.removeThreshold}</button></div>)}<button className="button button--secondary" type="button" disabled={thresholds.length >= 10} onClick={() => update('visualization', 'thresholds', [...thresholds, { value: 0, color: '#78a7ff', label: '' }])}>{configCopy.addThreshold}</button>{error('visualization.thresholds')}</div>
|
||||
</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>
|
||||
<details className="config-section config-section--advanced">
|
||||
<summary><span>{configCopy.layout}</span><small>Positie en formaat per viewport</small></summary>
|
||||
<div className="config-fields"><label htmlFor={fieldId('layout-x')}>{configCopy.positionX} ({viewportLabel})<input id={fieldId('layout-x')} type="number" min={0} max={Math.max(0, maxWidth - placement.w)} step={1} value={String(placement.x)} disabled={locked} onChange={(event) => onPlacementChange('x', Number(event.target.value))} /></label><label htmlFor={fieldId('layout-y')}>{configCopy.positionY}<input id={fieldId('layout-y')} type="number" min={0} step={1} value={String(placement.y)} disabled={locked} onChange={(event) => onPlacementChange('y', Number(event.target.value))} /></label><label htmlFor={fieldId('layout-width')}>{configCopy.width}<input id={fieldId('layout-width')} type="number" min={1} max={maxWidth} step={1} value={String(placement.w)} disabled={locked} onChange={(event) => onPlacementChange('w', Number(event.target.value))} /></label><label htmlFor={fieldId('layout-height')}>{configCopy.height}<input id={fieldId('layout-height')} type="number" min={1} max={24} step={1} value={String(placement.h)} disabled={locked} onChange={(event) => onPlacementChange('h', Number(event.target.value))} /></label></div>
|
||||
<p className="card-copy">{configCopy.widthHint}</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<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>
|
||||
<details className="config-section config-section--advanced">
|
||||
<summary><span>{configCopy.behavior}</span><small>Refresh, visibility en drilldown</small></summary>
|
||||
<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><label className="checkbox-field" htmlFor={fieldId('behavior-showOnlyOnProblem')}><input id={fieldId('behavior-showOnlyOnProblem')} type="checkbox" checked={behavior.showOnlyOnProblem === true} onChange={(event) => update('behavior', 'showOnlyOnProblem', event.target.checked)} /> {configCopy.showOnlyOnProblem}</label></div>{error('behavior.liveIntervalSeconds')}
|
||||
<label htmlFor={fieldId('behavior-link')}>{configCopy.link}<input id={fieldId('behavior-link')} value={text(behavior.link)} maxLength={1000} placeholder="/incidents" aria-invalid={Boolean(errors['behavior.link'])} aria-describedby={errors['behavior.link'] ? fieldId('behavior-link') + '-error' : undefined} onChange={(event) => update('behavior', 'link', event.target.value || null)} /></label>{error('behavior.link')}
|
||||
</details>
|
||||
|
||||
<div className="config-preview">
|
||||
<h3>{configCopy.preview}</h3>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export type ApiProblem = { title?: string; detail?: string; status?: number; code?: string; correlationId?: string; instance?: string };
|
||||
|
||||
export class ApiProblemError extends Error {
|
||||
constructor(readonly status: number, readonly problem: ApiProblem, readonly correlationId?: string) {
|
||||
super(problem.detail || problem.title || `API request failed (${status})`);
|
||||
this.name = 'ApiProblemError';
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = RequestInit & { retries?: number };
|
||||
|
||||
function retryable(method: string, status: number): boolean {
|
||||
return method === 'GET' && (status === 429 || status === 502 || status === 503 || status === 504);
|
||||
}
|
||||
|
||||
async function problemFrom(response: Response): Promise<ApiProblem> {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('json')) return {};
|
||||
try {
|
||||
const value = await response.json() as Record<string, unknown>;
|
||||
const bounded = (field: string, limit: number) => typeof value[field] === 'string' ? value[field].slice(0, limit) : undefined;
|
||||
return { title: bounded('title', 240), detail: bounded('detail', 1000), code: bounded('code', 120), correlationId: bounded('correlationId', 160), instance: bounded('instance', 500), status: typeof value.status === 'number' ? value.status : undefined };
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
export async function requestJSON<T>(url: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { retries = 0, ...init } = options;
|
||||
const method = (init.method || 'GET').toUpperCase();
|
||||
const attempts = Math.min(2, Math.max(0, Math.trunc(retries))) + 1;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
let response: Response;
|
||||
try { response = await fetch(url, { cache: 'no-store', ...init }); }
|
||||
catch (error) {
|
||||
if (attempt + 1 < attempts && method === 'GET' && !(error instanceof DOMException && error.name === 'AbortError')) continue;
|
||||
throw error;
|
||||
}
|
||||
if (response.ok) return response.status === 204 ? undefined as T : response.json() as Promise<T>;
|
||||
if (attempt + 1 < attempts && retryable(method, response.status)) continue;
|
||||
const problem = await problemFrom(response);
|
||||
const correlationId = (response.headers.get('x-correlation-id') || problem.correlationId)?.slice(0, 160);
|
||||
throw new ApiProblemError(response.status, problem, correlationId || undefined);
|
||||
}
|
||||
throw new ApiProblemError(500, { title: 'Onbereikbare requeststatus' });
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === 'AbortError';
|
||||
}
|
||||
+93
-2
@@ -3,6 +3,61 @@ export const copy = {
|
||||
name: 'ITWorx Pulse',
|
||||
context: 'Observability voor Unraid',
|
||||
},
|
||||
operator: {
|
||||
commandBar: 'Globale operatorbalk',
|
||||
operationalTruth: 'Operationele verbindingsstatus',
|
||||
server: 'Server',
|
||||
serverUnknown: 'Onbekende server',
|
||||
transport: 'API-verbinding',
|
||||
connected: 'Verbonden',
|
||||
reachable: 'Bereikbaar; sessie vereist',
|
||||
stale: 'Verbonden; gegevens verouderd',
|
||||
connecting: 'Verbinden…',
|
||||
unavailable: 'Niet beschikbaar',
|
||||
sources: 'Bronnen',
|
||||
sourcesUnknown: 'Bronstatus onbekend',
|
||||
sourcesCurrent: 'actueel',
|
||||
range: 'Globale periode',
|
||||
rangeLive: 'Live',
|
||||
range15m: '15 min',
|
||||
range1h: '1 uur',
|
||||
range6h: '6 uur',
|
||||
range24h: '24 uur',
|
||||
range7d: '7 dagen',
|
||||
range30d: '30 dagen',
|
||||
rangeCustom: 'Aangepast',
|
||||
customFrom: 'Vanaf',
|
||||
customTo: 'Tot',
|
||||
customInvalid: 'Kies een geldige begin- en eindtijd; de begintijd moet vóór de eindtijd liggen.',
|
||||
pause: 'Live grafieken pauzeren',
|
||||
resume: 'Live grafieken hervatten',
|
||||
refresh: 'Actuele gegevens vernieuwen',
|
||||
filters: 'Actieve filters',
|
||||
noFilters: 'Geen actieve filters',
|
||||
alerts: 'Actieve meldingen',
|
||||
incidents: 'Open incidenten',
|
||||
search: 'Zoeken in Pulse',
|
||||
searchPlaceholder: 'Pagina, service, container, disk…',
|
||||
searchHint: 'Ctrl/⌘ K',
|
||||
searchResults: 'Zoekresultaten',
|
||||
navigationResult: 'Pagina',
|
||||
entityResult: 'Entiteit',
|
||||
noSearchResults: 'Geen overeenkomsten gevonden.',
|
||||
searchLoading: 'Zoeken…',
|
||||
session: 'Sessie',
|
||||
sessionActive: 'Aangemeld',
|
||||
sessionRequired: 'Aanmelden vereist',
|
||||
sessionUnknown: 'Sessiestatus onbekend',
|
||||
environment: 'Omgeving',
|
||||
user: 'Gebruiker',
|
||||
role: 'Rol',
|
||||
openSettings: 'Instellingen openen',
|
||||
release: 'Release',
|
||||
currentPage: 'Huidige pagina',
|
||||
countCapped: '100+',
|
||||
close: 'Sluiten',
|
||||
},
|
||||
|
||||
navigation: {
|
||||
label: 'Hoofdnavigatie',
|
||||
groups: {
|
||||
@@ -28,7 +83,7 @@ export const copy = {
|
||||
network: 'Netwerk',
|
||||
applications: 'Applicaties',
|
||||
inventory: 'Inventaris',
|
||||
dashboards: 'Dashboards', wallboard: 'Wallboard',
|
||||
dashboards: 'Dashboards', explore: 'Verkennen', wallboard: 'Wallboard',
|
||||
alerts: 'Meldingen',
|
||||
events: 'Gebeurtenissen',
|
||||
incidents: 'Incidenten',
|
||||
@@ -467,6 +522,9 @@ export const copy = {
|
||||
retryDetail: 'De widget blijft begrensd verversen zodra de bron data levert.',
|
||||
unavailableDetail: 'De bron kon niet veilig worden gelezen.', checkedComponents: 'gecontroleerde componenten',
|
||||
semanticMetric: 'Semantische metriek', events: 'Gebeurtenissen', inventory: 'Inventaris',
|
||||
alerts: 'Meldingen', incidents: 'Incidenten', text: 'Notitie',
|
||||
capabilityDisabled: 'Niet ondersteund voor deze bron', unknownType: 'Onbekend widgettype',
|
||||
partial: 'Gedeeltelijk', stale: 'Verouderd', rows: 'rijen',
|
||||
},
|
||||
},
|
||||
widgets: {
|
||||
@@ -487,6 +545,13 @@ export const copy = {
|
||||
queryInspector: 'Query-inspecteur',
|
||||
unknown: 'Widget',
|
||||
},
|
||||
explore: {
|
||||
eyebrow: 'Semantische meetruimte', title: 'Metrics verkennen', intro: 'Vind, open en vergelijk goedgekeurde metrics zonder PromQL te schrijven.',
|
||||
workspaces: 'Metricwerkruimte', explore: 'Verkennen', compare: 'Vergelijken', history: 'Historie',
|
||||
catalog: 'Metriccatalogus', findMetric: 'Vind een metric', search: 'Zoeken', searchPlaceholder: 'CPU, opslag, responstijd…', loading: 'Catalogus laden…', catalogError: 'De metriccatalogus kon niet veilig worden geladen.', empty: 'Geen metrics gevonden.', unavailable: 'Niet beschikbaar',
|
||||
range: 'Periode', queryRange: 'Queryperiode', historyRange: 'Historische periode', invalidRange: 'Ongeldige periode', budgetExceeded: 'Serverbudget overschreden', budgetDetail: '{metric} ondersteunt maximaal {days} dagen. Kies een kortere periode.', compareLimit: 'Maximaal vier metrics kunnen tegelijk worden vergeleken.',
|
||||
selectTitle: 'Selecteer een metric', selectDetail: 'Zoek in de catalogus en open een ondersteunde semantische metric.', closeMetric: 'Sluiten', queryError: 'De metricquery kon niet veilig worden uitgevoerd.',
|
||||
},
|
||||
metrics: {
|
||||
fresh: 'Actueel',
|
||||
delayed: 'Vertraagd',
|
||||
@@ -506,6 +571,7 @@ export const copy = {
|
||||
chartTitle: 'Tijdreeks voor',
|
||||
metric: 'meting',
|
||||
totalSeries: 'Totaal',
|
||||
seriesVisibility: 'Zichtbare reeksen', zoomRecent: 'Zoom op recente helft', resetZoom: 'Volledige periode', annotations: 'Incident- en meldingsannotaties',
|
||||
summary: 'Tekstuele samenvatting per reeks',
|
||||
series: 'Reeks',
|
||||
latest: 'Laatste',
|
||||
@@ -646,6 +712,10 @@ export const copy = {
|
||||
errorTitle: 'Er ging iets mis',
|
||||
errorDetail: 'De pagina kon niet worden geladen. Probeer het opnieuw.',
|
||||
retry: 'Opnieuw proberen',
|
||||
boundaryTitle: 'Deze pagina kon niet worden weergegeven',
|
||||
boundaryDetail: 'Een onverwachte fout is veilig opgevangen. Probeer de pagina opnieuw te laden.',
|
||||
correlationReference: 'Referentie',
|
||||
reloadPage: 'Pagina opnieuw laden',
|
||||
unauthorizedTitle: 'Geen toegang',
|
||||
unauthorizedDetail: 'Je account heeft geen toegang tot deze pagina.',
|
||||
returnHome: 'Terug naar overzicht',
|
||||
@@ -707,6 +777,7 @@ export const copy = {
|
||||
serviceMatrix: 'Servicematrix',
|
||||
alertSummary: 'Meldingssamenvatting',
|
||||
text: 'Notitie',
|
||||
queryInspector: 'Query-inspecteur',
|
||||
fallback: 'Widget',
|
||||
newWidget: 'Nieuwe widget',
|
||||
untitled: 'Widget zonder titel',
|
||||
@@ -725,6 +796,10 @@ export const copy = {
|
||||
moveDown: 'Widget omlaag: ',
|
||||
moveUpVisible: 'Omhoog',
|
||||
moveDownVisible: 'Omlaag',
|
||||
moveLeft: 'Widget naar links: ',
|
||||
moveRight: 'Widget naar rechts: ',
|
||||
moveLeftVisible: 'Links',
|
||||
moveRightVisible: 'Rechts',
|
||||
unlockLabel: 'Widget ontgrendelen: ',
|
||||
lockLabel: 'Widget vergrendelen: ',
|
||||
unlock: 'Ontgrendel',
|
||||
@@ -753,6 +828,7 @@ export const copy = {
|
||||
previewFailedConnection: 'Voorbeeld kon niet worden geladen. Controleer de verbinding.',
|
||||
saveBlockedWidgets: 'Opslaan geblokkeerd: herstel eerst de gemarkeerde widgetconfiguratie.',
|
||||
saveBlockedVariables: 'Opslaan geblokkeerd: herstel eerst de dashboardvariabelen.',
|
||||
saveBlockedLayouts: 'Opslaan geblokkeerd: widgets overlappen in minstens één viewport.',
|
||||
saveConflict: 'Conflict: de serverversie is gewijzigd. Herlaad de serverversie voordat je verdergaat.',
|
||||
saveFailed: 'Opslaan is niet gelukt.',
|
||||
saveFailedConnection: 'Opslaan is niet gelukt. Controleer de verbinding.',
|
||||
@@ -770,6 +846,8 @@ export const copy = {
|
||||
limitRange: 'De limiet moet een geheel getal tussen 1 en 1000 zijn.',
|
||||
decimalsRange: 'Decimalen moeten tussen 0 en 6 liggen.',
|
||||
maxBelowMin: 'Maximum moet groter of gelijk aan minimum zijn.',
|
||||
thresholdsInvalid: 'Drempels vereisen een getal en een hexkleur, met maximaal tien regels.',
|
||||
linkInvalid: 'Gebruik alleen een intern Pulse-pad dat met / begint.',
|
||||
intervalRange: 'Verversing moet tussen 1 en 300 seconden liggen.',
|
||||
},
|
||||
config: {
|
||||
@@ -808,12 +886,23 @@ export const copy = {
|
||||
decimals: 'Decimalen',
|
||||
minimum: 'Minimum',
|
||||
maximum: 'Maximum',
|
||||
thresholds: 'Drempels',
|
||||
thresholdValue: 'Waarde',
|
||||
thresholdColor: 'Kleur',
|
||||
thresholdLabel: 'Label',
|
||||
addThreshold: 'Drempel toevoegen',
|
||||
removeThreshold: 'Drempel verwijderen',
|
||||
layout: 'Layout',
|
||||
positionX: 'Kolom (x)',
|
||||
positionY: 'Rij (y)',
|
||||
width: 'Breedte in kolommen',
|
||||
widthHint: 'De breedte geldt voor de actieve layout en is ook met pijltjestoetsen op de kaart aan te passen.',
|
||||
height: 'Hoogte in rijen',
|
||||
widthHint: 'x, y, breedte en hoogte gelden alleen voor de actieve layout. Vergrendelde widgets kunnen hier niet worden gewijzigd.',
|
||||
behavior: 'Gedrag',
|
||||
refresh: 'Verversing (sec.)',
|
||||
hideWhenEmpty: 'Verberg bij lege data',
|
||||
showOnlyOnProblem: 'Toon alleen bij problemen',
|
||||
link: 'Interne drilldownlink',
|
||||
preview: 'Voorbeeld',
|
||||
previewState: 'Toestand',
|
||||
previewStateLoading: 'Laden',
|
||||
@@ -879,3 +968,5 @@ export const copy = {
|
||||
skipToContent: 'Ga naar hoofdinhoud',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export { dashboardManagerCopy } from './dashboardManagerCopy';
|
||||
@@ -0,0 +1,39 @@
|
||||
.dashboard-manager { display: grid; gap: var(--space-5); }
|
||||
.dashboard-manager-header { display: flex; align-items: end; justify-content: space-between; gap: var(--space-5); }
|
||||
.dashboard-manager-header .button { flex: 0 0 auto; }
|
||||
.dashboard-manager-viewer { display: grid; gap: .35rem; border-left: 2px solid var(--unknown); }
|
||||
.dashboard-manager-viewer p { margin: 0; color: var(--muted); }
|
||||
.dashboard-create-panel { display: grid; gap: var(--space-4); }
|
||||
.dashboard-create-panel h2 { margin: 0; }
|
||||
.dashboard-create-grid { display: grid; grid-template-columns: minmax(0, 2fr) repeat(2, minmax(8rem, 1fr)); gap: var(--space-3); }
|
||||
.dashboard-create-grid label, .dashboard-manager-actions label { display: grid; gap: .35rem; color: var(--muted); font-size: .7rem; font-weight: 700; }
|
||||
.dashboard-create-grid input, .dashboard-create-grid select, .dashboard-create-grid textarea, .dashboard-manager-actions input, .dashboard-manager-actions textarea { width: 100%; min-height: 2.6rem; border: 1px solid var(--border); border-radius: .35rem; background: var(--surface-lowest); color: var(--text); padding: .5rem .65rem; }
|
||||
.dashboard-create-description { grid-column: 1 / -1; }
|
||||
.dashboard-manager-message { position: sticky; top: calc(var(--command-height) + .5rem); z-index: 10; margin: 0; }
|
||||
.dashboard-manager-list > ul { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.dashboard-manager-row { border-top: 1px solid var(--border-quiet); }
|
||||
.dashboard-manager-row:first-child { border-top: 0; }
|
||||
.dashboard-manager-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .75rem; padding: .4rem 0; }
|
||||
.dashboard-manager-open { display: flex; width: 100%; min-width: 0; min-height: 4rem; align-items: center; justify-content: space-between; gap: 1rem; padding: .65rem .75rem; border: 1px solid transparent; border-radius: .4rem; background: transparent; color: var(--text); text-align: left; }
|
||||
.dashboard-manager-open:hover, .dashboard-manager-open:focus-visible { border-color: var(--accent); background: rgb(120 167 255 / .05); }
|
||||
.dashboard-manager-open > span:first-child { display: grid; min-width: 0; gap: .18rem; }
|
||||
.dashboard-manager-open strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dashboard-manager-open small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dashboard-manager-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem; color: var(--muted); font: 600 .63rem ui-monospace, monospace; }
|
||||
.dashboard-manager-meta span { padding: .2rem .35rem; border: 1px solid var(--border-quiet); border-radius: .3rem; }
|
||||
.dashboard-manager-manage { min-width: 6rem; }
|
||||
.dashboard-manager-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-4); margin: 0 .75rem .8rem; padding: .8rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--surface-lowest); }
|
||||
.dashboard-manager-actions section:first-child { display: grid; grid-template-columns: 1fr 1.5fr auto; align-items: end; gap: .65rem; }
|
||||
.dashboard-manager-compact-actions { display: flex; flex-wrap: wrap; align-items: end; justify-content: flex-end; gap: .5rem; }
|
||||
.dashboard-manager-note { max-width: 18rem; margin: 0; color: var(--muted); font-size: .68rem; }
|
||||
.dashboard-manager-confirm, .dashboard-version-panel { grid-column: 1 / -1; }
|
||||
.dashboard-manager-confirm { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: .65rem; padding: .7rem; border: 1px solid var(--attention); border-radius: .4rem; }
|
||||
.dashboard-manager-confirm p { margin: 0; color: var(--muted); font-size: .72rem; }
|
||||
.dashboard-version-panel { border-top: 1px solid var(--border-quiet); padding-top: .7rem; }
|
||||
.dashboard-version-panel ol { display: grid; gap: .4rem; margin: 0; padding: 0; list-style: none; }
|
||||
.dashboard-version-panel li { display: flex; min-height: 3.2rem; align-items: center; justify-content: space-between; gap: 1rem; padding: .45rem .55rem; border: 1px solid var(--border-quiet); border-radius: .35rem; }
|
||||
.dashboard-version-panel li > span { display: grid; gap: .15rem; }
|
||||
.dashboard-version-panel small { color: var(--muted); }
|
||||
.dashboard-manager-list--archived { opacity: .78; }
|
||||
@media (max-width: 900px) { .dashboard-create-grid { grid-template-columns: 1fr 1fr; } .dashboard-create-grid label:first-child, .dashboard-create-description { grid-column: 1 / -1; } .dashboard-manager-actions { grid-template-columns: 1fr; } .dashboard-manager-actions section:first-child { grid-template-columns: 1fr; align-items: stretch; } .dashboard-manager-compact-actions { justify-content: flex-start; } }
|
||||
@media (max-width: 700px) { .dashboard-manager-header { align-items: stretch; flex-direction: column; } .dashboard-create-grid { grid-template-columns: 1fr; } .dashboard-create-grid label:first-child, .dashboard-create-description { grid-column: 1; } .dashboard-manager-summary { grid-template-columns: 1fr; } .dashboard-manager-manage { margin: 0 .75rem .5rem; min-height: 2.75rem; } .dashboard-manager-open { align-items: flex-start; flex-direction: column; } .dashboard-manager-meta { justify-content: flex-start; } .dashboard-manager-actions { margin-inline: 0; } .dashboard-manager-confirm { grid-template-columns: 1fr; } .dashboard-version-panel li { align-items: stretch; flex-direction: column; } }
|
||||
@@ -0,0 +1,9 @@
|
||||
.dashboard-grid[data-spatial="true"] { grid-auto-rows: 2.5rem; grid-auto-flow: row; }
|
||||
.dashboard-grid[data-spatial="true"] > .widget-card { min-height: 0; height: 100%; overflow: hidden; }
|
||||
.dashboard-grid--edit[data-spatial="true"] { position: relative; background-image: linear-gradient(to right, rgb(120 167 255 / .035) 1px, transparent 1px), linear-gradient(to bottom, rgb(120 167 255 / .035) 1px, transparent 1px); background-size: calc(100% / 18) 100%, 100% 2.5rem; }
|
||||
.dashboard-grid--edit[data-viewport="tablet"] { grid-template-columns: repeat(8, minmax(0, 1fr)); background-size: calc(100% / 8) 100%, 100% 2.5rem; }
|
||||
.dashboard-grid--edit[data-viewport="wallboard"] { grid-template-columns: repeat(24, minmax(0, 1fr)); background-size: calc(100% / 24) 100%, 100% 2.5rem; }
|
||||
.editor-spatial-controls { display: inline-flex; flex-wrap: wrap; gap: .25rem; }
|
||||
.editor-spatial-controls button { min-width: 2rem; }
|
||||
.editor-spatial-position { color: var(--muted); font: 600 .62rem ui-monospace, monospace; }
|
||||
@media (max-width: 700px) { .dashboard-grid[data-spatial="true"] { grid-auto-rows: auto; } .dashboard-grid--edit[data-spatial="true"] { background-image: none; } }
|
||||
@@ -0,0 +1,7 @@
|
||||
.dashboard-grid[data-spatial="true"] { grid-auto-rows: 1.5rem; grid-auto-flow: row; }
|
||||
.dashboard-grid[data-spatial="true"] > .widget-card { min-height: 0; height: 100%; overflow: hidden; }
|
||||
.dashboard-grid--edit[data-spatial="true"] { position: relative; background-image: linear-gradient(to right, rgb(120 167 255 / .035) 1px, transparent 1px), linear-gradient(to bottom, rgb(120 167 255 / .035) 1px, transparent 1px); background-size: calc(100% / 18) 100%, 100% 2.5rem; }
|
||||
.dashboard-grid--edit[data-viewport="tablet"] { grid-template-columns: repeat(8, minmax(0, 1fr)); background-size: calc(100% / 8) 100%, 100% 2.5rem; }
|
||||
.editor-spatial-controls { display: inline-flex; flex-wrap: wrap; gap: .25rem; }
|
||||
.editor-spatial-controls button { min-width: 2rem; }
|
||||
@media (max-width: 700px) { .dashboard-grid[data-spatial="true"] { grid-auto-rows: auto; } .dashboard-grid--edit[data-spatial="true"] { background-image: none; } }
|
||||
@@ -0,0 +1,107 @@
|
||||
export type DashboardSummary = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
scope: string;
|
||||
archivedAt?: string;
|
||||
revision: number;
|
||||
currentVersion: number;
|
||||
};
|
||||
|
||||
export type DashboardVersion = {
|
||||
id: string;
|
||||
dashboardId: string;
|
||||
number: number;
|
||||
schemaVersion: number;
|
||||
document: Record<string, unknown>;
|
||||
changeSummary: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export class DashboardApiError extends Error {
|
||||
constructor(readonly status: number, readonly code: string, detail: string, readonly correlationId?: string) { super(detail); }
|
||||
get conflict(): boolean { return this.status === 409 || this.code === 'REVISION_CONFLICT'; }
|
||||
}
|
||||
|
||||
type ApiRecord = Record<string, unknown>;
|
||||
function field<T>(record: ApiRecord, name: string): T | undefined {
|
||||
const upper = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
return (record[name] ?? record[upper] ?? record[name.toUpperCase()]) as T | undefined;
|
||||
}
|
||||
|
||||
export function dashboardSummaryFromApi(record: ApiRecord): DashboardSummary {
|
||||
const archived = field<unknown>(record, 'archivedAt');
|
||||
return {
|
||||
id: String(field(record, 'id') ?? ''),
|
||||
slug: String(field(record, 'slug') ?? ''),
|
||||
name: String(field(record, 'name') ?? ''),
|
||||
description: String(field(record, 'description') ?? ''),
|
||||
ownerId: String(field(record, 'ownerId') ?? ''),
|
||||
scope: String(field(record, 'scope') ?? 'unknown'),
|
||||
archivedAt: typeof archived === 'string' && archived ? archived : undefined,
|
||||
revision: Number(field(record, 'revision') ?? 0),
|
||||
currentVersion: Number(field(record, 'currentVersion') ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function dashboardVersionFromApi(record: ApiRecord): DashboardVersion {
|
||||
return {
|
||||
id: String(field(record, 'id') ?? ''),
|
||||
dashboardId: String(field(record, 'dashboardId') ?? ''),
|
||||
number: Number(field(record, 'number') ?? 0),
|
||||
schemaVersion: Number(field(record, 'schemaVersion') ?? 0),
|
||||
document: field<ApiRecord>(record, 'document') ?? {},
|
||||
changeSummary: String(field(record, 'changeSummary') ?? ''),
|
||||
createdBy: String(field(record, 'createdBy') ?? ''),
|
||||
createdAt: String(field(record, 'createdAt') ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
try { return await requestJSON<T>(url, init); }
|
||||
catch (error) {
|
||||
if (!(error instanceof ApiProblemError)) throw error;
|
||||
const problem = error.problem;
|
||||
const detail = problem.detail || (error.status === 403 ? 'Je hebt geen rechten voor deze dashboardwijziging.' : 'De dashboardbewerking kon niet worden uitgevoerd.');
|
||||
throw new DashboardApiError(error.status, problem.code || 'DASHBOARD_REQUEST_FAILED', detail, error.correlationId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDashboards(signal?: AbortSignal): Promise<DashboardSummary[]> {
|
||||
const data = await request<{ items?: ApiRecord[] }>('/api/v1/dashboards?limit=100', { signal });
|
||||
return (data.items ?? []).map(dashboardSummaryFromApi).filter((item) => item.id !== '');
|
||||
}
|
||||
|
||||
export async function createDashboard(document: Record<string, unknown>): Promise<DashboardSummary> {
|
||||
const data = await request<{ dashboard: ApiRecord }>('/api/v1/dashboards', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(document) });
|
||||
return dashboardSummaryFromApi(data.dashboard);
|
||||
}
|
||||
|
||||
export async function updateDashboardMetadata(item: DashboardSummary, name: string, description: string): Promise<DashboardSummary> {
|
||||
const data = await request<{ dashboard: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(item.id), { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'If-Match': String(item.revision) }, body: JSON.stringify({ name, description }) });
|
||||
return dashboardSummaryFromApi(data.dashboard);
|
||||
}
|
||||
|
||||
export async function cloneDashboard(item: DashboardSummary, slug: string, name: string): Promise<DashboardSummary> {
|
||||
const data = await request<{ dashboard: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(item.id) + '/clone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug, name }) });
|
||||
return dashboardSummaryFromApi(data.dashboard);
|
||||
}
|
||||
|
||||
export async function archiveDashboard(item: DashboardSummary): Promise<DashboardSummary> {
|
||||
const data = await request<{ dashboard: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(item.id), { method: 'DELETE', headers: { 'If-Match': String(item.revision) } });
|
||||
return dashboardSummaryFromApi(data.dashboard);
|
||||
}
|
||||
|
||||
export async function listDashboardVersions(item: DashboardSummary, signal?: AbortSignal): Promise<DashboardVersion[]> {
|
||||
const data = await request<{ items?: ApiRecord[] }>('/api/v1/dashboards/' + encodeURIComponent(item.id) + '/versions', { signal });
|
||||
return (data.items ?? []).map(dashboardVersionFromApi).filter((version) => version.number > 0);
|
||||
}
|
||||
|
||||
export async function restoreDashboardVersion(item: DashboardSummary, version: number): Promise<DashboardSummary> {
|
||||
const data = await request<{ dashboard: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(item.id) + '/restore/' + version, { method: 'POST', headers: { 'If-Match': String(item.revision) } });
|
||||
return dashboardSummaryFromApi(data.dashboard);
|
||||
}
|
||||
import { ApiProblemError, requestJSON } from './apiClient';
|
||||
@@ -0,0 +1,11 @@
|
||||
export const dashboardEditorM15Copy = {
|
||||
moveLeft: 'Verplaats widget één kolom naar links: ',
|
||||
moveRight: 'Verplaats widget één kolom naar rechts: ',
|
||||
moveUp: 'Verplaats widget één rij omhoog: ',
|
||||
moveDown: 'Verplaats widget één rij omlaag: ',
|
||||
position: 'Positie',
|
||||
heightDecrease: 'Maak widget één rij lager: ',
|
||||
heightIncrease: 'Maak widget één rij hoger: ',
|
||||
spatialHint: 'Sleep vrij over het raster of gebruik de pijlknoppen. Botsingen worden naar de eerstvolgende vrije rij opgelost.',
|
||||
mobileHint: 'Op mobiel blijft de eenvoudige volgorde-editor actief; vrije kolomplaatsing is daar bewust uitgeschakeld.',
|
||||
} as const;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const dashboardManagerCopy = {
|
||||
eyebrow: 'Dashboards', title: 'Dashboardbeheer', intro: 'Open bestaande dashboards of beheer hun lifecycle met versiebeveiliging en rolgebaseerde controls.', active: 'Actieve dashboards', archived: 'Gearchiveerd', showArchived: 'Toon gearchiveerde dashboards', hideArchived: 'Verberg gearchiveerde dashboards', create: 'Nieuw dashboard', createTitle: 'Dashboard aanmaken', name: 'Naam', description: 'Beschrijving', scope: 'Bereik', personal: 'Persoonlijk', shared: 'Gedeeld', system: 'Systeem', template: 'Template', templateBlank: 'Leeg', templateOperations: 'Operationeel overzicht', templateStorage: 'Opslagoverzicht', createAction: 'Dashboard maken', creating: 'Aanmaken…', cancel: 'Annuleren', open: 'Openen', manage: 'Beheren', closeManage: 'Beheer sluiten', rename: 'Metadata wijzigen', saveMetadata: 'Naam en beschrijving opslaan', clone: 'Dupliceren', archive: 'Archiveren', archiveConfirm: 'Dit dashboard archiveren?', archiveConfirmDetail: 'Het dashboard verdwijnt uit de actieve lijst maar de versiehistoriek blijft bewaard.', confirmArchive: 'Ja, archiveren', versions: 'Versiehistoriek', loadVersions: 'Versies bekijken', restore: 'Herstellen als huidige versie', currentVersion: 'Huidige versie', revision: 'Revisie', changedBy: 'Gewijzigd door', systemProtected: 'Systeemdashboard: archiveren is via deze UI uitgeschakeld.', archivedBadge: 'Gearchiveerd', noActive: 'Geen actieve dashboards beschikbaar.', noArchived: 'Geen gearchiveerde dashboards.', viewerTitle: 'Alleen bekijken', viewerDetail: 'Je huidige rol heeft geen dashboardbewerkingsrecht. Beheercontrols zijn daarom verborgen.', loading: 'Dashboards laden…', unauthorized: 'Aanmelden vereist om dashboards te bekijken.', error: 'Dashboardbeheer is tijdelijk niet beschikbaar.', retry: 'Opnieuw laden', created: 'Dashboard aangemaakt.', updated: 'Dashboardmetadata bijgewerkt.', cloned: 'Dashboard gedupliceerd.', archivedDone: 'Dashboard gearchiveerd.', restored: 'Dashboardversie hersteld.', conflict: 'Het dashboard is intussen gewijzigd. De actuele lijst is opnieuw geladen; voer je wijziging opnieuw uit.', failed: 'De dashboardbewerking kon niet worden uitgevoerd.', nameRequired: 'Geef een dashboardnaam op.', noVersions: 'Geen versiehistoriek beschikbaar.', unknownActor: 'onbekend',
|
||||
} as const;
|
||||
@@ -0,0 +1,61 @@
|
||||
export type SpatialViewport = 'desktop' | 'tablet' | 'mobile' | 'wallboard';
|
||||
export type SpatialPlacement = { x: number; y: number; w: number; h: number; visible?: boolean };
|
||||
export type SpatialItem = { id: string; placement: SpatialPlacement };
|
||||
|
||||
export const spatialColumns: Record<SpatialViewport, number> = { desktop: 18, tablet: 8, mobile: 1, wallboard: 24 };
|
||||
|
||||
function integer(value: unknown, fallback: number): number {
|
||||
const number = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback;
|
||||
return number;
|
||||
}
|
||||
|
||||
export function normalizePlacement(value: Record<string, unknown> | undefined, viewport: SpatialViewport): SpatialPlacement {
|
||||
const columns = spatialColumns[viewport];
|
||||
const w = viewport === 'mobile' ? 1 : Math.min(columns, Math.max(1, integer(value?.w, viewport === 'tablet' ? 8 : 6)));
|
||||
return {
|
||||
x: viewport === 'mobile' ? 0 : Math.min(columns - w, Math.max(0, integer(value?.x, 0))),
|
||||
y: Math.max(0, integer(value?.y, 0)),
|
||||
w,
|
||||
h: Math.max(1, Math.min(24, integer(value?.h, 4))),
|
||||
visible: value?.visible !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export function overlaps(left: SpatialPlacement, right: SpatialPlacement): boolean {
|
||||
if (left.visible === false || right.visible === false) return false;
|
||||
return left.x < right.x + right.w && left.x + left.w > right.x && left.y < right.y + right.h && left.y + left.h > right.y;
|
||||
}
|
||||
|
||||
export function resolvePlacement(candidate: SpatialPlacement, others: readonly SpatialItem[], viewport: SpatialViewport): SpatialPlacement {
|
||||
if (viewport === 'mobile') return { ...normalizePlacement(candidate as unknown as Record<string, unknown>, viewport), x: 0, w: 1 };
|
||||
let next = normalizePlacement(candidate as unknown as Record<string, unknown>, viewport);
|
||||
const bounded = others.filter((item) => item.placement.visible !== false);
|
||||
for (let attempts = 0; attempts < bounded.length + 4; attempts += 1) {
|
||||
const collisions = bounded.filter((item) => overlaps(next, item.placement));
|
||||
if (collisions.length === 0) return next;
|
||||
next = { ...next, y: Math.max(...collisions.map((item) => item.placement.y + item.placement.h)) };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function movePlacement(current: SpatialPlacement, dx: number, dy: number, others: readonly SpatialItem[], viewport: SpatialViewport): SpatialPlacement {
|
||||
const columns = spatialColumns[viewport];
|
||||
const candidate = { ...current, x: Math.min(columns - current.w, Math.max(0, current.x + Math.round(dx))), y: Math.max(0, current.y + Math.round(dy)) };
|
||||
return resolvePlacement(candidate, others, viewport);
|
||||
}
|
||||
|
||||
export function resizePlacement(current: SpatialPlacement, dw: number, dh: number, others: readonly SpatialItem[], viewport: SpatialViewport): SpatialPlacement {
|
||||
const columns = spatialColumns[viewport];
|
||||
const candidate = { ...current, w: viewport === 'mobile' ? 1 : Math.min(columns - current.x, Math.max(1, current.w + Math.round(dw))), h: Math.max(1, Math.min(24, current.h + Math.round(dh))) };
|
||||
return resolvePlacement(candidate, others, viewport);
|
||||
}
|
||||
|
||||
export function spatialStyle(value: Record<string, unknown> | undefined, viewport: SpatialViewport): Record<string, string> {
|
||||
const placement = normalizePlacement(value, viewport);
|
||||
if (viewport === 'mobile') return { '--widget-span': '1' };
|
||||
return {
|
||||
'--widget-span': String(placement.w),
|
||||
gridColumn: `${placement.x + 1} / span ${placement.w}`,
|
||||
gridRow: `${placement.y + 1} / span ${placement.h}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
export type DashboardTemplateId = 'blank' | 'operations' | 'host' | 'storage' | 'workloads' | 'services' | 'incidents' | 'capacity' | 'wallboard';
|
||||
export type DashboardDocument = Record<string, unknown>;
|
||||
|
||||
type IDFactory = () => string;
|
||||
|
||||
function defaultID(): string {
|
||||
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `dashboard-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
export function dashboardSlug(name: string, suffix = defaultID().slice(0, 6)): string {
|
||||
const base = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 62) || 'dashboard';
|
||||
return `${base}-${suffix.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 8) || 'copy'}`.slice(0, 80).replace(/-+$/g, '');
|
||||
}
|
||||
|
||||
function layouts(x: number, y: number, w: number, h: number) {
|
||||
return {
|
||||
desktop: { x, y, w, h, visible: true },
|
||||
tablet: { x: 0, y, w: 8, h, visible: true },
|
||||
mobile: { x: 0, y, w: 1, h, visible: true },
|
||||
wallboard: { x, y, w: Math.min(24, Math.max(w, 8)), h, visible: true },
|
||||
};
|
||||
}
|
||||
|
||||
function behavior(link: string, seconds: number) {
|
||||
return { locked: false, hidden: false, hideWhenEmpty: false, showOnlyOnProblem: false, link, liveIntervalSeconds: seconds, independentTimeRange: null };
|
||||
}
|
||||
|
||||
function operationsWidgets(id: IDFactory) {
|
||||
return [
|
||||
{ id: id(), type: 'stat', title: 'Serverstatus', description: 'Samengevoegde status van de server.', data: { sourceType: 'inventory', scope: { entityType: 'server', variable: 'server' }, transformations: [] }, visualization: { legend: false, showSparkline: false, decimals: 0, thresholds: [] }, behavior: behavior('/host', 5), layouts: layouts(0, 0, 6, 4) },
|
||||
{ id: id(), type: 'timeseries', title: 'CPU-belasting', description: 'Historische en live CPU-benutting.', data: { sourceType: 'semantic-metric', metric: 'host.cpu.utilization', scope: { serverId: '$server' }, range: '$timeRange', aggregation: 'avg', groupBy: ['instance'], transformations: [] }, visualization: { unit: 'percent', decimals: 1, legend: true, showSparkline: false, min: 0, max: 100, thresholds: [] }, behavior: behavior('/host', 5), layouts: layouts(6, 0, 12, 7) },
|
||||
{ id: id(), type: 'status-grid', title: 'Applicaties', description: 'Actuele applicatiestatus.', data: { sourceType: 'inventory', scope: { entityType: 'application' }, transformations: [], limit: 50 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/applications', 10), layouts: layouts(0, 7, 9, 7) },
|
||||
{ id: id(), type: 'event-timeline', title: 'Recente gebeurtenissen', description: 'Recente operationele gebeurtenissen.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 10), layouts: layouts(9, 7, 9, 7) },
|
||||
];
|
||||
}
|
||||
|
||||
function storageWidgets(id: IDFactory) {
|
||||
return [
|
||||
{ id: id(), type: 'storage-map', title: 'Array en pools', description: 'Array-, pool- en diskstatus in één opslagweergave.', data: { sourceType: 'inventory', scope: { entityTypes: ['array', 'pool', 'disk'] }, transformations: [] }, visualization: { legend: true, decimals: 1, thresholds: [] }, behavior: behavior('/storage', 15), layouts: layouts(0, 0, 12, 8) },
|
||||
{ id: id(), type: 'event-timeline', title: 'Opslaggebeurtenissen', description: 'Recente gebeurtenissen voor operationele context.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 15), layouts: layouts(12, 0, 6, 8) },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
function hostWidgets(id: IDFactory) {
|
||||
return [
|
||||
{ id: id(), type: 'timeseries', title: 'CPU-belasting', description: 'CPU-benutting voor de geselecteerde server.', data: { sourceType: 'semantic-metric', metric: 'host.cpu.utilization', scope: { serverId: '$server' }, range: '$timeRange', aggregation: 'avg', transformations: [] }, visualization: { unit: 'percent', decimals: 1, legend: true, min: 0, max: 100, thresholds: [{ value: 85, color: '#f2a65a', label: 'Aandacht' }, { value: 95, color: '#e76f51', label: 'Kritiek' }] }, behavior: behavior('/host', 5), layouts: layouts(0, 0, 12, 7) },
|
||||
{ id: id(), type: 'timeseries', title: 'Geheugengebruik', description: 'Geheugenbenutting en trend.', data: { sourceType: 'semantic-metric', metric: 'host.memory.utilization', scope: { serverId: '$server' }, range: '$timeRange', aggregation: 'avg', transformations: [] }, visualization: { unit: 'percent', decimals: 1, legend: true, min: 0, max: 100, thresholds: [{ value: 85, color: '#f2a65a', label: 'Aandacht' }, { value: 95, color: '#e76f51', label: 'Kritiek' }] }, behavior: behavior('/host', 5), layouts: layouts(12, 0, 6, 7) },
|
||||
{ id: id(), type: 'ranked-list', title: 'Processen', description: 'Operationele procescontext.', data: { sourceType: 'inventory', scope: { entityType: 'process' }, transformations: [], limit: 20 }, visualization: { legend: false, decimals: 1, thresholds: [] }, behavior: behavior('/processes', 10), layouts: layouts(0, 7, 9, 7) },
|
||||
{ id: id(), type: 'event-timeline', title: 'Hostgebeurtenissen', description: 'Recente host- en systeemgebeurtenissen.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 10), layouts: layouts(9, 7, 9, 7) },
|
||||
];
|
||||
}
|
||||
|
||||
function workloadWidgets(id: IDFactory) {
|
||||
return [
|
||||
{ id: id(), type: 'status-grid', title: 'Applicaties', description: 'Applicaties als primaire workloadcontext.', data: { sourceType: 'inventory', scope: { entityType: 'application' }, transformations: [], limit: 60 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/applications', 10), layouts: layouts(0, 0, 9, 7) },
|
||||
{ id: id(), type: 'ranked-list', title: 'Containers', description: 'Containers en hun actuele toestand.', data: { sourceType: 'inventory', scope: { entityType: 'container' }, transformations: [], limit: 60 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/containers', 10), layouts: layouts(9, 0, 9, 7) },
|
||||
{ id: id(), type: 'event-timeline', title: 'Workload events', description: 'Recente gebeurtenissen rond workloads.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 10), layouts: layouts(0, 7, 18, 7) },
|
||||
];
|
||||
}
|
||||
|
||||
function serviceWidgets(id: IDFactory) {
|
||||
return [
|
||||
{ id: id(), type: 'service-matrix', title: 'Service health', description: 'Beschikbaarheid en serviceproblemen.', data: { sourceType: 'inventory', scope: { entityType: 'service' }, transformations: [], limit: 100 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/services', 10), layouts: layouts(0, 0, 12, 8) },
|
||||
{ id: id(), type: 'topology', title: 'Dependencies', description: 'Relationele servicecontext.', data: { sourceType: 'inventory', scope: { entityType: 'service' }, transformations: [], limit: 100 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/topology', 15), layouts: layouts(12, 0, 6, 8) },
|
||||
{ id: id(), type: 'event-timeline', title: 'Service events', description: 'Recente servicegebeurtenissen.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 10), layouts: layouts(0, 8, 18, 6) },
|
||||
];
|
||||
}
|
||||
|
||||
function incidentWidgets(id: IDFactory) {
|
||||
return [
|
||||
{ id: id(), type: 'alert-summary', title: 'Actieve meldingen', description: 'Actuele alerts op prioriteit.', data: { sourceType: 'alerts', scope: {}, transformations: [], limit: 100 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/alerts', 5), layouts: layouts(0, 0, 9, 6) },
|
||||
{ id: id(), type: 'ranked-list', title: 'Open incidenten', description: 'Incidenten die operatoraandacht vragen.', data: { sourceType: 'incidents', scope: {}, transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/incidents', 5), layouts: layouts(9, 0, 9, 6) },
|
||||
{ id: id(), type: 'event-timeline', title: 'Correlatiecontext', description: 'Events rond alerts en incidenten.', data: { sourceType: 'events', scope: {}, range: '$timeRange', transformations: [], limit: 100 }, visualization: { legend: false, decimals: 0, thresholds: [] }, behavior: behavior('/events', 10), layouts: layouts(0, 6, 18, 8) },
|
||||
];
|
||||
}
|
||||
|
||||
function capacityWidgets(id: IDFactory) {
|
||||
return [
|
||||
...storageWidgets(id),
|
||||
{ id: id(), type: 'ranked-list', title: 'Capaciteitsfocus', description: 'Storage-entiteiten op bruikbare operationele volgorde.', data: { sourceType: 'inventory', scope: { entityTypes: ['pool', 'disk', 'share'] }, transformations: [], limit: 50 }, visualization: { legend: false, decimals: 1, thresholds: [] }, behavior: behavior('/capacity', 30), layouts: layouts(0, 8, 18, 6) },
|
||||
];
|
||||
}
|
||||
|
||||
function wallboardWidgets(id: IDFactory) {
|
||||
return [
|
||||
{ id: id(), type: 'alert-summary', title: 'Actieve meldingen', description: 'Meldingen die nu aandacht vragen.', data: { sourceType: 'alerts', scope: {}, transformations: [], limit: 50 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/alerts', 5), layouts: layouts(0, 0, 8, 6) },
|
||||
{ id: id(), type: 'service-matrix', title: 'Services', description: 'Afstandsleesbare servicestatus.', data: { sourceType: 'inventory', scope: { entityType: 'service' }, transformations: [], limit: 60 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/services', 10), layouts: layouts(8, 0, 10, 6) },
|
||||
{ id: id(), type: 'storage-map', title: 'Storage', description: 'Array, pools en disks.', data: { sourceType: 'inventory', scope: { entityTypes: ['array', 'pool', 'disk'] }, transformations: [] }, visualization: { legend: true, decimals: 1, thresholds: [] }, behavior: behavior('/storage', 15), layouts: layouts(0, 6, 10, 8) },
|
||||
{ id: id(), type: 'status-grid', title: 'Applicaties', description: 'Applicatiestatus.', data: { sourceType: 'inventory', scope: { entityType: 'application' }, transformations: [], limit: 40 }, visualization: { legend: true, decimals: 0, thresholds: [] }, behavior: behavior('/applications', 10), layouts: layouts(10, 6, 8, 8) },
|
||||
];
|
||||
}
|
||||
|
||||
export function buildDashboardDocument(options: { name: string; description?: string; scope: 'personal' | 'shared'; template: DashboardTemplateId; idFactory?: IDFactory }): DashboardDocument {
|
||||
const id = options.idFactory ?? defaultID;
|
||||
const dashboardID = id();
|
||||
const widgets = options.template === 'operations' ? operationsWidgets(id)
|
||||
: options.template === 'host' ? hostWidgets(id)
|
||||
: options.template === 'storage' ? storageWidgets(id)
|
||||
: options.template === 'workloads' ? workloadWidgets(id)
|
||||
: options.template === 'services' ? serviceWidgets(id)
|
||||
: options.template === 'incidents' ? incidentWidgets(id)
|
||||
: options.template === 'capacity' ? capacityWidgets(id)
|
||||
: options.template === 'wallboard' ? wallboardWidgets(id)
|
||||
: [];
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
id: dashboardID,
|
||||
slug: dashboardSlug(options.name, dashboardID.slice(0, 6)),
|
||||
name: options.name.trim(),
|
||||
description: options.description?.trim() ?? '',
|
||||
scope: options.scope,
|
||||
variables: [
|
||||
{ name: 'server', type: 'server', label: 'Server', default: 'primary' },
|
||||
{ name: 'timeRange', type: 'time-range', label: 'Periode', default: '1h' },
|
||||
],
|
||||
widgets,
|
||||
settings: { defaultTimeRange: '1h', live: true, refreshSeconds: 5, rotationSeconds: null },
|
||||
};
|
||||
}
|
||||
@@ -48,18 +48,22 @@ export class SeriesRingBuffer {
|
||||
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) => {
|
||||
let remaining = 10_000;
|
||||
((data as { result: unknown[] }).result).slice(0, 100).forEach((item) => {
|
||||
if (remaining <= 0) return;
|
||||
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) => {
|
||||
record.values.slice(-remaining).forEach((raw) => {
|
||||
if (remaining <= 0) return;
|
||||
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 });
|
||||
remaining -= 1;
|
||||
});
|
||||
});
|
||||
return result;
|
||||
@@ -165,4 +169,4 @@ export class LiveChartAdapter {
|
||||
get pointCount(): number { return this.store.pointCount(); }
|
||||
|
||||
clear(): void { this.store.clear(); }
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
import './operator-shell.css';
|
||||
import './operator-flow.css';
|
||||
import './dashboard-spatial-runtime.css';
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 MetricRangePreset = 'live' | '15m' | '1h' | '6h' | '24h' | '7d' | '30d';
|
||||
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 } };
|
||||
@@ -30,11 +30,27 @@ export class MetricClient {
|
||||
}
|
||||
|
||||
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 seconds: Record<MetricRangePreset, number> = { live: 300, '15m': 900, '1h': 3600, '6h': 21600, '24h': 86400, '7d': 604800, '30d': 2592000 };
|
||||
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 rangeForSelection(selection: string, now = new Date()): MetricRange {
|
||||
if (selection === 'paused') return rangeForPreset('live', now);
|
||||
if (selection.startsWith('custom|')) {
|
||||
const [, fromRaw = '', toRaw = ''] = selection.split('|', 3);
|
||||
const from = new Date(fromRaw);
|
||||
const to = new Date(toRaw);
|
||||
if (!Number.isNaN(from.getTime()) && !Number.isNaN(to.getTime()) && from < to) {
|
||||
const seconds = Math.max(1, (to.getTime() - from.getTime()) / 1000);
|
||||
return { from: from.toISOString(), to: to.toISOString(), stepSeconds: Math.max(15, Math.ceil(seconds / 4000)) };
|
||||
}
|
||||
return rangeForPreset('1h', now);
|
||||
}
|
||||
const supported: readonly MetricRangePreset[] = ['live', '15m', '1h', '6h', '24h', '7d', '30d'];
|
||||
return rangeForPreset(supported.includes(selection as MetricRangePreset) ? selection as MetricRangePreset : '1h', now);
|
||||
}
|
||||
|
||||
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); }
|
||||
@@ -0,0 +1,34 @@
|
||||
/* M16 operator flow and information architecture. */
|
||||
.workspace-context { position: sticky; top: var(--command-height); z-index: 20; display: grid; grid-template-columns: minmax(0,1fr) auto; gap: .65rem; align-items: center; padding: .45rem 1rem; border-bottom: 1px solid var(--border-quiet); background: rgb(8 13 21 / .96); backdrop-filter: blur(10px); }
|
||||
.workspace-context-main { display: flex; min-width: 0; align-items: center; gap: .75rem; }
|
||||
.workspace-back { display: inline-flex; min-height: 2rem; align-items: center; gap: .35rem; padding: .25rem .45rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: var(--surface-lowest); color: var(--muted); font-size: .68rem; }
|
||||
.workspace-back:hover,.workspace-back:focus-visible { border-color: var(--accent); color: var(--accent); }
|
||||
.workspace-breadcrumbs { display: flex; min-width: 0; align-items: center; gap: .35rem; color: var(--muted); font-size: .68rem; }
|
||||
.workspace-breadcrumbs a,.workspace-breadcrumbs button { overflow: hidden; max-width: 14rem; border: 0; background: transparent; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-breadcrumbs a:hover,.workspace-breadcrumbs a:focus-visible,.workspace-breadcrumbs button:hover,.workspace-breadcrumbs button:focus-visible { color: var(--accent); }
|
||||
.workspace-breadcrumbs strong { overflow: hidden; max-width: 18rem; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-actions { display: flex; align-items: center; gap: .35rem; }
|
||||
.workspace-action { min-height: 2rem; padding: .25rem .5rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: var(--surface-lowest); color: var(--muted); font-size: .65rem; }
|
||||
.workspace-action:hover,.workspace-action:focus-visible,.workspace-action--active { border-color: var(--accent); color: var(--accent); background: rgb(120 167 255 / .06); }
|
||||
.workspace-subnav { grid-column: 1 / -1; display: flex; min-width: 0; gap: .25rem; overflow-x: auto; scrollbar-width: thin; }
|
||||
.workspace-subnav a { flex: 0 0 auto; padding: .28rem .5rem; border: 1px solid transparent; border-radius: .35rem; color: var(--muted); font-size: .66rem; font-weight: 650; text-decoration: none; }
|
||||
.workspace-subnav a:hover,.workspace-subnav a:focus-visible { color: var(--text); border-color: var(--border); }
|
||||
.workspace-subnav a[aria-current="page"] { color: var(--accent); border-color: rgb(120 167 255 / .35); background: rgb(120 167 255 / .07); }
|
||||
.operator-search-section-title { margin: .5rem .75rem .25rem; color: var(--muted); font-size: .58rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.operator-search-results .operator-workspace-row small { text-transform: none; }
|
||||
.operator-search-results .operator-workspace-row .operator-workspace-kind { color: var(--accent); text-transform: uppercase; }
|
||||
.nav-link--domain { min-height: 2.65rem; }
|
||||
.nav-link--domain .nav-icon { color: var(--accent); }
|
||||
.action-queue button.problem-link,.overview-data-list button.problem-link { display: grid; width: 100%; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: .65rem; padding: .35rem 0; border: 0; background: transparent; color: inherit; text-align: left; }
|
||||
.action-queue button.problem-link:hover strong,.action-queue button.problem-link:focus-visible strong,.overview-data-list button.problem-link:hover strong,.overview-data-list button.problem-link:focus-visible strong { color: var(--accent); }
|
||||
.problem-link-arrow { color: var(--muted); }
|
||||
@media (max-width: 860px) { .workspace-context { top: 3.45rem; padding-inline: .75rem; } .workspace-actions .workspace-action span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } }
|
||||
@media (max-width: 700px) { .workspace-context { position: static; grid-template-columns: minmax(0,1fr) auto; padding: .4rem .65rem; } .workspace-breadcrumbs { font-size: .62rem; } .workspace-breadcrumbs > :first-child,.workspace-breadcrumbs > :nth-child(2) { display: none; } .workspace-breadcrumbs button { min-height: 2.75rem; padding-inline: .35rem; } .workspace-back { min-width: 2.75rem; min-height: 2.75rem; justify-content: center; } .workspace-action { min-width: 2.75rem; min-height: 2.75rem; } .workspace-subnav { margin-inline: -.1rem; } .workspace-subnav a { min-height: 2.5rem; display: inline-flex; align-items: center; } }
|
||||
|
||||
.settings-facts-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .4rem .9rem; }
|
||||
.settings-access-panel,.settings-policy-panel { margin-top: 1rem; }
|
||||
.settings-permission-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: .45rem; margin: .6rem 0; }
|
||||
.settings-permission-grid span { display: flex; min-height: 2.8rem; align-items: center; justify-content: space-between; gap: .5rem; padding: .5rem .6rem; border: 1px solid var(--border-quiet); border-radius: .4rem; background: var(--surface-lowest); color: var(--muted); font-size: .68rem; }
|
||||
.settings-permission-grid span[data-enabled="true"] { border-color: rgb(92 201 146 / .3); color: var(--ready); }
|
||||
.settings-permission-grid strong { color: currentColor; }
|
||||
@media (max-width: 700px) { .settings-facts-grid,.settings-permission-grid { grid-template-columns: 1fr; } }
|
||||
@@ -0,0 +1,57 @@
|
||||
/* M15 operator command bar: truth-first global controls layered after M14. */
|
||||
.operator-command-bar { position: sticky; top: 0; z-index: 25; display: grid; grid-template-columns: minmax(13rem, auto) minmax(15rem, .75fr) minmax(0, 1.4fr); min-height: var(--command-height); align-items: center; gap: .75rem; padding: .4rem 1rem; border-bottom: 1px solid var(--border-quiet); background: rgb(7 11 18 / .97); }
|
||||
.operator-identity, .operator-truth, .operator-controls { display: flex; min-width: 0; align-items: center; gap: .5rem; }
|
||||
.operator-controls { position: relative; justify-content: flex-end; }
|
||||
.operator-server-mark { display: grid; width: 1.8rem; height: 1.8rem; flex: 0 0 auto; place-items: center; border: 1px solid var(--border); border-radius: .4rem; background: var(--surface-raised); color: var(--signal); font: 700 .72rem ui-monospace, monospace; }
|
||||
.operator-server-copy, .operator-route, .operator-truth-item > span:last-child { display: grid; min-width: 0; line-height: 1.1; }
|
||||
.operator-server-copy small, .operator-route small, .operator-truth-item small, .operator-counter small, .operator-filter-state small { color: var(--muted); font-size: .56rem; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; }
|
||||
.operator-server-copy strong, .operator-route strong, .operator-truth-item strong { max-width: 13rem; overflow: hidden; color: var(--text); font-size: .74rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.operator-divider { align-self: stretch; width: 1px; min-height: 1.7rem; background: var(--border-quiet); }
|
||||
.operator-route strong { color: var(--accent); }
|
||||
.operator-truth { justify-content: center; }
|
||||
.operator-truth-item { display: flex; min-width: 0; align-items: center; gap: .35rem; padding: .28rem .45rem; border: 1px solid var(--border-quiet); border-radius: .35rem; color: var(--unknown); background: var(--surface-lowest); }
|
||||
.operator-truth-item--ready { color: var(--ready); }
|
||||
.operator-truth-item > span:first-child { font-size: .55rem; }
|
||||
.operator-truth-item strong { color: currentColor; font-size: .66rem; }
|
||||
.operator-range { display: grid; gap: .12rem; color: var(--muted); font-size: .56rem; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.operator-range select { min-height: 2.15rem; padding: .25rem 1.6rem .25rem .55rem; border: 1px solid var(--border); border-radius: .35rem; background: #0a111c; color: var(--text); font-size: .68rem; font-weight: 650; }
|
||||
.operator-icon-button, .operator-search-trigger, .operator-counter, .operator-session > summary { min-height: 2.15rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: var(--surface-lowest); color: var(--muted); }
|
||||
.operator-icon-button { display: grid; min-width: 2.15rem; place-items: center; padding: .3rem; }
|
||||
.operator-icon-button:hover, .operator-icon-button:focus-visible, .operator-icon-button--active { border-color: var(--accent); color: var(--accent); background: rgb(120 167 255 / .08); }
|
||||
.operator-icon-button:disabled { cursor: not-allowed; opacity: .4; }
|
||||
.operator-search-trigger { display: flex; min-width: 9rem; align-items: center; gap: .45rem; padding: .3rem .5rem; text-align: left; }
|
||||
.operator-search-trigger > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .68rem; }
|
||||
.operator-search-trigger kbd { margin-left: auto; color: #71869f; font: 600 .58rem ui-monospace, monospace; }
|
||||
.operator-counter, .operator-filter-state { display: grid; min-width: 3.4rem; align-content: center; justify-items: center; padding: .22rem .4rem; }
|
||||
.operator-counter:hover, .operator-counter:focus-visible { border-color: var(--accent); }
|
||||
.operator-counter strong, .operator-filter-state strong { color: var(--text); font: 700 .78rem ui-monospace, monospace; }
|
||||
.operator-filter-state { min-height: 2.15rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: var(--surface-lowest); color: var(--muted); }
|
||||
.operator-session { position: relative; }
|
||||
.operator-session > summary { display: grid; width: 2.15rem; place-items: center; cursor: pointer; list-style: none; }
|
||||
.operator-session > summary::-webkit-details-marker { display: none; }
|
||||
.operator-session[open] > summary { border-color: var(--accent); color: var(--accent); }
|
||||
.operator-session-popover { position: absolute; top: calc(100% + .45rem); right: 0; display: grid; width: 17rem; gap: .45rem; padding: .8rem; border: 1px solid var(--border); border-radius: .45rem; background: #0c1420; box-shadow: 0 .9rem 2rem #02050aaa; }
|
||||
.operator-session-popover strong { font-size: .8rem; }
|
||||
.operator-session-popover small { color: var(--muted); line-height: 1.4; }
|
||||
.operator-session-popover button, .operator-session-popover .button { min-height: 2.5rem; margin: 0; padding: .45rem .65rem; border: 1px solid var(--border); border-radius: .35rem; background: var(--surface-raised); color: var(--text); text-align: left; }
|
||||
.operator-custom-range { position: absolute; top: calc(100% + .45rem); right: 0; z-index: 4; display: grid; width: min(29rem, calc(100vw - 2rem)); grid-template-columns: repeat(2, minmax(0, 1fr)) auto; gap: .55rem; padding: .75rem; border: 1px solid var(--border); border-radius: .45rem; background: #0c1420; box-shadow: 0 .9rem 2rem #02050aaa; }
|
||||
.operator-custom-range label { display: grid; gap: .25rem; color: var(--muted); font-size: .62rem; font-weight: 700; }
|
||||
.operator-custom-range input { min-width: 0; min-height: 2.35rem; padding: .4rem .5rem; border: 1px solid var(--border); border-radius: .35rem; background: var(--surface-lowest); color: var(--text); }
|
||||
.operator-custom-range p { grid-column: 1 / -1; margin: 0; color: var(--attention); font-size: .68rem; }
|
||||
.operator-custom-range .operator-icon-button { align-self: end; }
|
||||
.operator-search-panel { position: fixed; top: 4.35rem; left: 50%; z-index: 60; width: min(42rem, calc(100vw - 2rem)); transform: translateX(-50%); overflow: hidden; border: 1px solid var(--border); border-radius: .55rem; background: #0a1019; box-shadow: 0 1.5rem 4rem #02050acc; }
|
||||
.operator-search-input { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: .55rem; padding: .7rem .8rem; border-bottom: 1px solid var(--border-quiet); }
|
||||
.operator-search-input input { width: 100%; min-height: 2.5rem; border: 0; background: transparent; color: var(--text); font-size: .95rem; outline: 0; }
|
||||
.operator-search-input button { display: grid; width: 2.4rem; height: 2.4rem; place-items: center; border: 1px solid var(--border); border-radius: .35rem; background: var(--surface-raised); color: var(--muted); }
|
||||
.operator-search-results { display: grid; max-height: min(65vh, 32rem); overflow-y: auto; padding: .45rem; }
|
||||
.operator-search-results button { display: flex; min-height: 3.25rem; align-items: center; justify-content: space-between; gap: 1rem; padding: .6rem .75rem; border: 1px solid transparent; border-radius: .4rem; background: transparent; color: var(--text); text-align: left; }
|
||||
.operator-search-results button:hover, .operator-search-results button:focus-visible { border-color: var(--accent); background: rgb(120 167 255 / .06); }
|
||||
.operator-search-results button span:first-child { display: grid; min-width: 0; gap: .12rem; }
|
||||
.operator-search-results small { color: var(--muted); font-size: .62rem; text-transform: uppercase; }
|
||||
.operator-search-results strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.operator-search-results > p { margin: .5rem; color: var(--muted); font-size: .75rem; }
|
||||
.dashboard-controls > label:first-child { display: none; }
|
||||
@media (max-width: 1180px) { .operator-command-bar { grid-template-columns: minmax(12rem, auto) minmax(0, 1fr); } .operator-truth { display: none; } .operator-search-trigger { min-width: 2.15rem; width: 2.15rem; padding: .3rem; justify-content: center; } .operator-search-trigger > span:nth-child(2), .operator-search-trigger kbd { display: none; } }
|
||||
@media (max-width: 860px) { .operator-command-bar { grid-template-columns: minmax(0, 1fr) auto; padding-inline: .75rem; } .operator-route, .operator-divider, .operator-filter-state { display: none; } .operator-counter { min-width: 2.7rem; } .operator-counter small { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } }
|
||||
@media (max-width: 700px) { .operator-command-bar { min-height: 3.45rem; grid-template-columns: minmax(0, 1fr) auto; padding: .35rem .7rem; } .operator-server-mark { display: none; } .operator-server-copy strong { max-width: 10rem; } .operator-controls { gap: .3rem; } .operator-range span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } .operator-range select { width: 5.6rem; min-height: 2.75rem; } .operator-icon-button, .operator-search-trigger, .operator-session > summary { min-width: 2.75rem; min-height: 2.75rem; } .operator-session-popover button, .operator-session-popover .button { min-height: 2.75rem; } .operator-counter, .operator-filter-state { display: none; } .operator-custom-range { position: fixed; top: 3.8rem; right: .7rem; left: .7rem; width: auto; grid-template-columns: 1fr; } .operator-custom-range p { grid-column: 1; } .operator-custom-range .operator-icon-button { position: absolute; top: .4rem; right: .4rem; } .operator-search-panel { top: 3.8rem; width: calc(100vw - 1rem); } .mobile-primary-list { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; } .mobile-primary-list .nav-link > span:last-child { font-size: .75rem; letter-spacing: -.07em; } .mobile-navigation { grid-template-columns: minmax(0, 1fr) 2.75rem; gap: .15rem; padding-inline: .25rem; } .mobile-more .nav-list { right: -.25rem; } }
|
||||
@media (max-width: 430px) { .operator-server-copy { display: none; } .operator-command-bar { grid-template-columns: 1fr; } .operator-controls { justify-content: space-between; } .operator-range { flex: 1; } .operator-range select { width: 100%; } }
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type OperatorRangePreset = 'live' | '15m' | '1h' | '6h' | '24h' | '7d' | '30d' | 'custom';
|
||||
export type OperatorContextState = {
|
||||
range: OperatorRangePreset;
|
||||
customFrom: string;
|
||||
customTo: string;
|
||||
paused: boolean;
|
||||
refreshVersion: number;
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = 'pulse.operator.';
|
||||
const RANGE_PARAM = 'range';
|
||||
const FROM_PARAM = 'from';
|
||||
const TO_PARAM = 'to';
|
||||
export const operatorNavigationEvent = 'pulse:navigation-state';
|
||||
const presets = new Set<OperatorRangePreset>(['live', '15m', '1h', '6h', '24h', '7d', '30d', 'custom']);
|
||||
const initialState: OperatorContextState = { range: '1h', customFrom: '', customTo: '', paused: false, refreshVersion: 0 };
|
||||
let state = { ...initialState };
|
||||
let hydrated = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function localInputFromISO(value: string | null): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function readStored(name: string): string {
|
||||
try { return window.localStorage.getItem(STORAGE_PREFIX + name) ?? ''; } catch { return ''; }
|
||||
}
|
||||
|
||||
function storedBoolean(name: string): boolean {
|
||||
return readStored(name) === 'true';
|
||||
}
|
||||
|
||||
function ensureHydrated(): void {
|
||||
if (hydrated || typeof window === 'undefined') return;
|
||||
hydrated = true;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const rawRange = params.get(RANGE_PARAM) || readStored('range') || initialState.range;
|
||||
const range = presets.has(rawRange as OperatorRangePreset) ? rawRange as OperatorRangePreset : initialState.range;
|
||||
const customFrom = range === 'custom' ? localInputFromISO(params.get(FROM_PARAM)) || readStored('customFrom') : readStored('customFrom');
|
||||
const customTo = range === 'custom' ? localInputFromISO(params.get(TO_PARAM)) || readStored('customTo') : readStored('customTo');
|
||||
state = { ...state, range, customFrom, customTo, paused: storedBoolean('paused') };
|
||||
}
|
||||
|
||||
function persist(next: OperatorContextState): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_PREFIX + 'range', next.range);
|
||||
window.localStorage.setItem(STORAGE_PREFIX + 'customFrom', next.customFrom);
|
||||
window.localStorage.setItem(STORAGE_PREFIX + 'customTo', next.customTo);
|
||||
window.localStorage.setItem(STORAGE_PREFIX + 'paused', String(next.paused));
|
||||
} catch { /* Storage is optional; URL state remains the shareable source. */ }
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(RANGE_PARAM, next.range);
|
||||
params.delete(FROM_PARAM);
|
||||
params.delete(TO_PARAM);
|
||||
if (next.range === 'custom' && operatorCustomRangeValid(next)) {
|
||||
params.set(FROM_PARAM, new Date(next.customFrom).toISOString());
|
||||
params.set(TO_PARAM, new Date(next.customTo).toISOString());
|
||||
}
|
||||
const query = params.toString();
|
||||
window.history.replaceState({}, '', window.location.pathname + (query ? '?' + query : '') + window.location.hash);
|
||||
window.dispatchEvent(new CustomEvent(operatorNavigationEvent));
|
||||
}
|
||||
|
||||
function publish(next: OperatorContextState): void {
|
||||
state = next;
|
||||
persist(next);
|
||||
[...listeners].forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function update(change: (current: OperatorContextState) => OperatorContextState): void {
|
||||
ensureHydrated();
|
||||
publish(change(state));
|
||||
}
|
||||
|
||||
function syncFromLocation(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(RANGE_PARAM);
|
||||
if (!raw || !presets.has(raw as OperatorRangePreset)) return;
|
||||
const range = raw as OperatorRangePreset;
|
||||
const customFrom = range === 'custom' ? localInputFromISO(params.get(FROM_PARAM)) || state.customFrom : state.customFrom;
|
||||
const customTo = range === 'custom' ? localInputFromISO(params.get(TO_PARAM)) || state.customTo : state.customTo;
|
||||
const next = { ...state, range, customFrom, customTo };
|
||||
if (JSON.stringify(next) === JSON.stringify(state)) return;
|
||||
state = next;
|
||||
[...listeners].forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
ensureHydrated();
|
||||
listeners.add(listener);
|
||||
if (typeof window !== 'undefined' && listeners.size === 1) window.addEventListener('popstate', syncFromLocation);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (typeof window !== 'undefined' && listeners.size === 0) window.removeEventListener('popstate', syncFromLocation);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot(): OperatorContextState { ensureHydrated(); return state; }
|
||||
function getServerSnapshot(): OperatorContextState { return initialState; }
|
||||
|
||||
export function useOperatorContext(): OperatorContextState {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
export function setOperatorRangePreset(range: OperatorRangePreset): void {
|
||||
update((current) => ({ ...current, range, paused: range === 'live' ? current.paused : false }));
|
||||
}
|
||||
|
||||
export function setOperatorCustomRange(customFrom: string, customTo: string): void {
|
||||
update((current) => ({ ...current, range: 'custom', customFrom, customTo, paused: false }));
|
||||
}
|
||||
|
||||
export function setOperatorPaused(paused: boolean): void {
|
||||
update((current) => ({ ...current, paused: current.range === 'live' ? paused : false }));
|
||||
}
|
||||
|
||||
export function refreshOperatorData(): void {
|
||||
ensureHydrated();
|
||||
update((current) => ({ ...current, refreshVersion: current.refreshVersion + 1 }));
|
||||
}
|
||||
|
||||
export function operatorCustomRangeValid(value: Pick<OperatorContextState, 'customFrom' | 'customTo'>): boolean {
|
||||
const from = Date.parse(value.customFrom);
|
||||
const to = Date.parse(value.customTo);
|
||||
return Number.isFinite(from) && Number.isFinite(to) && from < to;
|
||||
}
|
||||
|
||||
export function operatorRangeSelection(value: OperatorContextState): string {
|
||||
if (value.range !== 'custom') return value.range;
|
||||
if (!operatorCustomRangeValid(value)) return '1h';
|
||||
return `custom|${new Date(value.customFrom).toISOString()}|${new Date(value.customTo).toISOString()}`;
|
||||
}
|
||||
|
||||
/** Clears the singleton between isolated DOM tests; production code never calls this. */
|
||||
export function resetOperatorContextForTests(): void {
|
||||
listeners.clear();
|
||||
if (typeof window !== 'undefined') window.removeEventListener('popstate', syncFromLocation);
|
||||
state = { ...initialState };
|
||||
hydrated = false;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export type WorkspaceItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
kind: 'favorite' | 'view' | 'recent';
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type WorkspaceState = {
|
||||
favorites: WorkspaceItem[];
|
||||
views: WorkspaceItem[];
|
||||
recent: WorkspaceItem[];
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'pulse.operator.workspace.v1';
|
||||
export const WORKSPACE_EVENT = 'pulse-workspace-changed';
|
||||
|
||||
const emptyState = (): WorkspaceState => ({ favorites: [], views: [], recent: [] });
|
||||
|
||||
function validItem(value: unknown): value is WorkspaceItem {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const item = value as Partial<WorkspaceItem>;
|
||||
return typeof item.id === 'string' && typeof item.label === 'string' && typeof item.path === 'string'
|
||||
&& ['favorite', 'view', 'recent'].includes(String(item.kind)) && typeof item.updatedAt === 'string';
|
||||
}
|
||||
|
||||
export function readWorkspaceState(): WorkspaceState {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return emptyState();
|
||||
const parsed = JSON.parse(raw) as Partial<WorkspaceState>;
|
||||
return {
|
||||
favorites: Array.isArray(parsed.favorites) ? parsed.favorites.filter(validItem).slice(0, 24) : [],
|
||||
views: Array.isArray(parsed.views) ? parsed.views.filter(validItem).slice(0, 24) : [],
|
||||
recent: Array.isArray(parsed.recent) ? parsed.recent.filter(validItem).slice(0, 12) : [],
|
||||
};
|
||||
} catch {
|
||||
return emptyState();
|
||||
}
|
||||
}
|
||||
|
||||
function writeWorkspaceState(state: WorkspaceState): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(WORKSPACE_EVENT));
|
||||
}
|
||||
|
||||
function timestamp(): string { return new Date().toISOString(); }
|
||||
function cleanPath(path: string): string {
|
||||
if (!path.startsWith('/')) return '/';
|
||||
return path.slice(0, 2048);
|
||||
}
|
||||
function cleanLabel(label: string): string { return label.trim().slice(0, 160) || 'Pulse'; }
|
||||
|
||||
export function isFavorite(path: string): boolean {
|
||||
const clean = cleanPath(path);
|
||||
return readWorkspaceState().favorites.some((item) => item.path === clean);
|
||||
}
|
||||
|
||||
export function toggleFavorite(label: string, path: string): boolean {
|
||||
const state = readWorkspaceState();
|
||||
const clean = cleanPath(path);
|
||||
const existing = state.favorites.some((item) => item.path === clean);
|
||||
const favorite: WorkspaceItem = { id: 'favorite:' + clean, label: cleanLabel(label), path: clean, kind: 'favorite', updatedAt: timestamp() };
|
||||
state.favorites = existing
|
||||
? state.favorites.filter((item) => item.path !== clean)
|
||||
: [favorite, ...state.favorites].slice(0, 24);
|
||||
writeWorkspaceState(state);
|
||||
return !existing;
|
||||
}
|
||||
|
||||
export function saveCurrentView(label: string, path: string): void {
|
||||
const state = readWorkspaceState();
|
||||
const clean = cleanPath(path);
|
||||
const item: WorkspaceItem = { id: 'view:' + clean, label: cleanLabel(label), path: clean, kind: 'view', updatedAt: timestamp() };
|
||||
state.views = [item, ...state.views.filter((value) => value.path !== clean)].slice(0, 24);
|
||||
writeWorkspaceState(state);
|
||||
}
|
||||
|
||||
export function removeSavedView(path: string): void {
|
||||
const state = readWorkspaceState();
|
||||
const clean = cleanPath(path);
|
||||
state.views = state.views.filter((item) => item.path !== clean);
|
||||
writeWorkspaceState(state);
|
||||
}
|
||||
|
||||
export function recordRecent(label: string, path: string): void {
|
||||
const state = readWorkspaceState();
|
||||
const clean = cleanPath(path);
|
||||
if (clean === '/404') return;
|
||||
const item: WorkspaceItem = { id: 'recent:' + clean, label: cleanLabel(label), path: clean, kind: 'recent', updatedAt: timestamp() };
|
||||
state.recent = [item, ...state.recent.filter((value) => value.path !== clean)].slice(0, 12);
|
||||
writeWorkspaceState(state);
|
||||
}
|
||||
@@ -10,6 +10,6 @@ export const routeFromLocation = (pathname: string): RoutePath => {
|
||||
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'];
|
||||
const supported = ['/', '/topology', '/network', '/host', '/array', '/disks', '/pools', '/shares', '/storage', '/capacity', '/processes', '/containers', '/services', '/applications', '/inventory', '/dashboards', '/explore', '/wallboard', '/alerts', '/events', '/incidents', '/settings', '/status', '/onboarding', '/loading', '/error', '/unauthorized', '/404'];
|
||||
return supported.includes(pathname) ? pathname : '/404';
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
export type SessionPermissions = { view: boolean; operate: boolean; edit: boolean; admin: boolean };
|
||||
export type SessionCapabilities = { subject: string; role: string; environment: string; permissions: SessionPermissions; source: 'session' | 'fallback' };
|
||||
|
||||
const none: SessionPermissions = { view: false, operate: false, edit: false, admin: false };
|
||||
|
||||
async function fallbackCapabilities(signal?: AbortSignal): Promise<SessionCapabilities> {
|
||||
const response = await fetch('/api/v1/widgets/preview', { method: 'GET', cache: 'no-store', signal });
|
||||
if (response.status === 401) throw Object.assign(new Error('unauthorized'), { status: 401 });
|
||||
const edit = response.status === 405;
|
||||
return { subject: '', role: edit ? 'editor+' : 'viewer/operator', environment: '', permissions: { ...none, view: true, operate: edit, edit }, source: 'fallback' };
|
||||
}
|
||||
|
||||
export async function readSessionCapabilities(signal?: AbortSignal): Promise<SessionCapabilities> {
|
||||
const response = await fetch('/api/v1/session', { method: 'GET', cache: 'no-store', signal });
|
||||
if (response.ok) {
|
||||
const value = await response.json() as Partial<SessionCapabilities> & { permissions?: Partial<SessionPermissions> };
|
||||
return {
|
||||
subject: typeof value.subject === 'string' ? value.subject : '',
|
||||
role: typeof value.role === 'string' ? value.role : 'unknown',
|
||||
environment: typeof value.environment === 'string' ? value.environment : '',
|
||||
permissions: {
|
||||
view: value.permissions?.view === true,
|
||||
operate: value.permissions?.operate === true,
|
||||
edit: value.permissions?.edit === true,
|
||||
admin: value.permissions?.admin === true,
|
||||
},
|
||||
source: 'session',
|
||||
};
|
||||
}
|
||||
if (response.status === 401) throw Object.assign(new Error('unauthorized'), { status: 401 });
|
||||
if (response.status === 404 || response.status === 405) return fallbackCapabilities(signal);
|
||||
return { subject: '', role: 'unknown', environment: '', permissions: { ...none }, source: 'fallback' };
|
||||
}
|
||||
+84
-12
@@ -141,6 +141,66 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
|
||||
.setting-row { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
|
||||
/* Bounded dashboard runtime adapters. */
|
||||
.dashboard-runtime-table-wrap { overflow: auto; max-height: 22rem; border: 1px solid var(--border-quiet); border-radius: .4rem; }
|
||||
.dashboard-runtime-table-wrap:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||
.dashboard-runtime-table { width: 100%; border-collapse: collapse; font-size: .78rem; }
|
||||
.dashboard-runtime-table th, .dashboard-runtime-table td { padding: .55rem .65rem; border-bottom: 1px solid var(--border-quiet); text-align: left; vertical-align: top; }
|
||||
.dashboard-runtime-table th small { display: block; margin-top: .2rem; color: var(--muted); font-weight: 400; }
|
||||
.dashboard-alert-summary dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0 0 .65rem; background: var(--border-quiet); }
|
||||
.dashboard-alert-summary dl > div { padding: .6rem; background: var(--surface-lowest); }
|
||||
.dashboard-alert-summary dt { color: var(--muted); font-size: .68rem; text-transform: uppercase; }
|
||||
.dashboard-alert-summary dd { margin: .2rem 0 0; font: 700 1.25rem ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.dashboard-text-widget { overflow: auto; max-height: 22rem; line-height: 1.55; }
|
||||
.dashboard-text-widget p { margin: 0 0 .65rem; }
|
||||
@media (max-width: 700px) { .dashboard-alert-summary dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
|
||||
/* Explore / compare workspace. */
|
||||
.explore-mode-tabs { display: flex; gap: .35rem; margin-bottom: .8rem; }
|
||||
.explore-mode-tabs button, .metric-series-toggles button { min-height: 2.35rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: var(--surface-lowest); color: var(--muted); cursor: pointer; }
|
||||
.explore-mode-tabs button[aria-selected="true"], .metric-series-toggles button[aria-pressed="true"] { border-color: var(--accent); color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
.explore-layout { display: grid; grid-template-columns: minmax(17rem, 23rem) minmax(0, 1fr); gap: .8rem; align-items: start; }
|
||||
.explore-catalog { position: sticky; top: 4rem; max-height: calc(100vh - 5rem); overflow: auto; }
|
||||
.explore-catalog > label { display: grid; gap: .3rem; }
|
||||
.explore-metric-list { display: grid; gap: .35rem; margin: .7rem 0 0; padding: 0; list-style: none; }
|
||||
.explore-metric-list button { display: flex; width: 100%; justify-content: space-between; gap: .65rem; padding: .65rem; border: 1px solid var(--border-quiet); border-radius: .35rem; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
||||
.explore-metric-list button[aria-pressed="true"] { border-color: var(--accent); background: rgb(120 167 255 / .08); }
|
||||
.explore-metric-list button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.explore-metric-list strong, .explore-metric-list small { display: block; overflow-wrap: anywhere; }
|
||||
.explore-metric-list small { margin-top: .2rem; color: var(--muted); }
|
||||
.explore-workspace, .explore-results { display: grid; min-width: 0; gap: .8rem; }
|
||||
.explore-range { display: flex; align-items: end; justify-content: space-between; gap: .8rem; }
|
||||
.explore-range label { display: grid; gap: .25rem; }
|
||||
.explore-custom-range { display: flex; gap: .5rem; }
|
||||
.metric-interaction-toolbar { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: .45rem; }
|
||||
.metric-series-toggles { display: flex; overflow-x: auto; gap: .3rem; }
|
||||
.metric-series-toggles button { flex: 0 0 auto; min-height: 2rem; padding: .3rem .5rem; font-size: .7rem; }
|
||||
.metric-interactive-plane { position: relative; }
|
||||
.metric-interactive-plane[style]::after { position: absolute; top: 0; bottom: 0; left: var(--metric-crosshair); width: 1px; pointer-events: none; background: var(--text); opacity: .45; content: ""; }
|
||||
.metric-crosshair-output { position: absolute; right: .4rem; bottom: .4rem; padding: .25rem .4rem; border: 1px solid var(--border); border-radius: .25rem; background: var(--surface-lowest); color: var(--text); font: 650 .68rem ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.metric-annotations { display: flex; overflow-x: auto; gap: .4rem; margin: .5rem 0 0; padding: 0; list-style: none; }
|
||||
.metric-annotations li { flex: 0 0 auto; max-width: 18rem; padding: .35rem .5rem; border-inline-start: 2px solid var(--attention); background: var(--surface-lowest); font-size: .68rem; }
|
||||
.metric-annotations li[data-tone="critical"] { border-inline-start-color: var(--critical); }
|
||||
.metric-annotations time, .metric-annotations span { display: block; }
|
||||
.metric-annotations time { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
@media (max-width: 1100px) { .explore-layout { grid-template-columns: 1fr; } .explore-catalog { position: static; max-height: 22rem; } }
|
||||
@media (max-width: 700px) { .explore-range, .metric-interaction-toolbar { align-items: stretch; flex-direction: column; } .explore-custom-range { flex-direction: column; } }
|
||||
|
||||
.incident-alert-list { display: grid; gap: .45rem; margin: .75rem 0; padding: 0; list-style: none; }
|
||||
.incident-alert-list > li { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .7rem; border: 1px solid var(--border-quiet); border-radius: .4rem; background: var(--surface-lowest); }
|
||||
.incident-alert-list strong, .incident-alert-list small { display: block; }
|
||||
.incident-alert-list small { margin-top: .25rem; color: var(--muted); }
|
||||
.incident-alert-action { display: flex; flex: 0 0 auto; align-items: center; gap: .5rem; }
|
||||
.incident-read-only-boundary { padding-inline-start: .65rem; border-inline-start: 2px solid var(--attention); }
|
||||
.settings-capabilities { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1rem; }
|
||||
.settings-capability-card .compact-list li { display: grid; gap: .2rem; }
|
||||
.settings-capability-card .compact-list span { color: var(--muted); }
|
||||
.settings-capability-list { display: grid; gap: 1px; background: var(--border-quiet); }
|
||||
.settings-capability-list > div { display: flex; justify-content: space-between; gap: .75rem; padding: .65rem; background: var(--surface-lowest); }
|
||||
.settings-capability-list dd { margin: 0; font-weight: 700; }
|
||||
@media (max-width: 900px) { .settings-capabilities { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 700px) { .incident-alert-list > li, .incident-alert-action { align-items: stretch; flex-direction: column; } .incident-alert-list button, .incident-follow-up button, .incident-notes-panel button, .settings-capability-card .button { min-height: 44px; width: 100%; } }
|
||||
|
||||
.event-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1rem; }
|
||||
.event-summary h2, .event-summary-action strong { display: block; margin: .3rem 0; color: var(--text); font-size: clamp(1.35rem, 2vw, 2rem); }
|
||||
.event-summary-action { width: 100%; border-color: color-mix(in srgb, var(--critical) 45%, var(--border)); color: inherit; text-align: left; cursor: pointer; }
|
||||
@@ -187,7 +247,7 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
|
||||
.metric-query-status { max-width: 28rem; color: var(--muted); font-size: .8rem; }
|
||||
.dashboard-controls input { min-width: 15rem; }
|
||||
.view-mode-note { margin-left: auto; color: var(--muted); font-size: .78rem; }
|
||||
.dashboard-grid { display: grid; grid-template-columns: repeat(18, minmax(0, 1fr)); gap: 1rem; align-items: stretch; }
|
||||
.dashboard-grid { display: grid; grid-template-columns: repeat(18, minmax(0, 1fr)); grid-auto-rows: 3rem; gap: 1rem; align-items: stretch; }
|
||||
.dashboard-grid > * { grid-column: span var(--widget-span, 6); min-width: 0; }
|
||||
.widget-card { min-height: 12rem; padding: 1.25rem; border: 1px solid var(--border); border-radius: .8rem; background: var(--surface); box-shadow: 0 1rem 3rem #05080d33; }
|
||||
.widget-card--error { border-color: var(--unknown); }
|
||||
@@ -219,7 +279,7 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
|
||||
.dashboard-view-header { gap: 1rem; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-grid { grid-template-columns: 1fr; grid-auto-rows: auto; }
|
||||
.dashboard-grid > * { grid-column: span 1; }
|
||||
.dashboard-view-header { flex-direction: column; }
|
||||
.dashboard-view-meta { justify-content: flex-start; }
|
||||
@@ -244,12 +304,13 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
|
||||
.editor-toolbar .button { margin-top: 0; }
|
||||
.editor-message { margin: 0; color: var(--unknown); font-size: .85rem; }
|
||||
.editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 16rem; gap: 1rem; align-items: start; }
|
||||
.dashboard-grid--edit { min-height: 12rem; padding: .75rem; border: 1px dashed var(--accent); border-radius: .8rem; background: #0e1622; }
|
||||
.dashboard-grid--edit { min-height: 12rem; grid-auto-rows: 3rem; padding: .75rem; border: 1px dashed var(--accent); border-radius: .8rem; background: #0e1622; }
|
||||
.dashboard-grid--edit .editor-widget { height: 100%; min-height: 0; overflow: auto; }
|
||||
.dashboard-grid--edit .editor-widget { cursor: grab; user-select: none; }
|
||||
.dashboard-grid--edit .editor-widget:active { cursor: grabbing; }
|
||||
.editor-widget--selected { border-color: var(--accent); box-shadow: 0 0 0 2px #83b8ff44; }
|
||||
.editor-widget--locked { cursor: default !important; border-color: var(--unknown); }
|
||||
.editor-widget--hidden { opacity: .55; }
|
||||
.editor-widget--hidden { border-style: dashed; background: color-mix(in srgb, var(--surface) 82%, var(--surface-lowest)); }
|
||||
.editor-lock-state { color: var(--muted); font-size: .72rem; }
|
||||
.editor-widget-actions { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: 1rem; }
|
||||
.editor-widget-actions button { padding: .3rem .45rem; border: 1px solid var(--border); border-radius: .35rem; background: var(--surface-raised); color: var(--text); font-size: .72rem; }
|
||||
@@ -270,6 +331,10 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
|
||||
.config-section input:focus-visible, .config-section select:focus-visible, .config-section textarea:focus-visible, .config-preview select:focus-visible { border-color: var(--accent); outline: 2px solid #83b8ff66; outline-offset: 1px; }
|
||||
.config-section [aria-invalid="true"] { border-color: var(--unknown); }
|
||||
.config-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; }
|
||||
.config-thresholds { display: grid; gap: .55rem; }
|
||||
.config-thresholds > span { color: var(--muted); font-size: .78rem; font-weight: 700; }
|
||||
.config-threshold-row { display: grid; grid-template-columns: minmax(4.5rem, .7fr) minmax(6rem, .8fr) minmax(7rem, 1fr) auto; gap: .45rem; align-items: end; }
|
||||
.config-threshold-row .button, .config-thresholds > .button { min-height: 2.4rem; margin-top: 0; }
|
||||
.checkbox-field { display: flex !important; align-items: center; gap: .45rem !important; min-height: 2.3rem; }
|
||||
.checkbox-field input { width: auto; }
|
||||
.field-error { margin: -.3rem 0 0; color: var(--unknown); font-size: .78rem; }
|
||||
@@ -281,9 +346,9 @@ h2 { margin-bottom: .7rem; font-size: 1.2rem; line-height: 1.3; }
|
||||
.preview-state--empty { border-color: var(--unknown); }
|
||||
.preview-state--error { border-color: var(--critical); }
|
||||
.preview-state--stale { border-color: var(--attention); }
|
||||
@media (max-width: 700px) { .config-fields { grid-template-columns: 1fr; } .editor-config-drawer { margin-top: 0; } }
|
||||
@media (max-width: 700px) { .config-fields, .config-threshold-row { grid-template-columns: 1fr; } .editor-config-drawer { margin-top: 0; } }
|
||||
|
||||
.dashboard-grid--edit[data-viewport="mobile"] { grid-template-columns: 1fr; }
|
||||
.dashboard-grid--edit[data-viewport="mobile"] { grid-template-columns: 1fr; grid-auto-rows: auto; }
|
||||
.dashboard-grid--edit[data-viewport="mobile"] > * { grid-column: span 1; }
|
||||
.dashboard-grid--edit[data-viewport="tablet"] { grid-template-columns: repeat(8, minmax(0, 1fr)); }
|
||||
.dashboard-grid--edit[data-viewport="wallboard"] { grid-template-columns: repeat(24, minmax(0, 1fr)); }
|
||||
@@ -589,14 +654,17 @@ a.topology-node:hover, a.topology-node:focus-visible { border-color: var(--accen
|
||||
.alert-operation-summary .alert-summary-critical { border-color: color-mix(in srgb, var(--critical) 42%, var(--border)); }
|
||||
.alert-operation-summary .alert-summary-critical strong { color: var(--critical); }
|
||||
.alert-operation-list-items { display: grid; margin: 1rem 0 0; padding: 0; list-style: none; }
|
||||
.alert-operation-list-items li { display: grid; grid-template-columns: minmax(7.5rem, auto) minmax(0, 1fr) auto; align-items: center; gap: .8rem; min-height: 4.1rem; padding: .65rem 0; border-top: 1px solid var(--border); }
|
||||
.alert-operation-list-items li { display: grid; grid-template-areas: "severity detail action"; grid-template-columns: minmax(7.5rem, auto) minmax(0, 1fr) auto; align-items: center; gap: .8rem; min-height: 4.1rem; padding: .65rem 0; border-top: 1px solid var(--border); }
|
||||
.alert-operation-list-items li > .status-badge { grid-area: severity; }
|
||||
.alert-operation-button { grid-area: detail; }
|
||||
.alert-operation-action { grid-area: action; }
|
||||
.alert-operation-button { display: grid; gap: .25rem; min-width: 0; padding: .55rem 0; border: 0; background: transparent; color: var(--text); text-align: left; }
|
||||
.alert-operation-button small { color: var(--muted); }
|
||||
.alert-operation-action { min-height: 44px; margin: 0; }
|
||||
.alert-detail { margin-top: 1rem; padding: 1rem; border: 1px solid var(--border); border-radius: .55rem; }
|
||||
.alert-detail h3 { margin: 0 0 .35rem; }
|
||||
.alert-detail p { margin: 0 0 .35rem; color: var(--muted); }
|
||||
@media (max-width: 700px) { .alert-section-nav, .alert-operation-summary { grid-template-columns: 1fr; } .alert-section-nav { position: static; } .alert-operation-summary { gap: .65rem; } .alert-operation-summary button { min-height: 5.5rem; } .alert-operation-list-items li { grid-template-columns: minmax(6.25rem, auto) minmax(0, 1fr); } .alert-operation-action { grid-column: 2; justify-self: start; } }
|
||||
@media (max-width: 700px) { .alert-section-nav, .alert-operation-summary { grid-template-columns: 1fr; } .alert-section-nav { position: static; } .alert-operation-summary { gap: .65rem; } .alert-operation-summary button { min-height: 5.5rem; } .alert-operation-list-items li { display: flex; align-items: stretch; flex-direction: column; } .alert-operation-button, .alert-operation-action { position: relative; width: 100%; } .alert-operation-button { z-index: 1; } .alert-operation-action { z-index: 2; align-self: stretch; } }
|
||||
/* M8-10 incident list, detail and timeline */
|
||||
.incident-list-panel, .incident-timeline-panel, .incident-notes-panel, .incident-workflow-placeholder { max-width: 72rem; margin-top: 1rem; }
|
||||
.incident-list { display: grid; gap: .25rem; margin: 1.5rem 0 0; padding: 0; list-style: none; }
|
||||
@@ -644,7 +712,7 @@ a.topology-node:hover, a.topology-node:focus-visible { border-color: var(--accen
|
||||
.onboarding-summary div { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .8rem 0; border-top: 1px solid var(--border); }
|
||||
.onboarding-summary dt { color: var(--muted); }
|
||||
.onboarding-summary dd { margin: 0; font-weight: 750; text-align: right; }
|
||||
.onboarding-complete .button { min-height: 44px; }
|
||||
.onboarding-complete .button { position: relative; z-index: 1; min-height: 44px; }
|
||||
.setting-row small { display: block; margin-top: .25rem; color: var(--muted); }
|
||||
@media (max-width: 800px) { .onboarding-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 560px) { .onboarding-capabilities li { align-items: flex-start; flex-direction: column; gap: .45rem; } }
|
||||
@@ -1174,8 +1242,8 @@ button, input, select, textarea { font-family: inherit; }
|
||||
.brand-copy { position: static; width: auto; height: auto; overflow: visible; clip: auto; white-space: normal; }
|
||||
.brand small { display: none; }
|
||||
.mobile-navigation { background: rgb(8 13 21 / .98); box-shadow: 0 -.5rem 2rem #02050aaa; }
|
||||
.mobile-navigation { grid-template-columns: minmax(0, 4fr) minmax(3.75rem, 1fr); }
|
||||
.mobile-primary-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.mobile-navigation { grid-template-columns: minmax(0, 1fr) 3.25rem; }
|
||||
.mobile-primary-list { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.mobile-primary-list .nav-link { width: 100%; min-height: 52px; }
|
||||
.mobile-primary-list .nav-link > span:last-child { position: static; display: block; width: auto; max-width: none; padding: 0; border: 0; background: transparent; box-shadow: none; font-size: .75rem; font-weight: 650; }
|
||||
.context-server, .context-divider, .context-product, .context-location > span[aria-hidden="true"] { display: none; }
|
||||
@@ -1184,6 +1252,8 @@ button, input, select, textarea { font-family: inherit; }
|
||||
.context-live { font-size: 0; }
|
||||
.context-live span { font-size: .72rem; }
|
||||
.content { padding: 1rem 1rem calc(6rem + env(safe-area-inset-bottom)); }
|
||||
html { scroll-padding-block: calc(var(--command-height) + 1rem) calc(6rem + env(safe-area-inset-bottom)); }
|
||||
.content :is(button, a, input, select, textarea) { scroll-margin-block: calc(var(--command-height) + 1rem) calc(6rem + env(safe-area-inset-bottom)); }
|
||||
.instrument-band { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.instrument-cell { border-top: 1px solid var(--border-quiet); }
|
||||
.instrument-cell:nth-child(-n + 2) { border-top: 0; }
|
||||
@@ -1424,6 +1494,8 @@ button, input, select, textarea { font-family: inherit; }
|
||||
.incident-command-strip > span:first-child { border-inline-start: 0; }
|
||||
.incident-command-strip small { display: block; margin-bottom: .3rem; color: var(--muted); font-size: .65rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.incident-command-strip strong { display: block; overflow-wrap: anywhere; font: 650 .85rem ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
|
||||
.incident-command-actions { display: flex; grid-column: 1 / -1; flex-wrap: wrap; gap: .5rem; padding: .65rem .75rem; border-top: 1px solid var(--border-quiet); }
|
||||
.incident-command-actions .button { min-height: 2.75rem; }
|
||||
.incident-rationale { border-inline-start: 2px solid var(--attention); }
|
||||
.incident-facts { gap: 0; overflow: hidden; border: 1px solid var(--border-quiet); border-radius: .4rem; }
|
||||
.incident-facts > div { padding: .7rem; border-inline-start: 1px solid var(--border-quiet); }
|
||||
@@ -1464,7 +1536,7 @@ button, input, select, textarea { font-family: inherit; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.incident-page-intro { padding-bottom: .6rem; }
|
||||
.incident-command-strip { position: sticky; top: 2.9rem; z-index: 7; grid-template-columns: repeat(2, minmax(0, 1fr)); box-shadow: 0 .75rem 1.5rem #02050a99; }
|
||||
.incident-command-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.incident-command-strip > span { min-height: 3.8rem; padding: .65rem .75rem; border-top: 1px solid var(--border-quiet); }
|
||||
.incident-command-strip > span:nth-child(-n + 2) { border-top: 0; }
|
||||
.incident-command-strip > span:nth-child(odd) { border-inline-start: 0; }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { copy } from './copy';
|
||||
|
||||
export const widgetTypes = ['stat', 'timeseries', 'gauge', 'ranked-list', 'status-grid', 'table', 'heatmap', 'event-timeline', 'storage-map', 'topology', 'service-matrix', 'alert-summary', 'text', 'query-inspector'] as const;
|
||||
export type WidgetType = typeof widgetTypes[number];
|
||||
export type WidgetCatalogItem = { type: WidgetType; title: string; capabilities: string[] };
|
||||
|
||||
const labels: Record<WidgetType, 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,
|
||||
};
|
||||
|
||||
export function isWidgetType(value: unknown): value is WidgetType {
|
||||
return typeof value === 'string' && (widgetTypes as readonly string[]).includes(value);
|
||||
}
|
||||
export function widgetLabel(value: unknown): string { return isWidgetType(value) ? labels[value] : copy.widgets.unknown; }
|
||||
export const fallbackWidgetCatalog: WidgetCatalogItem[] = widgetTypes.map((type) => ({ type, title: labels[type], capabilities: [] }));
|
||||
|
||||
function field(record: Record<string, unknown>, name: string): unknown {
|
||||
const upper = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
return record[name] ?? record[upper] ?? record[name.toUpperCase()];
|
||||
}
|
||||
|
||||
export async function readWidgetCatalog(signal?: AbortSignal): Promise<WidgetCatalogItem[]> {
|
||||
const response = await fetch('/api/v1/widgets/catalog', { signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json() as { items?: unknown[] };
|
||||
const items = (payload.items ?? []).slice(0, widgetTypes.length).flatMap((value): WidgetCatalogItem[] => {
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
const record = value as Record<string, unknown>;
|
||||
const type = field(record, 'type');
|
||||
if (!isWidgetType(type)) return [];
|
||||
const capabilities = field(record, 'capabilities');
|
||||
return [{ type, title: labels[type], capabilities: Array.isArray(capabilities) ? capabilities.filter((item): item is string => typeof item === 'string').slice(0, 20) : [] }];
|
||||
});
|
||||
const byType = new Map(items.map((item) => [item.type, item]));
|
||||
return widgetTypes.map((type) => byType.get(type) ?? fallbackWidgetCatalog.find((item) => item.type === type)!);
|
||||
}
|
||||
Reference in new issue
Block a user