Keep physical disk rows current across live snapshots

The keyed platform renderer preserves row owners, but the disk table captured mount-time presentation. Derive current health, readings, placement and target bindings reactively without discarding focus or expanded detail. Cover snapshot replacement, in-place updates, missing evidence and attention-filter recovery; record desktop and phone browser acceptance.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-02 11:17:00 +01:00
parent 33d6d729c1
commit 3d77bb5b3c
7 changed files with 548 additions and 61 deletions

View file

@ -6245,3 +6245,19 @@ state. The Recovery settings item and its capability gate are untouched.
`internal/api/onboarding_handlers.go` changed only the human-readable pairing
readiness messages so they name Settings > Pulse Mobile. This opens no new
storage or recovery path.
### Live physical-disk row continuity
Physical Disks uses the canonical keyed windowed renderer, which retains row
owners across resource snapshots. Disk presentation must therefore stay
reactive: health and its evidence, temperature, endurance, capacity, device,
host and placement labels reflect the latest snapshot rather than the mount-time
sample. Removed readings revert to their existing unknown/placeholders without
retaining old warning text or colour. Metric summary identities, highlight
matching and disclosure control IDs track the current canonical disk target.
Unchanged resource IDs keep their mounted row, focus and expanded detail;
attention filtering and the displayed health must agree through fault/recovery.
This changes no collector, health thresholds, history policy or server identity.
`frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx` exercises
snapshot replacement, in-place reconciliation, missing evidence, target changes
and fault/recovery using the real keyed renderer.

View file

@ -0,0 +1,177 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules/disk-live-refresh-proof');
fs.mkdirSync(artifacts, { recursive: true });
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-cache-')),
server: { host: '127.0.0.1', port: 5221, strictPort: true },
});
const observations = [];
let browser;
try {
await server.listen();
for (const scenario of [
{ engine: chromium, name: 'chromium', width: 1365, height: 900, dark: false },
{ engine: webkit, name: 'webkit', width: 390, height: 844, dark: true },
]) {
browser = await scenario.engine.launch(
scenario.name === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width: scenario.width, height: scenario.height },
isMobile: scenario.name === 'webkit',
hasTouch: scenario.name === 'webkit',
});
const errors = [],
requests = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5221') return route.abort();
if (!url.pathname.startsWith('/api/')) return route.continue();
requests.push(url.pathname);
if (url.pathname === '/api/license/runtime-capabilities')
return route.fulfill({
json: {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community', label: 'Pulse Community runtime' },
blocked_capabilities: [],
},
});
return route.fulfill({ json: { data: [], enabled: false } });
});
await page.goto('http://127.0.0.1:5221/browser-tests/disk-live-refresh.html');
await page.evaluate(
(dark) => document.documentElement.classList.toggle('dark', dark),
scenario.dark,
);
const fixture = page.getByTestId('disk-live-refresh-fixture');
const row = fixture.locator('[data-row-id="disk-one"]');
await row.getByText('Healthy', { exact: true }).waitFor();
await row.getByText('Archive SSD', { exact: true }).click();
const disclosure = row.getByRole('button', { name: 'Collapse Archive SSD', exact: true });
await disclosure.focus();
await row.evaluate((element) => {
element.dataset.proofOwner = 'retained';
});
const initialControls = await disclosure.getAttribute('aria-controls');
const snapshot = (phase) =>
page.locator(`button[data-update="${phase}"]`).evaluate((button) => button.click());
const checkContinuity = async () => {
assert.equal(await row.getAttribute('data-proof-owner'), 'retained');
assert.equal(
await row.locator('button').evaluate((button) => document.activeElement === button),
true,
);
assert.equal(await row.locator('button').getAttribute('aria-expanded'), 'true');
const controls = await row.locator('button').getAttribute('aria-controls');
assert.equal(await page.locator(`[id="${controls}"]`).count(), 1);
assert.equal(await fixture.locator('[data-inline-detail-for]').count(), 1);
const dimensions = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
width: innerWidth,
}));
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
return dimensions;
};
const screenshot = (state) =>
fixture.screenshot({ path: path.join(artifacts, `${scenario.name}-${state}.png`) });
await snapshot('fault');
await row.getByText('Replace Now', { exact: true }).waitFor();
for (const text of ['Archive SSD (fault)', '4%', '63°C'])
assert.equal(await row.getByText(text, { exact: true }).count(), 1);
assert.equal(await row.getByText('Healthy', { exact: true }).count(), 0);
assert.equal(await row.getAttribute('data-summary-series-id'), 'agent-archive:sdz');
assert.notEqual(await row.locator('button').getAttribute('aria-controls'), initialControls);
await fixture.getByText('2', { exact: true }).waitFor();
const faultDimensions = await checkContinuity();
await screenshot('fault');
await snapshot('missing');
await row.getByText('Unknown', { exact: true }).waitFor();
for (const text of ['Replace Now', 'SMART failed.', '4%', '63°C'])
assert.equal(await row.getByText(text, { exact: true }).count(), 0);
for (const column of ['temp', 'life', 'size', 'role', 'parent'])
assert.equal(
(await row.locator(`td[data-storage-column="${column}"]`).innerText()).trim(),
'—',
);
await fixture
.getByText('Temperature is temporarily unavailable: No current reading', { exact: true })
.waitFor();
const missingDimensions = await checkContinuity();
await screenshot('missing');
await snapshot('healthy');
await row.getByText('Healthy', { exact: true }).waitFor();
assert.equal(await row.getByText('96%', { exact: true }).count(), 1);
assert.equal(await row.getByText('41°C', { exact: true }).count(), 1);
assert.equal(await row.getAttribute('data-summary-series-id'), 'agent-archive:sda');
assert.equal(await row.locator('button').getAttribute('aria-controls'), initialControls);
const recoveredDimensions = await checkContinuity();
await screenshot('recovered');
await snapshot('fault');
await row.getByText('Replace Now', { exact: true }).waitFor();
await page.locator('[data-filter="attention"]').evaluate((button) => button.click());
assert.equal(await row.getByText('Replace Now', { exact: true }).count(), 1);
await snapshot('healthy');
await fixture.getByText('No disks need attention', { exact: true }).waitFor();
await page.locator('[data-filter="all"]').evaluate((button) => button.click());
await row.getByText('Healthy', { exact: true }).waitFor();
assert.deepEqual(errors, []);
observations.push({
browser: scenario.name,
version: browser.version(),
viewport: { width: scenario.width, height: scenario.height },
dark: scenario.dark,
faultDimensions,
missingDimensions,
recoveredDimensions,
errors,
requests,
});
await browser.close();
browser = undefined;
}
const source = 'src/components/Storage/DiskList.tsx';
const result = {
result: 'passed',
playwright: require('playwright/package.json').version,
content_sha256: {
[`frontend-modern/${source}`]: createHash('sha256')
.update(fs.readFileSync(path.join(root, source)))
.digest('hex'),
},
observations,
};
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
for (const name of fs.readdirSync(artifacts)) {
const file = path.join(artifacts, name);
if (fs.statSync(file).isFile()) fs.chmodSync(file, 0o644);
}
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Physical disk live refresh verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/disk-live-refresh.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,116 @@
// Real DiskList/DiskDetail and keyed renderer, with synthetic collector snapshots.
// This is browser behaviour evidence, not an installed SMART or appliance result.
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { DiskList } from '../src/components/Storage/DiskList';
import type { StorageHealthFilter } from '../src/features/storageBackups/models';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
const disk = (phase: 'healthy' | 'fault' | 'missing'): Resource => ({
id: 'disk-one',
type: 'physical_disk',
name: 'Archive disk',
platformType: 'proxmox-pbs',
sourceType: 'agent',
status: 'online',
lastSeen: Date.now(),
metricsTarget: {
resourceType: 'disk',
resourceId: phase === 'healthy' ? 'agent-archive:sda' : 'agent-archive:sdz',
},
identity: { hostname: 'archive-host' },
canonicalIdentity: { hostname: 'archive-host' },
physicalDisk: {
model: phase === 'fault' ? 'Archive SSD (fault)' : 'Archive SSD',
devPath: phase === 'healthy' ? '/dev/sda' : '/dev/sdz',
serial: 'SYNTHETIC-ONE',
diskType: phase === 'missing' ? undefined : 'ssd',
health: phase === 'healthy' ? 'PASSED' : phase === 'fault' ? 'FAILED' : 'UNKNOWN',
wearout: phase === 'healthy' ? 96 : phase === 'fault' ? 4 : -1,
temperature: phase === 'healthy' ? 41 : phase === 'fault' ? 63 : 0,
sizeBytes: phase === 'missing' ? 0 : 2_000_000_000_000,
storageRole: phase === 'missing' ? undefined : 'cache_pool',
storageGroup: phase === 'missing' ? undefined : 'Archive Pool',
risk:
phase === 'fault'
? {
level: 'critical',
reasons: [{ code: 'smart-failed', severity: 'critical', summary: 'SMART failed.' }],
}
: undefined,
smart:
phase === 'missing'
? undefined
: { powerOnHours: 100, pendingSectors: phase === 'fault' ? 2 : 0 },
collection:
phase === 'missing'
? {
temperature: { state: 'unavailable', source: 'fixture', reason: 'No current reading' },
}
: undefined,
},
});
const Fixture = () => {
const [disks, setDisks] = createSignal([disk('healthy')]);
const [selectedDiskId, setSelectedDiskId] = createSignal<string | null>(null);
const [healthFilter, setHealthFilter] = createSignal<StorageHealthFilter>('all');
return (
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">Physical disk live refresh verification</h1>
<p class="text-sm text-muted">
Synthetic snapshots in the production PBS disk table and detail.
</p>
<div class="flex flex-wrap gap-2">
<button
class="min-h-11 rounded border border-border px-3"
data-update="fault"
onClick={() => setDisks([disk('fault')])}
>
Report disk fault
</button>
<button
class="min-h-11 rounded border border-border px-3"
data-update="missing"
onClick={() => setDisks([disk('missing')])}
>
Remove current readings
</button>
<button
class="min-h-11 rounded border border-border px-3"
data-update="healthy"
onClick={() => setDisks([disk('healthy')])}
>
Recover disk
</button>
<button
class="min-h-11 rounded border border-border px-3"
data-filter="attention"
onClick={() => setHealthFilter('attention')}
>
Show disks needing attention
</button>
<button
class="min-h-11 rounded border border-border px-3"
data-filter="all"
onClick={() => setHealthFilter('all')}
>
Show all disks
</button>
</div>
<section data-testid="disk-live-refresh-fixture">
<DiskList
disks={disks()}
nodes={[]}
selectedNode={null}
healthFilter={healthFilter()}
searchTerm=""
selectedDiskId={selectedDiskId()}
onSelectedDiskChange={setSelectedDiskId}
/>
</section>
</main>
);
};
render(() => <Fixture />, document.getElementById('root')!);

View file

@ -1,49 +1,53 @@
{
"version": 1,
"base_sha": "1a0cd7c9d5aa02de754535a293dc0cacd4ef6fa0",
"verified_at": "2026-10-01T18:27:14.912695Z",
"base_sha": "33d6d729c1dc7c0913a970fd4c463a9f9575b7b1",
"verified_at": "2026-10-02T10:16:57.632722Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/docs/docMarkdown.ts"
"frontend-modern/src/components/Storage/DiskList.tsx"
],
"content_sha256": {
"frontend-modern/src/features/docs/docMarkdown.ts": "572a3b4bc5bc1114adef5aa64bb0ea833c0dbe5865bbd57d3dc767fd1965aa55"
"frontend-modern/src/components/Storage/DiskList.tsx": "df63db162779ff0d2064475a9db1fd67ac748703f398a88d108ecdd154dae9d2"
},
"routes": [
"/docs/PULSE_PRO",
"/browser-tests/docs-fragment-navigation.html?scenario=plans"
"/browser-tests/disk-live-refresh.html"
],
"viewports": [
{
"width": 1280,
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 900
"height": 844
}
],
"states": [
"Production Docs page/router/styles with the current shipped eight-column plan comparison, not a replacement table fixture",
"Every sanitized thead TH has trusted scope=col and all eight native columnheader roles; document role/scope/class remain stripped",
"Desktop Chromium light and phone-emulated WebKit dark; table-local horizontal scrolling and outer-page containment; four final full-page/header screenshots inspected",
"This establishes browser accessibility roles, not screen-reader speech or published availability"
"Production DiskList, DiskDetail, keyed renderer and styles with synthetic PBS collector snapshots, not installed appliance evidence",
"Healthy to failed disk updates health, warning text, endurance, temperature, model and canonical target without remounting the row or its expanded detail",
"Missing measurements remove obsolete values and critical styling; unavailable detail evidence remains visible; recovery restores current values",
"Disclosure keyboard focus and aria-controls survive same-ID updates; changed metric targets update summary/detail linkage",
"Attention filter includes the failed disk, removes the recovered disk and returns current healthy values in All",
"Desktop Chromium light and phone-emulated WebKit dark have no outer-page overflow or page errors; six fault/missing/recovered screenshots inspected; existing dense phone truncation is unchanged"
],
"interactions": [
"Navigate to the real plans document and inspect table DOM and accessibility snapshot",
"On phone, scroll the table horizontally without moving the outer page; bring its header into view"
"Expand the real disk row and focus its disclosure, apply fault/missing/recovery snapshots without navigating or reloading",
"Check current values, stable owner, focus, live disclosure target and selected detail after each snapshot",
"Toggle Attention/All across fault and recovery"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/docs-table-headers.cjs",
"command": "pulse-worker-browser frontend-modern/browser-tests/disk-live-refresh.cjs",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"webkit": "26.0"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/chromium-after.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/chromium-headers.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/webkit-after.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/webkit-headers.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/after.json"
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-fault.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-missing.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-recovered.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-fault.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-missing.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-recovered.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/result.json"
]
}

View file

@ -320,15 +320,20 @@ export const DiskList: Component<DiskListProps> = (props) => {
<TableBody class={PHYSICAL_DISK_TABLE_BODY_CLASS}>
<PlatformWindowedRows items={model.filteredDisks} estimatedRowHeight={32}>
{(disk) => {
const data = model.getDiskData(disk);
const status = getPhysicalDiskHealthStatus(data);
const hostLabel = getPhysicalDiskHostLabel(data, disk);
const healthSummary = getPhysicalDiskHealthSummary(status);
// Windowed rows retain their owner by resource ID across live
// snapshots. Derive the presentation reactively, not just when
// the row first mounts, so health and identity cannot go stale.
const data = createMemo(() => model.getDiskData(disk));
const status = createMemo(() => getPhysicalDiskHealthStatus(data()));
const hostLabel = createMemo(() => getPhysicalDiskHostLabel(data(), disk));
const healthSummary = createMemo(() => getPhysicalDiskHealthSummary(status()));
const isSelected = () => model.selectedDisk()?.id === disk.id;
const summarySeriesId = resolvePhysicalDiskMetricResourceId(disk);
const summarySeriesId = createMemo(() => resolvePhysicalDiskMetricResourceId(disk));
const isSummaryHighlighted = () =>
props.highlightedSummarySeriesId === summarySeriesId;
const detailControlsId = buildSummaryDisclosureControlsId(summarySeriesId);
props.highlightedSummarySeriesId === summarySeriesId();
const detailControlsId = createMemo(() =>
buildSummaryDisclosureControlsId(summarySeriesId()),
);
const interactiveRowHandlers = createSummaryInteractiveRowPreviewHandlers({
onPreview: () => props.onHoverChange?.(disk.id),
onPreviewClear: () => props.onHoverChange?.(null),
@ -338,7 +343,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
<>
<TableRow
data-row-id={disk.id}
data-summary-series-id={summarySeriesId}
data-summary-series-id={summarySeriesId()}
data-summary-row-active={
isSummaryHighlighted() && !isSelected() ? 'true' : 'false'
}
@ -358,18 +363,18 @@ export const DiskList: Component<DiskListProps> = (props) => {
<div class={PHYSICAL_DISK_NAME_WRAP_CLASS}>
<SummaryRowActionButton
kind="disclosure"
subjectLabel={data.model || 'disk'}
subjectLabel={data().model || 'disk'}
expanded={isSelected()}
controlsId={detailControlsId}
controlsId={detailControlsId()}
hideWhenRowTappableOnMobile
onAction={() => model.toggleSelectedDisk(disk)}
onPreviewClear={() => props.onHoverChange?.(null)}
/>
<span
class={PHYSICAL_DISK_NAME_TEXT_CLASS}
title={data.devPath || data.model || disk.name || 'Unknown Disk'}
title={data().devPath || data().model || disk.name || 'Unknown Disk'}
>
{data.model || 'Unknown Disk'}
{data().model || 'Unknown Disk'}
</span>
</div>
</TableCell>
@ -379,11 +384,11 @@ export const DiskList: Component<DiskListProps> = (props) => {
data-storage-column="device"
>
<Show
when={data.devPath}
when={data().devPath}
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
>
<span class={PHYSICAL_DISK_DEVICE_TEXT_CLASS} title={data.devPath}>
{data.devPath}
<span class={PHYSICAL_DISK_DEVICE_TEXT_CLASS} title={data().devPath}>
{data().devPath}
</span>
</Show>
</TableCell>
@ -393,11 +398,11 @@ export const DiskList: Component<DiskListProps> = (props) => {
data-storage-column="host"
>
<Show
when={hostLabel}
when={hostLabel()}
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
>
<span class={PHYSICAL_DISK_VALUE_TEXT_CLASS} title={hostLabel}>
{hostLabel}
<span class={PHYSICAL_DISK_VALUE_TEXT_CLASS} title={hostLabel()}>
{hostLabel()}
</span>
</Show>
</TableCell>
@ -407,14 +412,14 @@ export const DiskList: Component<DiskListProps> = (props) => {
data-storage-column="role"
>
<Show
when={getPhysicalDiskRoleLabel(data)}
when={getPhysicalDiskRoleLabel(data())}
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
>
<span
class={PHYSICAL_DISK_VALUE_TEXT_CLASS}
title={getPhysicalDiskRoleLabel(data)}
title={getPhysicalDiskRoleLabel(data())}
>
{getPhysicalDiskRoleLabel(data)}
{getPhysicalDiskRoleLabel(data())}
</span>
</Show>
</TableCell>
@ -424,14 +429,14 @@ export const DiskList: Component<DiskListProps> = (props) => {
data-storage-column="parent"
>
<Show
when={getPhysicalDiskParentLabel(data)}
when={getPhysicalDiskParentLabel(data())}
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
>
<span
class={PHYSICAL_DISK_VALUE_TEXT_CLASS}
title={getPhysicalDiskParentLabel(data)}
title={getPhysicalDiskParentLabel(data())}
>
{getPhysicalDiskParentLabel(data)}
{getPhysicalDiskParentLabel(data())}
</span>
</Show>
</TableCell>
@ -441,12 +446,15 @@ export const DiskList: Component<DiskListProps> = (props) => {
data-storage-column="health"
>
<div class={PHYSICAL_DISK_HEALTH_WRAP_CLASS}>
<span class={`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status.tone}`}>
{status.label}
<span class={`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`}>
{status().label}
</span>
<Show when={healthSummary}>
<span class={PHYSICAL_DISK_HEALTH_SUMMARY_CLASS} title={healthSummary}>
{healthSummary}
<Show when={healthSummary()}>
<span
class={PHYSICAL_DISK_HEALTH_SUMMARY_CLASS}
title={healthSummary()}
>
{healthSummary()}
</span>
</Show>
</div>
@ -457,13 +465,13 @@ export const DiskList: Component<DiskListProps> = (props) => {
data-storage-column="life"
>
<Show
when={getPhysicalDiskLifeLabel(data)}
when={getPhysicalDiskLifeLabel(data())}
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
>
<span
class={`${PHYSICAL_DISK_LIFE_CLASS} ${getPhysicalDiskLifeTextClass(data)}`}
class={`${PHYSICAL_DISK_LIFE_CLASS} ${getPhysicalDiskLifeTextClass(data())}`}
>
{getPhysicalDiskLifeLabel(data)}
{getPhysicalDiskLifeLabel(data())}
</span>
</Show>
</TableCell>
@ -473,17 +481,17 @@ export const DiskList: Component<DiskListProps> = (props) => {
data-storage-column="temp"
>
<Show
when={data.temperature > 0}
when={data().temperature > 0}
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
>
<span
class={`${PHYSICAL_DISK_TEMPERATURE_CLASS} ${getTemperatureTextClass(
data.temperature,
getDiskTemperatureThresholds(data.type),
data().temperature,
getDiskTemperatureThresholds(data().type),
'diskTemperature',
)}`}
>
{formatTemperature(data.temperature)}
{formatTemperature(data().temperature)}
</span>
</Show>
</TableCell>
@ -493,7 +501,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
data-storage-column="size"
>
<Show
when={data.size > 0}
when={data().size > 0}
fallback={
<span
class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}
@ -504,15 +512,15 @@ export const DiskList: Component<DiskListProps> = (props) => {
}
>
<span class={PHYSICAL_DISK_SIZE_VALUE_CLASS}>
{formatBytes(data.size)}
{formatBytes(data().size)}
</span>
</Show>
</TableCell>
</TableRow>
<Show when={isSelected()}>
<TableRow data-inline-detail-for={summarySeriesId}>
<TableRow data-inline-detail-for={summarySeriesId()}>
<TableCell
id={detailControlsId}
id={detailControlsId()}
colSpan={9}
class={PHYSICAL_DISK_DETAIL_ROW_CELL_CLASS}
>

View file

@ -1,5 +1,6 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { DiskList } from '@/components/Storage/DiskList';
@ -83,6 +84,159 @@ describe('DiskList', () => {
cleanup();
});
it('refreshes keyed disk rows without losing the expanded detail or keyboard focus', async () => {
const initial = buildDisk('sda', 'tower', { diskType: 'ssd', wearout: 96 });
const [disks, setDisks] = createSignal([initial]);
const [selectedDiskId, setSelectedDiskId] = createSignal<string | null>('sda');
const view = render(() => (
<DiskList
disks={disks()}
nodes={[]}
selectedNode={null}
searchTerm=""
selectedDiskId={selectedDiskId()}
onSelectedDiskChange={setSelectedDiskId}
/>
));
const row = view.container.querySelector('[data-row-id="sda"]')!;
const detail = screen.getByTestId('disk-detail');
const disclosure = within(row as HTMLElement).getByRole('button');
disclosure.focus();
const initialControls = disclosure.getAttribute('aria-controls');
setDisks([
{
...buildDisk('sda', 'archive', {
model: 'Replacement SSD',
devPath: '/dev/sdz',
diskType: 'ssd',
wearout: 4,
sizeBytes: 4_000_000_000_000,
temperature: 63,
storageRole: 'cache_pool',
storageGroup: 'Archive Pool',
health: 'FAILED',
risk: {
level: 'critical',
reasons: [{ code: 'smart-failed', severity: 'critical', summary: 'SMART failed.' }],
},
}),
metricsTarget: { resourceType: 'disk', resourceId: 'disk:archive:sdz' },
},
]);
await waitFor(() =>
expect(within(row as HTMLElement).getByText('Replace Now')).toBeInTheDocument(),
);
for (const text of [
'Replacement SSD',
'/dev/sdz',
'archive',
'Cache Pool',
'Archive Pool',
'SMART failed.',
'4%',
'63°C',
]) {
expect(within(row as HTMLElement).getByText(text)).toBeInTheDocument();
}
expect(within(row as HTMLElement).getByText('3.64 TB')).toBeInTheDocument();
expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
expect(screen.queryByText('96%')).not.toBeInTheDocument();
expect(view.container.querySelector('[data-row-id="sda"]')).toBe(row);
expect(screen.getByTestId('disk-detail')).toBe(detail);
expect(document.activeElement).toBe(disclosure);
expect(disclosure.getAttribute('aria-label')).toContain('Replacement SSD');
expect(row).toHaveAttribute('data-summary-series-id', 'disk:archive:sdz');
expect(disclosure.getAttribute('aria-controls')).not.toBe(initialControls);
expect(
view.container.querySelector('[data-inline-detail-for="disk:archive:sdz"]'),
).not.toBeNull();
expect(document.getElementById(disclosure.getAttribute('aria-controls')!)).not.toBeNull();
});
it('removes obsolete readings and fault styling when a keyed snapshot stops reporting them', async () => {
const [disks, setDisks] = createSignal([
buildDisk('sda', 'tower', {
health: 'FAILED',
diskType: 'ssd',
wearout: 4,
temperature: 63,
risk: {
level: 'critical',
reasons: [{ code: 'smart-failed', severity: 'critical', summary: 'SMART failed.' }],
},
}),
]);
const view = render(() => (
<DiskList
disks={disks()}
nodes={[]}
selectedNode={null}
searchTerm=""
selectedDiskId={null}
onSelectedDiskChange={() => {}}
/>
));
const row = view.container.querySelector('[data-row-id="sda"]')!;
expect(within(row as HTMLElement).getByText('Replace Now')).toHaveClass('text-red-700');
setDisks([
buildDisk('sda', 'tower', {
health: 'UNKNOWN',
diskType: '',
wearout: -1,
temperature: 0,
sizeBytes: 0,
storageRole: '',
storageGroup: '',
model: '',
devPath: '',
}),
]);
await waitFor(() =>
expect(within(row as HTMLElement).getByText('Unknown')).toBeInTheDocument(),
);
for (const text of ['Replace Now', 'SMART failed.', '4%', '63°C', 'Parity', 'Tower Array']) {
expect(within(row as HTMLElement).queryByText(text)).not.toBeInTheDocument();
}
expect(within(row as HTMLElement).getByText('Unknown')).not.toHaveClass('text-red-700');
expect(within(row as HTMLElement).getByText('sda')).toBeInTheDocument();
for (const column of ['temp', 'life', 'size', 'device', 'role', 'parent']) {
expect(row.querySelector(`td[data-storage-column="${column}"]`)).toHaveTextContent('—');
}
expect(view.container.querySelector('[data-row-id="sda"]')).toBe(row);
});
it('keeps the attention filter and row health consistent during live updates and recovery', async () => {
const [disks, setDisks] = createStore({ items: [buildDisk('sda', 'tower')] });
const [healthFilter, setHealthFilter] = createSignal<'all' | 'attention'>('all');
const view = render(() => (
<DiskList
disks={disks.items}
nodes={[]}
selectedNode={null}
searchTerm=""
healthFilter={healthFilter()}
selectedDiskId={null}
onSelectedDiskChange={() => {}}
/>
));
const row = view.container.querySelector('[data-row-id="sda"]');
setDisks(
'items',
reconcile([buildDisk('sda', 'tower', { smart: { pendingSectors: 2 }, temperature: 52 })]),
);
await waitFor(() => expect(screen.getByText('Needs Attention')).toBeInTheDocument());
expect(view.container.querySelector('[data-row-id="sda"]')).toBe(row);
setHealthFilter('attention');
expect(screen.getByText('Needs Attention')).toBeInTheDocument();
setDisks('items', reconcile([buildDisk('sda', 'tower')]));
await waitFor(() => expect(screen.getByText('No disks need attention')).toBeInTheDocument());
setHealthFilter('all');
await waitFor(() => expect(screen.getByText('Healthy')).toBeInTheDocument());
expect(screen.queryByText('52°C')).not.toBeInTheDocument();
});
it('renders physical disks in a single-line operational grid', () => {
renderDiskList({
disks: [