mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Merge branch 'develop' into feature/zram-memory-profile
This commit is contained in:
@@ -382,6 +382,19 @@ export function LxcTerminalModal({
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The host prompt came back: pct enter could not enter the
|
||||
// container. Show why and close, so the session is never left
|
||||
// on the host shell under the container's name.
|
||||
const reason = afterPctEnter.split(/\r?\n/).slice(0, -1)
|
||||
.map((line) => line.trim()).filter(Boolean).join("\r\n")
|
||||
if (reason) term.writeln(`\x1b[31m${reason}\x1b[0m`)
|
||||
term.writeln(`\r\n\x1b[33m${/Failed to exec/.test(reason)
|
||||
? t("lxcTerminal.errors.noShell")
|
||||
: t("lxcTerminal.errors.enterFailed")}\x1b[0m`)
|
||||
isInsideLxcRef.current = true
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -688,6 +701,10 @@ export function LxcTerminalModal({
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x04")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+D</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Trash2, ChevronRight, TestTube2, Mail, Webhook,
|
||||
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
|
||||
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb,
|
||||
Moon, Newspaper
|
||||
Moon, Newspaper, History, ChevronDown
|
||||
} from "lucide-react"
|
||||
|
||||
interface ChannelConfig {
|
||||
@@ -109,7 +109,8 @@ interface HistoryEntry {
|
||||
title: string
|
||||
severity: string
|
||||
sent_at: string
|
||||
success: boolean
|
||||
success: boolean | number
|
||||
message?: string | null
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
@@ -331,7 +332,8 @@ export function NotificationSettings() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
|
||||
const [status, setStatus] = useState<ServiceStatus | null>(null)
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [history, setHistory] = useState<HistoryEntry[] | null>(null)
|
||||
const [historyError, setHistoryError] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
@@ -452,12 +454,12 @@ export function NotificationSettings() {
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchApi<{ success: boolean; history: HistoryEntry[]; total: number }>("/api/notifications/history?limit=20")
|
||||
if (data.success) {
|
||||
setHistory(data.history || [])
|
||||
}
|
||||
const data = await fetchApi<{ history?: HistoryEntry[]; error?: string }>("/api/notifications/history?limit=20")
|
||||
if (data.error || !Array.isArray(data.history)) throw new Error("history unavailable")
|
||||
setHistory(data.history)
|
||||
setHistoryError(false)
|
||||
} catch {
|
||||
// Ignore
|
||||
setHistoryError(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -1097,15 +1099,6 @@ export function NotificationSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearHistory = async () => {
|
||||
try {
|
||||
await fetchApi("/api/notifications/history", { method: "DELETE" })
|
||||
setHistory([])
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Maps each eye-button local key to the body shape the backend
|
||||
// expects for /api/notifications/reveal-secret. Centralised here so
|
||||
// the JSX call site stays a one-liner — `toggleSecret(key)`.
|
||||
@@ -2761,6 +2754,75 @@ export function NotificationSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Notification history ── */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-start gap-3 py-1 text-left"
|
||||
onClick={() => setShowHistory(open => !open)}
|
||||
aria-expanded={showHistory}
|
||||
>
|
||||
<History className="h-5 w-5 text-blue-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{t("settings.notifications.history.title")}</span>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.description")}</p>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 mt-1 shrink-0 text-muted-foreground transition-transform ${showHistory ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{showHistory && (
|
||||
historyError ? (
|
||||
<p className="text-xs sm:text-sm text-red-400">{t("settings.notifications.history.loadFailed")}</p>
|
||||
) : history === null ? (
|
||||
<div className="flex justify-center py-3"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
|
||||
) : history.length === 0 ? (
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.empty")}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-80 overflow-y-auto pr-1">
|
||||
{history.map(entry => {
|
||||
const sent = Boolean(entry.success)
|
||||
const severity = (entry.severity || "").toLowerCase()
|
||||
const detail = !sent ? entry.error_message : entry.event_type === "digest" ? entry.message : null
|
||||
return (
|
||||
<div key={entry.id} className="flex items-start gap-2.5 p-2.5 rounded-md bg-muted/30 border border-border/50">
|
||||
{sent ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.sent")} />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.notSent")} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs sm:text-sm font-medium block truncate">{entry.title || entry.event_type}</span>
|
||||
<span className="text-[11px] sm:text-xs text-muted-foreground">
|
||||
{entry.channel ? entry.channel.charAt(0).toUpperCase() + entry.channel.slice(1) : ""} · {new Date(entry.sent_at).toLocaleString()}
|
||||
</span>
|
||||
{detail && (
|
||||
<p className={`text-[11px] sm:text-xs mt-0.5 break-words ${sent ? "text-muted-foreground" : "text-red-400"}`}>{detail}</p>
|
||||
)}
|
||||
</div>
|
||||
{(severity === "critical" || severity === "warning" || severity === "info") && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] shrink-0 ${
|
||||
severity === "critical"
|
||||
? "border-red-500/30 text-red-400"
|
||||
: severity === "warning"
|
||||
? "border-amber-500/30 text-amber-400"
|
||||
: "border-blue-500/30 text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{severity === "critical"
|
||||
? t("settings.notifications.history.severityCritical")
|
||||
: severity === "warning"
|
||||
? t("settings.notifications.history.severityWarning")
|
||||
: t("settings.notifications.history.severityInfo")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -307,7 +307,7 @@ const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <Package className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociManager",
|
||||
text: "OCI manager Apps (beta) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries over 360 applications.",
|
||||
text: "OCI manager Apps (Technology Preview) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries over 360 applications.",
|
||||
},
|
||||
{
|
||||
icon: <ScrollText className="h-5 w-5" />,
|
||||
|
||||
@@ -918,6 +918,10 @@ const initMessage = {
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x04")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+D</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||
|
||||
@@ -1038,6 +1038,10 @@ const handleClose = () => {
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x04")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+D</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type React from "react"
|
||||
|
||||
import { useState, useMemo, useEffect, useRef } from "react"
|
||||
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached, syncLxcAppsState } from "../lib/lxc-apps-cache"
|
||||
import { getUpdateActionButtonClass } from "../lib/update-action-style"
|
||||
import { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
@@ -27,8 +28,9 @@ import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { waitForVmControlTask } from "../lib/vm-control"
|
||||
import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search"
|
||||
import { checkLxcUpdates, isLxcUpdateStale } from "../lib/lxc-update-observation"
|
||||
import { refreshCheckLxcUpdates, getLxcOsUpdateState, isLxcUpdateStale } from "../lib/lxc-update-observation"
|
||||
import DOMPurify from "dompurify"
|
||||
import { marked } from "marked"
|
||||
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
|
||||
@@ -871,6 +873,8 @@ export function VirtualMachines() {
|
||||
const lifecycleCacheRevisionsRef = useRef(new Map<number, number>())
|
||||
const [dockerInventoryRefreshingVmid, setDockerInventoryRefreshingVmid] = useState<number | null>(null)
|
||||
const [controlLoading, setControlLoading] = useState(false)
|
||||
const controlRequestRef = useRef(false)
|
||||
const [controlAction, setControlAction] = useState<{ vmid: number; action: string } | null>(null)
|
||||
// Destructive control confirmation. `Force Stop` and `Reboot` skip the OS
|
||||
// shutdown sequence and can corrupt running guests; gate them behind a
|
||||
// typed-VMID match prompt to prevent misclicks. See audit Tier 2 #17.
|
||||
@@ -917,6 +921,7 @@ export function VirtualMachines() {
|
||||
// (or after a 15 s safety timeout).
|
||||
const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
|
||||
const [osCheckingVmid, setOsCheckingVmid] = useState<number | null>(null)
|
||||
const osCheckRequestRef = useRef<number | null>(null)
|
||||
const [osCheckError, setOsCheckError] = useState<string | null>(null)
|
||||
const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null)
|
||||
const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
|
||||
@@ -1231,6 +1236,12 @@ export function VirtualMachines() {
|
||||
useEffect(() => {
|
||||
if (!updatesRefreshing) return
|
||||
if (!selectedVM) return
|
||||
if (!selectedVM.update_check || selectedVM.update_check.error) {
|
||||
setUpdatesResult(null)
|
||||
setUpdatesRefreshing(false)
|
||||
setUpdatesBaselineCount(null)
|
||||
return
|
||||
}
|
||||
const currentCount = selectedVM.update_check?.count ?? 0
|
||||
if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) {
|
||||
const applied = Math.max(0, updatesBaselineCount - currentCount)
|
||||
@@ -1250,7 +1261,8 @@ export function VirtualMachines() {
|
||||
const safety = setTimeout(() => {
|
||||
const currentCount = selectedVMRef.current?.update_check?.count ?? 0
|
||||
const baseline = updatesBaselineCountRef.current
|
||||
setUpdatesResult({
|
||||
const observation = selectedVMRef.current?.update_check
|
||||
setUpdatesResult(!observation || observation.error ? null : {
|
||||
pendingAfter: currentCount,
|
||||
appliedCount: Math.max(0, (baseline ?? 0) - currentCount),
|
||||
})
|
||||
@@ -1788,12 +1800,16 @@ export function VirtualMachines() {
|
||||
}
|
||||
|
||||
const handleVMControl = async (vmid: number, action: string) => {
|
||||
if (controlRequestRef.current) return
|
||||
controlRequestRef.current = true
|
||||
setControlAction({ vmid, action })
|
||||
setControlLoading(true)
|
||||
try {
|
||||
await fetchApi(`/api/vms/${vmid}/control`, {
|
||||
const accepted = await fetchApi<{ task: string }>(`/api/vms/${vmid}/control`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
const completed = await waitForVmControlTask(vmid, action, accepted.task)
|
||||
|
||||
// Any control action can change what the guest reports: a stop
|
||||
// hides runtime-only fields, a start may bring a new config that
|
||||
@@ -1809,16 +1825,37 @@ export function VirtualMachines() {
|
||||
cache.firewall.delete(vmid)
|
||||
invalidateLxcApps(vmid)
|
||||
|
||||
mutate()
|
||||
setSelectedVM(null)
|
||||
setVMDetails(null)
|
||||
// Publish the confirmed result as an SWR mutation before revalidating.
|
||||
// SWR then discards list requests started before this completion.
|
||||
try {
|
||||
await mutate(current => current?.map(vm => vm.vmid === vmid
|
||||
? { ...vm, status: completed.guest_status as VMData["status"] } : vm), { revalidate: false })
|
||||
await mutate()
|
||||
} catch { /* Polling will retry; control already succeeded. */ }
|
||||
// Keep the user's tab/scroll and last complete detail while revalidating.
|
||||
// Never resurrect a modal the user closed, or overwrite another guest.
|
||||
if (selectedVMRef.current?.vmid === vmid) {
|
||||
setSelectedVM(current => current?.vmid === vmid
|
||||
? { ...current, status: completed.guest_status as VMData["status"] } : current)
|
||||
try {
|
||||
const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
|
||||
cache.details.set(vmid, details)
|
||||
if (selectedVMRef.current?.vmid === vmid) setVMDetails(details)
|
||||
} catch {
|
||||
// The control succeeded; detail polling will retry independently.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action} VM ${vmid}:`, error)
|
||||
// Same UX issue as handleCreateBackup: a silent console.error left the
|
||||
// user looking at a "Stop"/"Start" button that just never reacted.
|
||||
const msg = error instanceof Error ? error.message : t("vmLxc.errors.unknown")
|
||||
const msg = error instanceof Error
|
||||
? (error.message === "controlNotConfirmed" ? t("vmLxc.errors.controlNotConfirmed") : error.message)
|
||||
: t("vmLxc.errors.unknown")
|
||||
alert(t("vmLxc.errors.controlFailed", { action, vmid, message: msg }))
|
||||
} finally {
|
||||
controlRequestRef.current = false
|
||||
setControlAction(null)
|
||||
setControlLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -1983,6 +2020,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const [applyRestart, setApplyRestart] = useState(false)
|
||||
const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
|
||||
const [applyRunId, setApplyRunId] = useState<string>("")
|
||||
const [applyCheckedOs, setApplyCheckedOs] = useState(false)
|
||||
const [applyTargetIds, setApplyTargetIds] = useState<string[]>([])
|
||||
const [applyTargetLabels, setApplyTargetLabels] = useState<string[]>([])
|
||||
// Extra state carried alongside applyTarget when the App branch is
|
||||
@@ -2029,6 +2067,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// detected via the community-scripts pattern. All loaded once when
|
||||
// the user opens the Updates tab of a specific LXC.
|
||||
const [scheduleLoaded, setScheduleLoaded] = useState<number | null>(null)
|
||||
const scheduleLoadRequestRef = useRef(0)
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(false)
|
||||
const [scheduleCron, setScheduleCron] = useState("0 3 * * *")
|
||||
const [schedulePreset, setSchedulePreset] = useState<string>("daily-3am")
|
||||
@@ -2073,6 +2112,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// with scheduled updates: changing a one-click manual selection must never
|
||||
// rewrite the cron automation configured in Options.
|
||||
const [bulkLoaded, setBulkLoaded] = useState<number | null>(null)
|
||||
const bulkLoadRequestRef = useRef(0)
|
||||
const [bulkConfigured, setBulkConfigured] = useState(false)
|
||||
const [bulkTargets, setBulkTargets] = useState<string[]>(["os"])
|
||||
const [bulkPersistedTargets, setBulkPersistedTargets] = useState<string[]>(["os"])
|
||||
@@ -2107,9 +2147,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? s.targets.map((value: any) => String(value))
|
||||
: ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])]))
|
||||
setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0)
|
||||
if (s.backup !== undefined) setApplyBackup(!!s.backup)
|
||||
if (s.backup_storage) setApplyBackupStorage(s.backup_storage)
|
||||
if (s.restart !== undefined) setApplyRestart(!!s.restart)
|
||||
setApplyBackup(!!s.backup)
|
||||
setApplyBackupStorage(typeof s.backup_storage === "string" ? s.backup_storage : "")
|
||||
setApplyRestart(!!s.restart)
|
||||
setScheduleLastRunAt(s.last_run_at || null)
|
||||
setScheduleLastRunStatus(s.last_run_status || null)
|
||||
setScheduleLastRunReason(s.last_run_reason || null)
|
||||
@@ -2122,30 +2162,46 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
}
|
||||
|
||||
const loadSchedule = async (vmid: number) => {
|
||||
if (selectedVMRef.current?.vmid !== vmid) return
|
||||
const requestId = ++scheduleLoadRequestRef.current
|
||||
const isCurrent = () => selectedVMRef.current?.vmid === vmid
|
||||
&& scheduleLoadRequestRef.current === requestId
|
||||
setScheduleError(null)
|
||||
// Seed from ref cache so tab-switching to Updates isn't a "Loading…"
|
||||
// moment when the payload was already fetched this session.
|
||||
const cachedSched = vmModalCacheRef.current.schedule.get(vmid)
|
||||
if (cachedSched) applySchedulePayload(cachedSched)
|
||||
// Missing options are this guest's defaults, never the previous guest's.
|
||||
applySchedulePayload(cachedSched || {})
|
||||
try {
|
||||
const s: any = await fetchApi(`/api/vms/${vmid}/schedule`)
|
||||
if (s && typeof s === "object") {
|
||||
vmModalCacheRef.current.schedule.set(vmid, s)
|
||||
applySchedulePayload(s)
|
||||
}
|
||||
if (!isCurrent()) return
|
||||
if (!s || typeof s !== "object" || Array.isArray(s))
|
||||
throw new Error(t("vmLxc.scheduled.loadFailed"))
|
||||
vmModalCacheRef.current.schedule.set(vmid, s)
|
||||
applySchedulePayload(s)
|
||||
} catch (e: any) {
|
||||
setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed"))
|
||||
if (isCurrent()) setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed"))
|
||||
} finally {
|
||||
setScheduleLoaded(vmid)
|
||||
if (isCurrent()) setScheduleLoaded(vmid)
|
||||
}
|
||||
}
|
||||
|
||||
const loadBulkUpdate = async (vmid: number) => {
|
||||
if (selectedVMRef.current?.vmid !== vmid) return
|
||||
const requestId = ++bulkLoadRequestRef.current
|
||||
const isCurrent = () => selectedVMRef.current?.vmid === vmid
|
||||
&& bulkLoadRequestRef.current === requestId
|
||||
setBulkError(null)
|
||||
setBulkLoaded(null)
|
||||
setBulkEditMode(false)
|
||||
setBulkConfigured(false)
|
||||
setBulkTargets(["os"])
|
||||
setBulkPersistedTargets(["os"])
|
||||
try {
|
||||
const config: any = await fetchApi(`/api/vms/${vmid}/bulk-update`)
|
||||
if (!isCurrent()) return
|
||||
if (!config || typeof config !== "object" || Array.isArray(config))
|
||||
throw new Error(t("vmLxc.bulkUpdate.loadFailed"))
|
||||
const targets = Array.isArray(config?.targets)
|
||||
? Array.from(new Set(config.targets.map((value: any) => String(value)))) as string[]
|
||||
: []
|
||||
@@ -2155,12 +2211,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setBulkTargets(next)
|
||||
setBulkPersistedTargets(next)
|
||||
} catch (e: any) {
|
||||
if (!isCurrent()) return
|
||||
setBulkConfigured(false)
|
||||
setBulkTargets(["os"])
|
||||
setBulkPersistedTargets(["os"])
|
||||
setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed"))
|
||||
} finally {
|
||||
setBulkLoaded(vmid)
|
||||
if (isCurrent()) setBulkLoaded(vmid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2560,8 +2617,21 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const openApplyTerminal = (
|
||||
vmid: number,
|
||||
target: "os" | "app" | "both",
|
||||
opts?: { updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] },
|
||||
opts?: { checkedOs?: boolean; updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] },
|
||||
) => {
|
||||
const guest = selectedVMRef.current
|
||||
if (target === "os" || target === "both") {
|
||||
if (osCheckingVmid !== null || osCheckRequestRef.current !== null || updatesRefreshing || (opts?.checkedOs && osCheckError)) return
|
||||
const state = guest?.vmid === vmid ? getLxcOsUpdateState(guest.update_check) : "unknown"
|
||||
if (state === "unknown") {
|
||||
setOsCheckError(t("vmLxc.updates.checkBeforeUpdate"))
|
||||
return
|
||||
}
|
||||
// A combined plan can still update apps when OS packages are current.
|
||||
if (target === "os" && state === "current") return
|
||||
}
|
||||
// The explicit update button is the user's confirmation. The runner
|
||||
// owns the temporary start, backup and restoration of a stopped guest.
|
||||
const fallbackTargets = target === "os"
|
||||
? ["os"]
|
||||
: target === "both"
|
||||
@@ -2584,6 +2654,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setApplyVmid(vmid)
|
||||
setApplyTarget(target)
|
||||
setApplyRunId(runId)
|
||||
setApplyCheckedOs(opts?.checkedOs === true)
|
||||
setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets)
|
||||
setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels)
|
||||
setApplyUpdateCommand(opts?.updateCommand || "")
|
||||
@@ -2680,10 +2751,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// false "N package pending" every time a registered app has an
|
||||
// upstream bump.
|
||||
const checkOsUpdates = async (vmid: number) => {
|
||||
if (osCheckRequestRef.current !== null || applyOpen || controlLoading) return
|
||||
osCheckRequestRef.current = vmid
|
||||
setOsCheckingVmid(vmid)
|
||||
setOsCheckError(null)
|
||||
try {
|
||||
await checkLxcUpdates(vmid)
|
||||
const observation = await refreshCheckLxcUpdates<LxcUpdateCheck>(vmid)
|
||||
await mutate((current) => Array.isArray(current)
|
||||
? current.map(vm => vm.vmid === vmid ? { ...vm, update_check: observation } : vm)
|
||||
: current, { revalidate: false })
|
||||
setSelectedVM(current => current?.vmid === vmid ? { ...current, update_check: observation } : current)
|
||||
const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
|
||||
vmModalCacheRef.current.details.set(vmid, details)
|
||||
if (selectedVMRef.current?.vmid === vmid) setVMDetails(details)
|
||||
@@ -2692,9 +2769,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
if (selectedVMRef.current?.vmid === vmid) {
|
||||
setOsCheckError(error.status === 429
|
||||
? t("vmLxc.updates.checkCooldown", { seconds: error.body?.retry_after ?? 60 })
|
||||
: error.message || t("vmLxc.updates.osStatusUnavailable"))
|
||||
: error.message === "checkStillRunning"
|
||||
? t("vmLxc.updates.checkStillRunning")
|
||||
: t("vmLxc.updates.osStatusUnavailable"))
|
||||
}
|
||||
} finally {
|
||||
osCheckRequestRef.current = null
|
||||
setOsCheckingVmid(null)
|
||||
}
|
||||
}
|
||||
@@ -5707,7 +5787,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
!selectedVM.update_check?.is_oci_lxc && (() => {
|
||||
const uc = selectedVM.update_check
|
||||
const osUpdateStale = isLxcUpdateStale(uc)
|
||||
const osUpdateStatusKnown = !!uc && !uc.error && !osUpdateStale
|
||||
const osUpdateStatusKnown = getLxcOsUpdateState(uc) !== "unknown"
|
||||
const hasOsUpdates = osUpdateStatusKnown && !!uc.available
|
||||
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
|
||||
const dockerRegistered = !!dockerAppWatch
|
||||
@@ -5863,6 +5943,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
return {
|
||||
id: `app:${app.id}`,
|
||||
label: app.name || t("vmLxc.updates.applicationDefaultName"),
|
||||
methodLabel: app.update_command?.trim()
|
||||
? t("vmLxc.updates.customCommandTitle")
|
||||
: t("vmLxc.updates.helperMethod"),
|
||||
detail: "",
|
||||
logoUrl: webLinkLogo || app.logo_url?.trim() || "",
|
||||
}
|
||||
@@ -5877,6 +5960,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? [{
|
||||
id: "docker-engine",
|
||||
label: "Docker Engine",
|
||||
methodLabel: t("vmLxc.scheduled.dockerEngineTarget"),
|
||||
detail: "",
|
||||
logoUrl: "",
|
||||
}]
|
||||
@@ -5884,6 +5968,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
...dockerUpdateUnits.map((unit) => ({
|
||||
id: unit.id,
|
||||
label: unit.display_name || unit.primary_reference || unit.primary_service || unit.id,
|
||||
methodLabel: unit.kind === "compose"
|
||||
? "Docker Compose"
|
||||
: t("vmLxc.bulkUpdate.dockerImageMethod"),
|
||||
detail: (unit.dependent_services || []).length
|
||||
? t("vmLxc.bulkUpdate.includesDependencies", {
|
||||
names: (unit.dependent_services || []).join(", "),
|
||||
@@ -5891,7 +5978,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
: "",
|
||||
logoUrl: unit.logo_url || "",
|
||||
})),
|
||||
]
|
||||
].map((choice, _index, choices) => ({
|
||||
...choice,
|
||||
// Keep distinct action IDs; names alone never prove
|
||||
// that a custom updater and an image are one action.
|
||||
label: choices.filter((other) =>
|
||||
other.label.trim().toLowerCase() === choice.label.trim().toLowerCase()
|
||||
).length > 1 ? `${choice.label} — ${choice.methodLabel}` : choice.label,
|
||||
}))
|
||||
const bulkChoices = [{
|
||||
id: "os",
|
||||
label: t("vmLxc.bulkUpdate.osTarget"),
|
||||
@@ -5935,7 +6029,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// has been checked and is current, otherwise unknown.
|
||||
const selectedBulkUpdateStates = bulkTargets.map((target) => {
|
||||
if (target === "os") {
|
||||
if (!uc || uc.error) return null
|
||||
if (!osUpdateStatusKnown) return null
|
||||
return hasOsUpdates
|
||||
}
|
||||
if (target === "docker-engine") {
|
||||
@@ -5960,6 +6054,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
return null
|
||||
})
|
||||
const bulkHasPendingUpdates = selectedBulkUpdateStates.some((state) => state === true)
|
||||
const bulkHasUnknownTargets = selectedBulkUpdateStates.some((state) => state === null)
|
||||
const bulkAllTargetsUpToDate = selectedBulkUpdateStates.length > 0
|
||||
&& selectedBulkUpdateStates.every((state) => state === false)
|
||||
const toggleBulkChoice = (id: string, checked: boolean) => {
|
||||
@@ -5968,9 +6063,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? Array.from(new Set(["os", ...current.filter((value) => value !== "os"), id]))
|
||||
: current.filter((value) => value !== id))
|
||||
}
|
||||
const pendingBtnCls = "bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||
const upToDateBtnCls = "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300"
|
||||
const neutralBtnCls = "border border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground"
|
||||
const pendingBtnCls = getUpdateActionButtonClass(true, false)
|
||||
const upToDateBtnCls = getUpdateActionButtonClass(false, true)
|
||||
return (
|
||||
<>
|
||||
<Card className="border border-border bg-card/50">
|
||||
@@ -5982,14 +6076,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3>
|
||||
<Button
|
||||
size="sm" variant="outline" className="ml-auto"
|
||||
disabled={selectedVM.status !== "running" || osCheckingVmid !== null}
|
||||
title={t("vmLxc.updates.checkReadOnlyHelp")}
|
||||
disabled={selectedVM.status !== "running" || osCheckingVmid !== null || updatesRefreshing || controlLoading || applyOpen}
|
||||
title={t("vmLxc.updates.refreshCheckHelp")}
|
||||
onClick={() => checkOsUpdates(selectedVM.vmid)}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-1.5 ${osCheckingVmid === selectedVM.vmid ? "animate-spin" : ""}`} />
|
||||
{t("vmLxc.updates.checkNow")}
|
||||
{t("vmLxc.updates.checkSystemPackages")}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedVM.status !== "running" && (
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t("vmLxc.updates.startBeforeCheck")}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
||||
{t("vmLxc.updates.lastCheckedPrefix")}{" "}
|
||||
{uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"}
|
||||
@@ -5998,7 +6097,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
)}
|
||||
</div>
|
||||
{osCheckError && <p role="alert" className="text-sm text-amber-400 mb-2">{osCheckError}</p>}
|
||||
{osUpdateStale && !uc?.error ? (
|
||||
{osCheckingVmid === selectedVM.vmid ? (
|
||||
<div role="status" className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin flex-shrink-0" />
|
||||
{t("vmLxc.updates.checkInProgress")}
|
||||
</div>
|
||||
) : !uc?.last_check && !uc?.error ? (
|
||||
<p className="text-sm text-muted-foreground">{t("vmLxc.updates.notCheckedYet")}</p>
|
||||
) : osUpdateStale && !uc?.error ? (
|
||||
<p className="text-sm text-amber-400">{t("vmLxc.updates.checkStaleHelp")}</p>
|
||||
) : !osUpdateStatusKnown ? (
|
||||
<div
|
||||
@@ -6054,23 +6160,28 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{t("vmLxc.updates.noOsUpdatesPending")}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex justify-end">
|
||||
{osUpdateStatusKnown && osCheckingVmid !== selectedVM.vmid && <div className="mt-3 flex justify-end">
|
||||
{hasOsUpdates ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => openApplyTerminal(selectedVM.vmid, "os")}
|
||||
className={hasOsUpdates
|
||||
? pendingBtnCls
|
||||
: osUpdateStatusKnown
|
||||
? upToDateBtnCls
|
||||
: neutralBtnCls}
|
||||
disabled={!hasOsUpdates || osCheckingVmid !== null || updatesRefreshing || controlLoading || !!osCheckError}
|
||||
onClick={() => openApplyTerminal(selectedVM.vmid, "os", { checkedOs: true })}
|
||||
className={getUpdateActionButtonClass(hasOsUpdates, osUpdateStatusKnown)}
|
||||
>
|
||||
{hasOsUpdates && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
|
||||
{!hasOsUpdates && !osUpdateStatusKnown && <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
{hasOsUpdates || !osUpdateStatusKnown
|
||||
{hasOsUpdates
|
||||
? t("vmLxc.updates.applyOsUpdate")
|
||||
: t("vmLxc.updates.osUpToDate")}
|
||||
: osUpdateStatusKnown
|
||||
? t("vmLxc.updates.osUpToDate")
|
||||
: t("vmLxc.updates.checkBeforeUpdate")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button asChild size="sm" className={upToDateBtnCls}>
|
||||
<span role="status">{t("vmLxc.updates.osUpToDate")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Docker has three independent lifecycles:
|
||||
@@ -6200,11 +6311,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
dockerRefresh: true,
|
||||
},
|
||||
)}
|
||||
className={`${dockerEngineHasUpdate
|
||||
? pendingBtnCls
|
||||
: dockerEngineUpToDate
|
||||
? upToDateBtnCls
|
||||
: neutralBtnCls} flex-shrink-0`}
|
||||
className={`${getUpdateActionButtonClass(dockerEngineHasUpdate, dockerEngineUpToDate)} flex-shrink-0`}
|
||||
>
|
||||
{dockerEngineHasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
|
||||
{!dockerEngineHasUpdate && !dockerEngineUpToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
@@ -6480,7 +6587,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
) : helperTracksVersion ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("vmLxc.updates.versionTrackingPending")}
|
||||
{t("vmLxc.updates.versionStatusUnknown")}
|
||||
</p>
|
||||
) : helperExists ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
@@ -6503,7 +6610,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// still force the helper's own
|
||||
// update script.
|
||||
const noState = !hasUpd && !upToD
|
||||
const cls = hasUpd ? pendingBtnCls : upToD ? upToDateBtnCls : neutralBtnCls
|
||||
const cls = getUpdateActionButtonClass(hasUpd, upToD)
|
||||
const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater")
|
||||
return (
|
||||
<div className="mt-3 flex justify-end">
|
||||
@@ -6639,7 +6746,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<div>{t("vmLxc.updates.versionTrackingPendingShort")}</div>
|
||||
<div>{t("vmLxc.updates.versionStatusUnknown")}</div>
|
||||
)}
|
||||
</div>
|
||||
) : tracksVersion ? (
|
||||
@@ -6651,7 +6758,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<a className="text-blue-400 hover:underline" href={`#docker-images-${selectedVM.vmid}`}>
|
||||
{t("vmLxc.updates.dockerImagesSubheading")}
|
||||
</a></>
|
||||
: t("vmLxc.updates.versionTrackingPendingShort")}
|
||||
: aw.installed_via === "manual"
|
||||
? t("vmLxc.updates.installedVersionUnverified")
|
||||
: t("vmLxc.updates.versionStatusUnknown")}
|
||||
</div>
|
||||
) : hasCmd ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@@ -6673,7 +6782,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
targetLabels: [aw.name || t("vmLxc.updates.applicationDefaultName")],
|
||||
},
|
||||
)}
|
||||
className={hasUpdate ? pendingBtnCls : upToDate ? upToDateBtnCls : neutralBtnCls}
|
||||
className={getUpdateActionButtonClass(hasUpdate, upToDate)}
|
||||
>
|
||||
{hasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
|
||||
{!hasUpdate && !upToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
@@ -6681,7 +6790,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? t("vmLxc.updates.applyUpdate")
|
||||
: upToDate
|
||||
? t("vmLxc.updates.upToDate")
|
||||
: t("vmLxc.updates.runUpdater")}
|
||||
: aw.update_method === "helper"
|
||||
? t("vmLxc.updates.runUpdater")
|
||||
: t("vmLxc.updates.runCustomCommand")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -6719,6 +6830,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-relaxed">
|
||||
{t("vmLxc.bulkUpdate.description")}
|
||||
</p>
|
||||
{dockerRegistered && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{t("vmLxc.bulkUpdate.osDockerScope")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!bulkEditMode && (
|
||||
<button
|
||||
@@ -6868,19 +6984,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="text-sm text-muted-foreground break-words">
|
||||
{selectedBulkLabels.join(" · ")}
|
||||
</div>
|
||||
{bulkHasUnknownTargets && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("vmLxc.bulkUpdate.unverifiedTargetsHelp")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => applyBulkUpdate(selectedVM.vmid)}
|
||||
disabled={bulkApplying || (
|
||||
disabled={bulkApplying || osCheckingVmid !== null || updatesRefreshing || !osUpdateStatusKnown || bulkAllTargetsUpToDate || (
|
||||
bulkTargets.some((target) => target.startsWith("docker-unit:"))
|
||||
&& (dockerInventoryRefreshing || !dockerInventoryAvailable)
|
||||
)}
|
||||
className={bulkHasPendingUpdates
|
||||
? pendingBtnCls
|
||||
: bulkAllTargetsUpToDate
|
||||
? upToDateBtnCls
|
||||
: neutralBtnCls}
|
||||
className={getUpdateActionButtonClass(bulkHasPendingUpdates, bulkAllTargetsUpToDate)}
|
||||
>
|
||||
{bulkApplying
|
||||
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
@@ -7534,8 +7651,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
tab we added. */}
|
||||
{(() => {
|
||||
const hasTerminal = selectedVM?.type === "lxc" && selectedVM?.status === "running"
|
||||
const pendingAction = controlAction?.vmid === selectedVM?.vmid ? controlAction?.action : null
|
||||
const controlLabel = (action: string, key: string) => pendingAction === action
|
||||
? t(`vmLxc.actions.${action}Pending`) : t(key)
|
||||
return (
|
||||
<div className={`grid gap-2 sm:gap-3 ${hasTerminal ? "grid-cols-4 lg:grid-cols-5" : "grid-cols-4"}`}>
|
||||
<div aria-busy={controlLoading} className={`grid gap-2 sm:gap-3 ${hasTerminal ? "grid-cols-4 lg:grid-cols-5" : "grid-cols-4"}`}>
|
||||
{hasTerminal && (
|
||||
<Button
|
||||
className="w-full bg-zinc-600/20 border border-zinc-600/50 text-zinc-300 hover:bg-zinc-600/30 col-span-4 lg:col-span-1"
|
||||
@@ -7549,19 +7669,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
className="w-full bg-green-600/20 border border-green-600/50 text-green-400 hover:bg-green-600/30"
|
||||
disabled={selectedVM?.status === "running" || controlLoading}
|
||||
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "start")}
|
||||
title={t("vmLxc.actions.start")}
|
||||
title={controlLabel("start", "vmLxc.actions.start")}
|
||||
>
|
||||
<Play className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{t("vmLxc.actions.start")}</span>
|
||||
{pendingAction === "start" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <Play className="h-4 w-4 sm:mr-2" />}
|
||||
<span className="hidden sm:inline">{controlLabel("start", "vmLxc.actions.start")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-blue-600/20 border border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
|
||||
disabled={selectedVM?.status !== "running" || controlLoading}
|
||||
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "shutdown")}
|
||||
title={t("vmLxc.actions.shutdown")}
|
||||
title={controlLabel("shutdown", "vmLxc.actions.shutdown")}
|
||||
>
|
||||
<Power className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{t("vmLxc.actions.shutdown")}</span>
|
||||
{pendingAction === "shutdown" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <Power className="h-4 w-4 sm:mr-2" />}
|
||||
<span className="hidden sm:inline">{controlLabel("shutdown", "vmLxc.actions.shutdown")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-blue-600/20 border border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
|
||||
@@ -7571,10 +7691,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
vmid: selectedVM.vmid,
|
||||
vmName: selectedVM.name,
|
||||
})}
|
||||
title={t("vmLxc.actions.reboot")}
|
||||
title={controlLabel("reboot", "vmLxc.actions.reboot")}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{t("vmLxc.actions.reboot")}</span>
|
||||
{pendingAction === "reboot" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <RotateCcw className="h-4 w-4 sm:mr-2" />}
|
||||
<span className="hidden sm:inline">{controlLabel("reboot", "vmLxc.actions.reboot")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-red-600/20 border border-red-600/50 text-red-400 hover:bg-red-600/30"
|
||||
@@ -7584,10 +7704,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
vmid: selectedVM.vmid,
|
||||
vmName: selectedVM.name,
|
||||
})}
|
||||
title={t("vmLxc.actions.forceStop")}
|
||||
title={controlLabel("stop", "vmLxc.actions.forceStop")}
|
||||
>
|
||||
<StopCircle className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{t("vmLxc.actions.forceStop")}</span>
|
||||
{pendingAction === "stop" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <StopCircle className="h-4 w-4 sm:mr-2" />}
|
||||
<span className="hidden sm:inline">{controlLabel("stop", "vmLxc.actions.forceStop")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -7916,7 +8036,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setApplyVmid(null)
|
||||
}}
|
||||
onComplete={handleApplyComplete}
|
||||
scriptPath="/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"
|
||||
scriptPath={applyCheckedOs
|
||||
? "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh"
|
||||
: "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"}
|
||||
scriptName="lxc_apply_updates"
|
||||
title={t("vmLxc.updates.terminalTitle", { vmid: applyVmid })}
|
||||
completedSuccessfullyMessage={t("vmLxc.updates.terminalCompletedSuccessfully")}
|
||||
|
||||
Reference in New Issue
Block a user