fix: keep reboot actions blocked during health recovery

This commit is contained in:
VAIO73
2026-10-28 17:30:44 +00:00
parent ba3fb1197f
commit 9147ec2f52
5 changed files with 67 additions and 13 deletions
+1
View File
@@ -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",
+4 -3
View File
@@ -720,6 +720,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
.map(([checkKey, checkData]) => {
if (categoryKey === "updates" && checkKey === "reboot_required" && checkData.status === "INFO") {
return <RestartAfterUpdate key={checkKey} getApiUrl={getApiUrl}
unavailable={loading || snapshotUnavailable || !!error}
disabled={hostJobs.some(job => 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
</div>
)}
{healthData && !loading && (
<div className="space-y-4">
{healthData && (
<fieldset disabled={loading} className="space-y-4 min-w-0">
{/* Overall Stats Summary */}
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
<div className="text-center">
@@ -1069,7 +1070,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
</div>
)}
</div>
</fieldset>
)}
</DialogContent>
<HostUpdateResult jobId={resultJobId} getApiUrl={getApiUrl} onClose={() => setResultJobId(null)} />
+8 -7
View File
@@ -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<AbortController | null>(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 }:
<div className="flex items-start gap-2 flex-1 min-w-0 text-[10px] sm:text-xs"><span className="shrink-0">{icon}</span>
<div><span className="font-medium mr-2">{label}</span><span className="text-muted-foreground break-words">{detail}</span></div></div>
<Button variant="outline" className={`${compact} self-start sm:shrink-0 border-amber-500/40 text-amber-500`}
disabled={disabled || pending || accepted} onClick={() => setOpen(true)}>{t("healthStatus.restart.button")}</Button>
disabled={disabled || unavailable || !context?.allowed || pending || accepted} onClick={() => setOpen(true)}>{t("healthStatus.restart.button")}</Button>
</div>
{disabled && <p className="mt-2 text-xs text-muted-foreground">{t("healthStatus.restart.blocked")}</p>}
{accepted && <p role="status" className="mt-2 text-xs text-blue-500">{t("healthStatus.restart.accepted")}</p>}
@@ -103,7 +104,7 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }:
{(error || context?.reason === "unknown") && <p role="alert" className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
<div className="mt-5 flex justify-end gap-2.5">
<Confirm.Cancel asChild><Button variant="outline" className={compact} disabled={pending}>{t("healthStatus.restart.cancel")}</Button></Confirm.Cancel>
<Confirm.Action asChild><Button variant="destructive" className={compact} disabled={disabled || !context?.allowed || pending || submitted}
<Confirm.Action asChild><Button variant="destructive" className={compact} disabled={disabled || unavailable || !context?.allowed || pending || submitted}
onClick={event => { event.preventDefault(); void confirm() }}>{t("healthStatus.restart.confirm")}</Button></Confirm.Action>
</div>
</Confirm.Content>
+3 -3
View File
@@ -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
+51
View File
@@ -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('<fieldset disabled={loading}'), 'keeping old results mounted must not enable other actions during refresh')
console.log('PASS reboot recovery: closed-context probe, remount/current/offline/stale/pending guards, no POST or audit')
}
main().catch(e => {console.error(e); process.exitCode = 1})