mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 16:42:15 +00:00
Surface TrueNAS health alerts on overview
This commit is contained in:
+3
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user