Add TrueNAS alert detail rows

This commit is contained in:
rcourtman
2026-05-22 05:42:38 +01:00
parent f9bd009a1c
commit 4a371c6b1f
2 changed files with 273 additions and 11 deletions
@@ -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, 'title' | 'tone'> = {},
): AlertDetailRow | null => {
const trimmed = value?.trim();
if (!trimmed || trimmed === '-') return null;
return { label, value: trimmed, ...options };
};
const compactDetailRows = (rows: Array<AlertDetailRow | null>): AlertDetailRow[] =>
rows.filter((row): row is AlertDetailRow => Boolean(row));
const compactDetailSections = (sections: Array<AlertDetailSection | null>): 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 (
<div
class="space-y-3"
data-testid="truenas-alert-detail"
data-truenas-alert-detail-for={props.incident.id}
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content">
Alert detail
</div>
<div class="mt-1 text-[10px] text-muted">
{severityLabel(props.incident.severity)} · {formatCode(props.incident.code)}
</div>
</div>
<button
type="button"
class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[10px] font-medium text-base-content transition-colors hover:bg-base"
onClick={props.onClose}
>
Close
</button>
</div>
<div class="overflow-hidden rounded border border-border bg-surface">
<Table class="w-full table-fixed text-[11px]">
<TableBody class="divide-y divide-border">
<For each={sections()}>
{(section) => (
<>
<TableRow class="bg-surface-alt">
<TableHead
colspan={2}
class="px-2 py-1 text-left text-[10px] font-semibold uppercase tracking-wide text-muted"
>
{section.label}
</TableHead>
</TableRow>
<For each={section.rows}>
{(row) => (
<TableRow>
<TableCell class="w-[38%] px-2 py-1 align-top text-muted">
{row.label}
</TableCell>
<TableCell
class={`px-2 py-1 text-right align-top font-medium ${detailValueToneClass(row.tone)}`}
title={row.title ?? row.value}
>
<span class="block truncate">{row.value}</span>
</TableCell>
</TableRow>
)}
</For>
</>
)}
</For>
</TableBody>
</Table>
</div>
</div>
);
};
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<{
</span>
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={incident.resource}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={6}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(incident)}
/>
<Show when={isExpanded()}>
<TableRow
data-inline-detail-for={incident.id}
data-truenas-alert-detail-row={incident.id}
>
<TableCell
id={detailRowId()}
colspan={6}
class="border-b border-border bg-surface-alt p-0"
>
<div
class="px-2 py-3 sm:px-4 sm:py-4"
onClick={(event) => event.stopPropagation()}
>
<AlertDetailTable
incident={incident}
onClose={() => drawer.close(incident)}
/>
</div>
</TableCell>
</TableRow>
</Show>
</>
);
}}
@@ -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> = {}): 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(() => (
<TrueNASAlertsTable
incidents={incidents}
scope={[disk]}
emptyIcon={<span />}
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');
});
});