Say why TrueNAS storage is unhealthy, drop the healthy pills

The Storage tab put a green Healthy pill on every pool, dataset and disk,
repeating the status dot, while the few amber rows gave no reason. The
pool said Warning where the dataset said Attention for the same bucket,
and in the disk scope the pill clipped past the phone edge.

Healthy rows now leave Health empty. A non-healthy row says why in the
Health column, on the row's single line as the shared platform-table
rhythm requires: TrueNAS's own alert text first, Pulse's risk summary when
TrueNAS raised no alert, then the dataset state tag (read-only datasets
raise no incident, so the tag is the provider's only reason), and a plain
fallback so no amber row is silent. Stale TrueNAS data leads when it is
the cause. A count stands in for further reasons, with every reason on
hover and for screen readers. Health sorts by severity and is
desktop-only in every scope, so phones keep the status dot and the drawer.
This commit is contained in:
rcourtman
2026-10-05 06:37:24 +01:00
parent fdcffc6411
commit d6ccdd6943
8 changed files with 523 additions and 51 deletions
@@ -660,9 +660,11 @@ The TrueNAS Storage `Storage type` scope follows the same boundary with stable
the current option through pressed-state semantics, preserves the scope in the
URL across reload, and removes the query value for the default `all` state.
On narrow screens the physical-disk scope may reprioritize its canonical table
columns to endurance, temperature, and health while retaining the shared table
columns to endurance and temperature while retaining the shared table
overflow and touch-target behavior; it must not introduce a second mobile-only
filter or table shell.
filter or table shell. Its Health column stays desktop-only in every scope:
the status dot carries a disk's condition on a phone, the row drawer carries
the reason, and the old pill clipped past the phone container edge.
Large-estate platform pages must keep one canonical inventory snapshot for the
initial read and explicit refresh path. The Proxmox and VMware vSphere
@@ -960,7 +960,18 @@ before text and status filters so metadata from an excluded disk cannot retain
its pool or dataset ancestor. Physical-disk presentation may prioritize the
canonical SMART health, temperature, and endurance evidence; absent wearout or
percentage-used telemetry remains unknown rather than being rendered as zero
remaining life.
remaining life. Storage health is exception-first: `getTrueNASStorageIssue`
returns nothing for healthy pools, datasets, and disks, so their Health cell
stays empty and the status dot alone says healthy. A non-healthy row carries
the reasons TrueNAS gave, in order: native TrueNAS alert text on that
resource, storage and physical-disk risk summaries, then the dataset state tag
(`state:locked`, `state:unmounted`, `state:readonly`, which is the only place
the provider says why a dataset is impaired). Without any of those it falls
back to the pool state, the disk health value, or an impaired TrueNAS source.
The first reason renders in the desktop Health column on the row's single line,
coloured by bucket, with a count for the rest and the full list in the title
and screen-reader text. Phones keep the status dot and reach the reason through
the row drawer. Health sorts by severity rank, not label text.
That shell also owns responsive width composition. Source tables may declare
desktop breakpoint floors, but the phone branch must fit its selected columns
inside the available container without a horizontal rail. Each unified-resource
@@ -0,0 +1,45 @@
{
"version": 1,
"base_sha": "fdcffc64114bd9d7ff7a92324391a09702c3c26a",
"verified_at": "2026-10-05T05:37:16Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
"frontend-modern/src/features/truenas/truenasPageModel.ts"
],
"content_sha256": {
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "19e1e5b705285464607804f8a8d7b5174e39c856290bd8c6ce5dc5e1212ff2af",
"frontend-modern/src/features/truenas/truenasPageModel.ts": "b1955834430d00fca3fc4176614a83f4a8387c8c29a85f0f17c6fd1360faf514"
},
"routes": [
"/truenas/storage",
"/truenas/storage?kind=disks"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 820,
"height": 700
},
{
"width": 390,
"height": 844
}
],
"states": [
"Storage at 1440: every row is single-line. 18 healthy rows leave Health empty, where before each carried a green Healthy pill. The archive pool reads Pool archive is DEGRADED: one member of archive mirror-0 is reporting checksum and SMART faults (truncated, +1 for the copied SMART alert, full list on hover), archive/cold reads Dataset is read-only, sdc reads Device /dev/sdc has SMART test failures, coloured amber in the Health column",
"Health sorts by severity rank (Attention, Offline, Unknown, Healthy), not by label text",
"Disk scope at 1440: Endurance and Temp visible, sdc reason in Health",
"Tablet at 820 and dark mode at 1440: reasons truncate cleanly with the +1 kept",
"Phone at 390: Resource, Kind, Usage, Disks with status dots, Health hidden, so the old pill no longer clips past the edge in the disk scope",
"Phone drawer: tapping archive shows DEGRADED and tapping sdc shows the SMART failure. The read-only dataset drawer still only says Degraded, a pre-existing drawer gap tracked for a follow-up",
"Rebuilt as one commit on current main after the services and VMs change landed. The two frontend files are byte-identical to the version verified above, and the page was reshot on this tree"
],
"interactions": [
"Switched to the Physical disks scope",
"Tapped archive, archive/cold and sdc on a 390 phone and read each drawer"
]
}
@@ -18,7 +18,6 @@ import {
createPlatformTableFilterState,
createPlatformTableSortState,
formatPlatformTableBytesValue,
formatPlatformTableTitleCaseValue,
getPlatformTableCellClassForKind,
type PlatformTableFilterOption,
type PlatformTableSortState,
@@ -39,7 +38,8 @@ import {
filterTrueNASStorageTopologyRows,
filterTrueNASStorageTopologyRowsByKind,
getTrueNASResourceDisplayStatus,
mapTrueNASStorageStatus,
getTrueNASStorageIssue,
type TrueNASStorageIssue,
type TrueNASStorageStatusFilter,
type TrueNASStorageKindFilter,
type TrueNASStorageTopologyKind,
@@ -193,44 +193,52 @@ const DiskEnduranceCell: Component<{ row: TrueNASStorageTopologyRow }> = (props)
);
};
const riskLabel = (row: TrueNASStorageTopologyRow): string => {
const risk =
asTrimmedString(row.resource.storage?.risk?.level) ||
asTrimmedString(row.resource.physicalDisk?.risk?.level);
if (risk) return formatPlatformTableTitleCaseValue(risk);
const incidentCount = row.resource.incidentCount ?? row.resource.incidents?.length ?? 0;
if (incidentCount > 0) return `${incidentCount} alert${incidentCount === 1 ? '' : 's'}`;
const mapped = mapTrueNASStorageStatus(row.resource);
if (mapped === 'attention') return 'Attention';
if (mapped === 'offline') return 'Offline';
if (mapped === 'healthy') return 'Healthy';
return 'Unknown';
const issueReasonClass = (issue: TrueNASStorageIssue): string => {
if (issue.status === 'attention') return 'text-amber-700 dark:text-amber-300';
if (issue.status === 'offline') return 'text-red-600 dark:text-red-300';
return 'text-muted';
};
const riskPillClass = (row: TrueNASStorageTopologyRow): string => {
const mapped = mapTrueNASStorageStatus(row.resource);
if (mapped === 'attention') {
return 'border-amber-300/50 bg-amber-500/10 text-amber-700 dark:text-amber-300';
}
if (mapped === 'offline') {
return 'border-red-300/50 bg-red-500/10 text-red-700 dark:text-red-300';
}
if (mapped === 'healthy') {
return 'border-emerald-300/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
}
return 'border-border bg-surface-alt text-muted';
};
// The visible reason is truncated and abbreviates the rest to a count, so
// assistive technology gets the bucket and every reason instead.
const issueScreenReaderText = (issue: TrueNASStorageIssue): string =>
`${issue.label}: ${issue.reasons.map((reason) => reason.replace(/[.\s]+$/, '')).join('. ')}.`;
const RiskPill: Component<{ row: TrueNASStorageTopologyRow }> = (props) => (
<span
class={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${riskPillClass(
props.row,
)}`}
>
{riskLabel(props.row)}
</span>
// Healthy rows leave the Health column empty: the status dot already says
// healthy, and a column of green pills buried the few rows that are not. An
// exception shows why, on the row's single line (the shared platform-table
// rhythm), with every reason on hover and in the row drawer. The colour
// carries the bucket, so a pill repeating Attention would only take room
// from the reason.
const IssueSummary: Component<{ issue: TrueNASStorageIssue | null }> = (props) => (
<Show when={props.issue}>
{(issue) => (
<div
class={`flex min-w-0 items-center gap-1 text-[11px] font-medium ${issueReasonClass(issue())}`}
title={issue().reasons.join('\n')}
data-truenas-storage-health={issue().status}
>
<span aria-hidden="true" class="min-w-0 truncate">
{issue().reasons[0] ?? issue().label}
</span>
<Show when={issue().reasons.length > 1}>
<span aria-hidden="true" class="shrink-0 tabular-nums">
+{issue().reasons.length - 1}
</span>
</Show>
<span class="sr-only">{issueScreenReaderText(issue())}</span>
</div>
)}
</Show>
);
const HEALTH_SORT_RANK: Record<TrueNASStorageIssue['status'] | 'healthy', number> = {
attention: 0,
offline: 1,
unknown: 2,
healthy: 3,
};
// Columns a user can sort by. Usage / Size orders pools and datasets on their
// used percentage and disks on their raw size, so same-kind siblings compare
// on the number their cell actually shows.
@@ -272,7 +280,7 @@ const getTrueNASStorageSortValue = (
return typeof temperature === 'number' && Number.isFinite(temperature) ? temperature : null;
}
case 'health':
return riskLabel(row);
return HEALTH_SORT_RANK[getTrueNASStorageIssue(row.resource)?.status ?? 'healthy'];
default:
key satisfies never;
return null;
@@ -481,7 +489,7 @@ export const TrueNASStorageTopologyTable: Component<{
kind="metric-bar"
sort={sort}
sortKey="usage"
class="platform-table-mobile-w-25 md:w-[28%]"
class="platform-table-mobile-w-25 md:w-[20%]"
>
<PlatformResponsiveTableLabel compact="Usage" full="Usage / Size" />
</PlatformSortableTableHead>
@@ -489,7 +497,7 @@ export const TrueNASStorageTopologyTable: Component<{
kind="numeric-value"
sort={sort}
sortKey={kindFilter() === 'disks' ? 'endurance' : 'disks'}
class="platform-table-mobile-w-15 md:w-[8%]"
class="platform-table-mobile-w-15 md:w-[7%]"
>
{kindFilter() === 'disks' ? 'Endurance' : 'Disks'}
</PlatformSortableTableHead>
@@ -497,15 +505,15 @@ export const TrueNASStorageTopologyTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="temp"
class={`${kindFilter() === 'disks' ? 'table-cell' : 'hidden lg:table-cell'} md:w-[8%]`}
class={`${kindFilter() === 'disks' ? 'table-cell' : 'hidden lg:table-cell'} md:w-[7%]`}
>
Temp
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="badge"
kind="text"
sort={sort}
sortKey="health"
class={`${kindFilter() === 'disks' ? 'table-cell' : 'platform-table-phone-hidden'} md:w-[14%]`}
class="platform-table-phone-hidden md:w-[24%]"
>
<PlatformResponsiveTableLabel compact="H" full="Health" />
</PlatformSortableTableHead>
@@ -516,6 +524,7 @@ export const TrueNASStorageTopologyTable: Component<{
<PlatformWindowedRows items={sortedRows} estimatedRowHeight={32}>
{(row) => {
const resource = () => row.resource;
const issue = createMemo(() => getTrueNASStorageIssue(resource()));
const detailRowId = () => drawer.detailRowId(resource());
const isExpanded = () => drawer.isExpanded(resource());
return (
@@ -581,13 +590,9 @@ export const TrueNASStorageTopologyTable: Component<{
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('badge')} ${
kindFilter() === 'disks'
? 'table-cell'
: 'platform-table-phone-hidden'
}`}
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden`}
>
<RiskPill row={row} />
<IssueSummary issue={issue()} />
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
@@ -172,6 +172,73 @@ describe('TrueNASStorageTopologyTable user sorting', () => {
]);
});
it('sorts Health by severity rank rather than label text', () => {
const resources: Resource[] = [
makeStorageResource({
id: 'pool-alpha',
name: 'alpha',
displayName: 'alpha',
storage: { topology: 'pool', platform: 'truenas', path: 'alpha' },
}),
makeStorageResource({
id: 'dataset-healthy',
name: 'alpha/healthy',
displayName: 'alpha/healthy',
storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/healthy' },
}),
makeStorageResource({
id: 'dataset-offline',
name: 'alpha/offline',
displayName: 'alpha/offline',
status: 'offline',
storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/offline' },
}),
makeStorageResource({
id: 'dataset-warning',
name: 'alpha/warning',
displayName: 'alpha/warning',
status: 'warning',
storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/warning' },
}),
];
const { container } = render(() => (
<TrueNASStorageTopologyTable
resources={resources}
scope={resources}
emptyIcon={<span />}
emptyTitle="No storage"
emptyDescription="No storage"
showToolbar={false}
/>
));
// Alphabetical labels would put Healthy before Offline. Severity puts
// Attention first, then Offline, with healthy rows last.
fireEvent.click(screen.getByRole('columnheader', { name: /Health/ }));
expect(screen.getByRole('columnheader', { name: /Health/ })).toHaveAttribute(
'aria-sort',
'ascending',
);
expect(visibleRowOrder(container)).toEqual([
'pool:pool-alpha',
'dataset:dataset-warning',
'dataset:dataset-offline',
'dataset:dataset-healthy',
]);
fireEvent.click(screen.getByRole('columnheader', { name: /Health/ }));
expect(screen.getByRole('columnheader', { name: /Health/ })).toHaveAttribute(
'aria-sort',
'descending',
);
expect(visibleRowOrder(container)).toEqual([
'pool:pool-alpha',
'dataset:dataset-healthy',
'dataset:dataset-offline',
'dataset:dataset-warning',
]);
});
it('persists the chosen sort across a remount', () => {
const first = renderTable();
fireEvent.click(headerFor('Resource'));
@@ -143,7 +143,11 @@ describe('TrueNASStorageTopologyTable', () => {
expect(screen.getByRole('columnheader', { name: /Endurance/ })).toBeInTheDocument();
expect(screen.getByText('68% left')).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /Temp/ })).toHaveClass('table-cell');
expect(screen.getByRole('columnheader', { name: /Health/ })).toHaveClass('table-cell');
// The status dot and reason line carry health on phones, so the column
// stays desktop-only even when it is the disk view's main signal.
expect(screen.getByRole('columnheader', { name: /Health/ })).toHaveClass(
'platform-table-phone-hidden',
);
await fireEvent.click(screen.getByRole('button', { name: 'Volumes, 2' }));
expect(container.querySelectorAll('[data-truenas-storage-row]')).toHaveLength(2);
@@ -151,4 +155,64 @@ describe('TrueNASStorageTopologyTable', () => {
expect(container.querySelector('[data-truenas-storage-kind="dataset"]')).not.toBeNull();
expect(container.querySelector('[data-truenas-storage-kind="disk"]')).toBeNull();
});
it('shows health only for exceptions, with the reason in the Health cell', () => {
const pool = makeStorageResource({
id: 'pool-archive',
name: 'archive',
status: 'warning',
incidents: [
{
code: 'truenas_volume_status',
severity: 'warning',
summary: 'Pool archive is DEGRADED: one member of mirror-0 is faulted.',
},
{
code: 'truenas_smart',
severity: 'warning',
summary: 'Device /dev/sdc has SMART test failures.',
},
],
storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'DEGRADED' },
});
const healthy = makeStorageResource({
id: 'dataset-backups',
name: 'archive/backups',
parentId: 'pool-archive',
storage: { topology: 'dataset', platform: 'truenas' },
});
const resources = [pool, healthy];
const { container } = render(() => (
<TrueNASStorageTopologyTable
resources={resources}
scope={resources}
emptyIcon={<span />}
emptyTitle="No storage"
emptyDescription="No storage"
showToolbar={false}
/>
));
const poolRow = container.querySelector('[data-truenas-storage-resource="pool-archive"]');
const healthyRow = container.querySelector('[data-truenas-storage-resource="dataset-backups"]');
const health = poolRow?.querySelector('[data-truenas-storage-health="attention"]');
const visible = [...(health?.querySelectorAll('[aria-hidden="true"]') ?? [])].map(
(node) => node.textContent,
);
// The reason sits in the Health cell on the row's single line. The name
// cell carries only the name, per the shared platform-table rhythm.
expect(health?.closest('td')).toHaveClass('platform-table-phone-hidden');
expect(visible).toEqual(['Pool archive is DEGRADED: one member of mirror-0 is faulted.', '+1']);
expect(health?.querySelector('.sr-only')).toHaveTextContent(
'Attention: Pool archive is DEGRADED: one member of mirror-0 is faulted. Device /dev/sdc has SMART test failures.',
);
expect(health).toHaveAttribute(
'title',
'Pool archive is DEGRADED: one member of mirror-0 is faulted.\nDevice /dev/sdc has SMART test failures.',
);
expect(poolRow?.querySelector('td')).not.toHaveTextContent('DEGRADED');
expect(healthyRow?.querySelector('[data-truenas-storage-health]')).toBeNull();
expect(healthyRow).not.toHaveTextContent('Healthy');
});
});
@@ -19,6 +19,7 @@ import {
filterTrueNASVMs,
getTrueNASPageTabSpecs,
getTrueNASResourceDisplayStatus,
getTrueNASStorageIssue,
mapTrueNASAppStatus,
mapTrueNASIncidentSeverity,
mapTrueNASProtectionKind,
@@ -645,6 +646,200 @@ describe('truenasPageModel', () => {
expect(row && mapTrueNASStorageStatus(row.resource)).toBe('healthy');
});
it('explains non-healthy storage with TrueNAS alert text first and stays silent when healthy', () => {
const healthyPool = makeResource({
id: 'pool-tank',
type: 'storage',
name: 'tank',
storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'ONLINE' },
});
expect(getTrueNASStorageIssue(healthyPool)).toBeNull();
const poolRisk = {
level: 'warning',
reasons: [
{ code: 'zfs_pool_state', severity: 'warning', summary: 'ZFS pool archive is DEGRADED' },
],
};
// A pool carries its own alert plus the disk alerts the provider copies onto it.
const degradedPool = makeResource({
id: 'pool-archive',
type: 'storage',
name: 'archive',
status: 'warning',
incidents: [
{
code: 'truenas_volume_status',
severity: 'warning',
summary: 'Pool archive is DEGRADED: one member of mirror-0 is faulted.',
},
{
code: 'truenas_smart',
severity: 'warning',
summary: 'Device /dev/sdc has SMART test failures.',
},
],
storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'DEGRADED', risk: poolRisk },
});
expect(getTrueNASStorageIssue(degradedPool)).toEqual({
status: 'attention',
label: 'Attention',
reasons: [
'Pool archive is DEGRADED: one member of mirror-0 is faulted.',
'Device /dev/sdc has SMART test failures.',
],
});
// Without a TrueNAS alert, Pulse's own risk summary explains the row.
const riskOnlyPool = makeResource({
id: 'pool-risk',
type: 'storage',
name: 'archive',
status: 'warning',
storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'DEGRADED', risk: poolRisk },
});
expect(getTrueNASStorageIssue(riskOnlyPool)?.reasons).toEqual(['ZFS pool archive is DEGRADED']);
const failingDisk = makeResource({
id: 'disk-sdc',
type: 'physical_disk',
name: 'sdc',
status: 'warning',
incidents: [
{
code: 'truenas_smart',
severity: 'warning',
summary: 'Device /dev/sdc has SMART test failures.',
},
{
code: 'truenas_smart',
severity: 'warning',
summary: 'Device /dev/sdc has SMART test failures',
},
],
physicalDisk: {
devPath: '/dev/sdc',
health: 'DEGRADED',
risk: {
level: 'warning',
reasons: [
{
code: 'truenas_disk_state',
severity: 'warning',
summary: 'TrueNAS disk sdc is DEGRADED',
},
],
},
},
});
// The repeat differs only in spacing and its full stop, so it folds in, and
// the derived disk-state summary waits behind the native alert.
expect(getTrueNASStorageIssue(failingDisk)?.reasons).toEqual([
'Device /dev/sdc has SMART test failures.',
]);
const readOnlyDataset = makeResource({
id: 'dataset-cold',
type: 'storage',
name: 'archive/cold',
status: 'warning',
tags: ['truenas', 'dataset', 'zfs', 'state:readonly'],
storage: { topology: 'dataset', platform: 'truenas' },
});
expect(getTrueNASStorageIssue(readOnlyDataset)?.reasons).toEqual(['Dataset is read-only']);
// The provider raises its own incident for a locked dataset, which wins
// over the state tag instead of being repeated by it.
const lockedDataset = makeResource({
id: 'dataset-vault',
type: 'storage',
name: 'vault/secrets',
status: 'offline',
tags: ['truenas', 'dataset', 'zfs', 'state:locked'],
incidents: [
{
code: 'zfs_dataset_locked',
severity: 'warning',
summary: 'ZFS dataset vault/secrets is locked and unavailable',
},
],
storage: { topology: 'dataset', platform: 'truenas' },
});
expect(getTrueNASStorageIssue(lockedDataset)).toEqual({
status: 'offline',
label: 'Offline',
reasons: ['ZFS dataset vault/secrets is locked and unavailable'],
});
const lockedWithoutIncident = makeResource({
id: 'dataset-vault-tag',
type: 'storage',
name: 'vault/keys',
status: 'offline',
tags: ['truenas', 'dataset', 'zfs', 'state:locked'],
storage: { topology: 'dataset', platform: 'truenas' },
});
expect(getTrueNASStorageIssue(lockedWithoutIncident)?.reasons).toEqual(['Dataset is locked']);
const bareDegradedPool = makeResource({
id: 'pool-bare',
type: 'storage',
name: 'bare',
storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'degraded' },
});
expect(getTrueNASStorageIssue(bareDegradedPool)?.reasons).toEqual(['Pool state DEGRADED']);
// Stale TrueNAS data is the cause even when the pool also has other news.
const staleConnectionPool = makeResource({
id: 'pool-stale',
type: 'storage',
name: 'stale',
storage: {
topology: 'pool',
platform: 'truenas',
zfsPoolState: 'ONLINE',
risk: {
level: 'info',
reasons: [
{
code: 'zfs_scan_running',
severity: 'info',
summary: 'ZFS pool stale scrub is running',
},
],
},
},
platformData: { sourceStatus: { truenas: { status: 'stale' } } },
});
expect(getTrueNASStorageIssue(staleConnectionPool)?.reasons).toEqual([
'TrueNAS has not updated this recently',
'ZFS pool stale scrub is running',
]);
const unknownDisk = makeResource({
id: 'disk-unknown',
type: 'physical_disk',
name: 'ada0',
status: 'unknown',
physicalDisk: { devPath: '/dev/ada0', health: 'UNKNOWN' },
});
expect(getTrueNASStorageIssue(unknownDisk)).toEqual({
status: 'unknown',
label: 'Unknown',
reasons: ['TrueNAS has not reported a state for this'],
});
const silentOfflinePool = makeResource({
id: 'pool-gone',
type: 'storage',
name: 'gone',
status: 'offline',
storage: { topology: 'pool', platform: 'truenas' },
});
expect(getTrueNASStorageIssue(silentOfflinePool)?.reasons).toEqual([
'Offline, and TrueNAS gave no reason',
]);
});
it('filters apps using native TrueNAS app.query metadata', () => {
const nextcloud = makeResource({
id: 'app-nextcloud',
@@ -722,6 +722,89 @@ export function mapTrueNASStorageStatus(
return 'unknown';
}
export type TrueNASStorageIssue = {
status: Exclude<TrueNASStorageStatusFilter, 'all' | 'healthy'> | 'unknown';
label: string;
reasons: string[];
};
const TRUENAS_STORAGE_ISSUE_LABELS: Record<TrueNASStorageIssue['status'], string> = {
attention: 'Attention',
offline: 'Offline',
unknown: 'Unknown',
};
// A read-only dataset raises no incident, so its state tag
// (datasetStateTag in internal/truenas/provider.go) is the only place the
// provider says why it is impaired. Locked and unmounted datasets also raise
// incidents, which win when present.
const TRUENAS_DATASET_STATE_REASONS: Record<string, string> = {
'state:locked': 'Dataset is locked',
'state:unmounted': 'Dataset is not mounted',
'state:readonly': 'Dataset is read-only',
};
const TRUENAS_STORAGE_SILENT_REASONS: Record<TrueNASStorageIssue['status'], string> = {
attention: 'TrueNAS flagged this without a reason',
offline: 'Offline, and TrueNAS gave no reason',
unknown: 'TrueNAS has not reported a state for this',
};
const reasonKey = (text: string): string =>
text
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/[.\s]+$/, '');
// Healthy storage needs no explanation, so this returns null for it. Anything
// else carries the reasons TrueNAS gave, most specific first, so a row can say
// why it is amber instead of only that it is. TrueNAS's own alert text names
// the failing member or test, so it leads. Pulse's derived risk summaries
// restate the same pool and disk state, so they only stand in when TrueNAS
// raised no alert; the row drawer still lists every one of them.
export function getTrueNASStorageIssue(resource: Resource): TrueNASStorageIssue | null {
const status = mapTrueNASStorageStatus(resource);
if (status === 'healthy') return null;
const reasons: string[] = [];
const push = (value: unknown) => {
const text = asTrimmedString(value);
if (!text) return;
const key = reasonKey(text);
if (reasons.some((existing) => reasonKey(existing) === key)) return;
reasons.push(text);
};
// Stale TrueNAS data is why the row left Healthy, whatever else it says.
if (hasImpairedResourceSource(resource, 'truenas')) {
push('TrueNAS has not updated this recently');
}
const leading = reasons.length;
for (const incident of resource.incidents ?? []) push(incident.summary);
if (reasons.length === leading) {
for (const reason of resource.storage?.risk?.reasons ?? []) push(reason.summary);
for (const reason of resource.physicalDisk?.risk?.reasons ?? []) push(reason.summary);
}
if (reasons.length === leading) {
for (const tag of resource.tags ?? []) push(TRUENAS_DATASET_STATE_REASONS[normalize(tag)]);
}
if (reasons.length === leading) {
const zfsState = asTrimmedString(resource.storage?.zfsPoolState);
const diskHealth = asTrimmedString(resource.physicalDisk?.health);
if (zfsState && !['online', 'healthy'].includes(zfsState.toLowerCase())) {
push(`Pool state ${zfsState.toUpperCase()}`);
} else if (
diskHealth &&
!['passed', 'healthy', 'ok', 'unknown', 'unavailable'].includes(diskHealth.toLowerCase())
) {
push(`Disk health ${diskHealth.toUpperCase()}`);
}
}
if (reasons.length === 0) push(TRUENAS_STORAGE_SILENT_REASONS[status]);
return { status, label: TRUENAS_STORAGE_ISSUE_LABELS[status], reasons };
}
export function mapTrueNASProtectionStatus(point: RecoveryPoint): TrueNASProtectionStatusBucket {
return normalizeProtectionOutcome(point.outcome);
}