mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
fix: read completed update results without a live terminal
This commit is contained in:
@@ -21,6 +21,7 @@ PYTHON_FILES = (
|
|||||||
"tests/test_reboot_health_refresh.py",
|
"tests/test_reboot_health_refresh.py",
|
||||||
"tests/test_host_update_controls.py",
|
"tests/test_host_update_controls.py",
|
||||||
"tests/test_host_update_packaging.py",
|
"tests/test_host_update_packaging.py",
|
||||||
|
"tests/test_host_update_version.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",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||||
import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs"
|
import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs"
|
||||||
|
import { HostUpdateResult } from "./host-update-result"
|
||||||
import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status"
|
import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status"
|
||||||
import { useT } from "@/lib/i18n/provider"
|
import { useT } from "@/lib/i18n/provider"
|
||||||
import { useIsPbs } from "@/lib/use-platform"
|
import { useIsPbs } from "@/lib/use-platform"
|
||||||
@@ -134,6 +135,7 @@ 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 [resultJobId, setResultJobId] = useState<string | null>(null)
|
||||||
const healthRequest = useRef<AbortController | null>(null)
|
const healthRequest = useRef<AbortController | null>(null)
|
||||||
const previousAutoExpanded = useRef<Set<string>>(new Set())
|
const previousAutoExpanded = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
@@ -790,6 +792,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<HostUpdateJobs open={open} activeId={showUpdateTerminal ? hostJob?.runId : undefined}
|
<HostUpdateJobs open={open} activeId={showUpdateTerminal ? hostJob?.runId : undefined}
|
||||||
onJobs={setHostJobs} onSettled={() => { void fetchHealthDetails(true) }}
|
onJobs={setHostJobs} onSettled={() => { void fetchHealthDetails(true) }}
|
||||||
onResume={job => {
|
onResume={job => {
|
||||||
|
if (job.state === "complete") { setResultJobId(job.id); return }
|
||||||
setHostJob({ runId: job.id, resume: true, kind: "host" })
|
setHostJob({ runId: job.id, resume: true, kind: "host" })
|
||||||
setShowUpdateTerminal(true)
|
setShowUpdateTerminal(true)
|
||||||
}} />
|
}} />
|
||||||
@@ -1069,6 +1072,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
<HostUpdateResult jobId={resultJobId} getApiUrl={getApiUrl} onClose={() => setResultJobId(null)} />
|
||||||
<ScriptTerminalModal
|
<ScriptTerminalModal
|
||||||
key={hostJob?.runId || "host-update"}
|
key={hostJob?.runId || "host-update"}
|
||||||
open={showUpdateTerminal}
|
open={showUpdateTerminal}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function HostUpdateJobs({ open, activeId, onResume, onJobs, onSettled }:
|
|||||||
}) {
|
}) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const [jobs, setJobs] = useState<HostUpdateJob[]>([])
|
const [jobs, setJobs] = useState<HostUpdateJob[]>([])
|
||||||
|
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
||||||
const callbacks = useRef({ onJobs, onSettled })
|
const callbacks = useRef({ onJobs, onSettled })
|
||||||
callbacks.current = { onJobs, onSettled }
|
callbacks.current = { onJobs, onSettled }
|
||||||
const observed = useRef(new Set<string>())
|
const observed = useRef(new Set<string>())
|
||||||
@@ -47,15 +48,20 @@ export function HostUpdateJobs({ open, activeId, onResume, onJobs, onSettled }:
|
|||||||
const timer = setInterval(() => { void read() }, 5000)
|
const timer = setInterval(() => { void read() }, 5000)
|
||||||
return () => { closed = true; clearInterval(timer) }
|
return () => { closed = true; clearInterval(timer) }
|
||||||
}, [open])
|
}, [open])
|
||||||
const visible = jobs.filter(job => job.id !== activeId)
|
const visible = jobs.filter(job => job.id !== activeId && !(job.state === "complete" && hidden.has(job.id)))
|
||||||
if (!visible.length) return null
|
if (!visible.length) return null
|
||||||
return <div className="space-y-2" role="status">
|
return <div className="space-y-2" role="status">
|
||||||
{visible.map(job => <div key={job.id}
|
{visible.map(job => <div key={job.id}
|
||||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-violet-500/30 bg-violet-500/5 p-3">
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-violet-500/30 bg-violet-500/5 p-3">
|
||||||
<p className="text-sm">{t(`healthStatus.hostUpdate.${job.state === "complete" ? "finished" : job.state === "attention" ? "attention" : "running"}`)}</p>
|
<p className="text-sm">{t(`healthStatus.hostUpdate.${job.state === "complete" ? "finished" : job.state === "attention" ? "attention" : "running"}`)}</p>
|
||||||
<Button size="sm" variant="outline" onClick={() => onResume(job)}>
|
<div className="flex items-center gap-2">
|
||||||
{t("healthStatus.hostUpdate.view")}
|
<Button size="sm" variant="outline" onClick={() => onResume(job)}>
|
||||||
</Button>
|
{t(job.state === "complete" ? "healthStatus.hostUpdate.viewResult" : "healthStatus.hostUpdate.view")}
|
||||||
|
</Button>
|
||||||
|
{job.state === "complete" && <Button size="sm" variant="ghost" onClick={() => setHidden(previous => new Set([...previous, job.id]))}>
|
||||||
|
{t("healthStatus.hostUpdate.hideResult")}
|
||||||
|
</Button>}
|
||||||
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { getAuthToken } from "../lib/api-config"
|
||||||
|
import { scriptCompletion } from "../lib/script-completion"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import "xterm/css/xterm.css"
|
||||||
|
|
||||||
|
export function HostUpdateResult({ jobId, getApiUrl, onClose }: {
|
||||||
|
jobId: string | null
|
||||||
|
getApiUrl: (path: string) => string
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const t = useT()
|
||||||
|
const container = useRef<HTMLDivElement>(null)
|
||||||
|
const [revision, setRevision] = useState(0)
|
||||||
|
const [exitCode, setExitCode] = useState<number | null>(null)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jobId) return
|
||||||
|
const controller = new AbortController()
|
||||||
|
let terminal: import("xterm").Terminal | undefined
|
||||||
|
let observer: ResizeObserver | undefined
|
||||||
|
let disposed = false
|
||||||
|
setExitCode(null); setFailed(false)
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const token = getAuthToken()
|
||||||
|
const response = await fetch(getApiUrl(`/api/host-update-jobs/${encodeURIComponent(jobId)}/result`), {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (!response.ok) throw Error("unavailable")
|
||||||
|
const value = await response.json()
|
||||||
|
if (value.state !== "complete" || !Number.isSafeInteger(value.exit_code)
|
||||||
|
|| typeof value.text !== "string" || value.text.length > 256 * 1024
|
||||||
|
|| typeof value.truncated !== "boolean") throw Error("invalid result")
|
||||||
|
const [{ Terminal }, { FitAddon }] = await Promise.all([import("xterm"), import("xterm-addon-fit")])
|
||||||
|
if (controller.signal.aborted || !container.current) return
|
||||||
|
terminal = new Terminal({ disableStdin: true, convertEol: true, fontSize: 12,
|
||||||
|
screenReaderMode: true, theme: { background: "#000000", foreground: "#dddddd" } })
|
||||||
|
const fit = new FitAddon()
|
||||||
|
terminal.loadAddon(fit); terminal.open(container.current); fit.fit()
|
||||||
|
if (value.truncated) terminal.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
|
||||||
|
terminal.write(value.text)
|
||||||
|
observer = new ResizeObserver(() => { if (!controller.signal.aborted) fit.fit() })
|
||||||
|
observer.observe(container.current)
|
||||||
|
setExitCode(value.exit_code)
|
||||||
|
} catch {
|
||||||
|
if (!disposed) setFailed(true)
|
||||||
|
} finally { clearTimeout(timeout) }
|
||||||
|
})()
|
||||||
|
return () => { disposed = true; controller.abort(); clearTimeout(timeout); observer?.disconnect(); terminal?.dispose() }
|
||||||
|
}, [jobId, getApiUrl, revision, t])
|
||||||
|
const kind = exitCode === null ? null : scriptCompletion(exitCode, 130).kind
|
||||||
|
return <Dialog open={jobId !== null} onOpenChange={open => { if (!open) onClose() }}>
|
||||||
|
<DialogContent className="max-w-4xl w-[95vw] max-h-[85vh] overflow-y-auto p-4 sm:p-5">
|
||||||
|
<DialogHeader><DialogTitle>{t("healthStatus.hostUpdate.resultTitle")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("healthStatus.hostUpdate.resultReadOnly")}</DialogDescription></DialogHeader>
|
||||||
|
{failed && <div role="alert" className="text-sm text-amber-500">
|
||||||
|
<p>{t("healthStatus.hostUpdate.resultUnavailable")}</p>
|
||||||
|
<Button size="sm" variant="outline" className="mt-3" onClick={() => setRevision(value => value + 1)}>{t("healthStatus.hostUpdate.retryResult")}</Button>
|
||||||
|
</div>}
|
||||||
|
<div ref={container} className="h-[45vh] min-h-[180px] bg-black rounded overflow-hidden" />
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p role="status" className={`text-xs ${exitCode === 0 ? "text-green-500" : "text-muted-foreground"}`}>
|
||||||
|
{kind === null ? t(failed ? "healthStatus.hostUpdate.resultUnavailable" : "healthStatus.hostUpdate.loadingResult")
|
||||||
|
: kind === "cancelled" ? t("scriptTerminal.cancelled") : exitCode === 0
|
||||||
|
? t("scriptTerminal.completedSuccessfully") : t("scriptTerminal.completedWithError", { code: exitCode })}
|
||||||
|
</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={onClose}>{t("healthStatus.hostUpdate.closeResult")}</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
@@ -5334,9 +5334,17 @@
|
|||||||
},
|
},
|
||||||
"hostUpdate": {
|
"hostUpdate": {
|
||||||
"running": "The host update continues even if you sign out. Reopen it after signing in.",
|
"running": "The host update continues even if you sign out. Reopen it after signing in.",
|
||||||
"finished": "A host update has finished. View its result before starting another.",
|
"finished": "The last host update has finished. Its result is available.",
|
||||||
"attention": "The host update needs attention. It has not been stopped; reconnect to inspect it.",
|
"attention": "The host update needs attention. It has not been stopped; reconnect to inspect it.",
|
||||||
"view": "View update",
|
"view": "View update",
|
||||||
|
"viewResult": "View result",
|
||||||
|
"hideResult": "Hide result",
|
||||||
|
"resultTitle": "Host update result",
|
||||||
|
"resultReadOnly": "Saved output of a completed update. Opening it does not run another update.",
|
||||||
|
"resultUnavailable": "The saved result could not be loaded. No update was started.",
|
||||||
|
"retryResult": "Reload result",
|
||||||
|
"loadingResult": "Loading saved result…",
|
||||||
|
"closeResult": "Close result",
|
||||||
"noReboot": "No reboot required",
|
"noReboot": "No reboot required",
|
||||||
"rebootMarker": "The operating system requires a reboot.",
|
"rebootMarker": "The operating system requires a reboot.",
|
||||||
"rebootUnknown": "Could not determine whether a reboot is required.",
|
"rebootUnknown": "Could not determine whether a reboot is required.",
|
||||||
|
|||||||
@@ -5206,9 +5206,17 @@
|
|||||||
},
|
},
|
||||||
"hostUpdate": {
|
"hostUpdate": {
|
||||||
"running": "Aktualizácia servera pokračuje aj po odhlásení. Po prihlásení ju môžeš znova otvoriť.",
|
"running": "Aktualizácia servera pokračuje aj po odhlásení. Po prihlásení ju môžeš znova otvoriť.",
|
||||||
"finished": "Aktualizácia servera sa skončila. Pred ďalším spustením si pozri jej výsledok.",
|
"finished": "Posledná aktualizácia servera sa skončila. Jej výsledok je k dispozícii.",
|
||||||
"attention": "Aktualizácia servera potrebuje kontrolu. Nebola zastavená; otvor ju a skontroluj priebeh.",
|
"attention": "Aktualizácia servera potrebuje kontrolu. Nebola zastavená; otvor ju a skontroluj priebeh.",
|
||||||
"view": "Zobraziť aktualizáciu",
|
"view": "Zobraziť aktualizáciu",
|
||||||
|
"viewResult": "Zobraziť výsledok",
|
||||||
|
"hideResult": "Skryť výsledok",
|
||||||
|
"resultTitle": "Výsledok aktualizácie servera",
|
||||||
|
"resultReadOnly": "Uložený výpis dokončenej aktualizácie. Jeho otvorenie nespustí ďalšiu aktualizáciu.",
|
||||||
|
"resultUnavailable": "Uložený výsledok sa nepodarilo načítať. Žiadna aktualizácia sa nespustila.",
|
||||||
|
"retryResult": "Znova načítať výsledok",
|
||||||
|
"loadingResult": "Načítava sa uložený výsledok…",
|
||||||
|
"closeResult": "Zavrieť výsledok",
|
||||||
"noReboot": "Reštart nie je potrebný",
|
"noReboot": "Reštart nie je potrebný",
|
||||||
"rebootMarker": "Operačný systém vyžaduje reštart.",
|
"rebootMarker": "Operačný systém vyžaduje reštart.",
|
||||||
"rebootUnknown": "Nepodarilo sa zistiť, či je potrebný reštart.",
|
"rebootUnknown": "Nepodarilo sa zistiť, či je potrebný reštart.",
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ def get_full_health():
|
|||||||
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 if type(bg_last) in (int, float) else float('inf')
|
bg_age = _time.time() - bg_last if type(bg_last) in (int, float) else float('inf')
|
||||||
|
publish_live = False
|
||||||
|
|
||||||
if 0 <= 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)
|
||||||
@@ -318,12 +319,29 @@ def get_full_health():
|
|||||||
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()
|
||||||
|
if (not isinstance(details, dict) or not details.get('timestamp')
|
||||||
|
or details.get('overall') not in ('OK', 'INFO', 'WARNING', 'CRITICAL', 'UNKNOWN')
|
||||||
|
or not isinstance(details.get('summary'), str)
|
||||||
|
or not isinstance(details.get('details'), dict)
|
||||||
|
or not isinstance(details['details'].get('updates'), dict)):
|
||||||
|
raise ValueError('Health snapshot is incomplete')
|
||||||
|
# Publish an explicit check too: its caller may have invalidated
|
||||||
|
# the background cache after an update. A following read-only poll
|
||||||
|
# must see this result instead of waiting for another collector.
|
||||||
|
publish_live = True
|
||||||
|
|
||||||
# Installed/running kernel state changes outside Monitor jobs too.
|
# Installed/running kernel state changes outside Monitor jobs too.
|
||||||
# Overlay fresh read-only evidence without refreshing other checks or
|
# Overlay fresh read-only evidence without refreshing other checks or
|
||||||
# rewriting the background collector's snapshot/timestamp.
|
# rewriting the background collector's snapshot/timestamp.
|
||||||
from kernel_reboot import refresh_health_reboot
|
from kernel_reboot import refresh_health_reboot
|
||||||
details = refresh_health_reboot(details)
|
details = refresh_health_reboot(details)
|
||||||
|
if publish_live:
|
||||||
|
checked_at = _time.time()
|
||||||
|
health_monitor.cached_results[bg_key] = details
|
||||||
|
health_monitor.cached_results['_bg_overall'] = {
|
||||||
|
'status': details['overall'], 'summary': details['summary']}
|
||||||
|
health_monitor.last_check_times[bg_key] = checked_at
|
||||||
|
health_monitor.last_check_times['_bg_overall'] = checked_at
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
@@ -1752,6 +1752,7 @@ def get_uptime():
|
|||||||
_system_info_cache = {
|
_system_info_cache = {
|
||||||
'proxmox_version': None,
|
'proxmox_version': None,
|
||||||
'proxmox_version_time': 0,
|
'proxmox_version_time': 0,
|
||||||
|
'proxmox_version_stamp': 0.0,
|
||||||
'available_updates': 0,
|
'available_updates': 0,
|
||||||
'available_updates_time': 0,
|
'available_updates_time': 0,
|
||||||
'available_updates_stamp': 0.0,
|
'available_updates_stamp': 0.0,
|
||||||
@@ -3507,14 +3508,16 @@ def get_coral_info():
|
|||||||
|
|
||||||
|
|
||||||
def get_proxmox_version():
|
def get_proxmox_version():
|
||||||
"""Get Proxmox version if available. Cached for 6 hours."""
|
"""Cache the installed PVE version until package state changes."""
|
||||||
global _system_info_cache
|
global _system_info_cache
|
||||||
if is_pbs_host():
|
if is_pbs_host():
|
||||||
return get_platform().get('version')
|
return get_platform().get('version')
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if _system_info_cache['proxmox_version'] is not None and \
|
stamp = _apt_state_stamp()
|
||||||
now - _system_info_cache['proxmox_version_time'] < _SYSTEM_INFO_CACHE_TTL:
|
age = now - _system_info_cache['proxmox_version_time']
|
||||||
|
if _system_info_cache['proxmox_version'] is not None and (0 <= age < _AVAILABLE_UPDATES_MIN_INTERVAL or
|
||||||
|
(stamp == _system_info_cache.get('proxmox_version_stamp') and 0 <= age < _SYSTEM_INFO_CACHE_TTL)):
|
||||||
return _system_info_cache['proxmox_version']
|
return _system_info_cache['proxmox_version']
|
||||||
|
|
||||||
proxmox_version = None
|
proxmox_version = None
|
||||||
@@ -3530,8 +3533,11 @@ def get_proxmox_version():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if proxmox_version is None:
|
||||||
|
return None # Keep last-known cache intact, but do not present it as fresh.
|
||||||
_system_info_cache['proxmox_version'] = proxmox_version
|
_system_info_cache['proxmox_version'] = proxmox_version
|
||||||
_system_info_cache['proxmox_version_time'] = now
|
_system_info_cache['proxmox_version_time'] = now
|
||||||
|
_system_info_cache['proxmox_version_stamp'] = stamp
|
||||||
return proxmox_version
|
return proxmox_version
|
||||||
|
|
||||||
def _apt_state_stamp():
|
def _apt_state_stamp():
|
||||||
|
|||||||
@@ -215,6 +215,22 @@ def host_update_jobs_status():
|
|||||||
return jsonify({'jobs': list_jobs()})
|
return jsonify({'jobs': list_jobs()})
|
||||||
|
|
||||||
|
|
||||||
|
@terminal_bp.route('/api/host-update-jobs/<run_id>/result', methods=['GET'])
|
||||||
|
@require_admin_scope
|
||||||
|
def host_update_result(run_id):
|
||||||
|
"""Read a completed owner's retained output; never create or resume a job."""
|
||||||
|
from host_update_jobs import get
|
||||||
|
try:
|
||||||
|
view = get(run_id).read(0)
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return jsonify({'error': 'Host update result is unavailable'}), 404
|
||||||
|
if view['state'] != 'complete' or type(view['exit_code']) is not int:
|
||||||
|
return jsonify({'error': 'Host update has not completed'}), 409
|
||||||
|
response = jsonify({key: view[key] for key in ('state', 'exit_code', 'text', 'truncated')})
|
||||||
|
response.headers['Cache-Control'] = 'no-store'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@sock.route('/ws/host-update/<run_id>')
|
@sock.route('/ws/host-update/<run_id>')
|
||||||
def host_update_viewer(ws, run_id):
|
def host_update_viewer(ws, run_id):
|
||||||
"""New fixed-script contract; existing script/LXC routes stay intact."""
|
"""New fixed-script contract; existing script/LXC routes stay intact."""
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ class Routes(unittest.TestCase):
|
|||||||
_TICKET_MAX_INFLIGHT=256,time=time,secrets=__import__('secrets'),json=json,
|
_TICKET_MAX_INFLIGHT=256,time=time,secrets=__import__('secrets'),json=json,
|
||||||
_host_update_completed=self.callback)
|
_host_update_completed=self.callback)
|
||||||
names={'_issue_terminal_ticket','_consume_terminal_ticket','_ws_auth_check',
|
names={'_issue_terminal_ticket','_consume_terminal_ticket','_ws_auth_check',
|
||||||
'issue_terminal_ticket_route','host_update_jobs_status','host_update_viewer'}
|
'issue_terminal_ticket_route','host_update_jobs_status','host_update_result','host_update_viewer'}
|
||||||
nodes=[n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body if isinstance(n,ast.FunctionDef) and n.name in names]
|
nodes=[n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body if isinstance(n,ast.FunctionDef) and n.name in names]
|
||||||
self.assertEqual(len(nodes),len(names))
|
self.assertEqual(len(nodes),len(names))
|
||||||
exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual-host-routes','exec'),scope)
|
exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual-host-routes','exec'),scope)
|
||||||
@@ -277,10 +277,33 @@ class Routes(unittest.TestCase):
|
|||||||
with patch.object(jobs.subprocess,'Popen') as spawn:
|
with patch.object(jobs.subprocess,'Popen') as spawn:
|
||||||
self.assertEqual(self.ticket(token).status_code,status)
|
self.assertEqual(self.ticket(token).status_code,status)
|
||||||
self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer '+token}).status_code,status)
|
self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer '+token}).status_code,status)
|
||||||
|
self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer '+token}).status_code,status)
|
||||||
self.attach(dict(resume=False))
|
self.attach(dict(resume=False))
|
||||||
spawn.assert_not_called()
|
spawn.assert_not_called()
|
||||||
self.assertEqual(jobs.list_jobs(),[])
|
self.assertEqual(jobs.list_jobs(),[])
|
||||||
|
|
||||||
|
def test_finished_result_is_read_only_and_bounded_by_owner_output(self):
|
||||||
|
view=dict(state='complete',exit_code=0,text='inert saved output',truncated=False)
|
||||||
|
with patch.object(jobs,'get',return_value=SimpleNamespace(read=Mock(return_value=view))) as get,patch.object(jobs,'start') as start,patch.object(jobs.subprocess,'Popen') as spawn:
|
||||||
|
response=self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer valid'})
|
||||||
|
self.assertEqual(response.status_code,200)
|
||||||
|
self.assertEqual(response.json,view)
|
||||||
|
self.assertEqual(response.headers['Cache-Control'],'no-store')
|
||||||
|
get.assert_called_once_with('fixture');start.assert_not_called();spawn.assert_not_called()
|
||||||
|
|
||||||
|
def test_result_auth_missing_invalid_running_never_starts_job(self):
|
||||||
|
with patch.object(jobs,'get') as get,patch.object(jobs,'start') as start,patch.object(jobs.subprocess,'Popen') as spawn:
|
||||||
|
self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result').status_code,401)
|
||||||
|
get.assert_not_called()
|
||||||
|
get.side_effect=KeyError('missing')
|
||||||
|
self.assertEqual(self.client.get('/api/host-update-jobs/missing/result',headers={'Authorization':'Bearer valid'}).status_code,404)
|
||||||
|
get.side_effect=ValueError('invalid')
|
||||||
|
self.assertEqual(self.client.get('/api/host-update-jobs/invalid/result',headers={'Authorization':'Bearer valid'}).status_code,404)
|
||||||
|
get.side_effect=None
|
||||||
|
for state in ('running','attention','finalizing'):
|
||||||
|
get.return_value=SimpleNamespace(read=Mock(return_value=dict(state=state,exit_code=None)))
|
||||||
|
self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer valid'}).status_code,409)
|
||||||
|
start.assert_not_called();spawn.assert_not_called()
|
||||||
def test_real_route_disconnect_401_then_relogin_replays_one_job(self):
|
def test_real_route_disconnect_401_then_relogin_replays_one_job(self):
|
||||||
self.attach(dict(resume=False),self.ticket().json['ticket'])
|
self.attach(dict(resume=False),self.ticket().json['ticket'])
|
||||||
job=jobs.get('job-1')
|
job=jobs.get('job-1')
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ def wire_fixture():
|
|||||||
assert events.read_text().splitlines() == ['start', 'poststep']
|
assert events.read_text().splitlines() == ['start', 'poststep']
|
||||||
assert len(callbacks) == 1
|
assert len(callbacks) == 1
|
||||||
assert 'completed' in ''.join(frame.get('text', '') for frame in frames)
|
assert 'completed' in ''.join(frame.get('text', '') for frame in frames)
|
||||||
|
saved=requests.get(base+'/api/host-update-jobs/one-host-run/result',headers=headers,timeout=3)
|
||||||
|
assert saved.status_code==200 and saved.headers['Cache-Control']=='no-store'
|
||||||
|
assert saved.json()['exit_code']==0 and 'completed' in saved.json()['text']
|
||||||
|
assert events.read_text().splitlines()==['start','poststep'] and len(callbacks)==1
|
||||||
if viewer.connected:
|
if viewer.connected:
|
||||||
viewer.close()
|
viewer.close()
|
||||||
viewer = connect('missing-host-run', True)
|
viewer = connect('missing-host-run', True)
|
||||||
|
|||||||
@@ -60,4 +60,36 @@ const en=JSON.parse(fs.readFileSync(path.join(app,'messages/en/common.json'),'ut
|
|||||||
const sk=JSON.parse(fs.readFileSync(path.join(app,'messages/sk/common.json'),'utf8'))
|
const sk=JSON.parse(fs.readFileSync(path.join(app,'messages/sk/common.json'),'utf8'))
|
||||||
assert.deepEqual(Object.keys(en.healthStatus.hostUpdate).sort(),Object.keys(sk.healthStatus.hostUpdate).sort())
|
assert.deepEqual(Object.keys(en.healthStatus.hostUpdate).sort(),Object.keys(sk.healthStatus.hostUpdate).sort())
|
||||||
assert.ok(health.includes('Number(categoryData?.count ?? 0) > 0'),'reboot-only state has no Update Now action')
|
assert.ok(health.includes('Number(categoryData?.count ?? 0) > 0'),'reboot-only state has no Update Now action')
|
||||||
console.log('PASS host initial/resume transport, legacy/LXC routes, disconnect/unmount and EN/SK keys')
|
const savedTree=ts.createSourceFile('result.tsx',fs.readFileSync(path.join(app,'components/host-update-result.tsx'),'utf8'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX)
|
||||||
|
let readResult
|
||||||
|
function resultWalk(n){
|
||||||
|
if(ts.isArrowFunction(n)&&n.modifiers?.some(m=>m.kind===ts.SyntaxKind.AsyncKeyword)&&n.getText(savedTree).includes('value.state'))readResult=n.getText(savedTree)
|
||||||
|
ts.forEachChild(n,resultWalk)
|
||||||
|
}
|
||||||
|
resultWalk(savedTree);assert.ok(readResult)
|
||||||
|
async function results(){
|
||||||
|
const response={state:'complete',exit_code:0,text:'inert saved result',truncated:false}
|
||||||
|
for(const code of [0,1,130,-15]){
|
||||||
|
const calls=[],writes=[],codes=[],errors=[]
|
||||||
|
const scope={getAuthToken:()=>null,getApiUrl:p=>p,jobId:'saved-id',controller:new AbortController(),container:{current:{}},disposed:false,
|
||||||
|
fetch:async(p,options)=>{calls.push({p,options});return {ok:true,json:async()=>({...response,exit_code:code})}},
|
||||||
|
require:n=>n==='xterm'?{Terminal:class{loadAddon(){}open(){}write(v){writes.push(v)}writeln(v){writes.push(v)}}}:{FitAddon:class{fit(){}}},
|
||||||
|
ResizeObserver:class{observe(){}},setExitCode:v=>codes.push(v),setFailed:v=>errors.push(v),clearTimeout(){},timeout:1,t:s=>s}
|
||||||
|
await compile(readResult,scope)()
|
||||||
|
assert.deepEqual(codes,[code]);assert.deepEqual(errors,[]);assert.deepEqual(writes,['inert saved result'])
|
||||||
|
assert.equal(calls.length,1);assert.equal(calls[0].p,'/api/host-update-jobs/saved-id/result');assert.ok(!calls[0].options.method||calls[0].options.method==='GET')
|
||||||
|
for(const value of [null,{...response,state:'running'},{...response,exit_code:false},{...response,text:null},{...response,truncated:null}]){
|
||||||
|
codes.length=errors.length=writes.length=0
|
||||||
|
scope.fetch=async()=>({ok:true,json:async()=>value});await compile(readResult,scope)()
|
||||||
|
assert.deepEqual(codes,[]);assert.deepEqual(writes,[]);assert.deepEqual(errors,[true])
|
||||||
|
}
|
||||||
|
codes.length=errors.length=writes.length=0
|
||||||
|
scope.fetch=async()=>({ok:false});await compile(readResult,scope)()
|
||||||
|
assert.deepEqual(errors,[true]);assert.deepEqual(codes,[]);assert.deepEqual(writes,[])
|
||||||
|
codes.length=errors.length=writes.length=0;scope.disposed=true;scope.controller.abort()
|
||||||
|
scope.fetch=async()=>({ok:true,json:async()=>response});await compile(readResult,scope)()
|
||||||
|
assert.deepEqual(codes,[]);assert.deepEqual(errors,[]);assert.deepEqual(writes,[],'closed result cannot receive a late response')
|
||||||
|
}
|
||||||
|
console.log('PASS host/legacy/LXC transports and actual readonly result GET: success/error/cancel/invalid/late-response; zero WS/start callbacks')
|
||||||
|
}
|
||||||
|
results().catch(error=>{console.error(error);process.exitCode=1})
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Actual installed-version function; package changes invalidate its old display."""
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class VersionCache(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
source = ast.parse((ROOT/'AppImage/scripts/flask_server.py').read_text())
|
||||||
|
node = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == 'get_proxmox_version')
|
||||||
|
self.cache = dict(proxmox_version='9.2.21', proxmox_version_time=100, proxmox_version_stamp=1)
|
||||||
|
self.command = Mock(return_value=SimpleNamespace(returncode=0, stdout='pve-manager/9.2.24/hash\n'))
|
||||||
|
self.stamp = Mock(return_value=1)
|
||||||
|
self.scope = dict(is_pbs_host=Mock(return_value=False), get_platform=Mock(return_value={'version':'4.1.0'}),
|
||||||
|
_system_info_cache=self.cache, _SYSTEM_INFO_CACHE_TTL=21600,
|
||||||
|
_AVAILABLE_UPDATES_MIN_INTERVAL=30, _apt_state_stamp=self.stamp,
|
||||||
|
time=SimpleNamespace(time=Mock(return_value=200)), subprocess=SimpleNamespace(run=self.command))
|
||||||
|
exec(compile(ast.Module(body=[node], type_ignores=[]), 'actual-installed-version', 'exec'), self.scope)
|
||||||
|
|
||||||
|
def test_unchanged_package_state_has_zero_commands(self):
|
||||||
|
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.21')
|
||||||
|
self.command.assert_not_called()
|
||||||
|
|
||||||
|
def test_package_change_at_floor_refreshes_version_without_apt(self):
|
||||||
|
self.stamp.return_value=2
|
||||||
|
self.scope['time'].time.return_value=129.999
|
||||||
|
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.21')
|
||||||
|
self.command.assert_not_called()
|
||||||
|
self.scope['time'].time.return_value=130
|
||||||
|
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.24')
|
||||||
|
self.command.assert_called_once_with(['pveversion'],capture_output=True,text=True,timeout=5)
|
||||||
|
self.assertEqual(self.cache['proxmox_version_stamp'],2)
|
||||||
|
|
||||||
|
def test_failed_read_returns_unknown_but_keeps_last_known_cache(self):
|
||||||
|
self.stamp.return_value=2
|
||||||
|
before=dict(self.cache)
|
||||||
|
self.command.side_effect=OSError('inert unavailable command')
|
||||||
|
self.assertIsNone(self.scope['get_proxmox_version']())
|
||||||
|
self.assertEqual(self.cache,before)
|
||||||
|
|
||||||
|
def test_pbs_guard_precedes_all_pve_package_probes(self):
|
||||||
|
self.scope['is_pbs_host'].return_value=True
|
||||||
|
self.assertEqual(self.scope['get_proxmox_version'](),'4.1.0')
|
||||||
|
self.stamp.assert_not_called();self.command.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -143,6 +143,29 @@ class FullHealth(unittest.TestCase):
|
|||||||
self.assertEqual(code, 200)
|
self.assertEqual(code, 200)
|
||||||
self.owner.get_detailed_status.assert_called_once()
|
self.owner.get_detailed_status.assert_called_once()
|
||||||
|
|
||||||
|
def test_explicit_refresh_publishes_then_poll_never_rechecks(self):
|
||||||
|
result, code = self.call('?refresh=1')
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
self.assertIn('_bg_detailed', self.owner.cached_results)
|
||||||
|
self.assertEqual(self.owner.cached_results['_bg_overall']['status'],result['health']['overall'])
|
||||||
|
self.assertEqual(self.owner.last_check_times['_bg_overall'],self.owner.last_check_times['_bg_detailed'])
|
||||||
|
before = copy.deepcopy(self.owner.cached_results)
|
||||||
|
result, code = self.call('?cached=1')
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
self.assertEqual(result['health']['timestamp'], 'original')
|
||||||
|
self.assertEqual(self.owner.cached_results, before)
|
||||||
|
self.owner.get_detailed_status.assert_called_once()
|
||||||
|
|
||||||
|
def test_invalid_live_result_never_publishes_or_becomes_fresh(self):
|
||||||
|
for result in (None, {}, {'details': {}}, {'details': {'updates': {'status': 'OK'}}}):
|
||||||
|
with self.subTest(result=result):
|
||||||
|
self.owner.cached_results.clear()
|
||||||
|
self.owner.last_check_times.clear()
|
||||||
|
self.owner.get_detailed_status.return_value = result
|
||||||
|
self.assertEqual(self.call()[1], 500)
|
||||||
|
self.assertNotIn('_bg_detailed', self.owner.cached_results)
|
||||||
|
self.assertNotIn('_bg_detailed', self.owner.last_check_times)
|
||||||
|
|
||||||
|
|
||||||
class Overlay(unittest.TestCase):
|
class Overlay(unittest.TestCase):
|
||||||
def test_repeated_required_unknown_current_preserves_package_result_and_snapshot(self):
|
def test_repeated_required_unknown_current_preserves_package_result_and_snapshot(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user