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:
@@ -18,6 +18,7 @@ PYTHON_FILES = (
|
|||||||
"tests/test_host_update_shell.py",
|
"tests/test_host_update_shell.py",
|
||||||
"tests/test_host_update_auth.py",
|
"tests/test_host_update_auth.py",
|
||||||
"tests/test_kernel_reboot.py",
|
"tests/test_kernel_reboot.py",
|
||||||
|
"tests/test_reboot_health_refresh.py",
|
||||||
"tests/test_lxc_update_jobs.py",
|
"tests/test_lxc_update_jobs.py",
|
||||||
"tests/test_post_install_i18n.py",
|
"tests/test_post_install_i18n.py",
|
||||||
"tests/test_remaining_cli_i18n.py",
|
"tests/test_remaining_cli_i18n.py",
|
||||||
@@ -40,6 +41,7 @@ PYTHON_FILES = (
|
|||||||
)
|
)
|
||||||
NODE_FILES = (
|
NODE_FILES = (
|
||||||
"tests/test_host_update_ui.cjs",
|
"tests/test_host_update_ui.cjs",
|
||||||
|
"tests/test_reboot_health_refresh.cjs",
|
||||||
"tests/test_lxc_update_jobs.cjs",
|
"tests/test_lxc_update_jobs.cjs",
|
||||||
"tests/test_api_import_boundaries.cjs",
|
"tests/test_api_import_boundaries.cjs",
|
||||||
"tests/test_api_errors.cjs",
|
"tests/test_api_errors.cjs",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type React from "react"
|
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 { getAuthToken } from "@/lib/api-config"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -132,9 +132,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
|
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
|
||||||
const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null)
|
const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null)
|
||||||
const [hostJobs, setHostJobs] = useState<HostUpdateJob[]>([])
|
const [hostJobs, setHostJobs] = useState<HostUpdateJob[]>([])
|
||||||
|
const healthRequest = useRef<AbortController | null>(null)
|
||||||
|
const previousAutoExpanded = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
const fetchHealthDetails = useCallback(async (force = false) => {
|
const fetchHealthDetails = useCallback(async (force = false, cachedOnly = false) => {
|
||||||
setLoading(true)
|
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)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -147,14 +154,19 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
authHeaders["Authorization"] = `Bearer ${token}`
|
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
|
let infoCount = 0
|
||||||
|
|
||||||
if (!response.ok) {
|
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
|
// 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"))
|
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||||
const data = await legacyResponse.json()
|
const data = await legacyResponse.json()
|
||||||
|
if (healthRequest.current !== controller) return
|
||||||
setHealthData(data)
|
setHealthData(data)
|
||||||
setDismissedItems([])
|
setDismissedItems([])
|
||||||
setCustomSuppressions([])
|
setCustomSuppressions([])
|
||||||
@@ -171,6 +183,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const fullData: FullHealthData = await response.json()
|
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)
|
setHealthData(fullData.health)
|
||||||
setDismissedItems(fullData.dismissed || [])
|
setDismissedItems(fullData.dismissed || [])
|
||||||
setCustomSuppressions(fullData.custom_suppressions || [])
|
setCustomSuppressions(fullData.custom_suppressions || [])
|
||||||
@@ -210,10 +224,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
})
|
})
|
||||||
window.dispatchEvent(event)
|
window.dispatchEvent(event)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (healthRequest.current === controller) {
|
||||||
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
|
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (timeout !== undefined) clearTimeout(timeout)
|
||||||
|
if (healthRequest.current === controller) {
|
||||||
|
healthRequest.current = null
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [getApiUrl, t])
|
}, [getApiUrl, t])
|
||||||
|
|
||||||
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
||||||
@@ -228,9 +248,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
fetchHealthDetails()
|
fetchHealthDetails()
|
||||||
// Auto-refresh every 5 minutes while modal is open
|
// Recheck reboot evidence promptly without running APT/full health checks.
|
||||||
const refreshInterval = setInterval(() => fetchHealthDetails(), 300000)
|
const refresh = () => {
|
||||||
return () => clearInterval(refreshInterval)
|
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])
|
}, [open, fetchHealthDetails])
|
||||||
|
|
||||||
@@ -248,7 +280,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
nonOkCategories.add(key)
|
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])
|
}, [healthData])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from "react"
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"
|
||||||
import useSWR from "swr"
|
import useSWR from "swr"
|
||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
import { Button } from "./ui/button"
|
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)
|
// 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 {
|
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
|
let calculatedInfoCount = 0
|
||||||
|
|
||||||
if (response && response.health?.details) {
|
if (response && response.health?.details) {
|
||||||
@@ -218,7 +226,10 @@ function MonitorDashboard({ product }: { product: Product }) {
|
|||||||
|
|
||||||
setInfoCount(calculatedInfoCount)
|
setInfoCount(calculatedInfoCount)
|
||||||
} catch (error) {
|
} 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()
|
fetchHealthInfoCount()
|
||||||
fetchUpdateStatus()
|
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 interval: ReturnType<typeof setInterval> | null = null
|
||||||
let healthInterval: ReturnType<typeof setInterval> | null = null
|
let healthInterval: ReturnType<typeof setInterval> | null = null
|
||||||
if (activeTab === "overview") {
|
if (activeTab === "overview") {
|
||||||
interval = setInterval(fetchSystemData, 30000) // 30 segundos
|
interval = setInterval(fetchSystemData, 30000) // 30 segundos
|
||||||
healthInterval = setInterval(fetchHealthInfoCount, 30000) // Also refresh info count
|
|
||||||
} else {
|
} else {
|
||||||
interval = setInterval(fetchSystemData, 60000) // 60 segundos
|
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 () => {
|
return () => {
|
||||||
if (interval) clearInterval(interval)
|
if (interval) clearInterval(interval)
|
||||||
if (healthInterval) clearInterval(healthInterval)
|
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])
|
}, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab])
|
||||||
|
|
||||||
@@ -391,6 +411,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
|||||||
const refreshData = async () => {
|
const refreshData = async () => {
|
||||||
setIsRefreshing(true)
|
setIsRefreshing(true)
|
||||||
await fetchSystemData()
|
await fetchSystemData()
|
||||||
|
await fetchHealthInfoCount()
|
||||||
setComponentKey((prev) => prev + 1)
|
setComponentKey((prev) => prev + 1)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
|
|||||||
@@ -243,10 +243,14 @@ def get_full_health():
|
|||||||
security before returning, so an event that just changed underlying state
|
security before returning, so an event that just changed underlying state
|
||||||
(Update Now finished, dismiss action) sees the new value immediately
|
(Update Now finished, dismiss action) sees the new value immediately
|
||||||
instead of waiting for the next polling tick.
|
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
|
import time as _time
|
||||||
try:
|
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',
|
for ck in ('updates_check', 'pve_services', 'security_check',
|
||||||
'_bg_detailed', '_bg_overall', 'overall_health'):
|
'_bg_detailed', '_bg_overall', 'overall_health'):
|
||||||
health_monitor.last_check_times.pop(ck, None)
|
health_monitor.last_check_times.pop(ck, None)
|
||||||
@@ -255,15 +259,25 @@ def get_full_health():
|
|||||||
# Try to use the background-cached detailed result for instant response
|
# Try to use the background-cached detailed result for instant response
|
||||||
bg_key = '_bg_detailed'
|
bg_key = '_bg_detailed'
|
||||||
bg_last = health_monitor.last_check_times.get(bg_key, 0)
|
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)
|
# Use cached result (at most ~5 min old)
|
||||||
details = health_monitor.cached_results[bg_key]
|
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:
|
else:
|
||||||
# No fresh cache, run live (first load or cache expired)
|
# No fresh cache, run live (first load or cache expired)
|
||||||
details = health_monitor.get_detailed_status()
|
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()
|
active_errors = health_persistence.get_active_errors()
|
||||||
dismissed = health_persistence.get_dismissed_errors()
|
dismissed = health_persistence.get_dismissed_errors()
|
||||||
custom_suppressions = health_persistence.get_custom_suppressions()
|
custom_suppressions = health_persistence.get_custom_suppressions()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Read-only reboot evidence, independent of APT pending-update caches."""
|
"""Read-only reboot evidence, independent of APT pending-update caches."""
|
||||||
import os
|
import os
|
||||||
|
import copy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -63,6 +64,13 @@ def with_reboot_status(updates, reboot):
|
|||||||
return updates
|
return updates
|
||||||
result = dict(updates or dict(status='UNKNOWN', count=None, checks={}))
|
result = dict(updates or dict(status='UNKNOWN', count=None, checks={}))
|
||||||
result['checks'] = dict(result.get('checks') or {})
|
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'
|
required = reboot['state'] == 'required'
|
||||||
unknown = reboot['state'] == 'unknown'
|
unknown = reboot['state'] == 'unknown'
|
||||||
result['reboot_required'] = True if required else None if unknown else False
|
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):
|
if result.get('status') == 'OK' and (required or unknown):
|
||||||
result['status'] = 'INFO' if required else 'UNKNOWN'
|
result['status'] = 'INFO' if required else 'UNKNOWN'
|
||||||
return result
|
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
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Execute actual TS callbacks/effects with controlled transport and clocks.
|
||||||
|
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'))
|
||||||
|
function source(file, name, effectText) {
|
||||||
|
const tree = ts.createSourceFile(file, fs.readFileSync(path.join(app, 'components', file), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
let callback, effect
|
||||||
|
function walk(n) {
|
||||||
|
if (ts.isVariableDeclaration(n) && n.name.getText(tree) === name) callback = n.initializer.arguments[0].getText(tree)
|
||||||
|
if (ts.isCallExpression(n) && n.expression.getText(tree) === 'useEffect' && n.arguments[0].getText(tree).includes(effectText)) effect = n.arguments[0].getText(tree)
|
||||||
|
ts.forEachChild(n, walk)
|
||||||
|
}
|
||||||
|
walk(tree)
|
||||||
|
assert.ok(callback && effect)
|
||||||
|
return {callback, effect}
|
||||||
|
}
|
||||||
|
const modal = source('health-status-modal.tsx', 'fetchHealthDetails', 'Recheck reboot evidence')
|
||||||
|
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()
|
||||||
|
let timerId = 0
|
||||||
|
const categories = [{key:'updates', category:'updates'}]
|
||||||
|
const scope = {AbortController, CustomEvent:class {constructor(type, values){this.type=type;this.detail=values.detail}},
|
||||||
|
healthRequest:{current:null}, getAuthToken:()=>null, getApiUrl:p=>p, t:s=>s,
|
||||||
|
CATEGORIES:categories, HEALTH_CATEGORY_KEYS:categories,
|
||||||
|
setHealthData:v=>data.push(v), setDismissedItems(){}, setCustomSuppressions(){},
|
||||||
|
setLoading(){}, setError:v=>errors.push(v), setInfoCount:v=>counts.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}
|
||||||
|
}
|
||||||
|
function payload(status='OK') {return {health:{overall:'OK',timestamp:'original',details:{updates:{status,count:0}}},dismissed:[],custom_suppressions:[]}}
|
||||||
|
async function callbacks() {
|
||||||
|
for (const [item, args] of [[modal,[false,true]],[header,[true]]]) {
|
||||||
|
const env=environment(), run=compile(item.callback,env.scope)
|
||||||
|
await run(...args)
|
||||||
|
assert.equal(env.calls[0].p,'/api/health/full?cached=1')
|
||||||
|
assert.equal(env.scope.healthRequest.current,null)
|
||||||
|
assert.equal(env.timers.size,0)
|
||||||
|
assert.deepEqual(item===modal ? env.events[0].detail.infoCount : env.counts[0],1)
|
||||||
|
}
|
||||||
|
const env=environment(), run=compile(modal.callback,env.scope)
|
||||||
|
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'])
|
||||||
|
assert.equal(env.data.length,0)
|
||||||
|
assert.ok(env.errors.includes('healthStatus.errors.fetchFailed'))
|
||||||
|
const malformed=environment()
|
||||||
|
malformed.scope.fetch=async()=>({ok:true,json:async()=>({})})
|
||||||
|
await compile(modal.callback,malformed.scope)(false,true)
|
||||||
|
assert.equal(malformed.data.length,0)
|
||||||
|
assert.ok(malformed.errors.includes('healthStatus.errors.fetchFailed'))
|
||||||
|
const h=environment()
|
||||||
|
h.scope.fetchApi=async()=>({})
|
||||||
|
await compile(header.callback,h.scope)(true)
|
||||||
|
assert.equal(h.counts.length,0,'malformed data must not replace the previous count with zero')
|
||||||
|
}
|
||||||
|
async function races() {
|
||||||
|
for (const [item, args, forceArgs] of [[modal,[false,true],[true]],[header,[true],[]]]) {
|
||||||
|
const e=environment(), pending=[]
|
||||||
|
if(item===modal) e.scope.fetch=(p,opts)=>{e.calls.push({p,opts});return new Promise(resolve=>pending.push(resolve))}
|
||||||
|
else e.scope.fetchApi=(p,opts)=>{e.calls.push({p,opts});return new Promise(resolve=>pending.push(resolve))}
|
||||||
|
const run=compile(item.callback,e.scope), first=run(...args)
|
||||||
|
await run(...args)
|
||||||
|
assert.equal(e.calls.length,1,'overlapping polls are coalesced')
|
||||||
|
const second=run(...forceArgs)
|
||||||
|
assert.ok(e.calls[0].opts.signal.aborted,'explicit refresh supersedes old poll')
|
||||||
|
pending[1](item===modal ? {ok:true,json:async()=>payload('INFO')} : payload('INFO'))
|
||||||
|
await second
|
||||||
|
pending[0](item===modal ? {ok:true,json:async()=>payload('OK')} : payload('OK'))
|
||||||
|
await first
|
||||||
|
assert.equal(item===modal ? e.data.length : e.counts.length,1,'late response cannot overwrite new data')
|
||||||
|
assert.equal(item===modal ? e.events[0].detail.infoCount : e.counts[0],1)
|
||||||
|
assert.equal(e.timers.size,0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function effects(item, activeTab='overview') {
|
||||||
|
const e=environment(), intervals=new Map(), listeners=new Map(), polls=[]
|
||||||
|
let timerId=0
|
||||||
|
e.scope.open=true; e.scope.activeTab=activeTab
|
||||||
|
e.scope.fetchHealthDetails=(...args)=>polls.push(args)
|
||||||
|
e.scope.fetchHealthInfoCount=(...args)=>polls.push(args)
|
||||||
|
e.scope.fetchSystemData=()=>{}; e.scope.fetchUpdateStatus=()=>{}
|
||||||
|
e.scope.setInterval=(fn,delay)=>{intervals.set(++timerId,{fn,delay});return timerId}
|
||||||
|
e.scope.clearInterval=id=>intervals.delete(id)
|
||||||
|
const target = name=>({addEventListener:(type,fn)=>listeners.set(name+type,fn),removeEventListener:(type,fn)=>{assert.equal(listeners.get(name+type),fn);listeners.delete(name+type)}})
|
||||||
|
Object.assign(e.scope.window,target('window'))
|
||||||
|
e.scope.document={visibilityState:'visible',...target('document')}
|
||||||
|
const cleanup=compile(item.effect,e.scope)()
|
||||||
|
const interval=[...intervals.values()].find(x=>x.delay===30000 && x.fn.toString().includes('fetchHealth'))
|
||||||
|
assert.ok(interval,'every tab polls health at 30 seconds')
|
||||||
|
assert.equal(polls.length,1,'initial full request retained')
|
||||||
|
interval.fn(); listeners.get('windowfocus')(); listeners.get('documentvisibilitychange')()
|
||||||
|
assert.equal(polls.length,4)
|
||||||
|
for(const args of polls.slice(1))assert.deepEqual(args,item===modal?[false,true]:[true])
|
||||||
|
e.scope.document.visibilityState='hidden'
|
||||||
|
interval.fn();listeners.get('documentvisibilitychange')()
|
||||||
|
assert.equal(polls.length,4,'hidden document does not poll')
|
||||||
|
const controller=new AbortController();e.scope.healthRequest.current=controller
|
||||||
|
cleanup()
|
||||||
|
assert.ok(controller.signal.aborted)
|
||||||
|
assert.equal(e.scope.healthRequest.current,null)
|
||||||
|
assert.equal(intervals.size,0)
|
||||||
|
assert.equal(listeners.size,0)
|
||||||
|
}
|
||||||
|
function expansion() {
|
||||||
|
const text=fs.readFileSync(path.join(app,'components/health-status-modal.tsx'),'utf8')
|
||||||
|
const tree=ts.createSourceFile('modal.tsx',text,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX)
|
||||||
|
let code
|
||||||
|
function walk(n) {
|
||||||
|
if(ts.isCallExpression(n)&&n.expression.getText(tree)==='useEffect'&&n.arguments[0].getText(tree).includes('newlyDegraded'))code=n.arguments[0].getText(tree)
|
||||||
|
ts.forEachChild(n,walk)
|
||||||
|
}
|
||||||
|
walk(tree);assert.ok(code)
|
||||||
|
let expanded=new Set(['updates'])
|
||||||
|
const scope={Set,CATEGORIES:[{key:'updates'},{key:'cpu'}],previousAutoExpanded:{current:new Set()},
|
||||||
|
healthData:{details:{updates:{status:'INFO'},cpu:{status:'OK'}}},setExpandedCategories:fn=>{expanded=fn(expanded)}}
|
||||||
|
const effect=compile(code,scope)
|
||||||
|
effect();assert.ok(expanded.has('updates'),'poll must preserve manually opened updates')
|
||||||
|
scope.healthData.details.cpu.status='WARNING';effect()
|
||||||
|
assert.ok(expanded.has('cpu'),'new warnings may expand')
|
||||||
|
expanded.delete('cpu');effect()
|
||||||
|
assert.ok(!expanded.has('cpu'),'poll must preserve user collapsing a known warning')
|
||||||
|
}
|
||||||
|
async function main() {await callbacks();await races();effects(modal);effects(header);effects(header,'settings');expansion();console.log('PASS actual health callbacks: polling, focus, errors, no fallback, races, expansion and cleanup')}
|
||||||
|
main().catch(e=>{console.error(e);process.exitCode=1})
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Actual full-health route with inert collaborators; no APT or host actions."""
|
||||||
|
import ast
|
||||||
|
import copy
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
|
||||||
|
import kernel_reboot as kernel
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot():
|
||||||
|
return dict(overall='OK', summary='All systems operational', timestamp='original', details={
|
||||||
|
'cpu': dict(status='OK', checks={}),
|
||||||
|
'updates': dict(status='OK', count=0, checks={'pending_updates': dict(status='OK')})})
|
||||||
|
|
||||||
|
|
||||||
|
class FullHealth(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.app = Flask(__name__)
|
||||||
|
self.now = time.time()
|
||||||
|
self.cached = snapshot()
|
||||||
|
self.owner = SimpleNamespace(cached_results={'_bg_detailed': self.cached},
|
||||||
|
last_check_times={'_bg_detailed': self.now},
|
||||||
|
get_detailed_status=Mock(return_value=snapshot()))
|
||||||
|
persistence = SimpleNamespace(get_active_errors=Mock(return_value=[]),
|
||||||
|
get_dismissed_errors=Mock(return_value=[]),
|
||||||
|
get_custom_suppressions=Mock(return_value=[]))
|
||||||
|
node = next(n for n in ast.parse((ROOT/'AppImage/scripts/flask_health_routes.py').read_text()).body
|
||||||
|
if isinstance(n, ast.FunctionDef) and n.name == 'get_full_health')
|
||||||
|
node.decorator_list = []
|
||||||
|
self.scope = dict(health_monitor=self.owner, health_persistence=persistence,
|
||||||
|
request=request, jsonify=jsonify)
|
||||||
|
exec(compile(ast.Module(body=[node], type_ignores=[]), 'actual-health-route', 'exec'), self.scope)
|
||||||
|
self.uname = patch.object(kernel.os, 'uname', return_value=SimpleNamespace(release='7.0.14-25-pve'))
|
||||||
|
self.marker = patch.object(Path, 'exists', return_value=False)
|
||||||
|
self.uname.start(); self.marker.start()
|
||||||
|
self.addCleanup(self.uname.stop); self.addCleanup(self.marker.stop)
|
||||||
|
def command(args, **kwargs):
|
||||||
|
self.assertEqual(args, ['dpkg-query', '-W', '-f=${db:Status-Status}\t${binary:Package}\n'])
|
||||||
|
return subprocess.CompletedProcess(args, 0, 'installed\tproxmox-kernel-7.0.14-26-pve-signed\n', '')
|
||||||
|
self.probe = patch.object(kernel.subprocess, 'run', side_effect=command).start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
def call(self, query=''):
|
||||||
|
with self.app.test_request_context('/api/health/full'+query), patch('time.time', return_value=self.now):
|
||||||
|
response = self.scope['get_full_health']()
|
||||||
|
if isinstance(response, tuple):
|
||||||
|
return response[0].get_json(), response[1]
|
||||||
|
return response.get_json(), response.status_code
|
||||||
|
|
||||||
|
def test_cached_full_response_detects_external_update_without_full_check(self):
|
||||||
|
before = copy.deepcopy(self.cached)
|
||||||
|
result, code = self.call()
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
self.assertTrue(result['health']['details']['updates']['reboot_required'])
|
||||||
|
self.assertEqual(result['health']['details']['updates']['count'], 0)
|
||||||
|
self.assertEqual(result['health']['timestamp'], 'original')
|
||||||
|
self.assertEqual(self.cached, before)
|
||||||
|
self.owner.get_detailed_status.assert_not_called()
|
||||||
|
self.assertEqual(self.probe.call_count, 1)
|
||||||
|
|
||||||
|
def test_cached_only_miss_expiry_and_future_never_run_full_check_or_clear_cache(self):
|
||||||
|
for age in (360, 360.001, -1):
|
||||||
|
for query in ('?cached=1', '?cached=1&refresh=1'):
|
||||||
|
with self.subTest(age=age, query=query):
|
||||||
|
self.owner.last_check_times['_bg_detailed'] = self.now-age
|
||||||
|
before = copy.deepcopy(self.owner.cached_results)
|
||||||
|
result, code = self.call(query)
|
||||||
|
self.assertEqual(code, 503)
|
||||||
|
self.assertIn('error', result)
|
||||||
|
self.assertEqual(self.owner.cached_results, before)
|
||||||
|
self.owner.cached_results.clear()
|
||||||
|
self.assertEqual(self.call('?cached=1')[1], 503)
|
||||||
|
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')
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
self.assertTrue(result['health']['details']['updates']['reboot_required'])
|
||||||
|
self.owner.get_detailed_status.assert_not_called()
|
||||||
|
|
||||||
|
def test_invalid_timestamp_and_incomplete_snapshot_never_start_full_check(self):
|
||||||
|
for value in (None, 'bad', float('nan'), float('inf')):
|
||||||
|
self.owner.last_check_times['_bg_detailed'] = value
|
||||||
|
self.assertEqual(self.call('?cached=1')[1], 503)
|
||||||
|
self.owner.last_check_times['_bg_detailed'] = self.now
|
||||||
|
self.owner.cached_results['_bg_detailed'] = {'details': {}}
|
||||||
|
self.assertEqual(self.call('?cached=1')[1], 500)
|
||||||
|
self.owner.get_detailed_status.assert_not_called()
|
||||||
|
self.probe.assert_not_called()
|
||||||
|
|
||||||
|
def test_probe_failure_is_unknown_not_green_and_retains_other_categories(self):
|
||||||
|
self.cached['details']['cpu']['status'] = 'CRITICAL'
|
||||||
|
self.cached['overall'] = 'CRITICAL'
|
||||||
|
with patch.object(kernel.subprocess, 'run', side_effect=OSError('fixture-read-error')):
|
||||||
|
result, code = self.call('?cached=1')
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
health = result['health']
|
||||||
|
self.assertIsNone(health['details']['updates']['reboot_required'])
|
||||||
|
self.assertEqual(health['details']['updates']['status'], 'UNKNOWN')
|
||||||
|
self.assertEqual(health['overall'], 'CRITICAL')
|
||||||
|
self.assertEqual(health['details']['cpu'], self.cached['details']['cpu'])
|
||||||
|
|
||||||
|
def test_marker_zero_package_probes_and_no_process_or_shell_actions(self):
|
||||||
|
with patch.object(Path, 'exists', return_value=True), \
|
||||||
|
patch.object(kernel.subprocess, 'Popen') as spawn, patch.object(kernel.os, 'system') as shell:
|
||||||
|
self.assertTrue(self.call('?cached=1')[0]['health']['details']['updates']['reboot_required'])
|
||||||
|
spawn.assert_not_called()
|
||||||
|
shell.assert_not_called()
|
||||||
|
self.probe.assert_not_called()
|
||||||
|
|
||||||
|
def test_explicit_refresh_retains_original_full_check_contract(self):
|
||||||
|
result, code = self.call('?refresh=1')
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
self.owner.get_detailed_status.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class Overlay(unittest.TestCase):
|
||||||
|
def test_repeated_required_unknown_current_preserves_package_result_and_snapshot(self):
|
||||||
|
states = [dict(state=s, running_kernel='old', installed_kernel='new' if s=='required' else None)
|
||||||
|
for s in ('required', 'unknown', 'current')]
|
||||||
|
for package in ('OK', 'INFO', 'WARNING', 'CRITICAL', 'UNKNOWN'):
|
||||||
|
value = snapshot()
|
||||||
|
value['details']['updates']['status'] = package
|
||||||
|
before = copy.deepcopy(value)
|
||||||
|
for state in states:
|
||||||
|
previous = copy.deepcopy(value)
|
||||||
|
with patch.object(kernel, 'reboot_status', return_value=state):
|
||||||
|
refreshed = kernel.refresh_health_reboot(value)
|
||||||
|
self.assertEqual(value, previous)
|
||||||
|
value = refreshed
|
||||||
|
block = value['details']['updates']
|
||||||
|
expected = ('INFO' if state['state']=='required' else 'UNKNOWN' if state['state']=='unknown' else 'OK') if package=='OK' else package
|
||||||
|
self.assertEqual(block['status'], expected)
|
||||||
|
self.assertEqual(block['package_status'], package)
|
||||||
|
self.assertEqual(value['timestamp'], 'original')
|
||||||
|
self.assertEqual(value['details']['cpu'], before['details']['cpu'])
|
||||||
|
|
||||||
|
def test_dismissed_package_warning_stays_dismissed_but_reboot_remains_visible(self):
|
||||||
|
value = snapshot()
|
||||||
|
value['details']['updates'].update(status='OK', package_status='WARNING', all_dismissed=True)
|
||||||
|
state = dict(state='required', running_kernel='old', installed_kernel='new')
|
||||||
|
with patch.object(kernel, 'reboot_status', return_value=state):
|
||||||
|
result = kernel.refresh_health_reboot(value)
|
||||||
|
self.assertEqual(result['details']['updates']['status'], 'INFO')
|
||||||
|
self.assertTrue(result['details']['updates']['all_dismissed'])
|
||||||
|
self.assertEqual(value['details']['updates']['status'], 'OK')
|
||||||
|
|
||||||
|
def test_unknown_recovery_recomputes_overall_without_erasing_unrelated_warning(self):
|
||||||
|
value = snapshot()
|
||||||
|
with patch.object(kernel, 'reboot_status', return_value=dict(state='unknown', running_kernel='old')):
|
||||||
|
value = kernel.refresh_health_reboot(value)
|
||||||
|
self.assertEqual(value['overall'], 'WARNING')
|
||||||
|
with patch.object(kernel, 'reboot_status', return_value=dict(state='current', running_kernel='new')):
|
||||||
|
recovered = kernel.refresh_health_reboot(value)
|
||||||
|
value['details']['cpu']['status'] = 'WARNING'
|
||||||
|
preserved = kernel.refresh_health_reboot(value)
|
||||||
|
self.assertEqual(recovered['overall'], 'OK')
|
||||||
|
self.assertEqual(preserved['overall'], 'WARNING')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main(verbosity=2)
|
||||||
Reference in New Issue
Block a user