mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +00:00
Merge pull request #424 from f3rs3n/feature/health-popup-i18n-it
fix(i18n): translate health popup details and add Italian messages
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -299,6 +299,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
const translateHealthText = (value?: string): string => {
|
||||
if (!value) return ""
|
||||
const exact: Record<string, string> = {
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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`);
|
||||
Reference in New Issue
Block a user