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()} +

+
= (props) => { onMouseLeave={chart.handleMouseLeave} />

- {accessibleDescription()} + {refreshStatus()} {accessibleDescription()} Use Left and Right arrow keys to inspect + readings, Home and End for the first and last reading, and Escape to clear inspection. +

+

+ {chart.keyboardInspecting() && chart.hoveredPoint() + ? `${new Date(chart.hoveredPoint()!.timestamp).toLocaleString()}: ${formatHistoryChartTooltipValue(chart.hoveredPoint()!.value, props.unit)}` + : ''}

= (props)
-

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),