221 lines
21 KiB
TypeScript
221 lines
21 KiB
TypeScript
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, 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';
|
|
import { useLiveMetric } from './useLiveMetric';
|
|
import { useMetricQuery } from './useMetricQuery';
|
|
import { formatDateTime } from './locale';
|
|
import { copy } from './copy';
|
|
import { presentEventSummary, presentEventType, presentReason, presentStatus } from './presentation';
|
|
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' | '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 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>;
|
|
}
|
|
|
|
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 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 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 (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 load = async () => {
|
|
controller?.abort();
|
|
const current = new AbortController();
|
|
controller = current;
|
|
try {
|
|
if (sourceType === 'inventory' && Array.isArray(entityTypes)) {
|
|
const [array, disks, pools] = await Promise.all([
|
|
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 });
|
|
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' });
|
|
}
|
|
};
|
|
void load();
|
|
const timer = window.setInterval(load, interval);
|
|
return () => { active = false; controller?.abort(); window.clearInterval(timer); };
|
|
}, [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 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 || !metricTypes.has(widget.type)) return null;
|
|
const metric = String(field(widget.data, 'metric') ?? '');
|
|
if (!metric) return null;
|
|
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';
|
|
const historicalSeries = useMemo(() => { const adapter = new LiveChartAdapter(4000); adapter.append(historicalSamples.map((sample) => ({ ...sample, freshness }))); return adapter.snapshot(); }, [historicalSamples, freshness]);
|
|
const live = useLiveMetric(liveClient, semantic && timeRange === 'live' ? request : null, historicalSamples);
|
|
const systemWidget = sourceType === 'inventory' && String(field(field<RecordValue>(widget.data, 'scope'), 'entityType') ?? '') === 'server';
|
|
const systemStatus = aggregateStatus(system);
|
|
const metricRuntime: RuntimeState = !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 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 : 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>;
|
|
}
|