From 1330fc810563362b08e293b886017fef64a03bb0 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Fri, 2 Oct 2026 17:41:05 +0100
Subject: [PATCH 1/2] Backport accessible and truthful History states to v6.5
Compose reviewed keyboard navigation, tooltip geometry, empty copy and background-refresh warnings through df18123e7da3b682eed811c0e01c94a44dc2c29c. Preserve target isolation and line-specific guardrails; verify both browser engines against the assigned line. Later checkpoint only, not frozen v6.4.6.
Change-source: pulse-maintainer
---
.../subsystems/frontend-primitives.md | 27 ++++++
.../browser-tests/history-keyboard.cjs | 80 ++++++++++++++++
.../browser-tests/history-keyboard.html | 1 +
.../browser-tests/history-keyboard.tsx | 48 ++++++++++
.../browser-tests/history-status.cjs | 93 +++++++++++++++++++
.../browser-tests/history-status.html | 1 +
.../browser-tests/history-status.tsx | 30 ++++++
frontend-modern/browser-verification.json | 40 ++++----
.../src/components/shared/HistoryChart.tsx | 26 +++++-
.../components/shared/HistoryChartOverlay.tsx | 2 +-
.../SharedPrimitives.guardrails.test.ts | 3 +-
.../shared/__tests__/HistoryChart.test.tsx | 56 ++++++++++-
.../historyChartModel.branchcov2.test.ts | 6 +-
.../__tests__/useHistoryChartState.test.tsx | 69 ++++++++++----
.../components/shared/historyChartModel.ts | 3 +-
.../components/shared/useHistoryChartState.ts | 50 +++++++++-
.../frontendResourceTypeBoundaries.test.ts | 3 +-
17 files changed, 487 insertions(+), 51 deletions(-)
create mode 100644 frontend-modern/browser-tests/history-keyboard.cjs
create mode 100644 frontend-modern/browser-tests/history-keyboard.html
create mode 100644 frontend-modern/browser-tests/history-keyboard.tsx
create mode 100644 frontend-modern/browser-tests/history-status.cjs
create mode 100644 frontend-modern/browser-tests/history-status.html
create mode 100644 frontend-modern/browser-tests/history-status.tsx
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index a51faafde..323b250bc 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -7453,3 +7453,30 @@ field, polling, supplied-data transitions, locked/empty targets and cleanup.
accessible description in desktop Chromium and phone WebKit, including a late
old-target response and current-target loading/failure. Synthetic response proof
is not native PBS collection or whole-report #1723 acceptance.
+
+### Shared History keyboard inspection
+
+The existing Storage pool and disk canvas charts accept keyboard focus with a
+visible focus indicator. Focus selects the latest observed sample; Left/Right
+step through actual observations, Home/End select the endpoints, and Escape or
+blur clears inspection without trapping focus. Each keyboard-selected timestamp
+and formatted value is announced politely. Pointer inspection remains available
+without announcing every mouse move. Matching sample refreshes retain inspection;
+selection changes and empty data clear stale readings. No readings are invented
+for missing data. Tooltips reserve room for a wrapped local timestamp and reading. The mounted HistoryChart regression and history-keyboard browser
+fixture cover navigation, bounds, focus exit, refresh and replacement. Synthetic
+browser proof does not establish native collector or assistive-device acceptance.
+
+### Shared History request-state honesty
+
+An empty successful response states only that the selected time range has no
+samples; it does not claim collection is running or promise future readings.
+A failed matching background refresh keeps the last successful data and source,
+with a visible polite status also included in the chart's accessible description.
+The warning persists while retrying and clears only on success or selection
+replacement. This applies to previously empty results too. Initial failures
+remain separate errors. No polling frequency, request or access policy changes.
+`useHistoryChartState.test.tsx` covers failure, pending recovery, empty results
+and selection changes; `browser-tests/history-status.cjs` checks actual chart
+states in desktop Chromium and phone WebKit with synthetic transport failures.
+These checks do not establish native collection or released availability.
diff --git a/frontend-modern/browser-tests/history-keyboard.cjs b/frontend-modern/browser-tests/history-keyboard.cjs
new file mode 100644
index 000000000..419182ab6
--- /dev/null
+++ b/frontend-modern/browser-tests/history-keyboard.cjs
@@ -0,0 +1,80 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium, webkit } = require('playwright');
+(async () => {
+ const root = '/workspace/frontend-modern';
+ process.chdir(root);
+ const artifacts = path.join(root, 'node_modules/history-keyboard-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: 5223, strictPort: true } });
+ let browser;
+ const observations = [];
+ try {
+ await server.listen();
+ for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) {
+ browser = await engine.launch(name === 'chromium' ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
+ const page = await browser.newPage({ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit' });
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ await page.route('**/*', route => {
+ const url = new URL(route.request().url());
+ if (url.origin !== 'http://127.0.0.1:5223') return route.abort();
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ return route.fulfill({ json: url.pathname === '/api/license/runtime-capabilities' ? { capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: { build: 'community' }, blocked_capabilities: [] } : { data: [], enabled: false } });
+ });
+ await page.goto('http://127.0.0.1:5223/browser-tests/history-keyboard.html');
+ if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
+ const chart = page.getByRole('img', { name: 'Usage chart', exact: true });
+ await chart.waitFor();
+ if (process.argv.includes('--parent')) {
+ assert.equal(await chart.getAttribute('tabindex'), null);
+ await page.getByRole('button', { name: 'Change target' }).focus();
+ await page.keyboard.press('Tab');
+ assert.equal(await page.evaluate(() => document.activeElement.textContent), 'After charts');
+ await page.screenshot({ path: path.join(artifacts, 'parent-skipped.png'), fullPage: true });
+ console.log('Parent reproduced: Tab skips both storage charts; no keyboard inspection');
+ break;
+ }
+ assert.equal(await chart.getAttribute('tabindex'), '0');
+ await page.getByRole('button', { name: 'Change target' }).focus();
+ await page.keyboard.press('Tab');
+ assert.equal(await chart.evaluate(el => el === document.activeElement), true);
+ const live = page.locator('[aria-live="polite"]').first();
+ for (const [phase, key, expected] of [['focus', null, '30.0%'], ['previous', 'ArrowLeft', '20.0%'], ['first', 'Home', '10.0%'], ['lower-bound', 'ArrowLeft', '10.0%'], ['last', 'End', '30.0%'], ['upper-bound', 'ArrowRight', '30.0%'], ['escape', 'Escape', '']]) {
+ if (key) await page.keyboard.press(key);
+ await page.waitForTimeout(60);
+ assert.ok((await live.textContent()).includes(expected));
+ if (!expected) assert.equal(await live.textContent(), '');
+ else assert.ok((await page.locator('[data-history-chart-tooltip]').first().textContent()).includes(expected));
+ if (phase === 'previous') {
+ assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 2);
+ for (const tooltip of await page.locator('[data-history-chart-tooltip]').all()) {
+ assert.ok(await tooltip.evaluate(el => el.scrollHeight <= el.clientHeight));
+ }
+ assert.equal(await page.locator('[aria-live="polite"]').nth(1).textContent(), '');
+ const outline = await chart.evaluate(el => getComputedStyle(el).outlineWidth);
+ assert.notEqual(outline, '0px');
+ await page.screenshot({ path: path.join(artifacts, `${name}-keyboard.png`), fullPage: true });
+ }
+ observations.push({ name, version: browser.version(), phase, announcement: await live.textContent() });
+ }
+ await page.keyboard.press('Tab');
+ assert.equal(await page.getByRole('img', { name: 'Read chart', exact: true }).evaluate(el => el === document.activeElement), true);
+ await page.keyboard.press('Tab');
+ assert.equal(await page.getByRole('button', { name: 'After charts' }).evaluate(el => el === document.activeElement), true);
+ await page.getByRole('button', { name: 'Toggle empty' }).click();
+ await chart.focus();
+ await page.keyboard.press('End');
+ assert.equal(await live.textContent(), '');
+ assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 0);
+ const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, width: innerWidth }));
+ assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
+ assert.deepEqual(errors, []);
+ await browser.close(); browser = null;
+ }
+ fs.writeFileSync(path.join(artifacts, process.argv.includes('--parent') ? 'parent.json' : 'result.json'), JSON.stringify({ playwright: require('playwright/package.json').version, observations }, null, 2));
+ console.log(JSON.stringify({ result: 'passed', states: observations.length }));
+ } finally { if (browser) await browser.close(); await server.close(); }
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/frontend-modern/browser-tests/history-keyboard.html b/frontend-modern/browser-tests/history-keyboard.html
new file mode 100644
index 000000000..f04472ecf
--- /dev/null
+++ b/frontend-modern/browser-tests/history-keyboard.html
@@ -0,0 +1 @@
+
History keyboard verification
diff --git a/frontend-modern/browser-tests/history-keyboard.tsx b/frontend-modern/browser-tests/history-keyboard.tsx
new file mode 100644
index 000000000..5747fb707
--- /dev/null
+++ b/frontend-modern/browser-tests/history-keyboard.tsx
@@ -0,0 +1,48 @@
+import { createSignal } from 'solid-js';
+import { render } from 'solid-js/web';
+import { HistoryChart, HistoryChartHoverGroup } from '../src/components/shared/HistoryChart';
+import '../src/index.css';
+const Fixture = () => {
+ const [empty, setEmpty] = createSignal(false);
+ const [target, setTarget] = createSignal('a');
+ const points = () =>
+ empty()
+ ? []
+ : [10, 20, 30].map((value, i) => ({
+ timestamp: 1790942400000 + i * 60000,
+ value,
+ min: value,
+ max: value,
+ }));
+ return (
+
+ Storage History keyboard inspection
+
+
+
+
+
+
+
+
+ );
+};
+render(() => , document.getElementById('root')!);
diff --git a/frontend-modern/browser-tests/history-status.cjs b/frontend-modern/browser-tests/history-status.cjs
new file mode 100644
index 000000000..8fe339bdc
--- /dev/null
+++ b/frontend-modern/browser-tests/history-status.cjs
@@ -0,0 +1,93 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium, webkit } = require('playwright');
+(async () => {
+ const root = '/workspace/frontend-modern';
+ process.chdir(root);
+ const artifacts = path.join(root, 'node_modules/history-status-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: 5224, strictPort: true } });
+ let browser;
+ const observations = [];
+ const parent = process.argv.includes('--parent');
+ try {
+ await server.listen();
+ for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) {
+ browser = await engine.launch(name === 'chromium' ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
+ const page = await browser.newPage({ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit' });
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ let mode = 'empty', calls = 0;
+ const pending = [];
+ await page.route('**/*', route => {
+ const url = new URL(route.request().url());
+ if (url.origin !== 'http://127.0.0.1:5224') return route.abort();
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ if (url.pathname.includes('/metrics-store/history')) {
+ calls++;
+ if (mode === 'pending') { pending.push(route); return; }
+ if (mode === 'failed') return route.fulfill({status: 503, json: {error: 'Synthetic unavailable'}});
+ return route.fulfill({json: {points: mode === 'empty' ? [] : [0,1,2].map(i => ({timestamp: 1790942400000 + i*60000, value: 20+i*10, min: 20+i*10, max: 20+i*10})), source: 'store'}});
+ }
+ return route.fulfill({json: url.pathname === '/api/license/runtime-capabilities' ? {capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: {build:'community'}, blocked_capabilities: []} : {data: [], enabled: false}});
+ });
+ await page.clock.install();
+ await page.goto('http://127.0.0.1:5224/browser-tests/history-status.html');
+ if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
+ const chart = page.getByRole('img', {name:'Usage chart', exact:true});
+ await chart.waitFor();
+ const description = page.locator('#' + await chart.getAttribute('aria-describedby'));
+ async function state(phase, expected) {
+ await page.waitForFunction(({id, expected}) => document.getElementById(id).textContent.includes(expected), {id: await chart.getAttribute('aria-describedby'), expected});
+ const actual = await description.textContent();
+ const dimensions = await page.evaluate(() => ({scroll:document.documentElement.scrollWidth, width:innerWidth}));
+ assert.ok(dimensions.scroll <= dimensions.width + 1);
+ await page.screenshot({path:path.join(artifacts, `${parent?'parent-':''}${name}-${phase}.png`),fullPage:true});
+ observations.push({name, version:browser.version(),phase,actual,dimensions,calls});
+ }
+ await state('empty', 'No 1-hour');
+ assert.equal(await page.getByText(parent ? 'Collecting data... History will appear here.' : 'No history samples in this time range.', {exact:true}).count(),1);
+ mode = 'populated';
+ await page.clock.runFor(10_000);
+ await state('loaded','40.0%');
+ mode = 'failed';
+ await page.clock.runFor(10_000);
+ if (parent) {
+ await page.waitForTimeout(100);
+ assert.ok(!(await description.textContent()).includes('Could not refresh'));
+ assert.equal(await page.getByText('Could not refresh history. Showing the last successful result.',{exact:true}).count(),0);
+ await state('silent-failure','40.0%');
+ await browser.close(); browser = null; break;
+ }
+ await state('refresh-failed','Could not refresh history');
+ assert.ok((await description.textContent()).includes('40.0%'));
+ assert.ok((await page.getByRole('status', {name:'History refresh status'}).textContent()).includes('last successful result'));
+ mode = 'pending';
+ await page.clock.runFor(10_000);
+ await state('retry-pending','Could not refresh history');
+ for (let i=0; !pending.length && i<100; i++) await new Promise(r=>setTimeout(r,20));
+ assert.ok(pending.length > 0, 'pending refresh observed');
+ for (const route of pending.splice(0)) await route.fulfill({json:{points:[],source:'store'}});
+ await state('recovered-empty','No 1-hour');
+ assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),'');
+ mode = 'failed';
+ await page.clock.runFor(10_000);
+ await state('empty-refresh-failed','Could not refresh history');
+ mode = 'pending';
+ await page.getByRole('button',{name:'Select pool b'}).click();
+ await state('target-loading','Loading');
+ assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),'');
+ for (let i=0; !pending.length && i<100; i++) await new Promise(r=>setTimeout(r,20));
+ assert.ok(pending.length > 0, 'pending refresh observed');
+ for (const route of pending.splice(0)) await route.fulfill({status:503,json:{error:'Synthetic unavailable'}});
+ await state('initial-failed','could not be loaded');
+ assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),'');
+ assert.deepEqual(errors,[]);
+ await browser.close(); browser=null;
+ }
+ fs.writeFileSync(path.join(artifacts,parent?'parent.json':'result.json'),JSON.stringify({playwright:require('playwright/package.json').version,observations},null,2));
+ console.log(JSON.stringify({result:'passed',states:observations.length,parent}));
+ } finally { if(browser) await browser.close(); await server.close(); }
+})().catch(e=>{console.error(e);process.exitCode=1});
diff --git a/frontend-modern/browser-tests/history-status.html b/frontend-modern/browser-tests/history-status.html
new file mode 100644
index 000000000..ce78449b9
--- /dev/null
+++ b/frontend-modern/browser-tests/history-status.html
@@ -0,0 +1 @@
+History request states verification
diff --git a/frontend-modern/browser-tests/history-status.tsx b/frontend-modern/browser-tests/history-status.tsx
new file mode 100644
index 000000000..19099f793
--- /dev/null
+++ b/frontend-modern/browser-tests/history-status.tsx
@@ -0,0 +1,30 @@
+import { createSignal } from 'solid-js';
+import { render } from 'solid-js/web';
+import { HistoryChart } from '../src/components/shared/HistoryChart';
+import '../src/index.css';
+const Fixture = () => {
+ const [target, setTarget] = createSignal('a');
+ return (
+
+ Storage History request states
+ Synthetic transport; production History chart. Selected pool: {target()}
+
+
+
+ );
+};
+render(() => , document.getElementById('root')!);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 3e6d3f261..00953bb50 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,16 +1,23 @@
{
"version": 1,
- "base_sha": "abf6bcec5b6b94d181264dae62a9983c48619ac6",
- "verified_at": "2026-10-02T16:04:03.580037Z",
+ "base_sha": "f5e73b2ce1289c3f71be438c77021d7a696b00c5",
+ "verified_at": "2026-10-02T16:40:31.760601Z",
"result": "passed",
"changed_paths": [
+ "frontend-modern/src/components/shared/HistoryChart.tsx",
+ "frontend-modern/src/components/shared/HistoryChartOverlay.tsx",
+ "frontend-modern/src/components/shared/historyChartModel.ts",
"frontend-modern/src/components/shared/useHistoryChartState.ts"
],
"content_sha256": {
- "frontend-modern/src/components/shared/useHistoryChartState.ts": "f10b5cfc7cb296c60ad7e87bf7c3001c4308d29af702b8c7a7e77e7c4c4c3fb6"
+ "frontend-modern/src/components/shared/HistoryChart.tsx": "a2ab0198a705e66ce9bea222066078709c8334fc342415916b8c179987b139e2",
+ "frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "4192731461a19ba7c33185c2e616c5023dfef58d93e3893cd3c4fceb58598391",
+ "frontend-modern/src/components/shared/historyChartModel.ts": "bd9e8954ef7e274a1eb026752d304556df303f612c4485369f8c90a7183cc751",
+ "frontend-modern/src/components/shared/useHistoryChartState.ts": "8bebaa331f1e2dfc57587f517628bed1b0c4f7d9c9466e21597e7f9f7a4d7137"
},
"routes": [
- "/browser-tests/history-selection.html"
+ "/browser-tests/history-keyboard.html",
+ "/browser-tests/history-status.html"
],
"viewports": [
{
@@ -23,29 +30,24 @@
}
],
"states": [
- "Synthetic delayed responses in the production shared History chart, not native PBS acceptance",
- "B remains at 80% after late A=10%; C loading and failure clear B readings and accessible description",
- "Desktop Chromium light and phone WebKit dark; no page errors or horizontal overflow"
+ "Empty successful result, loaded readings, failed refresh, pending recovery, recovered empty result, failed refresh of empty result, changed-target loading and initial failure",
+ "Chromium desktop light and WebKit phone dark; screenshots inspected, no horizontal overflow or page errors; synthetic transport, not native collection",
+ "Keyboard focus, Left/Right, Home/End, Escape, bounds, focus exit and empty data; grouped tooltip geometry and polite active-chart-only announcements"
],
"interactions": [
- "Select B and complete it before A; complete stale A",
- "Select C and fail its request; inspect visible and accessible states"
+ "Advance polling clock without altering production timers; fail and recover HTTP responses; switch pool target",
+ "Parent control reproduces misleading empty promise and silent failed refresh; final warning retains data and clears only on recovery or selection replacement"
],
- "command": "pulse-worker-browser frontend-modern/browser-tests/history-selection.cjs",
+ "command": "pulse-worker-browser frontend-modern/browser-tests/history-keyboard.cjs && pulse-worker-browser frontend-modern/browser-tests/history-status.cjs",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"webkit": "26.0"
},
"artifacts": [
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/chromium-b-loaded.png",
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/chromium-c-failed.png",
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/chromium-c-loading.png",
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/chromium-old-a-finished.png",
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/result.json",
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/webkit-b-loaded.png",
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/webkit-c-failed.png",
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/webkit-c-loading.png",
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/webkit-old-a-finished.png"
+ "/var/lib/pulse-maintainer/worker-outputs/release-line-3t375h3z/keyboard65-parent.json",
+ "/var/lib/pulse-maintainer/worker-outputs/release-line-3t375h3z/keyboard65-result.json",
+ "/var/lib/pulse-maintainer/worker-outputs/release-line-3t375h3z/status65-parent.json",
+ "/var/lib/pulse-maintainer/worker-outputs/release-line-3t375h3z/status65-result.json"
]
}
diff --git a/frontend-modern/src/components/shared/HistoryChart.tsx b/frontend-modern/src/components/shared/HistoryChart.tsx
index 6448b34d2..ac36f7f4e 100644
--- a/frontend-modern/src/components/shared/HistoryChart.tsx
+++ b/frontend-modern/src/components/shared/HistoryChart.tsx
@@ -1,5 +1,6 @@
import { Component, createMemo, createUniqueId } from 'solid-js';
import {
+ formatHistoryChartTooltipValue,
getHistoryChartAccessibleDescription,
getHistoryChartAccessibleLabel,
type HistoryChartProps,
@@ -27,6 +28,9 @@ export const HistoryChart: Component = (props) => {
},
hoverGroup,
);
+ const refreshStatus = createMemo(() =>
+ chart.refreshFailed() ? 'Could not refresh history. Showing the last successful result.' : '',
+ );
const accessibleDescription = createMemo(() =>
getHistoryChartAccessibleDescription({
data: chart.data(),
@@ -50,13 +54,25 @@ export const HistoryChart: Component = (props) => {
unit={props.unit}
/>
+
+ {refreshStatus()}
+
+
- Collecting data... History will appear here.
+ No history samples in this time range.
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index 1325a4260..95c948524 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -5283,7 +5283,8 @@ describe('shared primitive guardrails', () => {
expect(historyChartHeaderSource).not.toContain('ChartsAPI.getMetricsHistory');
expect(historyChartHeaderSource).not.toContain('setupCanvasDPR');
- expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.');
+ expect(historyChartOverlaySource).toContain('No history samples in this time range.');
+ expect(historyChartOverlaySource).not.toContain('History will appear here.');
expect(historyChartOverlaySource).toContain(
'Historical data beyond {props.chart.lockDays()} days requires a higher license plan.',
);
diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
index d90be587f..7bfe9b99c 100644
--- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
+++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
@@ -104,7 +104,8 @@ describe('HistoryChart', () => {
expect(historyChartHeaderSource).not.toContain('ChartsAPI.getMetricsHistory');
expect(historyChartHeaderSource).not.toContain('setupCanvasDPR');
- expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.');
+ expect(historyChartOverlaySource).toContain('No history samples in this time range.');
+ expect(historyChartOverlaySource).not.toContain('History will appear here.');
expect(historyChartOverlaySource).toContain(
'Historical data beyond {props.chart.lockDays()} days requires a higher license plan.',
);
@@ -130,6 +131,56 @@ describe('HistoryChart', () => {
expect(historyChartTooltipSource).not.toContain('ChartsAPI.getMetricsHistory');
});
+ it('inspects actual readings with the keyboard and clears on escape, blur and selection changes', () => {
+ const [target, setTarget] = createSignal('a');
+ const [points, setPoints] = createSignal([
+ { timestamp: 1_000, value: 10, min: 10, max: 10 },
+ { timestamp: 2_000, value: 20, min: 20, max: 20 },
+ { timestamp: 3_000, value: 30, min: 30, max: 30 },
+ ]);
+ const { container } = render(() => (
+
+ ));
+ const canvas = screen.getByRole('img', { name: 'History chart' });
+ const announcement = container.querySelector('[aria-live="polite"]')!;
+ expect(canvas).toHaveAttribute('tabindex', '0');
+ expect(announcement.textContent).toBe('');
+ fireEvent.focus(canvas);
+ expect(announcement).toHaveTextContent('30.0%');
+ fireEvent.keyDown(canvas, { key: 'ArrowLeft' });
+ expect(announcement).toHaveTextContent('20.0%');
+ fireEvent.keyDown(canvas, { key: 'Home' });
+ fireEvent.keyDown(canvas, { key: 'ArrowLeft' });
+ expect(announcement).toHaveTextContent('10.0%');
+ fireEvent.keyDown(canvas, { key: 'ArrowRight', ctrlKey: true });
+ expect(announcement).toHaveTextContent('10.0%');
+ fireEvent.keyDown(canvas, { key: 'End' });
+ fireEvent.keyDown(canvas, { key: 'ArrowRight' });
+ expect(announcement).toHaveTextContent('30.0%');
+ fireEvent.keyDown(canvas, { key: 'Escape' });
+ expect(announcement.textContent).toBe('');
+ expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
+ fireEvent.keyDown(canvas, { key: 'Home' });
+ setPoints(points().map((point) => ({ ...point, value: point.value + 1 })));
+ expect(announcement).toHaveTextContent('11.0%');
+ setTarget('b');
+ expect(announcement.textContent).toBe('');
+ fireEvent.keyDown(canvas, { key: 'Home' });
+ fireEvent.blur(canvas);
+ expect(announcement.textContent).toBe('');
+ setPoints([]);
+ fireEvent.focus(canvas);
+ fireEvent.keyDown(canvas, { key: 'End' });
+ expect(announcement.textContent).toBe('');
+ });
+
it('renders the default history label', () => {
render(() => );
@@ -272,7 +323,8 @@ describe('HistoryChart', () => {
expect(layout.x).toBe(162);
expect(layout.x).toBeGreaterThan(150);
- expect(layout.y).toBe(47);
+ expect(layout.height).toBe(64);
+ expect(layout.y + layout.height / 2).toBe(70);
});
it('moves the tooltip to the left edge side near the right chart boundary', () => {
diff --git a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts
index a5f6bda04..76f4c1c49 100644
--- a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts
+++ b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts
@@ -496,7 +496,7 @@ describe('getHistoryChartTooltipLayout', () => {
chartHeight: 180,
});
- expect(layout).toStrictEqual({ x: 162, y: 47, width: 156, height: 46 });
+ expect(layout).toStrictEqual({ x: 162, y: 38, width: 156, height: 64 });
});
it('places the tooltip to the left when only the left side has room', () => {
@@ -537,7 +537,7 @@ describe('getHistoryChartTooltipLayout', () => {
chartHeight: 180,
});
- expect(layout).toStrictEqual({ x: 12, y: 12, width: 156, height: 46 });
+ expect(layout).toStrictEqual({ x: 12, y: 82, width: 156, height: 64 });
});
it('pushes an overlapping tooltip above the hovered point when there is headroom above', () => {
@@ -558,6 +558,6 @@ describe('getHistoryChartTooltipLayout', () => {
chartHeight: 180,
});
- expect(layout.y).toBe(126);
+ expect(layout.y + layout.height).toBe(172);
});
});
diff --git a/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx b/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx
index 0942f115e..872920316 100644
--- a/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx
+++ b/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx
@@ -117,25 +117,56 @@ describe('History request ownership', () => {
},
);
- it('does not overlap polling and preserves matching samples on refresh failure', async () => {
- const initial = deferred();
- request.mockReturnValueOnce(initial.promise);
- const { state } = mount();
- vi.advanceTimersByTime(120_000);
- expect(request).toHaveBeenCalledTimes(1);
- initial.resolve({ points: points(10), source: 'store' } as never);
- await settle();
- const refresh = deferred();
- request.mockReturnValueOnce(refresh.promise);
- vi.advanceTimersByTime(120_000);
- expect(request).toHaveBeenCalledTimes(2);
- vi.spyOn(console, 'error').mockImplementation(() => {});
- refresh.reject(new Error('refresh failed'));
- await settle();
- expect(state.data()).toEqual(points(10));
- expect(state.error()).toBeNull();
- expect(state.loading()).toBe(false);
- });
+ it.each([{ samples: points(10) }, { samples: [] }])(
+ 'retains the last result %j and exposes refresh failure until recovery',
+ async ({ samples }) => {
+ const initial = deferred();
+ request.mockReturnValueOnce(initial.promise);
+ const { state } = mount();
+ vi.advanceTimersByTime(120_000);
+ expect(request).toHaveBeenCalledTimes(1);
+ initial.resolve({ points: samples, source: 'store' } as never);
+ await settle();
+ const refresh = deferred();
+ request.mockReturnValueOnce(refresh.promise);
+ vi.advanceTimersByTime(120_000);
+ expect(request).toHaveBeenCalledTimes(2);
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ refresh.reject(new Error('refresh failed'));
+ await settle();
+ expect(state.data()).toEqual(samples);
+ expect(state.error()).toBeNull();
+ expect(state.refreshFailed()).toBe(true);
+ expect(state.source()).toBe('store');
+ expect(state.loading()).toBe(false);
+ const recovery = deferred();
+ request.mockReturnValueOnce(recovery.promise);
+ vi.advanceTimersByTime(10_000);
+ expect(state.refreshFailed()).toBe(true);
+ recovery.resolve({ points: points(30), source: 'memory' } as never);
+ await settle();
+ expect(state.refreshFailed()).toBe(false);
+ expect(state.data()).toEqual(points(30));
+ expect(state.source()).toBe('memory');
+ },
+ );
+
+ it.each([{ resourceId: 'b' }, { range: '6h' }, { data: [] }, { range: '90d' }])(
+ 'clears refresh failure on selection replacement %j',
+ async (next) => {
+ request.mockResolvedValueOnce({ points: points(10), source: 'store' } as never);
+ const { state, change } = mount();
+ await settle();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ request.mockRejectedValueOnce(new Error('refresh failed'));
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(state.refreshFailed()).toBe(true);
+ request.mockReturnValueOnce(deferred().promise);
+ change(next as Partial);
+ expect(state.refreshFailed()).toBe(false);
+ expect(state.data()).toEqual([]);
+ },
+ );
it('cancels fetched data when supplied data takes ownership, including empty samples', async () => {
const old = deferred();
diff --git a/frontend-modern/src/components/shared/historyChartModel.ts b/frontend-modern/src/components/shared/historyChartModel.ts
index 4f8a6d251..1e9cab000 100644
--- a/frontend-modern/src/components/shared/historyChartModel.ts
+++ b/frontend-modern/src/components/shared/historyChartModel.ts
@@ -279,7 +279,8 @@ export function getHistoryChartTooltipLayout({
chartHeight: number;
}): HistoryChartTooltipLayout {
const width = 156;
- const height = 46;
+ // Allow a wrapped local timestamp plus the reading at normal text size.
+ const height = 64;
const margin = 8;
const pointGap = 12;
const minX = margin;
diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts
index 51328aaa4..a63b01d35 100644
--- a/frontend-modern/src/components/shared/useHistoryChartState.ts
+++ b/frontend-modern/src/components/shared/useHistoryChartState.ts
@@ -34,7 +34,9 @@ export function useHistoryChartState(
) {
const [range, setRange] = createSignal(props.range || '24h');
const [data, setData] = createSignal(props.data ?? []);
+ const [keyboardInspecting, setKeyboardInspecting] = createSignal(false);
const [loading, setLoading] = createSignal(false);
+ const [refreshFailed, setRefreshFailed] = createSignal(false);
const [error, setError] = createSignal(null);
const [source, setSource] = createSignal<'store' | 'memory' | 'live' | 'mock_synthetic' | null>(
null,
@@ -118,6 +120,7 @@ export function useHistoryChartState(
setData(suppliedData ?? []);
setSource(suppliedData !== undefined ? 'live' : null);
setError(null);
+ setRefreshFailed(false);
setLoading(false);
const selection = JSON.stringify([
resourceType,
@@ -153,12 +156,13 @@ export function useHistoryChartState(
if (!active) return;
setData('points' in result ? (result.points ?? []) : []);
setSource(result.source ?? 'store');
+ setRefreshFailed(false);
hasLoaded = true;
} catch (err) {
if (!active) return;
console.error('Failed to fetch metrics history:', err);
- if (!hasLoaded) setError('Failed to load history data');
- setSource(null);
+ if (hasLoaded) setRefreshFailed(true);
+ else setError('Failed to load history data');
} finally {
if (active) {
pending = false;
@@ -351,11 +355,47 @@ export function useHistoryChartState(
onCleanup(() => resizeObserver.disconnect());
});
+ const handleFocus = () => {
+ setKeyboardInspecting(true);
+ const points = data();
+ setHoveredTimestamp(points.length ? points[points.length - 1].timestamp : null);
+ };
+
+ const handleBlur = () => {
+ setKeyboardInspecting(false);
+ setHoveredTimestamp(null);
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
+ if (event.key === 'Escape') {
+ setHoveredTimestamp(null);
+ return;
+ }
+ const points = data();
+ if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
+ event.preventDefault();
+ setKeyboardInspecting(true);
+ const timestamp = hoveredTimestamp();
+ const index =
+ timestamp === null
+ ? points.length - 1
+ : points.indexOf(findHistoryChartClosestPoint(points, timestamp));
+ const next =
+ event.key === 'Home'
+ ? 0
+ : event.key === 'End'
+ ? points.length - 1
+ : Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
+ setHoveredTimestamp(points[next].timestamp);
+ };
+
const handleMouseMove = (event: MouseEvent) => {
const canvas = refs.getCanvas();
const points = data();
if (!canvas || points.length === 0) return;
+ setKeyboardInspecting(false);
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const width = rect.width;
@@ -370,6 +410,7 @@ export function useHistoryChartState(
};
const handleMouseLeave = () => {
+ if (keyboardInspecting()) return;
setHoveredTimestamp(null);
};
@@ -378,6 +419,11 @@ export function useHistoryChartState(
dataMax,
dataMin,
error,
+ refreshFailed,
+ handleFocus,
+ handleBlur,
+ handleKeyDown,
+ keyboardInspecting,
handleMouseLeave,
handleMouseMove,
chartHeight,
diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
index 35e2debb9..9dfa48545 100644
--- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
+++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
@@ -2452,7 +2452,8 @@ describe('frontend resource type boundaries', () => {
expect(historyChartModelSource).toContain('HISTORY_CHART_RANGES');
expect(historyChartModelSource).toContain('getHistoryChartScale');
expect(historyChartHeaderSource).toContain('formatHistoryChartTooltipValue');
- expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.');
+ expect(historyChartOverlaySource).toContain('No history samples in this time range.');
+ expect(historyChartOverlaySource).not.toContain('History will appear here.');
expect(historyChartOverlaySource).not.toContain('presentationPolicyHidesUpgradePrompts');
expect(historyChartOverlaySource).not.toContain(
'Unlock {props.chart.lockTierLabel()} Features',
From 5a79299001fc1627c13410618f663856f7b851f9 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Fri, 2 Oct 2026 19:11:16 +0100
Subject: [PATCH 2/2] Backport disk temperature and bind composed v6.5 browser
proof
Adapt reviewed 442717f4099e80c5386130e542eb91bd691e6a24 on the preserved reviewed History ancestry. Fresh Chromium/WebKit proof covers the full five-file integration delta from supplied ea3b7a896527eddcadc07aa57dd5f80f4886a947: ten temperature, fourteen keyboard and sixteen request-state observations. Record the actual composed parent and final blobs; no re-parenting of old evidence, guard change or frozen v6.4.6 recut.
Contract-Neutral: Existing disk-detail presentation bug fix and fresh browser proof for unchanged reviewed History content; no shared frontend primitive, API, ownership, or entitlement boundary changes.
Change-source: pulse-maintainer
---
.../internal/subsystems/storage-recovery.md | 16 +++++
.../browser-tests/disk-temperature.cjs | 70 +++++++++++++++++++
.../browser-tests/disk-temperature.html | 1 +
.../browser-tests/disk-temperature.tsx | 57 +++++++++++++++
frontend-modern/browser-verification.json | 42 +++++------
.../Storage/__tests__/DiskDetail.test.tsx | 33 ++++++++-
...DetailPresentation.branchcov0724pm.test.ts | 35 ++++++++++
.../storageBackups/diskDetailPresentation.ts | 6 +-
8 files changed, 234 insertions(+), 26 deletions(-)
create mode 100644 frontend-modern/browser-tests/disk-temperature.cjs
create mode 100644 frontend-modern/browser-tests/disk-temperature.html
create mode 100644 frontend-modern/browser-tests/disk-temperature.tsx
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 31b7b40f3..dbd5a7108 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -21,6 +21,22 @@
## Purpose
+### Independent physical disk temperature
+
+Disk Overview displays a finite positive reported temperature even when the
+optional extended SMART object is missing or empty. Temperature formatting and
+configured per-disk-type thresholds remain unchanged; absent counters are not
+invented. When no detail readings or collection messages remain, the existing
+accessible unavailable-details message is shown. This presentation does not
+establish fresh collection, SMART health or native appliance acceptance.
+
+Verification: `DiskDetail.test.tsx` covers same-identity snapshot replacement
+through temperature-only, hot, missing and extended-SMART states;
+`diskDetailPresentation.branchcov0724pm.test.ts` covers disk types, invalid
+readings, units and configured thresholds. The production detail fixture in
+`frontend-modern/browser-tests/disk-temperature.cjs` checks desktop and phone
+presentation with synthetic snapshots, including the predecessor's omission.
+
### Pool detail capacity observations
The existing pool drawer's Configuration rows preserve missing capacity as
diff --git a/frontend-modern/browser-tests/disk-temperature.cjs b/frontend-modern/browser-tests/disk-temperature.cjs
new file mode 100644
index 000000000..860705782
--- /dev/null
+++ b/frontend-modern/browser-tests/disk-temperature.cjs
@@ -0,0 +1,70 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium, webkit } = require('playwright');
+(async () => {
+ const root = '/workspace/frontend-modern';
+ process.chdir(root);
+ const artifacts = path.join(root, 'node_modules/disk-temperature-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: 5225, strictPort: true } });
+ let browser;
+ const observations = [];
+ const parent = process.argv.includes('--parent');
+ try {
+ await server.listen();
+ for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) {
+ browser = await engine.launch(name === 'chromium' ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
+ const page = await browser.newPage({ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit' });
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ await page.route('**/*', route => {
+ const url = new URL(route.request().url());
+ if (url.origin !== 'http://127.0.0.1:5225') return route.abort();
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ return route.fulfill({json: url.pathname === '/api/license/runtime-capabilities' ? {capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: {build:'community'}, blocked_capabilities: []} : {data: [], enabled: false}});
+ });
+ await page.goto('http://127.0.0.1:5225/browser-tests/disk-temperature.html');
+ if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
+ const fallback = page.getByText('Detailed SMART attributes are not available for this disk.', {exact:true});
+ const value = text => page.getByText(text, {exact:true});
+ async function state(phase, expected) {
+ await value(expected).waitFor();
+ const dimensions = await page.evaluate(() => ({scroll:document.documentElement.scrollWidth, width:innerWidth}));
+ assert.ok(dimensions.scroll <= dimensions.width + 1);
+ await page.screenshot({path:path.join(artifacts, `${parent?'parent-':''}${name}-${phase}.png`),fullPage:true});
+ observations.push({name, version:browser.version(),phase,expected,dimensions});
+ }
+ if (parent) {
+ await state('temperature-hidden', 'Detailed SMART attributes are not available for this disk.');
+ assert.equal(await value('42°C').count(),0);
+ } else {
+ await state('temperature-only','42°C');
+ assert.equal(await fallback.count(),0);
+ assert.equal(await value('Power-On Time').count(),0);
+ assert.match(await value('42°C').getAttribute('class'), /text-green-600/);
+ await page.getByRole('button',{name:'Hot reading'}).click();
+ await state('hot','65°C');
+ assert.match(await value('65°C').getAttribute('class'), /text-red-600/);
+ await page.getByRole('button',{name:'Fahrenheit',exact:true}).click();
+ await state('fahrenheit','149°F');
+ assert.match(await value('149°F').getAttribute('class'), /text-red-600/);
+ await page.getByRole('button',{name:'No reading',exact:true}).click();
+ await state('missing','Detailed SMART attributes are not available for this disk.');
+ assert.equal(await value('Temperature').count(),0);
+ assert.equal(await fallback.getAttribute('role'),'status');
+ await page.getByRole('button',{name:'Extended SMART',exact:true}).click();
+ await state('extended','42°C');
+ await value('4 days').waitFor();
+ await value('Reallocated Sectors').waitFor();
+ assert.equal(await value('0').count(),1);
+ assert.equal(await value('Temperature').count(),1);
+ }
+ assert.deepEqual(errors,[]);
+ await browser.close(); browser=null;
+ }
+ fs.writeFileSync(path.join(artifacts,parent?'parent.json':'result.json'),JSON.stringify({playwright:require('playwright/package.json').version,observations},null,2));
+ console.log(JSON.stringify({result:'passed',states:observations.length,parent}));
+ } finally { if(browser) await browser.close(); await server.close(); }
+})().catch(e=>{console.error(e);process.exitCode=1});
diff --git a/frontend-modern/browser-tests/disk-temperature.html b/frontend-modern/browser-tests/disk-temperature.html
new file mode 100644
index 000000000..18e9c9e5b
--- /dev/null
+++ b/frontend-modern/browser-tests/disk-temperature.html
@@ -0,0 +1 @@
+Disk temperature verification
diff --git a/frontend-modern/browser-tests/disk-temperature.tsx b/frontend-modern/browser-tests/disk-temperature.tsx
new file mode 100644
index 000000000..986f7c8ba
--- /dev/null
+++ b/frontend-modern/browser-tests/disk-temperature.tsx
@@ -0,0 +1,57 @@
+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 { temperatureStore } from '../src/utils/temperature';
+import '../src/index.css';
+
+const Fixture = () => {
+ const initial = {
+ id: 'disk-fixture',
+ type: 'physical_disk',
+ name: 'Archive HDD',
+ displayName: 'Archive HDD',
+ platformType: 'proxmox-pve',
+ platformId: 'fixture',
+ sourceType: 'api',
+ status: 'online',
+ lastSeen: Date.now(),
+ identity: { hostname: 'storage-host' },
+ physicalDisk: { devPath: '/dev/sda', model: 'Archive HDD', diskType: 'hdd', temperature: 42 },
+ } as Resource;
+ const [disk, setDisk] = createSignal(initial);
+ const reading = (
+ temperature: number,
+ smart?: { powerOnHours: number; reallocatedSectors: number },
+ ) => {
+ temperatureStore.setUnit('celsius');
+ setDisk({ ...initial, physicalDisk: { ...initial.physicalDisk!, temperature, smart } });
+ };
+ return (
+
+ Physical disk Overview
+ Synthetic disk snapshots; production detail component.
+
+
+
+
+
+
+
+
+ );
+};
+render(() => , document.getElementById('root')!);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 00953bb50..888d0a420 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,21 +1,24 @@
{
"version": 1,
- "base_sha": "f5e73b2ce1289c3f71be438c77021d7a696b00c5",
- "verified_at": "2026-10-02T16:40:31.760601Z",
+ "base_sha": "a3f3b7564e6576831d87a5ea99cd08d77dd00549",
+ "verified_at": "2026-10-02T18:10:48Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/HistoryChart.tsx",
"frontend-modern/src/components/shared/HistoryChartOverlay.tsx",
"frontend-modern/src/components/shared/historyChartModel.ts",
- "frontend-modern/src/components/shared/useHistoryChartState.ts"
+ "frontend-modern/src/components/shared/useHistoryChartState.ts",
+ "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts"
],
"content_sha256": {
"frontend-modern/src/components/shared/HistoryChart.tsx": "a2ab0198a705e66ce9bea222066078709c8334fc342415916b8c179987b139e2",
"frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "4192731461a19ba7c33185c2e616c5023dfef58d93e3893cd3c4fceb58598391",
"frontend-modern/src/components/shared/historyChartModel.ts": "bd9e8954ef7e274a1eb026752d304556df303f612c4485369f8c90a7183cc751",
- "frontend-modern/src/components/shared/useHistoryChartState.ts": "8bebaa331f1e2dfc57587f517628bed1b0c4f7d9c9466e21597e7f9f7a4d7137"
+ "frontend-modern/src/components/shared/useHistoryChartState.ts": "8bebaa331f1e2dfc57587f517628bed1b0c4f7d9c9466e21597e7f9f7a4d7137",
+ "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "035614af7ddee52c5ee56099b4f447b0137afd9dccf07be9981d44a486eb19a4"
},
"routes": [
+ "/browser-tests/disk-temperature.html",
"/browser-tests/history-keyboard.html",
"/browser-tests/history-status.html"
],
@@ -30,24 +33,21 @@
}
],
"states": [
- "Empty successful result, loaded readings, failed refresh, pending recovery, recovered empty result, failed refresh of empty result, changed-target loading and initial failure",
- "Chromium desktop light and WebKit phone dark; screenshots inspected, no horizontal overflow or page errors; synthetic transport, not native collection",
- "Keyboard focus, Left/Right, Home/End, Escape, bounds, focus exit and empty data; grouped tooltip geometry and polite active-chart-only announcements"
+ "Temperature-only, hot, Fahrenheit, missing and extended-SMART production detail snapshots",
+ "Keyboard chart focus, previous/first/last/bounds/Escape, shared wrapped hover tooltip, empty state",
+ "Empty success, populated History, failed refresh, pending retry, recovered empty, failed empty refresh, selected-target loading and initial failure"
],
"interactions": [
- "Advance polling clock without altering production timers; fail and recover HTTP responses; switch pool target",
- "Parent control reproduces misleading empty promise and silent failed refresh; final warning retains data and clears only on recovery or selection replacement"
+ "On Chromium and WebKit, switch disk snapshots and temperature units; check severity, absent counters, accessible fallback and retained counters",
+ "Tab between charts and following control; inspect observations with arrows, Home, End and Escape; verify active-chart-only polite announcements and visible focus",
+ "Drive synthetic History HTTP success/503/pending/recovery and pool change; retain prior successful source/values with a refresh warning until replacement",
+ "Inspect both full keyboard screenshots and all temperature/request-state screenshots through retained contact sheets; no horizontal overflow or uncaught page errors observed"
],
- "command": "pulse-worker-browser frontend-modern/browser-tests/history-keyboard.cjs && pulse-worker-browser frontend-modern/browser-tests/history-status.cjs",
- "browser_versions": {
- "playwright": "1.56.1",
- "chromium": "141.0.7390.37",
- "webkit": "26.0"
- },
- "artifacts": [
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3t375h3z/keyboard65-parent.json",
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3t375h3z/keyboard65-result.json",
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3t375h3z/status65-parent.json",
- "/var/lib/pulse-maintainer/worker-outputs/release-line-3t375h3z/status65-result.json"
- ]
+ "playwright": "1.56.1",
+ "browser_versions": [
+ "chromium 141.0.7390.37",
+ "webkit 26.0"
+ ],
+ "integration_frontier": "ea3b7a896527eddcadc07aa57dd5f80f4886a947",
+ "proof_scope": "Fresh owning-line browser proof on the composed candidate; all five runtime files in the complete assigned-base integration delta. The receipt is bound to this non-merge commit's own parent, not re-parented historical proof."
}
diff --git a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx
index f9654ac36..701ada624 100644
--- a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx
+++ b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@solidjs/testing-library';
-import type { JSX } from 'solid-js';
+import { createSignal, type JSX } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
import { DiskDetail } from '@/components/Storage/DiskDetail';
import type { Resource } from '@/types/resource';
@@ -107,9 +107,38 @@ describe('DiskDetail', () => {
expect(screen.queryByText(/:diskwrite:/)).not.toBeInTheDocument();
});
- it('shows an explicit overview fallback when SMART details are unavailable', () => {
+ it('keeps standalone temperature readings visible through snapshot replacement', () => {
+ const initial = buildDisk();
+ delete initial.physicalDisk!.smart;
+ const [disk, setDisk] = createSignal(initial);
+ render(() => );
+
+ expect(screen.getByText('42°C')).toBeInTheDocument();
+ expect(screen.queryByText('Power-On Time')).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('Detailed SMART attributes are not available for this disk.'),
+ ).not.toBeInTheDocument();
+
+ setDisk({ ...initial, physicalDisk: { ...initial.physicalDisk!, temperature: 65 } });
+ expect(screen.getByText('65°C')).toHaveClass('text-red-600');
+ expect(screen.queryByText('42°C')).not.toBeInTheDocument();
+
+ setDisk({ ...initial, physicalDisk: { ...initial.physicalDisk!, temperature: 0 } });
+ expect(screen.queryByText('Temperature')).not.toBeInTheDocument();
+ expect(
+ screen.getByText('Detailed SMART attributes are not available for this disk.'),
+ ).toHaveAttribute('role', 'status');
+
+ setDisk(buildDisk());
+ expect(screen.getByText('42°C')).toBeInTheDocument();
+ expect(screen.getByText('Power-On Time')).toBeInTheDocument();
+ expect(screen.getByText('Reallocated Sectors')).toBeInTheDocument();
+ });
+
+ it('shows an explicit overview fallback when no detail readings are available', () => {
const disk = buildDisk();
delete disk.physicalDisk!.smart;
+ delete disk.physicalDisk!.temperature;
render(() => );
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 46b10a037..3b5af9753 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+import { temperatureStore } from '@/utils/temperature';
import type { PhysicalDiskPresentationData } from '@/features/storageBackups/diskPresentation';
import {
getDiskDetailAttributeCards,
@@ -53,6 +54,40 @@ describe('diskDetailPresentation.branchcov0724pm', () => {
});
});
+ describe('independently reported temperature', () => {
+ it.each(['hdd', 'ssd', 'nvme'])('shows %s temperature without extended SMART data', (type) => {
+ expect(getDiskDetailAttributeCards(makeDiskData({ type, temperature: 42 }))).toEqual([
+ { label: 'Temperature', value: '42°C', ok: true },
+ ]);
+ });
+
+ it.each([0, -1, NaN, Infinity, -Infinity])('does not invent a card for %s', (temperature) => {
+ expect(getDiskDetailAttributeCards(makeDiskData({ temperature }))).toEqual([]);
+ });
+
+ it('keeps configured thresholds and display units independent of attribute presence', () => {
+ const disk = makeDiskData({ type: 'nvme', temperature: 65 });
+ expect(getDiskDetailAttributeCards(disk, { warning: 70, critical: 80 })).toEqual([
+ { label: 'Temperature', value: '65°C', ok: true },
+ ]);
+ temperatureStore.setUnit('fahrenheit');
+ try {
+ expect(getDiskDetailAttributeCards(disk, { warning: 50, critical: 60 })).toEqual([
+ { label: 'Temperature', value: '149°F', ok: false },
+ ]);
+ } finally {
+ temperatureStore.setUnit('celsius');
+ }
+ });
+
+ it('treats empty and missing SMART data alike without inventing counters', () => {
+ const disk = makeDiskData({ temperature: 42 });
+ expect(getDiskDetailAttributeCards({ ...disk, smartAttributes: {} })).toEqual(
+ getDiskDetailAttributeCards(disk),
+ );
+ });
+ });
+
describe('getDiskDetailAttributeCards SATA offlineUncorrectable arm (L129)', () => {
it('emits an Offline Uncorrectable card marked ok when the count is zero', () => {
const cards = getDiskDetailAttributeCards(
diff --git a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts
index 7e8ddcf01..87af184ce 100644
--- a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts
+++ b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts
@@ -82,8 +82,8 @@ export function getDiskDetailAttributeCards(
disk: PhysicalDiskPresentationData,
diskTempThresholds?: MetricDisplayThresholds | null,
): DiskDetailAttributeCard[] {
- const attrs = disk.smartAttributes;
- if (!attrs) return [];
+ // Temperature is reported independently of optional extended SMART attributes.
+ const attrs = disk.smartAttributes ?? {};
const cards: DiskDetailAttributeCard[] = [];
const isNvme = disk.type?.toLowerCase() === 'nvme';
@@ -96,7 +96,7 @@ export function getDiskDetailAttributeCards(
});
}
- if (disk.temperature > 0) {
+ if (Number.isFinite(disk.temperature) && disk.temperature > 0) {
cards.push({
label: 'Temperature',
value: formatTemperature(disk.temperature),