Surface TrueNAS health alerts on overview

This commit is contained in:
rcourtman
2026-05-21 00:34:00 +01:00
parent 58a2ad8cc6
commit 0fb0287e86
10 changed files with 641 additions and 4 deletions
@@ -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('<KubernetesNodesTable');
expect(kubernetesPageSurfaceSource).toContain('<KubernetesDeploymentsTable');
expect(truenasPageSurfaceSource).toContain('<TrueNASSystemsTable');
expect(truenasPageSurfaceSource).toContain('<TrueNASAlertsTable');
expect(truenasPageSurfaceSource).toContain('<TrueNASVirtualMachinesTable');
expect(truenasPageSurfaceSource).toContain('<TrueNASAppsTable');
expect(truenasPageSurfaceSource).toContain('<StorageSurface');
@@ -235,10 +235,10 @@ export const filterPlatformResources = (
return result;
};
export function createPlatformTableFilterState<Status extends string | number>(props: {
resources: () => Resource[];
export function createPlatformTableFilterState<Row, Status extends string | number>(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<Status>(props.initialStatus);
@@ -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<TrueNASIncidentSeverityFilter>[] =
[
{ 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 (
<Show
when={props.incidents.length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<div class="space-y-3">
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search TrueNAS alerts"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={TRUENAS_INCIDENT_STATUS_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="alerts"
/>
</Show>
<Show
when={tableState.filtered().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={filteredEmptyState().title}
description={filteredEmptyState().description}
/>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title="Health Alerts" />
<Table class="min-w-full table-fixed text-xs md:min-w-[1120px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[20%]`}>
Resource
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[10%]`}>
Severity
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[32%]`}>
Alert
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[13%]`}
>
Source
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[10%]`}
>
Started
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden xl:table-cell md:w-[15%]`}
>
Action
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(incident) => {
const detailRowId = () => drawer.detailRowId(incident);
const isExpanded = () => drawer.isExpanded(incident);
return (
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-truenas-alert-row={incident.id}
onClick={() => drawer.toggle(incident)}
onKeyDown={drawer.handleActivationKey(incident)}
tabIndex={0}
>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={severityVariant(incident.severityBucket)}
title={severityLabel(incident.severity)}
/>
<div class="min-w-0">
<div
class="truncate font-medium text-base-content"
title={incident.resourceName}
>
{incident.resourceName}
</div>
<div class="truncate text-[10px] text-muted">
{formatResourceType(incident.resourceType)}
<Show when={incident.resource.parentName}>
{' '}
on {incident.resource.parentName}
</Show>
</div>
</div>
</div>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<span
class={`text-[11px] font-semibold ${severityTextClass(
incident.severityBucket,
)}`}
>
{severityLabel(incident.severity)}
</span>
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('text')}`}>
<span class="block truncate text-base-content" title={incident.summary}>
{incident.summary}
</span>
<span class="block truncate text-[10px] text-muted">
{incident.label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden md:table-cell`}
>
<span class="block truncate" title={incident.code}>
{formatCode(incident.code)}
</span>
<span class="block truncate text-[10px] text-muted">
{incident.source}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
>
{formatStartedAt(incident.startedAt)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content xl:table-cell`}
>
<span class="block truncate" title={incident.action}>
{incident.action}
</span>
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={incident.resource}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={6}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(incident)}
/>
</>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</div>
</Show>
);
};
export default TrueNASAlertsTable;
@@ -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}
/>
<Show when={props.model().incidents.length > 0}>
<TrueNASAlertsTable
incidents={props.model().incidents}
scope={props.model().resources}
emptyIcon={truenasIcon()}
emptyTitle="No active TrueNAS alerts"
emptyDescription="TrueNAS health alerts appear here when the TrueNAS API reports active system, pool, or disk incidents."
/>
</Show>
<Show when={props.model().shares.length > 0}>
<TrueNASNetworkSharesTable
shares={props.model().shares}
@@ -4,9 +4,11 @@ import {
TRUENAS_TAB_SPECS,
buildTrueNASPageModel,
filterTrueNASApps,
filterTrueNASIncidents,
filterTrueNASShares,
filterTrueNASVMs,
mapTrueNASAppStatus,
mapTrueNASIncidentSeverity,
mapTrueNASShareStatus,
mapTrueNASVMStatus,
} from '../truenasPageModel';
@@ -191,4 +193,85 @@ describe('truenasPageModel', () => {
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']);
});
});
@@ -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<TrueNASIncidentSeverityFilter, 'all'>;
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<TrueNASIncidentSeverityFilter, 'all'> {
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<TrueNASAppStatusFilter, 'all'> {
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);
});
}
@@ -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,
+12
View File
@@ -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<string, string>;
lastSeen: number; // Unix milliseconds
alerts?: ResourceAlert[];
incidents?: ResourceIncident[];
incidentCount?: number;
incidentCode?: string;
incidentSeverity?: string;
@@ -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...',
@@ -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,