mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
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:
parent
33d6d729c1
commit
3d77bb5b3c
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
|
`internal/api/onboarding_handlers.go` changed only the human-readable pairing
|
||||||
readiness messages so they name Settings > Pulse Mobile. This opens no new
|
readiness messages so they name Settings > Pulse Mobile. This opens no new
|
||||||
storage or recovery path.
|
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,
|
"version": 1,
|
||||||
"base_sha": "1a0cd7c9d5aa02de754535a293dc0cacd4ef6fa0",
|
"base_sha": "33d6d729c1dc7c0913a970fd4c463a9f9575b7b1",
|
||||||
"verified_at": "2026-10-01T18:27:14.912695Z",
|
"verified_at": "2026-10-02T10:16:57.632722Z",
|
||||||
"result": "passed",
|
"result": "passed",
|
||||||
"changed_paths": [
|
"changed_paths": [
|
||||||
"frontend-modern/src/features/docs/docMarkdown.ts"
|
"frontend-modern/src/components/Storage/DiskList.tsx"
|
||||||
],
|
],
|
||||||
"content_sha256": {
|
"content_sha256": {
|
||||||
"frontend-modern/src/features/docs/docMarkdown.ts": "572a3b4bc5bc1114adef5aa64bb0ea833c0dbe5865bbd57d3dc767fd1965aa55"
|
"frontend-modern/src/components/Storage/DiskList.tsx": "df63db162779ff0d2064475a9db1fd67ac748703f398a88d108ecdd154dae9d2"
|
||||||
},
|
},
|
||||||
"routes": [
|
"routes": [
|
||||||
"/docs/PULSE_PRO",
|
"/browser-tests/disk-live-refresh.html"
|
||||||
"/browser-tests/docs-fragment-navigation.html?scenario=plans"
|
|
||||||
],
|
],
|
||||||
"viewports": [
|
"viewports": [
|
||||||
{
|
{
|
||||||
"width": 1280,
|
"width": 1365,
|
||||||
"height": 900
|
"height": 900
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"width": 390,
|
"width": 390,
|
||||||
"height": 900
|
"height": 844
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"states": [
|
"states": [
|
||||||
"Production Docs page/router/styles with the current shipped eight-column plan comparison, not a replacement table fixture",
|
"Production DiskList, DiskDetail, keyed renderer and styles with synthetic PBS collector snapshots, not installed appliance evidence",
|
||||||
"Every sanitized thead TH has trusted scope=col and all eight native columnheader roles; document role/scope/class remain stripped",
|
"Healthy to failed disk updates health, warning text, endurance, temperature, model and canonical target without remounting the row or its expanded detail",
|
||||||
"Desktop Chromium light and phone-emulated WebKit dark; table-local horizontal scrolling and outer-page containment; four final full-page/header screenshots inspected",
|
"Missing measurements remove obsolete values and critical styling; unavailable detail evidence remains visible; recovery restores current values",
|
||||||
"This establishes browser accessibility roles, not screen-reader speech or published availability"
|
"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": [
|
"interactions": [
|
||||||
"Navigate to the real plans document and inspect table DOM and accessibility snapshot",
|
"Expand the real disk row and focus its disclosure, apply fault/missing/recovery snapshots without navigating or reloading",
|
||||||
"On phone, scroll the table horizontally without moving the outer page; bring its header into view"
|
"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": {
|
"browser_versions": {
|
||||||
"playwright": "1.56.1",
|
"playwright": "1.56.1",
|
||||||
"chromium": "141.0.7390.37",
|
"chromium": "141.0.7390.37",
|
||||||
"webkit": "26.0"
|
"webkit": "26.0"
|
||||||
},
|
},
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/chromium-after.png",
|
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-fault.png",
|
||||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/chromium-headers.png",
|
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-missing.png",
|
||||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/webkit-after.png",
|
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-recovered.png",
|
||||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/webkit-headers.png",
|
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-fault.png",
|
||||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/after.json"
|
"/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}>
|
<TableBody class={PHYSICAL_DISK_TABLE_BODY_CLASS}>
|
||||||
<PlatformWindowedRows items={model.filteredDisks} estimatedRowHeight={32}>
|
<PlatformWindowedRows items={model.filteredDisks} estimatedRowHeight={32}>
|
||||||
{(disk) => {
|
{(disk) => {
|
||||||
const data = model.getDiskData(disk);
|
// Windowed rows retain their owner by resource ID across live
|
||||||
const status = getPhysicalDiskHealthStatus(data);
|
// snapshots. Derive the presentation reactively, not just when
|
||||||
const hostLabel = getPhysicalDiskHostLabel(data, disk);
|
// the row first mounts, so health and identity cannot go stale.
|
||||||
const healthSummary = getPhysicalDiskHealthSummary(status);
|
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 isSelected = () => model.selectedDisk()?.id === disk.id;
|
||||||
const summarySeriesId = resolvePhysicalDiskMetricResourceId(disk);
|
const summarySeriesId = createMemo(() => resolvePhysicalDiskMetricResourceId(disk));
|
||||||
const isSummaryHighlighted = () =>
|
const isSummaryHighlighted = () =>
|
||||||
props.highlightedSummarySeriesId === summarySeriesId;
|
props.highlightedSummarySeriesId === summarySeriesId();
|
||||||
const detailControlsId = buildSummaryDisclosureControlsId(summarySeriesId);
|
const detailControlsId = createMemo(() =>
|
||||||
|
buildSummaryDisclosureControlsId(summarySeriesId()),
|
||||||
|
);
|
||||||
const interactiveRowHandlers = createSummaryInteractiveRowPreviewHandlers({
|
const interactiveRowHandlers = createSummaryInteractiveRowPreviewHandlers({
|
||||||
onPreview: () => props.onHoverChange?.(disk.id),
|
onPreview: () => props.onHoverChange?.(disk.id),
|
||||||
onPreviewClear: () => props.onHoverChange?.(null),
|
onPreviewClear: () => props.onHoverChange?.(null),
|
||||||
|
|
@ -338,7 +343,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
<>
|
<>
|
||||||
<TableRow
|
<TableRow
|
||||||
data-row-id={disk.id}
|
data-row-id={disk.id}
|
||||||
data-summary-series-id={summarySeriesId}
|
data-summary-series-id={summarySeriesId()}
|
||||||
data-summary-row-active={
|
data-summary-row-active={
|
||||||
isSummaryHighlighted() && !isSelected() ? 'true' : 'false'
|
isSummaryHighlighted() && !isSelected() ? 'true' : 'false'
|
||||||
}
|
}
|
||||||
|
|
@ -358,18 +363,18 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
<div class={PHYSICAL_DISK_NAME_WRAP_CLASS}>
|
<div class={PHYSICAL_DISK_NAME_WRAP_CLASS}>
|
||||||
<SummaryRowActionButton
|
<SummaryRowActionButton
|
||||||
kind="disclosure"
|
kind="disclosure"
|
||||||
subjectLabel={data.model || 'disk'}
|
subjectLabel={data().model || 'disk'}
|
||||||
expanded={isSelected()}
|
expanded={isSelected()}
|
||||||
controlsId={detailControlsId}
|
controlsId={detailControlsId()}
|
||||||
hideWhenRowTappableOnMobile
|
hideWhenRowTappableOnMobile
|
||||||
onAction={() => model.toggleSelectedDisk(disk)}
|
onAction={() => model.toggleSelectedDisk(disk)}
|
||||||
onPreviewClear={() => props.onHoverChange?.(null)}
|
onPreviewClear={() => props.onHoverChange?.(null)}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class={PHYSICAL_DISK_NAME_TEXT_CLASS}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -379,11 +384,11 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
data-storage-column="device"
|
data-storage-column="device"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={data.devPath}
|
when={data().devPath}
|
||||||
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
||||||
>
|
>
|
||||||
<span class={PHYSICAL_DISK_DEVICE_TEXT_CLASS} title={data.devPath}>
|
<span class={PHYSICAL_DISK_DEVICE_TEXT_CLASS} title={data().devPath}>
|
||||||
{data.devPath}
|
{data().devPath}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -393,11 +398,11 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
data-storage-column="host"
|
data-storage-column="host"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={hostLabel}
|
when={hostLabel()}
|
||||||
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
||||||
>
|
>
|
||||||
<span class={PHYSICAL_DISK_VALUE_TEXT_CLASS} title={hostLabel}>
|
<span class={PHYSICAL_DISK_VALUE_TEXT_CLASS} title={hostLabel()}>
|
||||||
{hostLabel}
|
{hostLabel()}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -407,14 +412,14 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
data-storage-column="role"
|
data-storage-column="role"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={getPhysicalDiskRoleLabel(data)}
|
when={getPhysicalDiskRoleLabel(data())}
|
||||||
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={PHYSICAL_DISK_VALUE_TEXT_CLASS}
|
class={PHYSICAL_DISK_VALUE_TEXT_CLASS}
|
||||||
title={getPhysicalDiskRoleLabel(data)}
|
title={getPhysicalDiskRoleLabel(data())}
|
||||||
>
|
>
|
||||||
{getPhysicalDiskRoleLabel(data)}
|
{getPhysicalDiskRoleLabel(data())}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -424,14 +429,14 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
data-storage-column="parent"
|
data-storage-column="parent"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={getPhysicalDiskParentLabel(data)}
|
when={getPhysicalDiskParentLabel(data())}
|
||||||
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={PHYSICAL_DISK_VALUE_TEXT_CLASS}
|
class={PHYSICAL_DISK_VALUE_TEXT_CLASS}
|
||||||
title={getPhysicalDiskParentLabel(data)}
|
title={getPhysicalDiskParentLabel(data())}
|
||||||
>
|
>
|
||||||
{getPhysicalDiskParentLabel(data)}
|
{getPhysicalDiskParentLabel(data())}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -441,12 +446,15 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
data-storage-column="health"
|
data-storage-column="health"
|
||||||
>
|
>
|
||||||
<div class={PHYSICAL_DISK_HEALTH_WRAP_CLASS}>
|
<div class={PHYSICAL_DISK_HEALTH_WRAP_CLASS}>
|
||||||
<span class={`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status.tone}`}>
|
<span class={`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`}>
|
||||||
{status.label}
|
{status().label}
|
||||||
</span>
|
</span>
|
||||||
<Show when={healthSummary}>
|
<Show when={healthSummary()}>
|
||||||
<span class={PHYSICAL_DISK_HEALTH_SUMMARY_CLASS} title={healthSummary}>
|
<span
|
||||||
{healthSummary}
|
class={PHYSICAL_DISK_HEALTH_SUMMARY_CLASS}
|
||||||
|
title={healthSummary()}
|
||||||
|
>
|
||||||
|
{healthSummary()}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -457,13 +465,13 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
data-storage-column="life"
|
data-storage-column="life"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={getPhysicalDiskLifeLabel(data)}
|
when={getPhysicalDiskLifeLabel(data())}
|
||||||
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={`${PHYSICAL_DISK_LIFE_CLASS} ${getPhysicalDiskLifeTextClass(data)}`}
|
class={`${PHYSICAL_DISK_LIFE_CLASS} ${getPhysicalDiskLifeTextClass(data())}`}
|
||||||
>
|
>
|
||||||
{getPhysicalDiskLifeLabel(data)}
|
{getPhysicalDiskLifeLabel(data())}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -473,17 +481,17 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
data-storage-column="temp"
|
data-storage-column="temp"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={data.temperature > 0}
|
when={data().temperature > 0}
|
||||||
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}>—</span>}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={`${PHYSICAL_DISK_TEMPERATURE_CLASS} ${getTemperatureTextClass(
|
class={`${PHYSICAL_DISK_TEMPERATURE_CLASS} ${getTemperatureTextClass(
|
||||||
data.temperature,
|
data().temperature,
|
||||||
getDiskTemperatureThresholds(data.type),
|
getDiskTemperatureThresholds(data().type),
|
||||||
'diskTemperature',
|
'diskTemperature',
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
{formatTemperature(data.temperature)}
|
{formatTemperature(data().temperature)}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -493,7 +501,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
data-storage-column="size"
|
data-storage-column="size"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={data.size > 0}
|
when={data().size > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<span
|
<span
|
||||||
class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}
|
class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}
|
||||||
|
|
@ -504,15 +512,15 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span class={PHYSICAL_DISK_SIZE_VALUE_CLASS}>
|
<span class={PHYSICAL_DISK_SIZE_VALUE_CLASS}>
|
||||||
{formatBytes(data.size)}
|
{formatBytes(data().size)}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<Show when={isSelected()}>
|
<Show when={isSelected()}>
|
||||||
<TableRow data-inline-detail-for={summarySeriesId}>
|
<TableRow data-inline-detail-for={summarySeriesId()}>
|
||||||
<TableCell
|
<TableCell
|
||||||
id={detailControlsId}
|
id={detailControlsId()}
|
||||||
colSpan={9}
|
colSpan={9}
|
||||||
class={PHYSICAL_DISK_DETAIL_ROW_CELL_CLASS}
|
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 { createSignal } from 'solid-js';
|
||||||
|
import { createStore, reconcile } from 'solid-js/store';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { Resource } from '@/types/resource';
|
import type { Resource } from '@/types/resource';
|
||||||
import { DiskList } from '@/components/Storage/DiskList';
|
import { DiskList } from '@/components/Storage/DiskList';
|
||||||
|
|
@ -83,6 +84,159 @@ describe('DiskList', () => {
|
||||||
cleanup();
|
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', () => {
|
it('renders physical disks in a single-line operational grid', () => {
|
||||||
renderDiskList({
|
renderDiskList({
|
||||||
disks: [
|
disks: [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue