fix: refresh reboot evidence after external host updates

This commit is contained in:
VAIO73
2026-10-28 17:30:44 +00:00
parent f40e0a53e1
commit ac9565fa19
7 changed files with 434 additions and 21 deletions
+47 -11
View File
@@ -2,7 +2,7 @@
import type React from "react"
import { useState, useEffect, useCallback } from "react"
import { useState, useEffect, useCallback, useRef } from "react"
import { getAuthToken } from "@/lib/api-config"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
@@ -132,9 +132,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null)
const [hostJobs, setHostJobs] = useState<HostUpdateJob[]>([])
const healthRequest = useRef<AbortController | null>(null)
const previousAutoExpanded = useRef<Set<string>>(new Set())
const fetchHealthDetails = useCallback(async (force = false) => {
setLoading(true)
const fetchHealthDetails = useCallback(async (force = false, cachedOnly = false) => {
if (cachedOnly && healthRequest.current) return
healthRequest.current?.abort()
const controller = new AbortController()
healthRequest.current = controller
const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined
if (!cachedOnly) setLoading(true)
setError(null)
try {
@@ -147,14 +154,19 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
authHeaders["Authorization"] = `Bearer ${token}`
}
const response = await fetch(getApiUrl(force ? "/api/health/full?refresh=1" : "/api/health/full"), { headers: authHeaders })
const path = cachedOnly ? "/api/health/full?cached=1" : force ? "/api/health/full?refresh=1" : "/api/health/full"
const response = await fetch(getApiUrl(path), { headers: authHeaders, signal: controller.signal })
if (healthRequest.current !== controller) return
let infoCount = 0
if (!response.ok) {
// 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
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders, signal: controller.signal })
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
const data = await legacyResponse.json()
if (healthRequest.current !== controller) return
setHealthData(data)
setDismissedItems([])
setCustomSuppressions([])
@@ -171,6 +183,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}
} else {
const fullData: FullHealthData = await response.json()
if (healthRequest.current !== controller) return
if (!fullData?.health?.details) throw new Error(t("healthStatus.errors.fetchFailed"))
setHealthData(fullData.health)
setDismissedItems(fullData.dismissed || [])
setCustomSuppressions(fullData.custom_suppressions || [])
@@ -210,9 +224,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
})
window.dispatchEvent(event)
} catch (err) {
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
if (healthRequest.current === controller) {
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
}
} finally {
setLoading(false)
if (timeout !== undefined) clearTimeout(timeout)
if (healthRequest.current === controller) {
healthRequest.current = null
setLoading(false)
}
}
}, [getApiUrl, t])
@@ -228,9 +248,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
useEffect(() => {
if (open) {
fetchHealthDetails()
// Auto-refresh every 5 minutes while modal is open
const refreshInterval = setInterval(() => fetchHealthDetails(), 300000)
return () => clearInterval(refreshInterval)
// Recheck reboot evidence promptly without running APT/full health checks.
const refresh = () => {
if (document.visibilityState === "visible") void fetchHealthDetails(false, true)
}
const refreshInterval = setInterval(refresh, 30000)
window.addEventListener("focus", refresh)
document.addEventListener("visibilitychange", refresh)
return () => {
clearInterval(refreshInterval)
window.removeEventListener("focus", refresh)
document.removeEventListener("visibilitychange", refresh)
const controller = healthRequest.current
healthRequest.current = null
controller?.abort()
}
}
}, [open, fetchHealthDetails])
@@ -248,7 +280,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
nonOkCategories.add(key)
}
})
setExpandedCategories(nonOkCategories)
const newlyDegraded = [...nonOkCategories].filter(key => !previousAutoExpanded.current.has(key))
previousAutoExpanded.current = nonOkCategories
if (newlyDegraded.length) {
setExpandedCategories(previous => new Set([...previous, ...newlyDegraded]))
}
}
}, [healthData])
+28 -7
View File
@@ -1,6 +1,6 @@
"use client"
import React, { useState, useEffect, useMemo, useCallback } from "react"
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"
import useSWR from "swr"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
@@ -185,10 +185,18 @@ function MonitorDashboard({ product }: { product: Product }) {
}
}, [])
const healthRequest = useRef<AbortController | null>(null)
// Fetch health info count independently (for initial load and refresh)
const fetchHealthInfoCount = useCallback(async () => {
const fetchHealthInfoCount = useCallback(async (cachedOnly = false) => {
if (cachedOnly && healthRequest.current) return
healthRequest.current?.abort()
const controller = new AbortController()
healthRequest.current = controller
const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined
try {
const response = await fetchApi("/api/health/full")
const response = await fetchApi(cachedOnly ? "/api/health/full?cached=1" : "/api/health/full", { signal: controller.signal })
if (healthRequest.current !== controller) return
if (!response?.health?.details) throw new Error("Invalid health snapshot")
let calculatedInfoCount = 0
if (response && response.health?.details) {
@@ -218,7 +226,10 @@ function MonitorDashboard({ product }: { product: Product }) {
setInfoCount(calculatedInfoCount)
} catch (error) {
// Silently fail - infoCount will remain at 0
// Preserve the last known count; a failed poll is not a healthy result.
} finally {
if (timeout !== undefined) clearTimeout(timeout)
if (healthRequest.current === controller) healthRequest.current = null
}
}, [])
@@ -269,20 +280,29 @@ function MonitorDashboard({ product }: { product: Product }) {
fetchHealthInfoCount()
fetchUpdateStatus()
// Overview polls health every 30 s; the other tabs every 60 s.
// Header reboot information stays current on every tab, without full audits.
const refreshHealth = () => {
if (document.visibilityState === "visible") void fetchHealthInfoCount(true)
}
let interval: ReturnType<typeof setInterval> | null = null
let healthInterval: ReturnType<typeof setInterval> | null = null
if (activeTab === "overview") {
interval = setInterval(fetchSystemData, 30000) // 30 segundos
healthInterval = setInterval(fetchHealthInfoCount, 30000) // Also refresh info count
} else {
interval = setInterval(fetchSystemData, 60000) // 60 segundos
healthInterval = setInterval(fetchHealthInfoCount, 60000) // Also refresh info count
}
healthInterval = setInterval(refreshHealth, 30000)
window.addEventListener("focus", refreshHealth)
document.addEventListener("visibilitychange", refreshHealth)
return () => {
if (interval) clearInterval(interval)
if (healthInterval) clearInterval(healthInterval)
window.removeEventListener("focus", refreshHealth)
document.removeEventListener("visibilitychange", refreshHealth)
const controller = healthRequest.current
healthRequest.current = null
controller?.abort()
}
}, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab])
@@ -391,6 +411,7 @@ function MonitorDashboard({ product }: { product: Product }) {
const refreshData = async () => {
setIsRefreshing(true)
await fetchSystemData()
await fetchHealthInfoCount()
setComponentKey((prev) => prev + 1)
await new Promise((resolve) => setTimeout(resolve, 500))
setIsRefreshing(false)
+17 -3
View File
@@ -243,10 +243,14 @@ def get_full_health():
security before returning, so an event that just changed underlying state
(Update Now finished, dismiss action) sees the new value immediately
instead of waiting for the next polling tick.
?cached=1 is a strictly read-only poll: no full checks or cache clearing,
even with refresh=1. Unavailable/stale snapshots return 503. Every valid
response refreshes reboot evidence, while keeping the full audit's date.
"""
import time as _time
try:
if request.args.get('refresh') == '1':
cached_only = request.args.get('cached') == '1'
if not cached_only and request.args.get('refresh') == '1':
for ck in ('updates_check', 'pve_services', 'security_check',
'_bg_detailed', '_bg_overall', 'overall_health'):
health_monitor.last_check_times.pop(ck, None)
@@ -255,14 +259,24 @@ def get_full_health():
# Try to use the background-cached detailed result for instant response
bg_key = '_bg_detailed'
bg_last = health_monitor.last_check_times.get(bg_key, 0)
bg_age = _time.time() - bg_last
bg_age = _time.time() - bg_last if type(bg_last) in (int, float) else float('inf')
if bg_age < 360 and bg_key in health_monitor.cached_results:
if 0 <= bg_age < 360 and bg_key in health_monitor.cached_results:
# Use cached result (at most ~5 min old)
details = health_monitor.cached_results[bg_key]
elif cached_only:
# Automatic polling must never start APT or a full audit, even
# during startup, collector failure, expiry or a conflicting flag.
return jsonify({'error': 'Health snapshot is not available yet'}), 503
else:
# No fresh cache, run live (first load or cache expired)
details = health_monitor.get_detailed_status()
# Installed/running kernel state changes outside Monitor jobs too.
# Overlay fresh read-only evidence without refreshing other checks or
# rewriting the background collector's snapshot/timestamp.
from kernel_reboot import refresh_health_reboot
details = refresh_health_reboot(details)
active_errors = health_persistence.get_active_errors()
dismissed = health_persistence.get_dismissed_errors()
+37
View File
@@ -1,5 +1,6 @@
"""Read-only reboot evidence, independent of APT pending-update caches."""
import os
import copy
from pathlib import Path
import re
import subprocess
@@ -63,6 +64,13 @@ def with_reboot_status(updates, reboot):
return updates
result = dict(updates or dict(status='UNKNOWN', count=None, checks={}))
result['checks'] = dict(result.get('checks') or {})
# Keep the package result separate so a later reboot-only overlay can
# clear its own INFO/UNKNOWN without hiding a package-check failure.
base_status = result.get('package_status', result.get('status', 'UNKNOWN'))
if result.get('all_dismissed'):
base_status = 'OK'
result['package_status'] = base_status
result['status'] = base_status
required = reboot['state'] == 'required'
unknown = reboot['state'] == 'unknown'
result['reboot_required'] = True if required else None if unknown else False
@@ -77,3 +85,32 @@ def with_reboot_status(updates, reboot):
if result.get('status') == 'OK' and (required or unknown):
result['status'] = 'INFO' if required else 'UNKNOWN'
return result
def refresh_health_reboot(snapshot):
"""Refresh only reboot evidence on an immutable, dated health snapshot.
Never runs the full health check, APT, persistence writes or lifecycle
commands. Other categories and the audit timestamp retain their age.
"""
result = copy.deepcopy(snapshot)
details = result.get('details')
if not isinstance(details, dict) or not isinstance(details.get('updates'), dict):
raise ValueError('Health snapshot has no updates result')
details['updates'] = with_reboot_status(details['updates'], reboot_status())
if details['updates']['status'] == snapshot['details']['updates']['status']:
return result
issues = {status: [] for status in ('CRITICAL', 'WARNING', 'UNKNOWN', 'INFO')}
for key, block in details.items():
if not isinstance(block, dict):
continue
status = block.get('status', 'UNKNOWN')
if status in issues:
label = key.replace('_', ' ').capitalize()
reason = block.get('reason')
issues[status].append(f'{label}: {reason}' if reason else label)
result['overall'] = ('CRITICAL' if issues['CRITICAL'] else 'WARNING'
if issues['WARNING'] or issues['UNKNOWN'] else 'OK')
primary = next((values for values in issues.values() if values), [])
result['summary'] = '; '.join(primary) if primary else 'All systems operational'
return result