mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 04:59:43 +00:00
Keep TrueNAS storage rows single-line, reason in Health
The first version put the reason on a second line under the name. The shared platform-table contract forbids that: summary rows keep one line on a 32px rhythm, and context belongs in an operational column, a tooltip or the row drawer. The taller rows also needed a windowing change to keep the spacers aligned. The reason now sits in the desktop Health column on the row's single line, coloured by bucket, with a count for the rest and every reason on hover and for screen readers. Phones keep the status dot and reach the reason through the row drawer. The windowing change is reverted, since rows are uniform again and it no longer had a case to serve.
This commit is contained in:
@@ -663,8 +663,8 @@ On narrow screens the physical-disk scope may reprioritize its canonical table
|
|||||||
columns to endurance and temperature 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
|
overflow and touch-target behavior; it must not introduce a second mobile-only
|
||||||
filter or table shell. Its Health column stays desktop-only in every scope:
|
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
|
the status dot carries a disk's condition on a phone, the row drawer carries
|
||||||
condition, and the pill clipped past the phone container edge.
|
the reason, and the old pill clipped past the phone container edge.
|
||||||
|
|
||||||
Large-estate platform pages must keep one canonical inventory snapshot for the
|
Large-estate platform pages must keep one canonical inventory snapshot for the
|
||||||
initial read and explicit refresh path. The Proxmox and VMware vSphere
|
initial read and explicit refresh path. The Proxmox and VMware vSphere
|
||||||
@@ -713,10 +713,7 @@ scrolling exposes it. Touch scrolling must remain compositor-native: windowed
|
|||||||
renderers must not attach touch listeners or replace keyed rows before the
|
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
|
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
|
native scroll event. Spacer geometry is structural only: no feature may present
|
||||||
it as loading, pagination, or an intentionally blank data region. Table rows
|
it as loading, pagination, or an intentionally blank data region. Settings
|
||||||
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
|
resource pickers, Availability target lists, Actions, alerts, and every
|
||||||
provider-native platform table share this contract. For table rows with unique
|
provider-native platform table share this contract. For table rows with unique
|
||||||
logical ids, or an explicit unique key extractor, `PlatformWindowedRows` owns a
|
logical ids, or an explicit unique key extractor, `PlatformWindowedRows` owns a
|
||||||
|
|||||||
@@ -2555,15 +2555,11 @@ 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
|
or discarding its tab and form state. Rows without a unique logical ID retain
|
||||||
reference-keyed rendering rather than paying for guessed index identity.
|
reference-keyed rendering rather than paying for guessed index identity.
|
||||||
Spacer geometry and the inverse scroll-to-index mapping must share one
|
Spacer geometry and the inverse scroll-to-index mapping must share one
|
||||||
representative item height. A table runway, anchored on its top spacer row,
|
representative item height. The controller samples several leading siblings and
|
||||||
measures every mounted row up to the bottom spacer and divides by the mounted
|
keeps the tallest, because a short leading group header sampled alone collapses
|
||||||
item count, so a few taller rows (a reason line under an exception's name, an
|
the estimate and lets the mounted window outrun the real scroll position,
|
||||||
open detail row) count once instead of sizing every unmounted row and making
|
turning a small scroll into many rows of unmounting and making an estate appear
|
||||||
the window jump as they scroll out. Other anchors sample several leading
|
to lose rows.
|
||||||
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
|
The workload guest-row path now follows the same pattern: the render shell
|
||||||
stays in `frontend-modern/src/components/Workloads/GuestRow.tsx`, tooltip-backed
|
stays in `frontend-modern/src/components/Workloads/GuestRow.tsx`, tooltip-backed
|
||||||
cell presentation lives in `frontend-modern/src/components/Workloads/GuestRowCells.tsx`,
|
cell presentation lives in `frontend-modern/src/components/Workloads/GuestRowCells.tsx`,
|
||||||
|
|||||||
@@ -968,9 +968,10 @@ resource, storage and physical-disk risk summaries, then the dataset state tag
|
|||||||
(`state:locked`, `state:unmounted`, `state:readonly`, which is the only place
|
(`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
|
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.
|
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
|
The first reason renders in the desktop Health column on the row's single line,
|
||||||
in the title, so a phone user learns why a row is amber without the Health
|
coloured by bucket, with a count for the rest and the full list in the title
|
||||||
column. Health sorts by severity rank, not label text.
|
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
|
That shell also owns responsive width composition. Source tables may declare
|
||||||
desktop breakpoint floors, but the phone branch must fit its selected columns
|
desktop breakpoint floors, but the phone branch must fit its selected columns
|
||||||
inside the available container without a horizontal rail. Each unified-resource
|
inside the available container without a horizontal rail. Each unified-resource
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"base_sha": "a99f3804a756acc293b3962fb9eaba2cbec04655",
|
||||||
|
"verified_at": "2026-10-05T05:22:24Z",
|
||||||
|
"result": "passed",
|
||||||
|
"changed_paths": [
|
||||||
|
"frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts",
|
||||||
|
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx"
|
||||||
|
],
|
||||||
|
"content_sha256": {
|
||||||
|
"frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts": "e1cc0cb0f4087c5c66014d254421aeea6b02842795c41978e1b9847dcfcb5c77",
|
||||||
|
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "19e1e5b705285464607804f8a8d7b5174e39c856290bd8c6ce5dc5e1212ff2af"
|
||||||
|
},
|
||||||
|
"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 again. Healthy rows leave Health empty, the archive pool reads Pool archive is DEGRADED: one member of archive mirror-0 is reporting checksum and SMART faults (truncated, +1, full list on hover), archive/cold reads Dataset is read-only, sdc reads Device /dev/sdc has SMART test failures, all in the Health column and coloured amber",
|
||||||
|
"Name cells hold only the name, so the reason no longer stacks under it",
|
||||||
|
"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: the table is the contract phone projection (Resource, Kind, Usage, Disks) with status dots, Health hidden",
|
||||||
|
"Phone drawer: tapping archive shows DEGRADED and tapping sdc shows the SMART failure. The read-only dataset drawer only says Degraded, a pre-existing drawer gap left for a follow-up because the drawer model is also changed by the open services and VMs pull request",
|
||||||
|
"usePlatformWindowedItems is back to its main content, with rows uniform again"
|
||||||
|
],
|
||||||
|
"interactions": [
|
||||||
|
"Switched to the Physical disks scope",
|
||||||
|
"Tapped archive, archive/cold and sdc on a 390 phone and read each drawer"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -263,63 +263,4 @@ 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,37 +23,6 @@ const PHONE_WINDOW_SIZE = 36;
|
|||||||
// collapsing the estimate and desynchronising the window from the scroll
|
// collapsing the estimate and desynchronising the window from the scroll
|
||||||
// position, while uniform tables still measure their real row height.
|
// position, while uniform tables still measure their real row height.
|
||||||
const MEASUREMENT_SAMPLE_SIZE = 3;
|
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> {
|
export interface PlatformWindowedItemsOptions<Item> {
|
||||||
items: Accessor<readonly Item[]>;
|
items: Accessor<readonly Item[]>;
|
||||||
estimatedItemHeight?: number;
|
estimatedItemHeight?: number;
|
||||||
@@ -104,9 +73,13 @@ export function usePlatformWindowedItems<Item>(options: PlatformWindowedItemsOpt
|
|||||||
if (!anchor || isWindowedSurfaceHidden(anchor)) return;
|
if (!anchor || isWindowedSurfaceHidden(anchor)) return;
|
||||||
|
|
||||||
if (measureItems) {
|
if (measureItems) {
|
||||||
const measuredHeight =
|
let measuredHeight = 0;
|
||||||
measureRenderedRunwayItemHeight(anchor, visibleItems().length) ||
|
let sibling = anchor.nextElementSibling;
|
||||||
measureTallestSampledSibling(anchor);
|
for (let sampled = 0; sampled < MEASUREMENT_SAMPLE_SIZE && sibling; sampled += 1) {
|
||||||
|
const height = sibling.getBoundingClientRect().height;
|
||||||
|
if (height > measuredHeight) measuredHeight = height;
|
||||||
|
sibling = sibling.nextElementSibling;
|
||||||
|
}
|
||||||
if (measuredHeight > 0) setEstimatedItemHeight(measuredHeight);
|
if (measuredHeight > 0) setEstimatedItemHeight(measuredHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,40 +193,41 @@ const DiskEnduranceCell: Component<{ row: TrueNASStorageTopologyRow }> = (props)
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (issue.status === 'offline') {
|
|
||||||
return 'border-red-300/50 bg-red-500/10 text-red-700 dark:text-red-300';
|
|
||||||
}
|
|
||||||
return 'border-border bg-surface-alt text-muted';
|
|
||||||
};
|
|
||||||
|
|
||||||
const issueReasonClass = (issue: TrueNASStorageIssue): string => {
|
const issueReasonClass = (issue: TrueNASStorageIssue): string => {
|
||||||
if (issue.status === 'attention') return 'text-amber-700 dark:text-amber-300';
|
if (issue.status === 'attention') return 'text-amber-700 dark:text-amber-300';
|
||||||
if (issue.status === 'offline') return 'text-red-600 dark:text-red-300';
|
if (issue.status === 'offline') return 'text-red-600 dark:text-red-300';
|
||||||
return 'text-muted';
|
return 'text-muted';
|
||||||
};
|
};
|
||||||
|
|
||||||
// The visible reason is clamped and abbreviates the rest to a count, so
|
// The visible reason is truncated and abbreviates the rest to a count, so
|
||||||
// assistive technology gets the bucket and every reason instead.
|
// assistive technology gets the bucket and every reason instead.
|
||||||
const issueScreenReaderText = (issue: TrueNASStorageIssue): string =>
|
const issueScreenReaderText = (issue: TrueNASStorageIssue): string =>
|
||||||
`${issue.label}: ${issue.reasons.map((reason) => reason.replace(/[.\s]+$/, '')).join('. ')}.`;
|
`${issue.label}: ${issue.reasons.map((reason) => reason.replace(/[.\s]+$/, '')).join('. ')}.`;
|
||||||
|
|
||||||
// Healthy rows leave the Health column empty: the status dot already says
|
// 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.
|
// healthy, and a column of green pills buried the few rows that are not. An
|
||||||
const IssuePill: Component<{ issue: TrueNASStorageIssue | null }> = (props) => (
|
// 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}>
|
<Show when={props.issue}>
|
||||||
{(issue) => (
|
{(issue) => (
|
||||||
<span
|
<div
|
||||||
class={`inline-flex items-center rounded-sm border px-1.5 py-0.5 text-[10px] font-medium ${issuePillClass(
|
class={`flex min-w-0 items-center gap-1 text-[11px] font-medium ${issueReasonClass(issue())}`}
|
||||||
issue(),
|
title={issue().reasons.join('\n')}
|
||||||
)}`}
|
|
||||||
data-truenas-storage-health={issue().status}
|
data-truenas-storage-health={issue().status}
|
||||||
>
|
>
|
||||||
{issue().label}
|
<span aria-hidden="true" class="min-w-0 truncate">
|
||||||
</span>
|
{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>
|
</Show>
|
||||||
);
|
);
|
||||||
@@ -329,11 +330,9 @@ export const getTrueNASStorageTopologyIndentClass = (depth: number): string => {
|
|||||||
return 'pl-8 sm:pl-16';
|
return 'pl-8 sm:pl-16';
|
||||||
};
|
};
|
||||||
|
|
||||||
const ResourceCell: Component<{
|
const ResourceCell: Component<{ row: TrueNASStorageTopologyRow; detailToggle?: JSX.Element }> = (
|
||||||
row: TrueNASStorageTopologyRow;
|
props,
|
||||||
issue: TrueNASStorageIssue | null;
|
) => {
|
||||||
detailToggle?: JSX.Element;
|
|
||||||
}> = (props) => {
|
|
||||||
const displayStatus = () => getTrueNASResourceDisplayStatus(props.row.resource);
|
const displayStatus = () => getTrueNASResourceDisplayStatus(props.row.resource);
|
||||||
const indicator = () => getSimpleStatusIndicator(displayStatus());
|
const indicator = () => getSimpleStatusIndicator(displayStatus());
|
||||||
const name = () => resourceName(props.row.resource);
|
const name = () => resourceName(props.row.resource);
|
||||||
@@ -353,28 +352,6 @@ const ResourceCell: Component<{
|
|||||||
>
|
>
|
||||||
{name()}
|
{name()}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -512,7 +489,7 @@ export const TrueNASStorageTopologyTable: Component<{
|
|||||||
kind="metric-bar"
|
kind="metric-bar"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
sortKey="usage"
|
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" />
|
<PlatformResponsiveTableLabel compact="Usage" full="Usage / Size" />
|
||||||
</PlatformSortableTableHead>
|
</PlatformSortableTableHead>
|
||||||
@@ -520,7 +497,7 @@ export const TrueNASStorageTopologyTable: Component<{
|
|||||||
kind="numeric-value"
|
kind="numeric-value"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
sortKey={kindFilter() === 'disks' ? 'endurance' : 'disks'}
|
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'}
|
{kindFilter() === 'disks' ? 'Endurance' : 'Disks'}
|
||||||
</PlatformSortableTableHead>
|
</PlatformSortableTableHead>
|
||||||
@@ -528,15 +505,15 @@ export const TrueNASStorageTopologyTable: Component<{
|
|||||||
kind="numeric-value"
|
kind="numeric-value"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
sortKey="temp"
|
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
|
Temp
|
||||||
</PlatformSortableTableHead>
|
</PlatformSortableTableHead>
|
||||||
<PlatformSortableTableHead
|
<PlatformSortableTableHead
|
||||||
kind="badge"
|
kind="text"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
sortKey="health"
|
sortKey="health"
|
||||||
class="platform-table-phone-hidden md:w-[14%]"
|
class="platform-table-phone-hidden md:w-[24%]"
|
||||||
>
|
>
|
||||||
<PlatformResponsiveTableLabel compact="H" full="Health" />
|
<PlatformResponsiveTableLabel compact="H" full="Health" />
|
||||||
</PlatformSortableTableHead>
|
</PlatformSortableTableHead>
|
||||||
@@ -563,7 +540,6 @@ export const TrueNASStorageTopologyTable: Component<{
|
|||||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||||
<ResourceCell
|
<ResourceCell
|
||||||
row={row}
|
row={row}
|
||||||
issue={issue()}
|
|
||||||
detailToggle={
|
detailToggle={
|
||||||
<PlatformResourceDetailToggleButton
|
<PlatformResourceDetailToggleButton
|
||||||
expanded={isExpanded()}
|
expanded={isExpanded()}
|
||||||
@@ -614,9 +590,9 @@ export const TrueNASStorageTopologyTable: Component<{
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
class={`${getPlatformTableCellClassForKind('badge')} platform-table-phone-hidden`}
|
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden`}
|
||||||
>
|
>
|
||||||
<IssuePill issue={issue()} />
|
<IssueSummary issue={issue()} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<PlatformResourceDetailTableRow
|
<PlatformResourceDetailTableRow
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ describe('TrueNASStorageTopologyTable', () => {
|
|||||||
expect(container.querySelector('[data-truenas-storage-kind="disk"]')).toBeNull();
|
expect(container.querySelector('[data-truenas-storage-kind="disk"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows health only for exceptions, with the reason under the name', () => {
|
it('shows health only for exceptions, with the reason in the Health cell', () => {
|
||||||
const pool = makeStorageResource({
|
const pool = makeStorageResource({
|
||||||
id: 'pool-archive',
|
id: 'pool-archive',
|
||||||
name: 'archive',
|
name: 'archive',
|
||||||
@@ -195,23 +195,23 @@ describe('TrueNASStorageTopologyTable', () => {
|
|||||||
|
|
||||||
const poolRow = container.querySelector('[data-truenas-storage-resource="pool-archive"]');
|
const poolRow = container.querySelector('[data-truenas-storage-resource="pool-archive"]');
|
||||||
const healthyRow = container.querySelector('[data-truenas-storage-resource="dataset-backups"]');
|
const healthyRow = container.querySelector('[data-truenas-storage-resource="dataset-backups"]');
|
||||||
const reason = poolRow?.querySelector('[data-truenas-storage-reason]');
|
const health = poolRow?.querySelector('[data-truenas-storage-health="attention"]');
|
||||||
const visible = [...(reason?.querySelectorAll('[aria-hidden="true"]') ?? [])].map(
|
const visible = [...(health?.querySelectorAll('[aria-hidden="true"]') ?? [])].map(
|
||||||
(node) => node.textContent,
|
(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(visible).toEqual(['Pool archive is DEGRADED: one member of mirror-0 is faulted.', '+1']);
|
||||||
expect(reason?.querySelector('.sr-only')).toHaveTextContent(
|
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.',
|
'Attention: Pool archive is DEGRADED: one member of mirror-0 is faulted. Device /dev/sdc has SMART test failures.',
|
||||||
);
|
);
|
||||||
expect(reason).toHaveAttribute(
|
expect(health).toHaveAttribute(
|
||||||
'title',
|
'title',
|
||||||
'Pool archive is DEGRADED: one member of mirror-0 is faulted.\nDevice /dev/sdc has SMART test failures.',
|
'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(
|
expect(poolRow?.querySelector('td')).not.toHaveTextContent('DEGRADED');
|
||||||
'Attention',
|
|
||||||
);
|
|
||||||
expect(healthyRow?.querySelector('[data-truenas-storage-reason]')).toBeNull();
|
|
||||||
expect(healthyRow?.querySelector('[data-truenas-storage-health]')).toBeNull();
|
expect(healthyRow?.querySelector('[data-truenas-storage-health]')).toBeNull();
|
||||||
expect(healthyRow).not.toHaveTextContent('Healthy');
|
expect(healthyRow).not.toHaveTextContent('Healthy');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user