Merge branch 'develop' into feature/zram-memory-profile

This commit is contained in:
MacRimi
2026-10-16 17:27:04 +02:00
79 changed files with 5421 additions and 764 deletions
Binary file not shown.
+1 -1
View File
@@ -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>
+79 -17
View File
@@ -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>
</>
)}
+1 -1
View File
@@ -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>
+4
View File
@@ -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>
+194 -72
View File
@@ -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")}
+26
View File
@@ -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")
}
+7
View File
@@ -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"
}
+32
View File
@@ -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')
}
File diff suppressed because it is too large Load Diff
+37 -5
View File
@@ -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",
+40 -8
View File
@@ -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",
+41 -9
View File
@@ -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…",
+39 -7
View File
@@ -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…",
+41 -9
View File
@@ -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…",
+38 -6
View File
@@ -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…",
+41 -9
View File
@@ -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…",
+1
View File
@@ -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"
+164 -3
View File
@@ -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
+301
View File
@@ -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'}
+29 -1
View File
@@ -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(),