From c9a0f0bf6ae19cb4cb7a2ed20e2d7e435550dafb Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sat, 3 Oct 2026 01:39:20 +0100
Subject: [PATCH] Keep stored disk History independent of current collection
Use stable disk-family chart entries so missing current SMART or temperature fields cannot hide stored observations, and matching snapshots do not remount charts or restart reads. Preserve existing identity, live collection status and entitlement boundaries; cover cancellation, measured zero, empty series and desktop/phone presentation.
Change-source: pulse-maintainer
---
.../subsystems/frontend-primitives.md | 12 +
.../internal/subsystems/storage-recovery.md | 26 ++
.../browser-tests/disk-history-collection.cjs | 303 ++++++++++++++++++
.../disk-history-collection.html | 12 +
.../browser-tests/disk-history-collection.tsx | 75 +++++
frontend-modern/browser-verification.json | 49 ++-
.../DiskDetail.history-collection.test.tsx | 260 +++++++++++++++
.../Storage/__tests__/DiskDetail.test.tsx | 13 +
.../__tests__/useDiskDetailModel.test.ts | 16 +
...DetailPresentation.branchcov0724pm.test.ts | 17 +-
.../storageBackups/diskDetailPresentation.ts | 82 +++--
11 files changed, 789 insertions(+), 76 deletions(-)
create mode 100644 frontend-modern/browser-tests/disk-history-collection.cjs
create mode 100644 frontend-modern/browser-tests/disk-history-collection.html
create mode 100644 frontend-modern/browser-tests/disk-history-collection.tsx
create mode 100644 frontend-modern/src/components/Storage/__tests__/DiskDetail.history-collection.test.tsx
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;
}