mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 20:52:51 +00:00
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. On a phone the Health column is hidden, so an amber dot was all a user got. Healthy rows now leave Health empty. A non-healthy row shows why under its name on every width: TrueNAS's own alert text first, Pulse's risk summary when TrueNAS raised none, then the dataset state (read-only datasets raise no incident, so the state tag is the only reason the provider gives), and a plain fallback so no amber row is silent. Stale TrueNAS data leads when it is the cause. Screen readers get the full list. The pill uses the same Attention/Offline/Unknown words as the filter chips, Health sorts by severity, and the column is desktop-only in every scope because it clipped past the phone edge in the disk view. Rows with a reason line are taller, and the shared windowing hook sized every unmounted row from the tallest of the first three, which overshoots the spacers once a table passes its window budget. Table runways now take the average of the mounted rows, so mixed heights stay aligned. Other anchors keep the sampled estimate.
This commit is contained in:
@@ -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 plus the reason line under the name already carry a disk's
|
||||
condition, and the 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
|
||||
@@ -711,7 +713,10 @@ scrolling exposes it. Touch scrolling must remain compositor-native: windowed
|
||||
renderers must not attach touch listeners or replace keyed rows before the
|
||||
browser moves the page, and must update their runway only from the passive
|
||||
native scroll event. Spacer geometry is structural only: no feature may present
|
||||
it as loading, pagination, or an intentionally blank data region. Settings
|
||||
it as loading, pagination, or an intentionally blank data region. Table rows
|
||||
may differ in height (an exception row with a reason line under its name),
|
||||
because a table runway sizes its spacers from the average mounted row rather
|
||||
than its tallest leading row. Settings
|
||||
resource pickers, Availability target lists, Actions, alerts, and every
|
||||
provider-native platform table share this contract. For table rows with unique
|
||||
logical ids, or an explicit unique key extractor, `PlatformWindowedRows` owns a
|
||||
|
||||
@@ -2555,11 +2555,15 @@ sparse live snapshots update fields without remounting an open inline drawer
|
||||
or discarding its tab and form state. Rows without a unique logical ID retain
|
||||
reference-keyed rendering rather than paying for guessed index identity.
|
||||
Spacer geometry and the inverse scroll-to-index mapping must share one
|
||||
representative item height. The controller samples several leading siblings and
|
||||
keeps the tallest, because a short leading group header sampled alone collapses
|
||||
the estimate and lets the mounted window outrun the real scroll position,
|
||||
turning a small scroll into many rows of unmounting and making an estate appear
|
||||
to lose rows.
|
||||
representative item height. A table runway, anchored on its top spacer row,
|
||||
measures every mounted row up to the bottom spacer and divides by the mounted
|
||||
item count, so a few taller rows (a reason line under an exception's name, an
|
||||
open detail row) count once instead of sizing every unmounted row and making
|
||||
the window jump as they scroll out. Other anchors sample several leading
|
||||
siblings and keep the tallest, because a short leading group header sampled
|
||||
alone collapses the estimate and lets the mounted window outrun the real scroll
|
||||
position, turning a small scroll into many rows of unmounting and making an
|
||||
estate appear to lose rows.
|
||||
The workload guest-row path now follows the same pattern: the render shell
|
||||
stays in `frontend-modern/src/components/Workloads/GuestRow.tsx`, tooltip-backed
|
||||
cell presentation lives in `frontend-modern/src/components/Workloads/GuestRowCells.tsx`,
|
||||
|
||||
@@ -960,7 +960,17 @@ 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 under the name on every width and the full list stays
|
||||
in the title, so a phone user learns why a row is amber without the Health
|
||||
column. 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,47 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "94ac386abfe13a8eb0109ac1eea4c087390f9717",
|
||||
"verified_at": "2026-10-05T04:55:33Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts",
|
||||
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
|
||||
"frontend-modern/src/features/truenas/truenasPageModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts": "3ce382df4466178015acbc4e28eb85743e66e89fac2ef75b4616107145101431",
|
||||
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "206003ff23c53f6b41f893130c5f29c17f2b91a1dfab7f7742cd3094b774f46f",
|
||||
"frontend-modern/src/features/truenas/truenasPageModel.ts": "b1955834430d00fca3fc4176614a83f4a8387c8c29a85f0f17c6fd1360faf514"
|
||||
},
|
||||
"routes": [
|
||||
"/truenas/storage",
|
||||
"/truenas/storage?kind=disks",
|
||||
"/docker/containers"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 820,
|
||||
"height": 700
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 760
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Storage at 1440: 18 healthy rows leave Health empty, the three exceptions keep an Attention pill (archive pool, archive/cold dataset, sdc disk), where before every row had a pill and the pool said Warning while the dataset said Attention",
|
||||
"Reason under the name: archive shows the TrueNAS volume alert with +1 (the copied SMART alert), archive/cold shows Dataset is read-only, sdc shows Device /dev/sdc has SMART test failures with no +1 now that derived risk summaries only stand in when TrueNAS raised no alert",
|
||||
"Phone 390: reason wraps to two lines with the +1 outside the clamp, so the pool reads Pool archive is DEGRADED, where before the amber rows gave no reason at all",
|
||||
"Disk scope on phone: Health column is hidden and the pill no longer clips past the right edge, endurance and temperature stay visible",
|
||||
"Dark mode at 1440 and tablet at 820 render the reason lines and pills legibly",
|
||||
"Docker containers at 390 (44 rows, windowed): scrolled to the end in 250px wheel steps, each probed row moved about one scroll step as the window shifted, no spacer reached the viewport, estimate stayed 32px on the uniform table"
|
||||
],
|
||||
"interactions": [
|
||||
"Switched the storage type scope to Physical disks on desktop and phone",
|
||||
"Wheel-scrolled the windowed Docker containers table on phone and probed the rows at mid and lower viewport each step"
|
||||
]
|
||||
}
|
||||
@@ -263,4 +263,63 @@ describe('PlatformWindowedRows', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('averages the mounted table rows so a few taller rows do not size every unmounted row', () => {
|
||||
// Exception rows can carry a reason line under the name and sort first.
|
||||
// Taking the tallest of the leading rows as every row's height made the
|
||||
// spacers overshoot and the window jump as rows scrolled out.
|
||||
const items = () => Array.from({ length: 41 }, (_, index) => index);
|
||||
const originalRect = Element.prototype.getBoundingClientRect;
|
||||
const originalVisibility = (Element.prototype as Element & { checkVisibility?: () => boolean })
|
||||
.checkVisibility;
|
||||
Element.prototype.getBoundingClientRect = function (this: Element) {
|
||||
const raw = (this as HTMLElement).getAttribute?.('data-height');
|
||||
const height = raw ? Number(raw) : 0;
|
||||
return {
|
||||
height,
|
||||
width: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
};
|
||||
(Element.prototype as Element & { checkVisibility?: () => boolean }).checkVisibility = () =>
|
||||
true;
|
||||
|
||||
try {
|
||||
const { container } = render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<PlatformWindowedRows
|
||||
items={items}
|
||||
estimatedRowHeight={30}
|
||||
enableThreshold={4}
|
||||
windowSize={8}
|
||||
>
|
||||
{(item) => <tr data-row-id={item} data-height={item < 2 ? 60 : 30} />}
|
||||
</PlatformWindowedRows>
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
|
||||
const bottomSpacer = container.querySelector(
|
||||
'[data-platform-window-spacer="bottom"]',
|
||||
) as HTMLElement;
|
||||
// 8 mounted rows measure 2 x 60 + 6 x 30 = 300px, 37.5px each, so the
|
||||
// 33 unmounted rows reserve 1237.5px rather than 33 x 60.
|
||||
expect(bottomSpacer.style.height).toBe('1237.5px');
|
||||
} finally {
|
||||
Element.prototype.getBoundingClientRect = originalRect;
|
||||
if (originalVisibility) {
|
||||
(Element.prototype as Element & { checkVisibility?: () => boolean }).checkVisibility =
|
||||
originalVisibility;
|
||||
} else {
|
||||
Reflect.deleteProperty(Element.prototype, 'checkVisibility');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,37 @@ const PHONE_WINDOW_SIZE = 36;
|
||||
// collapsing the estimate and desynchronising the window from the scroll
|
||||
// position, while uniform tables still measure their real row height.
|
||||
const MEASUREMENT_SAMPLE_SIZE = 3;
|
||||
|
||||
const measureTallestSampledSibling = (anchor: HTMLElement): number => {
|
||||
let measuredHeight = 0;
|
||||
let sibling = anchor.nextElementSibling;
|
||||
for (let sampled = 0; sampled < MEASUREMENT_SAMPLE_SIZE && sibling; sampled += 1) {
|
||||
const height = sibling.getBoundingClientRect().height;
|
||||
if (height > measuredHeight) measuredHeight = height;
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
return measuredHeight;
|
||||
};
|
||||
|
||||
// A table runway sits between a top and a bottom spacer row, so the mounted
|
||||
// rows can be measured as a whole. Averaging them per item lets a few taller
|
||||
// rows (a reason line under the name, an open detail row) count once instead
|
||||
// of setting the height of every row off screen, which made the spacers
|
||||
// overshoot and the window jump as rows scrolled out. Returns 0 when the
|
||||
// anchor is not such a runway, so other surfaces keep the sampled estimate.
|
||||
const measureRenderedRunwayItemHeight = (anchor: HTMLElement, renderedItems: number): number => {
|
||||
if (anchor.tagName !== 'TR' || anchor.dataset.platformWindowSpacer !== 'top') return 0;
|
||||
if (renderedItems <= 0) return 0;
|
||||
let total = 0;
|
||||
let sibling = anchor.nextElementSibling;
|
||||
while (sibling && (sibling as HTMLElement).dataset.platformWindowSpacer !== 'bottom') {
|
||||
total += sibling.getBoundingClientRect().height;
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
if (!sibling || total <= 0) return 0;
|
||||
return total / renderedItems;
|
||||
};
|
||||
|
||||
export interface PlatformWindowedItemsOptions<Item> {
|
||||
items: Accessor<readonly Item[]>;
|
||||
estimatedItemHeight?: number;
|
||||
@@ -73,13 +104,9 @@ export function usePlatformWindowedItems<Item>(options: PlatformWindowedItemsOpt
|
||||
if (!anchor || isWindowedSurfaceHidden(anchor)) return;
|
||||
|
||||
if (measureItems) {
|
||||
let measuredHeight = 0;
|
||||
let sibling = anchor.nextElementSibling;
|
||||
for (let sampled = 0; sampled < MEASUREMENT_SAMPLE_SIZE && sibling; sampled += 1) {
|
||||
const height = sibling.getBoundingClientRect().height;
|
||||
if (height > measuredHeight) measuredHeight = height;
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
const measuredHeight =
|
||||
measureRenderedRunwayItemHeight(anchor, visibleItems().length) ||
|
||||
measureTallestSampledSibling(anchor);
|
||||
if (measuredHeight > 0) setEstimatedItemHeight(measuredHeight);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,51 @@ 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 riskPillClass = (row: TrueNASStorageTopologyRow): string => {
|
||||
const mapped = mapTrueNASStorageStatus(row.resource);
|
||||
if (mapped === 'attention') {
|
||||
const issuePillClass = (issue: TrueNASStorageIssue): string => {
|
||||
if (issue.status === 'attention') {
|
||||
return 'border-amber-300/50 bg-amber-500/10 text-amber-700 dark:text-amber-300';
|
||||
}
|
||||
if (mapped === 'offline') {
|
||||
if (issue.status === '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';
|
||||
};
|
||||
|
||||
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>
|
||||
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';
|
||||
};
|
||||
|
||||
// The visible reason is clamped 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('. ')}.`;
|
||||
|
||||
// Healthy rows leave the Health column empty: the status dot already says
|
||||
// healthy, and a column of green pills buries the few rows that are not.
|
||||
const IssuePill: Component<{ issue: TrueNASStorageIssue | null }> = (props) => (
|
||||
<Show when={props.issue}>
|
||||
{(issue) => (
|
||||
<span
|
||||
class={`inline-flex items-center rounded-sm border px-1.5 py-0.5 text-[10px] font-medium ${issuePillClass(
|
||||
issue(),
|
||||
)}`}
|
||||
data-truenas-storage-health={issue().status}
|
||||
>
|
||||
{issue().label}
|
||||
</span>
|
||||
)}
|
||||
</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 +279,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;
|
||||
@@ -322,9 +329,11 @@ export const getTrueNASStorageTopologyIndentClass = (depth: number): string => {
|
||||
return 'pl-8 sm:pl-16';
|
||||
};
|
||||
|
||||
const ResourceCell: Component<{ row: TrueNASStorageTopologyRow; detailToggle?: JSX.Element }> = (
|
||||
props,
|
||||
) => {
|
||||
const ResourceCell: Component<{
|
||||
row: TrueNASStorageTopologyRow;
|
||||
issue: TrueNASStorageIssue | null;
|
||||
detailToggle?: JSX.Element;
|
||||
}> = (props) => {
|
||||
const displayStatus = () => getTrueNASResourceDisplayStatus(props.row.resource);
|
||||
const indicator = () => getSimpleStatusIndicator(displayStatus());
|
||||
const name = () => resourceName(props.row.resource);
|
||||
@@ -344,6 +353,28 @@ const ResourceCell: Component<{ row: TrueNASStorageTopologyRow; detailToggle?: J
|
||||
>
|
||||
{name()}
|
||||
</div>
|
||||
<Show when={props.issue?.reasons.length ? props.issue : undefined}>
|
||||
{(issue) => (
|
||||
<div
|
||||
class={`flex min-w-0 items-start gap-1 text-[11px] leading-snug font-medium ${issueReasonClass(issue())}`}
|
||||
title={issue().reasons.join('\n')}
|
||||
data-truenas-storage-reason
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="line-clamp-2 min-w-0 whitespace-normal [overflow-wrap:anywhere]"
|
||||
>
|
||||
{issue().reasons[0]}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -505,7 +536,7 @@ export const TrueNASStorageTopologyTable: Component<{
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="health"
|
||||
class={`${kindFilter() === 'disks' ? 'table-cell' : 'platform-table-phone-hidden'} md:w-[14%]`}
|
||||
class="platform-table-phone-hidden md:w-[14%]"
|
||||
>
|
||||
<PlatformResponsiveTableLabel compact="H" full="Health" />
|
||||
</PlatformSortableTableHead>
|
||||
@@ -516,6 +547,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 (
|
||||
@@ -531,6 +563,7 @@ export const TrueNASStorageTopologyTable: Component<{
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<ResourceCell
|
||||
row={row}
|
||||
issue={issue()}
|
||||
detailToggle={
|
||||
<PlatformResourceDetailToggleButton
|
||||
expanded={isExpanded()}
|
||||
@@ -581,13 +614,9 @@ export const TrueNASStorageTopologyTable: Component<{
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('badge')} ${
|
||||
kindFilter() === 'disks'
|
||||
? 'table-cell'
|
||||
: 'platform-table-phone-hidden'
|
||||
}`}
|
||||
class={`${getPlatformTableCellClassForKind('badge')} platform-table-phone-hidden`}
|
||||
>
|
||||
<RiskPill row={row} />
|
||||
<IssuePill 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 under the name', () => {
|
||||
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 reason = poolRow?.querySelector('[data-truenas-storage-reason]');
|
||||
const visible = [...(reason?.querySelectorAll('[aria-hidden="true"]') ?? [])].map(
|
||||
(node) => node.textContent,
|
||||
);
|
||||
|
||||
expect(visible).toEqual(['Pool archive is DEGRADED: one member of mirror-0 is faulted.', '+1']);
|
||||
expect(reason?.querySelector('.sr-only')).toHaveTextContent(
|
||||
'Attention: Pool archive is DEGRADED: one member of mirror-0 is faulted. Device /dev/sdc has SMART test failures.',
|
||||
);
|
||||
expect(reason).toHaveAttribute(
|
||||
'title',
|
||||
'Pool archive is DEGRADED: one member of mirror-0 is faulted.\nDevice /dev/sdc has SMART test failures.',
|
||||
);
|
||||
expect(poolRow?.querySelector('[data-truenas-storage-health="attention"]')).toHaveTextContent(
|
||||
'Attention',
|
||||
);
|
||||
expect(healthyRow?.querySelector('[data-truenas-storage-reason]')).toBeNull();
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user