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; 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(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 {label}; } 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({ state: 'loading' }); const sourceType = String(field(widget.data, 'sourceType') ?? ''); const scope = field(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 (url: string, signal: AbortSignal): Promise => { const response = await fetch(url, { signal, cache: 'no-store' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json() as Promise; }; 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('/api/v1/array', current.signal), read('/api/v1/disks?limit=100', current.signal), read('/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(() => { if (!semantic) return null; const metric = String(field(widget.data, 'metric') ?? ''); if (!metric) return null; const scope = resolveDashboardScope(field(widget.data, 'scope') ?? {}, field(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(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(widget.layouts, viewport) ?? field(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 = ; } else if (systemWidget && system.state === 'ready') { content =
{systemStatus.label}{systemStatus.detail}{system.status?.components.length ?? 0} {copy.dashboards.runtime.checkedComponents}
; } else if (inventory.items?.length) { content = ; } else if (inventory.storage) { content = ; } else if (inventory.events?.length) { content =
    {inventory.events.slice(0, viewport === 'mobile' ? 6 : 12).map((event) =>
  1. )}
; } else { content =
{runtime === 'error' ? copy.dashboards.runtime.sourceUnavailable : runtime === 'loading' ? copy.dashboards.runtime.telemetryLoading : copy.dashboards.runtime.noCurrentData}{runtime === 'error' ? copy.dashboards.runtime.unavailableDetail : copy.dashboards.runtime.retryDetail}
; } return

{sourceType === 'semantic-metric' ? copy.dashboards.runtime.semanticMetric : sourceType === 'events' ? copy.dashboards.runtime.events : copy.dashboards.runtime.inventory}

{widget.title}

{widget.description &&

{widget.description}

}{content}
; }