Make v6.5 History accessible and keep disk temperatures visible (#2404)
Some checks are pending
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Core E2E Tests / Validate E2E tier selection (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Blocked by required conditions
Core E2E Tests / Offline Organization provisioning (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Blocked by required conditions
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions

## What changed and why
Backport the reviewed History usability and request-state repairs and
the disk-temperature display repair to v6.5:
- Inspect History readings with Left/Right and Home/End, clear
inspection with Escape, and hear the active reading. Wrapped tooltip
text fits its geometry.
- Empty History states no longer promise future collection. A failed
background refresh warns while retaining the last successful readings
and their source; recovery or a new selection clears the warning.
- Disk details retain an independently reported temperature when
optional extended SMART attributes are absent, without inventing
counters or changing units, configured thresholds, or the
unavailable-details status.

These are repairs to existing surfaces, prompted by inaccessible chart
inspection, silently failed refreshes, and a reproduced hidden 42°C disk
reading. They do not claim that native PBS/TrueNAS collection or every
reported History symptom is fixed.

## Included source
- Reviewed History candidate `20261002T163600Z-release-line`:
`1330fc8105`, composing main History
repairs through `df18123e7d`.
- Reviewed composed candidate `20261002T175724Z-release-line`:
`5a79299001`, adapting disk-temperature
repair `442717f409` and refreshing browser
proof for the complete composed delta.
- Complete unpublished range:
`ea3b7a896527eddcadc07aa57dd5f80f4886a947..5a79299001fc1627c13410618f663856f7b851f9`,
including additive ancestry merge
`a3f3b7564e`. Reviewed commit identities
are preserved; the current published v6.5 frontier is already included.

## Validation and limits
- Recorded reviewed source proof `source-rdr86c0k`: 2,797 connected
tests, typecheck, lint, production/embedded builds and budgets passed,
with complete output and guest shutdown. Separate exact-path boundary
proof passed 11 tests. The earlier complete frontend suite
`source-3e4lem7w` remains evidence for the unchanged History content;
sufficient proof was not repeated solely for batching.
- Chromium and WebKit predecessor controls reproduced hidden temperature
and the History defects. Final composed-line browser checks passed ten
desktop/phone temperature observations, fourteen keyboard observations
and sixteen request-state observations; screenshots were inspected in
the reviewed evidence. The receipt binds all five final runtime files to
the composed candidate's actual parent.
- Publication provenance, range whitespace and the line's supported
browser-verification guard passed. All five changed runtime blobs
exactly match current public main and the reviewed v6.4 backports.
- Earlier admission/selector errors remain recorded; they are not
passing tests. Exact-head PR checks must pass before landing. Native
panels, installed acceptance and containing release qualification remain
separate.

This is later-checkpoint source for v6.5, not a change to frozen v6.4.6
and not a release publication or promotion decision.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot] 2026-10-02 19:18:10 +00:00 • committed by GitHub
commit f034dc6518
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 708 additions and 64 deletions

View file

@ -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.

View file

@ -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

View file

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

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Disk temperature verification</title></head><body><div id="root"></div><script type="module" src="./disk-temperature.tsx"></script></body></html>

View file

@ -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 (
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1>Physical disk Overview</h1>
<p>Synthetic disk snapshots; production detail component.</p>
<div class="flex flex-wrap gap-2">
<button class="min-h-11 border px-3" onClick={() => reading(65)}>
Hot reading
</button>
<button class="min-h-11 border px-3" onClick={() => temperatureStore.setUnit('fahrenheit')}>
Fahrenheit
</button>
<button class="min-h-11 border px-3" onClick={() => reading(0)}>
No reading
</button>
<button
class="min-h-11 border px-3"
onClick={() => reading(42, { powerOnHours: 100, reallocatedSectors: 0 })}
>
Extended SMART
</button>
</div>
<section class="max-w-3xl">
<DiskDetail disk={disk()} nodes={[]} />
</section>
</main>
);
};
render(() => <Fixture />, document.getElementById('root')!);

View file

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

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>History keyboard verification</title></head><body><div id="root"></div><script type="module" src="./history-keyboard.tsx"></script></body></html>

View file

@ -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 (
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1>Storage History keyboard inspection</h1>
<button onClick={() => setEmpty(!empty())}>Toggle empty</button>
<button onClick={() => setTarget(target() === 'a' ? 'b' : 'a')}>Change target</button>
<HistoryChartHoverGroup>
<HistoryChart
resourceType="disk"
resourceId={target()}
metric="usage"
label="Usage"
unit="%"
hideSelector
range="1h"
data={points()}
/>
<HistoryChart
resourceType="disk"
resourceId={target()}
metric="diskread"
label="Read"
unit="B/s"
hideSelector
range="1h"
data={points()}
/>
</HistoryChartHoverGroup>
<button>After charts</button>
</main>
);
};
render(() => <Fixture />, document.getElementById('root')!);

View file

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

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>History request states verification</title></head><body><div id="root"></div><script type="module" src="./history-status.tsx"></script></body></html>

View file

@ -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 (
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1>Storage History request states</h1>
<p>Synthetic transport; production History chart. Selected pool: {target()}</p>
<button class="min-h-11 border px-3" onClick={() => setTarget('b')}>
Select pool b
</button>
<section class="max-w-xl">
<HistoryChart
resourceType="storage"
resourceId={target()}
metric="usage"
label="Usage"
unit="%"
hideSelector
compact
range="1h"
height={140}
/>
</section>
</main>
);
};
render(() => <Fixture />, document.getElementById('root')!);

View file

@ -1,16 +1,26 @@
{
"version": 1,
"base_sha": "abf6bcec5b6b94d181264dae62a9983c48619ac6",
"verified_at": "2026-10-02T16:04:03.580037Z",
"base_sha": "a3f3b7564e6576831d87a5ea99cd08d77dd00549",
"verified_at": "2026-10-02T18:10:48Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/useHistoryChartState.ts"
"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/features/storageBackups/diskDetailPresentation.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",
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "035614af7ddee52c5ee56099b4f447b0137afd9dccf07be9981d44a486eb19a4"
},
"routes": [
"/browser-tests/history-selection.html"
"/browser-tests/disk-temperature.html",
"/browser-tests/history-keyboard.html",
"/browser-tests/history-status.html"
],
"viewports": [
{
@ -23,29 +33,21 @@
}
],
"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"
"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": [
"Select B and complete it before A; complete stale A",
"Select C and fail its request; inspect visible and accessible states"
"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-selection.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"
]
"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."
}

View file

@ -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(() => <DiskDetail disk={disk()} nodes={[]} />);
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(() => <DiskDetail disk={disk} nodes={[]} />);

View file

@ -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<HistoryChartProps> = (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<HistoryChartProps> = (props) => {
unit={props.unit}
/>
<p
role="status"
aria-label="History refresh status"
class="text-xs text-amber-700 dark:text-amber-300"
>
{refreshStatus()}
</p>
<div
class={`relative flex-1 w-full ${props.compact ? 'min-h-[120px]' : 'min-h-[200px]'}`}
ref={containerRef}
>
<canvas
ref={canvasRef}
class="block w-full h-full cursor-crosshair"
class="block w-full h-full cursor-crosshair rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500"
tabIndex={0}
onFocus={chart.handleFocus}
onBlur={chart.handleBlur}
onKeyDown={chart.handleKeyDown}
role="img"
aria-label={getHistoryChartAccessibleLabel(props.label)}
aria-describedby={descriptionId}
@ -64,7 +80,13 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
onMouseLeave={chart.handleMouseLeave}
/>
<p id={descriptionId} class="sr-only">
{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.
</p>
<p class="sr-only" aria-live="polite" aria-atomic="true">
{chart.keyboardInspecting() && chart.hoveredPoint()
? `${new Date(chart.hoveredPoint()!.timestamp).toLocaleString()}: ${formatHistoryChartTooltipValue(chart.hoveredPoint()!.value, props.unit)}`
: ''}
</p>
<HistoryChartOverlay chart={chart} hideLock={props.hideLock} />
<HistoryChartTooltip

View file

@ -35,7 +35,7 @@ export const HistoryChartOverlay: Component<HistoryChartOverlayProps> = (props)
<path d="M21 21v-5h-5" />
</svg>
</div>
<p class="text-sm text-slate-500">Collecting data... History will appear here.</p>
<p class="text-sm text-slate-500">No history samples in this time range.</p>
</div>
</div>
</Show>

View file

@ -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.',
);

View file

@ -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(() => (
<HistoryChart
resourceType="disk"
resourceId={target()}
metric="usage"
unit="%"
hideSelector
data={points()}
/>
));
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(() => <HistoryChart resourceType="agent" resourceId="node-1" metric="cpu" />);
@ -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', () => {

View file

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

View file

@ -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<HistoryChartProps>);
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();

View file

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

View file

@ -34,7 +34,9 @@ export function useHistoryChartState(
) {
const [range, setRange] = createSignal<HistoryTimeRange>(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<string | null>(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,

View file

@ -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(

View file

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

View file

@ -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',