mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
i18n: localize stopped scheduled update reason
This commit is contained in:
@@ -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<string | null>(null)
|
||||
const [scheduleLastRunStatus, setScheduleLastRunStatus] = 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 [scheduleLastRunRebootRequired, setScheduleLastRunRebootRequired] = useState(false)
|
||||
const [scheduleLastRunRebootPackages, setScheduleLastRunRebootPackages] = useState<string[]>([])
|
||||
@@ -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 (
|
||||
<div className={`space-y-1.5 ${spacing}`}>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -2448,9 +2470,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</span></>
|
||||
)}
|
||||
</div>
|
||||
{scheduleLastRunReason && (
|
||||
{scheduleLastRunReasonText && (
|
||||
<div className="text-xs text-muted-foreground break-words">
|
||||
{scheduleLastRunReason}
|
||||
{scheduleLastRunReasonText}
|
||||
</div>
|
||||
)}
|
||||
{scheduleLastRunRebootRequired && (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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["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(
|
||||
|
||||
Reference in New Issue
Block a user