mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
fix(storage): qualify SMART status and report explanations
This commit is contained in:
@@ -90,6 +90,18 @@ class CommandDescriptionsTests(unittest.TestCase):
|
||||
path = root / lang / "common.json"
|
||||
path.parent.mkdir()
|
||||
shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path)
|
||||
# Model steady state after automation fills this intentional new key.
|
||||
# Runtime missing-key fallback is tested separately in test_storage_messages.cjs.
|
||||
if lang != "en":
|
||||
messages = json.loads(path.read_text())
|
||||
messages["storage"].setdefault("savedSmartData", catalog("en")["storage"]["savedSmartData"])
|
||||
report = messages["storage"]["smartReport"]
|
||||
source_report = catalog("en")["storage"]["smartReport"]
|
||||
for key in ("passedAssessment", "noReallocatedSectorsReported", "passedMeaningTitle", "passedMeaning"):
|
||||
report.setdefault(key, source_report[key])
|
||||
for key in ("passedTitle", "passedText"):
|
||||
report["recommendations"].setdefault(key, source_report["recommendations"][key])
|
||||
path.write_text(json.dumps(messages))
|
||||
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
|
||||
|
||||
@@ -2065,7 +2065,7 @@ export function StorageOverview() {
|
||||
<h4 className="font-semibold mb-3 flex items-center gap-2">
|
||||
{t("storage.wearLifetime")}
|
||||
{smartJsonData?.has_data && !wi && (
|
||||
<Badge className="bg-green-500/10 text-green-400 border-green-500/20 text-[10px] px-1.5">{t("storage.realTest")}</Badge>
|
||||
<Badge className="bg-green-500/10 text-green-400 border-green-500/20 text-[10px] px-1.5">{t("storage.savedSmartData")}</Badge>
|
||||
)}
|
||||
</h4>
|
||||
<div className="flex gap-5 items-start">
|
||||
@@ -2777,7 +2777,7 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
||||
// Build recommendations
|
||||
const recommendations: string[] = []
|
||||
if (isHealthy) {
|
||||
recommendations.push(`<div class="rec-item rec-ok"><div class="rec-icon">✓</div><div><strong>${t("storage.smartReport.recommendations.healthyTitle")}</strong><p>${t("storage.smartReport.recommendations.healthyText")}</p></div></div>`)
|
||||
recommendations.push(`<div class="rec-item rec-ok"><div class="rec-icon">✓</div><div><strong>${t("storage.smartReport.recommendations.passedTitle")}</strong><p>${t("storage.smartReport.recommendations.passedText")}</p></div></div>`)
|
||||
} else {
|
||||
recommendations.push(`<div class="rec-item rec-critical"><div class="rec-icon">✗</div><div><strong>${t("storage.smartReport.recommendations.criticalTitle")}</strong><p>${t("storage.smartReport.recommendations.criticalText")}</p></div></div>`)
|
||||
}
|
||||
@@ -3284,7 +3284,7 @@ function pmxPrint(){
|
||||
<h3>${t("storage.smartReport.healthAssessment")}</h3>
|
||||
<p>
|
||||
${isHealthy
|
||||
? t("storage.smartReport.healthyAssessment", { uptime: powerOnFormatted, temperature: disk.temperature > 0 ? disk.temperature + '°C' : na, sectors: (disk.reallocated_sectors ?? 0) === 0 ? t("storage.smartReport.noBadSectors") : t("storage.smartReport.reallocatedSectors", { count: disk.reallocated_sectors ?? 0 }) })
|
||||
? t("storage.smartReport.passedAssessment", { uptime: powerOnFormatted, temperature: disk.temperature > 0 ? disk.temperature + '°C' : na, sectors: (disk.reallocated_sectors ?? 0) === 0 ? t("storage.smartReport.noReallocatedSectorsReported") : t("storage.smartReport.reallocatedSectors", { count: disk.reallocated_sectors ?? 0 }) })
|
||||
: t("storage.smartReport.failedAssessment")
|
||||
}
|
||||
</p>
|
||||
@@ -3294,11 +3294,11 @@ function pmxPrint(){
|
||||
<!-- Simple Explanation for Non-Technical Users -->
|
||||
<div style="background:${isHealthy ? '#dcfce7' : (hasCritical ? '#fee2e2' : '#fef3c7')};border:1px solid ${isHealthy ? '#86efac' : (hasCritical ? '#fca5a5' : '#fcd34d')};border-radius:8px;padding:16px;margin-top:12px;">
|
||||
<div style="font-weight:700;font-size:14px;color:${isHealthy ? '#166534' : (hasCritical ? '#991b1b' : '#92400e')};margin-bottom:8px;">
|
||||
${isHealthy ? t("storage.smartReport.healthyMeaningTitle") : (hasCritical ? t("storage.smartReport.attentionTitle") : t("storage.smartReport.monitorTitle"))}
|
||||
${isHealthy ? t("storage.smartReport.passedMeaningTitle") : (hasCritical ? t("storage.smartReport.attentionTitle") : t("storage.smartReport.monitorTitle"))}
|
||||
</div>
|
||||
<p style="color:${isHealthy ? '#166534' : (hasCritical ? '#991b1b' : '#92400e')};font-size:12px;margin:0 0 8px 0;">
|
||||
${isHealthy
|
||||
? t("storage.smartReport.healthyMeaning")
|
||||
? t("storage.smartReport.passedMeaning")
|
||||
: (hasCritical
|
||||
? t("storage.smartReport.criticalMeaning")
|
||||
: t("storage.smartReport.warningMeaning")
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"life": "life",
|
||||
"lifeRemaining": "Life Remaining",
|
||||
"realTest": "Real Test",
|
||||
"savedSmartData": "Saved SMART data",
|
||||
"wear": {
|
||||
"label": "Wear",
|
||||
"percentageUsed": "Percentage Used",
|
||||
@@ -362,6 +363,10 @@
|
||||
"healthAssessment": "Disk Health Assessment",
|
||||
"passedUpper": "PASSED",
|
||||
"testAgeWarning": "This report is based on a SMART test performed {days} days ago ({date}). Disk health may have changed since then. We recommend running a new SMART test for up-to-date results.",
|
||||
"passedAssessment": "The supplied SMART status is PASSED. This status alone does not establish that every attribute is within range or that the disk is free of faults. Power-on time shown: {uptime}. Temperature shown: {temperature}. {sectors}",
|
||||
"noReallocatedSectorsReported": "The supplied reallocated-sector value is zero or unavailable; this does not establish that there are no bad sectors.",
|
||||
"passedMeaningTitle": "What does a PASSED status mean?",
|
||||
"passedMeaning": "PASSED is the overall status supplied to this report, not proof of a completed self-test or a guarantee against failure. Review the attributes, error logs and self-test results, and keep current backups.",
|
||||
"healthyAssessment": "This disk is operating within normal parameters. All SMART attributes are within acceptable thresholds. The disk has been powered on for approximately {uptime} and is currently operating at {temperature}. {sectors}",
|
||||
"failedAssessment": "This disk has reported a SMART health failure. Immediate action is required. Back up all critical data and plan for disk replacement.",
|
||||
"noBadSectors": "No bad sectors have been detected.",
|
||||
@@ -718,6 +723,8 @@
|
||||
"backgroundScanStatus": "Status of the SCSI background media scan. Runs continuously to detect surface defects."
|
||||
},
|
||||
"recommendations": {
|
||||
"passedTitle": "Review the reported results",
|
||||
"passedText": "A PASSED status can coexist with warnings or recorded errors. Review the report details, continue regular monitoring and keep current backups.",
|
||||
"healthyTitle": "Disk is Healthy",
|
||||
"healthyText": "All SMART attributes are within normal ranges. Continue regular monitoring.",
|
||||
"criticalTitle": "Critical: Disk Health Issue Detected",
|
||||
|
||||
@@ -232,7 +232,7 @@ while true; do
|
||||
else
|
||||
HEALTH=$(echo "$OUTPUT" | grep -i "critical_warning" | awk '{print $NF}')
|
||||
if [[ "$HEALTH" == "0" ]]; then
|
||||
msg_ok "$(translate 'NVMe health status: PASSED')"
|
||||
msg_ok "$(translate 'NVMe critical_warning is 0 (no critical warnings reported).')"
|
||||
else
|
||||
msg_warn "$(translate 'NVMe health status: WARNING (critical_warning =') $HEALTH)"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Execute only the actual pure HEALTH decision; never source the admin script."""
|
||||
import pathlib
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / 'scripts/storage/smart-disk-test.sh'
|
||||
|
||||
|
||||
class NvmeStatusMessage(unittest.TestCase):
|
||||
def render(self, health):
|
||||
source = SOURCE.read_text()
|
||||
start = source.index(' if [[ "$HEALTH" == "0" ]]; then')
|
||||
end = source.index('\n fi', start) + len('\n fi')
|
||||
seam = source[start:end]
|
||||
# Only printf-backed display/translation stubs are available to this seam.
|
||||
harness = '''translate() { printf '%s' "$1"; }
|
||||
msg_ok() { printf 'OK:%s\\n' "$1"; }
|
||||
msg_warn() { printf 'WARN:%s\\n' "$1"; }
|
||||
HEALTH=$1
|
||||
'''
|
||||
return subprocess.run(['bash', '-c', harness + seam, 'fixture', health],
|
||||
check=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
def test_zero_describes_critical_warning_not_self_test(self):
|
||||
self.assertEqual(self.render('0'), 'OK:NVMe critical_warning is 0 (no critical warnings reported).')
|
||||
|
||||
def test_nonzero_and_missing_keep_existing_warning_branch(self):
|
||||
for health in ['1', '2', '255', '', '0x00']:
|
||||
with self.subTest(health=health):
|
||||
self.assertEqual(self.render(health), f'WARN:NVMe health status: WARNING (critical_warning = {health})')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
// Offline actual JSX/provider seams. No component imports or effects.
|
||||
// NODE_PATH may point to an isolated TypeScript installation.
|
||||
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 print = ts.createPrinter();
|
||||
const emit = (node, source) => print.printNode(ts.EmitHint.Unspecified, node, source);
|
||||
const compile = code => ts.transpileModule(code, {compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.React,
|
||||
}}).outputText;
|
||||
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;
|
||||
function walk(node, fn) { fn(node); ts.forEachChild(node, n => walk(n, fn)); }
|
||||
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);
|
||||
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`))]));
|
||||
// Only trusted checked-out source enters Function bodies; fixture/catalog values are arguments.
|
||||
const translator = (language, messages = catalogs) => new Function('MESSAGE_CATALOG', 'language',
|
||||
compile(helpers.map(n => emit(n, provider)).join('\n') + '\nconst t = ' + emit(lookup, provider)) + '\nreturn t;')(messages, language);
|
||||
const source = parse('AppImage/components/storage-overview.tsx');
|
||||
let badge;
|
||||
walk(source, n => {
|
||||
if (ts.isJsxExpression(n) && n.expression && ts.isBinaryExpression(n.expression) &&
|
||||
n.expression.left.getText(source) === 'smartJsonData?.has_data && !wi') badge = n.expression;
|
||||
});
|
||||
assert.ok(badge, 'actual availability guard and badge');
|
||||
const render = new Function('React', 'Badge', 't', 'smartJsonData', 'wi',
|
||||
compile('const result = ' + emit(badge, source)) + '\nreturn result;');
|
||||
const React = {createElement: (tag, props, ...children) => children.join('')};
|
||||
const text = (locale, data = {has_data: true}, wi = false, messages = catalogs) =>
|
||||
render(React, 'badge', translator(locale, messages), data, wi);
|
||||
assert.equal(text('en'), 'Saved SMART data');
|
||||
assert.equal(text('missing', undefined, false, {...catalogs, missing: {}}), 'Saved SMART data');
|
||||
for (const locale of Object.keys(catalogs)) {
|
||||
if (catalogs[locale].storage?.savedSmartData === undefined) assert.equal(text(locale), 'Saved SMART data');
|
||||
}
|
||||
assert.equal(text('en', {has_data: false}), false);
|
||||
assert.equal(text('en', {has_data: true}, true), false);
|
||||
assert.equal(text('en', null), undefined);
|
||||
assert.equal(text('synthetic', undefined, false, {...catalogs, synthetic: {storage: {savedSmartData: 'SAVED_DATA_TRANSLATED'}}}), 'SAVED_DATA_TRANSLATED');
|
||||
assert.equal(catalogs.en.storage.realTest, 'Real Test', 'legacy label preserved');
|
||||
console.log('PASS: saved-data badge guards and actual provider fallback/translation');
|
||||
|
||||
// Execute the actual printable-report function with inert browser sinks, not component effects.
|
||||
const report = source.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === 'openSmartReport');
|
||||
assert.ok(report);
|
||||
let reportHtml;
|
||||
const openReport = new Function('window', 'Blob', 'URL', 'setTimeout', 'getDiskTempThresholdsSync', 'getNvmeSmartAttributeKey',
|
||||
compile('const APP_VERSION = "fixture";\n' + emit(report, source)) + '\nreturn openSmartReport;')(
|
||||
{location: {origin: 'http://fixture.invalid'}, open() {}},
|
||||
class { constructor(parts) { reportHtml = parts.join(''); } },
|
||||
{createObjectURL() { return 'blob:fixture'; }, revokeObjectURL() {}}, () => {},
|
||||
() => ({warning: 55, critical: 65}), () => undefined);
|
||||
const expectedReport = {
|
||||
passedAssessment: 'The supplied SMART status is PASSED. This status alone does not establish that every attribute is within range or that the disk is free of faults. Power-on time shown: {uptime}. Temperature shown: {temperature}. {sectors}',
|
||||
noReallocatedSectorsReported: 'The supplied reallocated-sector value is zero or unavailable; this does not establish that there are no bad sectors.',
|
||||
passedMeaningTitle: 'What does a PASSED status mean?',
|
||||
passedMeaning: 'PASSED is the overall status supplied to this report, not proof of a completed self-test or a guarantee against failure. Review the attributes, error logs and self-test results, and keep current backups.',
|
||||
recommendations: {
|
||||
passedTitle: 'Review the reported results',
|
||||
passedText: 'A PASSED status can coexist with warnings or recorded errors. Review the report details, continue regular monitoring and keep current backups.',
|
||||
},
|
||||
};
|
||||
function leaves(value, prefix = '') {
|
||||
return Object.entries(value).flatMap(([k, v]) => typeof v === 'string' ? [[prefix + k, v]] : leaves(v, prefix + k + '.'));
|
||||
}
|
||||
function renderReport(diskOverrides = {}, status = 'passed', locale = 'en', messages = catalogs, smartData = {}) {
|
||||
openReport({name: 'sda', model: 'Fixture disk', size: 100, rotation_rate: 7200, temperature: 35, power_on_hours: 24, ...diskOverrides},
|
||||
{smart_status: status, smart_data: smartData}, [], [], undefined, undefined, false, undefined, translator(locale, messages));
|
||||
return reportHtml;
|
||||
}
|
||||
const html = renderReport();
|
||||
for (const [key, value] of leaves(expectedReport)) {
|
||||
const expected = value.replace('{uptime}', '1d (24h)').replace('{temperature}', '35°C').replace('{sectors}', expectedReport.noReallocatedSectorsReported);
|
||||
assert.ok(html.includes(expected), `report consumer: ${key}`);
|
||||
}
|
||||
assert.ok(html.includes('The supplied SMART status is PASSED.'));
|
||||
assert.ok(!html.includes('All SMART attributes are within'));
|
||||
assert.ok(!html.includes('Your disk is healthy!'));
|
||||
for (const sectors of [undefined, 0]) assert.ok(renderReport({reallocated_sectors: sectors}).includes(expectedReport.noReallocatedSectorsReported));
|
||||
assert.ok(!renderReport({reallocated_sectors: 3}).includes(expectedReport.noReallocatedSectorsReported));
|
||||
for (const status of ['failed', 'unknown', 'warning']) assert.ok(!renderReport({}, status).includes(expectedReport.passedMeaning));
|
||||
for (const locale of ['synthetic', ...Object.keys(catalogs).filter(l => l !== 'en')]) {
|
||||
const messages = {...catalogs, synthetic: {}};
|
||||
const rendered = renderReport({}, 'passed', locale, messages);
|
||||
if (locale === 'synthetic') assert.ok(rendered.includes(expectedReport.passedAssessment.replace('{uptime}', '1d (24h)').replace('{temperature}', '35°C').replace('{sectors}', expectedReport.noReallocatedSectorsReported)));
|
||||
for (const [key, value] of leaves(expectedReport)) {
|
||||
const localValue = ('storage.smartReport.' + key).split('.').reduce((v, k) => v?.[k], messages[locale]);
|
||||
if (localValue === undefined && !value.includes('{')) assert.ok(rendered.includes(value), `${locale}: ${key}`);
|
||||
}
|
||||
}
|
||||
for (const temperature of [undefined, 0]) assert.ok(renderReport({temperature}).includes('Temperature shown: N/A.'));
|
||||
assert.ok(renderReport({name: 'nvme0n1'}, 'passed').includes(expectedReport.passedMeaning));
|
||||
const translated = Object.fromEntries(leaves(expectedReport).filter(([k]) => !k.includes('.')).map(([k]) => [k, `TRANSLATED_${k}`]));
|
||||
translated.passedAssessment += ' {uptime} {temperature} {sectors}';
|
||||
translated.recommendations = {passedTitle: 'TRANSLATED_REC_TITLE', passedText: 'TRANSLATED_REC_TEXT'};
|
||||
const localized = renderReport({}, 'passed', 'synthetic', {...catalogs, synthetic: {storage: {smartReport: translated}}});
|
||||
for (const [, value] of leaves(translated)) assert.ok(localized.includes(value.split(' {')[0]), value);
|
||||
assert.ok(renderReport({name: 'nvme0n1'}, 'passed', 'en', catalogs, {nvme_raw: {critical_warning: 0, media_errors: 7}}).includes(expectedReport.passedMeaning));
|
||||
if (process.env.STORAGE_REPORT_HTML) fs.writeFileSync(process.env.STORAGE_REPORT_HTML, renderReport());
|
||||
console.log('PASS: actual printable report, passed/failed/unknown/warning, missing/zero/nonzero sectors, NVMe errors and provider fallback');
|
||||
Reference in New Issue
Block a user