mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Preserve reviewed monitoring work alongside upstream TrueNAS storage explanations
Incorporate exact current upstream additively without rewriting accepted candidates. Retain truthful History, private guidance and source ownership controls. Change-source: pulse-maintainer
This commit is contained in:
@@ -695,9 +695,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
|
||||
@@ -5278,7 +5280,10 @@ Platform table title-case fallback formatting follows the same rule.
|
||||
`formatPlatformTableTitleCaseValue` owns the repeated trimmed-string plus
|
||||
`Unknown` fallback behavior for state/status labels that need simple title
|
||||
case. TrueNAS platform tables must compose that helper instead of declaring
|
||||
local `titleCase` helpers.
|
||||
local `titleCase` helpers. The apps, shares, services and VMs tables are its
|
||||
required consumers. The storage table left that list when its Health cell
|
||||
switched from a title-cased risk-level pill to TrueNAS's own reason text, and
|
||||
the scope-wide guard against a local `titleCase` helper still covers it.
|
||||
Platform table compact list summaries follow the same rule.
|
||||
`summarizePlatformTableValues` owns the repeated trimming, empty-marker label,
|
||||
visible-value count, `+N` overflow suffix, full-title text, and normalized
|
||||
|
||||
@@ -1014,7 +1014,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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "146595b8484aa742e22e218675ea141164b55d4a",
|
||||
"verified_at": "2026-10-05T06:53:20Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/truenas/truenasPageModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/truenas/truenasPageModel.ts": "67c724aa3023666c689a06f5cc4b92f634ab632b0e8842ee6fc709f3a06ed9b6"
|
||||
},
|
||||
"routes": [
|
||||
"/truenas/storage"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Storage Health cells after switching the dataset state reason to the shared util: the archive pool lists its volume alert and the copied SMART alert, archive/cold reads Dataset is read-only, sdc reads its SMART failure. Same text as before the switch, now from the mapping the drawer also uses",
|
||||
"Phone at 390: the table keeps its dot-only projection, and tapping archive/cold opens a drawer that says Dataset is read-only"
|
||||
],
|
||||
"interactions": [
|
||||
"Read every Health cell title on the storage table",
|
||||
"Tapped archive/cold on a 390 phone"
|
||||
]
|
||||
}
|
||||
@@ -2304,7 +2304,6 @@
|
||||
{ "path": "src/features/truenas/TrueNASAppsTable.tsx" },
|
||||
{ "path": "src/features/truenas/TrueNASNetworkSharesTable.tsx" },
|
||||
{ "path": "src/features/truenas/TrueNASServicesTable.tsx" },
|
||||
{ "path": "src/features/truenas/TrueNASStorageTopologyTable.tsx" },
|
||||
{ "path": "src/features/truenas/TrueNASVirtualMachinesTable.tsx" }
|
||||
],
|
||||
"forbiddenPatterns": [
|
||||
@@ -2320,10 +2319,6 @@
|
||||
"path": "src/features/truenas/TrueNASServicesTable.tsx",
|
||||
"patterns": ["const titleCase"]
|
||||
},
|
||||
{
|
||||
"path": "src/features/truenas/TrueNASStorageTopologyTable.tsx",
|
||||
"patterns": ["const titleCase"]
|
||||
},
|
||||
{
|
||||
"path": "src/features/truenas/TrueNASVirtualMachinesTable.tsx",
|
||||
"patterns": ["const titleCase"]
|
||||
|
||||
@@ -5895,7 +5895,9 @@ describe('shared primitive guardrails', () => {
|
||||
['src/features/truenas/TrueNASAppsTable.tsx', truenasAppsTableSource],
|
||||
['src/features/truenas/TrueNASNetworkSharesTable.tsx', truenasNetworkSharesTableSource],
|
||||
['src/features/truenas/TrueNASServicesTable.tsx', truenasServicesTableSource],
|
||||
['src/features/truenas/TrueNASStorageTopologyTable.tsx', truenasStorageTopologyTableSource],
|
||||
// The storage table no longer formats a raw provider value: its Health
|
||||
// cell carries TrueNAS's own reason text. The scope-wide local-helper
|
||||
// guard below still keeps a private titleCase out of it.
|
||||
['src/features/truenas/TrueNASVirtualMachinesTable.tsx', truenasVirtualMachinesTableSource],
|
||||
];
|
||||
const truenasTitleCaseConsumerPaths = truenasTitleCaseConsumers.map(([path]) => path);
|
||||
|
||||
@@ -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
|
||||
|
||||
+67
@@ -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'));
|
||||
|
||||
+65
-1
@@ -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',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveResourcePlatformType } from '@/utils/sourcePlatforms';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import { hasImpairedResourceSource } from '@/utils/resourceSourceHealth';
|
||||
import { getTrueNASDatasetStateSummary } from '@/utils/truenasDatasetState';
|
||||
import type {
|
||||
Resource,
|
||||
ResourceIncident,
|
||||
@@ -722,6 +723,81 @@ 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',
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
// A read-only dataset raises no incident, so its state tag is the only
|
||||
// reason the provider gives. The drawer reads the same shared mapping.
|
||||
if (reasons.length === leading && resource.storage?.topology === 'dataset') {
|
||||
push(getTrueNASDatasetStateSummary(resource.tags));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,12 @@ test.describe("TrueNAS storage disk history", () => {
|
||||
await expect(search).toBeVisible({ timeout: 60_000 });
|
||||
await search.fill("sdc");
|
||||
|
||||
const diskRow = page.locator("tr").filter({ hasText: "sdc" }).first();
|
||||
// The archive pool row also mentions /dev/sdc, because its Health cell
|
||||
// carries the copied SMART alert, so pick the disk row by its kind.
|
||||
const diskRow = page
|
||||
.locator('tr[data-truenas-storage-kind="disk"]')
|
||||
.filter({ hasText: "sdc" })
|
||||
.first();
|
||||
await expect(diskRow).toBeVisible();
|
||||
await diskRow.getByRole("button").first().click();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user