mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +00:00
266 lines
20 KiB
JavaScript
266 lines
20 KiB
JavaScript
// 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`);
|