diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 87d1bf07..88e0bc6e 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -35,6 +35,23 @@ import DOMPurify from "dompurify" import { marked } from "marked" import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider" +// A schedule record predating the structured reason code still contains one +// of these exact diagnostic messages. Recognise only those known historical +// values so an already-recorded safe skip localizes after an upgrade, while +// unrelated freeform diagnostics remain verbatim for troubleshooting. +const LEGACY_STOPPED_SCHEDULE_REASONS = new Set([ + "container is stopped; scheduled update was skipped", + "container was stopped before scheduled update could begin", +]) + +const scheduleReasonCodeFromPersisted = (reasonCode: unknown, reason: unknown): string | null => { + if (typeof reasonCode === "string" && reasonCode) return reasonCode + if (typeof reason === "string" && LEGACY_STOPPED_SCHEDULE_REASONS.has(reason.toLowerCase())) { + return "stopped_container" + } + return null +} + // Sent by /api/vms only for LXC rows, only when the user has enabled // `lxc_updates_available` notifications. The Monitor populates this // from managed_installs registry → frontend uses it to render the @@ -2077,6 +2094,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { const [scheduleLastRunAt, setScheduleLastRunAt] = useState(null) const [scheduleLastRunStatus, setScheduleLastRunStatus] = useState(null) const [scheduleLastRunReason, setScheduleLastRunReason] = useState(null) + const [scheduleLastRunReasonCode, setScheduleLastRunReasonCode] = useState(null) const [scheduleLastRunLog, setScheduleLastRunLog] = useState(null) const [scheduleLastRunRebootRequired, setScheduleLastRunRebootRequired] = useState(false) const [scheduleLastRunRebootPackages, setScheduleLastRunRebootPackages] = useState([]) @@ -2153,6 +2171,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { setScheduleLastRunAt(s.last_run_at || null) setScheduleLastRunStatus(s.last_run_status || null) setScheduleLastRunReason(s.last_run_reason || null) + setScheduleLastRunReasonCode(scheduleReasonCodeFromPersisted(s.last_run_reason_code, s.last_run_reason)) setScheduleLastRunLog(s.last_run_log || null) setScheduleLastRunRebootRequired(s.last_run_reboot_required === true) setScheduleLastRunRebootPackages(Array.isArray(s.last_run_reboot_packages) @@ -2430,6 +2449,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { const renderScheduleRunDetails = (indented = false) => { if (!scheduleLastRunAt || !selectedVM) return null const spacing = indented ? "pl-4" : "" + const scheduleLastRunReasonText = scheduleLastRunReasonCode === "stopped_container" + ? t("vmLxc.scheduled.reasonStoppedContainer") + : scheduleLastRunReason return (
@@ -2448,9 +2470,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { )}
- {scheduleLastRunReason && ( + {scheduleLastRunReasonText && (
- {scheduleLastRunReason} + {scheduleLastRunReasonText}
)} {scheduleLastRunRebootRequired && ( diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index ca7e536d..512338fa 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1288,6 +1288,7 @@ "runFailed": "✗ failed", "runDeferred": "held for safety", "runSkipped": "nothing pending", + "reasonStoppedContainer": "Container is stopped; scheduled update was skipped.", "releaseDelaySummary": "{days}-day release hold", "releaseDelayLabel": "Wait after a new app release", "releaseDelayNone": "No waiting period", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index dc5b7b80..20f9d9f8 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1288,6 +1288,7 @@ "runFailed": "✗ zlyhalo", "runDeferred": "odložené z bezpečnostných dôvodov", "runSkipped": "nič nečaká", + "reasonStoppedContainer": "Kontajner je vypnutý; plánovaná aktualizácia sa preskočila.", "releaseDelaySummary": "odklad {days} deň/dní", "releaseDelayLabel": "Počkať po novej verzii aplikácie", "releaseDelayNone": "Bez čakacej doby", diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 965f548a..a85f7939 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -22700,6 +22700,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict: requested_target = sched.get("target") or "both" deferred_targets: list[str] = [] reasons: list[str] = [] + reason_code: str | None = None reboot_required: bool | None = None reboot_packages: list[str] = [] reboot_check_error: str | None = None @@ -22751,6 +22752,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict: 'status': status, 'target': actual_target, 'reason': reason, + 'reason_code': reason_code, 'run_id': run_id, 'requested_targets': requested_targets, 'executed_targets': list(executed), @@ -22763,6 +22765,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict: } if not originally_running: + reason_code = 'stopped_container' reasons.append('container is stopped; scheduled update was skipped') return finish('skipped', requested_target, []) @@ -22978,6 +22981,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict: timeout=60 * 60, ) if _scheduled_runner_reported_stopped(r, log_path, scheduled_stopped_marker): + reason_code = 'stopped_container' reasons.append('container was stopped before scheduled update could begin') return finish('skipped', target, []) reboot_required, reboot_packages, reboot_check_error = ( @@ -23055,12 +23059,14 @@ def _scheduler_loop(): status = outcome.get('status') or 'failure' actual_target = outcome.get('target') or 'both' reason = outcome.get('reason') + reason_code = outcome.get('reason_code') try: lxc_apps.record_schedule_run( _vmid, status, actual_target, reason, + reason_code=reason_code, log_name=outcome.get('log_name'), reboot_required=outcome.get('reboot_required'), reboot_packages=outcome.get('reboot_packages'), diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index 10eb5736..bc828d7e 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -94,6 +94,7 @@ _VALID_UPSTREAM_TYPES = ("github", "http_json", "docker_hub") # `*/N` step, and comma lists — that covers every preset the UI # exposes and the freeform "custom" text field. _VALID_SCHEDULE_TARGETS = ("os", "app", "both") +_SCHEDULE_REASON_CODES = frozenset({"stopped_container"}) _SCHEDULE_TARGET_ID_RE = re.compile( r"^(?:os|apps|oci_image|app:[A-Za-z0-9_-]{1,64}|docker-engine|docker-(?:compose|container):[A-Za-z0-9][A-Za-z0-9_.-]{0,127}|docker-unit:[a-f0-9]{20})$" ) @@ -3918,6 +3919,7 @@ def record_schedule_run( target: str, reason: Optional[str] = None, *, + reason_code: Optional[str] = None, log_name: Optional[str] = None, reboot_required: Optional[bool] = None, reboot_packages: Optional[list[str]] = None, @@ -3937,6 +3939,10 @@ def record_schedule_run( sidecar["schedule"]["last_run_reason"] = str(reason)[:300] else: sidecar["schedule"].pop("last_run_reason", None) + if reason_code in _SCHEDULE_REASON_CODES: + sidecar["schedule"]["last_run_reason_code"] = reason_code + else: + sidecar["schedule"].pop("last_run_reason_code", None) if log_name: sidecar["schedule"]["last_run_log"] = os.path.basename(str(log_name))[:220] else: diff --git a/AppImage/scripts/tests/test_lxc_scheduled_update_records.py b/AppImage/scripts/tests/test_lxc_scheduled_update_records.py index a2d89168..89bb0d59 100644 --- a/AppImage/scripts/tests/test_lxc_scheduled_update_records.py +++ b/AppImage/scripts/tests/test_lxc_scheduled_update_records.py @@ -33,6 +33,8 @@ class ScheduledUpdateRecordTests(unittest.TestCase): self.vmid, 'success', 'both', + reason='container is stopped; scheduled update was skipped', + reason_code='stopped_container', log_name='../9911-scheduled-' + ('a' * 32) + '.log', reboot_required=True, reboot_packages=['linux-image-amd64', 'libc6'], @@ -43,11 +45,31 @@ class ScheduledUpdateRecordTests(unittest.TestCase): '9911-scheduled-' + ('a' * 32) + '.log', ) self.assertTrue(schedule['last_run_reboot_required']) + self.assertEqual(schedule['last_run_reason_code'], 'stopped_container') self.assertEqual( schedule['last_run_reboot_packages'], ['linux-image-amd64', 'libc6'], ) + def test_record_rejects_unknown_reason_code_and_clears_the_previous_one(self): + self.assertTrue(lxc_apps.record_schedule_run( + self.vmid, + 'skipped', + 'os', + reason='container is stopped; scheduled update was skipped', + reason_code='stopped_container', + )) + self.assertTrue(lxc_apps.record_schedule_run( + self.vmid, + 'failure', + 'os', + reason='update runner exited with code 9', + reason_code='untrusted_value', + )) + schedule = lxc_apps._read_sidecar(self.vmid)['schedule'] + self.assertEqual(schedule['last_run_reason'], 'update runner exited with code 9') + self.assertNotIn('last_run_reason_code', schedule) + def test_lifecycle_clear_preserves_run_and_log(self): lxc_apps.record_schedule_run( self.vmid, diff --git a/tests/lxc_updates/test_scheduled_reason_i18n.cjs b/tests/lxc_updates/test_scheduled_reason_i18n.cjs new file mode 100644 index 00000000..61201925 --- /dev/null +++ b/tests/lxc_updates/test_scheduled_reason_i18n.cjs @@ -0,0 +1,83 @@ +// Execute the real scheduled-run detail JSX: a known backend reason code is +// localized, while an unknown/missing code keeps its raw diagnostic text. +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const root = path.resolve(__dirname, '../../AppImage') +const ts = require(path.join(root, 'node_modules/typescript')) +const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8') +const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) +let declaration +let legacyReasons +let reasonCodeHelper +function walk(node) { + if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'renderScheduleRunDetails') declaration = node.getText(tree) + if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'LEGACY_STOPPED_SCHEDULE_REASONS') legacyReasons = node.getText(tree) + if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'scheduleReasonCodeFromPersisted') reasonCodeHelper = node.getText(tree) + ts.forEachChild(node, walk) +} +walk(tree) +assert(declaration, 'scheduled run detail renderer must exist') +assert(legacyReasons, 'legacy stopped-reason list must exist') +assert(reasonCodeHelper, 'persisted schedule reason-code helper must exist') + +function compile(context) { + const result = ts.transpileModule(`const ${declaration}; exports.render = renderScheduleRunDetails;`, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX }, + reportDiagnostics: true, + }) + assert.equal(result.diagnostics.length, 0) + vm.runInNewContext(result.outputText, context) + return context.exports.render +} + +function compileReasonCodeHelper() { + const result = ts.transpileModule(`const ${legacyReasons}; const ${reasonCodeHelper}; exports.reasonCode = scheduleReasonCodeFromPersisted;`, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + reportDiagnostics: true, + }) + assert.equal(result.diagnostics.length, 0) + const context = { exports: {}, Set } + vm.runInNewContext(result.outputText, context) + return context.exports.reasonCode +} + +function flattenText(node) { + if (Array.isArray(node)) return node.map(flattenText).join(' ') + if (!node || typeof node !== 'object') return typeof node === 'string' ? node : '' + return flattenText(node.props?.children) +} + +const persistedReasonCode = compileReasonCodeHelper() +assert.equal(persistedReasonCode('stopped_container', 'anything'), 'stopped_container') +assert.equal(persistedReasonCode(null, 'Container is stopped; scheduled update was skipped'), 'stopped_container') +assert.equal(persistedReasonCode(null, 'container was stopped before scheduled update could begin'), 'stopped_container') +assert.equal(persistedReasonCode('future_reason', 'raw diagnostic'), 'future_reason') +assert.equal(persistedReasonCode(null, 'raw diagnostic'), null) + +for (const locale of ['en', 'sk']) { + const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`), 'utf8')) + const t = (key, variables = {}) => { + const message = key.split('.').reduce((value, part) => value?.[part], messages) + return String(message ?? key).replace(/\{([^}]+)\}/g, (_, name) => String(variables[name] ?? `{${name}}`)) + } + const context = { + exports: {}, t, selectedVM: { vmid: 100 }, scheduleLastRunAt: '2026-10-16T05:10:02Z', + scheduleLastRunStatus: 'skipped', scheduleLastRunReasonCode: 'stopped_container', + scheduleLastRunReason: 'container is stopped; scheduled update was skipped', + scheduleLastRunRebootRequired: false, scheduleLastRunRebootPackages: [], scheduleLastRunLog: null, + openScheduleLog() {}, AlertTriangle: 'AlertTriangle', FileText: 'FileText', + require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }), Fragment: 'Fragment' }), + } + const render = compile(context) + const translated = flattenText(render(true)) + const expected = t('vmLxc.scheduled.reasonStoppedContainer') + assert(translated.includes(expected), `${locale}: known reason code must use the locale catalog`) + assert(!translated.includes(context.scheduleLastRunReason), `${locale}: raw stopped reason must not leak into UI`) + + context.scheduleLastRunReasonCode = 'unknown_reason' + const fallback = flattenText(render(true)) + assert(fallback.includes(context.scheduleLastRunReason), `${locale}: unknown code must retain the raw diagnostic`) +} +console.log('PASS scheduled reason i18n: EN/SK structured stopped reason and raw fallback') diff --git a/tests/lxc_updates/test_update_contract.py b/tests/lxc_updates/test_update_contract.py index 029cd7a5..89725469 100644 --- a/tests/lxc_updates/test_update_contract.py +++ b/tests/lxc_updates/test_update_contract.py @@ -355,6 +355,7 @@ class ScheduledStoppedContainerTests(unittest.TestCase): self.assertEqual(outcome["status"], "skipped") self.assertEqual(outcome["executed_targets"], []) self.assertIn("container is stopped", outcome["reason"]) + self.assertEqual(outcome["reason_code"], "stopped_container") runner.assert_not_called() self.assertEqual(finalizer.call_args.kwargs["status"], "skipped") self.assertEqual(finalizer.call_args.kwargs["source"], "scheduled") @@ -409,6 +410,7 @@ class ScheduledStoppedContainerTests(unittest.TestCase): self.assertEqual(outcome["status"], "skipped") self.assertEqual(outcome["executed_targets"], []) self.assertIn("before scheduled update could begin", outcome["reason"]) + self.assertEqual(outcome["reason_code"], "stopped_container") self.assertEqual(runner.call_args.args[0], ["bash", "/bin/true"]) self.assertEqual(runner.call_args.kwargs["env"]["EXECUTION_MODE"], "scheduled") self.assertTrue(runner.call_args.kwargs["env"]["SCHEDULED_STOPPED_MARKER"].startswith(