mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-09 06:56:37 +00:00
fix(i18n): translate health popup details and add Italian messages
This commit is contained in:
@@ -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