diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5572c24252..6b766e3ef5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,18 @@ ## Purpose +### Stable physical-disk History composition + +Disk detail composes shared HistoryChart owners from stable disk-family catalog +entries, independently of the latest optional temperature/SMART fields. Matching +snapshots must not recreate chart DOM, reset inspection or restart reads. Stored +series retain their own sample provenance; current collection unavailability is +still reported in Overview, and empty history is not a manufactured zero. The +shared target/range owner continues to cancel old selections and reject late +responses. `DiskDetail.history-collection.test.tsx` and the production +`browser-tests/disk-history-collection.cjs` fixture verify these boundaries with +synthetic data; neither establishes native NAS health or installed acceptance. + Storage pool Configuration key/value rows keep capacity absence distinct from measured zero: unavailable bytes and percentages render as `n/a`, while valid independent observations survive a missing total. Live partial snapshots must diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 97ffabafe7..c0a85bd7b9 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,32 @@ ## Purpose +### Disk History is independent of current collection + +The existing physical-disk History catalog is selected by disk family, not by +whether the latest snapshot contains temperature or extended SMART values. +Temperature and ATA reallocated-sector charts, or temperature and NVMe +endurance/spare charts, query only the already-resolved canonical disk target. +A missing current field must not hide stored observations. An empty series is +shown as no history samples, never a fabricated zero or current-value fallback. +Overview continues to report current collection availability; explicit +unavailable/unsupported I/O still suppresses the existing live I/O section. + +Catalog entries retain their identity across matching snapshots so ordinary +current-temperature changes cannot remount charts, drop inspection or restart +History reads. The shared chart owner still invalidates reads and inspection +when the explicit target or range changes; a late cancelled response cannot +populate its replacement. This changes neither disk correlation nor collector, +retention, permission or entitlement policy, and proves no appliance recovery. + +Verification: `DiskDetail.test.tsx` and `useDiskDetailModel.test.ts` pin the +catalog and matching-snapshot boundary; `DiskDetail.history-collection.test.tsx` +uses the actual chart owner to cover absent current fields, stored zero versus +empty, NVMe/ATA catalogs, request counts, preserved inspection and late former +target responses. `frontend-modern/browser-tests/disk-history-collection.cjs` +checks the production components/client/CSS with synthetic API data on desktop +Chromium and touch/dark phone WebKit, not native NAS or installed acceptance. + ### Independent physical disk temperature Disk Overview displays a finite positive reported temperature even when the diff --git a/frontend-modern/browser-tests/disk-history-collection.cjs b/frontend-modern/browser-tests/disk-history-collection.cjs new file mode 100644 index 0000000000..c8492ba1e4 --- /dev/null +++ b/frontend-modern/browser-tests/disk-history-collection.cjs @@ -0,0 +1,303 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); +const parent = false; +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = path.join( + root, + 'node_modules', + parent ? 'disk-history-parent' : 'disk-history-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-')), + server: { host: '127.0.0.1', port: 5251, strictPort: true }, + }); + const playwright = require('playwright/package.json').version; + const expected = JSON.parse( + fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8'), + ).packages['node_modules/@playwright/test'].version; + assert.equal(playwright, expected); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [ + ['chromium-desktop', chromium, false], + ['webkit-phone', webkit, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, + hasTouch: phone, + isMobile: phone, + }); + const requests = [], + errors = [], + checks = []; + const observedAt = Date.now() - 60000; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5251') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + const query = Object.fromEntries(url.searchParams); + requests.push(query); + const value = + query.metric === 'smart_temp' + ? query.resourceId.includes('nas-b') + ? 55 + : 42 + : query.metric === 'smart_percentage_used' + ? 12 + : query.metric === 'smart_available_spare' + ? 97 + : 0; + const empty = query.range === '1h' && query.metric === 'smart_temp'; + return route.fulfill({ + json: { + resourceType: query.resourceType, + resourceId: query.resourceId, + metric: query.metric, + range: query.range, + start: Date.now() - 60000, + end: Date.now(), + source: 'store', + points: empty ? [] : [{ timestamp: observedAt, value, min: value, max: value }], + }, + }); + } + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { enabled: false, data: [] }, + }); + }); + const check = async (label, predicate) => { + try { + await predicate(); + checks.push({ label, passed: true }); + } catch (error) { + checks.push({ label, passed: false, error: error.message }); + } + }; + const screenshot = async (state) => { + const file = `${name}-${state}.png`; + await page.screenshot({ + path: path.join(artifacts, file), + fullPage: true, + animations: 'disabled', + }); + observations.push({ + browser: name, + state, + screenshot: file, + visibleCharts: await page.locator('canvas[role="img"]').allTextContents(), + selectedTabs: await page + .getByRole('tab') + .evaluateAll((tabs) => + tabs.map((tab) => ({ + label: tab.textContent, + selected: tab.getAttribute('aria-selected'), + border: getComputedStyle(tab).borderBottomColor, + })), + ), + overflow: await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth, + ), + body: await page.locator('main').innerText(), + }); + }; + const thermal = () => page.getByRole('img', { name: 'Temperature chart' }); + const description = async () => + thermal().evaluate( + (el) => document.getElementById(el.getAttribute('aria-describedby')).textContent, + ); + await page.goto('http://127.0.0.1:5251/browser-tests/disk-history-collection.html'); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByRole('tab', { name: 'History' }).click(); + await page.waitForTimeout(300); + await check('Stored ATA history is accessible before any current fields', async () => { + assert.equal(await page.locator('canvas[role="img"]').count(), 2); + assert.match(await description(), /42°C/); + }); + await check('History selection matches its visible content', async () => { + assert.equal( + await page.getByRole('tab', { name: 'History' }).getAttribute('aria-selected'), + 'true', + ); + }); + await screenshot('missing-current'); + await page.getByRole('button', { name: 'Restore current fields' }).click(); + await thermal().waitFor(); + await page.getByRole('combobox', { name: 'Disk history range' }).selectOption('7d'); + await page.waitForFunction( + () => + document + .querySelector('canvas[aria-label="Temperature chart"]') + ?.getAttribute('aria-describedby') && + document + .getElementById( + document + .querySelector('canvas[aria-label="Temperature chart"]') + .getAttribute('aria-describedby'), + ) + .textContent.includes('42°C'), + ); + if (phone) await thermal().tap({ position: { x: 80, y: 60 } }); + else { + await thermal().focus(); + await thermal().press('End'); + } + await page.waitForTimeout(100); + await check('Stored zero and exact temperature can be inspected', async () => { + assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 2); + assert.match( + await page + .locator('[data-history-chart-tooltip]') + .allTextContents() + .then((parts) => parts.join(' ')), + /0 sectors/, + ); + }); + await thermal().evaluate((el) => (el.dataset.proofOwner = 'original')); + const reads = requests.length; + await page.getByRole('button', { name: 'Update current fields' }).click(); + await page.waitForTimeout(200); + await check('Same-ID snapshot keeps mounted chart and makes no extra reads', async () => { + assert.equal(await thermal().getAttribute('data-proof-owner'), 'original'); + assert.equal(requests.length, reads); + assert.equal(await page.getByRole('combobox').inputValue(), '7d'); + }); + await screenshot('updated-current'); + await page.getByRole('button', { name: 'Lose current fields' }).click(); + await page.waitForTimeout(200); + await check( + 'Missing current fields do not withdraw stored readings or invent live I/O', + async () => { + assert.equal(await thermal().count(), 1); + assert.equal(await thermal().getAttribute('data-proof-owner'), 'original'); + assert.match(await description(), /42°C/); + assert.equal(requests.length, reads); + assert.equal(await page.getByText('Live I/O (30m)').count(), 0); + }, + ); + await screenshot('missing-again'); + if (!parent) { + await page.getByRole('tab', { name: 'Overview' }).click(); + await check( + 'Overview reports unavailable collection rather than the old temperature', + async () => { + assert.equal( + await page + .getByText('Temperature is temporarily unavailable: collection deadline exceeded') + .isVisible(), + true, + ); + assert.equal(await page.getByText('42°C', { exact: true }).count(), 0); + }, + ); + await page.getByRole('tab', { name: 'History' }).click(); + await page.getByRole('combobox').selectOption('1h'); + await page.getByText('No history samples in this time range.').waitFor(); + await check( + 'Empty temperature is not zero and reallocated zero remains stored', + async () => { + assert.doesNotMatch(await description(), /0°C/); + assert.equal(await page.locator('canvas[role="img"]').count(), 2); + }, + ); + await screenshot('empty-temperature'); + await page.getByRole('combobox').selectOption('7d'); + await page.getByRole('button', { name: 'Select NVMe B' }).click(); + await page.getByRole('img', { name: 'Available Spare chart' }).waitFor(); + await page.waitForTimeout(200); + await check( + 'NVMe History uses explicit B target and its own metrics without live fields', + async () => { + assert.equal(await page.locator('canvas[role="img"]').count(), 3); + assert.equal( + await page.getByRole('img', { name: 'Reallocated Sectors chart' }).count(), + 0, + ); + assert.match(await description(), /55°C/); + const own = requests.filter((r) => r.resourceId === 'disk:nas-b:nvme0n1'); + assert.equal( + own.some((r) => r.metric === 'smart_available_spare'), + true, + ); + assert.equal( + own.every((r) => r.resourceType === 'disk'), + true, + ); + }, + ); + await screenshot('nvme'); + const beforeClear = requests.length; + await page.getByRole('button', { name: 'Clear identity' }).click(); + await page.waitForTimeout(200); + await check( + 'Unresolvable identity withdraws History and does not guess a target', + async () => { + assert.equal(await page.getByRole('tab', { name: 'History' }).count(), 0); + assert.equal(await page.locator('canvas[role="img"]').count(), 0); + assert.equal(requests.length, beforeClear); + }, + ); + await screenshot('no-identity'); + } + await check('No page errors, off-origin data reads or horizontal overflow', async () => { + assert.deepEqual(errors, []); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), + false, + ); + assert.equal( + requests.some((r) => ['diskread', 'diskwrite', 'disk'].includes(r.metric)), + false, + ); + }); + observations.push({ browser: name, version: browser.version(), checks, requests, errors }); + await browser.close(); + browser = undefined; + } + } finally { + if (browser) await browser.close(); + await server.close(); + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify({ parent, playwright, expected, observations }, null, 2), + ); + } + const failed = observations.flatMap((o) => o.checks ?? []).filter((c) => !c.passed); + console.log( + JSON.stringify({ + parent, + checks: observations.flatMap((o) => o.checks ?? []).length, + failed: failed.map((c) => c.label), + }), + ); + if (!parent) assert.equal(failed.length, 0); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/disk-history-collection.html b/frontend-modern/browser-tests/disk-history-collection.html new file mode 100644 index 0000000000..1d1a1db082 --- /dev/null +++ b/frontend-modern/browser-tests/disk-history-collection.html @@ -0,0 +1,12 @@ + + + + + + Disk History collection verification + + +
+ + + diff --git a/frontend-modern/browser-tests/disk-history-collection.tsx b/frontend-modern/browser-tests/disk-history-collection.tsx new file mode 100644 index 0000000000..8c467a1c9d --- /dev/null +++ b/frontend-modern/browser-tests/disk-history-collection.tsx @@ -0,0 +1,75 @@ +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { DiskDetail } from '../src/components/Storage/DiskDetail'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +// Only snapshots are synthetic: detail, history, client, inspection and CSS are production code. +const disk = (missing = false, nvme = false, hot = false): Resource => ({ + id: nvme ? 'disk-b' : 'disk-a', + name: nvme ? 'NVMe B' : 'Archive A', + displayName: nvme ? 'NVMe B' : 'Archive A', + type: 'physical_disk', + platformId: nvme ? 'nas-b' : 'nas-a', + platformType: 'truenas', + sourceType: 'api', + status: 'online', + lastSeen: Date.now(), + identity: { hostname: nvme ? 'nas-b' : 'nas-a' }, + metricsTarget: { + resourceType: 'disk', + resourceId: nvme ? 'disk:nas-b:nvme0n1' : 'disk:nas-a:sda', + }, + physicalDisk: { + devPath: nvme ? '/dev/nvme0n1' : '/dev/sda', + model: nvme ? 'NVMe B' : 'Archive HDD A', + serial: nvme ? 'SERIAL-B' : 'SERIAL-A', + diskType: nvme ? 'nvme' : 'hdd', + temperature: missing ? undefined : hot ? 65 : 42, + smart: missing + ? undefined + : nvme + ? { percentageUsed: 12, availableSpare: 97 } + : { reallocatedSectors: 0 }, + collection: { + temperature: { + state: missing ? 'unavailable' : 'available', + reason: missing ? 'collection deadline exceeded' : undefined, + }, + io: { state: 'unsupported', reason: 'per-member counters unavailable' }, + }, + }, +}); +const [snapshot, setSnapshot] = createSignal(disk(true)); +const controls = [ + ['Restore current fields', () => setSnapshot(disk())], + ['Update current fields', () => setSnapshot(disk(false, false, true))], + ['Lose current fields', () => setSnapshot(disk(true))], + ['Select NVMe B', () => setSnapshot(disk(true, true))], + [ + 'Clear identity', + () => + setSnapshot({ + ...disk(true), + id: '', + metricsTarget: undefined, + physicalDisk: { ...disk(true).physicalDisk!, serial: '', wwn: '' }, + }), + ], +] as const; +render( + () => ( +
+

Physical disk History

+
+ {controls.map(([label, action]) => ( + + ))} +
+ +
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 79959c2dd1..7555748f01 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,27 +1,16 @@ { "version": 1, "result": "passed", - "base_sha": "90c436c83d616eccbe544bf3737b13c3b6251e53", - "verified_at": "2026-10-02T23:38:43Z", + "base_sha": "b6a42d8a4f329a1f3d7359d8d64b17c385006ed7", + "verified_at": "2026-10-03T00:38:42Z", "changed_paths": [ - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts", - "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx" + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts" ], "content_sha256": { - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "63429552c461f36d4f5af06ac2ecd612b2a84630bf82e2e5d8311653519585d1", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "0e313b23f06c2c5fa139f2a8f1acd70299a047ab20da88016ec1aa01b5a50b4d", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "c7afd1510b7249021eb2af2a7dcd5fe71e5183bed019ade505489a2f46eb9635", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "fa04600d900f51395493d1d3a51d578d84d13adb64cc5a159322a61877854bd0", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "1ac0ffb9ee287a9da04fbf24e9953dbcbf4e55de906241e7702266808da2243d", - "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx": "161617fb877870ce0729dcb8312762b81d6eb4bd5cb3835023fed543b88ff02d" + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "31c71a2d696f1b74d55b855220885619bbeebc0b75a1f34354cc5b794ba49375" }, "routes": [ - "PBS resource detail History in existing platform tables", - "Availability fleet open resource detail" + "Existing Storage physical disk detail History" ], "viewports": [ { @@ -34,19 +23,23 @@ } ], "states": [ - "Same-ID replacement updates PBS heading, current CPU and history target without remounting the History body or losing the 7-day selection", - "Lost metrics target withdraws the History body and returning target restores the selected tab", - "Selecting another canonical resource starts on its own Overview and uses only its explicit History target", - "An open availability check changes from Up to Down with the current fleet snapshot", - "Selecting Check B after Check A changes header, endpoint and availability together", - "Removal of the selected check closes details; reintroduction does not reopen it", - "Desktop light Chromium and phone dark WebKit have no page overflow or uncaught page errors" + "Stored ATA temperature and measured-zero reallocated sectors render when current temperature and SMART are absent", + "Matching current snapshot updates keep the same chart owners and add zero History reads", + "Current field loss keeps stored samples and selected 7-day range; no unavailable live I/O is shown", + "Overview reports present collection unavailability instead of the earlier stored temperature", + "Empty temperature history is an explicit no-samples state, not a zero, while stored reallocated zero remains visible", + "NVMe uses its own explicit disk target and stored temperature/endurance/spare series without current SMART fields", + "No resolvable identity withdraws History and issues no guessed-target request", + "Keyboard desktop and touch phone inspection show real stored readings", + "All screenshots inspected with animations disabled; selected tab, light/dark layouts and page width agree with content", + "Playwright 1.56.1 matches the integration lock; no page errors or off-origin data reads" ], "interactions": [ - "Select History and 7-day range", - "Replace PBS snapshot, withdraw and restore metrics target", - "Select PBS B and open its History", - "Open Check A, publish failed replacement, then select Check B", - "Remove and restore selected check" + "Open History from unavailable current Overview", + "Restore, update and remove current fields", + "Change 24h/7d/1h ranges", + "Inspect by keyboard or phone tap", + "Select NVMe B", + "Clear disk identity" ] } diff --git a/frontend-modern/src/components/Storage/__tests__/DiskDetail.history-collection.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskDetail.history-collection.test.tsx new file mode 100644 index 0000000000..bfbd263fd4 --- /dev/null +++ b/frontend-modern/src/components/Storage/__tests__/DiskDetail.history-collection.test.tsx @@ -0,0 +1,260 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ChartsAPI, + type MetricsHistoryParams, + type SingleMetricHistoryResponse, +} from '@/api/charts'; +import { DiskDetail } from '../DiskDetail'; +import type { Resource } from '@/types/resource'; + +vi.mock('@/api/charts', () => ({ ChartsAPI: { getMetricsHistory: vi.fn() } })); +vi.mock('@/stores/license', () => ({ + isRangeLocked: () => false, + loadRuntimeCapabilities: vi.fn(), + maxHistoryDays: () => 7, +})); +vi.mock('@/stores/alertsActivation', () => ({ + useAlertsActivation: () => ({ getDiskTemperatureThresholds: () => null }), +})); +const request = vi.mocked(ChartsAPI.getMetricsHistory); +const context = { + clearRect: vi.fn(), + setTransform: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fillText: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + arc: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + setLineDash: vi.fn(), + measureText: () => ({ width: 20 }), +}; +const disk = (missing = false, nvme = false): Resource => ({ + id: 'disk-a', + type: 'physical_disk', + name: 'Archive disk', + displayName: 'Archive disk', + platformId: 'nas-a', + platformType: 'truenas', + sourceType: 'api', + status: 'online', + lastSeen: 1000, + identity: { hostname: 'nas-a' }, + metricsTarget: { resourceType: 'disk', resourceId: 'disk:nas-a:sda' }, + physicalDisk: { + devPath: nvme ? '/dev/nvme0n1' : '/dev/sda', + model: nvme ? 'NVMe' : 'Archive HDD', + serial: 'SERIAL-A', + diskType: nvme ? 'nvme' : 'hdd', + temperature: missing ? undefined : 42, + smart: missing + ? undefined + : nvme + ? { percentageUsed: 12, availableSpare: 97 } + : { reallocatedSectors: 0 }, + collection: { + temperature: { + state: missing ? 'unavailable' : 'available', + reason: missing ? 'collection deadline exceeded' : undefined, + }, + io: { state: 'unsupported', reason: 'per-member counters unavailable' }, + }, + }, +}); +const response = ( + params: MetricsHistoryParams, + value = params.metric === 'smart_temp' ? 42 : 0, +): SingleMetricHistoryResponse => ({ + resourceType: params.resourceType, + resourceId: params.resourceId, + metric: params.metric!, + range: params.range!, + start: 1000, + end: 2000, + source: 'store', + points: [{ timestamp: 1000, value, min: value, max: value }], +}); +const description = (canvas: HTMLElement) => + document.getElementById(canvas.getAttribute('aria-describedby')!)!; +const openHistory = () => fireEvent.click(screen.getByRole('tab', { name: 'History' })); + +beforeEach(() => { + request.mockReset().mockImplementation(async (params) => response(params)); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + context as unknown as CanvasRenderingContext2D, + ); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + }, + ); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('Disk History independently of current collection', () => { + it('loads stored ATA readings when current temperature and SMART are unavailable', async () => { + render(() => ); + expect(screen.getByText(/Temperature is temporarily unavailable/)).toBeInTheDocument(); + openHistory(); + await waitFor(() => + expect(screen.getByRole('img', { name: 'Temperature chart' })).toBeInTheDocument(), + ); + const thermal = screen.getByRole('img', { name: 'Temperature chart' }); + await waitFor(() => expect(description(thermal)).toHaveTextContent('42°C')); + expect( + description(screen.getByRole('img', { name: 'Reallocated Sectors chart' })), + ).toHaveTextContent('0 sectors'); + expect( + request.mock.calls.every( + ([params]) => params.resourceType === 'disk' && params.resourceId === 'disk:nas-a:sda', + ), + ).toBe(true); + expect(request.mock.calls.map(([params]) => params.metric)).not.toContain('diskread'); + expect(screen.queryByText('Live I/O (30m)')).not.toBeInTheDocument(); + }); + + it('loads stored NVMe temperature and endurance without current SMART fields', async () => { + render(() => ); + openHistory(); + await waitFor(() => expect(screen.getAllByRole('img')).toHaveLength(3)); + expect(screen.getByRole('img', { name: 'Life Used chart' })).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Available Spare chart' })).toBeInTheDocument(); + expect( + screen.queryByRole('img', { name: 'Reallocated Sectors chart' }), + ).not.toBeInTheDocument(); + await waitFor(() => + expect(description(screen.getByRole('img', { name: 'Temperature chart' }))).toHaveTextContent( + '42°C', + ), + ); + }); + + it('preserves mounted charts and inspection without extra reads on matching snapshots', async () => { + const [snapshot, setSnapshot] = createSignal(disk()); + render(() => ); + openHistory(); + fireEvent.change(screen.getByRole('combobox', { name: 'Disk history range' }), { + target: { value: '7d' }, + }); + const thermal = screen.getByRole('img', { name: 'Temperature chart' }); + await waitFor(() => expect(description(thermal)).toHaveTextContent('42°C')); + fireEvent.focus(thermal); + expect(document.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('42°C'); + const reads = request.mock.calls.length; + setSnapshot({ ...disk(), physicalDisk: { ...disk().physicalDisk!, temperature: 65 } }); + expect(screen.getByRole('img', { name: 'Temperature chart' })).toBe(thermal); + expect(document.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('42°C'); + expect(screen.getByRole('combobox', { name: 'Disk history range' })).toHaveValue('7d'); + expect(request).toHaveBeenCalledTimes(reads); + }); + + it('keeps stored observations mounted after current fields disappear', async () => { + const [snapshot, setSnapshot] = createSignal(disk()); + render(() => ); + openHistory(); + const thermal = screen.getByRole('img', { name: 'Temperature chart' }); + await waitFor(() => expect(description(thermal)).toHaveTextContent('42°C')); + const reads = request.mock.calls.length; + setSnapshot(disk(true)); + expect(screen.getByRole('img', { name: 'Temperature chart' })).toBe(thermal); + expect(description(thermal)).toHaveTextContent('42°C'); + expect(screen.getByRole('img', { name: 'Reallocated Sectors chart' })).toBeInTheDocument(); + expect(request).toHaveBeenCalledTimes(reads); + fireEvent.click(screen.getByRole('tab', { name: 'Overview' })); + expect(screen.getByText(/Temperature is temporarily unavailable/)).toBeVisible(); + expect(screen.queryByText('42°C')).not.toBeInTheDocument(); + }); + + it('ignores an old target completion and uses only the explicit new disk target', async () => { + const pending: { + params: MetricsHistoryParams; + resolve: (value: SingleMetricHistoryResponse) => void; + }[] = []; + request.mockImplementation( + (params) => new Promise((resolve) => pending.push({ params, resolve })), + ); + const [snapshot, setSnapshot] = createSignal(disk()); + render(() => ); + openHistory(); + await waitFor(() => + expect( + pending.filter(({ params }) => params.resourceId === 'disk:nas-a:sda').length, + ).toBeGreaterThan(0), + ); + setSnapshot({ + ...disk(), + metricsTarget: { resourceType: 'disk', resourceId: 'disk:nas-b:sda' }, + }); + await waitFor(() => + expect( + pending.filter(({ params }) => params.resourceId === 'disk:nas-b:sda').length, + ).toBeGreaterThan(0), + ); + for (const item of pending.filter(({ params }) => params.resourceId === 'disk:nas-b:sda')) + item.resolve(response(item.params, 55)); + await waitFor(() => + expect(description(screen.getByRole('img', { name: 'Temperature chart' }))).toHaveTextContent( + '55°C', + ), + ); + for (const item of pending.filter(({ params }) => params.resourceId === 'disk:nas-a:sda')) { + expect(item.params.signal?.aborted).toBe(true); + item.resolve(response(item.params, 99)); + } + await Promise.resolve(); + expect(description(screen.getByRole('img', { name: 'Temperature chart' }))).toHaveTextContent( + '55°C', + ); + expect( + description(screen.getByRole('img', { name: 'Temperature chart' })), + ).not.toHaveTextContent('99°C'); + }); + + it('distinguishes empty stored series from measured zero with no live fallback', async () => { + request.mockImplementation(async (params) => ({ + ...response(params), + points: params.metric === 'smart_temp' ? [] : response(params, 0).points, + })); + render(() => ); + openHistory(); + await waitFor(() => + expect(screen.getByText('No history samples in this time range.')).toBeInTheDocument(), + ); + expect( + description(screen.getByRole('img', { name: 'Temperature chart' })), + ).not.toHaveTextContent('0°C'); + await waitFor(() => + expect( + description(screen.getByRole('img', { name: 'Reallocated Sectors chart' })), + ).toHaveTextContent('0 sectors'), + ); + }); + + it('makes no history request without a resolvable disk identity', () => { + render(() => ( + + )); + expect(screen.queryByRole('tab', { name: 'History' })).not.toBeInTheDocument(); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx index 701ada6248..cfa8a0f5d0 100644 --- a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx @@ -146,4 +146,17 @@ describe('DiskDetail', () => { screen.getByText('Detailed SMART attributes are not available for this disk.'), ).toHaveAttribute('role', 'status'); }); + + it('offers the stored disk-family catalog when current SMART and temperature disappear', () => { + const disk = buildDisk(); + delete disk.physicalDisk!.temperature; + delete disk.physicalDisk!.smart; + disk.physicalDisk!.collection = { io: { state: 'unsupported' } }; + render(() => ); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + expect(screen.getAllByTestId('history-chart').map((chart) => chart.textContent)).toEqual([ + 'disk:agent-tower:sda:smart_temp:24h', + 'disk:agent-tower:sda:smart_reallocated_sectors:24h', + ]); + }); }); diff --git a/frontend-modern/src/components/Storage/__tests__/useDiskDetailModel.test.ts b/frontend-modern/src/components/Storage/__tests__/useDiskDetailModel.test.ts index 322e3024d2..fdf27e5f76 100644 --- a/frontend-modern/src/components/Storage/__tests__/useDiskDetailModel.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useDiskDetailModel.test.ts @@ -108,4 +108,20 @@ describe('useDiskDetailModel', () => { 'Disk I/O is unsupported: per-member counters unavailable', ]); }); + + it('keeps catalog entries stable across matching current-field snapshots', () => { + const initial = buildDisk(); + const [disk, setDisk] = createSignal(initial); + const { result } = renderHook(() => useDiskDetailModel({ disk })); + const charts = result.historyCharts(); + setDisk({ ...initial, physicalDisk: { ...initial.physicalDisk!, temperature: 65 } }); + expect(result.historyCharts()).toBe(charts); + setDisk({ + ...initial, + physicalDisk: { ...initial.physicalDisk!, temperature: undefined, smart: undefined }, + }); + expect(result.historyCharts()).toBe(charts); + expect(result.historyResourceId()).toBe('agent-tower:sda'); + expect(result.attributeCards()).toEqual([]); + }); }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts index 3b5af97539..39c67706d2 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts @@ -135,8 +135,8 @@ describe('diskDetailPresentation.branchcov0724pm', () => { }); }); - describe('getDiskDetailHistoryCharts SATA reallocated-sectors arm (L192)', () => { - it('emits only the smart_reallocated_sectors chart for a SATA disk with the field set', () => { + describe('getDiskDetailHistoryCharts disk-family catalog', () => { + it('includes stored temperature and reallocated sectors independently of current values', () => { const charts = getDiskDetailHistoryCharts( makeDiskData({ type: 'hdd', @@ -144,8 +144,8 @@ describe('diskDetailPresentation.branchcov0724pm', () => { smartAttributes: { reallocatedSectors: 3 }, }), ); - expect(charts.map((c) => c.metric)).toEqual(['smart_reallocated_sectors']); - expect(charts[0]).toMatchObject({ + expect(charts.map((c) => c.metric)).toEqual(['smart_temp', 'smart_reallocated_sectors']); + expect(charts[1]).toMatchObject({ metric: 'smart_reallocated_sectors', label: 'Reallocated Sectors', unit: 'sectors', @@ -160,8 +160,13 @@ describe('diskDetailPresentation.branchcov0724pm', () => { expect(charts.find((c) => c.metric === 'smart_reallocated_sectors')).toBeUndefined(); }); - it('returns an empty chart list when smartAttributes is absent', () => { - expect(getDiskDetailHistoryCharts(makeDiskData({ type: 'hdd' }))).toEqual([]); + it('keeps stored charts available when current SMART fields are absent', () => { + expect( + getDiskDetailHistoryCharts(makeDiskData({ type: 'hdd' })).map((chart) => chart.metric), + ).toEqual(['smart_temp', 'smart_reallocated_sectors']); + expect( + getDiskDetailHistoryCharts(makeDiskData({ type: 'NVMe' })).map((chart) => chart.metric), + ).toEqual(['smart_temp', 'smart_percentage_used', 'smart_available_spare']); }); }); }); diff --git a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts index 87af184ce3..12cc6a8f12 100644 --- a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts @@ -173,48 +173,46 @@ export function getDiskDetailAttributeCards( return cards; } +const DISK_TEMPERATURE_HISTORY_CHART: DiskDetailHistoryChartConfig = { + metric: 'smart_temp', + label: 'Temperature', + unit: 'C', + color: '#ef4444', +}; + +const ATA_DISK_HISTORY_CHARTS: readonly DiskDetailHistoryChartConfig[] = [ + DISK_TEMPERATURE_HISTORY_CHART, + { + metric: 'smart_reallocated_sectors', + label: 'Reallocated Sectors', + unit: 'sectors', + color: '#f59e0b', + }, +]; + +const NVME_DISK_HISTORY_CHARTS: readonly DiskDetailHistoryChartConfig[] = [ + DISK_TEMPERATURE_HISTORY_CHART, + { + metric: 'smart_percentage_used', + label: 'Life Used', + unit: '%', + color: '#f59e0b', + }, + { + metric: 'smart_available_spare', + label: 'Available Spare', + unit: '%', + color: '#10b981', + }, +]; + export function getDiskDetailHistoryCharts( disk: PhysicalDiskPresentationData, -): DiskDetailHistoryChartConfig[] { - const attrs = disk.smartAttributes; - const charts: DiskDetailHistoryChartConfig[] = []; - const isNvme = disk.type?.toLowerCase() === 'nvme'; - - if (disk.temperature > 0) { - charts.push({ - metric: 'smart_temp', - label: 'Temperature', - unit: 'C', - color: '#ef4444', - }); - } - - if (!isNvme && attrs?.reallocatedSectors != null) { - charts.push({ - metric: 'smart_reallocated_sectors', - label: 'Reallocated Sectors', - unit: 'sectors', - color: '#f59e0b', - }); - } - - if (isNvme && attrs?.percentageUsed != null) { - charts.push({ - metric: 'smart_percentage_used', - label: 'Life Used', - unit: '%', - color: '#f59e0b', - }); - } - - if (isNvme && attrs?.availableSpare != null) { - charts.push({ - metric: 'smart_available_spare', - label: 'Available Spare', - unit: '%', - color: '#10b981', - }); - } - - return charts; +): readonly DiskDetailHistoryChartConfig[] { + // Missing current collection does not erase stored observations. Ask for the + // existing disk-family catalog and let the store return samples or an honest + // empty series; never synthesize a value from the current snapshot. + // Stable entries also keep Solid's reference-keyed For from remounting charts + // and restarting History reads on every ordinary disk snapshot replacement. + return disk.type?.toLowerCase() === 'nvme' ? NVME_DISK_HISTORY_CHARTS : ATA_DISK_HISTORY_CHARTS; }