fix: keep LXC updates running when the browser session ends

This commit is contained in:
VAIO73
2026-10-26 07:57:20 +00:00
parent af29bc9274
commit 44ab9d506d
12 changed files with 1145 additions and 40 deletions
+48
View File
@@ -0,0 +1,48 @@
"use client"
import { useEffect, useState } from "react"
import { Button } from "./ui/button"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { viewedUpdateJobs, type LxcUpdateJob } from "../lib/lxc-update-jobs"
export function LxcUpdateJobs({ activeRunId, onResume }: {
activeRunId?: string
onResume: (job: LxcUpdateJob) => void
}) {
const t = useT()
const [jobs, setJobs] = useState<LxcUpdateJob[]>([])
const [seen, setSeen] = useState<string[]>([])
useEffect(() => {
let closed = false
const read = () => {
void fetchApi<{ jobs: LxcUpdateJob[] }>("/api/lxc-update-jobs")
.then((data) => { if (!closed) setJobs(Array.isArray(data.jobs) ? data.jobs : []) })
.catch(() => {}) // Auth recovery belongs to fetchApi, never to a job retry.
}
const viewed = () => setSeen(viewedUpdateJobs())
viewed()
read()
const timer = setInterval(read, 5000)
window.addEventListener("proxmenux-update-jobs-viewed", viewed)
return () => {
closed = true
clearInterval(timer)
window.removeEventListener("proxmenux-update-jobs-viewed", viewed)
}
}, [])
const visible = jobs.filter((job) => job.id !== activeRunId
&& (job.state !== "complete" || !seen.includes(job.id)))
if (!visible.length) return null
return <div className="space-y-2" role="status">
{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">
<p className="text-sm">{t(job.state === "complete"
? "vmLxc.updates.backgroundFinished" : "vmLxc.updates.backgroundRunning",
{ vmid: job.vmid, name: job.ct_name || `CT-${job.vmid}` })}</p>
<Button variant="outline" size="sm" onClick={() => onResume(job)}>
{t("vmLxc.updates.viewBackgroundJob")}
</Button>
</div>)}
</div>
}
+71 -14
View File
@@ -54,6 +54,8 @@ interface ScriptTerminalModalProps {
completedSuccessfullyMessage?: string
completedWithErrorMessage?: (exitCode: number) => string
cancelledExitCode?: number
// Only LXC Updates opts into a separate server-owned job transport.
backgroundUpdate?: { runId: string; vmid: number; resume: boolean }
// Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore
@@ -72,6 +74,7 @@ export function ScriptTerminalModal({
completedSuccessfullyMessage,
completedWithErrorMessage,
cancelledExitCode,
backgroundUpdate,
onComplete,
}: ScriptTerminalModalProps) {
const t = useT()
@@ -93,6 +96,7 @@ export function ScriptTerminalModal({
const reconnectAttemptsRef = useRef(0)
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
const completionReceivedRef = useRef(false)
const outputOffsetRef = useRef(0)
const [isMobile, setIsMobile] = useState(false)
const [isTablet, setIsTablet] = useState(false)
@@ -130,7 +134,7 @@ export function ScriptTerminalModal({
}, [onComplete])
const attemptReconnect = useCallback(() => {
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
if (!isOpenRef.current || isComplete || reconnectAttemptsRef.current >= 3) {
return
}
@@ -154,7 +158,9 @@ export function ScriptTerminalModal({
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
const ticketedUrl = await getTicketedWsUrl(wsUrl)
if (!isOpenRef.current || !termRef.current) return
const ws = new WebSocket(ticketedUrl)
wsRef.current = ws
ws.onopen = () => {
@@ -170,9 +176,11 @@ export function ScriptTerminalModal({
}
}, 30000)
const initMessage = {
const initMessage = {
script_path: scriptPath,
params: paramsRef.current,
...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid,
offset: outputOffsetRef.current } : {}),
}
ws.send(JSON.stringify(initMessage))
@@ -195,7 +203,7 @@ const initMessage = {
// The PTY worker always emits this final line. Treat it as a
// completion fallback because some WebSocket servers tear down the
// connection before the following structured message is flushed.
const exitMatch = typeof event.data === "string"
const exitMatch = !backgroundUpdate && typeof event.data === "string"
? event.data.match(/\[Script exited with code (-?\d+)\]/)
: null
if (exitMatch) {
@@ -211,6 +219,19 @@ const initMessage = {
try {
const msg = JSON.parse(event.data)
if (msg.type === "job_output") {
if (msg.truncated) termRef.current?.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
termRef.current?.write(msg.text)
outputOffsetRef.current = msg.offset
return
}
if (msg.type === "job_unavailable") {
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
completionReceivedRef.current = true
setIsComplete(true)
ws.close()
return
}
if (msg.type === "web_interaction" && msg.interaction) {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) {
@@ -255,6 +276,7 @@ const initMessage = {
}
ws.onclose = (event) => {
if (!isOpenRef.current) return
setConnectionStatus("offline")
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
@@ -266,13 +288,17 @@ const initMessage = {
if (!isComplete && reconnectAttemptsRef.current < 3) {
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
} else {
setIsComplete(true)
onCompleteRef.current?.(-1)
if (backgroundUpdate) {
termRef.current?.writeln(t("vmLxc.updates.backgroundDetached"))
} else {
setIsComplete(true)
onCompleteRef.current?.(-1)
}
}
}
}
}, 1000)
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t])
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t, backgroundUpdate])
const sendKey = useCallback((key: string) => {
if (!termRef.current) return
@@ -366,7 +392,9 @@ const initMessage = {
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
const ticketedUrl = await getTicketedWsUrl(wsUrl)
if (!isOpenRef.current || termRef.current !== term) return
const ws = new WebSocket(ticketedUrl)
wsRef.current = ws
ws.onopen = () => {
@@ -384,6 +412,8 @@ const initMessage = {
const initMessage = {
script_path: scriptPath,
params: paramsRef.current,
...(backgroundUpdate ? { resume: backgroundUpdate.resume,
vmid: backgroundUpdate.vmid, offset: outputOffsetRef.current } : {}),
}
ws.send(JSON.stringify(initMessage))
@@ -412,7 +442,7 @@ const initMessage = {
// See the reconnect handler above. The exit line is guaranteed to be
// sent with the PTY output and is therefore a robust fallback when a
// final JSON frame is lost during server-side socket teardown.
const exitMatch = typeof event.data === "string"
const exitMatch = !backgroundUpdate && typeof event.data === "string"
? event.data.match(/\[Script exited with code (-?\d+)\]/)
: null
if (exitMatch) {
@@ -428,6 +458,19 @@ const initMessage = {
try {
const msg = JSON.parse(event.data)
if (msg.type === "job_output") {
if (msg.truncated) term.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
term.write(msg.text)
outputOffsetRef.current = msg.offset
return
}
if (msg.type === "job_unavailable") {
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
completionReceivedRef.current = true
setIsComplete(true)
ws.close()
return
}
if (msg.type === "web_interaction" && msg.interaction) {
setIsWaitingNextInteraction(false)
@@ -478,6 +521,7 @@ const initMessage = {
}
ws.onclose = (event) => {
if (!isOpenRef.current) return
setConnectionStatus("offline")
if (!completionReceivedRef.current) {
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
@@ -489,8 +533,13 @@ const initMessage = {
}
if (!completionReceivedRef.current && !isComplete) {
setIsComplete(true)
onCompleteRef.current?.(-1)
if (backgroundUpdate) {
term.writeln(t("vmLxc.updates.backgroundDetached"))
attemptReconnect()
} else {
setIsComplete(true)
onCompleteRef.current?.(-1)
}
}
}
@@ -578,6 +627,7 @@ const initMessage = {
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
reconnectAttemptsRef.current = 0
completionReceivedRef.current = false
outputOffsetRef.current = 0
setIsComplete(false)
setInteractionInput("")
setCurrentInteraction(null)
@@ -641,8 +691,11 @@ const initMessage = {
}, [isOpen, isComplete, attemptReconnect])
const getScriptWebSocketUrl = (sid: string): string => {
const route = backgroundUpdate
? `/ws/lxc-update/${encodeURIComponent(backgroundUpdate.runId)}`
: `/ws/script/${sid}`
if (typeof window === "undefined") {
return `ws://localhost:${API_PORT}/ws/script/${sid}`
return `ws://localhost:${API_PORT}${route}`
}
const { protocol, hostname, port } = window.location
@@ -650,9 +703,9 @@ const initMessage = {
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
if (isStandardPort) {
return `${wsProtocol}//${hostname}/ws/script/${sid}`
return `${wsProtocol}//${hostname}${route}`
} else {
return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}`
return `${wsProtocol}//${hostname}:${API_PORT}${route}`
}
}
@@ -664,6 +717,10 @@ const initMessage = {
if (value === "cancel" || value === "") {
setCurrentInteraction(null)
setInteractionInput("")
if (backgroundUpdate) {
wsRef.current.send(JSON.stringify({ type: "cancel" }))
return
}
handleCloseModal()
return
}
+42 -26
View File
@@ -22,6 +22,8 @@ import useSWR from "swr"
import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { LxcUpdateJobs } from "./lxc-update-jobs"
import { markUpdateJobViewed, type LxcUpdateJob } from "../lib/lxc-update-jobs"
import { OCI_CANCELLED_EXIT_CODE, shouldRefreshOciAfterCompletion } from "@/lib/script-completion"
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
import { GuestBackupList } from "./guest-backup-list"
@@ -1998,6 +2000,7 @@ export function VirtualMachines() {
const [applyRestart, setApplyRestart] = useState(false)
const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
const [applyRunId, setApplyRunId] = useState<string>("")
const [applyResume, setApplyResume] = useState(false)
const [applyCheckedOs, setApplyCheckedOs] = useState(false)
const [applyTargetIds, setApplyTargetIds] = useState<string[]>([])
const [applyTargetLabels, setApplyTargetLabels] = useState<string[]>([])
@@ -2639,6 +2642,7 @@ export function VirtualMachines() {
setApplyVmid(vmid)
setApplyTarget(target)
setApplyRunId(runId)
setApplyResume(false)
setApplyCheckedOs(opts?.checkedOs === true)
setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets)
setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels)
@@ -2659,27 +2663,37 @@ export function VirtualMachines() {
setApplyStartedAt(Date.now())
setApplyOpen(true)
}
const handleApplyComplete = async (exitCode = 0) => {
const resumeApplyJob = (job: LxcUpdateJob) => {
setApplyVmid(job.vmid)
setApplyTarget(job.target)
setApplyRunId(job.id)
setApplyCheckedOs(job.script_path.endsWith("/install_checked_os_updates.sh"))
setApplyResume(true)
setApplyStartedAt(job.created_at * 1000)
setApplyOpen(true)
}
const handleApplyComplete = async (exitCode = 0, notifyBackend = true) => {
if (applyVmid == null) return
const duration = Math.max(0, Math.round((Date.now() - applyStartedAt) / 1000))
// The modal fires onComplete on any WS close (success or user cancel).
// Report the same run ID so the backend can collapse this compatibility
// callback with its authoritative process-completion hook.
// Legacy callers may report the same run ID to the compatibility route.
// Background Updates only refresh the view after the server's real exit.
try {
await fetchApi(`/api/lxc-updates/${applyVmid}/applied`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
success: exitCode === 0,
run_id: applyRunId,
target: applyTarget,
requested_targets: applyTargetIds,
target_labels: applyTargetLabels,
duration_seconds: duration,
ct_name: selectedVM?.name || `CT-${applyVmid}`,
refresh_docker_inventory: applyDockerRefresh,
}),
})
if (notifyBackend) {
await fetchApi(`/api/lxc-updates/${applyVmid}/applied`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
success: exitCode === 0,
run_id: applyRunId,
target: applyTarget,
requested_targets: applyTargetIds,
target_labels: applyTargetLabels,
duration_seconds: duration,
ct_name: selectedVM?.name || `CT-${applyVmid}`,
refresh_docker_inventory: applyDockerRefresh,
}),
})
}
} catch {
// Non-fatal — the backend process hook owns finalization.
}
@@ -7960,6 +7974,8 @@ export function VirtualMachines() {
/>
)}
<LxcUpdateJobs activeRunId={applyOpen ? applyRunId : undefined} onResume={resumeApplyJob} />
{/* LXC Update Apply Terminal — streams the apply_updates.sh
script over WS/PTY (same wiring as the hardware installers).
The backend finalizes on process exit; the close callback only
@@ -7971,7 +7987,13 @@ export function VirtualMachines() {
setApplyOpen(false)
setApplyVmid(null)
}}
onComplete={handleApplyComplete}
onComplete={(exitCode) => {
markUpdateJobViewed(applyRunId)
// The server-owned job is the sole completion authority.
void handleApplyComplete(exitCode ?? 1, false)
}}
backgroundUpdate={{ runId: applyRunId, vmid: applyVmid, resume: applyResume }}
cancelledExitCode={130}
scriptPath={applyCheckedOs
? "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh"
: "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"}
@@ -7981,13 +8003,7 @@ export function VirtualMachines() {
completedWithErrorMessage={(exitCode) =>
t("vmLxc.updates.terminalCompletedWithError", { code: exitCode })
}
description={
applyTarget === "os"
? t("vmLxc.updates.terminalDescriptionOs")
: applyTarget === "app"
? t("vmLxc.updates.terminalDescriptionApp")
: t("vmLxc.updates.terminalDescriptionBoth")
}
description={t("vmLxc.updates.backgroundDescription")}
params={{
VMID: String(applyVmid),
RUN_ID: applyRunId,