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;
}