From 0fb0287e8642fad5ea1dfaeb73da8b6090c4a62f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 21 May 2026 00:34:00 +0100 Subject: [PATCH] Surface TrueNAS health alerts on overview --- .../platformOverviewLayout.guardrails.test.ts | 3 + .../platformPage/sharedPlatformPage.tsx | 6 +- .../features/truenas/TrueNASAlertsTable.tsx | 306 ++++++++++++++++++ .../features/truenas/TrueNASPageSurface.tsx | 10 + .../__tests__/truenasPageModel.test.ts | 83 +++++ .../src/features/truenas/truenasPageModel.ts | 191 ++++++++++- .../src/hooks/useUnifiedResources.ts | 20 ++ frontend-modern/src/types/resource.ts | 12 + .../alertOverviewPresentation.test.ts | 5 + .../src/utils/alertOverviewPresentation.ts | 9 + 10 files changed, 641 insertions(+), 4 deletions(-) create mode 100644 frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index a4cab6a47b..44fb28d175 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -15,6 +15,7 @@ import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?ra import proxmoxPageSurfaceSource from '@/features/proxmox/ProxmoxPageSurface.tsx?raw'; import proxmoxReplicationTableSource from '@/features/proxmox/ProxmoxReplicationTable.tsx?raw'; import sharedPlatformPageSource from '@/features/platformPage/sharedPlatformPage.tsx?raw'; +import truenasAlertsTableSource from '@/features/truenas/TrueNASAlertsTable.tsx?raw'; import truenasPageSurfaceSource from '@/features/truenas/TrueNASPageSurface.tsx?raw'; import truenasSystemsTableSource from '@/features/truenas/TrueNASSystemsTable.tsx?raw'; import truenasVirtualMachinesTableSource from '@/features/truenas/TrueNASVirtualMachinesTable.tsx?raw'; @@ -28,6 +29,7 @@ const platformTableSources = [ kubernetesClustersTableSource, kubernetesNodesTableSource, kubernetesDeploymentsTableSource, + truenasAlertsTableSource, truenasSystemsTableSource, truenasVirtualMachinesTableSource, vsphereHostsTableSource, @@ -157,6 +159,7 @@ describe('platform overview layout guardrails', () => { expect(kubernetesPageSurfaceSource).toContain('(props: { - resources: () => Resource[]; +export function createPlatformTableFilterState(props: { + resources: () => Row[]; initialStatus: Status; - filter: (resources: Resource[], search: string, status: Status) => Resource[]; + filter: (resources: Row[], search: string, status: Status) => Row[]; }) { const [search, setSearch] = createSignal(''); const [status, setStatus] = createSignal(props.initialStatus); diff --git a/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx b/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx new file mode 100644 index 0000000000..9f1579c0e8 --- /dev/null +++ b/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx @@ -0,0 +1,306 @@ +import { For, Show, type Component, type JSX } from 'solid-js'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { TableCard } from '@/components/shared/TableCard'; +import { TableCardHeader } from '@/components/shared/TableCardHeader'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/shared/Table'; +import { + PLATFORM_TABLE_BODY_CLASS, + PLATFORM_TABLE_CARD_CLASS, + PLATFORM_TABLE_HEADER_ROW_CLASS, + PlatformTableEmptyState, + PlatformTableToolbar, + createPlatformTableFilterState, + getPlatformTableCellClassForKind, + getPlatformTableHeadClassForKind, + type PlatformTableFilterOption, +} from '@/features/platformPage/sharedPlatformPage'; +import { + PlatformResourceDetailTableRow, + createPlatformResourceDetailState, + createPlatformResourceLabelResolver, + getPlatformResourceDetailRowClass, +} from '@/features/platformPage/PlatformResourceDetailTableRow'; +import type { StatusIndicatorVariant } from '@/utils/status'; +import { + filterTrueNASIncidents, + type TrueNASIncidentRow, + type TrueNASIncidentSeverityFilter, +} from './truenasPageModel'; +import type { Resource, ResourceType } from '@/types/resource'; +import { getAlertFilteredEmptyState } from '@/utils/alertOverviewPresentation'; + +const TRUENAS_INCIDENT_STATUS_OPTIONS: PlatformTableFilterOption[] = + [ + { value: 'all', label: 'All' }, + { value: 'critical', label: 'Critical', tone: 'danger' }, + { value: 'warning', label: 'Warning', tone: 'warning' }, + { value: 'info', label: 'Info', tone: 'success' }, + ]; + +const severityVariant = ( + severity: TrueNASIncidentRow['severityBucket'], +): StatusIndicatorVariant => { + switch (severity) { + case 'critical': + return 'danger'; + case 'warning': + return 'warning'; + case 'info': + return 'muted'; + } +}; + +const severityLabel = (severity: string): string => { + const normalized = severity.trim(); + if (!normalized) return 'Info'; + return normalized.charAt(0).toUpperCase() + normalized.slice(1).toLowerCase(); +}; + +const severityTextClass = (severity: TrueNASIncidentRow['severityBucket']): string => { + switch (severity) { + case 'critical': + return 'text-red-700 dark:text-red-300'; + case 'warning': + return 'text-amber-700 dark:text-amber-300'; + case 'info': + return 'text-muted'; + } +}; + +const formatResourceType = (type: ResourceType): string => { + switch (type) { + case 'agent': + return 'System'; + case 'storage': + case 'pool': + return 'Pool'; + case 'dataset': + return 'Dataset'; + case 'physical_disk': + return 'Disk'; + case 'network-share': + return 'Share'; + case 'vm': + return 'VM'; + case 'app-container': + return 'App'; + default: + return type; + } +}; + +const formatCode = (code: string): string => { + const normalized = code.trim().replace(/^truenas_/, ''); + if (!normalized) return '-'; + return normalized + .split(/[\s_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(' '); +}; + +const formatStartedAt = (value: string | undefined): string => { + if (!value) return '-'; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return '-'; + return parsed.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +export const TrueNASAlertsTable: Component<{ + incidents: TrueNASIncidentRow[]; + scope: readonly Resource[]; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; + showToolbar?: boolean; +}> = (props) => { + const tableState = createPlatformTableFilterState({ + resources: () => props.incidents, + initialStatus: 'all' as TrueNASIncidentSeverityFilter, + filter: filterTrueNASIncidents, + }); + const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-alert-drawer' }); + const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope); + const filteredEmptyState = () => getAlertFilteredEmptyState('TrueNAS alerts', 'severity'); + + return ( + 0} + fallback={ + + } + > +
+ + + + + 0} + fallback={ + + } + > + + + + + + + Resource + + + Severity + + + Alert + + + + + + + + + {(incident) => { + const detailRowId = () => drawer.detailRowId(incident); + const isExpanded = () => drawer.isExpanded(incident); + return ( + <> + drawer.toggle(incident)} + onKeyDown={drawer.handleActivationKey(incident)} + tabIndex={0} + > + +
+ +
+
+ {incident.resourceName} +
+
+ {formatResourceType(incident.resourceType)} + + {' '} + on {incident.resource.parentName} + +
+
+
+
+ + + {severityLabel(incident.severity)} + + + + + {incident.summary} + + + {incident.label} + + + + + +
+ drawer.close(incident)} + /> + + ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default TrueNASAlertsTable; diff --git a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx index 2d26bd85dd..1f40f56fc6 100644 --- a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx +++ b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx @@ -11,6 +11,7 @@ import { PlatformTableLoadingState, } from '@/features/platformPage/sharedPlatformPage'; import { TrueNASAppsTable } from './TrueNASAppsTable'; +import { TrueNASAlertsTable } from './TrueNASAlertsTable'; import { TrueNASNetworkSharesTable } from './TrueNASNetworkSharesTable'; import { TrueNASSystemsTable } from './TrueNASSystemsTable'; import { TrueNASVirtualMachinesTable } from './TrueNASVirtualMachinesTable'; @@ -128,6 +129,15 @@ function TrueNASOverview(props: TrueNASOverviewProps) { emptyDescription="TrueNAS systems appear here once a TrueNAS connection reports its top-level appliance." showToolbar={false} /> + 0}> + + 0}> { filterTrueNASShares([media, archive], '10.10.20.0/24', 'disabled').map((r) => r.id), ).toEqual(['share-archive']); }); + + it('derives native TrueNAS alert rows from resource incidents', () => { + const system = makeResource({ + id: 'truenas-system', + type: 'agent', + incidentCount: 2, + incidentSeverity: 'critical', + incidentLabel: 'Storage Health Issue', + incidentAction: 'Investigate storage health immediately', + incidents: [ + { + provider: 'truenas', + nativeId: 'alert-system', + code: 'truenas_volume_status', + severity: 'CRITICAL', + source: 'VolumeStatus', + summary: 'Pool tank is FAULTED', + startedAt: '2026-05-20T12:00:00Z', + }, + ], + }); + const disk = makeResource({ + id: 'truenas-disk-sda', + type: 'physical_disk', + parentName: 'tank', + incidentCount: 1, + incidentSeverity: 'warning', + incidentLabel: 'Disk Health Issue', + incidents: [ + { + provider: 'truenas', + nativeId: 'alert-smart', + code: 'truenas_smart', + severity: 'WARNING', + source: 'SMART', + summary: 'Device /dev/sda has unreadable sectors', + }, + ], + }); + const poolRollup = makeResource({ + id: 'truenas-pool-archive', + type: 'storage', + incidentCount: 1, + incidentSeverity: 'warning', + incidentCode: 'truenas_scrub', + incidentLabel: 'Scrub Issue', + incidentSummary: 'Last scrub found checksum errors', + }); + const dockerIncident = makeResource({ + id: 'docker-host', + type: 'agent', + platformType: 'docker', + incidentCount: 1, + incidentSummary: 'Docker incident should not appear', + }); + + const model = buildTrueNASPageModel([system, disk, poolRollup, dockerIncident]); + + expect(model.incidents.map((incident) => incident.resourceId)).toEqual([ + 'truenas-system', + 'truenas-disk-sda', + 'truenas-pool-archive', + ]); + expect(model.incidents[0]).toMatchObject({ + severityBucket: 'critical', + code: 'truenas_volume_status', + summary: 'Pool tank is FAULTED', + action: 'Investigate storage health immediately', + }); + expect(model.incidents[2]).toMatchObject({ + id: 'truenas-pool-archive:incident:rollup', + code: 'truenas_scrub', + summary: 'Last scrub found checksum errors', + }); + expect(mapTrueNASIncidentSeverity('WARNING')).toBe('warning'); + expect( + filterTrueNASIncidents(model.incidents, 'smart', 'warning').map( + (incident) => incident.resourceId, + ), + ).toEqual(['truenas-disk-sda']); + }); }); diff --git a/frontend-modern/src/features/truenas/truenasPageModel.ts b/frontend-modern/src/features/truenas/truenasPageModel.ts index aad9e59233..ae8cd0c4cc 100644 --- a/frontend-modern/src/features/truenas/truenasPageModel.ts +++ b/frontend-modern/src/features/truenas/truenasPageModel.ts @@ -1,10 +1,12 @@ import { resolveResourcePlatformType } from '@/utils/sourcePlatforms'; -import type { Resource, ResourceType } from '@/types/resource'; +import { asTrimmedString } from '@/utils/stringUtils'; +import type { Resource, ResourceIncident, ResourceType } from '@/types/resource'; export type TrueNASPageTabId = 'overview' | 'storage' | 'protection'; export type TrueNASAppStatusFilter = 'all' | 'running' | 'attention' | 'stopped'; export type TrueNASVMStatusFilter = 'all' | 'running' | 'attention' | 'stopped'; export type TrueNASShareStatusFilter = 'all' | 'active' | 'attention' | 'disabled'; +export type TrueNASIncidentSeverityFilter = 'all' | 'critical' | 'warning' | 'info'; export type TrueNASTabSpec = { id: TrueNASPageTabId; @@ -42,6 +44,25 @@ export type TrueNASPageModel = { shares: Resource[]; vms: Resource[]; apps: Resource[]; + incidents: TrueNASIncidentRow[]; +}; + +export type TrueNASIncidentRow = { + id: string; + resource: Resource; + resourceId: string; + resourceName: string; + resourceType: ResourceType; + severity: string; + severityBucket: Exclude; + code: string; + source: string; + summary: string; + label: string; + category: string; + startedAt?: string; + action: string; + priority: number; }; export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel { @@ -59,12 +80,146 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel { shares, vms, apps, + incidents: buildTrueNASIncidentRows(trueNasResources), }; } const normalize = (value: unknown): string => typeof value === 'string' ? value.trim().toLowerCase() : ''; +const titleize = (value: string): string => + value + .split(/[\s_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(' '); + +const incidentSeverityRank = (severity: string): number => { + switch (mapTrueNASIncidentSeverity(severity)) { + case 'critical': + return 3; + case 'warning': + return 2; + case 'info': + return 1; + } +}; + +export function mapTrueNASIncidentSeverity( + severity: string | undefined, +): Exclude { + const normalized = normalize(severity); + if (['critical', 'crit', 'fatal', 'error', 'failed', 'failure'].includes(normalized)) { + return 'critical'; + } + if (['warning', 'warn', 'alert', 'degraded'].includes(normalized)) return 'warning'; + return 'info'; +} + +const resourceIncidentLabel = (resource: Resource, incident: ResourceIncident): string => { + const label = asTrimmedString(resource.incidentLabel); + if (label) return label; + const code = asTrimmedString(incident.code); + return code ? titleize(code.replace(/^truenas_/, '')) : 'TrueNAS Alert'; +}; + +const resourceDisplayName = (resource: Resource): string => + asTrimmedString(resource.displayName) || + asTrimmedString(resource.name) || + asTrimmedString(resource.truenas?.hostname) || + resource.id; + +const hasIncidentSignal = (incident: ResourceIncident): boolean => + Boolean(asTrimmedString(incident.code) || asTrimmedString(incident.summary)); + +const hasIncidentRollup = (resource: Resource): boolean => + (resource.incidentCount ?? 0) > 0 || + Boolean( + asTrimmedString(resource.incidentCode) || + asTrimmedString(resource.incidentSummary) || + asTrimmedString(resource.incidentLabel), + ); + +const buildIncidentRow = ( + resource: Resource, + incident: ResourceIncident, + index: number, +): TrueNASIncidentRow => { + const severity = + asTrimmedString(incident.severity) || asTrimmedString(resource.incidentSeverity) || 'info'; + const code = + asTrimmedString(incident.code) || asTrimmedString(resource.incidentCode) || 'truenas_alert'; + const summary = + asTrimmedString(incident.summary) || + asTrimmedString(resource.incidentSummary) || + resourceIncidentLabel(resource, incident); + const nativeId = asTrimmedString(incident.nativeId); + const rowKey = nativeId || code || String(index); + + return { + id: `${resource.id}:incident:${rowKey}:${index}`, + resource, + resourceId: resource.id, + resourceName: resourceDisplayName(resource), + resourceType: resource.type, + severity, + severityBucket: mapTrueNASIncidentSeverity(severity), + code, + source: asTrimmedString(incident.source) || asTrimmedString(incident.provider) || 'truenas', + summary, + label: resourceIncidentLabel(resource, incident), + category: asTrimmedString(resource.incidentCategory) || 'health', + startedAt: incident.startedAt, + action: asTrimmedString(resource.incidentAction) || 'Investigate in TrueNAS', + priority: resource.incidentPriority ?? incidentSeverityRank(severity) * 1000, + }; +}; + +const buildRollupIncidentRow = (resource: Resource): TrueNASIncidentRow => { + const severity = asTrimmedString(resource.incidentSeverity) || 'info'; + const code = asTrimmedString(resource.incidentCode) || 'truenas_alert'; + const count = resource.incidentCount ?? 0; + const summary = + asTrimmedString(resource.incidentSummary) || + asTrimmedString(resource.incidentLabel) || + `${count || 1} active TrueNAS alert${count === 1 ? '' : 's'}`; + const incident: ResourceIncident = { + code, + severity, + summary, + source: 'truenas', + }; + + return { + ...buildIncidentRow(resource, incident, 0), + id: `${resource.id}:incident:rollup`, + }; +}; + +export function buildTrueNASIncidentRows(resources: Resource[]): TrueNASIncidentRow[] { + const rows: TrueNASIncidentRow[] = []; + for (const resource of resources) { + const incidents = (resource.incidents ?? []).filter(hasIncidentSignal); + if (incidents.length > 0) { + incidents.forEach((incident, index) => + rows.push(buildIncidentRow(resource, incident, index)), + ); + continue; + } + if (hasIncidentRollup(resource)) { + rows.push(buildRollupIncidentRow(resource)); + } + } + + return rows.sort((a, b) => { + const severityDelta = incidentSeverityRank(b.severity) - incidentSeverityRank(a.severity); + if (severityDelta !== 0) return severityDelta; + const priorityDelta = b.priority - a.priority; + if (priorityDelta !== 0) return priorityDelta; + return a.resourceName.localeCompare(b.resourceName); + }); +} + export function mapTrueNASAppStatus(resource: Resource): Exclude { const state = normalize(resource.truenas?.app?.state); if (state === 'running') return 'running'; @@ -275,3 +430,37 @@ export function filterTrueNASShares( return shareSearchHaystack(share).includes(needle); }); } + +const incidentSearchHaystack = (row: TrueNASIncidentRow): string => + [ + row.resourceName, + row.resourceId, + row.resourceType, + row.resource.parentName, + row.resource.platformId, + row.resource.truenas?.hostname, + row.severity, + row.code, + row.source, + row.summary, + row.label, + row.category, + row.action, + ...(row.resource.tags ?? []), + ] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join(' ') + .toLowerCase(); + +export function filterTrueNASIncidents( + incidents: TrueNASIncidentRow[], + search: string, + severity: TrueNASIncidentSeverityFilter, +): TrueNASIncidentRow[] { + const needle = normalize(search); + return incidents.filter((incident) => { + if (severity !== 'all' && incident.severityBucket !== severity) return false; + if (!needle) return true; + return incidentSearchHaystack(incident).includes(needle); + }); +} diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 9ff406276b..94734c8c49 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -445,6 +445,15 @@ type APIResource = { }; }; aiSafeSummary?: string; + incidents?: Array<{ + provider?: string; + nativeId?: string; + code?: string; + severity?: string; + source?: string; + summary?: string; + startedAt?: string; + }>; incidentCount?: number; incidentCode?: string; incidentSeverity?: string; @@ -652,6 +661,17 @@ const toResource = (v2: APIResource): Resource => { parentName: v2.parentName, clusterId: getExplicitResourceClusterName(v2), status: resolveStatus(v2.status), + incidents: (v2.incidents || []) + .map((incident) => ({ + provider: incident.provider, + nativeId: incident.nativeId, + code: incident.code || '', + severity: incident.severity || '', + source: incident.source, + summary: incident.summary || '', + startedAt: incident.startedAt, + })) + .filter((incident) => incident.code.trim() || incident.summary.trim()), incidentCount: v2.incidentCount, incidentCode: v2.incidentCode, incidentSeverity: v2.incidentSeverity, diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 5f6a22c58c..98363f28a6 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -102,6 +102,17 @@ export interface ResourceAlert { startTime: number; // Unix milliseconds } +// Provider-native incident associated with a unified resource. +export interface ResourceIncident { + provider?: string; + nativeId?: string; + code: string; + severity: string; + source?: string; + summary: string; + startedAt?: string; +} + // Identity information for deduplication export interface ResourceIdentity { hostname?: string; @@ -906,6 +917,7 @@ export interface Resource { labels?: Record; lastSeen: number; // Unix milliseconds alerts?: ResourceAlert[]; + incidents?: ResourceIncident[]; incidentCount?: number; incidentCode?: string; incidentSeverity?: string; diff --git a/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts index b4d9fed35a..b808f6add1 100644 --- a/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts @@ -38,6 +38,7 @@ import { getAlertHistoryLoadingState, getAlertHistorySearchPlaceholder, getAlertBucketCountLabel, + getAlertFilteredEmptyState, getAlertTimelineEmptyState, getAlertTimelineFailureState, getAlertTimelineFilterEmptyState, @@ -71,6 +72,10 @@ describe('alertOverviewPresentation', () => { title: 'No alerts found', description: 'Try adjusting your filters or check back later', }); + expect(getAlertFilteredEmptyState('TrueNAS alerts', 'severity')).toEqual({ + title: 'No TrueNAS alerts match current filters', + description: 'Adjust the search or severity filter to see more TrueNAS alerts.', + }); expect(ALERT_HISTORY_LOADING_STATE).toBe('Loading alert history...'); expect(getAlertHistoryLoadingState()).toEqual({ text: 'Loading alert history...', diff --git a/frontend-modern/src/utils/alertOverviewPresentation.ts b/frontend-modern/src/utils/alertOverviewPresentation.ts index 189e3d0c57..ddd32ed559 100644 --- a/frontend-modern/src/utils/alertOverviewPresentation.ts +++ b/frontend-modern/src/utils/alertOverviewPresentation.ts @@ -117,6 +117,15 @@ export function getAlertHistoryEmptyState() { } as const; } +export function getAlertFilteredEmptyState(subject = 'alerts', filterLabel = 'active') { + const normalizedSubject = subject.trim() || 'alerts'; + const normalizedFilterLabel = filterLabel.trim() || 'active'; + return { + title: `No ${normalizedSubject} match current filters`, + description: `Adjust the search or ${normalizedFilterLabel} filter to see more ${normalizedSubject}.`, + } as const; +} + export function getAlertHistoryLoadingState() { return { text: ALERT_HISTORY_LOADING_STATE,