From 3d77bb5b3c4ed29ee7475d3eb771f80f2fbcd7fc Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:17:00 +0100 Subject: [PATCH] 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 --- .../internal/subsystems/storage-recovery.md | 16 ++ .../browser-tests/disk-live-refresh.cjs | 177 ++++++++++++++++++ .../browser-tests/disk-live-refresh.html | 12 ++ .../browser-tests/disk-live-refresh.tsx | 116 ++++++++++++ frontend-modern/browser-verification.json | 44 +++-- .../src/components/Storage/DiskList.tsx | 88 +++++---- .../Storage/__tests__/DiskList.test.tsx | 156 ++++++++++++++- 7 files changed, 548 insertions(+), 61 deletions(-) create mode 100644 frontend-modern/browser-tests/disk-live-refresh.cjs create mode 100644 frontend-modern/browser-tests/disk-live-refresh.html create mode 100644 frontend-modern/browser-tests/disk-live-refresh.tsx diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 987a1e183..6431645e1 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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. diff --git a/frontend-modern/browser-tests/disk-live-refresh.cjs b/frontend-modern/browser-tests/disk-live-refresh.cjs new file mode 100644 index 000000000..11f7d2237 --- /dev/null +++ b/frontend-modern/browser-tests/disk-live-refresh.cjs @@ -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; +}); diff --git a/frontend-modern/browser-tests/disk-live-refresh.html b/frontend-modern/browser-tests/disk-live-refresh.html new file mode 100644 index 000000000..e3fc664d8 --- /dev/null +++ b/frontend-modern/browser-tests/disk-live-refresh.html @@ -0,0 +1,12 @@ + + + + + + Physical disk live refresh verification + + +
+ + + diff --git a/frontend-modern/browser-tests/disk-live-refresh.tsx b/frontend-modern/browser-tests/disk-live-refresh.tsx new file mode 100644 index 000000000..e7a7dc87d --- /dev/null +++ b/frontend-modern/browser-tests/disk-live-refresh.tsx @@ -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(null); + const [healthFilter, setHealthFilter] = createSignal('all'); + return ( +
+

Physical disk live refresh verification

+

+ Synthetic snapshots in the production PBS disk table and detail. +

+
+ + + + + +
+
+ +
+
+ ); +}; +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 45fb502a2..bc8f3ba8b 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/components/Storage/DiskList.tsx b/frontend-modern/src/components/Storage/DiskList.tsx index 280ebce9c..571539031 100644 --- a/frontend-modern/src/components/Storage/DiskList.tsx +++ b/frontend-modern/src/components/Storage/DiskList.tsx @@ -320,15 +320,20 @@ export const DiskList: Component = (props) => { {(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 = (props) => { <> = (props) => {
model.toggleSelectedDisk(disk)} onPreviewClear={() => props.onHoverChange?.(null)} /> - {data.model || 'Unknown Disk'} + {data().model || 'Unknown Disk'}
@@ -379,11 +384,11 @@ export const DiskList: Component = (props) => { data-storage-column="device" > —} > - - {data.devPath} + + {data().devPath} @@ -393,11 +398,11 @@ export const DiskList: Component = (props) => { data-storage-column="host" > —} > - - {hostLabel} + + {hostLabel()} @@ -407,14 +412,14 @@ export const DiskList: Component = (props) => { data-storage-column="role" > —} > - {getPhysicalDiskRoleLabel(data)} + {getPhysicalDiskRoleLabel(data())} @@ -424,14 +429,14 @@ export const DiskList: Component = (props) => { data-storage-column="parent" > —} > - {getPhysicalDiskParentLabel(data)} + {getPhysicalDiskParentLabel(data())} @@ -441,12 +446,15 @@ export const DiskList: Component = (props) => { data-storage-column="health" >
- - {status.label} + + {status().label} - - - {healthSummary} + + + {healthSummary()}
@@ -457,13 +465,13 @@ export const DiskList: Component = (props) => { data-storage-column="life" > —} > - {getPhysicalDiskLifeLabel(data)} + {getPhysicalDiskLifeLabel(data())} @@ -473,17 +481,17 @@ export const DiskList: Component = (props) => { data-storage-column="temp" > 0} + when={data().temperature > 0} fallback={—} > - {formatTemperature(data.temperature)} + {formatTemperature(data().temperature)} @@ -493,7 +501,7 @@ export const DiskList: Component = (props) => { data-storage-column="size" > 0} + when={data().size > 0} fallback={ = (props) => { } > - {formatBytes(data.size)} + {formatBytes(data().size)}
- + diff --git a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx index 0956cb917..c8b0ad943 100644 --- a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx @@ -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('sda'); + const view = render(() => ( + + )); + 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(() => ( + {}} + /> + )); + 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(() => ( + {}} + /> + )); + 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: [