From 442717f4099e80c5386130e542eb91bd691e6a24 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:36:04 +0100
Subject: [PATCH] Show disk temperature without extended SMART attributes
Treat optional SMART counters independently from a reported temperature. Preserve units, configured thresholds and the unavailable-details fallback. Cover live snapshot transitions and bind desktop/mobile browser proof.
Contract-Neutral: Existing disk-detail presentation bug fix; 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 | 39 +++++------
.../Storage/__tests__/DiskDetail.test.tsx | 33 ++++++++-
...DetailPresentation.branchcov0724pm.test.ts | 35 ++++++++++
.../storageBackups/diskDetailPresentation.ts | 6 +-
8 files changed, 232 insertions(+), 25 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 bd8ef3ced..4806fe41a 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 bd379e2eb..3daf270df 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -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"
]
}
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),