mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
fix: refresh reboot evidence after external host updates
This commit is contained in:
@@ -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])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user