mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Compose retained disk-row refresh repair with current main
Change-source: pulse-maintainer
This commit is contained in:
commit
ffd7c9d993
7 changed files with 548 additions and 61 deletions
|
|
@ -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.
|
||||
|
|
|
|||
177
frontend-modern/browser-tests/disk-live-refresh.cjs
Normal file
177
frontend-modern/browser-tests/disk-live-refresh.cjs
Normal 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;
|
||||
});
|
||||
12
frontend-modern/browser-tests/disk-live-refresh.html
Normal file
12
frontend-modern/browser-tests/disk-live-refresh.html
Normal 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>
|
||||
116
frontend-modern/browser-tests/disk-live-refresh.tsx
Normal file
116
frontend-modern/browser-tests/disk-live-refresh.tsx
Normal 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')!);
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue