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