mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
Merge develop into OCI input review
This commit is contained in:
@@ -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>
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
changes: {
|
||||
added: [
|
||||
"Audit & Report — a new page under Admin that assesses the node against forty-three Proxmox checks, keeps a journal of everything ProxMenux changed on the host, lets the expected state be declared as a policy and prints focused reports.",
|
||||
"OCI manager Apps (Technology Preview) — applications installed from their official container images as native Proxmox LXC, with no Docker engine: a catalog of 464 applications, multi-container applications, hardware acceleration, updates that keep the data and recovery after a restore.",
|
||||
"OCI manager Apps (Technology Preview) — applications installed from their official container images as native Proxmox LXC, with no Docker engine: a catalog of 514 applications, multi-container applications, hardware acceleration, updates that keep the data and recovery after a restore.",
|
||||
"OCI applications in the Monitor — registered in the App tab, updated and modified from the Updates tab, with a Logs tab that follows their console and a watchdog switch.",
|
||||
"ProxMenux on Proxmox Backup Server — the same installer, a menu, post-install and a Monitor of its own; a remote PBS can also be watched from PVE with a read-only token (contributed by @jcastro).",
|
||||
"Search in the main menu across ProxMenux options, OCI applications and Helper-Scripts, and Helper-Scripts addons run on the host or inside the LXC they belong to.",
|
||||
@@ -356,7 +356,7 @@ const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <Package className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociCatalog",
|
||||
text: "OCI manager Apps (Technology Preview) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries 464 applications.",
|
||||
text: "OCI manager Apps (Technology Preview) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries 514 applications.",
|
||||
},
|
||||
{
|
||||
icon: <ScrollText className="h-5 w-5" />,
|
||||
|
||||
@@ -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()
|
||||
@@ -81,6 +84,7 @@ export function ScriptTerminalModal({
|
||||
// after dynamic imports resolve — captures the latest value without
|
||||
// re-binding the closure.
|
||||
const isOpenRef = useRef<boolean>(false)
|
||||
const openGenerationRef = useRef(0)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
|
||||
|
||||
@@ -93,6 +97,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 +135,7 @@ export function ScriptTerminalModal({
|
||||
}, [onComplete])
|
||||
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
if (!isOpenRef.current || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -154,7 +159,10 @@ 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 generation = openGenerationRef.current
|
||||
const ticketedUrl = await getTicketedWsUrl(wsUrl)
|
||||
if (!isOpenRef.current || !termRef.current || generation !== openGenerationRef.current) return
|
||||
const ws = new WebSocket(ticketedUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -170,9 +178,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 +205,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 +221,31 @@ const initMessage = {
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "job_output") {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
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") {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
if (backgroundUpdate && msg.type === "web_interaction" && msg.interaction === null) {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
return
|
||||
}
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
@@ -231,6 +266,11 @@ const initMessage = {
|
||||
return
|
||||
}
|
||||
if (msg.type === "script_complete") {
|
||||
if (backgroundUpdate) {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
@@ -255,6 +295,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!isOpenRef.current) return
|
||||
setConnectionStatus("offline")
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
@@ -266,13 +307,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
|
||||
@@ -300,12 +345,13 @@ const initMessage = {
|
||||
// otherwise we attach a Terminal to a stale ref and open a WS that
|
||||
// nobody reads.
|
||||
const wasOpenAtCall = isOpenRef.current
|
||||
const generation = ++openGenerationRef.current
|
||||
const [TerminalClass, FitAddonClass] = await Promise.all([
|
||||
import("xterm").then((mod) => mod.Terminal),
|
||||
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
||||
import("xterm/css/xterm.css"),
|
||||
])
|
||||
if (!wasOpenAtCall || !isOpenRef.current) return
|
||||
if (!wasOpenAtCall || !isOpenRef.current || generation !== openGenerationRef.current) return
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
@@ -366,7 +412,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 +432,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 +462,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,7 +478,32 @@ const initMessage = {
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "job_output") {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
if (msg.truncated) term.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
|
||||
term.write(msg.text)
|
||||
outputOffsetRef.current = msg.offset
|
||||
return
|
||||
}
|
||||
if (msg.type === "job_unavailable") {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (backgroundUpdate && msg.type === "web_interaction" && msg.interaction === null) {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
return
|
||||
}
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
@@ -450,6 +525,11 @@ const initMessage = {
|
||||
return
|
||||
}
|
||||
if (msg.type === "script_complete") {
|
||||
if (backgroundUpdate) {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
@@ -478,6 +558,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 +570,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,6 +638,7 @@ const initMessage = {
|
||||
if (isOpen) {
|
||||
initializeTerminal()
|
||||
} else {
|
||||
openGenerationRef.current++
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
@@ -578,6 +665,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)
|
||||
@@ -586,6 +674,28 @@ const initMessage = {
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (!backgroundUpdate) return // Transport mode is fixed for this instance.
|
||||
// Conditional parents can unmount an open modal. Detach that viewer too;
|
||||
// only the background job owns its process and completion result.
|
||||
isOpenRef.current = false
|
||||
openGenerationRef.current++
|
||||
for (const ref of [checkConnectionInterval, keepAliveIntervalRef]) {
|
||||
if (ref.current) clearInterval(ref.current)
|
||||
}
|
||||
for (const ref of [waitingTimeoutRef, reconnectTimeoutRef]) {
|
||||
if (ref.current) clearTimeout(ref.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.onmessage = null
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
termRef.current?.dispose()
|
||||
termRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const updateDeviceType = () => {
|
||||
const width = window.innerWidth
|
||||
@@ -641,8 +751,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 +763,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 +777,10 @@ const initMessage = {
|
||||
if (value === "cancel" || value === "") {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
if (backgroundUpdate) {
|
||||
wsRef.current.send(JSON.stringify({ type: "cancel" }))
|
||||
return
|
||||
}
|
||||
handleCloseModal()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -2697,6 +2711,12 @@ export function VirtualMachines() {
|
||||
// performs the requests (the data map is only a render seed), so no
|
||||
// invalidation is needed and there is no "Loading applications" flash.
|
||||
void fetchLxcApps(applyVmid)
|
||||
// Recovery can be opened from the job banner while another CT is selected.
|
||||
// Refresh the operated guest without changing that other guest's result UI.
|
||||
if (selectedVMRef.current?.vmid !== applyVmid) {
|
||||
void mutate()
|
||||
return
|
||||
}
|
||||
// Enter the "Comprobando resultado…" state on the Updates tab.
|
||||
// Baseline snapshot lets a downstream useEffect detect when the
|
||||
// SWR poll actually delivers the post-apply counts (as opposed
|
||||
@@ -7960,6 +7980,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 +7993,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 +8009,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,
|
||||
|
||||
@@ -63,18 +63,21 @@
|
||||
},
|
||||
"anthropic": {
|
||||
"models": [
|
||||
"claude-haiku-5-5",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-opus-4-7",
|
||||
"claude-sonnet-5-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-5-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5-1",
|
||||
"claude-opus-4-6"
|
||||
"claude-fable-5-1"
|
||||
],
|
||||
"recommended": "claude-haiku-4-5-20251001",
|
||||
"_note": "Verified 2026-09-02 — 9 of 11 tested pass. The `claude-haiku-4-5` alias no longer resolves upstream; the working snapshot is `claude-haiku-4-5-20251001` — used as recommended (2.89s). Full family surfaced: haiku/sonnet/opus/fable across generations 4.5/4.6/4.7/4.8/5."
|
||||
"recommended": "claude-haiku-5-5",
|
||||
"_note": "Verified 2026-10-26 against the live API. Added the 5.x family, recommended `claude-haiku-5-5`. Dropped `claude-sonnet-4-5` (retires 2026-11-30). `claude-opus-5` left out: false refusals on plain notifications."
|
||||
},
|
||||
"openrouter": {
|
||||
"models": [
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Opaque viewer metadata only; updater commands and credentials stay server-side. */
|
||||
export type LxcUpdateJob = {
|
||||
id: string
|
||||
vmid: number
|
||||
state: "running" | "finalizing" | "complete"
|
||||
target: "os" | "app" | "both"
|
||||
ct_name: string
|
||||
script_path: string
|
||||
created_at: number
|
||||
exit_code: number | null
|
||||
}
|
||||
|
||||
const SEEN = "proxmenux-viewed-update-jobs"
|
||||
|
||||
export function viewedUpdateJobs(): string[] {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(SEEN) || "[]")
|
||||
return Array.isArray(value) ? value.filter((id) => typeof id === "string").slice(-128) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function markUpdateJobViewed(id: string) {
|
||||
try {
|
||||
localStorage.setItem(SEEN, JSON.stringify([...new Set([...viewedUpdateJobs(), id])].slice(-128)))
|
||||
window.dispatchEvent(new Event("proxmenux-update-jobs-viewed"))
|
||||
} catch {
|
||||
// Unavailable browser storage never controls or restarts server work.
|
||||
}
|
||||
}
|
||||
@@ -1495,7 +1495,13 @@
|
||||
"startBeforeCheck": "Starten Sie den Container und suchen Sie nach Updates. Kein Abzeichen bedeutet nicht, dass es keine Updates gibt.",
|
||||
"runCustomCommand": "Benutzerdefinierten Befehl ausführen",
|
||||
"installedVersionUnverified": "Die installierte Version ist nicht bestätigt. Die Verfügbarkeit des Updates konnte nicht bestätigt werden.",
|
||||
"versionStatusUnknown": "Die Verfügbarkeit des Updates ist nicht bestätigt. Versionsinformationen sind nicht verfügbar oder unvollständig."
|
||||
"versionStatusUnknown": "Die Verfügbarkeit des Updates ist nicht bestätigt. Versionsinformationen sind nicht verfügbar oder unvollständig.",
|
||||
"backgroundRunning": "{name} (CT {vmid}): Auf dem Server läuft ein Update.",
|
||||
"backgroundOutputTrimmed": "Die frühere Ausgabe wurde gekürzt; das Update läuft normal weiter.",
|
||||
"backgroundFinished": "{name} (CT {vmid}): Ein Update ist abgeschlossen. Ergebnis ansehen.",
|
||||
"viewBackgroundJob": "Update anzeigen",
|
||||
"backgroundDetached": "Die Anzeige wurde getrennt. Das Update läuft auf dem Server weiter; verbinden Sie sich nach der Anmeldung erneut.",
|
||||
"backgroundDescription": "Das Schließen dieses Fensters beendet das Update nicht. Mit Strg+C brechen Sie es ab."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Sammelaktualisierung",
|
||||
@@ -3724,7 +3730,7 @@
|
||||
"guestBackupJobs": "Backup-Jobs von VMs und LXCs – die Backup-Registerkarte listet die geplanten Jobs von Proxmox auf und erstellt, bearbeitet, aktiviert und startet sie; es sind dieselben wie unter Datacenter → Backup (beigetragen von @MattiaC46).",
|
||||
"notificationLanguage": "Benachrichtigungen in Ihrer Sprache – Titel, Texte und tägliche Zusammenfassungen werden in der für Benachrichtigungen gewählten Sprache verfasst (beigetragen von @Vaso73).",
|
||||
"auditReviewDate": "Audit & Report – ein akzeptierter Befund kann ein Überprüfungsdatum tragen, das ihn wieder vorlegt, ohne die Entscheidung zurückzuziehen, und der Vergleich gibt an, ob sich ein Befund verschlechtert, verbessert oder gehalten hat (mit @Vaso73).",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) – ein neuer Eintrag im ProxMenux-Menü installiert Anwendungen aus ihren offiziellen Container-Images als native Proxmox-LXC, ohne Docker-Engine. Der Katalog umfasst 464 Anwendungen.",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) – ein neuer Eintrag im ProxMenux-Menü installiert Anwendungen aus ihren offiziellen Container-Images als native Proxmox-LXC, ohne Docker-Engine. Der Katalog umfasst 514 Anwendungen.",
|
||||
"pbsSupport": "ProxMenux auf Proxmox Backup Server – es wird mit demselben Befehl installiert und öffnet ein eigenes Menü, mit Post-Install und einem Monitor, der Zustand, Datastores und Aufgaben des Servers zeigt (beigetragen von @jcastro).",
|
||||
"menuSearch": "Suche im Hauptmenü – ein Name oder ein Teil davon findet eine ProxMenux-Option, eine OCI-Anwendung oder ein Helper-Script und öffnet es auf seinem ersten Bildschirm."
|
||||
}
|
||||
|
||||
@@ -1551,6 +1551,12 @@
|
||||
"installedVersionUnverified": "Installed version is unverified. Update availability could not be confirmed.",
|
||||
"versionStatusUnknown": "Update availability is unverified. Version information is unavailable or incomplete.",
|
||||
"terminalTitle": "Apply updates — CT {vmid}",
|
||||
"backgroundRunning": "{name} (CT {vmid}): an update is running on the server.",
|
||||
"backgroundOutputTrimmed": "Earlier output was trimmed; the update continues normally.",
|
||||
"backgroundFinished": "{name} (CT {vmid}): an update has finished. View its result.",
|
||||
"viewBackgroundJob": "View update",
|
||||
"backgroundDetached": "The viewer disconnected. The update continues on the server; reconnect after logging in.",
|
||||
"backgroundDescription": "Closing this window leaves the update running. Use Ctrl+C to cancel it.",
|
||||
"terminalDescriptionOs": "Applying OS package updates inside the container...",
|
||||
"terminalDescriptionApp": "Running the application updater inside the container...",
|
||||
"terminalDescriptionBoth": "Applying OS package and application updates inside the container...",
|
||||
@@ -3851,7 +3857,7 @@
|
||||
"guestBackupJobs": "Backup jobs of VMs and LXCs — the Backup tab lists the scheduled jobs of Proxmox and creates, edits, enables and runs them; they are the same ones as Datacenter → Backup (contributed by @MattiaC46).",
|
||||
"notificationLanguage": "Notifications in your language — titles, bodies and daily digests are written in the language chosen for notifications (contributed by @Vaso73).",
|
||||
"auditReviewDate": "Audit & Report — an accepted finding can carry a review date that brings it back without withdrawing the decision, and the comparison says whether a finding got worse, got better or held (with @Vaso73).",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries 464 applications.",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries 514 applications.",
|
||||
"pbsSupport": "ProxMenux on Proxmox Backup Server — it installs with the same command and opens a menu of its own, with post-install and a Monitor showing the health, datastores and tasks of the server (contributed by @jcastro).",
|
||||
"menuSearch": "Search in the main menu — a name or part of it finds a ProxMenux option, an OCI application or a Helper-Script, and opens it at its first screen."
|
||||
}
|
||||
|
||||
@@ -1648,7 +1648,13 @@
|
||||
"startBeforeCheck": "Inicia el contenedor y después comprueba las actualizaciones. Que no aparezca el indicador no significa que no haya actualizaciones.",
|
||||
"runCustomCommand": "Ejecutar comando personalizado",
|
||||
"installedVersionUnverified": "La versión instalada no está verificada. No se pudo confirmar si hay una actualización disponible.",
|
||||
"versionStatusUnknown": "No se ha verificado si hay una actualización disponible. La información de versión falta o está incompleta."
|
||||
"versionStatusUnknown": "No se ha verificado si hay una actualización disponible. La información de versión falta o está incompleta.",
|
||||
"backgroundRunning": "{name} (CT {vmid}): hay una actualización en curso en el servidor.",
|
||||
"backgroundOutputTrimmed": "Se ha recortado la salida anterior; la actualización continúa con normalidad.",
|
||||
"backgroundFinished": "{name} (CT {vmid}): ha terminado una actualización. Consulta su resultado.",
|
||||
"viewBackgroundJob": "Ver actualización",
|
||||
"backgroundDetached": "El visor se ha desconectado. La actualización continúa en el servidor; vuelve a conectarte después de iniciar sesión.",
|
||||
"backgroundDescription": "Cerrar esta ventana no detiene la actualización. Pulsa Ctrl+C para cancelarla."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Actualización en bloque",
|
||||
@@ -3877,7 +3883,7 @@
|
||||
"guestBackupJobs": "Trabajos de backup de VM y LXC — la pestaña Backup lista los trabajos programados de Proxmox y permite crearlos, editarlos, activarlos y ejecutarlos; son los mismos de Datacenter → Backup (aportado por @MattiaC46).",
|
||||
"notificationLanguage": "Notificaciones en tu idioma — títulos, cuerpos y resúmenes diarios se escriben en el idioma elegido para las notificaciones (aportado por @Vaso73).",
|
||||
"auditReviewDate": "Auditoría e Informes — un hallazgo aceptado puede llevar una fecha de revisión que lo trae de vuelta sin retirar la decisión, y la comparación indica si un hallazgo empeoró, mejoró o se mantuvo (con @Vaso73).",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — una nueva entrada del menú de ProxMenux instala aplicaciones desde sus imágenes de contenedor oficiales como LXC nativos de Proxmox, sin motor Docker. El catálogo reúne 464 aplicaciones.",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — una nueva entrada del menú de ProxMenux instala aplicaciones desde sus imágenes de contenedor oficiales como LXC nativos de Proxmox, sin motor Docker. El catálogo reúne 514 aplicaciones.",
|
||||
"pbsSupport": "ProxMenux en Proxmox Backup Server — se instala con el mismo comando y abre un menú propio, con post-install y un Monitor que muestra la salud, los datastores y las tareas del servidor (aportado por @jcastro).",
|
||||
"menuSearch": "Búsqueda en el menú principal — un nombre o parte de él encuentra una opción de ProxMenux, una aplicación OCI o un Helper-Script, y lo abre en su primera pantalla."
|
||||
}
|
||||
|
||||
@@ -1495,7 +1495,13 @@
|
||||
"startBeforeCheck": "Démarrez le conteneur, puis vérifiez les mises à jour. L'absence de badge ne signifie pas qu'il n'y a pas de mises à jour.",
|
||||
"runCustomCommand": "Exécuter une commande personnalisée",
|
||||
"installedVersionUnverified": "La version installée n'est pas vérifiée. La disponibilité d'une mise à jour n'a pas pu être confirmée.",
|
||||
"versionStatusUnknown": "La disponibilité d'une mise à jour n'est pas vérifiée. Les informations de version sont indisponibles ou incomplètes."
|
||||
"versionStatusUnknown": "La disponibilité d'une mise à jour n'est pas vérifiée. Les informations de version sont indisponibles ou incomplètes.",
|
||||
"backgroundRunning": "{name} (CT {vmid}) : une mise à jour est en cours sur le serveur.",
|
||||
"backgroundOutputTrimmed": "La sortie précédente a été raccourcie ; la mise à jour continue normalement.",
|
||||
"backgroundFinished": "{name} (CT {vmid}) : une mise à jour est terminée. Consultez son résultat.",
|
||||
"viewBackgroundJob": "Afficher la mise à jour",
|
||||
"backgroundDetached": "L’affichage s’est déconnecté. La mise à jour continue sur le serveur ; reconnectez-vous après vous être authentifié.",
|
||||
"backgroundDescription": "Fermer cette fenêtre n’arrête pas la mise à jour. Utilisez Ctrl+C pour l’annuler."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Mise à jour groupée",
|
||||
@@ -3724,7 +3730,7 @@
|
||||
"guestBackupJobs": "Tâches de sauvegarde des VM et des LXC — l'onglet Sauvegarde liste les tâches planifiées de Proxmox et permet de les créer, les modifier, les activer et les exécuter ; ce sont les mêmes que dans Datacenter → Backup (contribution de @MattiaC46).",
|
||||
"notificationLanguage": "Notifications dans votre langue — les titres, les corps et les résumés quotidiens sont rédigés dans la langue choisie pour les notifications (contribution de @Vaso73).",
|
||||
"auditReviewDate": "Audit & Report — une constatation acceptée peut porter une date de révision qui la fait réapparaître sans retirer la décision, et la comparaison indique si une constatation s'est aggravée, améliorée ou maintenue (avec @Vaso73).",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — une nouvelle entrée du menu ProxMenux installe les applications à partir de leurs images de conteneur officielles en tant que LXC Proxmox natifs, sans moteur Docker. Le catalogue réunit 464 applications.",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — une nouvelle entrée du menu ProxMenux installe les applications à partir de leurs images de conteneur officielles en tant que LXC Proxmox natifs, sans moteur Docker. Le catalogue réunit 514 applications.",
|
||||
"pbsSupport": "ProxMenux sur Proxmox Backup Server — il s'installe avec la même commande et ouvre son propre menu, avec post-installation et un Monitor qui affiche la santé, les datastores et les tâches du serveur (contribution de @jcastro).",
|
||||
"menuSearch": "Recherche dans le menu principal — un nom ou une partie du nom trouve une option de ProxMenux, une application OCI ou un Helper-Script, et l'ouvre sur son premier écran."
|
||||
}
|
||||
|
||||
@@ -1495,7 +1495,13 @@
|
||||
"startBeforeCheck": "Avvia il container, poi controlla gli aggiornamenti. L'assenza del badge non significa che non ci siano aggiornamenti.",
|
||||
"runCustomCommand": "Esegui comando personalizzato",
|
||||
"installedVersionUnverified": "La versione installata non è verificata. Non è stato possibile confermare la disponibilità di un aggiornamento.",
|
||||
"versionStatusUnknown": "La disponibilità di un aggiornamento non è verificata. Le informazioni sulla versione non sono disponibili o sono incomplete."
|
||||
"versionStatusUnknown": "La disponibilità di un aggiornamento non è verificata. Le informazioni sulla versione non sono disponibili o sono incomplete.",
|
||||
"backgroundRunning": "{name} (CT {vmid}): un aggiornamento è in corso sul server.",
|
||||
"backgroundOutputTrimmed": "L'output precedente è stato accorciato; l'aggiornamento prosegue normalmente.",
|
||||
"backgroundFinished": "{name} (CT {vmid}): un aggiornamento è terminato. Visualizza il risultato.",
|
||||
"viewBackgroundJob": "Visualizza aggiornamento",
|
||||
"backgroundDetached": "La visualizzazione si è disconnessa. L'aggiornamento prosegue sul server; ricollegati dopo aver effettuato l'accesso.",
|
||||
"backgroundDescription": "Chiudere questa finestra non interrompe l'aggiornamento. Usa Ctrl+C per annullarlo."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Aggiornamento in blocco",
|
||||
@@ -3724,7 +3730,7 @@
|
||||
"guestBackupJobs": "Job di backup di VM e LXC: la scheda Backup elenca i job pianificati di Proxmox e li crea, modifica, abilita ed esegue; sono gli stessi di Datacenter → Backup (contributo di @MattiaC46).",
|
||||
"notificationLanguage": "Notifiche nella tua lingua: titoli, testi e riepiloghi giornalieri sono scritti nella lingua scelta per le notifiche (contributo di @Vaso73).",
|
||||
"auditReviewDate": "Audit & Report: un risultato accettato può avere una data di revisione che lo ripresenta senza ritirare la decisione, e il confronto indica se un risultato è peggiorato, migliorato o rimasto invariato (con @Vaso73).",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview): una nuova voce nel menu di ProxMenux installa le applicazioni dalle loro immagini container ufficiali come LXC nativi di Proxmox, senza motore Docker. Il catalogo contiene 464 applicazioni.",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview): una nuova voce nel menu di ProxMenux installa le applicazioni dalle loro immagini container ufficiali come LXC nativi di Proxmox, senza motore Docker. Il catalogo contiene 514 applicazioni.",
|
||||
"pbsSupport": "ProxMenux su Proxmox Backup Server: si installa con lo stesso comando e apre un menu proprio, con post-installazione e un Monitor che mostra lo stato, i datastore e le attività del server (contributo di @jcastro).",
|
||||
"menuSearch": "Ricerca nel menu principale: basta un nome o parte di esso per trovare un'opzione di ProxMenux, un'applicazione OCI o un Helper-Script e aprirne la prima schermata."
|
||||
}
|
||||
|
||||
@@ -1495,7 +1495,13 @@
|
||||
"startBeforeCheck": "Inicie o contêiner e depois verifique as atualizações. A ausência do selo não significa que não haja atualizações.",
|
||||
"runCustomCommand": "Executar comando personalizado",
|
||||
"installedVersionUnverified": "A versão instalada não foi verificada. Não foi possível confirmar se há uma atualização disponível.",
|
||||
"versionStatusUnknown": "A disponibilidade de atualização não foi verificada. As informações de versão estão indisponíveis ou incompletas."
|
||||
"versionStatusUnknown": "A disponibilidade de atualização não foi verificada. As informações de versão estão indisponíveis ou incompletas.",
|
||||
"backgroundRunning": "{name} (CT {vmid}): está a decorrer uma atualização no servidor.",
|
||||
"backgroundOutputTrimmed": "A saída anterior foi encurtada; a atualização continua normalmente.",
|
||||
"backgroundFinished": "{name} (CT {vmid}): uma atualização terminou. Veja o resultado.",
|
||||
"viewBackgroundJob": "Ver atualização",
|
||||
"backgroundDetached": "O visualizador desligou-se. A atualização continua no servidor; volte a ligar-se depois de iniciar sessão.",
|
||||
"backgroundDescription": "Fechar esta janela não interrompe a atualização. Use Ctrl+C para a cancelar."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Atualização em bloco",
|
||||
@@ -3724,7 +3730,7 @@
|
||||
"guestBackupJobs": "Tarefas de backup de VM e LXC — o separador Backup lista as tarefas agendadas do Proxmox e permite criá-las, editá-las, ativá-las e executá-las; são as mesmas de Datacenter → Backup (contribuição de @MattiaC46).",
|
||||
"notificationLanguage": "Notificações no seu idioma — títulos, corpos e resumos diários são escritos no idioma escolhido para as notificações (contribuição de @Vaso73).",
|
||||
"auditReviewDate": "Auditoria & Relatório — uma constatação aceite pode ter uma data de revisão que a traz de volta sem retirar a decisão, e a comparação indica se uma constatação piorou, melhorou ou se manteve (com @Vaso73).",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — uma nova entrada no menu do ProxMenux instala aplicações a partir das suas imagens de contentor oficiais como LXC nativos do Proxmox, sem motor Docker. O catálogo reúne 464 aplicações.",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — uma nova entrada no menu do ProxMenux instala aplicações a partir das suas imagens de contentor oficiais como LXC nativos do Proxmox, sem motor Docker. O catálogo reúne 514 aplicações.",
|
||||
"pbsSupport": "ProxMenux no Proxmox Backup Server — instala-se com o mesmo comando e abre um menu próprio, com pós-instalação e um Monitor que mostra o estado, os datastores e as tarefas do servidor (contribuição de @jcastro).",
|
||||
"menuSearch": "Pesquisa no menu principal — um nome ou parte dele encontra uma opção do ProxMenux, uma aplicação OCI ou um Helper-Script e abre-o no seu primeiro ecrã."
|
||||
}
|
||||
|
||||
@@ -1425,6 +1425,12 @@
|
||||
"installedVersionUnverified": "Nainštalovaná verzia nie je overená. Dostupnosť aktualizácie sa nepodarilo potvrdiť.",
|
||||
"versionStatusUnknown": "Dostupnosť aktualizácie nie je overená. Údaje o verzii chýbajú alebo sú neúplné.",
|
||||
"terminalTitle": "Aktualizácia — CT {vmid}",
|
||||
"backgroundRunning": "{name} (CT {vmid}): na serveri prebieha aktualizácia.",
|
||||
"backgroundOutputTrimmed": "Starší výpis bol skrátený; aktualizácia pokračuje normálne.",
|
||||
"backgroundFinished": "{name} (CT {vmid}): aktualizácia sa skončila. Pozrite si výsledok.",
|
||||
"viewBackgroundJob": "Zobraziť aktualizáciu",
|
||||
"backgroundDetached": "Spojenie s terminálom sa prerušilo. Aktualizácia na serveri pokračuje; po prihlásení sa znova pripojte.",
|
||||
"backgroundDescription": "Zatvorením okna sa aktualizácia nezastaví. Zrušiť ju môžete cez Ctrl+C.",
|
||||
"terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...",
|
||||
"terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...",
|
||||
"terminalDescriptionBoth": "Aktualizujem systém aj aplikácie v kontajneri...",
|
||||
@@ -3723,7 +3729,7 @@
|
||||
"guestBackupJobs": "Úlohy zálohovania VM a LXC — karta Zálohovanie zobrazuje naplánované úlohy Proxmoxu a umožňuje ich vytvárať, upravovať, povoľovať a spúšťať; sú to tie isté ako v Datacenter → Backup (prispel @MattiaC46).",
|
||||
"notificationLanguage": "Upozornenia vo vašom jazyku — názvy, texty a denné súhrny sú napísané v jazyku zvolenom pre upozornenia (prispel @Vaso73).",
|
||||
"auditReviewDate": "Audit a správy — akceptované zistenie môže mať dátum kontroly, ktorý ho znova pripomenie bez zrušenia rozhodnutia, a porovnanie ukáže, či sa zistenie zhoršilo, zlepšilo alebo zostalo rovnaké (s @Vaso73).",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — nová položka v ponuke ProxMenux inštaluje aplikácie z ich oficiálnych kontajnerových image ako natívne Proxmox LXC, bez enginu Docker. Katalóg obsahuje 464 aplikácií.",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — nová položka v ponuke ProxMenux inštaluje aplikácie z ich oficiálnych kontajnerových image ako natívne Proxmox LXC, bez enginu Docker. Katalóg obsahuje 514 aplikácií.",
|
||||
"pbsSupport": "ProxMenux na Proxmox Backup Server — inštaluje sa rovnakým príkazom a otvára vlastnú ponuku s poinštaláciou a Monitorom, ktorý zobrazuje stav, úložiská dát a úlohy servera (prispel @jcastro).",
|
||||
"menuSearch": "Vyhľadávanie v hlavnej ponuke — názov alebo jeho časť nájde možnosť ProxMenux, aplikáciu OCI alebo Helper-Script a otvorí ju na jej prvej obrazovke."
|
||||
}
|
||||
|
||||
@@ -1495,7 +1495,13 @@
|
||||
"startBeforeCheck": "Starta behållaren och kontrollera sedan uppdateringar. Att ett märke saknas betyder inte att det inte finns uppdateringar.",
|
||||
"runCustomCommand": "Kör anpassat kommando",
|
||||
"installedVersionUnverified": "Den installerade versionen är inte verifierad. Det gick inte att bekräfta om en uppdatering finns.",
|
||||
"versionStatusUnknown": "Det är inte verifierat om en uppdatering finns. Versionsinformationen saknas eller är ofullständig."
|
||||
"versionStatusUnknown": "Det är inte verifierat om en uppdatering finns. Versionsinformationen saknas eller är ofullständig.",
|
||||
"backgroundRunning": "{name} (CT {vmid}): en uppdatering pågår på servern.",
|
||||
"backgroundOutputTrimmed": "Tidigare utdata har kortats; uppdateringen fortsätter som vanligt.",
|
||||
"backgroundFinished": "{name} (CT {vmid}): en uppdatering är klar. Visa resultatet.",
|
||||
"viewBackgroundJob": "Visa uppdatering",
|
||||
"backgroundDetached": "Visningen kopplades från. Uppdateringen fortsätter på servern; anslut igen efter inloggningen.",
|
||||
"backgroundDescription": "Att stänga det här fönstret stoppar inte uppdateringen. Använd Ctrl+C för att avbryta den."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Gruppuppdatering",
|
||||
@@ -3724,7 +3730,7 @@
|
||||
"guestBackupJobs": "Säkerhetskopieringsjobb för VM:ar och LXC:er — fliken Säkerhetskopiering listar Proxmox schemalagda jobb och skapar, redigerar, aktiverar och kör dem; de är desamma som i Datacenter → Backup (bidrag från @MattiaC46).",
|
||||
"notificationLanguage": "Meddelanden på ditt språk — titlar, brödtexter och dagliga sammanfattningar skrivs på det språk som valts för meddelanden (bidrag från @Vaso73).",
|
||||
"auditReviewDate": "Audit & Report — ett accepterat fynd kan ha ett granskningsdatum som tar upp det igen utan att beslutet dras tillbaka, och jämförelsen visar om ett fynd blev värre, blev bättre eller låg kvar (med @Vaso73).",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — en ny post i ProxMenux-menyn installerar applikationer från deras officiella containeravbilder som inbyggda Proxmox LXC, utan Docker-motor. Katalogen innehåller 464 applikationer.",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) — en ny post i ProxMenux-menyn installerar applikationer från deras officiella containeravbilder som inbyggda Proxmox LXC, utan Docker-motor. Katalogen innehåller 514 applikationer.",
|
||||
"pbsSupport": "ProxMenux på Proxmox Backup Server — installeras med samma kommando och öppnar en egen meny, med efterinstallation och en Monitor som visar serverns hälsa, datalager och uppgifter (bidrag från @jcastro).",
|
||||
"menuSearch": "Sökning i huvudmenyn — ett namn eller en del av det hittar ett ProxMenux-alternativ, en OCI-applikation eller ett Helper-Script och öppnar det på dess första skärm."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Anthropic (Claude) provider implementation.
|
||||
|
||||
Anthropic's Claude models are excellent for text generation and translation.
|
||||
Models use "-latest" aliases that auto-update to newest versions.
|
||||
Models are addressed by their pinned IDs (the "-latest" aliases were retired).
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from .base import AIProvider, AIProviderError
|
||||
@@ -15,29 +15,35 @@ class AnthropicProvider(AIProvider):
|
||||
API_URL = "https://api.anthropic.com/v1/messages"
|
||||
API_VERSION = "2023-06-01"
|
||||
|
||||
# Anthropic model aliases that resolve to pinned snapshots (per docs
|
||||
# at platform.claude.com/docs/en/docs/about-claude/models). Kept as a
|
||||
# runtime fallback for the `/api/notifications/provider-models`
|
||||
# intersection with `verified_ai_models.json`; the JSON is the
|
||||
# authoritative UI-facing list. Refreshed 2026-07-13 to drop the
|
||||
# deprecated claude-3-5-* aliases (retired upstream, no longer in the
|
||||
# Models API response) and add the current-generation IDs. Ordered
|
||||
# cheapest → most capable so the first entry is a safe default.
|
||||
# Current Claude API model IDs (per docs at
|
||||
# platform.claude.com/docs/en/docs/about-claude/models). Kept as a
|
||||
# runtime fallback for `/api/notifications/provider-models` when
|
||||
# `verified_ai_models.json` has no Anthropic entry; the JSON is the
|
||||
# authoritative UI-facing list. Refreshed 2026-10-26 against the live
|
||||
# Models API. Ordered cheapest → most capable so the first entry is a
|
||||
# safe default.
|
||||
KNOWN_MODELS = [
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-5-5",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-sonnet-5-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-5-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5-1",
|
||||
]
|
||||
|
||||
# Newer models (Opus 5.x, Fable) may think before answering and the
|
||||
# thinking counts against max_tokens, so a small cap can end the turn
|
||||
# with no text at all. max_tokens is a ceiling, not a cost.
|
||||
MIN_MAX_TOKENS = 1024
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""Return known Anthropic model aliases.
|
||||
|
||||
Anthropic doesn't have a public models list API, but their "-latest"
|
||||
aliases auto-update to the newest versions, making them reliable choices.
|
||||
"""
|
||||
"""Return known Anthropic model IDs."""
|
||||
return self.KNOWN_MODELS
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
@@ -68,7 +74,7 @@ class AnthropicProvider(AIProvider):
|
||||
'messages': [
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'max_tokens': max_tokens,
|
||||
'max_tokens': max(max_tokens, self.MIN_MAX_TOKENS),
|
||||
}
|
||||
|
||||
headers = {
|
||||
@@ -79,11 +85,20 @@ class AnthropicProvider(AIProvider):
|
||||
|
||||
result = self._make_request(self.API_URL, payload, headers)
|
||||
|
||||
try:
|
||||
# Anthropic returns content as array of content blocks
|
||||
content = result['content']
|
||||
if isinstance(content, list) and len(content) > 0:
|
||||
return content[0].get('text', '').strip()
|
||||
return str(content).strip()
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
# A safety classifier can decline even benign notifications
|
||||
# (HTTP 200, stop_reason "refusal", empty content). Raising lets the
|
||||
# caller fall back to the original, untranslated notification.
|
||||
if result.get('stop_reason') == 'refusal':
|
||||
category = (result.get('stop_details') or {}).get('category')
|
||||
raise AIProviderError(f"Request declined by Anthropic (category: {category})")
|
||||
|
||||
# Content is a list of blocks; thinking blocks may come before the
|
||||
# text, so take the first text block rather than content[0].
|
||||
content = result.get('content')
|
||||
if not isinstance(content, list):
|
||||
raise AIProviderError("Unexpected response format: missing content")
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get('type') == 'text':
|
||||
return block.get('text', '').strip()
|
||||
raise AIProviderError(
|
||||
f"No text in response (stop_reason: {result.get('stop_reason')})")
|
||||
|
||||
@@ -148,6 +148,7 @@ cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
|
||||
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
|
||||
cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_update_jobs.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/local_app_icons.py" "$APP_DIR/usr/bin/"
|
||||
|
||||
@@ -514,13 +514,10 @@ def get_provider_models():
|
||||
'message': f'Found {len(models)} local models'
|
||||
})
|
||||
|
||||
# Handle Anthropic - no models list API, return verified models directly
|
||||
# Handle Anthropic - return verified models directly
|
||||
if provider == 'anthropic':
|
||||
models = list(verified_models) if verified_models else [
|
||||
'claude-3-5-haiku-latest',
|
||||
'claude-3-5-sonnet-latest',
|
||||
'claude-3-opus-latest',
|
||||
]
|
||||
from ai_providers.anthropic_provider import AnthropicProvider
|
||||
models = list(verified_models) if verified_models else list(AnthropicProvider.KNOWN_MODELS)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'models': sorted(models),
|
||||
|
||||
@@ -134,6 +134,72 @@ def _run_script_completion_hook(script_path, params, exit_code, duration_seconds
|
||||
except Exception as exc:
|
||||
print(f"[ProxMenux] script completion hook failed: {exc}", flush=True)
|
||||
|
||||
|
||||
@terminal_bp.route('/api/lxc-update-jobs', methods=['GET'])
|
||||
@require_admin_scope
|
||||
def lxc_update_jobs_status():
|
||||
"""Read-only recovery after login. This never creates or restarts a job."""
|
||||
from lxc_update_jobs import list_jobs
|
||||
return jsonify({'jobs': list_jobs()})
|
||||
|
||||
|
||||
@sock.route('/ws/lxc-update/<run_id>')
|
||||
def lxc_update_viewer(ws, run_id):
|
||||
"""An authenticated viewer, deliberately not the owner of the update."""
|
||||
if not _ws_auth_check():
|
||||
ws.close(reason=1008, message="Authentication required")
|
||||
return
|
||||
from lxc_update_jobs import get, start
|
||||
try:
|
||||
init = json.loads(ws.receive(timeout=10))
|
||||
if not isinstance(init, dict) or type(init.get('resume')) is not bool:
|
||||
raise ValueError("Invalid update job request")
|
||||
offset = init.get('offset', 0)
|
||||
if type(offset) is not int or offset < 0:
|
||||
raise ValueError("Invalid update output cursor")
|
||||
if init['resume']:
|
||||
job = get(run_id, init.get('vmid'))
|
||||
else:
|
||||
job = start(run_id, init.get('script_path'), init.get('params'),
|
||||
_run_script_completion_hook)
|
||||
revision = -1
|
||||
while True:
|
||||
view = job.read(offset)
|
||||
if view['text'] or view['offset'] != offset:
|
||||
ws.send(json.dumps({'type': 'job_output', 'text': view['text'],
|
||||
'offset': view['offset'], 'truncated': view['truncated']}))
|
||||
offset = view['offset']
|
||||
if view['revision'] != revision:
|
||||
revision = view['revision']
|
||||
# A second viewer must also dismiss a question already
|
||||
# answered elsewhere, or cleared when the job ends.
|
||||
ws.send(json.dumps({'type': 'web_interaction',
|
||||
'interaction': view['interaction']}))
|
||||
if view['state'] == 'complete':
|
||||
ws.send(json.dumps({'type': 'script_complete', 'exit_code': view['exit_code']}))
|
||||
break
|
||||
data = ws.receive(timeout=0.2)
|
||||
if data is None:
|
||||
continue
|
||||
try:
|
||||
message = json.loads(data)
|
||||
except (TypeError, ValueError):
|
||||
message = data
|
||||
if isinstance(message, dict) and message.get('type') == 'ping':
|
||||
ws.send('{"type":"pong"}')
|
||||
else:
|
||||
job.input(message if isinstance(message, dict) else data)
|
||||
except (ValueError, KeyError, OSError, TypeError) as exc:
|
||||
try:
|
||||
ws.send(json.dumps({'type': 'job_unavailable', 'message': str(exc)}))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# Viewer loss, including auth expiry/reload, has no job side effects.
|
||||
pass
|
||||
# No process termination, PTY close or completion callback here. The job's
|
||||
# worker alone owns those, even while no viewer is authenticated/connected.
|
||||
|
||||
@terminal_bp.route('/api/terminal/health', methods=['GET'])
|
||||
def terminal_health():
|
||||
"""Health check for terminal service"""
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Browser-independent owners for explicitly confirmed LXC Updates runs.
|
||||
|
||||
Only the two Updates entrypoints are allowed. Import/list/attach never runs
|
||||
anything. Interactive /ws/script consumers retain their original lifecycle.
|
||||
Jobs survive viewer loss, not a Monitor/host restart; missing resume IDs fail
|
||||
closed. The update shell still owns the shared scheduler/control/check flock.
|
||||
"""
|
||||
import codecs
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import termios
|
||||
import fcntl
|
||||
import threading
|
||||
import time
|
||||
|
||||
SCRIPTS = {
|
||||
"/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh",
|
||||
"/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh",
|
||||
}
|
||||
PARAMS = {
|
||||
"VMID", "RUN_ID", "CT_NAME", "TARGET", "REQUESTED_TARGETS_JSON",
|
||||
"TARGET_LABELS_JSON", "BACKUP", "BACKUP_STORAGE", "RESTART",
|
||||
"UPDATE_COMMAND", "APP_NAME", "RUN_HELPER", "ALLOW_HELPER_WITH_CUSTOM",
|
||||
"DOCKER_STANDALONE_TARGETS", "UPDATE_DOCKER_ENGINE",
|
||||
"REFRESH_DOCKER_INVENTORY",
|
||||
}
|
||||
RUN_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||
INTERACTION_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
MAX_RUNTIME = 4 * 3600
|
||||
# The shared runner restores a guest it started in its EXIT trap. Its package
|
||||
# observation and `pct shutdown --timeout 60` need time after SIGTERM, too.
|
||||
STOP_GRACE = 300
|
||||
RESULT_TTL = 6 * 3600
|
||||
MAX_OUTPUT = 256 * 1024
|
||||
MAX_INPUT = 64 * 1024
|
||||
MAX_JOBS = 32
|
||||
_lock = threading.RLock()
|
||||
_jobs = {}
|
||||
|
||||
|
||||
class JobConflict(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate(run_id, script, params):
|
||||
if not isinstance(run_id, str) or not RUN_ID.fullmatch(run_id):
|
||||
raise ValueError("Invalid update job ID")
|
||||
if script not in SCRIPTS or os.path.realpath(script) != script:
|
||||
raise ValueError("Only the LXC Updates entrypoints support background runs")
|
||||
if not isinstance(params, dict) or set(params) - PARAMS:
|
||||
raise ValueError("Invalid update parameters")
|
||||
if any(not isinstance(v, str) or len(v) > 65536 for v in params.values()):
|
||||
raise ValueError("Invalid update parameter value")
|
||||
if len(json.dumps(params)) > 128 * 1024:
|
||||
raise ValueError("Update parameters are too large")
|
||||
if params.get("RUN_ID") != run_id:
|
||||
raise ValueError("Update run ID does not match")
|
||||
vmid = params.get("VMID", "")
|
||||
if not re.fullmatch(r"[1-9][0-9]{0,8}", vmid):
|
||||
raise ValueError("Invalid container ID")
|
||||
if params.get("TARGET") not in ("os", "app", "both"):
|
||||
raise ValueError("Invalid update target")
|
||||
return int(vmid)
|
||||
|
||||
|
||||
class UpdateJob:
|
||||
def __init__(self, run_id, script, params, callback):
|
||||
self.id, self.script, self.params = run_id, script, dict(params)
|
||||
self.vmid = int(params["VMID"])
|
||||
self.created_at = time.time()
|
||||
self.started = time.monotonic()
|
||||
self.finished_at = None
|
||||
self.exit_code = None
|
||||
self.state = "running"
|
||||
self.output = ""
|
||||
self.offset = 0
|
||||
self.interaction = None
|
||||
self.revision = 0
|
||||
self.lock = threading.RLock()
|
||||
self.callback = callback
|
||||
self.stop_reason = None
|
||||
self.stop_at = None
|
||||
self.master = None
|
||||
self.process = None
|
||||
self.log_path = None
|
||||
self._interaction_buffer = ""
|
||||
self._input = bytearray()
|
||||
|
||||
def launch(self):
|
||||
master, slave = pty.openpty()
|
||||
self.master = master
|
||||
try:
|
||||
# A viewer's paste must never block the worker's output, timeout
|
||||
# or cancellation when the updater does not read its stdin.
|
||||
os.set_blocking(master, False)
|
||||
fd, self.log_path = tempfile.mkstemp(prefix="proxmenux_update_", suffix=".log")
|
||||
os.close(fd)
|
||||
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 120, 0, 0))
|
||||
env = dict(os.environ, **self.params, EXECUTION_MODE="web",
|
||||
WEB_LOG=self.log_path, TERM="xterm-256color", PYTHONUNBUFFERED="1")
|
||||
self.process = subprocess.Popen(
|
||||
["/bin/bash", self.script], stdin=slave, stdout=slave, stderr=slave,
|
||||
start_new_session=True, env=env,
|
||||
)
|
||||
except BaseException:
|
||||
os.close(master)
|
||||
self.master = None
|
||||
if self.log_path:
|
||||
os.unlink(self.log_path)
|
||||
raise
|
||||
finally:
|
||||
os.close(slave)
|
||||
try:
|
||||
threading.Thread(target=self._run, daemon=True,
|
||||
name=f"lxc-update-{self.id}").start()
|
||||
except BaseException:
|
||||
# A failed worker start must not leave an untracked updater alive.
|
||||
try:
|
||||
os.killpg(self.process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self.process.wait()
|
||||
os.close(master)
|
||||
self.master = None
|
||||
os.unlink(self.log_path)
|
||||
raise
|
||||
|
||||
def _append(self, text):
|
||||
with self.lock:
|
||||
self.output = (self.output + text)[-MAX_OUTPUT:]
|
||||
self.offset += len(text)
|
||||
|
||||
def _interactions(self, stream):
|
||||
self._interaction_buffer += stream.read(16384)
|
||||
if len(self._interaction_buffer) > 65536:
|
||||
self._interaction_buffer = ""
|
||||
return
|
||||
lines = self._interaction_buffer.split("\n")
|
||||
self._interaction_buffer = lines.pop()
|
||||
for line in lines:
|
||||
if not line.startswith("WEB_INTERACTION:"):
|
||||
continue
|
||||
try:
|
||||
import base64
|
||||
parts = line.rstrip("\n")[16:].split(":", 4)
|
||||
kind, identity, title, message = parts[:4]
|
||||
if not INTERACTION_ID.fullmatch(identity):
|
||||
continue
|
||||
value = dict(type=kind, id=identity,
|
||||
title=base64.b64decode(title).decode(),
|
||||
message=base64.b64decode(message).decode())
|
||||
if len(parts) > 4:
|
||||
if kind == "menu":
|
||||
value["options"] = json.loads(parts[4])
|
||||
elif kind in ("input", "inputbox"):
|
||||
value["default"] = base64.b64decode(parts[4]).decode()
|
||||
with self.lock:
|
||||
if not self.stop_reason:
|
||||
self.interaction = value
|
||||
self.revision += 1
|
||||
except (ValueError, UnicodeError, IndexError):
|
||||
continue
|
||||
|
||||
def _signal(self, reason):
|
||||
with self.lock:
|
||||
if self.process.poll() is not None or self.stop_reason:
|
||||
return
|
||||
self.stop_reason, self.stop_at = reason, time.monotonic()
|
||||
self._input.clear()
|
||||
self.interaction = None
|
||||
self.revision += 1
|
||||
try:
|
||||
os.killpg(self.process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
def cancel(self):
|
||||
self._signal("cancelled")
|
||||
|
||||
def _write_input(self):
|
||||
# The descriptor is nonblocking, so even a full PTY cannot hold this
|
||||
# lock. Retain unwritten bytes in order until the child can read them.
|
||||
with self.lock:
|
||||
if not self._input or self.stop_reason:
|
||||
return
|
||||
try:
|
||||
written = os.write(self.master, self._input[:16384])
|
||||
except BlockingIOError:
|
||||
return
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EIO:
|
||||
raise
|
||||
# The child can close its terminal and keep working. Losing
|
||||
# stdin must not turn that update into a viewer failure.
|
||||
self._input.clear()
|
||||
return
|
||||
del self._input[:written]
|
||||
|
||||
def _run(self):
|
||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||
eof, exited_at = False, None
|
||||
try:
|
||||
with open(self.log_path, encoding="utf-8", errors="replace") as interactions:
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
running = self.process.poll() is None
|
||||
if not running:
|
||||
exited_at = exited_at or now
|
||||
if eof or now - exited_at >= 1:
|
||||
break
|
||||
if time.monotonic() - self.started >= MAX_RUNTIME:
|
||||
self._signal("timeout")
|
||||
if running and self.stop_at and now - self.stop_at >= STOP_GRACE:
|
||||
try:
|
||||
os.killpg(self.process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self._interactions(interactions)
|
||||
with self.lock:
|
||||
pending_input = bool(self._input) and running and not self.stop_reason
|
||||
readable, writable, _ = select.select(
|
||||
[] if eof else [self.master],
|
||||
[self.master] if pending_input else [], [], 0.05)
|
||||
if writable:
|
||||
self._write_input()
|
||||
if readable:
|
||||
try:
|
||||
data = os.read(self.master, 16384)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EIO:
|
||||
raise
|
||||
data = b""
|
||||
if data:
|
||||
self._append(decoder.decode(data))
|
||||
continue
|
||||
eof = True
|
||||
code = self.process.wait()
|
||||
self._append(decoder.decode(b"", final=True))
|
||||
if self.stop_reason:
|
||||
code = 124 if self.stop_reason == "timeout" else 130
|
||||
except BaseException as exc:
|
||||
self._append(f"\r\nUpdate viewer failed: {type(exc).__name__}\r\n")
|
||||
self._signal("viewer failure")
|
||||
try:
|
||||
self.process.wait(timeout=STOP_GRACE)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(self.process.pid, signal.SIGKILL)
|
||||
self.process.wait()
|
||||
code = 1
|
||||
finally:
|
||||
os.close(self.master)
|
||||
self.master = None
|
||||
try:
|
||||
os.unlink(self.log_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
with self.lock:
|
||||
self.exit_code = code
|
||||
self.state = "finalizing"
|
||||
self.interaction = None
|
||||
self._input.clear()
|
||||
self.revision += 1
|
||||
try:
|
||||
self.callback(self.script, self.params, code, time.monotonic() - self.started)
|
||||
finally:
|
||||
with self.lock:
|
||||
self.state = "complete"
|
||||
self.finished_at = time.time()
|
||||
self.revision += 1
|
||||
|
||||
def view(self):
|
||||
with self.lock:
|
||||
return dict(id=self.id, vmid=self.vmid, state=self.state,
|
||||
target=self.params["TARGET"], ct_name=self.params.get("CT_NAME", ""),
|
||||
created_at=self.created_at, finished_at=self.finished_at,
|
||||
exit_code=self.exit_code, script_path=self.script)
|
||||
|
||||
def read(self, offset):
|
||||
with self.lock:
|
||||
begin = self.offset - len(self.output)
|
||||
cursor = max(begin, min(offset, self.offset))
|
||||
return dict(text=self.output[cursor - begin:], offset=self.offset,
|
||||
truncated=offset < begin, interaction=self.interaction,
|
||||
revision=self.revision, state=self.state, exit_code=self.exit_code)
|
||||
|
||||
def input(self, message):
|
||||
with self.lock:
|
||||
if self.state != "running" or self.master is None:
|
||||
return
|
||||
if isinstance(message, str):
|
||||
if len(message) > 16384:
|
||||
raise ValueError("Terminal input is too large")
|
||||
if message == "\x03":
|
||||
self.cancel()
|
||||
else:
|
||||
data = message.encode()
|
||||
if len(self._input) + len(data) > MAX_INPUT:
|
||||
raise ValueError("Terminal input buffer is full")
|
||||
if not self.stop_reason:
|
||||
self._input.extend(data)
|
||||
elif message.get("type") == "cancel":
|
||||
self.cancel()
|
||||
elif message.get("type") == "resize":
|
||||
cols = max(20, min(400, int(message.get("cols", 120))))
|
||||
rows = max(5, min(200, int(message.get("rows", 30))))
|
||||
fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
elif message.get("type") == "interaction_response":
|
||||
identity, value = message.get("id"), message.get("value")
|
||||
if (not self.interaction or identity != self.interaction["id"]
|
||||
or not isinstance(value, str) or len(value) > 16384):
|
||||
raise ValueError("No matching pending interaction")
|
||||
path = f"/tmp/proxmenux_response_{identity}"
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as response:
|
||||
response.write(value)
|
||||
self.interaction = None
|
||||
self.revision += 1
|
||||
|
||||
|
||||
def start(run_id, script, params, callback):
|
||||
vmid = _validate(run_id, script, params)
|
||||
with _lock:
|
||||
existing = _jobs.get(run_id)
|
||||
if existing:
|
||||
if existing.script != script or existing.params != params:
|
||||
raise JobConflict("Update job ID already belongs to another request")
|
||||
return existing
|
||||
_prune()
|
||||
if any(j.vmid == vmid and j.state != "complete" for j in _jobs.values()):
|
||||
raise JobConflict("An update is already running for this container")
|
||||
if len(_jobs) >= MAX_JOBS:
|
||||
completed = [j for j in _jobs.values() if j.state == "complete"]
|
||||
if not completed:
|
||||
raise JobConflict("Too many active update jobs; try again later")
|
||||
oldest = min(completed, key=lambda j: j.finished_at)
|
||||
del _jobs[oldest.id]
|
||||
job = UpdateJob(run_id, script, params, callback)
|
||||
_jobs[run_id] = job
|
||||
try:
|
||||
job.launch()
|
||||
except BaseException:
|
||||
_jobs.pop(run_id, None)
|
||||
raise
|
||||
return job
|
||||
|
||||
|
||||
def _prune():
|
||||
now = time.time()
|
||||
for identity, job in list(_jobs.items()):
|
||||
if job.finished_at and now - job.finished_at > RESULT_TTL:
|
||||
del _jobs[identity]
|
||||
|
||||
|
||||
def get(run_id, vmid):
|
||||
with _lock:
|
||||
job = _jobs.get(run_id)
|
||||
if job is None or job.vmid != int(vmid):
|
||||
raise KeyError("Update job is unavailable; it was not restarted")
|
||||
return job
|
||||
|
||||
|
||||
def list_jobs():
|
||||
with _lock:
|
||||
_prune()
|
||||
return [j.view() for j in sorted(_jobs.values(), key=lambda j: j.created_at, reverse=True)]
|
||||
@@ -0,0 +1,65 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from ai_providers.anthropic_provider import AnthropicProvider
|
||||
from ai_providers.base import AIProviderError
|
||||
|
||||
|
||||
class CannedAnthropicProvider(AnthropicProvider):
|
||||
def __init__(self, response):
|
||||
super().__init__(api_key="test-key", model="claude-opus-5-5")
|
||||
self.response = response
|
||||
self.captured_payload = None
|
||||
|
||||
def _make_request(self, url, payload, headers):
|
||||
self.captured_payload = payload
|
||||
return self.response
|
||||
|
||||
|
||||
class AnthropicProviderTests(unittest.TestCase):
|
||||
def test_skips_thinking_block_before_text(self):
|
||||
provider = CannedAnthropicProvider({
|
||||
"stop_reason": "end_turn",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "", "signature": "x"},
|
||||
{"type": "text", "text": " Hola "},
|
||||
],
|
||||
})
|
||||
self.assertEqual(provider.generate("system", "user"), "Hola")
|
||||
|
||||
def test_thinking_only_response_raises(self):
|
||||
provider = CannedAnthropicProvider({
|
||||
"stop_reason": "max_tokens",
|
||||
"content": [{"type": "thinking", "thinking": "", "signature": "x"}],
|
||||
})
|
||||
with self.assertRaises(AIProviderError):
|
||||
provider.generate("system", "user")
|
||||
|
||||
def test_refusal_with_empty_content_raises(self):
|
||||
provider = CannedAnthropicProvider({
|
||||
"stop_reason": "refusal",
|
||||
"stop_details": {"type": "refusal", "category": "cyber"},
|
||||
"content": [],
|
||||
})
|
||||
with self.assertRaisesRegex(AIProviderError, "cyber"):
|
||||
provider.generate("system", "user")
|
||||
|
||||
def test_small_max_tokens_is_raised_to_floor(self):
|
||||
provider = CannedAnthropicProvider({
|
||||
"stop_reason": "end_turn",
|
||||
"content": [{"type": "text", "text": "CONNECTION_OK"}],
|
||||
})
|
||||
provider.generate("system", "user", max_tokens=50)
|
||||
self.assertEqual(provider.captured_payload["max_tokens"], AnthropicProvider.MIN_MAX_TOKENS)
|
||||
provider.generate("system", "user", max_tokens=4000)
|
||||
self.assertEqual(provider.captured_payload["max_tokens"], 4000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user