From 9147ec2f52426cd83dec520b226295623bc9bd18 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:08:45 +0000 Subject: [PATCH] fix: keep reboot actions blocked during health recovery --- .github/scripts/run_offline_qualified.py | 1 + AppImage/components/health-status-modal.tsx | 7 +-- AppImage/components/host-update-status.tsx | 15 +++--- tests/test_host_update_controls.cjs | 6 +-- tests/test_reboot_recovery.cjs | 51 +++++++++++++++++++++ 5 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 tests/test_reboot_recovery.cjs diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 2af5a61b..580fb866 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -46,6 +46,7 @@ NODE_FILES = ( "tests/test_host_update_ui.cjs", "tests/test_reboot_health_refresh.cjs", "tests/test_host_update_controls.cjs", + "tests/test_reboot_recovery.cjs", "tests/test_lxc_update_jobs.cjs", "tests/test_api_import_boundaries.cjs", "tests/test_api_errors.cjs", diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index b554e18e..f4f90bc3 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -720,6 +720,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu .map(([checkKey, checkData]) => { if (categoryKey === "updates" && checkKey === "reboot_required" && checkData.status === "INFO") { return job.state !== "complete")} label={formatCheckLabel(checkKey)} detail={translateHealthText(checkData.detail)} icon={getStatusIcon(checkData.status, "sm")} /> } @@ -817,8 +818,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu )} - {healthData && !loading && ( -
+ {healthData && ( +
{/* Overall Stats Summary */}
0 ? "grid-cols-5" : "grid-cols-4"}`}>
@@ -1069,7 +1070,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
)} -
+
)} setResultJobId(null)} /> diff --git a/AppImage/components/host-update-status.tsx b/AppImage/components/host-update-status.tsx index 20693da3..6b92db4a 100644 --- a/AppImage/components/host-update-status.tsx +++ b/AppImage/components/host-update-status.tsx @@ -14,8 +14,8 @@ type Url = (path: string) => string type Context = { hostname: string; boot_id: string; allowed: boolean; reason: string } const compact = "h-10 sm:h-[34px] px-3 text-xs" -export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: { - getApiUrl: Url; disabled: boolean; label: string; detail: string; icon: ReactNode +export function RestartAfterUpdate({ getApiUrl, disabled, unavailable = false, label, detail, icon }: { + getApiUrl: Url; disabled: boolean; unavailable?: boolean; label: string; detail: string; icon: ReactNode }) { const t = useT() const [open, setOpen] = useState(false) @@ -29,7 +29,6 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: const postRequest = useRef(null) useEffect(() => { alive.current = true; return () => { alive.current = false; postRequest.current?.abort() } }, []) useEffect(() => { - if (!open) return let disposed = false, reading = false let active: AbortController | undefined setContext(null); setSubmitted(false); setError(false); sending.current = false @@ -48,7 +47,9 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: finally { clearTimeout(timeout); reading = false } } void read() - const interval = setInterval(read, 2000) + // A retained health snapshot must never grant permission after reconnect + // or remount. This cheap read does not run APT or a full health check. + const interval = setInterval(read, open ? 2000 : 30000) return () => { disposed = true; clearInterval(interval); active?.abort() } }, [open, getApiUrl]) useEffect(() => { @@ -70,7 +71,7 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: return () => { disposed = true; controller.abort(); clearInterval(interval); clearTimeout(timeout) } }, [accepted, getApiUrl]) const confirm = async () => { - if (disabled || !context?.allowed || sending.current || submitted) return + if (disabled || unavailable || !context?.allowed || sending.current || submitted) return sending.current = true; setSubmitted(true); setPending(true); setError(false) const controller = new AbortController(); postRequest.current = controller const timeout = setTimeout(() => controller.abort(), 15000) @@ -88,7 +89,7 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }:
{icon}
{label}{detail}
+ disabled={disabled || unavailable || !context?.allowed || pending || accepted} onClick={() => setOpen(true)}>{t("healthStatus.restart.button")}
{disabled &&

{t("healthStatus.restart.blocked")}

} {accepted &&

{t("healthStatus.restart.accepted")}

} @@ -103,7 +104,7 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: {(error || context?.reason === "unknown") &&

{t("healthStatus.restart.unconfirmed")}

}
-
diff --git a/tests/test_host_update_controls.cjs b/tests/test_host_update_controls.cjs index 460c5816..b877ed21 100644 --- a/tests/test_host_update_controls.cjs +++ b/tests/test_host_update_controls.cjs @@ -13,7 +13,7 @@ function walk(n){ walk(tree);assert.ok(callback) function fixture(){ const calls=[],events=[] - const scope={AbortController,JSON,Error,disabled:false,context:{allowed:true,hostname:'fixture-host',boot_id:'fixture-boot'},submitted:false, + const scope={AbortController,JSON,Error,disabled:false,unavailable:false,context:{allowed:true,hostname:'fixture-host',boot_id:'fixture-boot'},submitted:false, sending:{current:false},alive:{current:true},postRequest:{current:null},getApiUrl:p=>p,headers:()=>({}), setSubmitted:v=>events.push(['submitted',v]),setPending:v=>events.push(['pending',v]),setError:v=>events.push(['error',v]), setAccepted:v=>events.push(['accepted',v]),setOpen:v=>events.push(['open',v]), @@ -23,8 +23,8 @@ function fixture(){ } async function main(){ assert.ok(triggerDisabled) - assert.equal(vm.runInNewContext(triggerDisabled,{disabled:false,accepted:false,pending:true}),true,'closing confirmation while its request is pending must not enable another request') - for(const block of ['disabled','submitted','sending','missing-context','not-allowed']){ + assert.equal(vm.runInNewContext(triggerDisabled,{disabled:false,unavailable:false,context:{allowed:true},accepted:false,pending:true}),true,'closing confirmation while its request is pending must not enable another request') + for(const block of ['disabled','unavailable','submitted','sending','missing-context','not-allowed']){ const x=fixture() if(block==='sending')x.scope.sending.current=true else if(block==='missing-context')x.scope.context=null diff --git a/tests/test_reboot_recovery.cjs b/tests/test_reboot_recovery.cjs new file mode 100644 index 00000000..2fe00d7f --- /dev/null +++ b/tests/test_reboot_recovery.cjs @@ -0,0 +1,51 @@ +// The retained health snapshot is informational, never permission to reboot. +const assert = require('node:assert/strict') +const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm') +const app = path.resolve(__dirname, '../AppImage') +const ts = require(path.join(app, 'node_modules/typescript')) +const source = fs.readFileSync(path.join(app, 'components/host-update-status.tsx'), 'utf8') +const tree = ts.createSourceFile('restart.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) +let trigger, contextEffect +function walk(n) { + if (ts.isJsxElement(n) && n.openingElement.tagName.getText(tree) === 'Button' && n.children.some(c => c.getText(tree).includes('healthStatus.restart.button'))) { + trigger = n.openingElement.attributes.properties.find(p => p.name?.getText(tree) === 'disabled').initializer.expression.getText(tree) + } + if (ts.isCallExpression(n) && n.expression.getText(tree) === 'useEffect' && n.arguments[0].getText(tree).includes('/api/health/reboot-context')) contextEffect = n.arguments[0].getText(tree) + ts.forEachChild(n, walk) +} +walk(tree) +const evaluate = (code, scope) => vm.runInNewContext(ts.transpileModule('(' + code + ')', {compilerOptions: {target: ts.ScriptTarget.ES2020}}).outputText, scope) +function enabled(overrides = {}) { + return !evaluate(trigger, {disabled: false, unavailable: false, context: {allowed: true}, pending: false, accepted: false, ...overrides}) +} +async function main() { + assert.equal(enabled({context: null}), false, 'remount after loading/reboot cannot enable a retained button before a fresh context') + for (const reason of ['current', 'busy', 'unknown']) assert.equal(enabled({context: {allowed: false, reason}}), false) + for (const key of ['disabled', 'unavailable', 'pending', 'accepted']) assert.equal(enabled({[key]: true}), false, key) + assert.equal(enabled(), true, 'fresh required context permits an explicit confirmation') + const calls = [], contexts = [], intervals = new Map(), timers = new Map() + let seq = 0 + const scope = {open: false, getApiUrl: p => p, headers: () => ({}), AbortController, + setContext: x => contexts.push(x), setSubmitted() {}, setError() {}, sending: {current: false}, + setTimeout: f => {timers.set(++seq, f); return seq}, clearTimeout: id => timers.delete(id), + setInterval: (f, delay) => {intervals.set(++seq, {f, delay}); return seq}, clearInterval: id => intervals.delete(id), + fetch: async (p, opts) => {calls.push({p, opts}); return {ok: true, json: async () => ({hostname: 'fixture', boot_id: 'new-boot', allowed: false, reason: 'current'})}}} + const cleanup = evaluate(contextEffect, scope)() + await new Promise(setImmediate) + assert.equal(calls.length, 1, 'read the real reboot context even while confirmation is closed') + assert.equal(calls[0].p, '/api/health/reboot-context') + assert.ok(!calls[0].opts.method || calls[0].opts.method === 'GET') + assert.equal(contexts.at(-1).allowed, false) + assert.equal([...intervals.values()][0].delay, 30000, 'closed confirmation uses a bounded cheap probe') + scope.fetch = async () => {throw Error('offline')} + await [...intervals.values()][0].f() + assert.equal(contexts.at(-1), null, 'connection loss revokes old permission') + cleanup() + assert.equal(intervals.size, 0); assert.equal(timers.size, 0) + const modal = fs.readFileSync(path.join(app, 'components/health-status-modal.tsx'), 'utf8') + assert.ok(modal.includes('unavailable={loading || snapshotUnavailable || !!error}'), 'stale/error/refresh snapshots must disable the action') + assert.ok(!modal.includes('{healthData && !loading && ('), 'refresh must not unmount and forget an accepted reboot') + assert.ok(modal.includes('
{console.error(e); process.exitCode = 1})