mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-02 20:29:43 +00:00
Keep disk temperatures visible without extended SMART attributes (#2401)
## What changed and why Disk Overview now displays a valid reported temperature even when extended SMART attributes are missing or empty. Previously an early return hid that independent reading. Units and configured thresholds are preserved; invalid readings do not create cards, and the existing unavailable-details message returns when no details remain. This repairs the existing storage presentation, without changing collection or adding a new surface. ## Included source - Reviewed Web candidate `20261002T163042Z-web-product`: `442717f409`. - Complete unpublished range: `005eb08cb9bce5a8ab8a07379f2a2414c92596b4..68272db93bfba4819438b35171d9b34478bc7a9b` (candidate plus additive integration commits `d69aae6347` and `68272db93bfba4819438b35171d9b34478bc7a9b`). - The proposal tree exactly matches the reviewed candidate and validated source `547c255612`; reconciliation retained all reviewed commit identities. Current upstream main is incorporated unchanged. ## Validation - Recorded source proof `source-17es3j79`: 2,869 tests across 182 files, typecheck, lint, production/embedded build and budgets passed, with complete output and guest shutdown. - Predecessor Chromium/WebKit controls reproduce the hidden temperature. Final browser checks passed ten desktop/phone states covering standalone temperature, critical thresholds, Fahrenheit, missing readings and full SMART data; screenshots were inspected in the reviewed evidence. - Publication provenance and range whitespace checks passed. Exact tree equality makes repeating these checks unnecessary. - Exact-head PR checks must pass before landing. Synthetic presentation proof does not establish native appliance collection, whole PBS/TrueNAS issue resolution, installed acceptance or release availability. This does not alter the frozen v6.4.6 candidate. [How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
commit
ad7f7a1063
8 changed files with 232 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
70
frontend-modern/browser-tests/disk-temperature.cjs
Normal file
70
frontend-modern/browser-tests/disk-temperature.cjs
Normal 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});
|
||||
1
frontend-modern/browser-tests/disk-temperature.html
Normal file
1
frontend-modern/browser-tests/disk-temperature.html
Normal 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>
|
||||
57
frontend-modern/browser-tests/disk-temperature.tsx
Normal file
57
frontend-modern/browser-tests/disk-temperature.tsx
Normal 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')!);
|
||||
|
|
@ -1,20 +1,16 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "c6edeee869a11f9206390e855fc63da744fb394a",
|
||||
"verified_at": "2026-10-02T15:53:54.725571Z",
|
||||
"base_sha": "df18123e7da3b682eed811c0e01c94a44dc2c29c",
|
||||
"verified_at": "2026-10-02T16:36:02.327983Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/shared/HistoryChart.tsx",
|
||||
"frontend-modern/src/components/shared/HistoryChartOverlay.tsx",
|
||||
"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/useHistoryChartState.ts": "8bebaa331f1e2dfc57587f517628bed1b0c4f7d9c9466e21597e7f9f7a4d7137"
|
||||
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "035614af7ddee52c5ee56099b4f447b0137afd9dccf07be9981d44a486eb19a4"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/history-status.html"
|
||||
"/browser-tests/disk-temperature.html"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
|
|
@ -27,25 +23,28 @@
|
|||
}
|
||||
],
|
||||
"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"
|
||||
"Temperature without extended SMART; critical temperature; Fahrenheit; absent temperature; full SMART snapshot",
|
||||
"Chromium desktop light and WebKit phone dark: 10 final states, no page errors or horizontal overflow; synthetic inputs, not native collection"
|
||||
],
|
||||
"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"
|
||||
"Same-identity snapshot replacement and reactive temperature-unit changes",
|
||||
"Parent Chromium and WebKit reproduce hidden 42 C; representative desktop and all five phone final screenshots inspected"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-status.cjs",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/disk-temperature.cjs",
|
||||
"browser_versions": {
|
||||
"playwright": "1.56.1",
|
||||
"chromium": "141.0.7390.37",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/parent.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/chromium-refresh-failed.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/webkit-refresh-failed.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/webkit-empty.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/webkit-initial-failed.png"
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/parent.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/parent-chromium-temperature-hidden.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/chromium-temperature-only.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-temperature-only.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-hot.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-fahrenheit.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-missing.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-extended.png"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={[]} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue