mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
Merge branch 'develop' into feature/zram-memory-profile
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
||||
90a46ab219d193ee635165ae1761f1c3806f0f0be2fc4434907ea6e8d2c86414 ProxMenux-1.2.6.2-beta.AppImage
|
||||
79ba79ea553e9ba473b33c8614f4dae5f684c960390f42272dc282c902627020 ProxMenux-1.2.6.2-beta.AppImage
|
||||
|
||||
@@ -382,6 +382,19 @@ export function LxcTerminalModal({
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The host prompt came back: pct enter could not enter the
|
||||
// container. Show why and close, so the session is never left
|
||||
// on the host shell under the container's name.
|
||||
const reason = afterPctEnter.split(/\r?\n/).slice(0, -1)
|
||||
.map((line) => line.trim()).filter(Boolean).join("\r\n")
|
||||
if (reason) term.writeln(`\x1b[31m${reason}\x1b[0m`)
|
||||
term.writeln(`\r\n\x1b[33m${/Failed to exec/.test(reason)
|
||||
? t("lxcTerminal.errors.noShell")
|
||||
: t("lxcTerminal.errors.enterFailed")}\x1b[0m`)
|
||||
isInsideLxcRef.current = true
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -688,6 +701,10 @@ export function LxcTerminalModal({
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x04")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+D</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Trash2, ChevronRight, TestTube2, Mail, Webhook,
|
||||
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
|
||||
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb,
|
||||
Moon, Newspaper
|
||||
Moon, Newspaper, History, ChevronDown
|
||||
} from "lucide-react"
|
||||
|
||||
interface ChannelConfig {
|
||||
@@ -109,7 +109,8 @@ interface HistoryEntry {
|
||||
title: string
|
||||
severity: string
|
||||
sent_at: string
|
||||
success: boolean
|
||||
success: boolean | number
|
||||
message?: string | null
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
@@ -331,7 +332,8 @@ export function NotificationSettings() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
|
||||
const [status, setStatus] = useState<ServiceStatus | null>(null)
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [history, setHistory] = useState<HistoryEntry[] | null>(null)
|
||||
const [historyError, setHistoryError] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
@@ -452,12 +454,12 @@ export function NotificationSettings() {
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchApi<{ success: boolean; history: HistoryEntry[]; total: number }>("/api/notifications/history?limit=20")
|
||||
if (data.success) {
|
||||
setHistory(data.history || [])
|
||||
}
|
||||
const data = await fetchApi<{ history?: HistoryEntry[]; error?: string }>("/api/notifications/history?limit=20")
|
||||
if (data.error || !Array.isArray(data.history)) throw new Error("history unavailable")
|
||||
setHistory(data.history)
|
||||
setHistoryError(false)
|
||||
} catch {
|
||||
// Ignore
|
||||
setHistoryError(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -1097,15 +1099,6 @@ export function NotificationSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearHistory = async () => {
|
||||
try {
|
||||
await fetchApi("/api/notifications/history", { method: "DELETE" })
|
||||
setHistory([])
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Maps each eye-button local key to the body shape the backend
|
||||
// expects for /api/notifications/reveal-secret. Centralised here so
|
||||
// the JSX call site stays a one-liner — `toggleSecret(key)`.
|
||||
@@ -2761,6 +2754,75 @@ export function NotificationSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Notification history ── */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-start gap-3 py-1 text-left"
|
||||
onClick={() => setShowHistory(open => !open)}
|
||||
aria-expanded={showHistory}
|
||||
>
|
||||
<History className="h-5 w-5 text-blue-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{t("settings.notifications.history.title")}</span>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.description")}</p>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 mt-1 shrink-0 text-muted-foreground transition-transform ${showHistory ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{showHistory && (
|
||||
historyError ? (
|
||||
<p className="text-xs sm:text-sm text-red-400">{t("settings.notifications.history.loadFailed")}</p>
|
||||
) : history === null ? (
|
||||
<div className="flex justify-center py-3"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
|
||||
) : history.length === 0 ? (
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.empty")}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-80 overflow-y-auto pr-1">
|
||||
{history.map(entry => {
|
||||
const sent = Boolean(entry.success)
|
||||
const severity = (entry.severity || "").toLowerCase()
|
||||
const detail = !sent ? entry.error_message : entry.event_type === "digest" ? entry.message : null
|
||||
return (
|
||||
<div key={entry.id} className="flex items-start gap-2.5 p-2.5 rounded-md bg-muted/30 border border-border/50">
|
||||
{sent ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.sent")} />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.notSent")} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs sm:text-sm font-medium block truncate">{entry.title || entry.event_type}</span>
|
||||
<span className="text-[11px] sm:text-xs text-muted-foreground">
|
||||
{entry.channel ? entry.channel.charAt(0).toUpperCase() + entry.channel.slice(1) : ""} · {new Date(entry.sent_at).toLocaleString()}
|
||||
</span>
|
||||
{detail && (
|
||||
<p className={`text-[11px] sm:text-xs mt-0.5 break-words ${sent ? "text-muted-foreground" : "text-red-400"}`}>{detail}</p>
|
||||
)}
|
||||
</div>
|
||||
{(severity === "critical" || severity === "warning" || severity === "info") && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] shrink-0 ${
|
||||
severity === "critical"
|
||||
? "border-red-500/30 text-red-400"
|
||||
: severity === "warning"
|
||||
? "border-amber-500/30 text-amber-400"
|
||||
: "border-blue-500/30 text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{severity === "critical"
|
||||
? t("settings.notifications.history.severityCritical")
|
||||
: severity === "warning"
|
||||
? t("settings.notifications.history.severityWarning")
|
||||
: t("settings.notifications.history.severityInfo")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -307,7 +307,7 @@ const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <Package className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociManager",
|
||||
text: "OCI manager Apps (beta) — 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 over 360 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 over 360 applications.",
|
||||
},
|
||||
{
|
||||
icon: <ScrollText className="h-5 w-5" />,
|
||||
|
||||
@@ -918,6 +918,10 @@ const initMessage = {
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x04")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+D</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||
|
||||
@@ -1038,6 +1038,10 @@ const handleClose = () => {
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x04")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+D</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type React from "react"
|
||||
|
||||
import { useState, useMemo, useEffect, useRef } from "react"
|
||||
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached, syncLxcAppsState } from "../lib/lxc-apps-cache"
|
||||
import { getUpdateActionButtonClass } from "../lib/update-action-style"
|
||||
import { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
@@ -27,8 +28,9 @@ import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { waitForVmControlTask } from "../lib/vm-control"
|
||||
import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search"
|
||||
import { checkLxcUpdates, isLxcUpdateStale } from "../lib/lxc-update-observation"
|
||||
import { refreshCheckLxcUpdates, getLxcOsUpdateState, isLxcUpdateStale } from "../lib/lxc-update-observation"
|
||||
import DOMPurify from "dompurify"
|
||||
import { marked } from "marked"
|
||||
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
|
||||
@@ -871,6 +873,8 @@ export function VirtualMachines() {
|
||||
const lifecycleCacheRevisionsRef = useRef(new Map<number, number>())
|
||||
const [dockerInventoryRefreshingVmid, setDockerInventoryRefreshingVmid] = useState<number | null>(null)
|
||||
const [controlLoading, setControlLoading] = useState(false)
|
||||
const controlRequestRef = useRef(false)
|
||||
const [controlAction, setControlAction] = useState<{ vmid: number; action: string } | null>(null)
|
||||
// Destructive control confirmation. `Force Stop` and `Reboot` skip the OS
|
||||
// shutdown sequence and can corrupt running guests; gate them behind a
|
||||
// typed-VMID match prompt to prevent misclicks. See audit Tier 2 #17.
|
||||
@@ -917,6 +921,7 @@ export function VirtualMachines() {
|
||||
// (or after a 15 s safety timeout).
|
||||
const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
|
||||
const [osCheckingVmid, setOsCheckingVmid] = useState<number | null>(null)
|
||||
const osCheckRequestRef = useRef<number | null>(null)
|
||||
const [osCheckError, setOsCheckError] = useState<string | null>(null)
|
||||
const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null)
|
||||
const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
|
||||
@@ -1231,6 +1236,12 @@ export function VirtualMachines() {
|
||||
useEffect(() => {
|
||||
if (!updatesRefreshing) return
|
||||
if (!selectedVM) return
|
||||
if (!selectedVM.update_check || selectedVM.update_check.error) {
|
||||
setUpdatesResult(null)
|
||||
setUpdatesRefreshing(false)
|
||||
setUpdatesBaselineCount(null)
|
||||
return
|
||||
}
|
||||
const currentCount = selectedVM.update_check?.count ?? 0
|
||||
if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) {
|
||||
const applied = Math.max(0, updatesBaselineCount - currentCount)
|
||||
@@ -1250,7 +1261,8 @@ export function VirtualMachines() {
|
||||
const safety = setTimeout(() => {
|
||||
const currentCount = selectedVMRef.current?.update_check?.count ?? 0
|
||||
const baseline = updatesBaselineCountRef.current
|
||||
setUpdatesResult({
|
||||
const observation = selectedVMRef.current?.update_check
|
||||
setUpdatesResult(!observation || observation.error ? null : {
|
||||
pendingAfter: currentCount,
|
||||
appliedCount: Math.max(0, (baseline ?? 0) - currentCount),
|
||||
})
|
||||
@@ -1788,12 +1800,16 @@ export function VirtualMachines() {
|
||||
}
|
||||
|
||||
const handleVMControl = async (vmid: number, action: string) => {
|
||||
if (controlRequestRef.current) return
|
||||
controlRequestRef.current = true
|
||||
setControlAction({ vmid, action })
|
||||
setControlLoading(true)
|
||||
try {
|
||||
await fetchApi(`/api/vms/${vmid}/control`, {
|
||||
const accepted = await fetchApi<{ task: string }>(`/api/vms/${vmid}/control`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
const completed = await waitForVmControlTask(vmid, action, accepted.task)
|
||||
|
||||
// Any control action can change what the guest reports: a stop
|
||||
// hides runtime-only fields, a start may bring a new config that
|
||||
@@ -1809,16 +1825,37 @@ export function VirtualMachines() {
|
||||
cache.firewall.delete(vmid)
|
||||
invalidateLxcApps(vmid)
|
||||
|
||||
mutate()
|
||||
setSelectedVM(null)
|
||||
setVMDetails(null)
|
||||
// Publish the confirmed result as an SWR mutation before revalidating.
|
||||
// SWR then discards list requests started before this completion.
|
||||
try {
|
||||
await mutate(current => current?.map(vm => vm.vmid === vmid
|
||||
? { ...vm, status: completed.guest_status as VMData["status"] } : vm), { revalidate: false })
|
||||
await mutate()
|
||||
} catch { /* Polling will retry; control already succeeded. */ }
|
||||
// Keep the user's tab/scroll and last complete detail while revalidating.
|
||||
// Never resurrect a modal the user closed, or overwrite another guest.
|
||||
if (selectedVMRef.current?.vmid === vmid) {
|
||||
setSelectedVM(current => current?.vmid === vmid
|
||||
? { ...current, status: completed.guest_status as VMData["status"] } : current)
|
||||
try {
|
||||
const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
|
||||
cache.details.set(vmid, details)
|
||||
if (selectedVMRef.current?.vmid === vmid) setVMDetails(details)
|
||||
} catch {
|
||||
// The control succeeded; detail polling will retry independently.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action} VM ${vmid}:`, error)
|
||||
// Same UX issue as handleCreateBackup: a silent console.error left the
|
||||
// user looking at a "Stop"/"Start" button that just never reacted.
|
||||
const msg = error instanceof Error ? error.message : t("vmLxc.errors.unknown")
|
||||
const msg = error instanceof Error
|
||||
? (error.message === "controlNotConfirmed" ? t("vmLxc.errors.controlNotConfirmed") : error.message)
|
||||
: t("vmLxc.errors.unknown")
|
||||
alert(t("vmLxc.errors.controlFailed", { action, vmid, message: msg }))
|
||||
} finally {
|
||||
controlRequestRef.current = false
|
||||
setControlAction(null)
|
||||
setControlLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -1983,6 +2020,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const [applyRestart, setApplyRestart] = useState(false)
|
||||
const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
|
||||
const [applyRunId, setApplyRunId] = useState<string>("")
|
||||
const [applyCheckedOs, setApplyCheckedOs] = useState(false)
|
||||
const [applyTargetIds, setApplyTargetIds] = useState<string[]>([])
|
||||
const [applyTargetLabels, setApplyTargetLabels] = useState<string[]>([])
|
||||
// Extra state carried alongside applyTarget when the App branch is
|
||||
@@ -2029,6 +2067,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// detected via the community-scripts pattern. All loaded once when
|
||||
// the user opens the Updates tab of a specific LXC.
|
||||
const [scheduleLoaded, setScheduleLoaded] = useState<number | null>(null)
|
||||
const scheduleLoadRequestRef = useRef(0)
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(false)
|
||||
const [scheduleCron, setScheduleCron] = useState("0 3 * * *")
|
||||
const [schedulePreset, setSchedulePreset] = useState<string>("daily-3am")
|
||||
@@ -2073,6 +2112,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// with scheduled updates: changing a one-click manual selection must never
|
||||
// rewrite the cron automation configured in Options.
|
||||
const [bulkLoaded, setBulkLoaded] = useState<number | null>(null)
|
||||
const bulkLoadRequestRef = useRef(0)
|
||||
const [bulkConfigured, setBulkConfigured] = useState(false)
|
||||
const [bulkTargets, setBulkTargets] = useState<string[]>(["os"])
|
||||
const [bulkPersistedTargets, setBulkPersistedTargets] = useState<string[]>(["os"])
|
||||
@@ -2107,9 +2147,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? s.targets.map((value: any) => String(value))
|
||||
: ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])]))
|
||||
setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0)
|
||||
if (s.backup !== undefined) setApplyBackup(!!s.backup)
|
||||
if (s.backup_storage) setApplyBackupStorage(s.backup_storage)
|
||||
if (s.restart !== undefined) setApplyRestart(!!s.restart)
|
||||
setApplyBackup(!!s.backup)
|
||||
setApplyBackupStorage(typeof s.backup_storage === "string" ? s.backup_storage : "")
|
||||
setApplyRestart(!!s.restart)
|
||||
setScheduleLastRunAt(s.last_run_at || null)
|
||||
setScheduleLastRunStatus(s.last_run_status || null)
|
||||
setScheduleLastRunReason(s.last_run_reason || null)
|
||||
@@ -2122,30 +2162,46 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
}
|
||||
|
||||
const loadSchedule = async (vmid: number) => {
|
||||
if (selectedVMRef.current?.vmid !== vmid) return
|
||||
const requestId = ++scheduleLoadRequestRef.current
|
||||
const isCurrent = () => selectedVMRef.current?.vmid === vmid
|
||||
&& scheduleLoadRequestRef.current === requestId
|
||||
setScheduleError(null)
|
||||
// Seed from ref cache so tab-switching to Updates isn't a "Loading…"
|
||||
// moment when the payload was already fetched this session.
|
||||
const cachedSched = vmModalCacheRef.current.schedule.get(vmid)
|
||||
if (cachedSched) applySchedulePayload(cachedSched)
|
||||
// Missing options are this guest's defaults, never the previous guest's.
|
||||
applySchedulePayload(cachedSched || {})
|
||||
try {
|
||||
const s: any = await fetchApi(`/api/vms/${vmid}/schedule`)
|
||||
if (s && typeof s === "object") {
|
||||
vmModalCacheRef.current.schedule.set(vmid, s)
|
||||
applySchedulePayload(s)
|
||||
}
|
||||
if (!isCurrent()) return
|
||||
if (!s || typeof s !== "object" || Array.isArray(s))
|
||||
throw new Error(t("vmLxc.scheduled.loadFailed"))
|
||||
vmModalCacheRef.current.schedule.set(vmid, s)
|
||||
applySchedulePayload(s)
|
||||
} catch (e: any) {
|
||||
setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed"))
|
||||
if (isCurrent()) setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed"))
|
||||
} finally {
|
||||
setScheduleLoaded(vmid)
|
||||
if (isCurrent()) setScheduleLoaded(vmid)
|
||||
}
|
||||
}
|
||||
|
||||
const loadBulkUpdate = async (vmid: number) => {
|
||||
if (selectedVMRef.current?.vmid !== vmid) return
|
||||
const requestId = ++bulkLoadRequestRef.current
|
||||
const isCurrent = () => selectedVMRef.current?.vmid === vmid
|
||||
&& bulkLoadRequestRef.current === requestId
|
||||
setBulkError(null)
|
||||
setBulkLoaded(null)
|
||||
setBulkEditMode(false)
|
||||
setBulkConfigured(false)
|
||||
setBulkTargets(["os"])
|
||||
setBulkPersistedTargets(["os"])
|
||||
try {
|
||||
const config: any = await fetchApi(`/api/vms/${vmid}/bulk-update`)
|
||||
if (!isCurrent()) return
|
||||
if (!config || typeof config !== "object" || Array.isArray(config))
|
||||
throw new Error(t("vmLxc.bulkUpdate.loadFailed"))
|
||||
const targets = Array.isArray(config?.targets)
|
||||
? Array.from(new Set(config.targets.map((value: any) => String(value)))) as string[]
|
||||
: []
|
||||
@@ -2155,12 +2211,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setBulkTargets(next)
|
||||
setBulkPersistedTargets(next)
|
||||
} catch (e: any) {
|
||||
if (!isCurrent()) return
|
||||
setBulkConfigured(false)
|
||||
setBulkTargets(["os"])
|
||||
setBulkPersistedTargets(["os"])
|
||||
setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed"))
|
||||
} finally {
|
||||
setBulkLoaded(vmid)
|
||||
if (isCurrent()) setBulkLoaded(vmid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2560,8 +2617,21 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const openApplyTerminal = (
|
||||
vmid: number,
|
||||
target: "os" | "app" | "both",
|
||||
opts?: { updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] },
|
||||
opts?: { checkedOs?: boolean; updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] },
|
||||
) => {
|
||||
const guest = selectedVMRef.current
|
||||
if (target === "os" || target === "both") {
|
||||
if (osCheckingVmid !== null || osCheckRequestRef.current !== null || updatesRefreshing || (opts?.checkedOs && osCheckError)) return
|
||||
const state = guest?.vmid === vmid ? getLxcOsUpdateState(guest.update_check) : "unknown"
|
||||
if (state === "unknown") {
|
||||
setOsCheckError(t("vmLxc.updates.checkBeforeUpdate"))
|
||||
return
|
||||
}
|
||||
// A combined plan can still update apps when OS packages are current.
|
||||
if (target === "os" && state === "current") return
|
||||
}
|
||||
// The explicit update button is the user's confirmation. The runner
|
||||
// owns the temporary start, backup and restoration of a stopped guest.
|
||||
const fallbackTargets = target === "os"
|
||||
? ["os"]
|
||||
: target === "both"
|
||||
@@ -2584,6 +2654,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setApplyVmid(vmid)
|
||||
setApplyTarget(target)
|
||||
setApplyRunId(runId)
|
||||
setApplyCheckedOs(opts?.checkedOs === true)
|
||||
setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets)
|
||||
setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels)
|
||||
setApplyUpdateCommand(opts?.updateCommand || "")
|
||||
@@ -2680,10 +2751,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// false "N package pending" every time a registered app has an
|
||||
// upstream bump.
|
||||
const checkOsUpdates = async (vmid: number) => {
|
||||
if (osCheckRequestRef.current !== null || applyOpen || controlLoading) return
|
||||
osCheckRequestRef.current = vmid
|
||||
setOsCheckingVmid(vmid)
|
||||
setOsCheckError(null)
|
||||
try {
|
||||
await checkLxcUpdates(vmid)
|
||||
const observation = await refreshCheckLxcUpdates<LxcUpdateCheck>(vmid)
|
||||
await mutate((current) => Array.isArray(current)
|
||||
? current.map(vm => vm.vmid === vmid ? { ...vm, update_check: observation } : vm)
|
||||
: current, { revalidate: false })
|
||||
setSelectedVM(current => current?.vmid === vmid ? { ...current, update_check: observation } : current)
|
||||
const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
|
||||
vmModalCacheRef.current.details.set(vmid, details)
|
||||
if (selectedVMRef.current?.vmid === vmid) setVMDetails(details)
|
||||
@@ -2692,9 +2769,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
if (selectedVMRef.current?.vmid === vmid) {
|
||||
setOsCheckError(error.status === 429
|
||||
? t("vmLxc.updates.checkCooldown", { seconds: error.body?.retry_after ?? 60 })
|
||||
: error.message || t("vmLxc.updates.osStatusUnavailable"))
|
||||
: error.message === "checkStillRunning"
|
||||
? t("vmLxc.updates.checkStillRunning")
|
||||
: t("vmLxc.updates.osStatusUnavailable"))
|
||||
}
|
||||
} finally {
|
||||
osCheckRequestRef.current = null
|
||||
setOsCheckingVmid(null)
|
||||
}
|
||||
}
|
||||
@@ -5707,7 +5787,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
!selectedVM.update_check?.is_oci_lxc && (() => {
|
||||
const uc = selectedVM.update_check
|
||||
const osUpdateStale = isLxcUpdateStale(uc)
|
||||
const osUpdateStatusKnown = !!uc && !uc.error && !osUpdateStale
|
||||
const osUpdateStatusKnown = getLxcOsUpdateState(uc) !== "unknown"
|
||||
const hasOsUpdates = osUpdateStatusKnown && !!uc.available
|
||||
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
|
||||
const dockerRegistered = !!dockerAppWatch
|
||||
@@ -5863,6 +5943,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
return {
|
||||
id: `app:${app.id}`,
|
||||
label: app.name || t("vmLxc.updates.applicationDefaultName"),
|
||||
methodLabel: app.update_command?.trim()
|
||||
? t("vmLxc.updates.customCommandTitle")
|
||||
: t("vmLxc.updates.helperMethod"),
|
||||
detail: "",
|
||||
logoUrl: webLinkLogo || app.logo_url?.trim() || "",
|
||||
}
|
||||
@@ -5877,6 +5960,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? [{
|
||||
id: "docker-engine",
|
||||
label: "Docker Engine",
|
||||
methodLabel: t("vmLxc.scheduled.dockerEngineTarget"),
|
||||
detail: "",
|
||||
logoUrl: "",
|
||||
}]
|
||||
@@ -5884,6 +5968,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
...dockerUpdateUnits.map((unit) => ({
|
||||
id: unit.id,
|
||||
label: unit.display_name || unit.primary_reference || unit.primary_service || unit.id,
|
||||
methodLabel: unit.kind === "compose"
|
||||
? "Docker Compose"
|
||||
: t("vmLxc.bulkUpdate.dockerImageMethod"),
|
||||
detail: (unit.dependent_services || []).length
|
||||
? t("vmLxc.bulkUpdate.includesDependencies", {
|
||||
names: (unit.dependent_services || []).join(", "),
|
||||
@@ -5891,7 +5978,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
: "",
|
||||
logoUrl: unit.logo_url || "",
|
||||
})),
|
||||
]
|
||||
].map((choice, _index, choices) => ({
|
||||
...choice,
|
||||
// Keep distinct action IDs; names alone never prove
|
||||
// that a custom updater and an image are one action.
|
||||
label: choices.filter((other) =>
|
||||
other.label.trim().toLowerCase() === choice.label.trim().toLowerCase()
|
||||
).length > 1 ? `${choice.label} — ${choice.methodLabel}` : choice.label,
|
||||
}))
|
||||
const bulkChoices = [{
|
||||
id: "os",
|
||||
label: t("vmLxc.bulkUpdate.osTarget"),
|
||||
@@ -5935,7 +6029,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// has been checked and is current, otherwise unknown.
|
||||
const selectedBulkUpdateStates = bulkTargets.map((target) => {
|
||||
if (target === "os") {
|
||||
if (!uc || uc.error) return null
|
||||
if (!osUpdateStatusKnown) return null
|
||||
return hasOsUpdates
|
||||
}
|
||||
if (target === "docker-engine") {
|
||||
@@ -5960,6 +6054,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
return null
|
||||
})
|
||||
const bulkHasPendingUpdates = selectedBulkUpdateStates.some((state) => state === true)
|
||||
const bulkHasUnknownTargets = selectedBulkUpdateStates.some((state) => state === null)
|
||||
const bulkAllTargetsUpToDate = selectedBulkUpdateStates.length > 0
|
||||
&& selectedBulkUpdateStates.every((state) => state === false)
|
||||
const toggleBulkChoice = (id: string, checked: boolean) => {
|
||||
@@ -5968,9 +6063,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? Array.from(new Set(["os", ...current.filter((value) => value !== "os"), id]))
|
||||
: current.filter((value) => value !== id))
|
||||
}
|
||||
const pendingBtnCls = "bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||
const upToDateBtnCls = "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300"
|
||||
const neutralBtnCls = "border border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground"
|
||||
const pendingBtnCls = getUpdateActionButtonClass(true, false)
|
||||
const upToDateBtnCls = getUpdateActionButtonClass(false, true)
|
||||
return (
|
||||
<>
|
||||
<Card className="border border-border bg-card/50">
|
||||
@@ -5982,14 +6076,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3>
|
||||
<Button
|
||||
size="sm" variant="outline" className="ml-auto"
|
||||
disabled={selectedVM.status !== "running" || osCheckingVmid !== null}
|
||||
title={t("vmLxc.updates.checkReadOnlyHelp")}
|
||||
disabled={selectedVM.status !== "running" || osCheckingVmid !== null || updatesRefreshing || controlLoading || applyOpen}
|
||||
title={t("vmLxc.updates.refreshCheckHelp")}
|
||||
onClick={() => checkOsUpdates(selectedVM.vmid)}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-1.5 ${osCheckingVmid === selectedVM.vmid ? "animate-spin" : ""}`} />
|
||||
{t("vmLxc.updates.checkNow")}
|
||||
{t("vmLxc.updates.checkSystemPackages")}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedVM.status !== "running" && (
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t("vmLxc.updates.startBeforeCheck")}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
||||
{t("vmLxc.updates.lastCheckedPrefix")}{" "}
|
||||
{uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"}
|
||||
@@ -5998,7 +6097,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
)}
|
||||
</div>
|
||||
{osCheckError && <p role="alert" className="text-sm text-amber-400 mb-2">{osCheckError}</p>}
|
||||
{osUpdateStale && !uc?.error ? (
|
||||
{osCheckingVmid === selectedVM.vmid ? (
|
||||
<div role="status" className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin flex-shrink-0" />
|
||||
{t("vmLxc.updates.checkInProgress")}
|
||||
</div>
|
||||
) : !uc?.last_check && !uc?.error ? (
|
||||
<p className="text-sm text-muted-foreground">{t("vmLxc.updates.notCheckedYet")}</p>
|
||||
) : osUpdateStale && !uc?.error ? (
|
||||
<p className="text-sm text-amber-400">{t("vmLxc.updates.checkStaleHelp")}</p>
|
||||
) : !osUpdateStatusKnown ? (
|
||||
<div
|
||||
@@ -6054,23 +6160,28 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{t("vmLxc.updates.noOsUpdatesPending")}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex justify-end">
|
||||
{osUpdateStatusKnown && osCheckingVmid !== selectedVM.vmid && <div className="mt-3 flex justify-end">
|
||||
{hasOsUpdates ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => openApplyTerminal(selectedVM.vmid, "os")}
|
||||
className={hasOsUpdates
|
||||
? pendingBtnCls
|
||||
: osUpdateStatusKnown
|
||||
? upToDateBtnCls
|
||||
: neutralBtnCls}
|
||||
disabled={!hasOsUpdates || osCheckingVmid !== null || updatesRefreshing || controlLoading || !!osCheckError}
|
||||
onClick={() => openApplyTerminal(selectedVM.vmid, "os", { checkedOs: true })}
|
||||
className={getUpdateActionButtonClass(hasOsUpdates, osUpdateStatusKnown)}
|
||||
>
|
||||
{hasOsUpdates && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
|
||||
{!hasOsUpdates && !osUpdateStatusKnown && <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
{hasOsUpdates || !osUpdateStatusKnown
|
||||
{hasOsUpdates
|
||||
? t("vmLxc.updates.applyOsUpdate")
|
||||
: t("vmLxc.updates.osUpToDate")}
|
||||
: osUpdateStatusKnown
|
||||
? t("vmLxc.updates.osUpToDate")
|
||||
: t("vmLxc.updates.checkBeforeUpdate")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button asChild size="sm" className={upToDateBtnCls}>
|
||||
<span role="status">{t("vmLxc.updates.osUpToDate")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Docker has three independent lifecycles:
|
||||
@@ -6200,11 +6311,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
dockerRefresh: true,
|
||||
},
|
||||
)}
|
||||
className={`${dockerEngineHasUpdate
|
||||
? pendingBtnCls
|
||||
: dockerEngineUpToDate
|
||||
? upToDateBtnCls
|
||||
: neutralBtnCls} flex-shrink-0`}
|
||||
className={`${getUpdateActionButtonClass(dockerEngineHasUpdate, dockerEngineUpToDate)} flex-shrink-0`}
|
||||
>
|
||||
{dockerEngineHasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
|
||||
{!dockerEngineHasUpdate && !dockerEngineUpToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
@@ -6480,7 +6587,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
) : helperTracksVersion ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("vmLxc.updates.versionTrackingPending")}
|
||||
{t("vmLxc.updates.versionStatusUnknown")}
|
||||
</p>
|
||||
) : helperExists ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
@@ -6503,7 +6610,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// still force the helper's own
|
||||
// update script.
|
||||
const noState = !hasUpd && !upToD
|
||||
const cls = hasUpd ? pendingBtnCls : upToD ? upToDateBtnCls : neutralBtnCls
|
||||
const cls = getUpdateActionButtonClass(hasUpd, upToD)
|
||||
const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater")
|
||||
return (
|
||||
<div className="mt-3 flex justify-end">
|
||||
@@ -6639,7 +6746,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<div>{t("vmLxc.updates.versionTrackingPendingShort")}</div>
|
||||
<div>{t("vmLxc.updates.versionStatusUnknown")}</div>
|
||||
)}
|
||||
</div>
|
||||
) : tracksVersion ? (
|
||||
@@ -6651,7 +6758,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<a className="text-blue-400 hover:underline" href={`#docker-images-${selectedVM.vmid}`}>
|
||||
{t("vmLxc.updates.dockerImagesSubheading")}
|
||||
</a></>
|
||||
: t("vmLxc.updates.versionTrackingPendingShort")}
|
||||
: aw.installed_via === "manual"
|
||||
? t("vmLxc.updates.installedVersionUnverified")
|
||||
: t("vmLxc.updates.versionStatusUnknown")}
|
||||
</div>
|
||||
) : hasCmd ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@@ -6673,7 +6782,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
targetLabels: [aw.name || t("vmLxc.updates.applicationDefaultName")],
|
||||
},
|
||||
)}
|
||||
className={hasUpdate ? pendingBtnCls : upToDate ? upToDateBtnCls : neutralBtnCls}
|
||||
className={getUpdateActionButtonClass(hasUpdate, upToDate)}
|
||||
>
|
||||
{hasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
|
||||
{!hasUpdate && !upToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
@@ -6681,7 +6790,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? t("vmLxc.updates.applyUpdate")
|
||||
: upToDate
|
||||
? t("vmLxc.updates.upToDate")
|
||||
: t("vmLxc.updates.runUpdater")}
|
||||
: aw.update_method === "helper"
|
||||
? t("vmLxc.updates.runUpdater")
|
||||
: t("vmLxc.updates.runCustomCommand")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -6719,6 +6830,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-relaxed">
|
||||
{t("vmLxc.bulkUpdate.description")}
|
||||
</p>
|
||||
{dockerRegistered && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{t("vmLxc.bulkUpdate.osDockerScope")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!bulkEditMode && (
|
||||
<button
|
||||
@@ -6868,19 +6984,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="text-sm text-muted-foreground break-words">
|
||||
{selectedBulkLabels.join(" · ")}
|
||||
</div>
|
||||
{bulkHasUnknownTargets && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("vmLxc.bulkUpdate.unverifiedTargetsHelp")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => applyBulkUpdate(selectedVM.vmid)}
|
||||
disabled={bulkApplying || (
|
||||
disabled={bulkApplying || osCheckingVmid !== null || updatesRefreshing || !osUpdateStatusKnown || bulkAllTargetsUpToDate || (
|
||||
bulkTargets.some((target) => target.startsWith("docker-unit:"))
|
||||
&& (dockerInventoryRefreshing || !dockerInventoryAvailable)
|
||||
)}
|
||||
className={bulkHasPendingUpdates
|
||||
? pendingBtnCls
|
||||
: bulkAllTargetsUpToDate
|
||||
? upToDateBtnCls
|
||||
: neutralBtnCls}
|
||||
className={getUpdateActionButtonClass(bulkHasPendingUpdates, bulkAllTargetsUpToDate)}
|
||||
>
|
||||
{bulkApplying
|
||||
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
@@ -7534,8 +7651,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
tab we added. */}
|
||||
{(() => {
|
||||
const hasTerminal = selectedVM?.type === "lxc" && selectedVM?.status === "running"
|
||||
const pendingAction = controlAction?.vmid === selectedVM?.vmid ? controlAction?.action : null
|
||||
const controlLabel = (action: string, key: string) => pendingAction === action
|
||||
? t(`vmLxc.actions.${action}Pending`) : t(key)
|
||||
return (
|
||||
<div className={`grid gap-2 sm:gap-3 ${hasTerminal ? "grid-cols-4 lg:grid-cols-5" : "grid-cols-4"}`}>
|
||||
<div aria-busy={controlLoading} className={`grid gap-2 sm:gap-3 ${hasTerminal ? "grid-cols-4 lg:grid-cols-5" : "grid-cols-4"}`}>
|
||||
{hasTerminal && (
|
||||
<Button
|
||||
className="w-full bg-zinc-600/20 border border-zinc-600/50 text-zinc-300 hover:bg-zinc-600/30 col-span-4 lg:col-span-1"
|
||||
@@ -7549,19 +7669,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
className="w-full bg-green-600/20 border border-green-600/50 text-green-400 hover:bg-green-600/30"
|
||||
disabled={selectedVM?.status === "running" || controlLoading}
|
||||
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "start")}
|
||||
title={t("vmLxc.actions.start")}
|
||||
title={controlLabel("start", "vmLxc.actions.start")}
|
||||
>
|
||||
<Play className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{t("vmLxc.actions.start")}</span>
|
||||
{pendingAction === "start" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <Play className="h-4 w-4 sm:mr-2" />}
|
||||
<span className="hidden sm:inline">{controlLabel("start", "vmLxc.actions.start")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-blue-600/20 border border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
|
||||
disabled={selectedVM?.status !== "running" || controlLoading}
|
||||
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "shutdown")}
|
||||
title={t("vmLxc.actions.shutdown")}
|
||||
title={controlLabel("shutdown", "vmLxc.actions.shutdown")}
|
||||
>
|
||||
<Power className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{t("vmLxc.actions.shutdown")}</span>
|
||||
{pendingAction === "shutdown" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <Power className="h-4 w-4 sm:mr-2" />}
|
||||
<span className="hidden sm:inline">{controlLabel("shutdown", "vmLxc.actions.shutdown")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-blue-600/20 border border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
|
||||
@@ -7571,10 +7691,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
vmid: selectedVM.vmid,
|
||||
vmName: selectedVM.name,
|
||||
})}
|
||||
title={t("vmLxc.actions.reboot")}
|
||||
title={controlLabel("reboot", "vmLxc.actions.reboot")}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{t("vmLxc.actions.reboot")}</span>
|
||||
{pendingAction === "reboot" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <RotateCcw className="h-4 w-4 sm:mr-2" />}
|
||||
<span className="hidden sm:inline">{controlLabel("reboot", "vmLxc.actions.reboot")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-red-600/20 border border-red-600/50 text-red-400 hover:bg-red-600/30"
|
||||
@@ -7584,10 +7704,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
vmid: selectedVM.vmid,
|
||||
vmName: selectedVM.name,
|
||||
})}
|
||||
title={t("vmLxc.actions.forceStop")}
|
||||
title={controlLabel("stop", "vmLxc.actions.forceStop")}
|
||||
>
|
||||
<StopCircle className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{t("vmLxc.actions.forceStop")}</span>
|
||||
{pendingAction === "stop" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <StopCircle className="h-4 w-4 sm:mr-2" />}
|
||||
<span className="hidden sm:inline">{controlLabel("stop", "vmLxc.actions.forceStop")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -7916,7 +8036,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setApplyVmid(null)
|
||||
}}
|
||||
onComplete={handleApplyComplete}
|
||||
scriptPath="/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"
|
||||
scriptPath={applyCheckedOs
|
||||
? "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh"
|
||||
: "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"}
|
||||
scriptName="lxc_apply_updates"
|
||||
title={t("vmLxc.updates.terminalTitle", { vmid: applyVmid })}
|
||||
completedSuccessfullyMessage={t("vmLxc.updates.terminalCompletedSuccessfully")}
|
||||
|
||||
@@ -4,6 +4,17 @@ interface UpdateObservation {
|
||||
last_check?: string | null
|
||||
stale?: boolean
|
||||
stale_after_seconds?: number
|
||||
available?: boolean
|
||||
count?: number | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export function getLxcOsUpdateState(observation?: UpdateObservation, now = Date.now()): "unknown" | "pending" | "current" {
|
||||
if (isLxcUpdateStale(observation, now) || observation?.error
|
||||
|| typeof observation?.available !== "boolean"
|
||||
|| !Number.isInteger(observation.count) || (observation.count ?? -1) < 0
|
||||
|| observation.available !== ((observation.count ?? 0) > 0)) return "unknown"
|
||||
return observation.available ? "pending" : "current"
|
||||
}
|
||||
|
||||
export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now()): boolean {
|
||||
@@ -19,3 +30,18 @@ export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now
|
||||
export function checkLxcUpdates<T>(vmid: number): Promise<T> {
|
||||
return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" })
|
||||
}
|
||||
|
||||
// Explicit action: refresh indexes and check an already-running CT. The
|
||||
// backend owns the job even if the browser closes or navigates away.
|
||||
export async function refreshCheckLxcUpdates<T>(vmid: number): Promise<T> {
|
||||
const job = await fetchApi<{ job_id: string }>(`/api/vms/${vmid}/updates/refresh-check`, { method: "POST" })
|
||||
const deadline = Date.now() + 8 * 60_000
|
||||
while (Date.now() < deadline) {
|
||||
const result = await fetchApi<{ state: string; error?: string; update_check?: T }>(
|
||||
`/api/vms/${vmid}/updates/refresh-check/${job.job_id}`)
|
||||
if (result.state === "failed") throw new Error("osStatusUnavailable")
|
||||
if (result.state === "complete") return result.update_check as T
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
throw new Error("checkStillRunning")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Use the dashboard's existing update colors for every executable action.
|
||||
// Version availability remains separate from action styling and enablement.
|
||||
export function getUpdateActionButtonClass(hasUpdate: boolean, isCurrent: boolean): string {
|
||||
return !hasUpdate && isCurrent
|
||||
? "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300"
|
||||
: "bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fetchApi } from './api-config'
|
||||
|
||||
// A successful control POST accepts an asynchronous Proxmox task. In
|
||||
// particular, an unchanged "running" status cannot confirm a reboot.
|
||||
export async function waitForVmControlTask(vmid: number, action: string, task: string) {
|
||||
if (!task) throw new Error('controlNotConfirmed')
|
||||
const expected = action === 'shutdown' || action === 'stop' ? 'stopped' : 'running'
|
||||
const deadline = Date.now() + 180_000
|
||||
let readFailures = 0
|
||||
while (Date.now() < deadline) {
|
||||
let result: { status?: string; exitstatus?: string; guest_status?: string }
|
||||
try {
|
||||
result = await fetchApi(`/api/vms/${vmid}/control-task/${encodeURIComponent(task)}`, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
readFailures = 0
|
||||
} catch (error) {
|
||||
if (++readFailures >= 3) throw new Error('controlNotConfirmed')
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
continue
|
||||
}
|
||||
if (result?.status === 'stopped') {
|
||||
if (!result.exitstatus) throw new Error('controlNotConfirmed')
|
||||
if (result.exitstatus !== 'OK') throw new Error(result.exitstatus)
|
||||
if (result.guest_status === expected) return result
|
||||
} else if (result?.status !== 'running') {
|
||||
throw new Error('controlNotConfirmed')
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
throw new Error('controlNotConfirmed')
|
||||
}
|
||||
+501
-469
File diff suppressed because it is too large
Load Diff
@@ -1106,7 +1106,11 @@
|
||||
"shutdown": "Shutdown",
|
||||
"reboot": "Reboot",
|
||||
"forceStop": "Force Stop",
|
||||
"working": "Working..."
|
||||
"working": "Working...",
|
||||
"startPending": "Starting...",
|
||||
"shutdownPending": "Shutting down...",
|
||||
"rebootPending": "Restarting...",
|
||||
"stopPending": "Stopping..."
|
||||
},
|
||||
"confirm": {
|
||||
"forceStopTitle": "Force Stop",
|
||||
@@ -1150,6 +1154,7 @@
|
||||
"unknown": "Unknown error",
|
||||
"backupStartFailed": "Failed to start backup: {message}",
|
||||
"controlFailed": "Failed to {action} VM {vmid}: {message}",
|
||||
"controlNotConfirmed": "The operation has not been confirmed complete. Check the guest status before trying again.",
|
||||
"saveNotesFailed": "Error saving notes. Please try again.",
|
||||
"appNotFound": "This application is no longer available. Refresh the page and try again.",
|
||||
"saveCustomCommandFailed": "Could not save the custom update command: {message}",
|
||||
@@ -1338,7 +1343,14 @@
|
||||
},
|
||||
"updates": {
|
||||
"osPackagesTitle": "OS packages",
|
||||
"checkNow": "Check now",
|
||||
"checkNow": "Check updates",
|
||||
"checkSystemPackages": "Check system packages",
|
||||
"checkInProgress": "Check in progress",
|
||||
"refreshCheckHelp": "Refreshes package indexes and checks pending updates while the container is running. Nothing is installed.",
|
||||
"notCheckedYet": "Updates have not been checked yet.",
|
||||
"checkStillRunning": "The check is still running. Its result will be saved when it finishes.",
|
||||
"checkBeforeUpdate": "Check updates first",
|
||||
"startBeforeCheck": "Start the container, then check for updates. No badge does not mean there are no updates.",
|
||||
"checkReadOnlyHelp": "Read current OS and pending packages from existing indexes. No installation or package index refresh.",
|
||||
"checkCooldown": "Check already running or recently requested. Try again in {seconds} seconds.",
|
||||
"checkStale": "Check outdated",
|
||||
@@ -1386,6 +1398,9 @@
|
||||
"applyUpdate": "Apply update",
|
||||
"upToDate": "Up to date",
|
||||
"runUpdater": "Run updater",
|
||||
"runCustomCommand": "Run custom command",
|
||||
"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}",
|
||||
"terminalDescriptionOs": "Applying OS package updates inside the container...",
|
||||
"terminalDescriptionApp": "Running the application updater inside the container...",
|
||||
@@ -1489,9 +1504,12 @@
|
||||
"notConfigured": "Not configured.",
|
||||
"configure": "Configure",
|
||||
"edit": "Edit",
|
||||
"apply": "Apply updates",
|
||||
"apply": "Start bulk update",
|
||||
"osTarget": "OS",
|
||||
"osRequired": "Required for a bulk update.",
|
||||
"osDockerScope": "OS updates also include Docker when it is installed as a system package.",
|
||||
"unverifiedTargetsHelp": "Update availability for some selected items is unverified. Starting will run the selected update methods even without a confirmed new version.",
|
||||
"dockerImageMethod": "Docker image",
|
||||
"includesDependencies": "Also includes declared dependencies: {names}",
|
||||
"noMethod": "No executable update method is available.",
|
||||
"staleTarget": "This selection is no longer available. Remove it before saving.",
|
||||
@@ -2182,6 +2200,17 @@
|
||||
"localPveApi": "The address cannot point to the local PVE API (localhost:8006/8007).",
|
||||
"metadataIp": "The link-local metadata IP cannot be used as a Gotify endpoint."
|
||||
},
|
||||
"history": {
|
||||
"title": "Notification history",
|
||||
"description": "The last 20 notifications sent, including each run of the daily digest, with the result of each one.",
|
||||
"empty": "No notifications have been sent yet.",
|
||||
"loadFailed": "The notification history could not be loaded.",
|
||||
"sent": "Sent",
|
||||
"notSent": "Not sent",
|
||||
"severityInfo": "Info",
|
||||
"severityWarning": "Warning",
|
||||
"severityCritical": "Critical"
|
||||
},
|
||||
"customPromptInfo": {
|
||||
"title": "Custom prompt mode",
|
||||
"description": "Create your own AI prompt for ProxMenux Monitor notifications",
|
||||
@@ -3409,7 +3438,7 @@
|
||||
"webhookHttps": "Proxmox notifications reach the Monitor with HTTPS enabled — they are delivered on a local-only address and no longer fail with a certificate error.",
|
||||
"persistentLogs": "A burst of log errors that ended is no longer reported as persistent: a pattern has to keep appearing for 15 minutes, and its warning clears on its own (reported by @Joshua1264).",
|
||||
"mountsLanAddress": "Mount points on LVM-thin and other block storage show their usage, and multi-container applications open at their LAN address.",
|
||||
"ociManager": "OCI manager Apps (beta) — 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 over 360 applications.",
|
||||
"ociManager": "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 over 360 applications.",
|
||||
"ociInMonitor": "OCI applications in the Monitor — the App tab recognises them, the Updates tab updates and modifies them as the menu does, and a Logs tab follows the console output of the application live.",
|
||||
"ociAcceleration": "Hardware acceleration for OCI applications — the installer offers only the profiles the host can run: NVIDIA, Intel and AMD GPUs and, for Frigate, an Intel NPU or a Coral. The NVIDIA Container Toolkit installs with the driver.",
|
||||
"ociRecovery": "An OCI application travels with its backup — restored on this host or on another one, it is registered and managed again, and in a cluster it starts on whichever node it moves to.",
|
||||
@@ -4991,7 +5020,9 @@
|
||||
"lxcTerminal": {
|
||||
"title": "Terminal: {name} (ID: {id})",
|
||||
"errors": {
|
||||
"invalidVmid": "Invalid VMID — refusing to execute pct enter"
|
||||
"invalidVmid": "Invalid VMID — refusing to execute pct enter",
|
||||
"noShell": "This container's image has no shell, so it cannot open a terminal. Its output is in the Logs tab.",
|
||||
"enterFailed": "The container could not be entered."
|
||||
},
|
||||
"commands": {
|
||||
"listFiles": "List all files with details",
|
||||
@@ -5206,6 +5237,7 @@
|
||||
"processing": "Processing...",
|
||||
"controlSequences": "Control Sequences",
|
||||
"cancelInterrupt": "Cancel/Interrupt",
|
||||
"endOfInput": "End of input",
|
||||
"exitNano": "Exit (nano)",
|
||||
"searchHistory": "Search history",
|
||||
"clipboard": "Clipboard",
|
||||
|
||||
@@ -1085,7 +1085,11 @@
|
||||
"shutdown": "Apagar",
|
||||
"reboot": "Reiniciar",
|
||||
"forceStop": "Parar",
|
||||
"working": "Procesando..."
|
||||
"working": "Procesando...",
|
||||
"startPending": "Iniciando...",
|
||||
"shutdownPending": "Apagando...",
|
||||
"rebootPending": "Reiniciando...",
|
||||
"stopPending": "Parando..."
|
||||
},
|
||||
"confirm": {
|
||||
"forceStopTitle": "Parar",
|
||||
@@ -1134,7 +1138,8 @@
|
||||
"saveCustomCommandFailed": "No se pudo guardar el comando de actualización personalizado: {message}",
|
||||
"removeCustomCommandConfirm": "¿Eliminar el comando de actualización personalizado para \"{name}\"?",
|
||||
"removeCustomCommandFailed": "No se pudo eliminar el comando de actualización personalizado: {message}",
|
||||
"hideNoticeFailed": "No se pudo ocultar este aviso: {message}"
|
||||
"hideNoticeFailed": "No se pudo ocultar este aviso: {message}",
|
||||
"controlNotConfirmed": "No se ha confirmado que la operación haya terminado. Compruebe el estado de la VM o del contenedor antes de volver a intentarlo."
|
||||
},
|
||||
"backupModal": {
|
||||
"title": "Backup de {type} {vmid} ({name})",
|
||||
@@ -1455,11 +1460,21 @@
|
||||
"postApplyNothingPending": "Nada pendiente — todo actualizado.",
|
||||
"postApplyPartial": "{pending} paquete(s) siguen pendientes tras la ejecución.",
|
||||
"postApplyPartialSubline": "{applied} aplicados. Algunas actualizaciones no finalizaron — revisa la salida del terminal.",
|
||||
"checkNow": "Comprobar ahora",
|
||||
"checkNow": "Comprobar actualizaciones",
|
||||
"checkReadOnlyHelp": "Lee el sistema operativo actual y los paquetes pendientes a partir de los índices existentes. No instala nada ni actualiza los índices de paquetes.",
|
||||
"checkCooldown": "Ya hay una comprobación en curso o se ha solicitado hace poco. Inténtalo de nuevo en {seconds} segundos.",
|
||||
"checkStale": "Comprobación caducada",
|
||||
"checkStaleHelp": "Este resultado tiene más de 48 horas o no tiene una fecha de comprobación válida. Lanza una nueva comprobación antes de fiarte de él."
|
||||
"checkStaleHelp": "Este resultado tiene más de 48 horas o no tiene una fecha de comprobación válida. Lanza una nueva comprobación antes de fiarte de él.",
|
||||
"checkSystemPackages": "Comprobar paquetes del sistema",
|
||||
"checkInProgress": "Comprobación en curso",
|
||||
"refreshCheckHelp": "Renueva los índices de paquetes y comprueba las actualizaciones pendientes mientras el contenedor está en marcha. No instala nada.",
|
||||
"notCheckedYet": "Las actualizaciones aún no se han comprobado.",
|
||||
"checkStillRunning": "La comprobación sigue en curso. Su resultado se guardará al terminar.",
|
||||
"checkBeforeUpdate": "Comprobar actualizaciones primero",
|
||||
"startBeforeCheck": "Inicie el contenedor y después compruebe 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."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Actualización en bloque",
|
||||
@@ -1468,7 +1483,7 @@
|
||||
"notConfigured": "No configurada.",
|
||||
"configure": "Configurar",
|
||||
"edit": "Editar",
|
||||
"apply": "Aplicar actualizaciones",
|
||||
"apply": "Iniciar actualización masiva",
|
||||
"osTarget": "SO",
|
||||
"osRequired": "Obligatorio en una actualización en bloque.",
|
||||
"includesDependencies": "Incluye también las dependencias declaradas: {names}",
|
||||
@@ -1484,7 +1499,10 @@
|
||||
"saveFailed": "No se pudo guardar la configuración de actualización en bloque.",
|
||||
"deleteFailed": "No se pudo eliminar la configuración de actualización en bloque.",
|
||||
"planFailed": "No se pudo preparar la actualización en bloque. Edite la configuración y revise los métodos no disponibles.",
|
||||
"loadFailed": "no se pudo cargar la configuración de actualización masiva."
|
||||
"loadFailed": "no se pudo cargar la configuración de actualización masiva.",
|
||||
"osDockerScope": "Las actualizaciones del sistema operativo también incluyen Docker cuando está instalado como paquete del sistema.",
|
||||
"unverifiedTargetsHelp": "No se ha verificado si hay actualización para algunos de los elementos seleccionados. Al iniciar se ejecutarán los métodos de actualización elegidos aunque no haya una versión nueva confirmada.",
|
||||
"dockerImageMethod": "Imagen Docker"
|
||||
},
|
||||
"appEditor": {
|
||||
"dockerDetectedWithWorkloads": "Docker detectado con {count} aplicaciones en contenedores",
|
||||
@@ -2183,6 +2201,17 @@
|
||||
"localPveApi": "La dirección no puede apuntar a la API PVE local (localhost:8006/8007).",
|
||||
"metadataIp": "La IP de metadatos del enlace local no se puede utilizar como punto final de Gotify."
|
||||
},
|
||||
"history": {
|
||||
"title": "Historial de notificaciones",
|
||||
"description": "Las últimas 20 notificaciones enviadas, incluida cada ejecución del resumen diario, con el resultado de cada una.",
|
||||
"empty": "Todavía no se ha enviado ninguna notificación.",
|
||||
"loadFailed": "No se pudo cargar el historial de notificaciones.",
|
||||
"sent": "Enviada",
|
||||
"notSent": "No enviada",
|
||||
"severityInfo": "Info",
|
||||
"severityWarning": "Advertencia",
|
||||
"severityCritical": "Crítico"
|
||||
},
|
||||
"customPromptInfo": {
|
||||
"title": "Modo de aviso personalizado",
|
||||
"description": "Cree su propio mensaje de IA para las notificaciones de ProxMenux Monitor",
|
||||
@@ -3410,7 +3439,7 @@
|
||||
"webhookHttps": "Las notificaciones de Proxmox llegan al Monitor con HTTPS activado — se entregan en una dirección solo local y ya no fallan por un error de certificado.",
|
||||
"persistentLogs": "Una ráfaga de errores en los logs que ya terminó no se presenta como persistente: el patrón tiene que seguir apareciendo durante 15 minutos, y su aviso se cierra solo (reportado por @Joshua1264).",
|
||||
"mountsLanAddress": "Los puntos de montaje en LVM-thin y otros almacenamientos de bloques muestran su uso, y las aplicaciones multicontenedor se abren en su dirección de la LAN.",
|
||||
"ociManager": "OCI manager Apps (beta) — 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 más de 360 aplicaciones.",
|
||||
"ociManager": "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 más de 360 aplicaciones.",
|
||||
"ociInMonitor": "Aplicaciones OCI en el Monitor — la pestaña App las reconoce, la pestaña Actualizaciones las actualiza y modifica igual que el menú, y una pestaña Logs sigue en directo la salida de consola de la aplicación.",
|
||||
"ociAcceleration": "Aceleración por hardware para aplicaciones OCI — el instalador ofrece solo los perfiles que el host puede ejecutar: GPU NVIDIA, Intel y AMD y, para Frigate, una NPU de Intel o una Coral. NVIDIA Container Toolkit se instala con el driver.",
|
||||
"ociRecovery": "Una aplicación OCI viaja con su backup — restaurada en este host o en otro, vuelve a quedar registrada y gestionada, y en un clúster arranca en el nodo al que se mueva.",
|
||||
@@ -4992,7 +5021,9 @@
|
||||
"lxcTerminal": {
|
||||
"title": "Terminal: {name} (ID: {id})",
|
||||
"errors": {
|
||||
"invalidVmid": "VMID no válido: se niega a ejecutar pct enter"
|
||||
"invalidVmid": "VMID no válido: se niega a ejecutar pct enter",
|
||||
"noShell": "La imagen de este contenedor no incluye shell, así que no puede abrir una terminal. Su salida está en la pestaña Logs.",
|
||||
"enterFailed": "No se pudo entrar en el contenedor."
|
||||
},
|
||||
"commands": {
|
||||
"listFiles": "Listar todos los archivos con detalles",
|
||||
@@ -5207,6 +5238,7 @@
|
||||
"processing": "Tratamiento...",
|
||||
"controlSequences": "Secuencias de control",
|
||||
"cancelInterrupt": "Cancelar/Interrumpir",
|
||||
"endOfInput": "Fin de la entrada",
|
||||
"exitNano": "Salir (nano)",
|
||||
"searchHistory": "Historial de búsqueda",
|
||||
"clipboard": "Portapapeles",
|
||||
|
||||
@@ -1081,11 +1081,15 @@
|
||||
"logs": "Logs"
|
||||
},
|
||||
"actions": {
|
||||
"start": "Commencer",
|
||||
"shutdown": "Fermer",
|
||||
"start": "Démarrer",
|
||||
"shutdown": "Éteindre",
|
||||
"reboot": "Redémarrer",
|
||||
"forceStop": "Forcer l'arrêt",
|
||||
"working": "Fonctionnement..."
|
||||
"working": "En cours...",
|
||||
"startPending": "Démarrage...",
|
||||
"shutdownPending": "Extinction...",
|
||||
"rebootPending": "Redémarrage...",
|
||||
"stopPending": "Arrêt..."
|
||||
},
|
||||
"confirm": {
|
||||
"forceStopTitle": "Forcer l'arrêt",
|
||||
@@ -1134,7 +1138,8 @@
|
||||
"saveCustomCommandFailed": "Impossible d'enregistrer la commande de mise à jour personnalisée : {message}",
|
||||
"removeCustomCommandConfirm": "Supprimer la commande de mise à jour personnalisée pour \"{name}\" ?",
|
||||
"removeCustomCommandFailed": "Impossible de supprimer la commande de mise à jour personnalisée : {message}",
|
||||
"hideNoticeFailed": "Impossible de masquer cet avis : {message}"
|
||||
"hideNoticeFailed": "Impossible de masquer cet avis : {message}",
|
||||
"controlNotConfirmed": "La fin de l'opération n'a pas été confirmée. Vérifiez l'état de l'invité avant de réessayer."
|
||||
},
|
||||
"backupModal": {
|
||||
"title": "Sauvegarde {type} {vmid} ({name})",
|
||||
@@ -1459,7 +1464,17 @@
|
||||
"checkReadOnlyHelp": "Lit le système d’exploitation actuel et les paquets en attente à partir des index existants. Aucune installation ni actualisation des index de paquets.",
|
||||
"checkCooldown": "Une vérification est déjà en cours ou a été demandée récemment. Réessayez dans {seconds} secondes.",
|
||||
"checkStale": "Vérification obsolète",
|
||||
"checkStaleHelp": "Ce résultat date de plus de 48 heures ou n’a pas de date de vérification valide. Lancez une nouvelle vérification avant de vous y fier."
|
||||
"checkStaleHelp": "Ce résultat date de plus de 48 heures ou n’a pas de date de vérification valide. Lancez une nouvelle vérification avant de vous y fier.",
|
||||
"checkSystemPackages": "Vérifier les paquets système",
|
||||
"checkInProgress": "Vérification en cours",
|
||||
"refreshCheckHelp": "Actualise les index de paquets et vérifie les mises à jour en attente pendant que le conteneur est en cours d'exécution. Rien n'est installé.",
|
||||
"notCheckedYet": "Les mises à jour n'ont pas encore été vérifiées.",
|
||||
"checkStillRunning": "La vérification est toujours en cours. Son résultat sera enregistré à la fin.",
|
||||
"checkBeforeUpdate": "Vérifier d'abord les mises à jour",
|
||||
"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."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Mise à jour groupée",
|
||||
@@ -1484,7 +1499,10 @@
|
||||
"saveFailed": "Impossible d’enregistrer la configuration de mise à jour groupée.",
|
||||
"deleteFailed": "Impossible de supprimer la configuration de mise à jour groupée.",
|
||||
"planFailed": "Impossible de préparer la mise à jour groupée. Vérifiez les méthodes indisponibles.",
|
||||
"loadFailed": "Impossible de charger la configuration de la mise à jour groupée."
|
||||
"loadFailed": "Impossible de charger la configuration de la mise à jour groupée.",
|
||||
"osDockerScope": "Les mises à jour du système incluent aussi Docker lorsqu'il est installé comme paquet système.",
|
||||
"unverifiedTargetsHelp": "La disponibilité des mises à jour n'est pas vérifiée pour certains éléments sélectionnés. Le lancement exécutera les méthodes de mise à jour choisies même sans nouvelle version confirmée.",
|
||||
"dockerImageMethod": "Image Docker"
|
||||
},
|
||||
"appEditor": {
|
||||
"methodOciImage": "Image OCI (installée par ProxMenux)",
|
||||
@@ -2199,6 +2217,17 @@
|
||||
"gettingStartedDescription": "Un exemple d’invite est prêt à vous aider à démarrer. Vous pouvez l'ajuster, l'exporter pour le partager avec d'autres ou importer une invite de la communauté.",
|
||||
"loadExample": "Charger un exemple",
|
||||
"startFromScratch": "Repartir de zéro"
|
||||
},
|
||||
"history": {
|
||||
"title": "Historique des notifications",
|
||||
"description": "Les 20 dernières notifications envoyées, y compris chaque exécution du résumé quotidien, avec le résultat de chacune.",
|
||||
"empty": "Aucune notification n'a encore été envoyée.",
|
||||
"loadFailed": "L'historique des notifications n'a pas pu être chargé.",
|
||||
"sent": "Envoyé",
|
||||
"notSent": "Non envoyé",
|
||||
"severityInfo": "Info",
|
||||
"severityWarning": "Avertissement",
|
||||
"severityCritical": "Critique"
|
||||
}
|
||||
},
|
||||
"optimizations": {
|
||||
@@ -3410,7 +3439,7 @@
|
||||
"webhookHttps": "Les notifications de Proxmox atteignent le Monitor avec HTTPS activé — elles sont livrées sur une adresse uniquement locale et n'échouent plus sur une erreur de certificat.",
|
||||
"persistentLogs": "Une rafale d'erreurs de logs terminée n'est plus signalée comme persistante : un motif doit continuer d'apparaître pendant 15 minutes, et son avertissement se ferme de lui-même (signalé par @Joshua1264).",
|
||||
"mountsLanAddress": "Les points de montage sur LVM-thin et autres stockages en mode bloc affichent leur utilisation, et les applications multi-conteneurs s'ouvrent sur leur adresse LAN.",
|
||||
"ociManager": "OCI manager Apps (bêta) — 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 plus de 360 applications.",
|
||||
"ociManager": "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 plus de 360 applications.",
|
||||
"ociInMonitor": "Applications OCI dans le Monitor — l'onglet App les reconnaît, l'onglet Mises à jour les met à jour et les modifie comme le fait le menu, et un onglet Logs suit en direct la sortie console de l'application.",
|
||||
"ociAcceleration": "Accélération matérielle pour les applications OCI — l'installateur ne propose que les profils que l'hôte peut exécuter : GPU NVIDIA, Intel et AMD et, pour Frigate, un NPU Intel ou une Coral. Le NVIDIA Container Toolkit s'installe avec le pilote.",
|
||||
"ociRecovery": "Une application OCI voyage avec sa sauvegarde — restaurée sur cet hôte ou sur un autre, elle est de nouveau enregistrée et gérée, et dans un cluster elle démarre sur le nœud vers lequel elle est déplacée.",
|
||||
@@ -4992,7 +5021,9 @@
|
||||
"lxcTerminal": {
|
||||
"title": "Terminal : {name} (ID : {id})",
|
||||
"errors": {
|
||||
"invalidVmid": "VMID invalide - refus d'exécuter PCT Enter"
|
||||
"invalidVmid": "VMID invalide - refus d'exécuter PCT Enter",
|
||||
"noShell": "L'image de ce conteneur n'a pas de shell, elle ne peut donc pas ouvrir de terminal. Sa sortie se trouve dans l'onglet Logs.",
|
||||
"enterFailed": "Impossible d'entrer dans le conteneur."
|
||||
},
|
||||
"commands": {
|
||||
"listFiles": "Lister tous les fichiers avec des détails",
|
||||
@@ -5225,7 +5256,8 @@
|
||||
"yes": "Oui",
|
||||
"yourInput": "Votre contribution :",
|
||||
"submit": "Soumettre",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"endOfInput": "Fin de la saisie"
|
||||
},
|
||||
"apps": {
|
||||
"searchPlaceholder": "Search app or CT…",
|
||||
|
||||
@@ -1085,7 +1085,11 @@
|
||||
"shutdown": "Spegni",
|
||||
"reboot": "Riavvia",
|
||||
"forceStop": "Forza arresto",
|
||||
"working": "Operazione in corso..."
|
||||
"working": "Operazione in corso...",
|
||||
"startPending": "Avvio...",
|
||||
"shutdownPending": "Spegnimento...",
|
||||
"rebootPending": "Riavvio...",
|
||||
"stopPending": "Arresto..."
|
||||
},
|
||||
"confirm": {
|
||||
"forceStopTitle": "Forza arresto",
|
||||
@@ -1134,7 +1138,8 @@
|
||||
"saveCustomCommandFailed": "Impossibile salvare il comando di aggiornamento personalizzato: {message}",
|
||||
"removeCustomCommandConfirm": "Rimuovere il comando di aggiornamento personalizzato per \"{name}\"?",
|
||||
"removeCustomCommandFailed": "Impossibile rimuovere il comando di aggiornamento personalizzato: {message}",
|
||||
"hideNoticeFailed": "Impossibile nascondere questo avviso: {message}"
|
||||
"hideNoticeFailed": "Impossibile nascondere questo avviso: {message}",
|
||||
"controlNotConfirmed": "Il completamento dell'operazione non è stato confermato. Controlla lo stato del guest prima di riprovare."
|
||||
},
|
||||
"backupModal": {
|
||||
"title": "Backup {type} {vmid} ({name})",
|
||||
@@ -1459,7 +1464,17 @@
|
||||
"checkReadOnlyHelp": "Legge il sistema operativo attuale e i pacchetti in sospeso dagli indici esistenti. Nessuna installazione né aggiornamento degli indici dei pacchetti.",
|
||||
"checkCooldown": "Un controllo è già in corso o è stato richiesto di recente. Riprovare tra {seconds} secondi.",
|
||||
"checkStale": "Controllo obsoleto",
|
||||
"checkStaleHelp": "Questo risultato ha più di 48 ore o non ha una data di controllo valida. Eseguire un nuovo controllo prima di farvi affidamento."
|
||||
"checkStaleHelp": "Questo risultato ha più di 48 ore o non ha una data di controllo valida. Eseguire un nuovo controllo prima di farvi affidamento.",
|
||||
"checkSystemPackages": "Controlla pacchetti di sistema",
|
||||
"checkInProgress": "Controllo in corso",
|
||||
"refreshCheckHelp": "Aggiorna gli indici dei pacchetti e controlla gli aggiornamenti in sospeso mentre il container è in esecuzione. Non viene installato nulla.",
|
||||
"notCheckedYet": "Gli aggiornamenti non sono ancora stati controllati.",
|
||||
"checkStillRunning": "Il controllo è ancora in corso. Il risultato verrà salvato al termine.",
|
||||
"checkBeforeUpdate": "Controlla prima gli aggiornamenti",
|
||||
"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."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Aggiornamento in blocco",
|
||||
@@ -1484,7 +1499,10 @@
|
||||
"saveFailed": "Impossibile salvare la configurazione di aggiornamento in blocco.",
|
||||
"deleteFailed": "Impossibile rimuovere la configurazione di aggiornamento in blocco.",
|
||||
"planFailed": "Impossibile preparare l’aggiornamento in blocco. Modifica la configurazione e controlla i metodi non disponibili.",
|
||||
"loadFailed": "Impossibile caricare la configurazione dell’aggiornamento in blocco."
|
||||
"loadFailed": "Impossibile caricare la configurazione dell’aggiornamento in blocco.",
|
||||
"osDockerScope": "Gli aggiornamenti del sistema operativo includono anche Docker quando è installato come pacchetto di sistema.",
|
||||
"unverifiedTargetsHelp": "La disponibilità di aggiornamenti per alcuni elementi selezionati non è verificata. L'avvio eseguirà i metodi di aggiornamento scelti anche senza una nuova versione confermata.",
|
||||
"dockerImageMethod": "Immagine Docker"
|
||||
},
|
||||
"appEditor": {
|
||||
"methodOciImage": "Immagine OCI (installata da ProxMenux)",
|
||||
@@ -2199,6 +2217,17 @@
|
||||
"gettingStartedDescription": "Un prompt di esempio è pronto per aiutarti a iniziare. Puoi modificarlo, esportarlo per condividerlo con altri o importare un prompt dalla community.",
|
||||
"loadExample": "Carica esempio",
|
||||
"startFromScratch": "Inizia da zero"
|
||||
},
|
||||
"history": {
|
||||
"title": "Cronologia delle notifiche",
|
||||
"description": "Le ultime 20 notifiche inviate, inclusa ogni esecuzione del digest giornaliero, con il risultato di ciascuna.",
|
||||
"empty": "Nessuna notifica è stata ancora inviata.",
|
||||
"loadFailed": "Non è stato possibile caricare la cronologia delle notifiche.",
|
||||
"sent": "Inviata",
|
||||
"notSent": "Non inviata",
|
||||
"severityInfo": "Info",
|
||||
"severityWarning": "Avviso",
|
||||
"severityCritical": "Critico"
|
||||
}
|
||||
},
|
||||
"optimizations": {
|
||||
@@ -3410,7 +3439,7 @@
|
||||
"webhookHttps": "Le notifiche Proxmox raggiungono il Monitor con HTTPS attivo: consegnate su un indirizzo solo locale, non falliscono più per errori di certificato.",
|
||||
"persistentLogs": "Una raffica di errori di log conclusa non è più segnalata come persistente: un pattern deve continuare a comparire per 15 minuti e l’avviso si chiude da solo (segnalato da @Joshua1264).",
|
||||
"mountsLanAddress": "I mount su LVM-thin e altri storage a blocchi mostrano l’utilizzo; le app multi-container si aprono al proprio indirizzo LAN.",
|
||||
"ociManager": "OCI manager Apps (beta): 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 oltre 360 applicazioni.",
|
||||
"ociManager": "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 oltre 360 applicazioni.",
|
||||
"ociInMonitor": "Applicazioni OCI nel Monitor: la scheda App le riconosce, la scheda Aggiornamenti le aggiorna e le modifica come fa il menu, e una scheda Log segue in tempo reale l'output della console dell'applicazione.",
|
||||
"ociAcceleration": "Accelerazione hardware per le applicazioni OCI: l'installer offre solo i profili che l'host può eseguire: GPU NVIDIA, Intel e AMD e, per Frigate, una NPU Intel o una Coral. NVIDIA Container Toolkit viene installato con il driver.",
|
||||
"ociRecovery": "Un'applicazione OCI viaggia con il suo backup: ripristinata su questo host o su un altro, viene di nuovo registrata e gestita, e in un cluster si avvia sul nodo su cui viene spostata.",
|
||||
@@ -4992,7 +5021,9 @@
|
||||
"lxcTerminal": {
|
||||
"title": "Terminale: {name} (ID: {id})",
|
||||
"errors": {
|
||||
"invalidVmid": "VMID non valido: esecuzione di pct enter rifiutata"
|
||||
"invalidVmid": "VMID non valido: esecuzione di pct enter rifiutata",
|
||||
"noShell": "L'immagine di questo container non ha una shell, quindi non può aprire un terminale. Il suo output si trova nella scheda Log.",
|
||||
"enterFailed": "Impossibile entrare nel container."
|
||||
},
|
||||
"commands": {
|
||||
"listFiles": "Elenca tutti i file con dettagli",
|
||||
@@ -5225,7 +5256,8 @@
|
||||
"yes": "Sì",
|
||||
"yourInput": "Input:",
|
||||
"submit": "Invia",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"endOfInput": "Fine dell'input"
|
||||
},
|
||||
"apps": {
|
||||
"searchPlaceholder": "Cerca app o CT…",
|
||||
|
||||
@@ -1081,11 +1081,15 @@
|
||||
"logs": "Logs"
|
||||
},
|
||||
"actions": {
|
||||
"start": "Começar",
|
||||
"start": "Iniciar",
|
||||
"shutdown": "Desligar",
|
||||
"reboot": "Reinício",
|
||||
"reboot": "Reiniciar",
|
||||
"forceStop": "Forçar parada",
|
||||
"working": "Trabalhando..."
|
||||
"working": "Processando...",
|
||||
"startPending": "Iniciando...",
|
||||
"shutdownPending": "Desligando...",
|
||||
"rebootPending": "Reiniciando...",
|
||||
"stopPending": "Parando..."
|
||||
},
|
||||
"confirm": {
|
||||
"forceStopTitle": "Forçar parada",
|
||||
@@ -1134,7 +1138,8 @@
|
||||
"saveCustomCommandFailed": "Não foi possível salvar o comando de atualização personalizado: {message}",
|
||||
"removeCustomCommandConfirm": "Remover o comando de atualização personalizado para \"{name}\"?",
|
||||
"removeCustomCommandFailed": "não foi possível remover o comando de atualização personalizado: {message}",
|
||||
"hideNoticeFailed": "Não foi possível ocultar este aviso: {message}"
|
||||
"hideNoticeFailed": "Não foi possível ocultar este aviso: {message}",
|
||||
"controlNotConfirmed": "A conclusão da operação não foi confirmada. Verifique o estado do convidado antes de tentar novamente."
|
||||
},
|
||||
"backupModal": {
|
||||
"title": "Backup {type} {vmid} ({name})",
|
||||
@@ -1459,7 +1464,17 @@
|
||||
"checkReadOnlyHelp": "Lê o sistema operativo atual e os pacotes pendentes a partir dos índices existentes. Não instala nada nem atualiza os índices de pacotes.",
|
||||
"checkCooldown": "Já há uma verificação em execução ou pedida recentemente. Tente novamente dentro de {seconds} segundos.",
|
||||
"checkStale": "Verificação desatualizada",
|
||||
"checkStaleHelp": "Este resultado tem mais de 48 horas ou não tem uma data de verificação válida. Execute uma nova verificação antes de confiar nele."
|
||||
"checkStaleHelp": "Este resultado tem mais de 48 horas ou não tem uma data de verificação válida. Execute uma nova verificação antes de confiar nele.",
|
||||
"checkSystemPackages": "Verificar pacotes do sistema",
|
||||
"checkInProgress": "Verificação em andamento",
|
||||
"refreshCheckHelp": "Atualiza os índices de pacotes e verifica as atualizações pendentes enquanto o contêiner está em execução. Nada é instalado.",
|
||||
"notCheckedYet": "As atualizações ainda não foram verificadas.",
|
||||
"checkStillRunning": "A verificação ainda está em execução. O resultado será salvo quando terminar.",
|
||||
"checkBeforeUpdate": "Verificar atualizações primeiro",
|
||||
"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."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Atualização em bloco",
|
||||
@@ -1484,7 +1499,10 @@
|
||||
"saveFailed": "Não foi possível guardar a configuração de atualização em bloco.",
|
||||
"deleteFailed": "Não foi possível remover a configuração de atualização em bloco.",
|
||||
"planFailed": "Não foi possível preparar a atualização em bloco. Reveja os métodos indisponíveis.",
|
||||
"loadFailed": "não foi possível carregar a configuração de atualização em massa."
|
||||
"loadFailed": "não foi possível carregar a configuração de atualização em massa.",
|
||||
"osDockerScope": "As atualizações do sistema operacional também incluem o Docker quando ele está instalado como pacote do sistema.",
|
||||
"unverifiedTargetsHelp": "A disponibilidade de atualização de alguns itens selecionados não foi verificada. Ao iniciar, os métodos de atualização escolhidos serão executados mesmo sem uma nova versão confirmada.",
|
||||
"dockerImageMethod": "Imagem Docker"
|
||||
},
|
||||
"appEditor": {
|
||||
"methodOciImage": "Imagem OCI (instalada pelo ProxMenux)",
|
||||
@@ -2199,6 +2217,17 @@
|
||||
"gettingStartedDescription": "Um exemplo de prompt está pronto para ajudá-lo a começar. Você pode ajustá-lo, exportá-lo para compartilhar com outras pessoas ou importar um prompt da comunidade.",
|
||||
"loadExample": "Exemplo de carregamento",
|
||||
"startFromScratch": "Comece do zero"
|
||||
},
|
||||
"history": {
|
||||
"title": "Histórico de notificações",
|
||||
"description": "As últimas 20 notificações enviadas, incluindo cada execução do resumo diário, com o resultado de cada uma.",
|
||||
"empty": "Nenhuma notificação foi enviada ainda.",
|
||||
"loadFailed": "O histórico de notificações não pôde ser carregado.",
|
||||
"sent": "Enviado",
|
||||
"notSent": "Não enviado",
|
||||
"severityInfo": "Info",
|
||||
"severityWarning": "Aviso",
|
||||
"severityCritical": "Crítico"
|
||||
}
|
||||
},
|
||||
"optimizations": {
|
||||
@@ -3410,7 +3439,7 @@
|
||||
"webhookHttps": "As notificações do Proxmox chegam ao Monitor com HTTPS ativo — são entregues num endereço apenas local e já não falham por um erro de certificado.",
|
||||
"persistentLogs": "Uma rajada de erros nos logs que terminou já não é apresentada como persistente: um padrão tem de continuar a aparecer durante 15 minutos, e o seu aviso fecha-se sozinho (reportado por @Joshua1264).",
|
||||
"mountsLanAddress": "Os pontos de montagem em LVM-thin e outros armazenamentos em bloco mostram a sua utilização, e as aplicações com vários contentores abrem no seu endereço LAN.",
|
||||
"ociManager": "OCI manager Apps (beta) — 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 mais de 360 aplicações.",
|
||||
"ociManager": "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 mais de 360 aplicações.",
|
||||
"ociInMonitor": "Aplicações OCI no Monitor — o separador App reconhece-as, o separador Atualizações atualiza-as e modifica-as como o menu, e um separador Logs segue em direto a saída da consola da aplicação.",
|
||||
"ociAcceleration": "Aceleração por hardware para aplicações OCI — o instalador oferece apenas os perfis que o host pode executar: GPU NVIDIA, Intel e AMD e, para o Frigate, uma NPU Intel ou uma Coral. O NVIDIA Container Toolkit é instalado com o driver.",
|
||||
"ociRecovery": "Uma aplicação OCI viaja com o seu backup — restaurada neste host ou noutro, volta a ficar registada e gerida, e num cluster arranca no nó para onde for movida.",
|
||||
@@ -4992,7 +5021,9 @@
|
||||
"lxcTerminal": {
|
||||
"title": "Terminal: {name} (ID: {id})",
|
||||
"errors": {
|
||||
"invalidVmid": "VMID inválido – recusando-se a executar pct enter"
|
||||
"invalidVmid": "VMID inválido – recusando-se a executar pct enter",
|
||||
"noShell": "A imagem deste contêiner não possui shell, portanto não pode abrir um terminal. A sua saída está na aba Logs.",
|
||||
"enterFailed": "Não foi possível entrar no contêiner."
|
||||
},
|
||||
"commands": {
|
||||
"listFiles": "Liste todos os arquivos com detalhes",
|
||||
@@ -5225,7 +5256,8 @@
|
||||
"yes": "Sim",
|
||||
"yourInput": "Sua entrada:",
|
||||
"submit": "Enviar",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"endOfInput": "Fim da entrada"
|
||||
},
|
||||
"apps": {
|
||||
"searchPlaceholder": "Search app or CT…",
|
||||
|
||||
@@ -1106,7 +1106,11 @@
|
||||
"shutdown": "Vypnúť",
|
||||
"reboot": "Reštartovať",
|
||||
"forceStop": "Vynútiť vypnutie",
|
||||
"working": "Pracujem..."
|
||||
"working": "Pracujem...",
|
||||
"startPending": "Spúšťa sa...",
|
||||
"shutdownPending": "Vypína sa...",
|
||||
"rebootPending": "Reštartuje sa...",
|
||||
"stopPending": "Vypína sa..."
|
||||
},
|
||||
"confirm": {
|
||||
"forceStopTitle": "Vynútiť vypnutie",
|
||||
@@ -1150,6 +1154,7 @@
|
||||
"unknown": "Neznáma chyba",
|
||||
"backupStartFailed": "Zálohu sa nepodarilo spustiť: {message}",
|
||||
"controlFailed": "Akcia {action} pre VM {vmid} zlyhala: {message}",
|
||||
"controlNotConfirmed": "Dokončenie operácie sa zatiaľ nepodarilo potvrdiť. Pred ďalším pokusom skontrolujte stav VM alebo LXC.",
|
||||
"saveNotesFailed": "Poznámky sa nepodarilo uložiť. Skúste to znova.",
|
||||
"appNotFound": "Táto aplikácia už nie je dostupná. Obnovte stránku a skúste to znova.",
|
||||
"saveCustomCommandFailed": "Vlastný príkaz na aktualizáciu sa nepodarilo uložiť: {message}",
|
||||
@@ -1340,7 +1345,14 @@
|
||||
"updatedWithDockerImage": "Aktualizuje sa spolu so svojím obrazom Docker.",
|
||||
"dockerBindingUnavailable": "Zatiaľ nie je priradený žiadny obraz. Skontrolujte názov kontajnera a obnovte inventár obrazov.",
|
||||
"osPackagesTitle": "Balíky systému",
|
||||
"checkNow": "Skontrolovať teraz",
|
||||
"checkNow": "Skontrolovať aktualizácie",
|
||||
"checkSystemPackages": "Skontrolovať balíky systému",
|
||||
"checkInProgress": "Prebieha kontrola",
|
||||
"refreshCheckHelp": "Obnoví zoznamy balíkov a skontroluje čakajúce aktualizácie, kým je kontajner spustený. Nič nenainštaluje.",
|
||||
"notCheckedYet": "Aktualizácie zatiaľ nie sú skontrolované.",
|
||||
"checkStillRunning": "Kontrola ešte prebieha. Po dokončení sa uloží jej výsledok.",
|
||||
"checkBeforeUpdate": "Najprv skontrolovať aktualizácie",
|
||||
"startBeforeCheck": "Spustite kontajner a potom skontrolujte aktualizácie. Chýbajúci odznak neznamená, že nie sú žiadne aktualizácie.",
|
||||
"checkReadOnlyHelp": "Zistí aktuálny systém a čakajúce balíky podľa existujúcich zoznamov. Nič nenainštaluje ani neobnoví zoznamy balíkov.",
|
||||
"checkCooldown": "Kontrola už prebieha alebo sa práve vykonala. Skúste znova o {seconds} sekúnd.",
|
||||
"checkStale": "Zastaraná kontrola",
|
||||
@@ -1388,6 +1400,9 @@
|
||||
"applyUpdate": "Aktualizovať",
|
||||
"upToDate": "Aktuálne",
|
||||
"runUpdater": "Spustiť aktualizáciu",
|
||||
"runCustomCommand": "Spustiť vlastný príkaz",
|
||||
"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}",
|
||||
"terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...",
|
||||
"terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...",
|
||||
@@ -1489,9 +1504,12 @@
|
||||
"notConfigured": "Nenakonfigurované.",
|
||||
"configure": "Nastaviť",
|
||||
"edit": "Upraviť",
|
||||
"apply": "Použiť aktualizácie",
|
||||
"apply": "Spustiť hromadnú aktualizáciu",
|
||||
"osTarget": "Operačný systém",
|
||||
"osRequired": "Povinné pre hromadnú aktualizáciu.",
|
||||
"osDockerScope": "Systémová aktualizácia zahŕňa aj Docker nainštalovaný ako systémový balík.",
|
||||
"unverifiedTargetsHelp": "Dostupnosť aktualizácií pre niektoré vybrané položky nie je overená. Spustenie vykoná vybrané aktualizačné metódy aj bez potvrdenej novej verzie.",
|
||||
"dockerImageMethod": "Docker obraz",
|
||||
"includesDependencies": "Zahŕňa aj deklarované závislosti: {names}",
|
||||
"noMethod": "Nie je dostupná žiadna spustiteľná metóda aktualizácie.",
|
||||
"staleTarget": "Tento výber už nie je dostupný. Pred uložením ho odstráňte.",
|
||||
@@ -2198,6 +2216,17 @@
|
||||
"gettingStartedDescription": "Na začiatok máte pripravený vzorový prompt. Môžete ho upraviť, exportovať a zdieľať alebo importovať prompt od komunity.",
|
||||
"loadExample": "Načítať príklad",
|
||||
"startFromScratch": "Začať od začiatku"
|
||||
},
|
||||
"history": {
|
||||
"title": "História upozornení",
|
||||
"description": "Posledných 20 odoslaných upozornení vrátane každého spustenia denného súhrnu s výsledkom každého z nich.",
|
||||
"empty": "Zatiaľ neboli odoslané žiadne upozornenia.",
|
||||
"loadFailed": "Históriu upozornení sa nepodarilo načítať.",
|
||||
"sent": "Odoslané",
|
||||
"notSent": "Neodoslané",
|
||||
"severityInfo": "Info",
|
||||
"severityWarning": "Upozornenie",
|
||||
"severityCritical": "Kritické"
|
||||
}
|
||||
},
|
||||
"optimizations": {
|
||||
@@ -3409,7 +3438,7 @@
|
||||
"webhookHttps": "Upozornenia Proxmoxu sa dostanú do Monitora aj so zapnutým HTTPS — doručujú sa na výhradne lokálnu adresu a už nezlyhávajú na chybe certifikátu.",
|
||||
"persistentLogs": "Skončená vlna chýb v logoch sa už nehlási ako trvalá: vzor sa musí opakovať 15 minút a jeho upozornenie sa zatvorí samo (nahlásil @Joshua1264).",
|
||||
"mountsLanAddress": "Body pripojenia na LVM-thin a inom blokovom úložisku zobrazujú svoje využitie a aplikácie s viacerými kontajnermi sa otvárajú na svojej LAN adrese.",
|
||||
"ociManager": "OCI manager Apps (beta) — 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 viac ako 360 aplikácií.",
|
||||
"ociManager": "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 viac ako 360 aplikácií.",
|
||||
"ociInMonitor": "OCI aplikácie v Monitore — karta Aplikácie ich rozpozná, karta Aktualizácie ich aktualizuje a upravuje rovnako ako ponuka a karta Logy naživo sleduje výstup konzoly aplikácie.",
|
||||
"ociAcceleration": "Hardvérová akcelerácia pre OCI aplikácie — inštalátor ponúka iba profily, ktoré hostiteľ dokáže spustiť: GPU NVIDIA, Intel a AMD a pre Frigate Intel NPU alebo Coral. NVIDIA Container Toolkit sa inštaluje spolu s ovládačom.",
|
||||
"ociRecovery": "OCI aplikácia cestuje so svojou zálohou — po obnovení na tomto alebo inom hostiteľovi je znova zaregistrovaná a spravovaná a v klastri sa spustí na uzle, na ktorý sa presunie.",
|
||||
@@ -4991,7 +5020,9 @@
|
||||
"lxcTerminal": {
|
||||
"title": "Terminál: {name} (ID: {id})",
|
||||
"errors": {
|
||||
"invalidVmid": "Neplatné VMID — pct enter sa nespustí"
|
||||
"invalidVmid": "Neplatné VMID — pct enter sa nespustí",
|
||||
"noShell": "Image tohto kontajnera nemá shell, takže nemôže otvoriť terminál. Jeho výstup je na karte Logy.",
|
||||
"enterFailed": "Do kontajnera sa nepodarilo vstúpiť."
|
||||
},
|
||||
"commands": {
|
||||
"listFiles": "Zobraziť podrobnosti o súboroch",
|
||||
@@ -5224,7 +5255,8 @@
|
||||
"yes": "Áno",
|
||||
"yourInput": "Tvoj vstup:",
|
||||
"submit": "Odoslať",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"endOfInput": "Koniec zadávania"
|
||||
},
|
||||
"apps": {
|
||||
"searchPlaceholder": "Hľadať aplikáciu, VM alebo LXC…",
|
||||
|
||||
@@ -1081,11 +1081,15 @@
|
||||
"logs": "Loggar"
|
||||
},
|
||||
"actions": {
|
||||
"start": "Start",
|
||||
"shutdown": "Stängning",
|
||||
"start": "Starta",
|
||||
"shutdown": "Stäng av",
|
||||
"reboot": "Starta om",
|
||||
"forceStop": "Tvinga stopp",
|
||||
"working": "Arbetssätt..."
|
||||
"working": "Arbetar...",
|
||||
"startPending": "Startar...",
|
||||
"shutdownPending": "Stänger av...",
|
||||
"rebootPending": "Startar om...",
|
||||
"stopPending": "Stoppar..."
|
||||
},
|
||||
"confirm": {
|
||||
"forceStopTitle": "Tvinga stopp",
|
||||
@@ -1134,7 +1138,8 @@
|
||||
"saveCustomCommandFailed": "Kunde inte spara det anpassade uppdateringskommandot: {message}",
|
||||
"removeCustomCommandConfirm": "Ta bort det anpassade uppdateringskommandot för \"{name}\"?",
|
||||
"removeCustomCommandFailed": "Kunde inte ta bort det anpassade uppdateringskommandot: {message}",
|
||||
"hideNoticeFailed": "Kunde inte dölja detta meddelande: {message}"
|
||||
"hideNoticeFailed": "Kunde inte dölja detta meddelande: {message}",
|
||||
"controlNotConfirmed": "Det har inte bekräftats att åtgärden är slutförd. Kontrollera gästens status innan du försöker igen."
|
||||
},
|
||||
"backupModal": {
|
||||
"title": "Säkerhetskopiera {type} {vmid} ({name})",
|
||||
@@ -1459,7 +1464,17 @@
|
||||
"checkReadOnlyHelp": "Läser aktuellt operativsystem och väntande paket från befintliga index. Ingen installation och ingen uppdatering av paketindex.",
|
||||
"checkCooldown": "En kontroll körs redan eller har nyligen begärts. Försök igen om {seconds} sekunder.",
|
||||
"checkStale": "Föråldrad kontroll",
|
||||
"checkStaleHelp": "Detta resultat är äldre än 48 timmar eller saknar ett giltigt kontrolldatum. Kör en ny kontroll innan du litar på det."
|
||||
"checkStaleHelp": "Detta resultat är äldre än 48 timmar eller saknar ett giltigt kontrolldatum. Kör en ny kontroll innan du litar på det.",
|
||||
"checkSystemPackages": "Kontrollera systempaket",
|
||||
"checkInProgress": "Kontroll pågår",
|
||||
"refreshCheckHelp": "Uppdaterar paketindex och kontrollerar väntande uppdateringar medan behållaren körs. Inget installeras.",
|
||||
"notCheckedYet": "Uppdateringar har inte kontrollerats ännu.",
|
||||
"checkStillRunning": "Kontrollen pågår fortfarande. Resultatet sparas när den är klar.",
|
||||
"checkBeforeUpdate": "Kontrollera uppdateringar först",
|
||||
"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."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Gruppuppdatering",
|
||||
@@ -1484,7 +1499,10 @@
|
||||
"saveFailed": "Det gick inte att spara gruppuppdateringskonfigurationen.",
|
||||
"deleteFailed": "Det gick inte att ta bort gruppuppdateringskonfigurationen.",
|
||||
"planFailed": "Det gick inte att förbereda gruppuppdateringen. Kontrollera otillgängliga metoder.",
|
||||
"loadFailed": "Det gick inte att läsa in massuppdateringskonfigurationen."
|
||||
"loadFailed": "Det gick inte att läsa in massuppdateringskonfigurationen.",
|
||||
"osDockerScope": "OS-uppdateringar omfattar även Docker när det är installerat som systempaket.",
|
||||
"unverifiedTargetsHelp": "För vissa valda objekt är det inte verifierat om en uppdatering finns. Vid start körs de valda uppdateringsmetoderna även utan en bekräftad ny version.",
|
||||
"dockerImageMethod": "Docker-avbildning"
|
||||
},
|
||||
"appEditor": {
|
||||
"methodOciImage": "OCI-avbild (installerad av ProxMenux)",
|
||||
@@ -2199,6 +2217,17 @@
|
||||
"gettingStartedDescription": "Ett exempel är redo för att hjälpa dig att komma igång. Du kan justera den, exportera den för att dela med andra eller importera en uppmaning från communityn.",
|
||||
"loadExample": "Ladda exempel",
|
||||
"startFromScratch": "Börja från början"
|
||||
},
|
||||
"history": {
|
||||
"title": "Aviseringshistorik",
|
||||
"description": "De senaste 20 aviseringarna som skickats, inklusive varje körning av det dagliga sammandraget, med resultatet av var och en.",
|
||||
"empty": "Inga meddelanden har skickats än.",
|
||||
"loadFailed": "Aviseringshistoriken kunde inte laddas.",
|
||||
"sent": "Skickat",
|
||||
"notSent": "Ej skickat",
|
||||
"severityInfo": "Info",
|
||||
"severityWarning": "Varning",
|
||||
"severityCritical": "Kritisk"
|
||||
}
|
||||
},
|
||||
"optimizations": {
|
||||
@@ -3410,7 +3439,7 @@
|
||||
"webhookHttps": "Proxmox-aviseringar når Monitor med HTTPS aktiverat — de levereras till en adress som bara är lokal och misslyckas inte längre på grund av ett certifikatfel.",
|
||||
"persistentLogs": "En avslutad skur av loggfel rapporteras inte längre som bestående: ett mönster måste fortsätta att dyka upp i 15 minuter, och dess varning stängs av sig själv (rapporterat av @Joshua1264).",
|
||||
"mountsLanAddress": "Monteringspunkter på LVM-thin och annan blocklagring visar sin användning, och program med flera containrar öppnas på sin LAN-adress.",
|
||||
"ociManager": "OCI manager Apps (beta) — en ny post i ProxMenux-menyn installerar applikationer från deras officiella containeravbilder som inbyggda Proxmox LXC, utan Docker-motor. Katalogen innehåller över 360 applikationer.",
|
||||
"ociManager": "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 över 360 applikationer.",
|
||||
"ociInMonitor": "OCI-applikationer i Monitor — fliken App känner igen dem, fliken Uppdateringar uppdaterar och ändrar dem som menyn gör, och fliken Logs följer applikationens konsolutdata live.",
|
||||
"ociAcceleration": "Hårdvaruacceleration för OCI-applikationer — installationsprogrammet erbjuder bara de profiler som värden kan köra: NVIDIA-, Intel- och AMD-GPU:er och, för Frigate, en Intel NPU eller en Coral. NVIDIA Container Toolkit installeras med drivrutinen.",
|
||||
"ociRecovery": "En OCI-applikation reser med sin säkerhetskopia — återställd på den här värden eller på en annan registreras och hanteras den igen, och i ett kluster startar den på den nod den flyttas till.",
|
||||
@@ -4992,7 +5021,9 @@
|
||||
"lxcTerminal": {
|
||||
"title": "Terminal: {name} (ID: {id})",
|
||||
"errors": {
|
||||
"invalidVmid": "Ogiltig VMID — vägrar att köra pct enter"
|
||||
"invalidVmid": "Ogiltig VMID — vägrar att köra pct enter",
|
||||
"noShell": "Den här behållarens avbildning har inget skal, så den kan inte öppna en terminal. Dess utdata finns på fliken Loggar.",
|
||||
"enterFailed": "Det gick inte att gå in i behållaren."
|
||||
},
|
||||
"commands": {
|
||||
"listFiles": "Lista alla filer med detaljer",
|
||||
@@ -5225,7 +5256,8 @@
|
||||
"yes": "Ja",
|
||||
"yourInput": "Din inmatning:",
|
||||
"submit": "Skicka",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"endOfInput": "Slut på inmatning"
|
||||
},
|
||||
"apps": {
|
||||
"searchPlaceholder": "Search app or CT…",
|
||||
|
||||
@@ -147,6 +147,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_check_job.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
|
||||
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
|
||||
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
|
||||
|
||||
@@ -6729,6 +6729,11 @@ def _get_lxc_update_status_map() -> dict:
|
||||
'managed_oci_app': it.get('_managed_oci_app'),
|
||||
'os_family': it.get('_os_family'),
|
||||
}
|
||||
if os.path.exists(f'/run/lock/proxmenux-lxc-check-invalid-{int(vmid)}'):
|
||||
# Read-only projection: a failed/pending explicit check must not
|
||||
# re-expose a former positive count after a persistence failure.
|
||||
out[str(vmid)].update(available=False, count=0, packages=[],
|
||||
error='Latest explicit package check is running or failed; check again')
|
||||
return out
|
||||
|
||||
|
||||
@@ -6843,10 +6848,15 @@ def get_proxmox_vms():
|
||||
continue
|
||||
|
||||
vm_type = 'lxc' if resource.get('type') == 'lxc' else 'qemu'
|
||||
# The PVE summary can lag behind a completed LXC task.
|
||||
# Use the same direct local cgroup check as the detail
|
||||
# route; never resurrect a stopped CT from that summary.
|
||||
vm_status = (_fast_guest_status(resource.get('vmid'), vm_type)
|
||||
if vm_type == 'lxc' else resource.get('status', 'unknown'))
|
||||
vm_data = {
|
||||
'vmid': resource.get('vmid'),
|
||||
'name': resource.get('name', f"VM-{resource.get('vmid')}"),
|
||||
'status': resource.get('status', 'unknown'),
|
||||
'status': vm_status,
|
||||
'type': vm_type,
|
||||
'cpu': resource.get('cpu', 0),
|
||||
'mem': resource.get('mem', 0),
|
||||
@@ -6901,7 +6911,7 @@ def get_proxmox_vms():
|
||||
# detail-aware clients. The cache is still warmed
|
||||
# at startup and invalidated by LXC lifecycle events.
|
||||
vm_data['ips'] = []
|
||||
if resource.get('status') == 'running':
|
||||
if vm_data['status'] == 'running':
|
||||
lxc_ip_info = _get_lxc_ip_info_cached(resource.get('vmid'))
|
||||
lxc_ips = _normalise_lxc_ips(lxc_ip_info)
|
||||
vm_data['ips'] = lxc_ips
|
||||
@@ -13676,19 +13686,57 @@ def api_lxc_updates_check(vmid):
|
||||
'retry_after': retry}), 429, {'Retry-After': str(retry)}
|
||||
_lxc_update_check_attempts[vmid] = now
|
||||
_lxc_update_check_inflight.add(vmid)
|
||||
operation_lock = None
|
||||
try:
|
||||
import lxc_check_job
|
||||
operation_lock = lxc_check_job.acquire(vmid)
|
||||
item = managed_installs.refresh_lxc(vmid, refresh_package_cache=False)
|
||||
if item is None:
|
||||
return jsonify({'error': 'No supported running LXC detected'}), 409
|
||||
_vm_cache_invalidate(vmid, _lxc_os_info_cache)
|
||||
return jsonify({'vmid': vmid, 'update_check': _get_lxc_update_status_map().get(str(vmid))})
|
||||
except BlockingIOError:
|
||||
return jsonify({'error': 'Another container operation is running'}), 409
|
||||
except Exception as exc:
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
finally:
|
||||
if operation_lock is not None:
|
||||
operation_lock.close()
|
||||
with _lxc_update_check_lock:
|
||||
_lxc_update_check_inflight.discard(vmid)
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/updates/refresh-check', methods=['POST'])
|
||||
@require_auth
|
||||
def api_lxc_updates_refresh_check(vmid):
|
||||
"""Explicit index refresh/check for a running CT; never installs."""
|
||||
import lxc_check_job
|
||||
try:
|
||||
result, code = lxc_check_job.start(vmid)
|
||||
if code == 429:
|
||||
return jsonify({'error': 'Check already running or recently requested',
|
||||
'retry_after': 60}), 429, {'Retry-After': '60'}
|
||||
return jsonify(result), code
|
||||
except BlockingIOError:
|
||||
return jsonify({'error': 'Another container operation is running'}), 409
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
return jsonify({'error': str(exc)}), 409
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/updates/refresh-check/<job_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def api_lxc_updates_refresh_check_status(vmid, job_id):
|
||||
import lxc_check_job
|
||||
result = lxc_check_job.get(vmid, job_id)
|
||||
if result is None:
|
||||
return jsonify({'error': 'Check result not found; check again'}), 404
|
||||
if result['state'] != 'running':
|
||||
_vm_cache_invalidate(vmid, _lxc_os_info_cache)
|
||||
_pvesh_cache['cluster_resources_vm_time'] = 0
|
||||
result['update_check'] = _get_lxc_update_status_map().get(str(vmid))
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ─── LXC Update Detection toggle ────────────────────────────────────────────
|
||||
# Dedicated toggle so the operator can opt out of the per-CT `pct exec apt
|
||||
# list --upgradable` scan entirely. The Notifications section keeps its own
|
||||
@@ -14265,6 +14313,7 @@ def api_vm_apps_dismiss(vmid):
|
||||
# ─── LXC update finalization ────────────────────────────────────────────────
|
||||
|
||||
_LXC_APPLY_UPDATES_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"
|
||||
_LXC_INSTALL_CHECKED_OS_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh"
|
||||
_LXC_UPDATE_RUN_ID_RE = re.compile(r'^[A-Za-z0-9._:-]{1,128}$')
|
||||
_LXC_UPDATE_TARGET_RE = re.compile(r'^[A-Za-z0-9._:-]{1,180}$')
|
||||
from lxc_update_finalization import lock as _lxc_update_finalization_lock, runs as _lxc_update_finalizations
|
||||
@@ -14718,7 +14767,9 @@ def _finalize_lxc_update(
|
||||
|
||||
|
||||
def _terminal_lxc_update_completed(*, script_path, params, exit_code, duration_seconds):
|
||||
if os.path.realpath(script_path) != os.path.realpath(_LXC_APPLY_UPDATES_SCRIPT):
|
||||
if os.path.realpath(script_path) not in {
|
||||
os.path.realpath(_LXC_APPLY_UPDATES_SCRIPT),
|
||||
os.path.realpath(_LXC_INSTALL_CHECKED_OS_SCRIPT)}:
|
||||
return
|
||||
run_id = _normalise_lxc_update_run_id(params.get('RUN_ID'), create=False)
|
||||
if not run_id:
|
||||
@@ -15831,6 +15882,8 @@ def api_vm_config_set(vmid):
|
||||
@require_auth
|
||||
def api_vm_control(vmid):
|
||||
"""Control VM/LXC (start, stop, shutdown, reboot)"""
|
||||
operation_lock = None
|
||||
task_identity_confirmed = False
|
||||
try:
|
||||
data = request.get_json()
|
||||
action = data.get('action') # start, stop, shutdown, reboot
|
||||
@@ -15853,6 +15906,13 @@ def api_vm_control(vmid):
|
||||
|
||||
vm_type = 'lxc' if vm_info.get('type') == 'lxc' else 'qemu'
|
||||
node = vm_info.get('node', 'pve')
|
||||
if vm_type == 'lxc':
|
||||
import lxc_check_job
|
||||
try:
|
||||
operation_lock = lxc_check_job.acquire(vmid)
|
||||
except BlockingIOError:
|
||||
return jsonify({'error': 'Another container operation is running'}), 409
|
||||
lxc_check_job.begin_control(operation_lock, node)
|
||||
|
||||
# Execute action
|
||||
control_result = subprocess.run(
|
||||
@@ -15860,6 +15920,37 @@ def api_vm_control(vmid):
|
||||
capture_output=True, text=True, timeout=30)
|
||||
|
||||
if control_result.returncode == 0:
|
||||
import re
|
||||
# pvesh can print progress lines before the task identity.
|
||||
# The final non-empty line is the only task associated with
|
||||
# this request; accepting a different log line would make the
|
||||
# frontend poll an unrelated guest operation.
|
||||
lines = [line.strip().strip('"') for line in
|
||||
(control_result.stdout or '').splitlines() if line.strip()]
|
||||
task = lines[-1] if lines else ''
|
||||
task_types = [f"{'vz' if vm_type == 'lxc' else 'qm'}{action}"]
|
||||
if action == 'start':
|
||||
task_types.append('hastart')
|
||||
elif action in ('stop', 'shutdown'):
|
||||
# Proxmox 9 routes HA shutdown through hastop for both
|
||||
# LXC and QEMU guests.
|
||||
task_types.append('hastop')
|
||||
task_pattern = (r'UPID:[\w.-]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:'
|
||||
r'[0-9A-Fa-f]+:(?:' + '|'.join(task_types) +
|
||||
rf'):{vmid}:[\w.@!-]+:')
|
||||
if not re.fullmatch(task_pattern, task):
|
||||
# A successful process exit without a verifiable task
|
||||
# cannot be polled. Do not leave a task-less control
|
||||
# marker that would block this CT forever.
|
||||
if operation_lock is not None:
|
||||
lxc_check_job.cancel_control(operation_lock)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'vmid': vmid,
|
||||
'action': action,
|
||||
'error': 'Control task was not confirmed by Proxmox',
|
||||
}), 502
|
||||
task_identity_confirmed = True
|
||||
# Invalidate VM resources cache so the next /api/vms call
|
||||
# returns fresh status instead of the pre-action snapshot.
|
||||
_pvesh_cache['cluster_resources_vm_time'] = 0
|
||||
@@ -15867,13 +15958,21 @@ def api_vm_control(vmid):
|
||||
# (lxc_ip_info, os_info) — a start/stop flips those, so
|
||||
# drop the modal caches for this vmid.
|
||||
_vm_cache_invalidate(vmid)
|
||||
# The frontend waits for this exact task rather than assuming
|
||||
# that accepting the request means the action completed.
|
||||
if operation_lock is not None:
|
||||
lxc_check_job.retain_control(vmid, task, operation_lock)
|
||||
operation_lock = None
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'vmid': vmid,
|
||||
'action': action,
|
||||
'task': task,
|
||||
'message': f'Successfully executed {action} on {vm_info.get("name")}'
|
||||
})
|
||||
else:
|
||||
if operation_lock is not None:
|
||||
lxc_check_job.cancel_control(operation_lock)
|
||||
# `pvesh` failed → fire the matching vm_fail / ct_fail
|
||||
# notification so the user gets paged on their channels
|
||||
# too, not just an in-dashboard alert. Previously this
|
||||
@@ -15925,8 +16024,70 @@ def api_vm_control(vmid):
|
||||
else:
|
||||
return jsonify({'error': 'Failed to get VM details'}), 500
|
||||
except Exception as e:
|
||||
# A transport error happens before we have an identity that can be
|
||||
# recovered later. Leaving the pre-submission marker would otherwise
|
||||
# make the CT permanently busy after one timed-out pvesh invocation.
|
||||
if operation_lock is not None and not task_identity_confirmed:
|
||||
try:
|
||||
lxc_check_job.cancel_control(operation_lock)
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
finally:
|
||||
if operation_lock is not None:
|
||||
operation_lock.close()
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/control-task/<path:task>', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_control_task(vmid, task):
|
||||
"""Read only the power-control task bound to this guest and node."""
|
||||
import re
|
||||
import json
|
||||
if not re.fullmatch(r'UPID:[\w.-]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:'
|
||||
r'(?:(?:vz|qm)(?:start|stop|shutdown|reboot)|ha(?:start|stop)):'
|
||||
r'[0-9]+:[\w.@!-]+:', task):
|
||||
return jsonify({'error': 'Invalid control task'}), 400
|
||||
parts = task.split(':')
|
||||
resource = next((item for item in get_cached_pvesh_cluster_resources_vm() or []
|
||||
if item.get('vmid') == vmid), None)
|
||||
if resource is None:
|
||||
return jsonify({'error': 'VM/LXC not found'}), 404
|
||||
vm_type = 'lxc' if resource.get('type') == 'lxc' else 'qemu'
|
||||
node = resource.get('node', 'pve')
|
||||
prefix = 'vz' if vm_type == 'lxc' else 'qm'
|
||||
allowed_task_types = {f'{prefix}{action}' for action in
|
||||
('start', 'stop', 'shutdown', 'reboot')}
|
||||
allowed_task_types.update(('hastart', 'hastop'))
|
||||
if (parts[1] != node or parts[6] != str(vmid)
|
||||
or parts[5] not in allowed_task_types):
|
||||
return jsonify({'error': 'Control task does not belong to this guest'}), 400
|
||||
try:
|
||||
result = subprocess.run(['pvesh', 'get', f'/nodes/{node}/tasks/{task}/status',
|
||||
'--output-format', 'json'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if result.returncode != 0:
|
||||
return jsonify({'error': 'Control task status unavailable'}), 502
|
||||
status = json.loads(result.stdout)
|
||||
if not isinstance(status, dict) or status.get('status') not in ('running', 'stopped'):
|
||||
return jsonify({'error': 'Invalid control task status'}), 502
|
||||
payload = {'status': status['status'], 'exitstatus': status.get('exitstatus')}
|
||||
if status['status'] == 'stopped' and status.get('exitstatus') == 'OK':
|
||||
current = subprocess.run(['pvesh', 'get', f'/nodes/{node}/{vm_type}/{vmid}/status/current',
|
||||
'--output-format', 'json'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if current.returncode != 0:
|
||||
return jsonify({'error': 'Guest status unavailable'}), 502
|
||||
guest = json.loads(current.stdout)
|
||||
if not isinstance(guest, dict) or guest.get('status') not in ('running', 'stopped'):
|
||||
return jsonify({'error': 'Invalid guest status'}), 502
|
||||
payload['guest_status'] = guest['status']
|
||||
_pvesh_cache['cluster_resources_vm_time'] = 0
|
||||
_vm_cache_invalidate(vmid)
|
||||
return jsonify(payload)
|
||||
except (ValueError, subprocess.TimeoutExpired):
|
||||
return jsonify({'error': 'Control task status unavailable'}), 502
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/description', methods=['PUT'])
|
||||
@app.route('/api/vms/<int:vmid>/config', methods=['PUT']) # legacy alias
|
||||
@require_auth
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Explicit check-only jobs; the shared updater lock protects each CT.
|
||||
|
||||
Importing or reading a job never probes a guest. Only start() may launch work
|
||||
for an already-running CT; package indexes are never refreshed by starting one.
|
||||
"""
|
||||
import datetime
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import managed_installs
|
||||
import lxc_update_observation
|
||||
|
||||
LOCK_DIR = Path(os.environ.get('PROXMENUX_LOCK_DIR', '/run/lock'))
|
||||
CONF_DIR = Path('/etc/pve/lxc')
|
||||
OCI_REGISTRY = Path('/usr/local/share/proxmenux/oci/installed.json')
|
||||
_lock = threading.RLock()
|
||||
_jobs = {}
|
||||
_attempts = {}
|
||||
COOLDOWN = 60
|
||||
JOB_TTL = 3600
|
||||
CONTROL_TASK_WAIT_SECONDS = 180
|
||||
|
||||
|
||||
def acquire(vmid):
|
||||
path = LOCK_DIR / f'proxmenux-lxc-update-{int(vmid)}.lock'
|
||||
handle = os.fdopen(os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600), 'r+')
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
check_control_task(vmid, handle)
|
||||
return handle
|
||||
except BaseException:
|
||||
handle.close()
|
||||
raise
|
||||
|
||||
|
||||
def _invalid_marker(vmid):
|
||||
return LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}'
|
||||
|
||||
|
||||
def _acquire_invalid_marker_guard(vmid):
|
||||
path = LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}.guard'
|
||||
handle = os.fdopen(os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600), 'r+')
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
return handle
|
||||
except BaseException:
|
||||
handle.close()
|
||||
raise
|
||||
|
||||
|
||||
def _mark_check_invalid(vmid):
|
||||
handle = _acquire_invalid_marker_guard(vmid)
|
||||
try:
|
||||
_invalid_marker(vmid).touch(mode=0o600)
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
|
||||
def clear_invalid_marker(vmid, *, check_started_ns=None):
|
||||
"""Clear an obsolete failed-check marker without hiding a newer run."""
|
||||
try:
|
||||
handle = _acquire_invalid_marker_guard(vmid)
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
marker = _invalid_marker(vmid)
|
||||
try:
|
||||
if (check_started_ns is not None
|
||||
and marker.stat().st_mtime_ns > check_started_ns):
|
||||
return False
|
||||
marker.unlink()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
# A concurrent successful check may already have published the
|
||||
# valid state; there is no stale marker left to clear.
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
|
||||
def _write_control(handle, record):
|
||||
handle.seek(0)
|
||||
if record is not None:
|
||||
handle.write(json.dumps(record))
|
||||
handle.flush()
|
||||
# Do not erase the old reservation before the replacement was written.
|
||||
# A partial/failed write stays non-empty and therefore fails closed.
|
||||
handle.truncate(handle.tell())
|
||||
|
||||
|
||||
def begin_control(handle, node):
|
||||
"""Persist uncertainty before submission, including across Monitor restart."""
|
||||
if not isinstance(node, str) or not re.fullmatch(r'[\w.-]+', node):
|
||||
raise ValueError('Invalid control node')
|
||||
_write_control(handle, {'node': node, 'task': None})
|
||||
|
||||
|
||||
def cancel_control(handle):
|
||||
"""Clear a request for which no recoverable task identity was recorded."""
|
||||
_write_control(handle, None)
|
||||
|
||||
|
||||
def check_control_task(vmid, handle=None):
|
||||
"""Called under the shared flock, also by the shell update runner.
|
||||
|
||||
No record means no probe. A restart releases OS file descriptors, but the
|
||||
record still forbids a new operation until PVE confirms task termination.
|
||||
Unknown submission/status cannot safely be treated as completion.
|
||||
"""
|
||||
if handle is None:
|
||||
with open(LOCK_DIR / f'proxmenux-lxc-update-{int(vmid)}.lock', 'r+') as stream:
|
||||
return check_control_task(vmid, stream)
|
||||
handle.seek(0)
|
||||
raw = handle.read(1025)
|
||||
if not raw:
|
||||
return
|
||||
try:
|
||||
record = json.loads(raw)
|
||||
node, task = record['node'], record['task']
|
||||
if not isinstance(node, str) or not re.fullmatch(r'[\w.-]+', node):
|
||||
raise ValueError('Invalid node')
|
||||
if (not isinstance(task, str)
|
||||
or not re.fullmatch(r'UPID:[\w.-]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:'
|
||||
r'[0-9A-Fa-f]+:(?:vz(?:start|stop|shutdown|reboot)'
|
||||
r'|ha(?:start|stop)):'
|
||||
r'[0-9]+:[\w.@!-]+:', task)
|
||||
or task.split(':')[1] != node or task.split(':')[6] != str(vmid)):
|
||||
raise ValueError('Unconfirmed control task')
|
||||
result = subprocess.run(['pvesh', 'get', f'/nodes/{node}/tasks/{task}/status',
|
||||
'--output-format', 'json'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
status = json.loads(result.stdout) if result.returncode == 0 else None
|
||||
if (not isinstance(status, dict) or status.get('status') != 'stopped'
|
||||
or not isinstance(status.get('exitstatus'), str) or not status['exitstatus']):
|
||||
raise ValueError('Control task completion not confirmed')
|
||||
except (ValueError, KeyError, TypeError, OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise BlockingIOError('Container control completion is not confirmed') from exc
|
||||
_write_control(handle, None)
|
||||
|
||||
|
||||
def _wait_control(vmid, reservation):
|
||||
deadline = time.monotonic() + CONTROL_TASK_WAIT_SECONDS
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
check_control_task(vmid, reservation)
|
||||
return
|
||||
except BlockingIOError:
|
||||
# Keep the known task record for recovery after this bounded
|
||||
# waiter exits; a later caller asks Proxmox to verify it.
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining > 0:
|
||||
time.sleep(min(2, remaining))
|
||||
finally:
|
||||
reservation.close()
|
||||
|
||||
|
||||
def retain_control(vmid, task, reservation):
|
||||
reservation.seek(0)
|
||||
record = json.loads(reservation.read(1025))
|
||||
record['task'] = task
|
||||
_write_control(reservation, record)
|
||||
# Reservation ownership transfers only after Thread.start succeeds.
|
||||
threading.Thread(target=_wait_control, args=(vmid, reservation), daemon=True).start()
|
||||
|
||||
|
||||
def guard(vmid):
|
||||
if not managed_installs._lxc_updates_detection_enabled():
|
||||
raise RuntimeError('LXC update detection is disabled')
|
||||
# Fail closed if current configuration cannot be read. Never infer OCI
|
||||
# eligibility from an old OS hint or from a failed config read.
|
||||
config = (CONF_DIR / f'{int(vmid)}.conf').read_text()
|
||||
# The normal collector tolerates broken OCI metadata. An action must
|
||||
# instead fail closed: an unreadable registry is not an empty registry.
|
||||
if OCI_REGISTRY.exists():
|
||||
registry = json.loads(OCI_REGISTRY.read_text())
|
||||
instances = registry.get('instances')
|
||||
if not isinstance(instances, dict) or any(not isinstance(item, dict) for item in instances.values()):
|
||||
raise RuntimeError('OCI registry could not be verified')
|
||||
if any(str(item.get('vmid')) == str(vmid) for item in instances.values()):
|
||||
raise RuntimeError('OCI containers require their own updater')
|
||||
if any(line.lstrip().startswith(managed_installs._OCI_LXC_MARKERS)
|
||||
for line in config.splitlines()) or str(vmid) in managed_installs._get_oci_managed_vmids():
|
||||
raise RuntimeError('OCI containers require their own updater')
|
||||
# Retain the independent registered-OCI boundary if its collector is
|
||||
# temporarily unavailable. These hints can only forbid an action.
|
||||
if any(str(item.get('_vmid')) == str(vmid) and item.get('_managed_oci_app')
|
||||
for item in managed_installs.get_active_items()):
|
||||
raise RuntimeError('OCI containers require their own updater')
|
||||
if any(line.startswith('lock:') for line in config.splitlines()):
|
||||
raise RuntimeError('Container is locked by Proxmox')
|
||||
|
||||
|
||||
def command(vmid, *args, timeout=30):
|
||||
result = subprocess.run([managed_installs._PCT_BIN, *args[:1], str(vmid), *args[1:]],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f'Container {args[0]} failed')
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def status(vmid):
|
||||
value = command(vmid, 'status')
|
||||
if value not in ('status: running', 'status: stopped'):
|
||||
raise RuntimeError('Container state could not be determined')
|
||||
return value.split(': ', 1)[1]
|
||||
|
||||
|
||||
def checked_os(vmid):
|
||||
"""Installation precondition; called under the updater lock before start."""
|
||||
guard(vmid)
|
||||
if (LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}').exists():
|
||||
raise RuntimeError('Latest check failed or is still running')
|
||||
item = next((item for item in managed_installs.get_active_items()
|
||||
if item.get('id') == f'lxc:{int(vmid)}'), {})
|
||||
observation = item.get('update_check') or {}
|
||||
checked = datetime.datetime.fromisoformat(observation.get('last_check') or '')
|
||||
if checked.tzinfo is None:
|
||||
checked = checked.replace(tzinfo=datetime.timezone.utc)
|
||||
age = (datetime.datetime.now(datetime.timezone.utc) - checked).total_seconds()
|
||||
if (not 0 <= age < 172800 or observation.get('error')
|
||||
or observation.get('available') is not True
|
||||
or type(observation.get('_count')) is not int or observation['_count'] <= 0):
|
||||
raise RuntimeError('Check OS updates successfully before installation')
|
||||
|
||||
|
||||
def run(vmid, job_id, reservation):
|
||||
error = None
|
||||
check_started = False
|
||||
try:
|
||||
_mark_check_invalid(vmid)
|
||||
guard(vmid)
|
||||
if status(vmid) != 'running':
|
||||
raise RuntimeError('Container is stopped; start it first')
|
||||
check_started = True
|
||||
observation = lxc_update_observation.capture(vmid, job_id, refresh_indexes=True)
|
||||
if not managed_installs.store_lxc_update_observation(vmid, observation):
|
||||
raise RuntimeError(observation['result'].get('error') or 'Package check failed')
|
||||
lxc_update_observation.discard(vmid, job_id)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
# A failed package operation must block installation. A state change
|
||||
# before the worker runs did not check or alter packages, so keep the
|
||||
# last verified observation but leave the invalid marker in place.
|
||||
if check_started:
|
||||
try:
|
||||
managed_installs.store_lxc_update_observation(vmid, None)
|
||||
except Exception:
|
||||
error += '; package result could not be persisted'
|
||||
finally:
|
||||
if not error:
|
||||
if not clear_invalid_marker(vmid):
|
||||
error = 'Successful check could not be published for installation'
|
||||
reservation.close()
|
||||
with _lock:
|
||||
_jobs[job_id].update(state='failed' if error else 'complete', error=error)
|
||||
|
||||
|
||||
def start(vmid):
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
for key in list(_jobs):
|
||||
if _jobs[key]['state'] != 'running' and now - _jobs[key]['created'] >= JOB_TTL:
|
||||
del _jobs[key]
|
||||
last = _attempts.get(vmid)
|
||||
if (any(job['vmid'] == vmid and job['state'] == 'running' for job in _jobs.values())
|
||||
or last is not None and now - last < COOLDOWN):
|
||||
return None, 429
|
||||
reservation = acquire(vmid)
|
||||
try:
|
||||
guard(vmid)
|
||||
if status(vmid) != 'running':
|
||||
raise RuntimeError('Container is stopped; start it first')
|
||||
job_id = uuid.uuid4().hex
|
||||
_jobs[job_id] = dict(vmid=vmid, state='running', error=None, created=now)
|
||||
_attempts[vmid] = now
|
||||
threading.Thread(target=run, args=(vmid, job_id, reservation), daemon=False).start()
|
||||
except BaseException:
|
||||
if 'job_id' in locals():
|
||||
_jobs.pop(job_id, None)
|
||||
_attempts.pop(vmid, None)
|
||||
reservation.close()
|
||||
raise
|
||||
return dict(job_id=job_id, vmid=vmid, state='running'), 202
|
||||
|
||||
|
||||
def get(vmid, job_id):
|
||||
with _lock:
|
||||
job = _jobs.get(job_id)
|
||||
if job is None or job['vmid'] != vmid:
|
||||
return None
|
||||
return {key: value for key, value in job.items() if key != 'created'}
|
||||
@@ -1796,6 +1796,22 @@ def _store_update_result(item: dict, result: dict) -> None:
|
||||
item["update_check"][extra_key] = result[extra_key]
|
||||
|
||||
|
||||
def _clear_lxc_check_invalid_marker(vmid: int, check_started_ns: int) -> bool:
|
||||
"""Publish a persisted newer LXC check to the installation gate.
|
||||
|
||||
The marker is created by the explicit refresh worker. Its own lock also
|
||||
protects this cleanup against a later refresh starting while a scheduled
|
||||
or lifecycle check is still completing.
|
||||
"""
|
||||
try:
|
||||
import lxc_check_job
|
||||
return lxc_check_job.clear_invalid_marker(
|
||||
vmid, check_started_ns=check_started_ns)
|
||||
except Exception as exc:
|
||||
print(f'[ProxMenux] could not clear stale LXC check marker for {vmid}: {exc}')
|
||||
return False
|
||||
|
||||
|
||||
def refresh_oci_app(app_id: str) -> Optional[dict]:
|
||||
"""Read one OCI-managed application again and store what it reports.
|
||||
|
||||
@@ -1834,6 +1850,7 @@ def check_for_updates(force: bool = False) -> list[dict]:
|
||||
_nvidia_cache["fetched_at"] = 0
|
||||
|
||||
updates_available: list[dict] = []
|
||||
successful_lxc_checks: list[tuple[int, int]] = []
|
||||
with _lock:
|
||||
reg = _read_registry()
|
||||
items = reg.get("items", [])
|
||||
@@ -1854,6 +1871,7 @@ def check_for_updates(force: bool = False) -> list[dict]:
|
||||
checker = _CHECKERS.get(it.get("type"))
|
||||
if not checker:
|
||||
continue
|
||||
check_started_ns = time.time_ns() if it.get('type') == 'lxc' else None
|
||||
try:
|
||||
result = checker(it)
|
||||
except Exception as e:
|
||||
@@ -1863,13 +1881,20 @@ def check_for_updates(force: bool = False) -> list[dict]:
|
||||
"last_check": _now_iso(), "error": str(e)}
|
||||
|
||||
_store_update_result(it, result)
|
||||
if check_started_ns is not None and result.get('error') is None:
|
||||
try:
|
||||
successful_lxc_checks.append((int(it.get('_vmid')), check_started_ns))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if it["update_check"]["available"]:
|
||||
updates_available.append(it)
|
||||
|
||||
reg["items"] = items
|
||||
reg["last_check_run"] = _now_iso()
|
||||
_write_registry(reg)
|
||||
if _write_registry(reg):
|
||||
for vmid, check_started_ns in successful_lxc_checks:
|
||||
_clear_lxc_check_invalid_marker(vmid, check_started_ns)
|
||||
|
||||
return updates_available
|
||||
|
||||
@@ -1945,6 +1970,7 @@ def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[di
|
||||
else:
|
||||
_merge_detected_entry(target, entry, now)
|
||||
|
||||
check_started_ns = time.time_ns()
|
||||
try:
|
||||
result = (_check_lxc_updates(target) if refresh_package_cache else
|
||||
_check_lxc_updates(target, refresh_package_cache=False))
|
||||
@@ -1961,4 +1987,6 @@ def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[di
|
||||
reg["last_targeted_refresh"] = now
|
||||
if not _write_registry(reg):
|
||||
raise OSError("could not persist LXC update check")
|
||||
if result.get('error') is None:
|
||||
_clear_lxc_check_invalid_marker(target_vmid, check_started_ns)
|
||||
return dict(target)
|
||||
|
||||
@@ -36,6 +36,7 @@ class LxcVmIpInventoryTests(unittest.TestCase):
|
||||
"_get_lxc_app_watch_map": lambda: {},
|
||||
"_get_lxc_docker_inventory_map": lambda: {},
|
||||
"get_proxmox_node_name": lambda: "pve",
|
||||
"_fast_guest_status": lambda _vmid, _vm_type: "running",
|
||||
"_get_cached_vm_list_search_notes": lambda _resources, _node: {},
|
||||
"get_cached_vm_disk": lambda _vmid: None,
|
||||
"_guest_lifecycle_lock": threading.RLock(),
|
||||
|
||||
Reference in New Issue
Block a user