Public source validation / validate (push) Failing after 3m8s
808 lines
79 KiB
TypeScript
808 lines
79 KiB
TypeScript
import { Component, Suspense, lazy, type CSSProperties, type ErrorInfo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { MetricWidget, metricStatus, RankedListWidget, StatusGridWidget, type MetricWidgetProps, type RankedListItem, type StatusGridItem } from './MetricWidgets';
|
||
import { DashboardRuntimeWidget, type RuntimeState } from './DashboardRuntimeWidget';
|
||
import { copy } from './copy';
|
||
import { routeFromLocation, type RoutePath } from './routes';
|
||
import { HostPage } from './HostPage';
|
||
import { ArrayPage } from './ArrayPage';
|
||
import { DiskDetailPage, DiskPage } from './DiskPage';
|
||
import { PoolPage } from './PoolPage';
|
||
import { SharePage } from './SharePage';
|
||
import { StoragePage } from './StoragePage';
|
||
import { CapacityPage } from './CapacityPage';
|
||
import { StorageMapWidget, TemperatureHeatmap, type HeatmapPoint, type StorageMapNode } from './StorageVisuals';
|
||
import { ContainerDetailPage, ContainerPage } from './ContainerPage';
|
||
import { ApplicationPage } from './ApplicationPage';
|
||
import { ServicePage } from './ServicePage';
|
||
import type { TopologyData } from './TopologyPage';
|
||
import { NetworkPage, NetworkHealthWidget, type NetworkData } from './NetworkPage';
|
||
import { IncidentPage } from './IncidentPage';
|
||
import { OnboardingPage } from './OnboardingPage';
|
||
import { SystemStatusPage } from './SystemStatusPage';
|
||
import { InventoryPage } from './InventoryPage';
|
||
import { EventsPage } from './EventsPage';
|
||
import { NotFoundPage } from './NotFoundPage';
|
||
import { formatDateTime } from './locale';
|
||
import { installSessionWatcher, onUnauthenticated } from './auth';
|
||
import { AuthNoticeBanner, SignInButton } from './SignIn';
|
||
import { aggregateStatus, refreshSystemStatus, statusProblems, useSystemStatus } from './systemStatus';
|
||
import { operationalStorageState, presentComponent, presentReason, presentStatus } from './presentation';
|
||
import { wallboardColumns, wallboardPlacement, wallboardSlideIndex } from './wallboardLayout';
|
||
import { OperationalSignalPath, type OperationalSignalStage } from './OperationalSignalPath';
|
||
import { containerSignalTone, signalToneFromState, signalToneRank, sourceSignalTone, worstSignalTone, type SignalTone } from './overviewSignals';
|
||
|
||
// Heavy, rarely-used surfaces. The wallboard and mobile personas never execute
|
||
// the editor stack, the alert-rule editor, the topology graph or the process
|
||
// explorer, so those stay out of the initial chunk (FRONTEND_STANDARDS "Charts").
|
||
const DashboardEditor = lazy(() => import('./DashboardEditor').then((module) => ({ default: module.DashboardEditor })));
|
||
const AlertRulesPage = lazy(() => import('./AlertRulesPage').then((module) => ({ default: module.AlertRulesPage })));
|
||
const TopologyPage = lazy(() => import('./TopologyPage').then((module) => ({ default: module.TopologyPage })));
|
||
const TopologyWidget = lazy(() => import('./TopologyPage').then((module) => ({ default: module.TopologyWidget })));
|
||
const ProcessPage = lazy(() => import('./ProcessPage').then((module) => ({ default: module.ProcessPage })));
|
||
|
||
const navigation = [
|
||
{ path: '/', label: copy.navigation.overview, icon: '⌂', group: copy.navigation.groups.command },
|
||
{ path: '/dashboards', label: copy.navigation.dashboards, icon: '▦', group: copy.navigation.groups.command },
|
||
{ path: '/host', label: copy.navigation.host, icon: '▣', group: copy.navigation.groups.infrastructure },
|
||
{ path: '/array', label: copy.navigation.array, icon: '▥', group: copy.navigation.groups.infrastructure },
|
||
{ path: '/disks', label: copy.navigation.disks, icon: '◉', group: copy.navigation.groups.infrastructure },
|
||
{ path: '/pools', label: copy.navigation.pools, icon: '◫', group: copy.navigation.groups.infrastructure },
|
||
{ path: '/shares', label: copy.navigation.shares, icon: '⇄', group: copy.navigation.groups.infrastructure },
|
||
{ path: '/storage', label: copy.navigation.storage, icon: '▤', group: copy.navigation.groups.infrastructure },
|
||
{ path: '/capacity', label: copy.navigation.capacity, icon: '⌁', group: copy.navigation.groups.infrastructure },
|
||
{ path: '/network', label: copy.navigation.network, icon: '⌘', group: copy.navigation.groups.infrastructure },
|
||
{ path: '/processes', label: copy.navigation.processes, icon: '≋', group: copy.navigation.groups.workloads },
|
||
{ path: '/containers', label: copy.navigation.containers, icon: '⬡', group: copy.navigation.groups.workloads },
|
||
{ path: '/applications', label: copy.navigation.applications, icon: '◆', group: copy.navigation.groups.workloads },
|
||
{ path: '/services', label: copy.navigation.services, icon: '◉', group: copy.navigation.groups.services },
|
||
{ path: '/topology', label: copy.navigation.topology, icon: '⌬', group: copy.navigation.groups.services },
|
||
{ path: '/alerts', label: copy.navigation.alerts, icon: '!', group: copy.navigation.groups.response },
|
||
{ path: '/events', label: copy.navigation.events, icon: '≡', group: copy.navigation.groups.response },
|
||
{ path: '/incidents', label: copy.navigation.incidents, icon: '△', group: copy.navigation.groups.response },
|
||
{ path: '/inventory', label: copy.navigation.inventory, icon: '▥', group: copy.navigation.groups.manage },
|
||
{ path: '/wallboard', label: copy.navigation.wallboard, icon: '▰', group: copy.navigation.groups.manage },
|
||
{ path: '/settings', label: copy.navigation.settings, icon: '⚙', group: copy.navigation.groups.manage },
|
||
{ path: '/status', label: copy.navigation.status, icon: '♥', group: copy.navigation.groups.manage },
|
||
{ path: '/onboarding', label: copy.navigation.onboarding, icon: '→', group: copy.navigation.groups.manage },
|
||
] as const satisfies ReadonlyArray<{ path: RoutePath; label: string; icon: string; group: string }>;
|
||
|
||
const mobilePrimaryPaths = new Set<RoutePath>(['/', '/incidents', '/containers', '/storage']);
|
||
const mobilePrimaryNavigation = ['/', '/incidents', '/containers', '/storage'].map((path) => navigation.find((item) => item.path === path)).filter((item): item is NavigationItem => Boolean(item));
|
||
const mobileMoreNavigation = navigation.filter((item) => !mobilePrimaryPaths.has(item.path));
|
||
type NavigationItem = (typeof navigation)[number];
|
||
const navigationGroups = Array.from(new Set(navigation.map((item) => item.group)));
|
||
function NavigationLink({ item, label = item.label }: { item: NavigationItem; label?: string }) {
|
||
const active = routeFromLocation(window.location.pathname) === item.path;
|
||
return <li><a className={active ? 'nav-link nav-link--active' : 'nav-link'} aria-current={active ? 'page' : undefined} href={item.path} title={label} onClick={(event) => { event.preventDefault(); navigate(item.path); }}><span className="nav-icon" aria-hidden="true">{item.icon}</span><span>{label}</span></a></li>;
|
||
}
|
||
function DesktopNavigation({ route }: { route: RoutePath }) {
|
||
const activeGroup = navigation.find((item) => route === item.path || (item.path !== '/' && route.startsWith(item.path + '/')))?.group ?? navigationGroups[0];
|
||
const [openGroups, setOpenGroups] = useState<Set<string>>(() => new Set([navigationGroups[0], activeGroup]));
|
||
useEffect(() => setOpenGroups((current) => current.has(activeGroup) ? current : new Set([...current, activeGroup])), [activeGroup]);
|
||
return <nav className="desktop-navigation" aria-label={copy.navigation.label}>{navigationGroups.map((group) => {
|
||
const items = navigation.filter((item) => item.group === group);
|
||
return <details className="nav-group" key={group} open={openGroups.has(group)} onToggle={(event) => { const open = event.currentTarget.open; setOpenGroups((current) => { if (current.has(group) === open) return current; const next = new Set(current); if (open) next.add(group); else next.delete(group); return next; }); }}><summary><span>{group}</span><span aria-hidden="true">⌄</span></summary><ul className="nav-list">{items.map((item) => <NavigationLink key={item.path} item={item} />)}</ul></details>;
|
||
})}</nav>;
|
||
}
|
||
function navigate(path: string) {
|
||
window.history.pushState({}, '', path);
|
||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||
}
|
||
|
||
function StatusBadge({ label, tone = 'unknown' }: { label: string; tone?: 'unknown' | 'ready' }) {
|
||
return <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : '?'}</span>{label}</span>;
|
||
}
|
||
|
||
function PageIntro({ eyebrow, title, intro }: { eyebrow: string; title: string; intro: string }) {
|
||
return <header className="page-intro"><p className="eyebrow">{eyebrow}</p><h1>{title}</h1><p className="intro">{intro}</p></header>;
|
||
}
|
||
|
||
type OverviewSource = { state?: string; freshness?: string; reason?: string };
|
||
type OverviewHost = { identity?: { name?: string }; cpu?: { totalPercent?: number }; memory?: { utilizationPercent?: number }; source?: OverviewSource };
|
||
type OverviewContainer = { id?: string; state?: string; health?: string; intentionalStop?: boolean };
|
||
type OverviewPool = { id: string; name: string; state: string; capacitySeverity?: string; utilizationPercent: number };
|
||
type OverviewService = { id: string; name: string; state: string };
|
||
type OverviewIncident = { id: string; title: string; severity: string; startedAt: string };
|
||
type OverviewContainerSnapshot = { source?: OverviewSource; containers?: OverviewContainer[]; total?: number; nextCursor?: string };
|
||
type OverviewPoolSnapshot = { source?: OverviewSource; pools?: OverviewPool[]; total?: number };
|
||
type OverviewServiceSnapshot = { capabilityState?: string; configurationState?: string; reason?: string; services?: OverviewService[]; total?: number };
|
||
type OverviewResourceState = 'loading' | 'ready' | 'unavailable' | 'unauthorized' | 'forbidden';
|
||
type OverviewResource = 'host' | 'containers' | 'pools' | 'services' | 'incidents';
|
||
type OverviewData = {
|
||
host?: OverviewHost;
|
||
containers: OverviewContainer[];
|
||
containerSource?: OverviewSource;
|
||
containerTotal: number;
|
||
containersPartial: boolean;
|
||
pools: OverviewPool[];
|
||
poolSource?: OverviewSource;
|
||
poolTotal: number;
|
||
poolsPartial: boolean;
|
||
services: OverviewService[];
|
||
serviceTotal: number;
|
||
servicesPartial: boolean;
|
||
serviceCapability?: string;
|
||
serviceConfiguration?: string;
|
||
incidents: OverviewIncident[];
|
||
incidentsPartial: boolean;
|
||
resources: Record<OverviewResource, OverviewResourceState>;
|
||
};
|
||
|
||
const loadingOverviewResources: Record<OverviewResource, OverviewResourceState> = {
|
||
host: 'loading', containers: 'loading', pools: 'loading', services: 'loading', incidents: 'loading',
|
||
};
|
||
|
||
const overviewInitialData: OverviewData = {
|
||
containers: [], containerTotal: 0, containersPartial: false,
|
||
pools: [], poolTotal: 0, poolsPartial: false,
|
||
services: [], serviceTotal: 0, servicesPartial: false,
|
||
incidents: [], incidentsPartial: false,
|
||
resources: loadingOverviewResources,
|
||
};
|
||
|
||
const OVERVIEW_REFRESH_MS = 30_000;
|
||
const OVERVIEW_REQUEST_TIMEOUT_MS = 10_000;
|
||
const CONTAINER_PAGE_LIMIT = 100;
|
||
const CONTAINER_MAX_PAGES = 3;
|
||
|
||
type ReadResult<T> = { state: OverviewResourceState; data?: T };
|
||
|
||
function collectionExtent(reported: number | undefined, count: number): { total: number; partial: boolean } {
|
||
const valid = Number.isInteger(reported) && (reported ?? -1) >= count;
|
||
const total = valid ? reported as number : count;
|
||
return { total, partial: !valid || total > count };
|
||
}
|
||
|
||
function useOverviewData(): { data: OverviewData; refresh: () => void } {
|
||
const [data, setData] = useState<OverviewData>(overviewInitialData);
|
||
const [generation, setGeneration] = useState(0);
|
||
const refresh = useCallback(() => {
|
||
setData((current) => ({ ...current, resources: { ...loadingOverviewResources } }));
|
||
setGeneration((current) => current + 1);
|
||
}, []);
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
const read = async <T,>(url: string): Promise<ReadResult<T>> => {
|
||
const requestController = new AbortController();
|
||
const abortRequest = () => requestController.abort();
|
||
if (controller.signal.aborted) abortRequest();
|
||
else controller.signal.addEventListener('abort', abortRequest, { once: true });
|
||
const timeout = window.setTimeout(abortRequest, OVERVIEW_REQUEST_TIMEOUT_MS);
|
||
try {
|
||
const response = await fetch(url, { signal: requestController.signal, cache: 'no-store' });
|
||
if (requestController.signal.aborted) return { state: 'unavailable' };
|
||
if (response.status === 401) return { state: 'unauthorized' };
|
||
if (response.status === 403) return { state: 'forbidden' };
|
||
return response.ok ? { state: 'ready', data: await response.json() as T } : { state: 'unavailable' };
|
||
} catch {
|
||
return { state: 'unavailable' };
|
||
} finally {
|
||
window.clearTimeout(timeout);
|
||
controller.signal.removeEventListener('abort', abortRequest);
|
||
}
|
||
};
|
||
|
||
const readContainers = async (): Promise<ReadResult<{ source?: OverviewSource; items: OverviewContainer[]; total: number; partial: boolean }>> => {
|
||
const items: OverviewContainer[] = [];
|
||
const seen = new Set<string>();
|
||
let source: OverviewSource | undefined;
|
||
let expectedTotal: number | undefined;
|
||
let after = '';
|
||
let complete = false;
|
||
let inconsistent = false;
|
||
for (let page = 0; page < CONTAINER_MAX_PAGES; page += 1) {
|
||
const params = new URLSearchParams({ limit: String(CONTAINER_PAGE_LIMIT), sort: 'name' });
|
||
if (after) params.set('after', after);
|
||
const result = await read<OverviewContainerSnapshot>('/api/v1/containers?' + params);
|
||
if (result.state !== 'ready' || !result.data) return { state: result.state };
|
||
const pageItems = result.data.containers ?? [];
|
||
const reportedTotal = result.data.total;
|
||
if (!Number.isInteger(reportedTotal) || (reportedTotal ?? -1) < pageItems.length) {
|
||
inconsistent = true;
|
||
} else if (expectedTotal == null) {
|
||
expectedTotal = reportedTotal as number;
|
||
} else {
|
||
if (reportedTotal !== expectedTotal) inconsistent = true;
|
||
expectedTotal = Math.max(expectedTotal, reportedTotal as number);
|
||
}
|
||
if (!source || signalToneRank[sourceSignalTone(result.data.source)] < signalToneRank[sourceSignalTone(source)]) source = result.data.source;
|
||
for (const item of pageItems) {
|
||
const id = item.id?.trim();
|
||
if (!id) {
|
||
inconsistent = true;
|
||
items.push(item);
|
||
} else if (seen.has(id)) {
|
||
inconsistent = true;
|
||
} else {
|
||
seen.add(id);
|
||
items.push(item);
|
||
}
|
||
}
|
||
const next = result.data.nextCursor?.trim() ?? '';
|
||
if (!next) {
|
||
complete = true;
|
||
break;
|
||
}
|
||
if (next === after) {
|
||
inconsistent = true;
|
||
break;
|
||
}
|
||
after = next;
|
||
}
|
||
const total = Math.max(expectedTotal ?? 0, items.length);
|
||
return { state: 'ready', data: { source, items, total, partial: !complete || inconsistent || items.length < total } };
|
||
};
|
||
|
||
void read<OverviewHost>('/api/v1/host').then((result) => {
|
||
if (controller.signal.aborted) return;
|
||
setData((current) => ({ ...current, host: result.data, resources: { ...current.resources, host: result.state } }));
|
||
});
|
||
void readContainers().then((result) => {
|
||
if (controller.signal.aborted) return;
|
||
setData((current) => ({ ...current, containers: result.data?.items ?? [], containerSource: result.data?.source, containerTotal: result.data?.total ?? 0, containersPartial: result.data?.partial ?? false, resources: { ...current.resources, containers: result.state } }));
|
||
});
|
||
void read<OverviewPoolSnapshot>('/api/v1/pools?limit=64').then((result) => {
|
||
if (controller.signal.aborted) return;
|
||
const items = result.data?.pools ?? [];
|
||
const extent = collectionExtent(result.data?.total, items.length);
|
||
setData((current) => ({ ...current, pools: items, poolSource: result.data?.source, poolTotal: extent.total, poolsPartial: result.state === 'ready' && extent.partial, resources: { ...current.resources, pools: result.state } }));
|
||
});
|
||
void read<OverviewServiceSnapshot>('/api/v1/services?limit=100').then((result) => {
|
||
if (controller.signal.aborted) return;
|
||
const items = result.data?.services ?? [];
|
||
const extent = collectionExtent(result.data?.total, items.length);
|
||
setData((current) => ({ ...current, services: items, serviceTotal: extent.total, servicesPartial: result.state === 'ready' && extent.partial, serviceCapability: result.data?.capabilityState, serviceConfiguration: result.data?.configurationState, resources: { ...current.resources, services: result.state } }));
|
||
});
|
||
void read<{ items?: OverviewIncident[] }>('/api/v1/incidents?limit=100&status=open').then((result) => {
|
||
if (controller.signal.aborted) return;
|
||
const items = result.data?.items ?? [];
|
||
setData((current) => ({ ...current, incidents: items, incidentsPartial: result.state === 'ready' && items.length >= 100, resources: { ...current.resources, incidents: result.state } }));
|
||
});
|
||
return () => controller.abort();
|
||
}, [generation]);
|
||
useEffect(() => {
|
||
const timer = window.setInterval(() => {
|
||
if (document.visibilityState === 'visible') setGeneration((current) => current + 1);
|
||
}, OVERVIEW_REFRESH_MS);
|
||
return () => window.clearInterval(timer);
|
||
}, []);
|
||
return { data, refresh };
|
||
}
|
||
|
||
function signalResourceLabel(state: OverviewResourceState, tone: SignalTone): string {
|
||
if (state === 'loading') return copy.overview.signalPathLoading;
|
||
if (state === 'unauthorized') return copy.overview.signalPathUnauthorized;
|
||
if (state === 'forbidden') return copy.overview.signalPathForbidden;
|
||
if (state === 'unavailable') return copy.overview.signalPathUnavailable;
|
||
return presentStatus(tone === 'attention' ? 'attention' : tone);
|
||
}
|
||
|
||
function resourceStateDetail(state: OverviewResourceState): string | undefined {
|
||
if (state === 'loading') return copy.overview.resourceLoadingDetail;
|
||
if (state === 'unauthorized') return copy.overview.resourceUnauthorizedDetail;
|
||
if (state === 'forbidden') return copy.overview.resourceForbiddenDetail;
|
||
if (state === 'unavailable') return copy.overview.resourceUnavailableDetail;
|
||
return undefined;
|
||
}
|
||
|
||
function thresholdTone(values: Array<number | undefined>): SignalTone {
|
||
const usable = values.filter((value): value is number => value != null && Number.isFinite(value));
|
||
if (usable.length !== values.length) return 'unknown';
|
||
if (usable.some((value) => value >= 95)) return 'critical';
|
||
if (usable.some((value) => value >= 85)) return 'attention';
|
||
return 'healthy';
|
||
}
|
||
|
||
function metric(value: number | undefined, suffix = '%'): string {
|
||
return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + suffix;
|
||
}
|
||
|
||
function boundedRatio(known: number, total: number, partial: boolean): string {
|
||
return `${partial ? '≥' : ''}${known}/${total}`;
|
||
}
|
||
|
||
function signalCollectionLabel(state: OverviewResourceState, tone: SignalTone, options: { partial?: boolean; notConfigured?: boolean; emptyLabel?: string } = {}): string {
|
||
if (state !== 'ready') return signalResourceLabel(state, tone);
|
||
if (tone === 'critical' || tone === 'attention' || tone === 'stale') return presentStatus(tone);
|
||
if (options.notConfigured) return copy.overview.signalPathNotConfigured;
|
||
if (options.partial) return copy.overview.signalPathPartial;
|
||
if (options.emptyLabel) return options.emptyLabel;
|
||
return presentStatus(tone);
|
||
}
|
||
|
||
function OverviewPage() {
|
||
const snapshot = useSystemStatus();
|
||
const status = aggregateStatus(snapshot);
|
||
const { data: overview, refresh: refreshOverview } = useOverviewData();
|
||
const resourcesLoading = snapshot.state === 'loading' || Object.values(overview.resources).some((state) => state === 'loading');
|
||
const overviewNeedsAuthentication = snapshot.state === 'unauthorized' || Object.values(overview.resources).some((state) => state === 'unauthorized');
|
||
const systemResourceState: OverviewResourceState = snapshot.state === 'ready' ? 'ready' : snapshot.state === 'loading' ? 'loading' : snapshot.state === 'unauthorized' ? 'unauthorized' : snapshot.state === 'forbidden' ? 'forbidden' : 'unavailable';
|
||
const poolRank: Record<string, number> = { critical: 0, faulted: 0, degraded: 1, attention: 2, unknown: 3 };
|
||
const poolProblems = (overview.resources.pools === 'ready' && sourceSignalTone(overview.poolSource) === 'healthy' ? overview.pools : [])
|
||
.map((pool) => ({ pool, state: operationalStorageState(pool.state, pool.capacitySeverity) }))
|
||
.filter(({ state }) => state !== 'healthy' && state !== 'normal')
|
||
.sort((a, b) => (poolRank[a.state] ?? 4) - (poolRank[b.state] ?? 4))
|
||
.map(({ pool, state }) => ({ id: 'pool:' + pool.id, label: `${pool.name}: ${presentStatus(state)}`, reason: state === 'critical' || state === 'faulted' ? copy.overview.poolCapacityCritical : state === 'attention' ? copy.overview.poolCapacityAttention : presentReason('source_health_unknown') }));
|
||
const resourceLabels: Record<OverviewResource, string> = { host: copy.navigation.host, containers: copy.overview.signalWorkloads, pools: copy.overview.signalStorage, services: copy.navigation.services, incidents: copy.overview.signalIncidents };
|
||
const resourceProblems = (Object.keys(overview.resources) as OverviewResource[]).flatMap((resource) => {
|
||
const state = overview.resources[resource];
|
||
if (state !== 'unavailable' && state !== 'unauthorized' && state !== 'forbidden') return [];
|
||
return [{ id: `resource:${resource}`, label: `${resourceLabels[resource]}: ${signalResourceLabel(state, 'unknown')}`, reason: resourceStateDetail(state) ?? copy.overview.resourceUnavailableDetail }];
|
||
});
|
||
const systemProblems = systemResourceState === 'ready' || systemResourceState === 'loading' ? [] : [{ id: 'resource:system-status', label: `${copy.overview.sources}: ${signalResourceLabel(systemResourceState, 'unknown')}`, reason: resourceStateDetail(systemResourceState) ?? copy.overview.resourceUnavailableDetail }];
|
||
const partialProblems: Array<{ id: string; label: string; reason: string }> = [];
|
||
if (overview.containersPartial) partialProblems.push({ id: 'partial:containers', label: `${copy.overview.signalWorkloads}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
|
||
if (overview.poolsPartial) partialProblems.push({ id: 'partial:pools', label: `${copy.overview.signalStorage}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
|
||
if (overview.servicesPartial) partialProblems.push({ id: 'partial:services', label: `${copy.navigation.services}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
|
||
if (overview.incidentsPartial) partialProblems.push({ id: 'partial:incidents', label: `${copy.overview.signalIncidents}: ${copy.overview.signalPathPartial}`, reason: copy.overview.resourcePartialDetail });
|
||
const problems = [...systemProblems, ...resourceProblems, ...partialProblems, ...poolProblems, ...statusProblems(snapshot.status)].slice(0, 10);
|
||
const poolUsage = overview.pools.length ? Math.max(...overview.pools.map((pool) => pool.utilizationPercent)) : undefined;
|
||
const runningContainers = overview.containers.filter((item) => item.state?.toLowerCase() === 'running').length;
|
||
const availableServices = overview.services.filter((item) => item.state?.toLowerCase() === 'up').length;
|
||
const sourceLags = snapshot.status?.sourceLag ?? [];
|
||
const sourceTones = sourceLags.map((lag) => signalToneFromState(lag.state));
|
||
const healthySources = sourceTones.filter((tone) => tone === 'healthy').length;
|
||
const sourceTone = snapshot.state !== 'ready' || sourceTones.length === 0 ? 'unknown' : status.stale ? 'stale' : worstSignalTone(sourceTones);
|
||
const hostSourceTone = sourceSignalTone(overview.host?.source);
|
||
const hostTone = overview.resources.host !== 'ready' || !overview.host ? 'unknown' : hostSourceTone !== 'healthy' ? hostSourceTone : thresholdTone([overview.host.cpu?.totalPercent, overview.host.memory?.utilizationPercent]);
|
||
const poolSourceTone = sourceSignalTone(overview.poolSource);
|
||
const knownStorageTone = overview.pools.length ? worstSignalTone(overview.pools.map((pool) => signalToneFromState(operationalStorageState(pool.state, pool.capacitySeverity)))) : 'unknown';
|
||
const storageTone = overview.resources.pools !== 'ready' ? 'unknown' : poolSourceTone !== 'healthy' ? poolSourceTone : overview.poolsPartial ? worstSignalTone([knownStorageTone, 'unknown']) : overview.poolTotal === 0 ? 'unknown' : knownStorageTone;
|
||
const containerSourceTone = sourceSignalTone(overview.containerSource);
|
||
const knownWorkloadTone = overview.containerTotal === 0 ? 'healthy' : worstSignalTone(overview.containers.map(containerSignalTone));
|
||
const workloadTone = overview.resources.containers !== 'ready' ? 'unknown' : containerSourceTone !== 'healthy' ? containerSourceTone : overview.containersPartial ? worstSignalTone([knownWorkloadTone, 'unknown']) : knownWorkloadTone;
|
||
const serviceConfigured = overview.serviceCapability === 'available' && overview.serviceConfiguration === 'configured';
|
||
const knownServiceTone = overview.services.length ? worstSignalTone(overview.services.map((service) => signalToneFromState(service.state))) : 'unknown';
|
||
const serviceTone = overview.resources.services !== 'ready' || !serviceConfigured ? 'unknown' : overview.servicesPartial ? worstSignalTone([knownServiceTone, 'unknown']) : overview.serviceTotal === 0 ? 'unknown' : knownServiceTone;
|
||
const incidentTone = overview.resources.incidents !== 'ready' ? 'unknown' : overview.incidents.length === 0 ? 'healthy' : overview.incidents.some((incident) => signalToneFromState(incident.severity) === 'critical') ? 'critical' : 'attention';
|
||
const orderedIncidents = [...overview.incidents].sort((left, right) => signalToneRank[signalToneFromState(left.severity)] - signalToneRank[signalToneFromState(right.severity)] || right.startedAt.localeCompare(left.startedAt) || left.id.localeCompare(right.id));
|
||
const highestIncident = orderedIncidents[0];
|
||
const incidentSeverity = highestIncident ? presentStatus(highestIncident.severity) : copy.overview.noOpenIncidents;
|
||
const hostUsable = overview.resources.host === 'ready' && hostSourceTone === 'healthy';
|
||
const poolsUsable = overview.resources.pools === 'ready' && poolSourceTone === 'healthy';
|
||
const containersUsable = overview.resources.containers === 'ready' && containerSourceTone === 'healthy';
|
||
const servicesUsable = overview.resources.services === 'ready' && serviceConfigured;
|
||
const signalStages: OperationalSignalStage[] = [
|
||
{ id: 'sources', label: copy.overview.sources, icon: '◉', tone: sourceTone, statusLabel: signalResourceLabel(systemResourceState, sourceTone), primaryLabel: copy.overview.connectedSources, primaryValue: snapshot.state === 'ready' ? `${healthySources}/${sourceTones.length || '—'}` : '—', secondaryLabel: copy.overview.freshness, secondaryValue: signalResourceLabel(systemResourceState, sourceTone), detail: copy.overview.signalPathSourcesDetail, route: '/status' },
|
||
{ id: 'host', label: copy.navigation.host, icon: '▣', tone: hostTone, statusLabel: signalResourceLabel(overview.resources.host, hostTone), primaryLabel: copy.overview.cpu, primaryValue: hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—', secondaryLabel: copy.overview.memoryShort, secondaryValue: hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—', detail: copy.overview.signalPathHostDetail, route: '/host' },
|
||
{ id: 'storage', label: copy.overview.signalStorage, icon: '▤', tone: storageTone, statusLabel: signalCollectionLabel(overview.resources.pools, storageTone, { partial: overview.poolsPartial }), primaryLabel: copy.overview.storage, primaryValue: poolsUsable ? (overview.poolsPartial && poolUsage != null ? `≥${metric(poolUsage)}` : metric(poolUsage)) : '—', secondaryLabel: copy.navigation.pools, secondaryValue: poolsUsable ? String(overview.poolTotal) : '—', detail: copy.overview.signalPathStorageDetail, route: '/storage' },
|
||
{ id: 'workloads', label: copy.overview.signalWorkloads, icon: '⬡', tone: workloadTone, statusLabel: signalCollectionLabel(overview.resources.containers, workloadTone, { partial: overview.containersPartial, emptyLabel: overview.containerTotal === 0 ? copy.overview.signalPathNoWorkloads : undefined }), primaryLabel: copy.overview.activeContainers, primaryValue: containersUsable ? boundedRatio(runningContainers, overview.containerTotal, overview.containersPartial) : '—', secondaryLabel: copy.overview.total, secondaryValue: containersUsable ? String(overview.containerTotal) : '—', detail: copy.overview.signalPathWorkloadsDetail, route: '/containers' },
|
||
{ id: 'services', label: copy.navigation.services, icon: '◇', tone: serviceTone, statusLabel: signalCollectionLabel(overview.resources.services, serviceTone, { partial: overview.servicesPartial, notConfigured: overview.serviceConfiguration === 'not_configured' }), primaryLabel: copy.overview.available, primaryValue: servicesUsable ? boundedRatio(availableServices, overview.serviceTotal, overview.servicesPartial) : '—', secondaryLabel: copy.overview.total, secondaryValue: servicesUsable ? String(overview.serviceTotal) : '—', detail: copy.overview.signalPathServicesDetail, route: '/services' },
|
||
{ id: 'incidents', label: copy.overview.signalIncidents, icon: '△', tone: incidentTone, statusLabel: signalCollectionLabel(overview.resources.incidents, incidentTone, { partial: overview.incidentsPartial }), primaryLabel: copy.overview.openIncidents, primaryValue: overview.resources.incidents === 'ready' ? `${overview.incidents.length}${overview.incidentsPartial ? '+' : ''}` : '—', secondaryLabel: copy.overview.highestSeverity, secondaryValue: overview.resources.incidents === 'ready' ? incidentSeverity : '—', detail: copy.overview.signalPathIncidentsDetail, route: '/incidents' },
|
||
];
|
||
const hasSignalAttention = signalStages.some((stage) => stage.tone === 'critical' || stage.tone === 'attention');
|
||
const hasSignalUncertainty = signalStages.some((stage) => stage.tone === 'stale' || stage.tone === 'unknown');
|
||
const heading = hasSignalAttention || problems.length > 0 || (poolUsage != null && poolUsage >= 90) ? copy.overview.attentionTitle : hasSignalUncertainty ? copy.overview.unknownTitle : copy.overview.title;
|
||
return <div className="command-overview">
|
||
<header className="overview-heading"><div><p className="eyebrow">{copy.overview.eyebrow}</p><h1>{heading}</h1><p className="intro">{copy.overview.intro}</p></div><div className="overview-heading-status"><StatusBadge label={status.label} tone={status.tone} /><small>{snapshot.status ? formatDateTime(snapshot.status.generatedAt) : copy.overview.statusLoading}</small></div></header>
|
||
<section className="source-health-strip" aria-label={copy.overview.sourceLag} tabIndex={0}>
|
||
{sourceLags.slice(0, 6).map((source) => <span className={'health-chip health-chip--' + source.state} key={source.sourceId}><span aria-hidden="true">{source.state === 'healthy' ? '✓' : source.state === 'degraded' ? '!' : '?'}</span><strong>{presentComponent(source.sourceId)}</strong><small>{presentStatus(source.state)}</small></span>)}
|
||
{sourceLags.length === 0 && <span className="health-chip health-chip--unknown"><span aria-hidden="true">?</span><strong>{copy.overview.sourceLag}</strong><small>{copy.overview.unknown}</small></span>}
|
||
</section>
|
||
<section className="overview-kpi-grid instrument-band" aria-label={copy.overview.metrics}>
|
||
<article className="overview-kpi instrument-cell"><p>{copy.overview.cpu}</p><strong>{hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—'}</strong><small>{hostUsable ? presentStatus(overview.host?.source?.freshness ?? 'unknown') : signalResourceLabel(overview.resources.host, hostTone)}</small></article>
|
||
<article className="overview-kpi instrument-cell"><p>{copy.overview.memory}</p><strong>{hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—'}</strong><small>{hostUsable ? overview.host?.identity?.name ?? copy.overview.unknown : signalResourceLabel(overview.resources.host, hostTone)}</small></article>
|
||
<article className="overview-kpi instrument-cell"><p>{copy.overview.storage}</p><strong>{poolsUsable ? (overview.poolsPartial && poolUsage != null ? `≥${metric(poolUsage)}` : metric(poolUsage)) : '—'}</strong><small>{poolsUsable ? `${overview.poolTotal} ${copy.navigation.pools.toLowerCase()}` : signalResourceLabel(overview.resources.pools, storageTone)}</small></article>
|
||
<article className="overview-kpi instrument-cell"><p>{copy.overview.services}</p><strong>{servicesUsable ? boundedRatio(availableServices, overview.serviceTotal, overview.servicesPartial) : '—'}</strong><small>{overview.resources.services !== 'ready' ? signalResourceLabel(overview.resources.services, serviceTone) : !serviceConfigured ? copy.overview.signalPathNotConfigured : overview.servicesPartial ? copy.overview.signalPathPartial : copy.overview.available}</small></article>
|
||
</section>
|
||
<section className="overview-layout">
|
||
<article className="card focus-panel action-queue" aria-labelledby="overview-actions-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.actionQueue}</p><h2 id="overview-actions-title">{copy.overview.problems}</h2></div><span className="queue-count">{problems.length}</span></div>{problems.length ? <ol>{problems.map((problem, index) => <li key={problem.id}><span className="queue-severity">{index === 0 ? '!' : '?'}</span><span><strong>{problem.label}</strong><small>{problem.reason}</small></span></li>)}</ol> : <p className="card-copy">{resourcesLoading ? copy.overview.loadingResources : copy.overview.noProblems}</p>}<div className="overview-status-actions">{overviewNeedsAuthentication && <SignInButton />}<button className="button button--secondary" type="button" onClick={() => { refreshSystemStatus(); refreshOverview(); }}>{copy.overview.retry}</button><button className="button button--secondary" type="button" onClick={() => navigate('/status')}>{copy.overview.openStatus}</button></div></article>
|
||
<OperationalSignalPath stages={signalStages} onNavigate={navigate} />
|
||
<article className="card overview-table-card capacity-plane" aria-labelledby="pool-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.storagePools}</p><h2 id="pool-overview-title">{copy.overview.capacity}</h2></div><button className="text-action" type="button" onClick={() => navigate('/pools')}>{copy.overview.viewAll}</button></div>{overview.resources.pools !== 'ready' ? <p className="card-copy">{resourceStateDetail(overview.resources.pools)}</p> : poolSourceTone !== 'healthy' ? <p className="card-copy">{copy.overview.resourceStaleDetail}</p> : overview.pools.length ? <ul className="overview-data-list">{overview.pools.slice(0, 5).map((pool) => { const state = operationalStorageState(pool.state, pool.capacitySeverity); return <li key={pool.id}><span><strong>{pool.name}</strong><small>{presentStatus(state)} · device-health {presentStatus(pool.state).toLowerCase()}</small></span><span className="mono-value">{metric(pool.utilizationPercent)}</span></li>; })}</ul> : <p className="card-copy">{copy.overview.noPools}</p>}</article>
|
||
<article className="card context-inspector overview-table-card workload-inspector" aria-labelledby="workload-overview-title"><div className="card-heading"><div><p className="card-kicker">Nu</p><h2 id="workload-overview-title">{copy.navigation.containers}</h2></div><button className="text-action" type="button" onClick={() => navigate('/containers')}>{copy.overview.viewAll}</button></div><div className="workload-summary"><strong>{containersUsable ? `${overview.containersPartial ? '≥' : ''}${runningContainers}` : '—'}</strong><span>{copy.overview.running}</span><strong>{containersUsable && !overview.containersPartial ? Math.max(0, overview.containerTotal - runningContainers) : '—'}</strong><span>{copy.overview.other}</span></div><p className="card-copy">{overview.resources.containers !== 'ready' ? resourceStateDetail(overview.resources.containers) : containerSourceTone !== 'healthy' ? copy.overview.resourceStaleDetail : overview.containersPartial ? copy.overview.resourcePartialDetail : overview.containerTotal > 0 ? `${runningContainers} van ${overview.containerTotal} ${copy.overview.containersRunning}.` : copy.overview.signalPathNoWorkloads}</p><dl className="overview-now-list"><div><dt>{copy.overview.cpu}</dt><dd>{hostUsable ? metric(overview.host?.cpu?.totalPercent) : '—'}</dd></div><div><dt>{copy.overview.memory}</dt><dd>{hostUsable ? metric(overview.host?.memory?.utilizationPercent) : '—'}</dd></div><div><dt>Bron</dt><dd>{hostUsable ? presentStatus(overview.host?.source?.freshness ?? 'unknown') : signalResourceLabel(overview.resources.host, hostTone)}</dd></div></dl></article>
|
||
<article className="card overview-table-card incident-queue" aria-labelledby="incident-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.overview.recentIncidents}</p><h2 id="incident-overview-title">{copy.navigation.incidents}</h2></div><button className="text-action" type="button" onClick={() => navigate('/incidents')}>{copy.overview.viewAll}</button></div>{overview.resources.incidents !== 'ready' ? <p className="card-copy">{resourceStateDetail(overview.resources.incidents)}</p> : orderedIncidents.length ? <ul className="overview-data-list">{orderedIncidents.slice(0, 5).map((incident) => <li key={incident.id}><span className={'incident-dot incident-dot--' + incident.severity} aria-hidden="true" /><span><strong>{incident.title}</strong><small>{formatDateTime(incident.startedAt)}</small></span></li>)}</ul> : <p className="card-copy">{copy.overview.noIncidents}</p>}</article>
|
||
</section>
|
||
</div>;
|
||
}
|
||
|
||
type ApiRecord = Record<string, unknown>;
|
||
type DashboardSummary = { id: string; slug: string; name: string; description: string; scope: string; revision: number; currentVersion: number };
|
||
type DashboardWidget = { id: string; type: string; title: string; description?: string; data?: ApiRecord; visualization?: ApiRecord; behavior?: ApiRecord; layouts?: ApiRecord };
|
||
type DashboardViewport = 'desktop' | 'tablet' | 'mobile' | 'wallboard';
|
||
type CrossFilter = { key: string; value: string; label: string; sourceWidgetId: string };
|
||
class ApiError extends Error { constructor(readonly status: number) { super('api request failed'); } }
|
||
function field<T>(record: ApiRecord | undefined, name: string): T | undefined {
|
||
if (!record) return undefined;
|
||
const upper = name.charAt(0).toUpperCase() + name.slice(1);
|
||
return (record[name] ?? record[upper] ?? record[name.toUpperCase()]) as T | undefined;
|
||
}
|
||
function summaryFromApi(record: ApiRecord): DashboardSummary {
|
||
return { id: String(field(record, 'id') ?? ''), slug: String(field(record, 'slug') ?? ''), name: String(field(record, 'name') ?? copy.dashboards.unnamed), description: String(field(record, 'description') ?? ''), scope: String(field(record, 'scope') ?? 'unknown'), revision: Number(field(record, 'revision') ?? 0), currentVersion: Number(field(record, 'currentVersion') ?? 0) };
|
||
}
|
||
async function getJSON<T>(url: string, signal: AbortSignal): Promise<T> {
|
||
const response = await fetch(url, { signal, cache: 'no-store' });
|
||
if (!response.ok) throw new ApiError(response.status);
|
||
return response.json() as Promise<T>;
|
||
}
|
||
|
||
function DashboardsPage() {
|
||
const [state, setState] = useState<'loading' | 'error' | 'unauthorized' | 'empty' | 'ready'>('loading');
|
||
const [items, setItems] = useState<DashboardSummary[]>([]);
|
||
const [reload, setReload] = useState(0);
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
// Rotation is a background replacement once a dashboard is visible. Keep
|
||
// the current view (and any identical shared live subscription) mounted
|
||
// until the next document arrives instead of bouncing through `loading`.
|
||
setState((current) => current === 'ready' ? current : 'loading');
|
||
getJSON<{ items?: ApiRecord[] }>('/api/v1/dashboards?limit=100', controller.signal).then((data) => {
|
||
const next = (data.items ?? []).map(summaryFromApi).filter((item) => item.id !== '');
|
||
setItems(next); setState(next.length === 0 ? 'empty' : 'ready');
|
||
}).catch((error: unknown) => {
|
||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||
setState(error instanceof ApiError && error.status === 401 ? 'unauthorized' : 'error');
|
||
});
|
||
return () => controller.abort();
|
||
}, [reload]);
|
||
const stateContent = state === 'loading' ? <p className="card-copy" role="status">{copy.dashboards.loading}</p> :
|
||
state === 'unauthorized' ? <div className="dashboard-message" role="alert"><h2>{copy.dashboards.unauthorizedTitle}</h2><p>{copy.dashboards.unauthorizedDetail}</p><SignInButton /></div> :
|
||
state === 'error' ? <div className="dashboard-message" role="alert"><h2>{copy.dashboards.errorTitle}</h2><p>{copy.dashboards.errorDetail}</p><button className="button button--secondary" type="button" onClick={() => setReload((value) => value + 1)}>{copy.dashboards.retry}</button></div> :
|
||
state === 'empty' ? <div className="dashboard-message dashboard-message--empty"><span className="empty-state-icon" aria-hidden="true">▦</span><h2>{copy.dashboards.empty}</h2><p>{copy.dashboards.emptyDetail}</p></div> :
|
||
<ul className="dashboard-list">{items.map((item) => <li key={item.id}><button type="button" className="dashboard-list-item" onClick={() => navigate('/dashboards/' + encodeURIComponent(item.id))}><span><strong>{item.name}</strong><small>{item.description || item.slug}</small></span><span className="dashboard-list-meta"><span>{copy.dashboards.version} {item.currentVersion}</span><StatusBadge label={item.scope === 'personal' ? copy.dashboards.personal : copy.dashboards.shared} tone="ready" /></span></button></li>)}</ul>;
|
||
return <><PageIntro eyebrow={copy.dashboards.eyebrow} title={copy.dashboards.title} intro={copy.dashboards.intro} /><section className="card dashboard-list-panel" aria-labelledby="dashboard-list-title"><div className="card-heading"><div><p className="card-kicker">{copy.dashboards.catalog}</p><h2 id="dashboard-list-title">{copy.dashboards.listTitle}</h2></div><StatusBadge label={state === 'ready' ? copy.dashboards.ready : copy.dashboards.unknown} tone={state === 'ready' ? 'ready' : 'unknown'} /></div>{stateContent}</section>{state === 'ready' && <p className="dashboard-count">{items.length} {copy.dashboards.available}</p>}</>;
|
||
}
|
||
|
||
class WidgetBoundary extends Component<{ title: string; children: ReactNode }, { failed: boolean }> {
|
||
state = { failed: false };
|
||
static getDerivedStateFromError(): { failed: boolean } { return { failed: true }; }
|
||
componentDidCatch(_error: Error, _info: ErrorInfo): void {}
|
||
render() { return this.state.failed ? <article className="widget-card widget-card--error" role="alert"><p className="card-kicker">{copy.dashboards.widgetError}</p><h3>{this.props.title}</h3><p>{copy.dashboards.widgetErrorDetail}</p></article> : this.props.children; }
|
||
}
|
||
const widgetLabels: Record<string, string> = { stat: copy.widgets.stat, timeseries: copy.widgets.timeseries, gauge: copy.widgets.gauge, 'ranked-list': copy.widgets.rankedList, 'status-grid': copy.widgets.statusGrid, table: copy.widgets.table, heatmap: copy.widgets.heatmap, 'event-timeline': copy.widgets.eventTimeline, 'storage-map': copy.widgets.storageMap, topology: copy.widgets.topology, 'service-matrix': copy.widgets.serviceMatrix, 'alert-summary': copy.widgets.alertSummary, text: copy.widgets.text, 'query-inspector': copy.widgets.queryInspector };
|
||
|
||
// Installed before any client captures the global `fetch`, so every API call in
|
||
// the app funnels its 401s through one place.
|
||
installSessionWatcher();
|
||
function responsiveViewport(): 'desktop' | 'tablet' | 'mobile' { return window.innerWidth <= 700 ? 'mobile' : window.innerWidth <= 900 ? 'tablet' : 'desktop'; }
|
||
function widgetFilter(widget: DashboardWidget): { key: string; value: string; label: string } {
|
||
const data = widget.data ?? {};
|
||
const scope = field<ApiRecord>(data, 'scope') ?? {};
|
||
const entityType = field<string>(scope, 'entityType');
|
||
const sourceType = String(field(data, 'sourceType') ?? 'unknown');
|
||
return entityType ? { key: 'entityType', value: entityType, label: entityType } : { key: 'sourceType', value: sourceType, label: sourceType };
|
||
}
|
||
function filterAllowed(document: ApiRecord, key: string, value: string): boolean {
|
||
const safeSources = ['semantic-metric', 'inventory', 'events', 'alerts', 'incidents', 'text'];
|
||
if (key === 'sourceType') return safeSources.includes(value);
|
||
const variables = (field<unknown[]>(document, 'variables') ?? []) as ApiRecord[];
|
||
return variables.some((variable) => { const options = field<unknown[]>(variable, 'options') ?? []; return options.includes(value); });
|
||
}
|
||
function filterFromURL(document: ApiRecord): CrossFilter | null {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const key = params.get('filterKey');
|
||
const value = params.get('filterValue');
|
||
if (!key || !value || !filterAllowed(document, key, value)) return null;
|
||
return { key, value, label: value, sourceWidgetId: '' };
|
||
}
|
||
function compatibleWithFilter(widget: DashboardWidget, filter: CrossFilter | null): boolean {
|
||
if (!filter || widget.id === filter.sourceWidgetId) return true;
|
||
const next = widgetFilter(widget);
|
||
return next.key === filter.key && next.value === filter.value;
|
||
}
|
||
function wallboardSlideFor(widget: DashboardWidget): number {
|
||
const layout = field<ApiRecord>(widget.layouts ?? {}, 'wallboard') ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {};
|
||
return wallboardSlideIndex(field(layout, 'y'));
|
||
}
|
||
function widgetLayoutStyle(layout: ApiRecord, viewport: DashboardViewport): CSSProperties {
|
||
const columns = viewport === 'wallboard' ? wallboardColumns : viewport === 'tablet' ? 8 : viewport === 'mobile' ? 1 : 18;
|
||
const width = viewport === 'mobile' ? 1 : Math.min(columns, Math.max(1, Number(field<number>(layout, 'w') ?? 6)));
|
||
if (viewport !== 'wallboard') return { '--widget-span': String(width) } as CSSProperties;
|
||
const placement = wallboardPlacement(layout);
|
||
return { '--widget-span': String(placement.columnSpan), gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`, gridRow: `${placement.rowStart} / span ${placement.rowSpan}` } as CSSProperties;
|
||
}
|
||
function DashboardWidgetView({ widget, viewport, onFilter, metric }: { widget: DashboardWidget; viewport: DashboardViewport; onFilter: (widget: DashboardWidget) => void; metric?: MetricWidgetProps }) {
|
||
if (!widget.id || !widget.title) return <article className="widget-card widget-card--error" role="alert"><p className="card-kicker">{copy.dashboards.widgetError}</p><h3>{copy.widgets.unknown}</h3><p>{copy.dashboards.widgetErrorDetail}</p></article>;
|
||
const behavior = widget.behavior ?? {};
|
||
if (field<boolean>(behavior, 'hidden')) return null;
|
||
const activeLayout = field<ApiRecord>(widget.layouts ?? {}, viewport) ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {};
|
||
if (field<boolean>(activeLayout, 'visible') === false) return null;
|
||
const typeLabel = widgetLabels[widget.type] ?? copy.widgets.unknown;
|
||
const source = String(field(widget.data ?? {}, 'sourceType') ?? copy.dashboards.unknown);
|
||
const status = metric ? metricStatus(metric) : { label: copy.dashboards.unknown, tone: 'unknown' as const };
|
||
const metricKind = metric && (widget.type === 'stat' || widget.type === 'timeseries' || widget.type === 'gauge' || widget.type === 'query-inspector');
|
||
const widgetItems = (field<unknown[]>(widget.data ?? {}, 'items') ?? []) as ApiRecord[];
|
||
const rankedItems: RankedListItem[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), value: String(field(item, 'value') ?? ''), detail: field<string>(item, 'detail') } )).filter((item) => item.id !== '' && item.label !== '');
|
||
const statusItems: StatusGridItem[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), status: String(field(item, 'status') ?? 'unknown'), reason: field<string>(item, 'reason') } )).filter((item) => item.id !== '' && item.label !== '');
|
||
const storageNodes: StorageMapNode[] = widgetItems.map((item) => ({ id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), kind: String(field(item, 'kind') ?? 'storage'), state: String(field(item, 'status') ?? 'unknown'), detail: field<string>(item, 'detail'), href: field<string>(item, 'href') })).filter((item) => item.id !== '' && item.label !== '');
|
||
const topologyData = field<TopologyData>(widget.data ?? {}, 'topology');
|
||
const networkData = field<NetworkData>(widget.data ?? {}, 'network');
|
||
const heatmapPoints: HeatmapPoint[] = widgetItems.map((item) => { const value = Number(field(item, 'value')); return { id: String(field(item, 'id') ?? ''), label: String(field(item, 'label') ?? field(item, 'name') ?? ''), observedAt: String(field(item, 'observedAt') ?? new Date(0).toISOString()), value: Number.isFinite(value) ? value : null, status: String(field(item, 'status') ?? 'unknown'), href: field<string>(item, 'href') }; }).filter((item) => item.id !== '' && item.label !== '');
|
||
return <article className="widget-card" data-viewport={viewport} style={widgetLayoutStyle(activeLayout, viewport)} aria-labelledby={'widget-' + widget.id}><div className="widget-card-heading"><div><p className="card-kicker">{typeLabel}</p><h3 id={'widget-' + widget.id}>{widget.title}</h3></div><StatusBadge label={status.label} tone={status.tone} /></div>{widget.description && <p className="widget-description">{widget.description}</p>}{metricKind ? <MetricWidget {...metric!} /> : widget.type === 'ranked-list' && rankedItems.length > 0 ? <RankedListWidget items={rankedItems} onSelect={() => onFilter(widget)} /> : widget.type === 'status-grid' && statusItems.length > 0 ? <StatusGridWidget items={statusItems} onSelect={() => onFilter(widget)} /> : widget.type === 'storage-map' && storageNodes.length > 0 ? <StorageMapWidget nodes={storageNodes} title={widget.title} description={widget.description || copy.storage.mapDescription} idPrefix={'storage-map-' + widget.id} /> : widget.type === 'topology' && topologyData ? <Suspense fallback={<p className="card-copy" role="status">{copy.topology.loading}</p>}><TopologyWidget topology={topologyData} compact /></Suspense> : widget.type === 'network' && networkData ? <NetworkHealthWidget snapshot={networkData} compact /> : widget.type === 'heatmap' && heatmapPoints.length > 0 ? <TemperatureHeatmap points={heatmapPoints} title={widget.title} description={widget.description || copy.storage.heatmapDescription} idPrefix={'heatmap-' + widget.id} /> : <button className="widget-placeholder" type="button" data-widget-type={widget.type} aria-label={typeLabel + ': ' + widget.title + ' filteren'} onClick={() => onFilter(widget)}><span className="widget-placeholder-icon" aria-hidden="true">{widget.type === 'text' ? 'T' : '◌'}</span><strong>{copy.dashboards.noData}</strong><small>{source} · {copy.dashboards.dataPending}</small></button>}</article>;
|
||
}
|
||
function DashboardViewPage({ dashboardId, wallboard = false, wallboardSlide = 0, onWallboardSlideCount, onRuntimeState }: { dashboardId: string; wallboard?: boolean; wallboardSlide?: number; onWallboardSlideCount?: (count: number) => void; onRuntimeState?: (state: RuntimeState) => void }) {
|
||
const [state, setState] = useState<'loading' | 'error' | 'unauthorized' | 'ready'>('loading');
|
||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||
const [document, setDocument] = useState<ApiRecord>({});
|
||
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
|
||
const [editing, setEditing] = useState(false);
|
||
const [crossFilter, setCrossFilter] = useState<CrossFilter | null>(null);
|
||
const [runtimeStates, setRuntimeStates] = useState<Record<string, RuntimeState>>({});
|
||
const hasWallboardContent = useRef(false);
|
||
const systemSnapshot = useSystemStatus();
|
||
const storageKey = 'pulse.dashboard.view.' + dashboardId;
|
||
const [timeRange, setTimeRange] = useState(() => wallboard ? 'live' : window.localStorage.getItem(storageKey + '.range') ?? '1h');
|
||
const [filter, setFilter] = useState(() => window.localStorage.getItem(storageKey + '.filter') ?? '');
|
||
const updateRuntimeState = useCallback((id: string, next: RuntimeState) => setRuntimeStates((current) => current[id] === next ? current : { ...current, [id]: next }), []);
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
const replacingVisibleWallboard = wallboard && hasWallboardContent.current;
|
||
if (!replacingVisibleWallboard) setState('loading');
|
||
getJSON<{ dashboard: ApiRecord; version: ApiRecord }>('/api/v1/dashboards/' + encodeURIComponent(dashboardId), controller.signal).then((data) => {
|
||
const rawDocument = field<ApiRecord>(data.version, 'document') ?? {};
|
||
setDocument(rawDocument);
|
||
setCrossFilter(filterFromURL(rawDocument));
|
||
setSummary(summaryFromApi(data.dashboard));
|
||
setWidgets((field<unknown[]>(rawDocument, 'widgets') ?? []) as DashboardWidget[]);
|
||
setRuntimeStates({});
|
||
if (wallboard) hasWallboardContent.current = true;
|
||
setState('ready');
|
||
}).catch((error: unknown) => {
|
||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||
if (error instanceof ApiError && error.status === 401) {
|
||
setState('unauthorized');
|
||
return;
|
||
}
|
||
// A wallboard is a continuous operational display. During a transient
|
||
// failed rotation, keep the last verified document visible and let the
|
||
// next bounded rotation/refresh retry. Never apply this to first load or
|
||
// authentication failure.
|
||
if (!replacingVisibleWallboard) setState('error');
|
||
});
|
||
return () => controller.abort();
|
||
}, [dashboardId, wallboard]);
|
||
useEffect(() => { window.localStorage.setItem(storageKey + '.range', timeRange); }, [storageKey, timeRange]);
|
||
useEffect(() => { window.localStorage.setItem(storageKey + '.filter', filter); }, [storageKey, filter]);
|
||
useEffect(() => {
|
||
const values = Object.values(runtimeStates);
|
||
const aggregate: RuntimeState = values.includes('usable') ? 'usable' : values.includes('error') ? 'error' : values.length > 0 && values.every((value) => value === 'empty') ? 'empty' : 'loading';
|
||
onRuntimeState?.(aggregate);
|
||
}, [onRuntimeState, runtimeStates]);
|
||
useEffect(() => { if (wallboard) setRuntimeStates({}); }, [wallboard, wallboardSlide]);
|
||
const wallboardWidgets = widgets.filter((widget) => {
|
||
const layout = field<ApiRecord>(widget.layouts ?? {}, 'wallboard') ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {};
|
||
return field<boolean>(widget.behavior ?? {}, 'hidden') !== true && field<boolean>(layout, 'visible') !== false;
|
||
});
|
||
const wallboardSlideCount = Math.max(1, ...wallboardWidgets.map((widget) => wallboardSlideFor(widget) + 1));
|
||
useEffect(() => { if (wallboard) onWallboardSlideCount?.(wallboardSlideCount); }, [onWallboardSlideCount, wallboard, wallboardSlideCount]);
|
||
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.dashboards.loading}</h1></section>;
|
||
if (state === 'unauthorized') return <StatePage kind="unauthorized" />;
|
||
if (state === 'error' || !summary) return <StatePage kind="error" />;
|
||
if (editing) return <Suspense fallback={<StatePage kind="loading" />}><DashboardEditor dashboardId={dashboardId} revision={summary.revision} document={document} onExit={() => setEditing(false)} onSaved={(revision, nextDocument) => { setDocument(nextDocument); setWidgets((field<unknown[]>(nextDocument, 'widgets') ?? []) as DashboardWidget[]); setSummary({ ...summary, revision, currentVersion: summary.currentVersion + 1 }); setEditing(false); }} /></Suspense>;
|
||
const viewport: DashboardViewport = wallboard ? 'wallboard' : responsiveViewport();
|
||
const normalized = filter.trim().toLowerCase();
|
||
const shown = widgets.filter((widget) => { const activeLayout = field<ApiRecord>(widget.layouts ?? {}, viewport) ?? field<ApiRecord>(widget.layouts ?? {}, 'desktop') ?? {}; return field<boolean>(widget.behavior ?? {}, 'hidden') !== true && field<boolean>(activeLayout, 'visible') !== false && (!wallboard || wallboardSlideFor(widget) === Math.min(wallboardSlide, wallboardSlideCount - 1)) && (!normalized || widget.title.toLowerCase().includes(normalized)) && compatibleWithFilter(widget, crossFilter); });
|
||
const systemStatus = aggregateStatus(systemSnapshot);
|
||
const applyCrossFilter = (widget: DashboardWidget) => { const next = widgetFilter(widget); const filter = { ...next, sourceWidgetId: widget.id }; setCrossFilter(filter); const params = new URLSearchParams(window.location.search); params.set('filterKey', filter.key); params.set('filterValue', filter.value); window.history.replaceState({}, '', window.location.pathname + '?' + params.toString()); };
|
||
const clearCrossFilter = () => { setCrossFilter(null); const params = new URLSearchParams(window.location.search); params.delete('filterKey'); params.delete('filterValue'); const query = params.toString(); window.history.replaceState({}, '', window.location.pathname + (query ? '?' + query : '')); };
|
||
|
||
const usableCount = Object.values(runtimeStates).filter((value) => value === 'usable').length;
|
||
return <section className={wallboard ? 'dashboard-view wallboard-view' : 'dashboard-view'} aria-labelledby="dashboard-view-title">{!wallboard && <button className="back-link" type="button" onClick={() => navigate('/dashboards')}>← {copy.dashboards.back}</button>}<header className="dashboard-view-header"><div><p className="eyebrow">{copy.dashboards.viewMode}</p>{wallboard ? <h2 id="dashboard-view-title">{summary.name}</h2> : <h1 id="dashboard-view-title">{summary.name}</h1>}<p className="intro">{summary.description || copy.dashboards.noDescription}</p></div><div className="dashboard-view-meta">{wallboard && <span className="wallboard-read-only">{copy.wallboard.readOnly}</span>}<StatusBadge label={systemStatus.label} tone={systemStatus.tone} /><span>{copy.dashboards.version} {summary.currentVersion}</span>{!wallboard && <button className="button button--secondary" type="button" onClick={() => setEditing(true)}>{copy.dashboards.edit}</button>}</div></header>{!wallboard && <div className="dashboard-controls" aria-label={copy.dashboards.controls}><label>{copy.dashboards.timeRange}<select value={timeRange} onChange={(event) => setTimeRange(event.target.value)}><option value="live">{copy.dashboards.live}</option><option value="15m">15 {copy.dashboards.minutes}</option><option value="1h">1 {copy.dashboards.hour}</option><option value="6h">6 {copy.dashboards.hours}</option><option value="24h">24 {copy.dashboards.hours}</option><option value="7d">7 {copy.dashboards.days}</option></select></label><label>{copy.dashboards.filter}<input value={filter} onChange={(event) => setFilter(event.target.value)} placeholder={copy.dashboards.filterPlaceholder} /></label><span className="view-mode-note">{crossFilter ? copy.dashboards.filterActive + ': ' + crossFilter.label : copy.dashboards.fixedView}</span><span className="metric-query-status" role="status">{usableCount} van {shown.length} {copy.dashboards.widgetsWithData}</span>{crossFilter && <button className="button button--secondary clear-cross-filter" type="button" onClick={clearCrossFilter}>{copy.dashboards.clearFilter}</button>}</div>}{shown.length === 0 ? <div className="card dashboard-message"><h2>{copy.dashboards.noMatchingWidgets}</h2><p>{copy.dashboards.clearFilterHint}</p></div> : <><h2 className="sr-only" id="dashboard-widgets-title">{copy.dashboards.widgetCollection}</h2><div className="dashboard-grid" aria-labelledby="dashboard-widgets-title">{shown.map((widget) => <WidgetBoundary key={widget.id} title={widget.title || copy.widgets.unknown}>{['semantic-metric', 'inventory', 'events'].includes(String(field(widget.data, 'sourceType') ?? '')) ? <DashboardRuntimeWidget widget={widget} viewport={viewport} document={document} timeRange={timeRange} onState={updateRuntimeState} onFilter={() => applyCrossFilter(widget)} /> : <DashboardWidgetView widget={widget} viewport={viewport} onFilter={applyCrossFilter} />}</WidgetBoundary>)}</div></>}</section>;
|
||
}
|
||
|
||
type WallboardPriorityData = { serviceProblems: number; openIncidents: number; loading: boolean; unavailable: boolean };
|
||
function useWallboardPriorityData(): WallboardPriorityData {
|
||
const [value, setValue] = useState<WallboardPriorityData>({ serviceProblems: 0, openIncidents: 0, loading: true, unavailable: false });
|
||
useEffect(() => {
|
||
let active = true;
|
||
let controller: AbortController | null = null;
|
||
const load = async () => {
|
||
controller?.abort();
|
||
const current = new AbortController();
|
||
controller = current;
|
||
try {
|
||
const [servicesResponse, incidentsResponse] = await Promise.all([
|
||
fetch('/api/v1/services?limit=100', { signal: current.signal, cache: 'no-store' }),
|
||
fetch('/api/v1/incidents?limit=100&status=open', { signal: current.signal, cache: 'no-store' }),
|
||
]);
|
||
if (!servicesResponse.ok || !incidentsResponse.ok) throw new Error('priority');
|
||
const services = await servicesResponse.json() as { services?: Array<{ state?: string }> };
|
||
const incidents = await incidentsResponse.json() as { items?: unknown[] };
|
||
if (active) setValue({ serviceProblems: (services.services ?? []).filter((item) => item.state !== 'up').length, openIncidents: (incidents.items ?? []).length, loading: false, unavailable: false });
|
||
} catch (error: unknown) {
|
||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||
if (active) setValue((currentValue) => ({ ...currentValue, loading: false, unavailable: true }));
|
||
}
|
||
};
|
||
void load();
|
||
const timer = window.setInterval(load, 30000);
|
||
return () => { active = false; controller?.abort(); window.clearInterval(timer); };
|
||
}, []);
|
||
return value;
|
||
}
|
||
|
||
function WallboardPage() {
|
||
const [state, setState] = useState<'loading' | 'ready' | 'error' | 'unauthorized' | 'empty'>('loading');
|
||
const [items, setItems] = useState<DashboardSummary[]>([]);
|
||
const [activeIndex, setActiveIndex] = useState(0);
|
||
const [activeSlide, setActiveSlide] = useState(0);
|
||
const [slideCount, setSlideCount] = useState(1);
|
||
const [lastUpdated, setLastUpdated] = useState<string | undefined>();
|
||
const [transport, setTransport] = useState<'connected' | 'reconnecting' | 'unavailable'>('reconnecting');
|
||
const [dataState, setDataState] = useState<RuntimeState>('loading');
|
||
const [fullscreen, setFullscreen] = useState(false);
|
||
const [shift, setShift] = useState(0);
|
||
const systemSnapshot = useSystemStatus();
|
||
const system = aggregateStatus(systemSnapshot);
|
||
const storage = systemSnapshot.status?.components.find((component) => component.id === 'storage');
|
||
const priority = useWallboardPriorityData();
|
||
const params = new URLSearchParams(window.location.search);
|
||
const intervalSeconds = Math.min(300, Math.max(10, Number(params.get('interval') ?? 30) || 30));
|
||
const refreshSeconds = Math.min(300, Math.max(10, Number(params.get('refresh') ?? 30) || 30));
|
||
|
||
useEffect(() => onUnauthenticated(() => { setTransport('unavailable'); setState('unauthorized'); }), []);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
let inFlight: AbortController | null = null;
|
||
const load = async () => {
|
||
inFlight?.abort();
|
||
const controller = new AbortController();
|
||
inFlight = controller;
|
||
try {
|
||
const response = await fetch('/api/v1/dashboards?limit=100', { signal: controller.signal, cache: 'no-store' });
|
||
if (!response.ok) throw new Error('wallboard');
|
||
const data = await response.json() as { items?: ApiRecord[] };
|
||
const next = (data.items ?? []).map(summaryFromApi).filter((item) => item.id !== '').sort((a, b) => a.id.localeCompare(b.id));
|
||
if (!active) return;
|
||
setItems(next);
|
||
setActiveIndex((value) => next.length === 0 ? 0 : Math.min(value, next.length - 1));
|
||
setLastUpdated(new Date().toISOString());
|
||
setTransport('connected');
|
||
setState(next.length === 0 ? 'empty' : 'ready');
|
||
} catch (error: unknown) {
|
||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||
if (!active) return;
|
||
setTransport('unavailable');
|
||
setState((value) => value === 'ready' || value === 'unauthorized' ? value : 'error');
|
||
} finally {
|
||
if (inFlight === controller) inFlight = null;
|
||
}
|
||
};
|
||
void load();
|
||
const refresh = window.setInterval(() => { setTransport('reconnecting'); void load(); }, refreshSeconds * 1000);
|
||
return () => { active = false; inFlight?.abort(); window.clearInterval(refresh); };
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (items.length === 0) return undefined;
|
||
const rotation = window.setInterval(() => setActiveSlide((value) => {
|
||
if (value + 1 < slideCount) return value + 1;
|
||
if (items.length > 1) setActiveIndex((dashboard) => (dashboard + 1) % items.length);
|
||
return 0;
|
||
}), intervalSeconds * 1000);
|
||
return () => window.clearInterval(rotation);
|
||
}, [items.length, intervalSeconds, slideCount]);
|
||
|
||
useEffect(() => {
|
||
const timer = window.setInterval(() => setShift((value) => (value + 1) % 2), 60000);
|
||
return () => window.clearInterval(timer);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const update = () => setFullscreen(Boolean(document.fullscreenElement));
|
||
document.addEventListener('fullscreenchange', update);
|
||
update();
|
||
return () => document.removeEventListener('fullscreenchange', update);
|
||
}, []);
|
||
|
||
const toggleFullscreen = async () => {
|
||
try {
|
||
if (document.fullscreenElement) await document.exitFullscreen();
|
||
else if (document.documentElement.requestFullscreen) await document.documentElement.requestFullscreen();
|
||
} catch { /* Fullscreen is optional; transport and data state remain truthful. */ }
|
||
};
|
||
const handleRuntimeState = useCallback((runtime: RuntimeState) => setDataState(runtime), []);
|
||
const handleSlideCount = useCallback((count: number) => { setSlideCount(Math.max(1, count)); setActiveSlide((value) => Math.min(value, Math.max(1, count) - 1)); }, []);
|
||
|
||
if (state === 'loading') return <section className="wallboard-shell wallboard-shell--state" aria-live="polite"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.wallboard.loading}</h1></section>;
|
||
if (state === 'unauthorized') return <section className="wallboard-shell wallboard-shell--state" role="alert"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.states.unauthorizedTitle}</h1><p>{copy.states.unauthorizedDetail}</p><SignInButton /></section>;
|
||
if (state === 'error') return <section className="wallboard-shell wallboard-shell--state" role="alert"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.wallboard.errorTitle}</h1><p>{copy.wallboard.errorDetail}</p></section>;
|
||
if (state === 'empty') return <section className="wallboard-shell wallboard-shell--state"><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1>{copy.wallboard.noDashboards}</h1></section>;
|
||
|
||
const current = items[activeIndex];
|
||
return <section className={'wallboard-shell wallboard-shell--shift-' + shift} aria-labelledby="wallboard-title">
|
||
<header className="wallboard-header"><div><p className="eyebrow">{copy.wallboard.eyebrow}</p><h1 id="wallboard-title">{copy.wallboard.title}</h1><p className="intro">{copy.wallboard.intro}</p></div><div className="wallboard-actions"><span className={'wallboard-connection wallboard-connection--' + transport} role="status">{copy.wallboard.transport}: {transport === 'connected' ? copy.wallboard.connected : transport === 'reconnecting' ? copy.wallboard.reconnecting : copy.wallboard.unavailable}</span><span className={'wallboard-connection wallboard-connection--' + (dataState === 'usable' ? 'connected' : dataState === 'loading' ? 'reconnecting' : 'unavailable')} role="status">{copy.wallboard.data}: {dataState === 'usable' ? copy.wallboard.dataUsable : dataState === 'loading' ? copy.wallboard.dataLoading : dataState === 'empty' ? copy.wallboard.dataEmpty : copy.wallboard.unavailable}</span><button className="button button--secondary" type="button" onClick={toggleFullscreen}>{fullscreen ? copy.wallboard.exitFullscreen : copy.wallboard.enterFullscreen}</button></div></header>
|
||
<div className="wallboard-priority" aria-label={copy.wallboard.priority}><span className={'wallboard-priority-item wallboard-priority-item--' + (system.state === 'healthy' ? 'ready' : 'attention')}><strong>{copy.wallboard.overall}</strong><small>{system.label}</small></span><span className={'wallboard-priority-item wallboard-priority-item--' + (storage?.state === 'healthy' ? 'ready' : 'attention')}><strong>{copy.wallboard.storage}</strong><small>{storage ? presentStatus(storage.state) : copy.wallboard.unknown}</small></span><span className={'wallboard-priority-item wallboard-priority-item--' + (priority.serviceProblems === 0 && !priority.unavailable ? 'ready' : 'attention')}><strong>{copy.wallboard.services}</strong><small>{priority.loading ? copy.wallboard.dataLoading : priority.unavailable ? copy.wallboard.unknown : `${priority.serviceProblems} ${copy.wallboard.problems}`}</small></span><span className={'wallboard-priority-item wallboard-priority-item--' + (priority.openIncidents === 0 && !priority.unavailable ? 'ready' : 'attention')}><strong>{copy.wallboard.incidents}</strong><small>{priority.loading ? copy.wallboard.dataLoading : priority.unavailable ? copy.wallboard.unknown : `${priority.openIncidents} ${copy.wallboard.open}`}</small></span></div>
|
||
<div className="wallboard-status"><span>{copy.wallboard.lastUpdated}: {lastUpdated ? formatDateTime(lastUpdated) : copy.wallboard.reconnecting}</span><span>{copy.wallboard.rotate} {copy.wallboard.every} {intervalSeconds} {copy.wallboard.seconds}</span><span>{copy.wallboard.slide} {activeSlide + 1} / {slideCount}</span><span>{copy.wallboard.dashboard} {activeIndex + 1} / {items.length}</span></div>
|
||
<div className="wallboard-frame"><DashboardViewPage dashboardId={current.id} wallboard wallboardSlide={activeSlide} onWallboardSlideCount={handleSlideCount} onRuntimeState={handleRuntimeState} /></div>
|
||
</section>;
|
||
}
|
||
const AlertsPage = AlertRulesPage;
|
||
function SettingsPage() {
|
||
const snapshot = useSystemStatus();
|
||
const status = aggregateStatus(snapshot);
|
||
const connected = snapshot.status?.sourceLag.filter((source) => source.state === 'healthy').length ?? 0;
|
||
const total = snapshot.status?.sourceLag.length ?? 0;
|
||
const sourceDetail = snapshot.state === 'ready' && total > 0 ? `${connected} van ${total} ${copy.settings.sourcesCurrent}` : status.detail;
|
||
const groups = [
|
||
{ title: copy.settings.healthTitle, detail: copy.settings.healthDetail, links: [
|
||
{ href: '/status', title: copy.settings.systemStatus, detail: copy.settings.systemStatusDetail, access: copy.settings.adminActions },
|
||
{ href: '/onboarding', title: copy.settings.onboarding, detail: copy.settings.onboardingDetail, access: copy.settings.adminChanges },
|
||
] },
|
||
{ title: copy.settings.alertingTitle, detail: copy.settings.alertingDetail, links: [
|
||
{ href: '/alerts?section=rules', title: copy.settings.alertRules, detail: copy.settings.alertRulesDetail, access: copy.settings.editorChanges },
|
||
{ href: '/alerts?section=controls', title: copy.settings.alertControls, detail: copy.settings.alertControlsDetail, access: copy.settings.operatorChanges },
|
||
] },
|
||
{ title: copy.settings.presentationTitle, detail: copy.settings.presentationDetail, links: [
|
||
{ href: '/dashboards', title: copy.settings.dashboards, detail: copy.settings.dashboardsDetail, access: copy.settings.editorChanges },
|
||
{ href: '/inventory', title: copy.settings.inventory, detail: copy.settings.inventoryDetail, access: copy.settings.viewAccess },
|
||
] },
|
||
];
|
||
return <><PageIntro eyebrow={copy.settings.eyebrow} title={copy.settings.title} intro={copy.settings.intro} />
|
||
<section className="settings-overview card" aria-labelledby="settings-overview-title"><div className="card-heading"><div><p className="card-kicker">{copy.settings.current}</p><h2 id="settings-overview-title">{copy.settings.environment}</h2></div><StatusBadge label={status.label} tone={status.tone} /></div><div className="setting-row"><span><strong>{copy.settings.source}</strong><small>{sourceDetail}</small></span><span>{connected}/{total || '—'}</span></div><div className="setting-row"><span><strong>{copy.settings.language}</strong><small>{copy.settings.languageDetail}</small></span><strong>{copy.settings.languageValue}</strong></div></section>
|
||
<section className="settings-hub" aria-label={copy.settings.management}><h2 className="sr-only">{copy.settings.management}</h2>{groups.map((group) => <article className="card settings-hub-card" key={group.title}><p className="card-kicker">{copy.settings.management}</p><h3>{group.title}</h3><p className="card-copy">{group.detail}</p><ul>{group.links.map((link) => <li key={link.href}><a href={link.href} onClick={(event) => { event.preventDefault(); navigate(link.href); }}><span><strong>{link.title}</strong><small>{link.detail}</small></span><span className="settings-access">{link.access}<span aria-hidden="true">→</span></span></a></li>)}</ul></article>)}</section>
|
||
</>;
|
||
}
|
||
function StatePage({ kind }: { kind: 'loading' | 'error' | 'unauthorized' }) {
|
||
if (kind === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.states.loading}</h1></section>;
|
||
if (kind === 'unauthorized') {
|
||
// The API answered 401, so the visitor is not signed in: offer the real
|
||
// sign-in entry point and come back to the page they asked for.
|
||
return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">!</span><h1>{copy.states.unauthorizedTitle}</h1><p>{copy.states.unauthorizedDetail}</p><p>{copy.auth.signInHint}</p><div className="state-page-actions"><SignInButton /><button className="button button--secondary" type="button" onClick={() => navigate('/')}>{copy.states.returnHome}</button></div></section>;
|
||
}
|
||
return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.states.errorTitle}</h1><p>{copy.states.errorDetail}</p><button className="button" type="button" onClick={() => navigate(routeFromLocation(window.location.pathname))}>{copy.states.retry}</button></section>;
|
||
}
|
||
|
||
/** Wraps lazily loaded routes in the same loading state the rest of the app uses. */
|
||
function RouteSuspense({ children }: { children: ReactNode }) {
|
||
return <Suspense fallback={<StatePage kind="loading" />}>{children}</Suspense>;
|
||
}
|
||
|
||
function Page({ route }: { route: RoutePath }) {
|
||
if (route.startsWith('/inventory/')) return <InventoryPage id={decodeURIComponent(route.slice('/inventory/'.length))} />;
|
||
if (route.startsWith('/dashboards/')) return <DashboardViewPage dashboardId={decodeURIComponent(route.slice('/dashboards/'.length))} />;
|
||
if (route.startsWith('/services/')) return <ServicePage id={decodeURIComponent(route.slice('/services/'.length))} />;
|
||
if (route.startsWith('/incidents/')) return <IncidentPage id={decodeURIComponent(route.slice('/incidents/'.length))} />;
|
||
if (route.startsWith('/containers/')) return <ContainerDetailPage id={decodeURIComponent(route.slice('/containers/'.length))} />;
|
||
if (route.startsWith('/disks/')) return <DiskDetailPage id={decodeURIComponent(route.slice('/disks/'.length))} />;
|
||
if (route.startsWith('/pools/')) return <PoolPage id={decodeURIComponent(route.slice('/pools/'.length))} />;
|
||
if (route.startsWith('/shares/')) return <SharePage id={decodeURIComponent(route.slice('/shares/'.length))} />;
|
||
if (route.startsWith('/applications/')) return <ApplicationPage id={decodeURIComponent(route.slice('/applications/'.length))} />;
|
||
switch (route) {
|
||
case '/': return <OverviewPage />;
|
||
case '/processes': return <RouteSuspense><ProcessPage /></RouteSuspense>;
|
||
case '/containers': return <ContainerPage />;
|
||
case '/services': return <ServicePage />;
|
||
case '/topology': return <RouteSuspense><TopologyPage /></RouteSuspense>;
|
||
case '/network': return <NetworkPage />;
|
||
case '/applications': return <ApplicationPage />;
|
||
case '/host': return <HostPage />;
|
||
case '/array': return <ArrayPage />;
|
||
case '/disks': return <DiskPage />;
|
||
case '/pools': return <PoolPage />;
|
||
case '/shares': return <SharePage />;
|
||
case '/storage': return <StoragePage />;
|
||
case '/capacity': return <CapacityPage />;
|
||
case '/inventory': return <InventoryPage />;
|
||
case '/dashboards': return <DashboardsPage />;
|
||
case '/wallboard': return <WallboardPage />;
|
||
case '/alerts': return <RouteSuspense><AlertsPage /></RouteSuspense>;
|
||
case '/events': return <EventsPage />;
|
||
case '/incidents': return <IncidentPage />;
|
||
case '/settings': return <SettingsPage />;
|
||
case '/status': return <SystemStatusPage />;
|
||
case '/onboarding': return <OnboardingPage />;
|
||
case '/loading': return <StatePage kind="loading" />;
|
||
case '/error': return <StatePage kind="error" />;
|
||
case '/unauthorized': return <StatePage kind="unauthorized" />;
|
||
case '/404': return <NotFoundPage />;
|
||
default: return <NotFoundPage />;
|
||
}
|
||
}
|
||
|
||
function App() {
|
||
const [route, setRoute] = useState<RoutePath>(() => routeFromLocation(window.location.pathname));
|
||
const shellStatus = aggregateStatus(useSystemStatus());
|
||
useEffect(() => { const handleNavigation = () => setRoute(routeFromLocation(window.location.pathname)); window.addEventListener('popstate', handleNavigation); return () => window.removeEventListener('popstate', handleNavigation); }, []);
|
||
useEffect(() => {
|
||
const mobileMenu = document.querySelector<HTMLDetailsElement>('.mobile-more');
|
||
if (mobileMenu?.open) mobileMenu.open = false;
|
||
}, [route]);
|
||
const currentNavigation = navigation.find((item) => item.path === route)
|
||
?? navigation.find((item) => item.path !== '/' && route.startsWith(item.path + '/'))
|
||
?? navigation[0];
|
||
if (route === '/wallboard') return <div className="app-shell app-shell--wallboard"><a className="skip-link" href="#main-content">{copy.accessibility.skipToContent}</a><main id="main-content" className="content"><Page route={route} /></main></div>;
|
||
return <div className="app-shell"><a className="skip-link" href="#main-content">{copy.accessibility.skipToContent}</a><aside className="sidebar"><a className="brand" href="/" title={copy.brand.name} onClick={(event) => { event.preventDefault(); navigate('/'); }}><span className="brand-mark" aria-hidden="true"><span>P</span></span><span className="brand-copy"><strong>{copy.brand.name}</strong><small>{copy.brand.context}</small></span></a><DesktopNavigation route={route} /><nav className="mobile-navigation" aria-label={copy.navigation.label}><ul className="nav-list mobile-primary-list">{mobilePrimaryNavigation.map((item) => <NavigationLink key={item.path} item={item} label={item.path === '/storage' ? copy.navigation.mobileStorage : item.label} />)}</ul><details className="mobile-more" open={mobileMoreNavigation.some((item) => route === item.path)}><summary>{copy.navigation.more}</summary><ul className="nav-list">{mobileMoreNavigation.map((item) => <NavigationLink key={item.path} item={item} />)}</ul></details></nav><div className="sidebar-status" title={shellStatus.detail}><StatusBadge label={shellStatus.label} tone={shellStatus.tone} /><span>{shellStatus.detail}</span></div></aside><div className="app-workspace"><header className="context-bar"><div className="context-location"><span className="context-server"><span className="context-server-mark" aria-hidden="true">T</span><span><small>Server</small><strong>Tower</strong></span></span><span className="context-divider" aria-hidden="true" /><span className="context-product">Pulse</span><span aria-hidden="true">/</span><strong>{route === '/404' ? copy.notFound.context : currentNavigation.label}</strong></div><div className="context-actions"><span className="context-live"><span aria-hidden="true">●</span> Live verbonden</span><span className="context-read-only">Alle bronnen · alleen-lezen</span><StatusBadge label={shellStatus.label} tone={shellStatus.tone} /></div></header><main id="main-content" className="content"><AuthNoticeBanner /><Page route={route} /></main></div></div>;
|
||
}
|
||
export default App;
|