Public source validation / validate (push) Failing after 3m8s
142 lines
12 KiB
TypeScript
142 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
|
import { resolveDashboardScope } from './dashboardScope';
|
|
import { LiveChartAdapter, historicalSamplesFromData, type LiveFreshness } from './liveBuffer';
|
|
import { LiveClient } from './liveClient';
|
|
import { MetricClient, rangeForPreset, type MetricQueryRequest, type MetricRangePreset } from './metricClient';
|
|
import { MetricWidget, StatusGridWidget, type MetricWidgetProps, type StatusGridItem } from './MetricWidgets';
|
|
import { buildStorageNodes, type StorageData } from './StoragePage';
|
|
import { StorageMapWidget } from './StorageVisuals';
|
|
import { aggregateStatus, useSystemStatus } from './systemStatus';
|
|
import { useLiveMetric } from './useLiveMetric';
|
|
import { useMetricQuery } from './useMetricQuery';
|
|
import { formatDateTime } from './locale';
|
|
import { copy } from './copy';
|
|
import { presentEventSummary, presentEventType, presentReason, presentStatus } from './presentation';
|
|
import { wallboardColumns, wallboardPlacement } from './wallboardLayout';
|
|
|
|
type RecordValue = Record<string, unknown>;
|
|
export type RuntimeWidget = { id: string; type: string; title: string; description?: string; data?: RecordValue; visualization?: RecordValue; behavior?: RecordValue; layouts?: RecordValue };
|
|
export type RuntimeState = 'loading' | 'usable' | 'empty' | 'error';
|
|
|
|
const metricClient = new MetricClient();
|
|
const liveClient = new LiveClient();
|
|
|
|
function field<T>(record: RecordValue | undefined, name: string): T | undefined {
|
|
if (!record) return undefined;
|
|
const upper = name.charAt(0).toUpperCase() + name.slice(1);
|
|
return (record[name] ?? record[upper] ?? record[name.toUpperCase()]) as T | undefined;
|
|
}
|
|
|
|
function Badge({ state }: { state: RuntimeState }) {
|
|
const usable = state === 'usable';
|
|
const label = usable ? copy.dashboards.runtime.current : state === 'loading' ? copy.dashboards.runtime.loading : state === 'empty' ? copy.dashboards.runtime.empty : copy.dashboards.runtime.error;
|
|
return <span className={'status-badge status-badge--' + (usable ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{usable ? '✓' : '?'}</span>{label}</span>;
|
|
}
|
|
|
|
type Resource = { state: RuntimeState; items?: StatusGridItem[]; storage?: StorageData; events?: EventItem[]; error?: string };
|
|
type EventItem = { id: string; type: string; severity: string; summary: string; occurredAt: string; sourceId?: string };
|
|
|
|
function useInventoryResource(widget: RuntimeWidget): Resource {
|
|
const [resource, setResource] = useState<Resource>({ state: 'loading' });
|
|
const sourceType = String(field(widget.data, 'sourceType') ?? '');
|
|
const scope = field<RecordValue>(widget.data, 'scope') ?? {};
|
|
const entityType = String(field(scope, 'entityType') ?? '');
|
|
const isApplications = sourceType === 'inventory' && entityType === 'application';
|
|
const isStorage = sourceType === 'inventory' && Array.isArray(field(scope, 'entityTypes'));
|
|
const isEvents = sourceType === 'events';
|
|
const interval = Math.min(300, Math.max(5, Number(field(widget.behavior, 'liveIntervalSeconds') ?? 15))) * 1000;
|
|
|
|
useEffect(() => {
|
|
if (!isApplications && !isStorage && !isEvents) {
|
|
setResource({ state: 'empty' });
|
|
return undefined;
|
|
}
|
|
let active = true;
|
|
let controller: AbortController | null = null;
|
|
const read = async <T,>(url: string, signal: AbortSignal): Promise<T> => {
|
|
const response = await fetch(url, { signal, cache: 'no-store' });
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
return response.json() as Promise<T>;
|
|
};
|
|
const load = async () => {
|
|
controller?.abort();
|
|
const current = new AbortController();
|
|
controller = current;
|
|
try {
|
|
if (isApplications) {
|
|
const value = await read<{ applications?: Array<{ id: string; name: string; status: string; reasons?: Array<{ message?: string }> }> }>('/api/v1/applications', current.signal);
|
|
const items = (value.applications ?? []).slice(0, 50).map((item) => ({ id: item.id, label: item.name, status: presentStatus(item.status), reason: presentReason(item.reasons?.[0]?.message) }));
|
|
if (active) setResource({ state: items.length ? 'usable' : 'empty', items });
|
|
} else if (isStorage) {
|
|
const [array, disks, pools] = await Promise.all([
|
|
read<StorageData['array']>('/api/v1/array', current.signal),
|
|
read<StorageData['disks']>('/api/v1/disks?limit=100', current.signal),
|
|
read<StorageData['pools']>('/api/v1/pools?limit=100', current.signal),
|
|
]);
|
|
const storage = { array, disks, pools };
|
|
if (active) setResource({ state: buildStorageNodes(storage).length ? 'usable' : 'empty', storage });
|
|
} else {
|
|
const limit = Math.min(100, Math.max(1, Number(field(widget.data, 'limit') ?? 100)));
|
|
const value = await read<{ items?: EventItem[] }>('/api/v1/events?limit=' + limit, current.signal);
|
|
const events = (value.items ?? []).slice(0, limit);
|
|
if (active) setResource({ state: events.length ? 'usable' : 'empty', events });
|
|
}
|
|
} catch (error: unknown) {
|
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
|
if (active) setResource({ state: 'error', error: error instanceof Error ? error.message : 'bron niet beschikbaar' });
|
|
}
|
|
};
|
|
void load();
|
|
const timer = window.setInterval(load, interval);
|
|
return () => { active = false; controller?.abort(); window.clearInterval(timer); };
|
|
}, [isApplications, isStorage, isEvents, interval, widget.data]);
|
|
return resource;
|
|
}
|
|
|
|
export function DashboardRuntimeWidget({ widget, viewport, document, timeRange, onState, onFilter }: { widget: RuntimeWidget; viewport: 'desktop' | 'tablet' | 'mobile' | 'wallboard'; document: RecordValue; timeRange: string; onState: (id: string, state: RuntimeState) => void; onFilter: () => void }) {
|
|
const sourceType = String(field(widget.data, 'sourceType') ?? '');
|
|
const semantic = sourceType === 'semantic-metric';
|
|
const system = useSystemStatus();
|
|
const inventory = useInventoryResource(widget);
|
|
const range = useMemo(() => rangeForPreset(timeRange as MetricRangePreset), [timeRange]);
|
|
const request = useMemo<MetricQueryRequest | null>(() => {
|
|
if (!semantic) return null;
|
|
const metric = String(field(widget.data, 'metric') ?? '');
|
|
if (!metric) return null;
|
|
const scope = resolveDashboardScope(field<RecordValue>(widget.data, 'scope') ?? {}, field<unknown[]>(document, 'variables') ?? []);
|
|
return { metric, scope, range, aggregation: String(field(widget.data, 'aggregation') ?? 'avg') };
|
|
}, [semantic, widget.data, document, range]);
|
|
const metricState = useMetricQuery(metricClient, request);
|
|
const historicalSamples = useMemo(() => metricState.status === 'success' ? historicalSamplesFromData(metricState.response?.data) : [], [metricState.status, metricState.response?.data]);
|
|
const freshness: LiveFreshness = metricState.response?.freshness ?? 'unavailable';
|
|
const historicalSeries = useMemo(() => { const adapter = new LiveChartAdapter(4000); adapter.append(historicalSamples.map((sample) => ({ ...sample, freshness }))); return adapter.snapshot(); }, [historicalSamples, freshness]);
|
|
const live = useLiveMetric(liveClient, semantic && timeRange === 'live' ? request : null, historicalSamples);
|
|
const systemWidget = sourceType === 'inventory' && String(field(field<RecordValue>(widget.data, 'scope'), 'entityType') ?? '') === 'server';
|
|
const systemStatus = aggregateStatus(system);
|
|
const metricRuntime: RuntimeState = metricState.status === 'error' || (timeRange === 'live' && live.state === 'error') ? 'error' : (timeRange === 'live' ? live.series.length : historicalSeries.length) > 0 ? 'usable' : metricState.status === 'loading' || (timeRange === 'live' && live.state === 'connecting') ? 'loading' : 'empty';
|
|
const runtime = semantic ? metricRuntime : systemWidget ? (system.state === 'loading' ? 'loading' : system.state === 'ready' ? 'usable' : 'error') : inventory.state;
|
|
useEffect(() => { onState(widget.id, runtime); }, [onState, runtime, widget.id]);
|
|
|
|
const activeLayout = field<RecordValue>(widget.layouts, viewport) ?? field<RecordValue>(widget.layouts, 'desktop') ?? {};
|
|
const columns = viewport === 'wallboard' ? wallboardColumns : viewport === 'tablet' ? 8 : viewport === 'mobile' ? 1 : 18;
|
|
const width = viewport === 'mobile' ? 1 : Math.min(columns, Math.max(1, Number(field(activeLayout, 'w') ?? 6)));
|
|
const placement = wallboardPlacement(activeLayout);
|
|
const layoutStyle = viewport === 'wallboard' ? { '--widget-span': String(placement.columnSpan), gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`, gridRow: `${placement.rowStart} / span ${placement.rowSpan}` } as CSSProperties : { '--widget-span': String(width) } as CSSProperties;
|
|
let content;
|
|
if (semantic) {
|
|
const metric: MetricWidgetProps = { kind: widget.type as MetricWidgetProps['kind'], series: timeRange === 'live' ? live.series : historicalSeries, freshness, expectedStepSeconds: request?.range.stepSeconds ?? 15, availability: timeRange === 'live' ? live.state : metricState.status, error: timeRange === 'live' ? live.error : metricState.error?.actionable ?? null, visualization: widget.visualization, metricName: request?.metric, sourceObservedAt: metricState.response?.sourceObservedAt, receivedAt: metricState.response?.receivedAt, warnings: metricState.response?.warnings, inspector: metricState.response?.inspector };
|
|
content = <MetricWidget {...metric} />;
|
|
} else if (systemWidget && system.state === 'ready') {
|
|
content = <div className="dashboard-runtime-stat"><strong>{systemStatus.label}</strong><span>{systemStatus.detail}</span><small>{system.status?.components.length ?? 0} {copy.dashboards.runtime.checkedComponents}</small></div>;
|
|
} else if (inventory.items?.length) {
|
|
content = <StatusGridWidget items={inventory.items} onSelect={onFilter} />;
|
|
} else if (inventory.storage) {
|
|
content = <StorageMapWidget nodes={buildStorageNodes(inventory.storage)} title={widget.title} description={widget.description ?? ''} idPrefix={'dashboard-storage-' + widget.id} />;
|
|
} else if (inventory.events?.length) {
|
|
content = <ol className="dashboard-event-list">{inventory.events.slice(0, viewport === 'mobile' ? 6 : 12).map((event) => <li key={event.id}><span className={'event-severity event-severity--' + event.severity} aria-hidden="true" /><span><strong>{presentEventSummary(event.type, event.summary)}</strong><small>{presentEventType(event.type)} · {formatDateTime(event.occurredAt)}</small></span></li>)}</ol>;
|
|
} else {
|
|
content = <div className={'dashboard-runtime-state dashboard-runtime-state--' + runtime} role={runtime === 'error' ? 'alert' : 'status'}><strong>{runtime === 'error' ? copy.dashboards.runtime.sourceUnavailable : runtime === 'loading' ? copy.dashboards.runtime.telemetryLoading : copy.dashboards.runtime.noCurrentData}</strong><span>{runtime === 'error' ? copy.dashboards.runtime.unavailableDetail : copy.dashboards.runtime.retryDetail}</span></div>;
|
|
}
|
|
return <article className="widget-card widget-card--runtime" data-runtime-state={runtime} data-viewport={viewport} style={layoutStyle} aria-labelledby={'widget-' + widget.id}><div className="widget-card-heading"><div><p className="card-kicker">{sourceType === 'semantic-metric' ? copy.dashboards.runtime.semanticMetric : sourceType === 'events' ? copy.dashboards.runtime.events : copy.dashboards.runtime.inventory}</p><h3 id={'widget-' + widget.id}>{widget.title}</h3></div><Badge state={runtime} /></div>{widget.description && <p className="widget-description">{widget.description}</p>}{content}</article>;
|
|
}
|