diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index 3b27172c..41fa911c 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -128,6 +128,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const [dismissedItems, setDismissedItems] = useState([]) const [customSuppressions, setCustomSuppressions] = useState([]) const [error, setError] = useState(null) + const [snapshotUnavailable, setSnapshotUnavailable] = useState(false) const [dismissingKey, setDismissingKey] = useState(null) const [expandedCategories, setExpandedCategories] = useState>(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, categoryKey: string @@ -786,6 +807,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu )} + {snapshotUnavailable && ( +
+

{t("healthStatus.snapshotUnavailable")}

+ {healthData &&

{t("healthStatus.lastKnownResults")}

} +
+ )} + {healthData && !loading && (
{/* Overall Stats Summary */} @@ -822,7 +850,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {healthData.summary && healthData.summary !== "All systems operational" && (
-

{translateHealthText(healthData.summary)}

+

{translateHealthSummary(healthData)}

)} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index f35857dc..90ae8de3 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -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", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 84bfb9bc..93cf9bc7 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -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é", diff --git a/tests/test_health_text.cjs b/tests/test_health_text.cjs index 34bf9d81..c5156143 100644 --- a/tests/test_health_text.cjs +++ b/tests/test_health_text.cjs @@ -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`); diff --git a/tests/test_reboot_health_refresh.cjs b/tests/test_reboot_health_refresh.cjs index 318ef654..52f5b901 100644 --- a/tests/test_reboot_health_refresh.cjs +++ b/tests/test_reboot_health_refresh.cjs @@ -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']) diff --git a/tests/test_reboot_health_refresh.py b/tests/test_reboot_health_refresh.py index 2a046066..84d24b34 100644 --- a/tests/test_reboot_health_refresh.py +++ b/tests/test_reboot_health_refresh.py @@ -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')