diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index 569d58d8..308d3a9d 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -114,6 +114,14 @@ class CommandDescriptionsTests(unittest.TestCase): for key in ("passedTitle", "passedText"): report["recommendations"].setdefault( key, source_report["recommendations"][key]) + # New thermal messages also await automatic translation. + # Seed only these intentional additions in the temporary copy. + for key in ( + "temperatureNoDisks", "temperatureNormal", "temperatureHighCount", + "temperatureCriticalCount", "temperatureUnavailable", + "temperatureIncomplete", "temperatureUnavailableCount", + ): + temporary["storage"].setdefault(key, catalog("en")["storage"][key]) path.write_text(json.dumps(temporary, ensure_ascii=False)) # Model steady state after the bot fills these intentional new # messages; keep repository locales and all other leaves intact. diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 0437e62e..b24fe712 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -965,37 +965,24 @@ export function StorageOverview() { } const getDiskHealthBreakdown = () => { - if (!storageData || !storageData.disks) { - return { normal: 0, warning: 0, critical: 0 } + if (!storageData || !Array.isArray(storageData.disks)) { + return { normal: 0, warning: 0, critical: 0, unavailable: 0 } } let normal = 0 let warning = 0 let critical = 0 + let unavailable = 0 storageData.disks.forEach((disk) => { - if (disk.temperature === 0) { - // No temperature reading available — count as normal so a - // missing sensor doesn't inflate the warning count. - normal++ + if (disk.standby || typeof disk.temperature !== "number" || + !Number.isFinite(disk.temperature) || disk.temperature <= 0) { + unavailable++ return } - // Reuse the exact threshold lookup that the per-disk badge - // (`getTempColor`) uses, so the green / amber / red colour in - // the disk card and the "X normal, Y warning, Z critical" tally - // at the top of the page always agree. - // - // Previously this breakdown carried its own hardcoded ladder - // (HDD ≤45 normal, ≤55 warning, …) which was far stricter than - // the configurable defaults from `useDiskTempThresholds` - // (HDD warn 60, hot 65). A disk at 48 °C therefore showed a - // green badge but was counted as "warning" in the summary — - // exactly the case the user reported. Driving both from the - // same source (`dtThresholds`) means the user-tunable values - // under Settings → Health Monitor Thresholds → Disk temperature - // now apply to the breakdown as well, and the displayed colour - // is always the source of truth. + // Only usable readings enter the existing configurable threshold lookup. + // Keep the resolver and defaults shared with the per-disk temperature badge. const diskType = getDiskType(disk.name, disk.rotation_rate) const cls: keyof DiskTempMap = diskType === "NVMe" ? "NVMe" : diskType === "SSD" ? "SSD" : "HDD" @@ -1005,11 +992,11 @@ export function StorageOverview() { else normal++ }) - return { normal, warning, critical } + return { normal, warning, critical, unavailable } } const getDiskTypesBreakdown = () => { - if (!storageData || !storageData.disks) { + if (!storageData || !Array.isArray(storageData.disks)) { return { nvme: 0, ssd: 0, hdd: 0, usb: 0 } } @@ -1134,7 +1121,7 @@ export function StorageOverview() { ) } - if (!storageData || storageData.error) { + if (!storageData || storageData.error || !Array.isArray(storageData.disks)) { return (
@@ -1312,12 +1299,16 @@ export function StorageOverview() { {(() => { const total = Math.max(1, storageData.disk_count || 0) const seg = 100 / total - const allHealthy = diskHealthBreakdown.warning === 0 && diskHealthBreakdown.critical === 0 - const healthBadge = allHealthy - ? ✓ {t("storage.allHealthy")} - : diskHealthBreakdown.critical > 0 - ? {t("storage.criticalCount", { count: diskHealthBreakdown.critical })} - : {t("storage.warningCount", { count: diskHealthBreakdown.warning })} + const allTemperaturesNormal = storageData.disks.length > 0 && diskHealthBreakdown.unavailable === 0 && diskHealthBreakdown.warning === 0 && diskHealthBreakdown.critical === 0 + const healthBadge = storageData.disks.length === 0 + ? {t("storage.temperatureNoDisks")} + : allTemperaturesNormal + ? {t("storage.temperatureNormal")} + : diskHealthBreakdown.critical > 0 + ? {t("storage.temperatureCriticalCount", { count: diskHealthBreakdown.critical })} + : diskHealthBreakdown.warning > 0 + ? {t("storage.temperatureHighCount", { count: diskHealthBreakdown.warning })} + : {t(diskHealthBreakdown.normal > 0 ? "storage.temperatureIncomplete" : "storage.temperatureUnavailable")} const seg_purple = '#a855f7' const seg_cyan = '#06b6d4' const seg_blue = '#3b82f6' @@ -1339,7 +1330,15 @@ export function StorageOverview() { {storageData.disk_count} {diskCountLabel(storageData.disk_count)}
+
+
{healthBadge} + {diskHealthBreakdown.critical > 0 && diskHealthBreakdown.warning > 0 && ( +

{t("storage.temperatureHighCount", { count: diskHealthBreakdown.warning })}

+ )} + {diskHealthBreakdown.unavailable > 0 && ( +

{t("storage.temperatureUnavailableCount", { count: diskHealthBreakdown.unavailable })}

+ )}
{segments.map((s, i) => ( diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index da1cc036..ef44deee 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -185,6 +185,13 @@ "noRemoteStorage": "No remote storage", "diskSingular": "disk", "diskPlural": "disks", + "temperatureNoDisks": "No disks", + "temperatureNormal": "Temperatures normal", + "temperatureHighCount": "High temperatures: {count}", + "temperatureCriticalCount": "Critical temperatures: {count}", + "temperatureUnavailable": "Temperatures unavailable", + "temperatureIncomplete": "Temperature data incomplete", + "temperatureUnavailableCount": "Unavailable: {count}", "allHealthy": "all healthy", "criticalCount": "{count} critical", "warningCount": "{count} warning", diff --git a/AppImage/package.json b/AppImage/package.json index 2a65396d..9baeaaf3 100644 --- a/AppImage/package.json +++ b/AppImage/package.json @@ -4,6 +4,7 @@ "description": "Proxmox System Monitoring Dashboard", "private": true, "scripts": { + "test:storage-temperature": "node tests/storage-temperature-summary.cjs", "dev": "next dev", "build": "next build", "start": "next start", diff --git a/AppImage/tests/storage-temperature-summary.cjs b/AppImage/tests/storage-temperature-summary.cjs new file mode 100644 index 00000000..28a9df01 --- /dev/null +++ b/AppImage/tests/storage-temperature-summary.cjs @@ -0,0 +1,109 @@ +// Run from AppImage: node tests/storage-temperature-summary.cjs +// Execute the actual summary seam without mounting effects or contacting a host. +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const assert = require('node:assert/strict'); +const ts = require('typescript'); +const root = path.resolve(__dirname, '..'); +const read = p => fs.readFileSync(path.join(root, p), 'utf8'); +const source = read('components/storage-overview.tsx'); +const seam = source.slice(source.indexOf(' const getDiskHealthBreakdown ='), source.indexOf(' const getDiskTypesBreakdown =')); +const thresholds = read('lib/health-thresholds.ts'); +const defaults = thresholds.slice(thresholds.indexOf('export const DEFAULT_DISK_TEMP'), thresholds.indexOf('const CACHE_TTL_MS')).replaceAll('export ', ''); +const program = read('lib/disk-type.ts').replaceAll('export ', '') + '\n' + defaults + '\nlet storageData; let dtThresholds = DEFAULT_DISK_TEMP;\n' + seam + '\n globalThis.run = (disks, custom) => { storageData = {disks}; dtThresholds = custom || DEFAULT_DISK_TEMP; return getDiskHealthBreakdown(); };'; +const context = vm.createContext({}); +vm.runInContext(ts.transpileModule(program, {compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText, context); +function check(label, disks, expected, custom) { + assert.deepEqual(JSON.parse(JSON.stringify(context.run(disks, custom))), expected, label); +} +check('missing reading is unavailable, not normal', [{name:'sda', temperature:0}], {normal:0, warning:0, critical:0, unavailable:1}); +console.log('PASS missing reading is unavailable'); +for (const temperature of [undefined, null, -1, NaN, Infinity, -Infinity, '30']) + check(`unavailable ${temperature}`, [{name:'sda', temperature}], {normal:0,warning:0,critical:0,unavailable:1}); +for (const temperature of [30, 90]) + check('standby suppresses stale reading', [{name:'sda',temperature,standby:true}], {normal:0,warning:0,critical:0,unavailable:1}); +check('empty', [], {normal:0,warning:0,critical:0,unavailable:0}); +check('mixed coverage and alerts', [{name:'sda',rotation_rate:7200,temperature:30},{name:'sdb',rotation_rate:7200,temperature:60},{name:'sdc',rotation_rate:7200,temperature:65},{name:'sdd',temperature:0}], {normal:1,warning:1,critical:1,unavailable:1}); +for (const [name, rotation_rate, values] of [['sda',7200,[59.9,60,64.9,65]],['sdb',0,[69.9,70,74.9,75]],['nvme0n1',0,[79.9,80,84.9,85]]]) { + for (const [index, temperature] of values.entries()) { + const expected = [{normal:1,warning:0,critical:0,unavailable:0},{normal:0,warning:1,critical:0,unavailable:0},{normal:0,warning:1,critical:0,unavailable:0},{normal:0,warning:0,critical:1,unavailable:0}][index]; + check(`${name} exact default ${temperature}`, [{name,rotation_rate,temperature}], expected); + check(`${name} exact custom ${temperature - 20}`, [{name,rotation_rate,temperature:temperature-20}], expected, {HDD:{warn:40,hot:45},SSD:{warn:50,hot:55},NVMe:{warn:60,hot:65}}); + } +} +console.log('PASS availability, mixed counts, default/custom boundaries'); + +// Render the actual Physical Disks JSX with the real Card/Badge and provider lookup. +const React = require('react'); +const {renderToStaticMarkup} = require('react-dom/server'); +function load(file) { + if (!file.endsWith('.tsx') && !file.endsWith('.ts')) file += fs.existsSync(file+'.tsx') ? '.tsx' : '.ts'; + const module = {exports:{}}; + const code = ts.transpileModule(fs.readFileSync(file,'utf8'), {compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.React}}).outputText; + const localRequire = id => id.startsWith('@/') ? load(path.join(root,id.slice(2))) : require(id); + vm.runInNewContext(code, {module,exports:module.exports,require:localRequire,React}); + return module.exports; +} +const provider = read('lib/i18n/provider.tsx'); +const pure = provider.slice(provider.indexOf('function getMessage('),provider.indexOf('export function I18nProvider')); +const translator = provider.slice(provider.indexOf(' (key: string, params?: TranslationParams) => {'),provider.indexOf('\n [language],')).trim().replace(/,$/,''); +const en = JSON.parse(read('messages/en/common.json')); +function translation(locale) { + const ctx = vm.createContext({MESSAGE_CATALOG:{en,test:locale},language:'test'}); + vm.runInContext(ts.transpileModule(pure+'\nglobalThis.t = '+translator,{}).outputText,ctx); + return ctx.t; +} +const start = source.indexOf(' {(() => {',source.indexOf('/* ── Physical Disks')); +const end = source.indexOf(' })()}',start); +assert.ok(start >= 0 && end > start, 'actual JSX seam exists'); +const jsx = source.slice(start,end+' })()}'.length).trim().slice(1,-1); +function render(disks, locale = {}) { + const t = translation(locale); + const ctx = {React,...load(path.join(root,'components/ui/card')),...load(path.join(root,'components/ui/badge')),HardDrive:()=>null, + storageData:{disks,disk_count:disks.length}, diskHealthBreakdown:context.run(disks),diskTypesBreakdown:{nvme:0,ssd:0,hdd:disks.length,usb:0},diskCountLabel:()=> 'disks',t}; + return renderToStaticMarkup(vm.runInNewContext(ts.transpileModule('const result = '+jsx+'; result;', {compilerOptions:{jsx:ts.JsxEmit.React}}).outputText,ctx)); +} +const empty = render([]); +assert.ok(empty.includes('No disks'), 'empty array must show neutral No disks'); +assert.ok(!empty.includes('text-green-500'), 'empty is not green'); +console.log('PASS empty JSX is neutral'); +const disk = temperature => ({name:'sda',rotation_rate:7200,temperature}); +const fixtures = [ + [[], ['No disks'], 'neutral'], + [[disk(30)], ['Temperatures normal'], 'green'], + [[disk(30),disk(35)], ['Temperatures normal'], 'green'], + [[disk(0)], ['Temperatures unavailable','Unavailable: 1'], 'neutral'], + [[disk(0),{...disk(90),standby:true}], ['Temperatures unavailable','Unavailable: 2'], 'neutral'], + [[disk(30),disk(0)], ['Temperature data incomplete','Unavailable: 1'], 'neutral'], + [[disk(60),disk(0)], ['High temperatures: 1','Unavailable: 1'], 'yellow'], + [[disk(65),disk(60),disk(60),disk(0)], ['Critical temperatures: 1','High temperatures: 2','Unavailable: 1'], 'red'], +]; +for (const [disks,messages,tone] of fixtures) { + const html = render(disks); + for(const message of messages) assert.ok(html.includes(message), `actual JSX: ${message}`); + assert.equal(html.includes('text-green-500'),tone==='green'); + if(tone !== 'neutral') assert.ok(html.includes(`text-${tone}-500`)); + assert.ok(!html.includes('all healthy')); +} +const synthetic = {storage:{temperatureNoDisks:'SYN empty',temperatureNormal:'SYN normal',temperatureUnavailable:'SYN unavailable',temperatureIncomplete:'SYN incomplete',temperatureHighCount:'SYN high {count}',temperatureCriticalCount:'SYN critical {count}',temperatureUnavailableCount:'SYN missing {count}'}}; +for (const [disks] of fixtures) { + const html = render(disks,synthetic); + assert.ok(html.includes('SYN'), 'consumer uses localized whole messages'); + assert.ok(!html.includes('Temperatures') && !html.includes('High temperatures') && !html.includes('Critical temperatures') && !html.includes('Unavailable:')); + assert.ok(!html.includes('{count}')); +} +const mixedTranslated = render([disk(65),disk(60),disk(60),disk(0)],synthetic); +for (const text of ['SYN critical 1','SYN high 2','SYN missing 1']) assert.ok(mixedTranslated.includes(text)); +for (const key of Object.keys(synthetic.storage)) { + assert.equal(translation({})(`storage.${key}`,{count:12}),en.storage[key].replace('{count}','12'), 'unconditional missing-new-key English fallback'); +} +console.log('PASS complete thermal JSX, mixed counts, translated messages and missing-key fallback'); +for (const disks of [undefined,null,{}]) { + check('invalid list is safe before error rendering',disks,{normal:0,warning:0,critical:0,unavailable:0}); +} +const errorGuard = source.match(/ if \((!storageData \|\| storageData\.error[^\n]*)\) \{/)[1]; +for (const storageData of [null,{},{disks:{}},{disks:null},{disks:[],error:'fixture failure'}]) + assert.equal(Boolean(vm.runInNewContext(errorGuard,{storageData})),true,'invalid/failed payload renders error'); +assert.equal(Boolean(vm.runInNewContext(errorGuard,{storageData:{disks:[]}})),false,'valid empty is not error'); +console.log('PASS invalid payload remains error, not empty');