fix: preserve dated health results while update cache refreshes

This commit is contained in:
VAIO73
2026-10-28 17:30:44 +00:00
parent 43973e1d06
commit 889409de8d
6 changed files with 88 additions and 3 deletions
+29 -1
View File
@@ -128,6 +128,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([])
const [error, setError] = useState<string | null>(null)
const [snapshotUnavailable, setSnapshotUnavailable] = useState(false)
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
@@ -161,6 +162,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
let infoCount = 0
if (!response.ok) {
if (cachedOnly && response.status === 503) {
// Completion invalidates the snapshot before the collector refills it.
// Keep the dated result, explicitly unavailable; never start an audit.
setSnapshotUnavailable(true)
return
}
// Polling cannot fall back to a legacy endpoint that runs a full audit.
if (cachedOnly) throw new Error(t("healthStatus.errors.fetchFailed"))
// Fallback to legacy endpoint
@@ -168,6 +175,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
const data = await legacyResponse.json()
if (healthRequest.current !== controller) return
setSnapshotUnavailable(false)
setHealthData(data)
setDismissedItems([])
setCustomSuppressions([])
@@ -186,6 +194,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const fullData: FullHealthData = await response.json()
if (healthRequest.current !== controller) return
if (!fullData?.health?.details) throw new Error(t("healthStatus.errors.fetchFailed"))
setSnapshotUnavailable(false)
setHealthData(fullData.health)
setDismissedItems(fullData.dismissed || [])
setCustomSuppressions(fullData.custom_suppressions || [])
@@ -226,6 +235,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
window.dispatchEvent(event)
} catch (err) {
if (healthRequest.current === controller) {
setSnapshotUnavailable(false)
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
}
} finally {
@@ -684,6 +694,17 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
.replace(/\b\w/g, (c) => c.toUpperCase())
}
const translateHealthSummary = (snapshot: HealthDetails): string => {
if (snapshot.summary === "Updates") {
const reboot = snapshot.details?.updates?.checks?.reboot_required
if (reboot?.status === "INFO" && typeof reboot.detail === "string" && reboot.detail) {
return translateHealthText(reboot.detail)
}
return t("healthStatus.categories.updates")
}
return translateHealthText(snapshot.summary)
}
const renderChecks = (
checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>,
categoryKey: string
@@ -786,6 +807,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div>
)}
{snapshotUnavailable && (
<div role="status" className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5 text-xs text-amber-600 dark:text-amber-400">
<p>{t("healthStatus.snapshotUnavailable")}</p>
{healthData && <p className="mt-1">{t("healthStatus.lastKnownResults")}</p>}
</div>
)}
{healthData && !loading && (
<div className="space-y-4">
{/* Overall Stats Summary */}
@@ -822,7 +850,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{healthData.summary && healthData.summary !== "All systems operational" && (
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthText(healthData.summary)}</p>
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthSummary(healthData)}</p>
</div>
)}
+2
View File
@@ -5500,6 +5500,8 @@
"updateNow": "Update now",
"updateSystem": "Update system",
"packagePlanNote": "The plan also includes new packages, such as a new kernel. The overview counts updates to existing packages only.",
"snapshotUnavailable": "New health results are not available yet.",
"lastKnownResults": "Showing the last available results with their original check time.",
"dismissedItems": "Dismissed items ({count})",
"permanent": "Permanent",
"dismissed": "Dismissed",
+2
View File
@@ -5372,6 +5372,8 @@
"updateNow": "Aktualizovať teraz",
"updateSystem": "Aktualizovať systém",
"packagePlanNote": "Plán zahŕňa aj nové balíky, napríklad nové jadro. Prehľad počíta iba aktualizácie existujúcich balíkov.",
"snapshotUnavailable": "Nový výsledok kontroly zatiaľ nie je k dispozícii.",
"lastKnownResults": "Zobrazené sú posledné dostupné údaje s pôvodným časom kontroly.",
"dismissedItems": "Skryté upozornenia ({count})",
"permanent": "Natrvalo",
"dismissed": "Skryté",
+15
View File
@@ -272,5 +272,20 @@ for (const [locale, expectedText] of [
check(`prefixed diagnostic preserved ${locale}`, () => assert.equal(
render('Updates: ' + opaque, locale), translator(locale)('healthStatus.categories.updates') + ': ' + opaque));
}
let summaryMapper;
walk(source,n=>{if(ts.isVariableDeclaration(n)&&n.name.getText(source)==='translateHealthSummary')summaryMapper=n.initializer;});
assert.ok(summaryMapper,'actual summary mapper handles the real bare Updates result');
const summaryFactory=new Function('t',compile('const translateHealthText='+emit(mapper,source)+';const translateHealthSummary='+emit(summaryMapper,source))+';return translateHealthSummary;');
for(const locale of ['sk','en']){
const t=translator(locale), summary=summaryFactory(t);
const detail='Reboot required: running 7.0.14-25-pve; installed 7.0.14-26-pve';
const value={summary:'Updates',details:{updates:{checks:{reboot_required:{status:'INFO',detail}}}}};
const before=JSON.stringify(value);
assert.equal(summary(value),t('healthStatus.hostUpdate.rebootKernel',{running:'7.0.14-25-pve',installed:'7.0.14-26-pve'}));
assert.equal(JSON.stringify(value),before,'rendering never mutates the snapshot');
assert.equal(summary({summary:'Updates',details:{}}),t('healthStatus.categories.updates'));
assert.equal(summary({summary:'Updates',details:{updates:{checks:{reboot_required:{status:'UNKNOWN',detail}}}}}),t('healthStatus.categories.updates'),'unknown evidence cannot create a reboot-required summary');
assert.equal(summary({summary:'opaque diagnostic',details:{}}),'opaque diagnostic');
}
if (failures) throw new Error(`${failures} health-text assertions failed`);
console.log(`PASS: actual health mapper/provider; ${new Set(cases.map(row => row[1])).size} new keys, ${cases.length} message variants, ${Object.keys(catalogs).length} shipped locales and unconditional missing-key fallback`);
+22 -2
View File
@@ -19,7 +19,7 @@ const modal = source('health-status-modal.tsx', 'fetchHealthDetails', 'Recheck r
const header = source('proxmox-dashboard.tsx', 'fetchHealthInfoCount', 'Header reboot information')
const compile = (code, scope) => vm.runInNewContext(ts.transpileModule('('+code+')', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText, scope)
function environment() {
const calls = [], data = [], errors = [], counts = [], events = [], timers = new Map(), listeners = new Map()
const calls = [], data = [], errors = [], counts = [], events = [], availability = [], timers = new Map(), listeners = new Map()
let timerId = 0
const categories = [{key:'updates', category:'updates'}]
const scope = {AbortController, CustomEvent:class {constructor(type, values){this.type=type;this.detail=values.detail}},
@@ -27,11 +27,12 @@ function environment() {
CATEGORIES:categories, HEALTH_CATEGORY_KEYS:categories,
setHealthData:v=>data.push(v), setDismissedItems(){}, setCustomSuppressions(){},
setLoading(){}, setError:v=>errors.push(v), setInfoCount:v=>counts.push(v),
setSnapshotUnavailable:v=>availability.push(v),
setTimeout(fn, delay){timers.set(++timerId,{fn,delay});return timerId}, clearTimeout:id=>timers.delete(id),
window:{dispatchEvent:e=>events.push(e)},
fetch:async(p, opts)=>{calls.push({p,opts});return {ok:true,json:async()=>payload('INFO')}},
fetchApi:async(p, opts)=>{calls.push({p,opts});return payload('INFO')}}
return {scope,calls,data,errors,counts,events,timers,listeners}
return {scope,calls,data,errors,counts,events,availability,timers,listeners}
}
function payload(status='OK') {return {health:{overall:'OK',timestamp:'original',details:{updates:{status,count:0}}},dismissed:[],custom_suppressions:[]}}
async function callbacks() {
@@ -44,6 +45,25 @@ async function callbacks() {
assert.deepEqual(item===modal ? env.events[0].detail.infoCount : env.counts[0],1)
}
const env=environment(), run=compile(modal.callback,env.scope)
const pending=environment(), poll=compile(modal.callback,pending.scope)
pending.scope.fetch=async(p)=>{pending.calls.push({p});return {ok:false,status:503}}
await poll(false,true)
assert.deepEqual(pending.availability,[true],'expected cache gap is explicitly unavailable, not a red failure or healthy result')
assert.equal(pending.errors.filter(Boolean).length,0)
assert.equal(pending.data.length,0,'cache gap cannot erase or replace the last known data')
assert.deepEqual(pending.calls.map(x=>x.p),['/api/health/full?cached=1'],'no full/legacy fallback on cache miss')
pending.scope.fetch=async()=>({ok:true,json:async()=>payload('INFO')})
await poll(false,true)
assert.deepEqual(pending.availability,[true,false],'a real successful response clears the unavailable notice')
assert.equal(pending.data.length,1)
for(const status of [401,500]){
const failed=environment()
failed.scope.fetch=async()=>({ok:false,status})
await compile(modal.callback,failed.scope)(false,true)
assert.ok(failed.errors.filter(Boolean).length,'auth and server failures remain errors')
assert.equal(failed.data.length,0)
assert.ok(!failed.availability.includes(true),'real failures are not relabelled as a cache gap')
}
env.scope.fetch=async(p)=>{env.calls.push({p});return {ok:false}}
await run(false,true)
assert.deepEqual(env.calls.map(x=>x.p),['/api/health/full?cached=1'])
+18
View File
@@ -83,6 +83,24 @@ class FullHealth(unittest.TestCase):
self.owner.get_detailed_status.assert_not_called()
self.probe.assert_not_called()
def test_real_completion_finalizer_then_cached_poll_has_no_new_audit(self):
import sys
from types import ModuleType
node = next(n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == '_host_update_completed')
scope = {}
exec(compile(ast.Module(body=[node], type_ignores=[]), 'actual-finalizer', 'exec'), scope)
module = ModuleType('health_monitor')
module.health_monitor = self.owner
with patch.dict(sys.modules, {'health_monitor': module}):
scope['_host_update_completed']('/fixed-script', {}, 0, 164)
self.assertNotIn('_bg_detailed', self.owner.cached_results)
result, code = self.call('?cached=1')
self.assertEqual(code, 503)
self.assertIn('error', result)
self.owner.get_detailed_status.assert_not_called()
self.probe.assert_not_called()
def test_cached_only_just_before_boundary_is_fresh_reboot_without_full_check(self):
self.owner.last_check_times['_bg_detailed'] = self.now-359.999
result, code = self.call('?cached=1')