diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 7fe92a80..fd2bebfe 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -32,6 +32,7 @@ NODE_FILES = ( "tests/test_backup_destination_messages.cjs", "tests/test_borg_ssh_guidance.cjs", "tests/test_storage_messages.cjs", + "tests/test_health_text.cjs", ) # unittest's CLI accepts a missing pattern as a successful zero-test run. diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index c8fb6431..cccdd326 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -91,10 +91,10 @@ class CommandDescriptionsTests(unittest.TestCase): path.parent.mkdir() shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path) # This steady-state fixture assumes generation already finished. - # Seed only the keys the two message PRs introduce; keep the + # Seed only intentional message additions; keep the # recovered command arrays and the repo catalogs exact. Runtime # missing-key fallback is covered by the JSX seam tests and by - # test_storage_messages.cjs. + # test_storage_messages.cjs and test_health_text.cjs. if lang != "en": temporary = json.loads(path.read_text()) for section, key in ( @@ -114,6 +114,51 @@ class CommandDescriptionsTests(unittest.TestCase): for key in ("passedTitle", "passedText"): report["recommendations"].setdefault( key, source_report["recommendations"][key]) + # New health-popup messages deliberately use runtime English + # fallback until each locale is translated. Model generator + # steady state only in this disposable copy, as for thermal keys. + for key in ( + 'certificateExpired', + 'certificateExpiresDays', + 'certificateInconclusive', + 'cpuCheckFailed', + 'cpuHighSustained', + 'cpuSustained', + 'fail2banCheckUnavailable', + 'fail2banInactive', + 'failedLoginsWithinThreshold', + 'kernelPackagesMore', + 'kernelPveUpdates', + 'loginCheckUnavailable', + 'memoryCheckFailed', + 'memoryPressure', + 'noFailedLogins', + 'noSwapConfigured', + 'noTemperatureSensor', + 'nonRunningKernelUpdates', + 'pendingPackageUpdates', + 'pendingSecurityUpdates', + 'pendingSecurityUpdatesDays', + 'pendingSecurityUpdatesUnpatched', + 'pveVersionAvailable', + 'ramHighSustained', + 'ramSustained', + 'runningKernelUpdate', + 'securityCheckUnavailable', + 'sensorHighSamplesSpan', + 'swapRamPressure', + 'systemNotUpdatedMonths', + 'systemNotUpdatedYear', + 'temperatureElevated', + 'unknownDetail', + 'updatesAptError', + 'updatesAptTimeout', + 'updatesCheckUnavailable', + 'uptimeKernelAdvice', + 'uptimeUnavailable', + ): + temporary["healthStatus"]["details"].setdefault( + key, catalog("en")["healthStatus"]["details"][key]) # New thermal messages also await automatic translation. # Seed only these intentional additions in the temporary copy. for key in ( diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml index a199f439..df57a839 100644 --- a/.github/workflows/test-offline-qualified.yml +++ b/.github/workflows/test-offline-qualified.yml @@ -76,7 +76,7 @@ jobs: run: | npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules - - name: Run ten qualified Node files + - name: Run eleven qualified Node files env: NODE_PATH: ${{ github.workspace }}/AppImage/node_modules PYTHONDONTWRITEBYTECODE: '1' diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index 09a835ee..c8be541e 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -299,6 +299,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const translateHealthText = (value?: string): string => { if (!value) return "" const exact: Record = { + "No temperature sensor detected - install lm-sensors if hardware supports it": t("healthStatus.details.noTemperatureSensor"), + "Sustained high CPU usage": t("healthStatus.details.cpuHighSustained"), + "Temperature elevated": t("healthStatus.details.temperatureElevated"), + "No swap configured": t("healthStatus.details.noSwapConfigured"), + "High RAM usage sustained": t("healthStatus.details.ramHighSustained"), + "Swap nearly full with RAM tight": t("healthStatus.details.swapRamPressure"), + "Failed to check for updates (apt-get error)": t("healthStatus.details.updatesAptError"), + "apt-get timed out - repository may be unreachable": t("healthStatus.details.updatesAptTimeout"), + "Unknown": t("healthStatus.details.unknownDetail"), + "No failed login attempts in 24h": t("healthStatus.details.noFailedLogins"), + "Fail2Ban installed but service not active": t("healthStatus.details.fail2banInactive"), + "Unable to determine uptime": t("healthStatus.details.uptimeUnavailable"), + "Unable to check login attempts": t("healthStatus.details.loginCheckUnavailable"), + "Certificate expired": t("healthStatus.details.certificateExpired"), + "Certificate check inconclusive": t("healthStatus.details.certificateInconclusive"), "All systems operational": t("healthStatus.details.allOperational"), "Normal": t("healthStatus.details.normal"), "No I/O errors in dmesg": t("healthStatus.details.noIoErrors"), @@ -321,7 +336,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu "No VM startup failures": t("healthStatus.details.noVmFailures"), "Dismissed by user": t("healthStatus.details.dismissedByUser"), } - if (exact[value]) return exact[value] + if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value] let match = value.match(/^Latency ([\d.]+)ms to gateway$/) if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] }) @@ -361,6 +376,52 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu if (match) return t("healthStatus.details.pveStorageSafe", { count: match[1] }) match = value.match(/^(\d+) remote mount\(s\) healthy$/) if (match) return t("healthStatus.details.remoteMountsHealthy", { count: match[1] }) + match = value.match(/^CPU >(\d+(?:\.\d+)?)% sustained for (\d+)s$/) + if (match) return t("healthStatus.details.cpuSustained", { threshold: match[1], seconds: match[2] }) + match = value.match(/^CPU check failed: ([\s\S]*)$/) + if (match) return t("healthStatus.details.cpuCheckFailed", { error: match[1] }) + match = value.match(/^Sensor temperature (\d+(?:\.\d+)?)°C >80°C; high samples span ((?:\d+m )?\d+s)$/) + if (match) return t("healthStatus.details.sensorHighSamplesSpan", { temperature: match[1], duration: match[2] }) + match = value.match(/^RAM >(\d+(?:\.\d+)?)% sustained for (\d+)s$/) + if (match) return t("healthStatus.details.ramSustained", { threshold: match[1], seconds: match[2] }) + match = value.match(/^Memory pressure: swap (\d+)% used and only (\d+)% RAM available$/) + if (match) return t("healthStatus.details.memoryPressure", { swapPercent: match[1], ramAvailablePercent: match[2] }) + match = value.match(/^Memory check failed: ([\s\S]*)$/) + if (match) return t("healthStatus.details.memoryCheckFailed", { error: match[1] }) + match = value.match(/^(\d+) security update\(s\) pending$/) + if (match) return t("healthStatus.details.pendingSecurityUpdates", { count: match[1] }) + match = value.match(/^(\d+) security update\(s\) pending \((\d+) days unpatched\)$/) + if (match) return t("healthStatus.details.pendingSecurityUpdatesUnpatched", { count: match[1], days: match[2] }) + match = value.match(/^(\d+) security update\(s\) pending for (\d+) days$/) + if (match) return t("healthStatus.details.pendingSecurityUpdatesDays", { count: match[1], days: match[2] }) + match = value.match(/^System not updated in (\d+) days \(>18 months\)$/) + if (match) return t("healthStatus.details.systemNotUpdatedMonths", { days: match[1] }) + match = value.match(/^System not updated in (\d+) days \(>1 year\)$/) + if (match) return t("healthStatus.details.systemNotUpdatedYear", { days: match[1] }) + match = value.match(/^Kernel update available for running ([^\s()]+)$/) + if (match) return t("healthStatus.details.runningKernelUpdate", { kernel: match[1] }) + match = value.match(/^(\d+) kernel \+ (\d+) Proxmox update\(s\) available$/) + if (match) return t("healthStatus.details.kernelPveUpdates", { kernelCount: match[1], pveCount: match[2] }) + match = value.match(/^(\d+) package update\(s\) pending$/) + if (match) return t("healthStatus.details.pendingPackageUpdates", { count: match[1] }) + match = value.match(/^([a-z0-9][a-z0-9+.-]+(?::[a-z0-9]+)?(?: [^\s()]+ -> [^\s()]+)?) \(\+(\d+) more\)$/) + if (match) return t("healthStatus.details.kernelPackagesMore", { technicalIdentity: match[1], count: match[2] }) + match = value.match(/^(\d+) kernel update\(s\) available \(none for running kernel( [^\s()]+)?\)$/) + if (match) return t("healthStatus.details.nonRunningKernelUpdates", { count: match[1], optionalKernel: match[2] || "" }) + match = value.match(/^PVE ([^\s()]+) -> ([^\s()]+) available$/) + if (match) return t("healthStatus.details.pveVersionAvailable", { current: match[1], new: match[2] }) + match = value.match(/^Updates check unavailable: ([\s\S]*)$/) + if (match) return t("healthStatus.details.updatesCheckUnavailable", { error: match[1] }) + match = value.match(/^Unable to check Fail2Ban: ([\s\S]*)$/) + if (match) return t("healthStatus.details.fail2banCheckUnavailable", { error: match[1] }) + match = value.match(/^Uptime (\d+) days \(>1 year, consider updating kernel\/system\)$/) + if (match) return t("healthStatus.details.uptimeKernelAdvice", { days: match[1] }) + match = value.match(/^(\d+) failed attempts in 24h \(within threshold\)$/) + if (match) return t("healthStatus.details.failedLoginsWithinThreshold", { count: match[1] }) + match = value.match(/^Security check unavailable: ([\s\S]*)$/) + if (match) return t("healthStatus.details.securityCheckUnavailable", { error: match[1] }) + match = value.match(/^Certificate expires in (\d+) days$/) + if (match) return t("healthStatus.details.certificateExpiresDays", { days: match[1] }) return value } diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index c2b0c293..99d3ab15 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -4684,6 +4684,44 @@ "proxmox_storages": "Proxmox storage" }, "details": { + "noTemperatureSensor": "No temperature sensor detected - install lm-sensors if hardware supports it", + "cpuHighSustained": "Sustained high CPU usage", + "temperatureElevated": "Temperature elevated", + "cpuSustained": "CPU >{threshold}% sustained for {seconds}s", + "cpuCheckFailed": "CPU check failed: {error}", + "sensorHighSamplesSpan": "Sensor temperature {temperature}°C >80°C; high samples span {duration}", + "noSwapConfigured": "No swap configured", + "ramHighSustained": "High RAM usage sustained", + "swapRamPressure": "Swap nearly full with RAM tight", + "ramSustained": "RAM >{threshold}% sustained for {seconds}s", + "memoryPressure": "Memory pressure: swap {swapPercent}% used and only {ramAvailablePercent}% RAM available", + "memoryCheckFailed": "Memory check failed: {error}", + "pendingSecurityUpdates": "Security updates pending: {count}", + "pendingSecurityUpdatesUnpatched": "Security updates pending: {count} (days unpatched: {days})", + "pendingSecurityUpdatesDays": "Security updates pending: {count} (days waiting: {days})", + "systemNotUpdatedMonths": "System not updated in {days} days (>18 months)", + "systemNotUpdatedYear": "System not updated in {days} days (>1 year)", + "runningKernelUpdate": "Kernel update available for running {kernel}", + "kernelPveUpdates": "Kernel updates available: {kernelCount}; Proxmox updates available: {pveCount}", + "pendingPackageUpdates": "Package updates pending: {count}", + "updatesAptError": "Failed to check for updates (apt-get error)", + "updatesAptTimeout": "apt-get timed out - repository may be unreachable", + "kernelPackagesMore": "{technicalIdentity} (+{count} more)", + "nonRunningKernelUpdates": "Kernel updates available: {count} (none for the running kernel{optionalKernel})", + "unknownDetail": "Unknown", + "pveVersionAvailable": "PVE {current} -> {new} available", + "updatesCheckUnavailable": "Updates check unavailable: {error}", + "noFailedLogins": "No failed login attempts in 24h", + "fail2banInactive": "Fail2Ban installed but service not active", + "fail2banCheckUnavailable": "Unable to check Fail2Ban: {error}", + "uptimeKernelAdvice": "Uptime: {days} days (>1 year; consider updating kernel/system)", + "uptimeUnavailable": "Unable to determine uptime", + "failedLoginsWithinThreshold": "Failed login attempts in 24h: {count} (within threshold)", + "loginCheckUnavailable": "Unable to check login attempts", + "securityCheckUnavailable": "Security check unavailable: {error}", + "certificateExpired": "Certificate expired", + "certificateExpiresDays": "Certificate expiry (days): {days}", + "certificateInconclusive": "Certificate check inconclusive", "allOperational": "All systems operational", "normal": "Normal", "noIoErrors": "No I/O errors in dmesg", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 0d6a2bf0..33bafe5a 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -4685,6 +4685,44 @@ "proxmox_storages": "Storage Proxmox" }, "details": { + "noTemperatureSensor": "Nessun sensore di temperatura rilevato. Installa lm-sensors se l’hardware lo supporta.", + "cpuHighSustained": "Utilizzo CPU elevato e prolungato", + "temperatureElevated": "Temperatura elevata", + "cpuSustained": "CPU >{threshold}% per {seconds} s", + "cpuCheckFailed": "Controllo CPU non riuscito: {error}", + "sensorHighSamplesSpan": "Temperatura sensore {temperature}°C >80°C; campioni elevati nell’arco di {duration}", + "noSwapConfigured": "Swap non configurato", + "ramHighSustained": "Utilizzo RAM elevato a lungo", + "swapRamPressure": "Swap quasi pieno e poca RAM disponibile", + "ramSustained": "RAM >{threshold}% per {seconds} s", + "memoryPressure": "Pressione memoria: swap usato al {swapPercent}% e solo {ramAvailablePercent}% di RAM disponibile", + "memoryCheckFailed": "Controllo memoria non riuscito: {error}", + "pendingSecurityUpdates": "Aggiornamenti di sicurezza in sospeso: {count}", + "pendingSecurityUpdatesUnpatched": "Aggiornamenti di sicurezza in sospeso: {count} (giorni senza applicazione: {days})", + "pendingSecurityUpdatesDays": "Aggiornamenti di sicurezza in sospeso: {count} (giorni di attesa: {days})", + "systemNotUpdatedMonths": "Sistema non aggiornato da {days} giorni (>18 mesi)", + "systemNotUpdatedYear": "Sistema non aggiornato da {days} giorni (>1 anno)", + "runningKernelUpdate": "Aggiornamento disponibile per il kernel in uso: {kernel}", + "kernelPveUpdates": "Aggiornamenti disponibili: {kernelCount} kernel + {pveCount} Proxmox", + "pendingPackageUpdates": "Aggiornamenti di pacchetti in sospeso: {count}", + "updatesAptError": "Controllo aggiornamenti non riuscito (errore apt-get)", + "updatesAptTimeout": "Timeout di apt-get: il repository potrebbe non essere raggiungibile", + "kernelPackagesMore": "{technicalIdentity} (+{count} altri)", + "nonRunningKernelUpdates": "Aggiornamenti kernel disponibili: {count} (nessuno per il kernel in uso{optionalKernel})", + "unknownDetail": "Sconosciuto", + "pveVersionAvailable": "PVE {current} -> {new} disponibile", + "updatesCheckUnavailable": "Controllo aggiornamenti non disponibile: {error}", + "noFailedLogins": "Nessun tentativo di accesso fallito nelle ultime 24 ore", + "fail2banInactive": "Fail2Ban installato; servizio inattivo", + "fail2banCheckUnavailable": "Impossibile controllare Fail2Ban: {error}", + "uptimeKernelAdvice": "Tempo di attività: {days} giorni (>1 anno; valuta l’aggiornamento di kernel/sistema)", + "uptimeUnavailable": "Impossibile determinare il tempo di attività", + "failedLoginsWithinThreshold": "Tentativi di accesso falliti nelle ultime 24 ore: {count} (entro la soglia)", + "loginCheckUnavailable": "Impossibile controllare i tentativi di accesso", + "securityCheckUnavailable": "Controllo sicurezza non disponibile: {error}", + "certificateExpired": "Certificato scaduto", + "certificateExpiresDays": "Scadenza certificato (giorni): {days}", + "certificateInconclusive": "Controllo certificato non conclusivo", "allOperational": "Tutti i sistemi sono operativi", "normal": "Normale", "noIoErrors": "Nessun errore I/O in dmesg", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a404974a..f5b7587c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -863,7 +863,7 @@ publication workflow and has read-only repository permissions. #### Qualified offline `tests/` CI lanes `.github/workflows/test-offline-qualified.yml` runs **only** the explicitly listed -eight Python files (including the inert active-notification payload fixture) and ten +eight Python files (including the inert active-notification payload fixture) and eleven Node files in `.github/scripts/run_offline_qualified.py`. Run the same allowlist locally from the root of a clean, disposable checkout with no `AppImage/node_modules`. Do not run this over a normal Monitor install: diff --git a/tests/test_health_text.cjs b/tests/test_health_text.cjs new file mode 100644 index 00000000..2ffee6ff --- /dev/null +++ b/tests/test_health_text.cjs @@ -0,0 +1,265 @@ +// Portable offline execution of the actual health mapper and provider lookup. +// Only checked-out source enters Function; catalogs and opaque values are arguments. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const root = path.resolve(__dirname, '..'); +const read = p => fs.readFileSync(path.join(root, p), 'utf8'); +const parse = p => ts.createSourceFile(p, read(p), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const printer = ts.createPrinter(); +const emit = (node, source) => printer.printNode(ts.EmitHint.Unspecified, node, source); +const compile = code => ts.transpileModule(code, {compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, +}}).outputText; +function walk(node, fn) { fn(node); ts.forEachChild(node, n => walk(n, fn)); } +const provider = parse('AppImage/lib/i18n/provider.tsx'); +const helpers = provider.statements.filter(n => ts.isFunctionDeclaration(n) && ['getMessage', 'interpolate'].includes(n.name?.text)); +assert.equal(helpers.length, 2); +let lookup; +walk(provider, n => { + if (ts.isVariableDeclaration(n) && n.name.getText(provider) === 't' && + n.initializer && ts.isCallExpression(n.initializer) && n.initializer.expression.getText(provider) === 'useCallback') lookup = n.initializer.arguments[0]; +}); +assert.ok(lookup, 'actual provider callback'); +const catalogs = Object.fromEntries(fs.readdirSync(path.join(root, 'AppImage/messages')).filter(locale => + fs.existsSync(path.join(root, 'AppImage/messages', locale, 'common.json'))).map(locale => + [locale, JSON.parse(read(`AppImage/messages/${locale}/common.json`))])); +const translatorFactory = new Function('MESSAGE_CATALOG', 'language', + compile(helpers.map(n => emit(n, provider)).join('\n') + '\nconst t = ' + emit(lookup, provider)) + '\nreturn t;'); +const translator = (language, messages = catalogs) => translatorFactory(messages, language); +const source = parse('AppImage/components/health-status-modal.tsx'); +let mapper; +walk(source, n => { + if (ts.isVariableDeclaration(n) && n.name.getText(source) === 'translateHealthText') mapper = n.initializer; +}); +assert.ok(mapper, 'actual modal mapper'); +const mapperFactory = new Function('t', compile('const translateHealthText = ' + emit(mapper, source)) + '\nreturn translateHealthText;'); +const makeMapper = t => mapperFactory(t); +const select = makeMapper((key, params) => JSON.stringify([key, params || {}])); +const render = (input, locale = 'it', messages = catalogs) => makeMapper(translator(locale, messages))(input); +const format = (template, params) => template.replace(/\{(\w+)\}/g, (token, name) => params[name] === undefined ? token : String(params[name])); +// Frozen EN/IT additions: independent expected values, not candidate-derived. +const expected = { + "noTemperatureSensor": ["No temperature sensor detected - install lm-sensors if hardware supports it", "Nessun sensore di temperatura rilevato. Installa lm-sensors se l’hardware lo supporta."], + "noSwapConfigured": ["No swap configured", "Swap non configurato"], + "noFailedLogins": ["No failed login attempts in 24h", "Nessun tentativo di accesso fallito nelle ultime 24 ore"], + "pendingSecurityUpdates": ["Security updates pending: {count}", "Aggiornamenti di sicurezza in sospeso: {count}"], + "pendingSecurityUpdatesUnpatched": ["Security updates pending: {count} (days unpatched: {days})", "Aggiornamenti di sicurezza in sospeso: {count} (giorni senza applicazione: {days})"], + "pendingSecurityUpdatesDays": ["Security updates pending: {count} (days waiting: {days})", "Aggiornamenti di sicurezza in sospeso: {count} (giorni di attesa: {days})"], + "cpuHighSustained": ["Sustained high CPU usage", "Utilizzo CPU elevato e prolungato"], + "temperatureElevated": ["Temperature elevated", "Temperatura elevata"], + "cpuSustained": ["CPU >{threshold}% sustained for {seconds}s", "CPU >{threshold}% per {seconds} s"], + "cpuCheckFailed": ["CPU check failed: {error}", "Controllo CPU non riuscito: {error}"], + "sensorHighSamplesSpan": ["Sensor temperature {temperature}°C >80°C; high samples span {duration}", "Temperatura sensore {temperature}°C >80°C; campioni elevati nell’arco di {duration}"], + "ramHighSustained": ["High RAM usage sustained", "Utilizzo RAM elevato a lungo"], + "swapRamPressure": ["Swap nearly full with RAM tight", "Swap quasi pieno e poca RAM disponibile"], + "ramSustained": ["RAM >{threshold}% sustained for {seconds}s", "RAM >{threshold}% per {seconds} s"], + "memoryPressure": ["Memory pressure: swap {swapPercent}% used and only {ramAvailablePercent}% RAM available", "Pressione memoria: swap usato al {swapPercent}% e solo {ramAvailablePercent}% di RAM disponibile"], + "memoryCheckFailed": ["Memory check failed: {error}", "Controllo memoria non riuscito: {error}"], + "systemNotUpdatedMonths": ["System not updated in {days} days (>18 months)", "Sistema non aggiornato da {days} giorni (>18 mesi)"], + "systemNotUpdatedYear": ["System not updated in {days} days (>1 year)", "Sistema non aggiornato da {days} giorni (>1 anno)"], + "runningKernelUpdate": ["Kernel update available for running {kernel}", "Aggiornamento disponibile per il kernel in uso: {kernel}"], + "kernelPveUpdates": ["Kernel updates available: {kernelCount}; Proxmox updates available: {pveCount}", "Aggiornamenti disponibili: {kernelCount} kernel + {pveCount} Proxmox"], + "pendingPackageUpdates": ["Package updates pending: {count}", "Aggiornamenti di pacchetti in sospeso: {count}"], + "updatesAptError": ["Failed to check for updates (apt-get error)", "Controllo aggiornamenti non riuscito (errore apt-get)"], + "updatesAptTimeout": ["apt-get timed out - repository may be unreachable", "Timeout di apt-get: il repository potrebbe non essere raggiungibile"], + "kernelPackagesMore": ["{technicalIdentity} (+{count} more)", "{technicalIdentity} (+{count} altri)"], + "nonRunningKernelUpdates": ["Kernel updates available: {count} (none for the running kernel{optionalKernel})", "Aggiornamenti kernel disponibili: {count} (nessuno per il kernel in uso{optionalKernel})"], + "unknownDetail": ["Unknown", "Sconosciuto"], + "pveVersionAvailable": ["PVE {current} -> {new} available", "PVE {current} -> {new} disponibile"], + "updatesCheckUnavailable": ["Updates check unavailable: {error}", "Controllo aggiornamenti non disponibile: {error}"], + "fail2banInactive": ["Fail2Ban installed but service not active", "Fail2Ban installato; servizio inattivo"], + "fail2banCheckUnavailable": ["Unable to check Fail2Ban: {error}", "Impossibile controllare Fail2Ban: {error}"], + "uptimeKernelAdvice": ["Uptime: {days} days (>1 year; consider updating kernel/system)", "Tempo di attività: {days} giorni (>1 anno; valuta l’aggiornamento di kernel/sistema)"], + "uptimeUnavailable": ["Unable to determine uptime", "Impossibile determinare il tempo di attività"], + "failedLoginsWithinThreshold": ["Failed login attempts in 24h: {count} (within threshold)", "Tentativi di accesso falliti nelle ultime 24 ore: {count} (entro la soglia)"], + "loginCheckUnavailable": ["Unable to check login attempts", "Impossibile controllare i tentativi di accesso"], + "securityCheckUnavailable": ["Security check unavailable: {error}", "Controllo sicurezza non disponibile: {error}"], + "certificateExpired": ["Certificate expired", "Certificato scaduto"], + "certificateExpiresDays": ["Certificate expiry (days): {days}", "Scadenza certificato (giorni): {days}"], + "certificateInconclusive": ["Certificate check inconclusive", "Controllo certificato non conclusivo"] +}; +const cases = []; +// CPU and sensor +cases.push(...[ + ["No temperature sensor detected - install lm-sensors if hardware supports it", "noTemperatureSensor", {}], + ["Sustained high CPU usage", "cpuHighSustained", {}], + ["Temperature elevated", "temperatureElevated", {}], + ["CPU >90% sustained for 180s", "cpuSustained", {"threshold": "90", "seconds": "180"}], + ["CPU >85.5% sustained for 0s", "cpuSustained", {"threshold": "85.5", "seconds": "0"}], + ["CPU check failed: permission denied", "cpuCheckFailed", {"error": "permission denied"}], + ["Sensor temperature 87.5°C >80°C; high samples span 2m 3s", "sensorHighSamplesSpan", {"temperature": "87.5", "duration": "2m 3s"}], + ["Sensor temperature 81°C >80°C; high samples span 7s", "sensorHighSamplesSpan", {"temperature": "81", "duration": "7s"}] +]); +// Memory +cases.push(...[ + ["No swap configured", "noSwapConfigured", {}], + ["High RAM usage sustained", "ramHighSustained", {}], + ["Swap nearly full with RAM tight", "swapRamPressure", {}], + ["RAM >90% sustained for 60s", "ramSustained", {"threshold": "90", "seconds": "60"}], + ["RAM >95.5% sustained for 0s", "ramSustained", {"threshold": "95.5", "seconds": "0"}], + ["Memory pressure: swap 98% used and only 3% RAM available", "memoryPressure", {"swapPercent": "98", "ramAvailablePercent": "3"}], + ["Memory pressure: swap 0% used and only 0% RAM available", "memoryPressure", {"swapPercent": "0", "ramAvailablePercent": "0"}], + ["Memory check failed: unavailable", "memoryCheckFailed", {"error": "unavailable"}] +]); +// Updates and technical identities +cases.push(...[ + ["3 security update(s) pending", "pendingSecurityUpdates", {"count": "3"}], + ["3 security update(s) pending (14 days unpatched)", "pendingSecurityUpdatesUnpatched", {"count": "3", "days": "14"}], + ["3 security update(s) pending for 14 days", "pendingSecurityUpdatesDays", {"count": "3", "days": "14"}], + ["System not updated in 549 days (>18 months)", "systemNotUpdatedMonths", {"days": "549"}], + ["System not updated in 366 days (>1 year)", "systemNotUpdatedYear", {"days": "366"}], + ["Kernel update available for running 7.0.7-1-pve", "runningKernelUpdate", {"kernel": "7.0.7-1-pve"}], + ["2 kernel + 4 Proxmox update(s) available", "kernelPveUpdates", {"kernelCount": "2", "pveCount": "4"}], + ["12 package update(s) pending", "pendingPackageUpdates", {"count": "12"}], + ["Failed to check for updates (apt-get error)", "updatesAptError", {}], + ["apt-get timed out - repository may be unreachable", "updatesAptTimeout", {}], + ["proxmox-kernel-7.0.7-1-pve-signed 7.0.7-1 -> 7.0.7-2 (+2 more)", "kernelPackagesMore", {"technicalIdentity": "proxmox-kernel-7.0.7-1-pve-signed 7.0.7-1 -> 7.0.7-2", "count": "2"}], + ["proxmox-kernel-7.0:amd64 (+1 more)", "kernelPackagesMore", {"technicalIdentity": "proxmox-kernel-7.0:amd64", "count": "1"}], + ["linux-image:amd64 1:7.0+deb1~pve -> 1:7.0+deb2~pve (+3 more)", "kernelPackagesMore", {"technicalIdentity": "linux-image:amd64 1:7.0+deb1~pve -> 1:7.0+deb2~pve", "count": "3"}], + ["2 kernel update(s) available (none for running kernel 7.0.7-1-pve)", "nonRunningKernelUpdates", {"count": "2", "optionalKernel": " 7.0.7-1-pve"}], + ["2 kernel update(s) available (none for running kernel)", "nonRunningKernelUpdates", {"count": "2", "optionalKernel": ""}], + ["Unknown", "unknownDetail", {}], + ["PVE 9.0.3 -> 9.0.4 available", "pveVersionAvailable", {"current": "9.0.3", "new": "9.0.4"}], + ["PVE 1:9.0.3+deb1~pve -> 1:9.0.4+deb2~pve available", "pveVersionAvailable", {"current": "1:9.0.3+deb1~pve", "new": "1:9.0.4+deb2~pve"}], + ["Updates check unavailable: timeout", "updatesCheckUnavailable", {"error": "timeout"}] +]); +// Security, uptime and certificates +cases.push(...[ + ["No failed login attempts in 24h", "noFailedLogins", {}], + ["Fail2Ban installed but service not active", "fail2banInactive", {}], + ["Unable to check Fail2Ban: not permitted", "fail2banCheckUnavailable", {"error": "not permitted"}], + ["Uptime 400 days (>1 year, consider updating kernel/system)", "uptimeKernelAdvice", {"days": "400"}], + ["Unable to determine uptime", "uptimeUnavailable", {}], + ["3 failed attempts in 24h (within threshold)", "failedLoginsWithinThreshold", {"count": "3"}], + ["Unable to check login attempts", "loginCheckUnavailable", {}], + ["Security check unavailable: timeout", "securityCheckUnavailable", {"error": "timeout"}], + ["Certificate expired", "certificateExpired", {}], + ["Certificate expires in 30 days", "certificateExpiresDays", {"days": "30"}], + ["Certificate expires in 0 days", "certificateExpiresDays", {"days": "0"}], + ["Certificate check inconclusive", "certificateInconclusive", {}] +]); +// Opaque errors are single payloads: no splitting or recursive interpolation. +for (const [key, prefix] of [ + ['cpuCheckFailed', 'CPU check failed: '], + ['memoryCheckFailed', 'Memory check failed: '], + ['updatesCheckUnavailable', 'Updates check unavailable: '], + ['fail2banCheckUnavailable', 'Unable to check Fail2Ban: '], + ['securityCheckUnavailable', 'Security check unavailable: '], +]) { + for (const error of ['', ' \t \r\n ', 'first line\nsecond line\r\n{count}; {error} $& \\path "quoted" café', 'Certificate expired; No swap configured\n']) { + cases.push([prefix + error, key, {error}]); + } +} +// Execute every case even on RED, so missing families are visible in the log. +let failures = 0; +function check(label, fn) { + try { fn(); } catch (error) { failures++; console.error(`FAIL ${label}: ${error.message}`); } +} +assert.equal(Object.keys(expected).length, 38, 'frozen additive key count'); +assert.deepEqual([...new Set(cases.map(row => row[1]))].sort(), Object.keys(expected).sort(), 'every new key exercised'); +for (const row of cases) { + const [input, key, params] = row; + const [english, italian] = expected[key]; + check(`${key}: selection`, () => assert.equal(select(input), JSON.stringify(['healthStatus.details.' + key, params]))); + for (const [locale, expected] of [['en', english], ['it', italian]]) { + check(`${key}: ${locale} catalog`, () => assert.equal(catalogs[locale].healthStatus.details[key], expected)); + check(`${key}: ${locale} output`, () => assert.equal(render(input, locale), format(expected, params))); + } + // Shipped translations and fallback are distinct from unconditional missing-key proof. + for (const locale of Object.keys(catalogs)) { + const local = catalogs[locale].healthStatus?.details?.[key]; + check(`${key}: shipped ${locale}`, () => assert.equal(render(input, locale), format(local ?? english, params))); + } + check(`${key}: injected missing key`, () => assert.equal( + render(input, 'missing', {...catalogs, missing: {healthStatus: {details: {}}}}), format(english, params))); +} +for (const [input, key, params] of [ + ['All systems operational', 'allOperational', {}], + ['Normal', 'normal', {}], + ["No I/O errors in dmesg", "noIoErrors", {}], + ["Mounted read-write, space OK", "rootFilesystemOk", {}], + ["No SMART warnings in journal", "noSmartWarnings", {}], + ["No critical errors", "noCriticalErrors", {}], + ["No cascading errors", "noCascadingErrors", {}], + ["No error spikes", "noErrorSpikes", {}], + ["No persistent patterns", "noPersistentPatterns", {}], + ["Certificate valid", "certificateValid", {}], + ["Cluster detected (corosync.conf present)", "clusterDetected", {}], + ["Active", "active", {}], + ["UP", "up", {}], + ["Kernel/PVE up to date", "kernelUpToDate", {}], + ["Proxmox VE is up to date", "proxmoxUpToDate", {}], + ["No security updates pending", "noSecurityUpdates", {}], + ["No container startup errors", "noContainerErrors", {}], + ["No OOM events detected", "noOomEvents", {}], + ["No QMP timeouts detected", "noQmpTimeouts", {}], + ["No VM startup failures", "noVmFailures", {}], + ["Dismissed by user", "dismissedByUser", {}], + ['Latency 2.5ms to gateway', 'gatewayLatency', {latency: '2.5'}], + ['51 failed login attempts in 24h', 'failedLogins', {count: '51'}], + ['2 IP(s) currently banned by Fail2Ban (jails: sshd, pve)', 'fail2banBannedIps', {count: '2', jails: 'sshd, pve'}], + ['Uptime 1 day', 'uptimeDays', {count: '1'}], + ['Uptime 2 days', 'uptimeDays', {count: '2'}], + ['9 package(s) pending', 'pendingPackages', {count: '9'}], + ['Last updated 10 day(s) ago', 'updatedDaysAgo', {count: '10'}], + ['42.5% used', 'storageUsage', {percent: '42.5'}], + ['Storage: 2 Proxmox storages unavailable: local, nas (startup)', 'startupStoragesChecking', {storages: 'local, nas'}], + ['Storage: nas not yet available (startup)', 'startupStorageChecking', {storage: 'nas'}], + ['nas not yet available (startup)', 'startupStorageChecking', {storage: 'nas'}], + ["[Startup] Storage 'nas' is configured but not found on the server. (checking...)", 'startupStorageNotFound', {storage: 'nas'}], + ["[Startup] Storage 'nas' is not available (connection error or backend issue). (checking...)", 'startupStorageUnavailable', {storage: 'nas'}], + ["[Startup] Storage 'nas' has status: offline. (checking...)", 'startupStorageStatus', {storage: 'nas', status: 'offline'}], + ['nfs storage available', 'storageAvailable', {type: 'nfs'}], + ['nfs mount reachable', 'mountReachable', {type: 'nfs'}], + ['rootfs 21.5% used (3G)', 'rootfsUsed', {percent: '21.5', size: '3G'}], + ['2 running CT(s) within safe rootfs usage', 'runningCtsSafe', {count: '2'}], + ['3 PVE block storage(s) within safe usage', 'pveStorageSafe', {count: '3'}], + ['4 remote mount(s) healthy', 'remoteMountsHealthy', {count: '4'}], +]) { + check(`existing ${key}`, () => assert.equal(select(input), JSON.stringify(['healthStatus.details.' + key, params]))); + for (const locale of Object.keys(catalogs)) check(`existing ${key} ${locale}`, () => assert.equal(render(input, locale), translator(locale)('healthStatus.details.' + key, params))); +} +for (const input of ['', undefined]) check('empty input', () => assert.equal(render(input), '')); +const passthrough = [ + 'New backend reason', 'Disk check unavailable: unsupported', + 'custom {count}; /srv/path\nline2', + 'toString', 'constructor', '__proto__', 'hasOwnProperty', + 'Sensor temperature 87°C still elevated', + 'Uptime 400 days, system recently updated', + 'Certificate expired; No swap configured', + 'Normal; 3 security update(s) pending', + '3 security update(s) pending; Certificate expired', + 'CPU >90% sustained for 180s; Temperature elevated', + 'Sensor temperature 87.5°C >80°C; high samples span 2m 3s; Certificate expired', + 'System not updated in 549 days (>18 months); No swap configured', + 'unknown', 'Certificate expires in -1 days', 'Certificate expires in 1 day', + 'certificate expired', 'No swap configured ', ' No swap configured', + 'CPU >90% sustained for 180 seconds', 'CPU >90% sustained for 180s extra', + 'CPU >90..5% sustained for 180s', 'CPU >.5% sustained for 180s', + 'RAM >95..5% sustained for 60s', + 'Sensor temperature 87..5°C >80°C; high samples span 2m 3s', + 'Sensor temperature 87°C >80°C; high samples span 2m', + 'Memory pressure: swap 98.5% used and only 3% RAM available', + '3 security updates pending', '3 security update(s) pending (14 days unpatched) extra', + 'Kernel update available for running 7.0.7-1-pve (extra)', + '2 kernel update(s) available (none for running kernel 7.0.7-1-pve extra)', + 'PVE 9.0.3 -> 9.0.4 available (extra)', + 'not a package (+2 more)', 'proxmox-kernel-7.0 (+2 more) extra', + 'proxmox-kernel-7.0 1 -> 2 -> 3 (+2 more)', + 'proxmox-kernel-7.0 1 -> 2', // Pure technical identities need no translation. + 'proxmox-kernel-7.0:amd64', +]; +// Non-error atoms with trailing line breaks retain their exact bytes. +for (const [input, key] of cases) { + if (!key.endsWith('CheckFailed') && !key.endsWith('CheckUnavailable')) { + passthrough.push(input + '\n', input + '\r\n'); + } +} +for (const input of passthrough) { + check(`passthrough ${JSON.stringify(input)}`, () => assert.equal(select(input), input)); + for (const locale of Object.keys(catalogs)) check(`passthrough ${locale} ${JSON.stringify(input)}`, () => assert.equal(render(input, locale), input)); +} +if (failures) throw new Error(`${failures} health-text assertions failed`); +console.log(`PASS: actual health mapper/provider; ${new Set(cases.map(row => row[1])).size} new keys, ${cases.length} message variants, ${Object.keys(catalogs).length} shipped locales and unconditional missing-key fallback`);