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
@@ -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")}