i18n: localize stopped scheduled update reason

This commit is contained in:
VAIO73
2026-10-16 17:21:34 +00:00
parent c2ad35ca73
commit 59ab7784a7
8 changed files with 145 additions and 2 deletions
+24 -2
View File
@@ -35,6 +35,23 @@ import DOMPurify from "dompurify"
import { marked } from "marked" import { marked } from "marked"
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider" 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 // Sent by /api/vms only for LXC rows, only when the user has enabled
// `lxc_updates_available` notifications. The Monitor populates this // `lxc_updates_available` notifications. The Monitor populates this
// from managed_installs registry → frontend uses it to render the // 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<string | null>(null) const [scheduleLastRunAt, setScheduleLastRunAt] = useState<string | null>(null)
const [scheduleLastRunStatus, setScheduleLastRunStatus] = useState<string | null>(null) const [scheduleLastRunStatus, setScheduleLastRunStatus] = useState<string | null>(null)
const [scheduleLastRunReason, setScheduleLastRunReason] = useState<string | null>(null) const [scheduleLastRunReason, setScheduleLastRunReason] = useState<string | null>(null)
const [scheduleLastRunReasonCode, setScheduleLastRunReasonCode] = useState<string | null>(null)
const [scheduleLastRunLog, setScheduleLastRunLog] = useState<string | null>(null) const [scheduleLastRunLog, setScheduleLastRunLog] = useState<string | null>(null)
const [scheduleLastRunRebootRequired, setScheduleLastRunRebootRequired] = useState(false) const [scheduleLastRunRebootRequired, setScheduleLastRunRebootRequired] = useState(false)
const [scheduleLastRunRebootPackages, setScheduleLastRunRebootPackages] = useState<string[]>([]) const [scheduleLastRunRebootPackages, setScheduleLastRunRebootPackages] = useState<string[]>([])
@@ -2153,6 +2171,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setScheduleLastRunAt(s.last_run_at || null) setScheduleLastRunAt(s.last_run_at || null)
setScheduleLastRunStatus(s.last_run_status || null) setScheduleLastRunStatus(s.last_run_status || null)
setScheduleLastRunReason(s.last_run_reason || null) setScheduleLastRunReason(s.last_run_reason || null)
setScheduleLastRunReasonCode(scheduleReasonCodeFromPersisted(s.last_run_reason_code, s.last_run_reason))
setScheduleLastRunLog(s.last_run_log || null) setScheduleLastRunLog(s.last_run_log || null)
setScheduleLastRunRebootRequired(s.last_run_reboot_required === true) setScheduleLastRunRebootRequired(s.last_run_reboot_required === true)
setScheduleLastRunRebootPackages(Array.isArray(s.last_run_reboot_packages) setScheduleLastRunRebootPackages(Array.isArray(s.last_run_reboot_packages)
@@ -2430,6 +2449,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const renderScheduleRunDetails = (indented = false) => { const renderScheduleRunDetails = (indented = false) => {
if (!scheduleLastRunAt || !selectedVM) return null if (!scheduleLastRunAt || !selectedVM) return null
const spacing = indented ? "pl-4" : "" const spacing = indented ? "pl-4" : ""
const scheduleLastRunReasonText = scheduleLastRunReasonCode === "stopped_container"
? t("vmLxc.scheduled.reasonStoppedContainer")
: scheduleLastRunReason
return ( return (
<div className={`space-y-1.5 ${spacing}`}> <div className={`space-y-1.5 ${spacing}`}>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
@@ -2448,9 +2470,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</span></> </span></>
)} )}
</div> </div>
{scheduleLastRunReason && ( {scheduleLastRunReasonText && (
<div className="text-xs text-muted-foreground break-words"> <div className="text-xs text-muted-foreground break-words">
{scheduleLastRunReason} {scheduleLastRunReasonText}
</div> </div>
)} )}
{scheduleLastRunRebootRequired && ( {scheduleLastRunRebootRequired && (
+1
View File
@@ -1288,6 +1288,7 @@
"runFailed": "✗ failed", "runFailed": "✗ failed",
"runDeferred": "held for safety", "runDeferred": "held for safety",
"runSkipped": "nothing pending", "runSkipped": "nothing pending",
"reasonStoppedContainer": "Container is stopped; scheduled update was skipped.",
"releaseDelaySummary": "{days}-day release hold", "releaseDelaySummary": "{days}-day release hold",
"releaseDelayLabel": "Wait after a new app release", "releaseDelayLabel": "Wait after a new app release",
"releaseDelayNone": "No waiting period", "releaseDelayNone": "No waiting period",
+1
View File
@@ -1288,6 +1288,7 @@
"runFailed": "✗ zlyhalo", "runFailed": "✗ zlyhalo",
"runDeferred": "odložené z bezpečnostných dôvodov", "runDeferred": "odložené z bezpečnostných dôvodov",
"runSkipped": "nič nečaká", "runSkipped": "nič nečaká",
"reasonStoppedContainer": "Kontajner je vypnutý; plánovaná aktualizácia sa preskočila.",
"releaseDelaySummary": "odklad {days} deň/dní", "releaseDelaySummary": "odklad {days} deň/dní",
"releaseDelayLabel": "Počkať po novej verzii aplikácie", "releaseDelayLabel": "Počkať po novej verzii aplikácie",
"releaseDelayNone": "Bez čakacej doby", "releaseDelayNone": "Bez čakacej doby",
+6
View File
@@ -22700,6 +22700,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
requested_target = sched.get("target") or "both" requested_target = sched.get("target") or "both"
deferred_targets: list[str] = [] deferred_targets: list[str] = []
reasons: list[str] = [] reasons: list[str] = []
reason_code: str | None = None
reboot_required: bool | None = None reboot_required: bool | None = None
reboot_packages: list[str] = [] reboot_packages: list[str] = []
reboot_check_error: str | None = None reboot_check_error: str | None = None
@@ -22751,6 +22752,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
'status': status, 'status': status,
'target': actual_target, 'target': actual_target,
'reason': reason, 'reason': reason,
'reason_code': reason_code,
'run_id': run_id, 'run_id': run_id,
'requested_targets': requested_targets, 'requested_targets': requested_targets,
'executed_targets': list(executed), 'executed_targets': list(executed),
@@ -22763,6 +22765,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
} }
if not originally_running: if not originally_running:
reason_code = 'stopped_container'
reasons.append('container is stopped; scheduled update was skipped') reasons.append('container is stopped; scheduled update was skipped')
return finish('skipped', requested_target, []) return finish('skipped', requested_target, [])
@@ -22978,6 +22981,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
timeout=60 * 60, timeout=60 * 60,
) )
if _scheduled_runner_reported_stopped(r, log_path, scheduled_stopped_marker): 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') reasons.append('container was stopped before scheduled update could begin')
return finish('skipped', target, []) return finish('skipped', target, [])
reboot_required, reboot_packages, reboot_check_error = ( reboot_required, reboot_packages, reboot_check_error = (
@@ -23055,12 +23059,14 @@ def _scheduler_loop():
status = outcome.get('status') or 'failure' status = outcome.get('status') or 'failure'
actual_target = outcome.get('target') or 'both' actual_target = outcome.get('target') or 'both'
reason = outcome.get('reason') reason = outcome.get('reason')
reason_code = outcome.get('reason_code')
try: try:
lxc_apps.record_schedule_run( lxc_apps.record_schedule_run(
_vmid, _vmid,
status, status,
actual_target, actual_target,
reason, reason,
reason_code=reason_code,
log_name=outcome.get('log_name'), log_name=outcome.get('log_name'),
reboot_required=outcome.get('reboot_required'), reboot_required=outcome.get('reboot_required'),
reboot_packages=outcome.get('reboot_packages'), reboot_packages=outcome.get('reboot_packages'),
+6
View File
@@ -94,6 +94,7 @@ _VALID_UPSTREAM_TYPES = ("github", "http_json", "docker_hub")
# `*/N` step, and comma lists — that covers every preset the UI # `*/N` step, and comma lists — that covers every preset the UI
# exposes and the freeform "custom" text field. # exposes and the freeform "custom" text field.
_VALID_SCHEDULE_TARGETS = ("os", "app", "both") _VALID_SCHEDULE_TARGETS = ("os", "app", "both")
_SCHEDULE_REASON_CODES = frozenset({"stopped_container"})
_SCHEDULE_TARGET_ID_RE = re.compile( _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})$" 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, target: str,
reason: Optional[str] = None, reason: Optional[str] = None,
*, *,
reason_code: Optional[str] = None,
log_name: Optional[str] = None, log_name: Optional[str] = None,
reboot_required: Optional[bool] = None, reboot_required: Optional[bool] = None,
reboot_packages: Optional[list[str]] = None, reboot_packages: Optional[list[str]] = None,
@@ -3937,6 +3939,10 @@ def record_schedule_run(
sidecar["schedule"]["last_run_reason"] = str(reason)[:300] sidecar["schedule"]["last_run_reason"] = str(reason)[:300]
else: else:
sidecar["schedule"].pop("last_run_reason", None) 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: if log_name:
sidecar["schedule"]["last_run_log"] = os.path.basename(str(log_name))[:220] sidecar["schedule"]["last_run_log"] = os.path.basename(str(log_name))[:220]
else: else:
@@ -33,6 +33,8 @@ class ScheduledUpdateRecordTests(unittest.TestCase):
self.vmid, self.vmid,
'success', 'success',
'both', 'both',
reason='container is stopped; scheduled update was skipped',
reason_code='stopped_container',
log_name='../9911-scheduled-' + ('a' * 32) + '.log', log_name='../9911-scheduled-' + ('a' * 32) + '.log',
reboot_required=True, reboot_required=True,
reboot_packages=['linux-image-amd64', 'libc6'], reboot_packages=['linux-image-amd64', 'libc6'],
@@ -43,11 +45,31 @@ class ScheduledUpdateRecordTests(unittest.TestCase):
'9911-scheduled-' + ('a' * 32) + '.log', '9911-scheduled-' + ('a' * 32) + '.log',
) )
self.assertTrue(schedule['last_run_reboot_required']) self.assertTrue(schedule['last_run_reboot_required'])
self.assertEqual(schedule['last_run_reason_code'], 'stopped_container')
self.assertEqual( self.assertEqual(
schedule['last_run_reboot_packages'], schedule['last_run_reboot_packages'],
['linux-image-amd64', 'libc6'], ['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): def test_lifecycle_clear_preserves_run_and_log(self):
lxc_apps.record_schedule_run( lxc_apps.record_schedule_run(
self.vmid, self.vmid,
@@ -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')
@@ -355,6 +355,7 @@ class ScheduledStoppedContainerTests(unittest.TestCase):
self.assertEqual(outcome["status"], "skipped") self.assertEqual(outcome["status"], "skipped")
self.assertEqual(outcome["executed_targets"], []) self.assertEqual(outcome["executed_targets"], [])
self.assertIn("container is stopped", outcome["reason"]) self.assertIn("container is stopped", outcome["reason"])
self.assertEqual(outcome["reason_code"], "stopped_container")
runner.assert_not_called() runner.assert_not_called()
self.assertEqual(finalizer.call_args.kwargs["status"], "skipped") self.assertEqual(finalizer.call_args.kwargs["status"], "skipped")
self.assertEqual(finalizer.call_args.kwargs["source"], "scheduled") self.assertEqual(finalizer.call_args.kwargs["source"], "scheduled")
@@ -409,6 +410,7 @@ class ScheduledStoppedContainerTests(unittest.TestCase):
self.assertEqual(outcome["status"], "skipped") self.assertEqual(outcome["status"], "skipped")
self.assertEqual(outcome["executed_targets"], []) self.assertEqual(outcome["executed_targets"], [])
self.assertIn("before scheduled update could begin", outcome["reason"]) 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.args[0], ["bash", "/bin/true"])
self.assertEqual(runner.call_args.kwargs["env"]["EXECUTION_MODE"], "scheduled") self.assertEqual(runner.call_args.kwargs["env"]["EXECUTION_MODE"], "scheduled")
self.assertTrue(runner.call_args.kwargs["env"]["SCHEDULED_STOPPED_MARKER"].startswith( self.assertTrue(runner.call_args.kwargs["env"]["SCHEDULED_STOPPED_MARKER"].startswith(