diff --git a/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx b/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx index cc3bbb6647..dc96c0f69f 100644 --- a/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx +++ b/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx @@ -22,9 +22,7 @@ import { type PlatformTableFilterOption, } from '@/features/platformPage/sharedPlatformPage'; import { - PlatformResourceDetailTableRow, createPlatformResourceDetailState, - createPlatformResourceLabelResolver, getPlatformResourceDetailRowClass, } from '@/features/platformPage/PlatformResourceDetailTableRow'; import type { StatusIndicatorVariant } from '@/utils/status'; @@ -118,6 +116,169 @@ const formatStartedAt = (value: string | undefined): string => { }); }; +type AlertDetailTone = 'default' | 'danger' | 'warning' | 'muted'; + +type AlertDetailRow = { + label: string; + value: string; + title?: string; + tone?: AlertDetailTone; +}; + +type AlertDetailSection = { + label: string; + rows: AlertDetailRow[]; +}; + +const detailDateTime = (value?: string): string | null => { + if (!value) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +const detailRow = ( + label: string, + value?: string | null, + options: Pick = {}, +): AlertDetailRow | null => { + const trimmed = value?.trim(); + if (!trimmed || trimmed === '-') return null; + return { label, value: trimmed, ...options }; +}; + +const compactDetailRows = (rows: Array): AlertDetailRow[] => + rows.filter((row): row is AlertDetailRow => Boolean(row)); + +const compactDetailSections = (sections: Array): AlertDetailSection[] => + sections.filter((section): section is AlertDetailSection => + Boolean(section && section.rows.length > 0), + ); + +const alertTone = (severity: TrueNASIncidentRow['severityBucket']): AlertDetailTone => { + if (severity === 'critical') return 'danger'; + if (severity === 'warning') return 'warning'; + return 'muted'; +}; + +const detailValueToneClass = (tone: AlertDetailTone | undefined): string => { + if (tone === 'danger') return 'text-rose-700 dark:text-rose-300'; + if (tone === 'warning') return 'text-amber-700 dark:text-amber-300'; + if (tone === 'muted') return 'text-muted'; + return 'text-base-content'; +}; + +const buildAlertDetailSections = (incident: TrueNASIncidentRow): AlertDetailSection[] => { + const parentName = incident.resource.parentName?.trim(); + return compactDetailSections([ + { + label: 'Alert', + rows: compactDetailRows([ + detailRow('Severity', severityLabel(incident.severity), { + tone: alertTone(incident.severityBucket), + }), + detailRow('Summary', incident.summary), + detailRow('Label', incident.label), + detailRow('Code', formatCode(incident.code), { title: incident.code }), + detailRow('Category', formatCode(incident.category)), + ]), + }, + { + label: 'Source', + rows: compactDetailRows([ + detailRow('Provider', incident.source), + detailRow('Started', detailDateTime(incident.startedAt)), + ]), + }, + { + label: 'Affected resource', + rows: compactDetailRows([ + detailRow('Name', incident.resourceName), + detailRow('Type', formatResourceType(incident.resourceType)), + detailRow('Parent', parentName), + detailRow('Resource ID', incident.resourceId), + ]), + }, + { + label: 'Action', + rows: compactDetailRows([detailRow('Recommended', incident.action)]), + }, + ]); +}; + +const AlertDetailTable: Component<{ incident: TrueNASIncidentRow; onClose: () => void }> = ( + props, +) => { + const sections = () => buildAlertDetailSections(props.incident); + + return ( +
+
+
+
+ Alert detail +
+
+ {severityLabel(props.incident.severity)} ยท {formatCode(props.incident.code)} +
+
+ +
+
+ + + + {(section) => ( + <> + + + {section.label} + + + + {(row) => ( + + + {row.label} + + + {row.value} + + + )} + + + )} + + +
+
+
+ ); +}; + export const TrueNASAlertsTable: Component<{ incidents: TrueNASIncidentRow[]; scope: readonly Resource[]; @@ -132,7 +293,6 @@ export const TrueNASAlertsTable: Component<{ filter: filterTrueNASIncidents, }); const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-alert-drawer' }); - const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope); const filteredEmptyState = () => getAlertFilteredEmptyState('TrueNAS alerts', 'severity'); return ( @@ -282,14 +442,28 @@ export const TrueNASAlertsTable: Component<{ - drawer.close(incident)} - /> + + + +
event.stopPropagation()} + > + drawer.close(incident)} + /> +
+
+
+
); }} diff --git a/frontend-modern/src/features/truenas/__tests__/TrueNASAlertsTable.test.tsx b/frontend-modern/src/features/truenas/__tests__/TrueNASAlertsTable.test.tsx new file mode 100644 index 0000000000..cc52e0441c --- /dev/null +++ b/frontend-modern/src/features/truenas/__tests__/TrueNASAlertsTable.test.tsx @@ -0,0 +1,88 @@ +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { TrueNASAlertsTable } from '@/features/truenas/TrueNASAlertsTable'; +import { buildTrueNASIncidentRows } from '@/features/truenas/truenasPageModel'; +import type { Resource } from '@/types/resource'; + +const makeDisk = (overrides: Partial = {}): Resource => + ({ + id: 'disk-sdc', + type: 'physical_disk', + name: 'sdc', + displayName: 'sdc', + parentId: 'pool-archive', + parentName: 'archive', + status: 'degraded', + platformType: 'truenas', + platformScopes: ['truenas'], + truenas: { hostname: 'nas-primary' }, + physicalDisk: { + devPath: '/dev/sdc', + serial: 'WD-WX12A3456', + health: 'DEGRADED', + storageGroup: 'archive', + }, + incidents: [ + { + code: 'smart', + severity: 'warning', + source: 'SMART', + summary: 'Device /dev/sdc has SMART test failures.', + startedAt: '2026-05-21T16:03:00Z', + }, + ], + incidentAction: 'Investigate disk health and schedule replacement if degradation continues', + ...overrides, + }) as Resource; + +afterEach(() => { + cleanup(); +}); + +describe('TrueNASAlertsTable', () => { + it('opens inline native alert details for TrueNAS incident rows', async () => { + const disk = makeDisk(); + const incidents = buildTrueNASIncidentRows([disk]); + + render(() => ( + } + emptyTitle="No alerts" + emptyDescription="No alerts" + showToolbar={false} + /> + )); + + const row = screen.getByText('Device /dev/sdc has SMART test failures.').closest('tr'); + expect(row).toBeTruthy(); + expect(row).toHaveAttribute('aria-expanded', 'false'); + + await fireEvent.click(row!); + + expect(row).toHaveAttribute('aria-expanded', 'true'); + expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument(); + const detail = within(screen.getByTestId('truenas-alert-detail')); + expect(detail.getByText('Alert detail')).toBeInTheDocument(); + expect(detail.getByText('Alert')).toBeInTheDocument(); + expect(detail.getByText('Source')).toBeInTheDocument(); + expect(detail.getByText('Affected resource')).toBeInTheDocument(); + expect(detail.getByText('Action')).toBeInTheDocument(); + expect(detail.getByText('Severity')).toBeInTheDocument(); + expect(detail.getAllByText('Warning').length).toBeGreaterThan(0); + expect(detail.getByText('Provider')).toBeInTheDocument(); + expect(detail.getByText('SMART')).toBeInTheDocument(); + expect(detail.getByText('Resource ID')).toBeInTheDocument(); + expect(detail.getByText('disk-sdc')).toBeInTheDocument(); + expect( + detail.getByText('Investigate disk health and schedule replacement if degradation continues'), + ).toBeInTheDocument(); + + await fireEvent.click(detail.getByRole('button', { name: 'Close' })); + + expect(screen.queryByTestId('truenas-alert-detail')).not.toBeInTheDocument(); + expect(row).toHaveAttribute('aria-expanded', 'false'); + }); +});