mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-06 15:56:23 +00:00
Move Monitor dashboard UI copy into translation keys and expand the English source catalog across the main pages, modals, and shared AppImage components.
3500 lines
176 KiB
TypeScript
3500 lines
176 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
|
|
import { useState, useMemo, useEffect } from "react"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
|
import { Badge } from "./ui/badge"
|
|
import { Progress } from "./ui/progress"
|
|
import { Button } from "./ui/button"
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
|
|
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort } from 'lucide-react'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
|
import { Checkbox } from "./ui/checkbox"
|
|
import { Textarea } from "./ui/textarea"
|
|
import { Label } from "./ui/label"
|
|
import useSWR from "swr"
|
|
import { MetricsView } from "./metrics-dialog"
|
|
import { LxcTerminalModal } from "./lxc-terminal-modal"
|
|
import { formatStorage } from "../lib/utils"
|
|
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
|
import { fetchApi } from "../lib/api-config"
|
|
import DOMPurify from "dompurify"
|
|
import { marked } from "marked"
|
|
import { useT } from "@/lib/i18n/provider"
|
|
|
|
// Sent by /api/vms only for LXC rows, only when the user has enabled
|
|
// `lxc_updates_available` notifications. The Monitor populates this
|
|
// from managed_installs registry → frontend uses it to render the
|
|
// inline update badge + the modal's "Pending updates" section.
|
|
interface LxcPackageUpdate {
|
|
name: string
|
|
current: string
|
|
latest: string
|
|
security: boolean
|
|
}
|
|
interface LxcUpdateCheck {
|
|
available: boolean
|
|
count: number
|
|
security_count: number
|
|
last_check: string | null
|
|
latest: string | null
|
|
error: string | null
|
|
packages: LxcPackageUpdate[]
|
|
}
|
|
|
|
interface VMData {
|
|
vmid: number
|
|
name: string
|
|
status: string
|
|
type: string
|
|
cpu: number
|
|
maxcpu?: number
|
|
mem: number
|
|
maxmem: number
|
|
disk: number
|
|
maxdisk: number
|
|
uptime: number
|
|
netin?: number
|
|
netout?: number
|
|
diskread?: number
|
|
diskwrite?: number
|
|
ip?: string
|
|
update_check?: LxcUpdateCheck
|
|
}
|
|
|
|
interface VMConfig {
|
|
cores?: number
|
|
memory?: number
|
|
swap?: number
|
|
rootfs?: string
|
|
net0?: string
|
|
net1?: string
|
|
net2?: string
|
|
nameserver?: string
|
|
searchdomain?: string
|
|
onboot?: number
|
|
unprivileged?: number
|
|
features?: string
|
|
ostype?: string
|
|
arch?: string
|
|
hostname?: string
|
|
// VM specific
|
|
sockets?: number
|
|
scsi0?: string
|
|
ide0?: string
|
|
boot?: string
|
|
description?: string // Added for notes
|
|
// Hardware specific
|
|
numa?: boolean
|
|
bios?: string
|
|
machine?: string
|
|
vga?: string
|
|
agent?: boolean
|
|
tablet?: boolean
|
|
localtime?: boolean
|
|
// Storage specific
|
|
scsihw?: string
|
|
efidisk0?: string
|
|
tpmstate0?: string
|
|
// Mount points for LXC
|
|
mp0?: string
|
|
mp1?: string
|
|
mp2?: string
|
|
mp3?: string
|
|
mp4?: string
|
|
mp5?: string
|
|
// PCI Passthrough
|
|
hostpci0?: string
|
|
hostpci1?: string
|
|
hostpci2?: string
|
|
hostpci3?: string
|
|
hostpci4?: string
|
|
hostpci5?: string
|
|
// USB Devices
|
|
usb0?: string
|
|
usb1?: string
|
|
usb2?: string
|
|
// Serial Devices
|
|
serial0?: string
|
|
serial1?: string
|
|
// Advanced
|
|
vmgenid?: string
|
|
smbios1?: string
|
|
meta?: string
|
|
// CPU
|
|
cpu?: string
|
|
[key: string]: any
|
|
}
|
|
|
|
interface VMDetails extends VMData {
|
|
config?: VMConfig
|
|
node?: string
|
|
vm_type?: string
|
|
os_info?: {
|
|
id?: string
|
|
version_id?: string
|
|
name?: string
|
|
pretty_name?: string
|
|
}
|
|
hardware_info?: {
|
|
privileged?: boolean | null
|
|
gpu_passthrough?: string[]
|
|
devices?: string[]
|
|
}
|
|
lxc_ip_info?: {
|
|
all_ips: string[]
|
|
real_ips: string[]
|
|
docker_ips: string[]
|
|
primary_ip: string
|
|
}
|
|
}
|
|
|
|
interface BackupStorage {
|
|
storage: string
|
|
type: string
|
|
content: string
|
|
total: number
|
|
used: number
|
|
avail: number
|
|
total_human?: string
|
|
used_human?: string
|
|
avail_human?: string
|
|
}
|
|
|
|
interface VMBackup {
|
|
volid: string
|
|
storage: string
|
|
type: string
|
|
size: number
|
|
size_human: string
|
|
timestamp: number
|
|
date: string
|
|
notes?: string
|
|
}
|
|
|
|
// Sprint 13.29: shape returned by /api/lxc/<vmid>/mount-points. Lives
|
|
// next to VMBackup since both are LXC-modal data structures.
|
|
interface LxcMountPoint {
|
|
mp_index: string // "mp0", "mp1", "" for ad-hoc
|
|
source: string
|
|
target: string
|
|
type: "pve_volume" | "pve_storage_bind" | "host_bind" | "ad_hoc"
|
|
origin_storage: string
|
|
origin_storage_type: string
|
|
origin_label: string
|
|
config_options: Record<string, string>
|
|
config_flags: string[]
|
|
total_bytes: number | null
|
|
used_bytes: number | null
|
|
available_bytes: number | null
|
|
runtime_mounted?: boolean | null
|
|
runtime_source?: string
|
|
runtime_fstype?: string
|
|
runtime_options?: string
|
|
runtime_readonly?: boolean
|
|
runtime_reachable?: boolean
|
|
runtime_error?: string | null
|
|
// Sprint 14.x: host-side bind source state. Detects the case where the
|
|
// CT still reports a bind as mounted even though the host already
|
|
// umounted the source (Ignacio Seijo 11/05). Null = N/A (PVE volume,
|
|
// not a host path).
|
|
host_source_exists?: boolean | null
|
|
host_source_is_mountpoint?: boolean | null
|
|
}
|
|
|
|
const fetcher = async (url: string) => {
|
|
return fetchApi(url)
|
|
}
|
|
|
|
const formatBytes = (bytes: number | undefined, isNetwork: boolean = false): string => {
|
|
if (!bytes || bytes === 0) return isNetwork ? "0 B/s" : "0 B"
|
|
|
|
if (isNetwork) {
|
|
const networkUnit = getNetworkUnit()
|
|
return formatNetworkTraffic(bytes, networkUnit, 2)
|
|
}
|
|
|
|
// For non-network (disk), use standard bytes
|
|
const k = 1024
|
|
const sizes = ["B", "KB", "MB", "GB", "TB"]
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
|
|
}
|
|
|
|
const formatUptime = (seconds: number, t: (key: string, params?: Record<string, string | number>) => string) => {
|
|
const days = Math.floor(seconds / 86400)
|
|
const hours = Math.floor((seconds % 86400) / 3600)
|
|
const minutes = Math.floor((seconds % 3600) / 60)
|
|
return t("vmLxc.duration.dhm", { days, hours, minutes })
|
|
}
|
|
|
|
const extractIPFromConfig = (config?: VMConfig, lxcIPInfo?: VMDetails["lxc_ip_info"]): string => {
|
|
// Use primary IP from lxc-info if available
|
|
if (lxcIPInfo?.primary_ip) {
|
|
return lxcIPInfo.primary_ip
|
|
}
|
|
|
|
if (!config) return "DHCP"
|
|
|
|
// Check net0, net1, net2, etc.
|
|
for (let i = 0; i < 10; i++) {
|
|
const netKey = `net${i}`
|
|
const netConfig = config[netKey]
|
|
|
|
if (netConfig && typeof netConfig === "string") {
|
|
// Look for ip=x.x.x.x/xx or ip=x.x.x.x pattern
|
|
const ipMatch = netConfig.match(/ip=([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})/)
|
|
if (ipMatch) {
|
|
return ipMatch[1] // Return just the IP without CIDR
|
|
}
|
|
|
|
// Check if it's explicitly DHCP
|
|
if (netConfig.includes("ip=dhcp")) {
|
|
return "DHCP"
|
|
}
|
|
}
|
|
}
|
|
|
|
return "DHCP"
|
|
}
|
|
|
|
// const formatStorage = (sizeInGB: number): string => {
|
|
// if (sizeInGB < 1) {
|
|
// // Less than 1 GB, show in MB
|
|
// return `${(sizeInGB * 1024).toFixed(1)} MB`
|
|
// } else if (sizeInGB < 1024) {
|
|
// // Less than 1024 GB, show in GB
|
|
// return `${sizeInGB.toFixed(1)} GB`
|
|
// } else {
|
|
// // 1024 GB or more, show in TB
|
|
// return `${(sizeInGB / 1024).toFixed(1)} TB`
|
|
// }
|
|
// }
|
|
|
|
const getUsageColor = (percent: number): string => {
|
|
if (percent >= 95) return "text-red-500"
|
|
if (percent >= 86) return "text-orange-500"
|
|
if (percent >= 71) return "text-yellow-500"
|
|
return "text-foreground"
|
|
}
|
|
|
|
// Generate consistent color for storage names
|
|
const storageColors = [
|
|
{ bg: "bg-blue-500/20", text: "text-blue-400", border: "border-blue-500/30" },
|
|
{ bg: "bg-emerald-500/20", text: "text-emerald-400", border: "border-emerald-500/30" },
|
|
{ bg: "bg-purple-500/20", text: "text-purple-400", border: "border-purple-500/30" },
|
|
{ bg: "bg-amber-500/20", text: "text-amber-400", border: "border-amber-500/30" },
|
|
{ bg: "bg-pink-500/20", text: "text-pink-400", border: "border-pink-500/30" },
|
|
{ bg: "bg-cyan-500/20", text: "text-cyan-400", border: "border-cyan-500/30" },
|
|
{ bg: "bg-rose-500/20", text: "text-rose-400", border: "border-rose-500/30" },
|
|
{ bg: "bg-indigo-500/20", text: "text-indigo-400", border: "border-indigo-500/30" },
|
|
]
|
|
|
|
const getStorageColor = (storageName: string) => {
|
|
// Generate a consistent hash from storage name
|
|
let hash = 0
|
|
for (let i = 0; i < storageName.length; i++) {
|
|
hash = storageName.charCodeAt(i) + ((hash << 5) - hash)
|
|
}
|
|
const index = Math.abs(hash) % storageColors.length
|
|
return storageColors[index]
|
|
}
|
|
|
|
const getIconColor = (percent: number): string => {
|
|
if (percent >= 95) return "text-red-500"
|
|
if (percent >= 86) return "text-orange-500"
|
|
if (percent >= 71) return "text-yellow-500"
|
|
return "text-green-500"
|
|
}
|
|
|
|
const getProgressColor = (percent: number): string => {
|
|
if (percent >= 95) return "[&>div]:bg-red-500"
|
|
if (percent >= 86) return "[&>div]:bg-orange-500"
|
|
if (percent >= 71) return "[&>div]:bg-yellow-500"
|
|
return "[&>div]:bg-blue-500"
|
|
}
|
|
|
|
const getModalProgressColor = (percent: number): string => {
|
|
if (percent >= 95) return "[&>div]:bg-red-500"
|
|
if (percent >= 86) return "[&>div]:bg-orange-500"
|
|
if (percent >= 71) return "[&>div]:bg-yellow-500"
|
|
return "[&>div]:bg-blue-500"
|
|
}
|
|
|
|
const getOSIcon = (osInfo: VMDetails["os_info"] | undefined, vmType: string): React.ReactNode => {
|
|
if (vmType !== "lxc" || !osInfo?.id) {
|
|
return null
|
|
}
|
|
|
|
const osId = osInfo.id.toLowerCase()
|
|
|
|
switch (osId) {
|
|
case "debian":
|
|
return <img src="/icons/debian.svg" alt="Debian" className="h-16 w-16" />
|
|
case "ubuntu":
|
|
return <img src="/icons/ubuntu.svg" alt="Ubuntu" className="h-16 w-16" />
|
|
case "alpine":
|
|
return <img src="/icons/alpine.svg" alt="Alpine" className="h-16 w-16" />
|
|
case "arch":
|
|
return <img src="/icons/arch.svg" alt="Arch" className="h-16 w-16" />
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Sprint 13.29: render a single LXC mount point row.
|
|
// Lifted out of the main component so the Mount Points tab renders
|
|
// uniformly for both configured mpX entries and ad-hoc inside-CT
|
|
// remote mounts. Capacity displays whatever the backend resolved —
|
|
// PVE storage stats, `df` of host path, or n/a for ad-hoc.
|
|
function MountPointCard({ mp }: { mp: LxcMountPoint }) {
|
|
const t = useT()
|
|
const isStale = mp.runtime_reachable === false
|
|
const isReadonly = !isStale && mp.runtime_readonly === true
|
|
const isDivergent = mp.runtime_mounted === false // configured but not actually mounted
|
|
// "Zombie bind": the host removed the source (e.g. USB pulled, manual
|
|
// umount) but the CT mount namespace still shows the bind as mounted.
|
|
// Reported by Ignacio Seijo (11/05). Only flag host_bind /
|
|
// pve_storage_bind sources — PVE volume sources have no host path
|
|
// and `host_source_exists` comes back null for them.
|
|
const isHostDetached =
|
|
mp.runtime_mounted === true &&
|
|
(mp.type === "host_bind" || mp.type === "pve_storage_bind") &&
|
|
mp.host_source_exists === false
|
|
const cardClasses = isStale
|
|
? "border-red-500/50 bg-red-500/5"
|
|
: isDivergent || isHostDetached
|
|
? "border-amber-500/40 bg-amber-500/5"
|
|
: isReadonly
|
|
? "border-amber-500/30 bg-amber-500/5"
|
|
: "border border-white/10 sm:border-border bg-white/5 sm:bg-card"
|
|
|
|
const typeBadgeClass: Record<LxcMountPoint["type"], string> = {
|
|
pve_volume: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20",
|
|
pve_storage_bind: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
|
host_bind: "bg-purple-500/10 text-purple-400 border-purple-500/20",
|
|
ad_hoc: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
|
}
|
|
const typeLabel: Record<LxcMountPoint["type"], string> = {
|
|
pve_volume: t("vmLxc.details.mountTypes.pveVolume"),
|
|
pve_storage_bind: t("vmLxc.details.mountTypes.pveStorageBind"),
|
|
host_bind: t("vmLxc.details.mountTypes.hostBind"),
|
|
ad_hoc: t("vmLxc.details.mountTypes.adHoc"),
|
|
}
|
|
|
|
const fmtBytes = (b: number | null | undefined) => {
|
|
if (b == null) return "—"
|
|
const gb = b / 1024 ** 3
|
|
if (gb < 1) return `${(gb * 1024).toFixed(1)} MB`
|
|
if (gb >= 1000) return `${(gb / 1024).toFixed(2)} TB`
|
|
return `${gb.toFixed(2)} GB`
|
|
}
|
|
const usedPct =
|
|
mp.total_bytes && mp.used_bytes != null && mp.total_bytes > 0
|
|
? Math.round((mp.used_bytes / mp.total_bytes) * 100)
|
|
: null
|
|
|
|
// Parse mount options (runtime if available, else config flags) into
|
|
// flag chips + key=value pairs. Same UX as the Remote Mounts modal.
|
|
const optsString = mp.runtime_options || (mp.config_flags || []).join(",")
|
|
const optsEntries = (optsString || "")
|
|
.split(",")
|
|
.filter(Boolean)
|
|
.map((o) => {
|
|
const eq = o.indexOf("=")
|
|
return eq === -1
|
|
? { key: o, value: null as string | null }
|
|
: { key: o.slice(0, eq), value: o.slice(eq + 1) }
|
|
})
|
|
const flags = optsEntries.filter((o) => o.value === null).map((o) => o.key)
|
|
const keyValues = optsEntries.filter((o) => o.value !== null) as Array<{ key: string; value: string }>
|
|
|
|
return (
|
|
<div className={`rounded-lg p-4 ${cardClasses}`}>
|
|
<div className="flex items-center justify-between gap-3 flex-wrap mb-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<div
|
|
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
|
isStale ? "bg-red-500" : isDivergent ? "bg-amber-500" : "bg-green-500"
|
|
}`}
|
|
/>
|
|
<h3 className="font-mono font-semibold truncate">{mp.target}</h3>
|
|
{mp.mp_index && (
|
|
<Badge variant="outline" className="font-mono">
|
|
{mp.mp_index}
|
|
</Badge>
|
|
)}
|
|
<Badge className={typeBadgeClass[mp.type]}>{typeLabel[mp.type]}</Badge>
|
|
{mp.runtime_fstype && (
|
|
<Badge variant="outline" className="font-mono">
|
|
{mp.runtime_fstype}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<Badge
|
|
className={
|
|
isStale
|
|
? "bg-red-500/10 text-red-500 border-red-500/20"
|
|
: isDivergent || isHostDetached
|
|
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
|
: isReadonly
|
|
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
|
: mp.runtime_mounted === null
|
|
? "bg-gray-500/10 text-gray-400 border-gray-500/20"
|
|
: "bg-green-500/10 text-green-500 border-green-500/20"
|
|
}
|
|
>
|
|
{isStale
|
|
? t("vmLxc.details.mountStatus.stale")
|
|
: isDivergent
|
|
? t("vmLxc.details.mountStatus.notMounted")
|
|
: isHostDetached
|
|
? t("vmLxc.details.mountStatus.hostDetached")
|
|
: isReadonly
|
|
? t("vmLxc.details.mountStatus.readOnly")
|
|
: mp.runtime_mounted === null
|
|
? t("vmLxc.details.mountStatus.stopped")
|
|
: t("vmLxc.details.mountStatus.mounted")}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Source / Mounted-at info — what host resource backs the
|
|
mount, and where it shows up inside the CT. The header
|
|
already shows the target but it's worth surfacing the
|
|
source/target relationship explicitly here so the user
|
|
gets the full host→container path at a glance. */}
|
|
<div className="text-sm space-y-1">
|
|
<div>
|
|
<span className="text-muted-foreground">{t("vmLxc.details.sourceHost")}:</span>{" "}
|
|
<span className="font-mono">{mp.origin_label || mp.source}</span>
|
|
{mp.origin_storage && mp.origin_storage_type && (
|
|
<span className="text-muted-foreground ml-2">
|
|
({t("vmLxc.details.storageType", { type: mp.origin_storage_type })})
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">{t("vmLxc.details.mountedAtCt")}:</span>{" "}
|
|
<span className="font-mono">{mp.target}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Capacity — total/used/available with progress bar. Available
|
|
even when CT is stopped because numbers come from the host. */}
|
|
{mp.total_bytes != null && (
|
|
<div className="mt-3 space-y-2">
|
|
<Progress
|
|
value={usedPct ?? 0}
|
|
className={`h-2 ${
|
|
(usedPct ?? 0) > 90
|
|
? "[&>div]:bg-red-500"
|
|
: (usedPct ?? 0) > 75
|
|
? "[&>div]:bg-yellow-500"
|
|
: "[&>div]:bg-blue-500"
|
|
}`}
|
|
/>
|
|
<div className="grid grid-cols-3 gap-3 text-sm">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">{t("vmLxc.details.total")}</p>
|
|
<p className="font-medium">{fmtBytes(mp.total_bytes)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">{t("vmLxc.details.used")}</p>
|
|
<p className="font-medium">
|
|
{fmtBytes(mp.used_bytes)} {usedPct != null && `(${usedPct}%)`}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">{t("vmLxc.details.available")}</p>
|
|
<p className="font-medium">{fmtBytes(mp.available_bytes)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mount attributes — config_options/flags from the mpX line in
|
|
the LXC config (backup=0, shared=1, ro, replicate, etc.).
|
|
Hidden when there's nothing to show. */}
|
|
{(() => {
|
|
const configEntries: Array<{ key: string; value: string | null }> = []
|
|
for (const k of Object.keys(mp.config_options || {})) {
|
|
configEntries.push({ key: k, value: mp.config_options[k] })
|
|
}
|
|
for (const f of mp.config_flags || []) {
|
|
configEntries.push({ key: f, value: null })
|
|
}
|
|
if (configEntries.length === 0) return null
|
|
return (
|
|
<div className="mt-3">
|
|
<p className="text-xs text-muted-foreground mb-1.5">
|
|
{t("vmLxc.details.mountAttributes")}
|
|
</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{configEntries.map((e) => (
|
|
<Badge key={e.key} variant="outline" className="font-mono text-xs">
|
|
{e.key}{e.value !== null ? `=${e.value}` : ""}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{/* Runtime mount options — what the kernel actually uses
|
|
(vers, rsize, hard, sec, ...). Only meaningful when the CT
|
|
is running; for stopped CTs we hide this section because
|
|
the values would just repeat the config flags above.
|
|
|
|
Sprint 13.29 detail: we already render the runtime fstype
|
|
as a badge in the header, so it's fine to leave this
|
|
unlabelled-for-state — only show "(declared)" suffix in
|
|
the rare case where there's no runtime data but flags do
|
|
exist. */}
|
|
{(mp.runtime_mounted === true) && (keyValues.length > 0 || flags.length > 0) && (
|
|
<div className="mt-3">
|
|
<p className="text-xs text-muted-foreground mb-1.5">
|
|
{t("vmLxc.details.runtimeMountOptions")}
|
|
</p>
|
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
{flags.map((f) => (
|
|
<Badge key={f} variant="outline" className="font-mono text-xs">
|
|
{f}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
{keyValues.length > 0 && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
|
{keyValues.map((kv) => (
|
|
<div key={kv.key} className="min-w-0">
|
|
<span className="font-mono text-muted-foreground">{kv.key}</span>
|
|
<span className="font-mono text-foreground"> = {kv.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Error / divergence note. */}
|
|
{mp.runtime_error && (
|
|
<p
|
|
className={`mt-3 text-sm ${
|
|
isStale ? "text-red-400" : "text-amber-400"
|
|
}`}
|
|
>
|
|
{mp.runtime_error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function VirtualMachines() {
|
|
const t = useT()
|
|
const {
|
|
data: vmData,
|
|
error,
|
|
isLoading,
|
|
mutate,
|
|
} = useSWR<VMData[]>("/api/vms", fetcher, {
|
|
refreshInterval: 2500,
|
|
revalidateOnFocus: true,
|
|
revalidateOnReconnect: true,
|
|
dedupingInterval: 1000,
|
|
errorRetryCount: 2,
|
|
})
|
|
|
|
const [selectedVM, setSelectedVM] = useState<VMData | null>(null)
|
|
const [vmDetails, setVMDetails] = useState<VMDetails | null>(null)
|
|
const [controlLoading, setControlLoading] = useState(false)
|
|
// 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.
|
|
const [confirmDestructive, setConfirmDestructive] = useState<{
|
|
action: "stop" | "reboot"
|
|
vmid: number
|
|
vmName: string
|
|
} | null>(null)
|
|
const [confirmDestructiveTyped, setConfirmDestructiveTyped] = useState("")
|
|
const [detailsLoading, setDetailsLoading] = useState(false)
|
|
const [terminalOpen, setTerminalOpen] = useState(false)
|
|
const [terminalVmid, setTerminalVmid] = useState<number | null>(null)
|
|
const [terminalVmName, setTerminalVmName] = useState<string>("")
|
|
const [vmConfigs, setVmConfigs] = useState<Record<number, string>>({})
|
|
const [currentView, setCurrentView] = useState<"main" | "metrics">("main")
|
|
const [showAdditionalInfo, setShowAdditionalInfo] = useState(false)
|
|
const [showNotes, setShowNotes] = useState(false)
|
|
const [isEditingNotes, setIsEditingNotes] = useState(false)
|
|
const [editedNotes, setEditedNotes] = useState("")
|
|
const [savingNotes, setSavingNotes] = useState(false)
|
|
const [selectedMetric, setSelectedMetric] = useState<string | null>(null)
|
|
const [ipsLoaded, setIpsLoaded] = useState(false)
|
|
const [loadingIPs, setLoadingIPs] = useState(false)
|
|
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes")
|
|
|
|
// Backup states
|
|
const [vmBackups, setVmBackups] = useState<VMBackup[]>([])
|
|
const [backupStorages, setBackupStorages] = useState<BackupStorage[]>([])
|
|
const [selectedBackupStorage, setSelectedBackupStorage] = useState<string>("")
|
|
const [loadingBackups, setLoadingBackups] = useState(false)
|
|
const [creatingBackup, setCreatingBackup] = useState(false)
|
|
|
|
// Backup modal states
|
|
const [showBackupModal, setShowBackupModal] = useState(false)
|
|
const [backupMode, setBackupMode] = useState<string>("snapshot")
|
|
const [backupProtected, setBackupProtected] = useState(false)
|
|
const [backupNotification, setBackupNotification] = useState<string>("auto")
|
|
const [backupNotes, setBackupNotes] = useState<string>("{{guestname}}")
|
|
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
|
|
|
|
// Tab state for modal
|
|
const [activeModalTab, setActiveModalTab] = useState<"status" | "mounts" | "backups" | "updates" | "firewall">("status")
|
|
|
|
// Firewall log state — fetched only when the operator opens that tab
|
|
// so a CT/VM without firewall use doesn't pay the pvesh cost on every
|
|
// modal open. Issue #14554 from the helper-scripts discussions.
|
|
interface FirewallLogEntry { n: number; t: string }
|
|
const [firewallLogs, setFirewallLogs] = useState<FirewallLogEntry[]>([])
|
|
const [loadingFirewallLog, setLoadingFirewallLog] = useState(false)
|
|
const [firewallEnabled, setFirewallEnabled] = useState<boolean>(true)
|
|
const [firewallLogError, setFirewallLogError] = useState<string | null>(null)
|
|
// Sprint 13.29: per-LXC mount points lazy-loaded when the user opens
|
|
// the LXC modal. We fetch alongside backups (one-shot) so switching
|
|
// tabs is instantaneous; the cost is small (parses one config file
|
|
// + pvesm status which the kernel already caches).
|
|
const [mountPoints, setMountPoints] = useState<LxcMountPoint[]>([])
|
|
const [adHocMounts, setAdHocMounts] = useState<LxcMountPoint[]>([])
|
|
const [loadingMounts, setLoadingMounts] = useState(false)
|
|
|
|
// Detect standalone mode (webapp vs browser)
|
|
const [isStandalone, setIsStandalone] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const checkStandalone = () => {
|
|
const standalone = window.matchMedia('(display-mode: standalone)').matches ||
|
|
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
|
setIsStandalone(standalone)
|
|
}
|
|
checkStandalone()
|
|
|
|
const mediaQuery = window.matchMedia('(display-mode: standalone)')
|
|
mediaQuery.addEventListener('change', checkStandalone)
|
|
return () => mediaQuery.removeEventListener('change', checkStandalone)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
// `cancelled` short-circuits setState calls if the component unmounts
|
|
// mid-fetch (user navigates away while we're still iterating LXCs in
|
|
// batches). Without it, React logs "state update on unmounted
|
|
// component" and we leak the closure that holds the configs map.
|
|
let cancelled = false
|
|
|
|
const fetchLXCIPs = async () => {
|
|
if (!vmData || ipsLoaded || loadingIPs) return
|
|
|
|
const lxcs = vmData.filter((vm) => vm.type === "lxc")
|
|
|
|
if (lxcs.length === 0) {
|
|
if (!cancelled) setIpsLoaded(true)
|
|
return
|
|
}
|
|
|
|
setLoadingIPs(true)
|
|
const configs: Record<number, string> = {}
|
|
|
|
const batchSize = 5
|
|
for (let i = 0; i < lxcs.length; i += batchSize) {
|
|
if (cancelled) return
|
|
const batch = lxcs.slice(i, i + batchSize)
|
|
|
|
await Promise.all(
|
|
batch.map(async (lxc) => {
|
|
try {
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
|
|
|
const details = await fetchApi(`/api/vms/${lxc.vmid}`)
|
|
|
|
clearTimeout(timeoutId)
|
|
|
|
if (details.lxc_ip_info?.primary_ip) {
|
|
configs[lxc.vmid] = details.lxc_ip_info.primary_ip
|
|
} else if (details.config) {
|
|
configs[lxc.vmid] = extractIPFromConfig(details.config, details.lxc_ip_info)
|
|
}
|
|
} catch (error) {
|
|
console.log(`Could not fetch IP for LXC ${lxc.vmid}`)
|
|
configs[lxc.vmid] = "N/A"
|
|
}
|
|
}),
|
|
)
|
|
|
|
if (cancelled) return
|
|
setVmConfigs((prev) => ({ ...prev, ...configs }))
|
|
}
|
|
|
|
if (cancelled) return
|
|
setLoadingIPs(false)
|
|
setIpsLoaded(true)
|
|
}
|
|
|
|
fetchLXCIPs()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [vmData, ipsLoaded, loadingIPs])
|
|
|
|
// Load initial network unit and listen for changes
|
|
useEffect(() => {
|
|
setNetworkUnit(getNetworkUnit())
|
|
|
|
const handleNetworkUnitChange = () => {
|
|
setNetworkUnit(getNetworkUnit())
|
|
}
|
|
|
|
window.addEventListener("networkUnitChanged", handleNetworkUnitChange)
|
|
window.addEventListener("storage", handleNetworkUnitChange)
|
|
|
|
return () => {
|
|
window.removeEventListener("networkUnitChanged", handleNetworkUnitChange)
|
|
window.removeEventListener("storage", handleNetworkUnitChange)
|
|
}
|
|
}, [])
|
|
|
|
// Keep the open modal's VM in sync with the /api/vms poll so CPU/RAM/I-O values
|
|
// don't stay frozen at click-time. Single data source (/cluster/resources) shared
|
|
// with the list — no source mismatch, no flicker.
|
|
useEffect(() => {
|
|
if (!selectedVM || !vmData) return
|
|
const updated = vmData.find((v) => v.vmid === selectedVM.vmid)
|
|
if (!updated || updated === selectedVM) return
|
|
setSelectedVM(updated)
|
|
}, [vmData])
|
|
|
|
const handleVMClick = async (vm: VMData) => {
|
|
setSelectedVM(vm)
|
|
setCurrentView("main")
|
|
setShowAdditionalInfo(false)
|
|
setShowNotes(false)
|
|
setIsEditingNotes(false)
|
|
setEditedNotes("")
|
|
setDetailsLoading(true)
|
|
setActiveModalTab("status")
|
|
// Reset Sprint 13.29 mount-points state from any previous selection
|
|
// so the new modal doesn't briefly flash data from another LXC.
|
|
setMountPoints([])
|
|
setAdHocMounts([])
|
|
// Reset firewall log state — fetched lazily when the user opens
|
|
// that tab, since most operators won't visit it on every modal open.
|
|
setFirewallLogs([])
|
|
setFirewallLogError(null)
|
|
setFirewallEnabled(true)
|
|
|
|
// Load backups immediately (independent of config)
|
|
fetchBackupStorages()
|
|
fetchVmBackups(vm.vmid)
|
|
|
|
// Sprint 13.29: load LXC mount points alongside backups so
|
|
// switching to that tab is instant. Only LXCs have mpX entries —
|
|
// qemu VMs use disks, not mount points, so we skip the request
|
|
// and simply hide the tab below.
|
|
if (vm.type === "lxc") {
|
|
fetchMountPoints(vm.vmid)
|
|
}
|
|
|
|
try {
|
|
const details = await fetchApi(`/api/vms/${vm.vmid}`)
|
|
setVMDetails(details)
|
|
} catch (error) {
|
|
console.error("Error fetching VM details:", error)
|
|
} finally {
|
|
setDetailsLoading(false)
|
|
}
|
|
}
|
|
|
|
const fetchMountPoints = async (vmid: number) => {
|
|
setLoadingMounts(true)
|
|
try {
|
|
const response = await fetchApi<{
|
|
ok: boolean
|
|
running: boolean
|
|
mount_points: LxcMountPoint[]
|
|
ad_hoc: LxcMountPoint[]
|
|
}>(`/api/lxc/${vmid}/mount-points`)
|
|
if (response?.ok) {
|
|
setMountPoints(response.mount_points || [])
|
|
setAdHocMounts(response.ad_hoc || [])
|
|
} else {
|
|
setMountPoints([])
|
|
setAdHocMounts([])
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching LXC mount points:", error)
|
|
setMountPoints([])
|
|
setAdHocMounts([])
|
|
} finally {
|
|
setLoadingMounts(false)
|
|
}
|
|
}
|
|
|
|
const handleMetricsClick = () => {
|
|
setCurrentView("metrics")
|
|
}
|
|
|
|
const handleBackToMain = () => {
|
|
setCurrentView("main")
|
|
}
|
|
|
|
// Backup functions
|
|
const fetchBackupStorages = async () => {
|
|
try {
|
|
const response = await fetchApi("/api/backup-storages")
|
|
if (response.storages) {
|
|
setBackupStorages(response.storages)
|
|
if (response.storages.length > 0 && !selectedBackupStorage) {
|
|
setSelectedBackupStorage(response.storages[0].storage)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching backup storages:", error)
|
|
}
|
|
}
|
|
|
|
const fetchVmBackups = async (vmid: number) => {
|
|
setLoadingBackups(true)
|
|
try {
|
|
const response = await fetchApi(`/api/vms/${vmid}/backups`)
|
|
if (response.backups) {
|
|
setVmBackups(response.backups)
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching VM backups:", error)
|
|
setVmBackups([])
|
|
} finally {
|
|
setLoadingBackups(false)
|
|
}
|
|
}
|
|
|
|
// Firewall log fetcher — proxies the PVE per-VM/CT firewall log
|
|
// endpoint. The backend returns `firewall_enabled: false` when PVE
|
|
// says the firewall is OFF for that guest; in that case we render
|
|
// a callout instead of an empty viewer.
|
|
const fetchFirewallLog = async (vmid: number) => {
|
|
setLoadingFirewallLog(true)
|
|
setFirewallLogError(null)
|
|
try {
|
|
const response = await fetchApi<{
|
|
logs?: FirewallLogEntry[]
|
|
firewall_enabled?: boolean
|
|
error?: string
|
|
}>(`/api/vms/${vmid}/firewall/log?limit=500`)
|
|
setFirewallEnabled(response.firewall_enabled !== false)
|
|
setFirewallLogs(Array.isArray(response.logs) ? response.logs : [])
|
|
if (response.error && response.firewall_enabled !== false) {
|
|
setFirewallLogError(response.error)
|
|
}
|
|
} catch (error) {
|
|
setFirewallEnabled(true)
|
|
setFirewallLogs([])
|
|
setFirewallLogError(error instanceof Error ? error.message : String(error))
|
|
} finally {
|
|
setLoadingFirewallLog(false)
|
|
}
|
|
}
|
|
|
|
const openBackupModal = () => {
|
|
// Reset modal to defaults
|
|
setBackupMode("snapshot")
|
|
setBackupProtected(false)
|
|
setBackupNotification("auto")
|
|
setBackupNotes("{{guestname}}")
|
|
setBackupPbsChangeMode("default")
|
|
// Auto-select first storage if none selected
|
|
if (!selectedBackupStorage && backupStorages.length > 0) {
|
|
setSelectedBackupStorage(backupStorages[0].storage)
|
|
}
|
|
setShowBackupModal(true)
|
|
}
|
|
|
|
const handleCreateBackup = async () => {
|
|
if (!selectedVM || !selectedBackupStorage) return
|
|
|
|
setCreatingBackup(true)
|
|
setShowBackupModal(false)
|
|
|
|
try {
|
|
await fetchApi(`/api/vms/${selectedVM.vmid}/backup`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
storage: selectedBackupStorage,
|
|
mode: backupMode,
|
|
compress: "zstd",
|
|
protected: backupProtected,
|
|
notification: backupNotification,
|
|
notes: backupNotes,
|
|
pbs_change_detection: backupPbsChangeMode
|
|
}),
|
|
})
|
|
setTimeout(() => fetchVmBackups(selectedVM.vmid), 2000)
|
|
} catch (error) {
|
|
console.error("Error creating backup:", error)
|
|
// Surface the failure to the user. Previous behaviour silently swallowed
|
|
// backend errors so the user thought the backup started fine; in reality
|
|
// the request had 4xx/5xx'd and nothing was scheduled.
|
|
const msg = error instanceof Error ? error.message : t("vmLxc.errors.unknown")
|
|
alert(t("vmLxc.errors.backupStartFailed", { message: msg }))
|
|
} finally {
|
|
setCreatingBackup(false)
|
|
}
|
|
}
|
|
|
|
const handleVMControl = async (vmid: number, action: string) => {
|
|
setControlLoading(true)
|
|
try {
|
|
await fetchApi(`/api/vms/${vmid}/control`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ action }),
|
|
})
|
|
|
|
mutate()
|
|
setSelectedVM(null)
|
|
setVMDetails(null)
|
|
} 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")
|
|
alert(t("vmLxc.errors.controlFailed", { action, vmid, message: msg }))
|
|
} finally {
|
|
setControlLoading(false)
|
|
}
|
|
}
|
|
|
|
// Open terminal for LXC container
|
|
const openLxcTerminal = (vmid: number, vmName: string) => {
|
|
setTerminalVmid(vmid)
|
|
setTerminalVmName(vmName)
|
|
setTerminalOpen(true)
|
|
}
|
|
|
|
const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|
try {
|
|
const data = await fetchApi(`/api/vms/${vmid}/logs`)
|
|
|
|
// Format logs as plain text
|
|
let logText = `=== ${t("vmLxc.logs.header", { name: vmName, vmid })} ===\n`
|
|
logText += `${t("vmLxc.logs.node")}: ${data.node}\n`
|
|
logText += `${t("vmLxc.logs.type")}: ${data.type}\n`
|
|
logText += `${t("vmLxc.logs.totalLines")}: ${data.log_lines}\n`
|
|
logText += `${t("vmLxc.logs.generated")}: ${new Date().toISOString()}\n`
|
|
logText += `\n${"=".repeat(80)}\n\n`
|
|
|
|
if (data.logs && Array.isArray(data.logs)) {
|
|
data.logs.forEach((log: any) => {
|
|
if (typeof log === "object" && log.t) {
|
|
logText += `${log.t}\n`
|
|
} else if (typeof log === "string") {
|
|
logText += `${log}\n`
|
|
}
|
|
})
|
|
}
|
|
|
|
const blob = new Blob([logText], { type: "text/plain" })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement("a")
|
|
a.href = url
|
|
a.download = `${vmName}-${vmid}-logs.txt`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
} catch (error) {
|
|
console.error("Error downloading logs:", error)
|
|
}
|
|
}
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case "running":
|
|
return "bg-green-500/10 text-green-500 border-green-500/20"
|
|
case "stopped":
|
|
return "bg-red-500/10 text-red-500 border-red-500/20"
|
|
default:
|
|
return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
|
}
|
|
}
|
|
|
|
const getStatusIcon = (status: string) => {
|
|
switch (status) {
|
|
case "running":
|
|
return <Play className="h-3 w-3" />
|
|
case "stopped":
|
|
return <Square className="h-3 w-3" />
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
const getStatusLabel = (status: string, uppercase = true) => {
|
|
const label =
|
|
status === "running"
|
|
? t("vmLxc.running")
|
|
: status === "stopped"
|
|
? t("vmLxc.stopped")
|
|
: status
|
|
return uppercase ? label.toUpperCase() : label
|
|
}
|
|
|
|
const getTypeBadge = (type: string) => {
|
|
if (type === "lxc") {
|
|
return {
|
|
color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
|
|
label: "LXC",
|
|
icon: <Container className="h-3 w-3 mr-1" />,
|
|
}
|
|
}
|
|
return {
|
|
color: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
|
label: "VM",
|
|
icon: <Server className="h-3 w-3 mr-1" />,
|
|
}
|
|
}
|
|
|
|
// Ensure vmData is always an array (backend may return object on error)
|
|
const safeVMData = Array.isArray(vmData) ? vmData : []
|
|
|
|
// Render the "📦 N updates / 🛡 N security" badge next to an LXC in
|
|
// the dashboard list. Used ONLY in the card row alongside Uptime —
|
|
// the modal surfaces the same info via a dedicated tab instead of
|
|
// duplicating a badge in its header.
|
|
//
|
|
// Sizing matches the sibling "Uptime: …" text (text-sm + h-4 icon)
|
|
// so the row reads as a single visual unit. Colour is violet, the
|
|
// shared accent for "managed updates" across notifications and UI
|
|
// (mirrors the Secure Gateway visual treatment). Security count
|
|
// stays red because it's still an urgency cue independent of the
|
|
// update theme.
|
|
const renderLxcUpdateBadge = (
|
|
uc?: LxcUpdateCheck,
|
|
compact = false,
|
|
onClick?: () => void,
|
|
) => {
|
|
if (!uc?.available || !uc.count || uc.count <= 0) return null
|
|
const last = uc.last_check
|
|
? new Date(uc.last_check).toLocaleString()
|
|
: "—"
|
|
const topNames = (uc.packages || [])
|
|
.slice(0, 5)
|
|
.map((p) => p.name)
|
|
.join(", ")
|
|
const secHint =
|
|
uc.security_count > 0 ? ` · ${uc.security_count} ${t("vmLxc.updatesPanel.security")}` : ""
|
|
// Tooltip leads with the action when the badge is clickable so the
|
|
// affordance is explicit on hover — the chevron at the end of the
|
|
// badge reinforces the same signal visually for users who don't
|
|
// hover (mobile).
|
|
const tooltipPrefix = onClick ? `${t("vmLxc.updatesPanel.clickToView")} · ` : ""
|
|
const tooltip = `${tooltipPrefix}${t("vmLxc.updatesPanel.lastChecked")} ${last}${secHint}${topNames ? ` · ${topNames}` : ""}`
|
|
// Compact = mobile card; matches the surrounding 10-12px chrome
|
|
// (ID line, type badge) so the count doesn't visually dominate.
|
|
// Non-compact = desktop card row, sized to match "Uptime: ..." text.
|
|
const sizing = compact
|
|
? "text-[11px] gap-1 px-1.5 py-0"
|
|
: "text-sm gap-1.5 px-2 py-0.5"
|
|
const iconSize = compact ? "h-3 w-3" : "h-4 w-4"
|
|
// Only soften the bg on hover — no border change, no focus ring.
|
|
// The chevron at the end of the badge carries the "open this"
|
|
// affordance on its own. The Badge component's CVA base adds a
|
|
// `focus:ring-2 focus:ring-ring focus:ring-offset-2` (the white
|
|
// double border we kept seeing on tap/click) — explicitly cancel
|
|
// every piece of it here.
|
|
const clickable = onClick
|
|
? "cursor-pointer hover:bg-violet-500/20 transition-colors focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
: ""
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className={`bg-violet-500/10 text-violet-400 border-violet-500/30 flex items-center flex-shrink-0 ${sizing} ${clickable}`}
|
|
title={tooltip}
|
|
onClick={onClick}
|
|
role={onClick ? "button" : undefined}
|
|
tabIndex={onClick ? 0 : undefined}
|
|
>
|
|
<Package className={iconSize} />
|
|
{uc.count} {compact ? "" : (uc.count === 1 ? t("vmLxc.updatesPanel.updateSingular") : t("vmLxc.updatesPanel.updatePlural"))}
|
|
{/* Chevron only when the badge is wired up as a clickable
|
|
shortcut — its absence on the dashboard card avoids
|
|
implying interactivity where there isn't any (the whole
|
|
row is the click target there). */}
|
|
{onClick && <ChevronRight className={`${iconSize} -mr-0.5 opacity-80`} />}
|
|
</Badge>
|
|
)
|
|
}
|
|
|
|
// Total allocated RAM for ALL VMs/LXCs (running + stopped)
|
|
const totalAllocatedMemoryGB = useMemo(() => {
|
|
return (safeVMData.reduce((sum, vm) => sum + (vm.maxmem || 0), 0) / 1024 ** 3).toFixed(1)
|
|
}, [safeVMData])
|
|
|
|
// Allocated RAM only for RUNNING VMs/LXCs (this is what actually matters for overcommit)
|
|
const runningAllocatedMemoryGB = useMemo(() => {
|
|
return (safeVMData
|
|
.filter((vm) => vm.status === "running")
|
|
.reduce((sum, vm) => sum + (vm.maxmem || 0), 0) / 1024 ** 3).toFixed(1)
|
|
}, [safeVMData])
|
|
|
|
const { data: systemData } = useSWR<{ memory_total: number; memory_used: number; memory_usage: number; cpu_cores?: number; cpu_threads?: number }>(
|
|
"/api/system",
|
|
fetcher,
|
|
{
|
|
refreshInterval: 37000,
|
|
revalidateOnFocus: false,
|
|
},
|
|
)
|
|
|
|
const physicalMemoryGB = systemData?.memory_total ?? null
|
|
const usedMemoryGB = systemData?.memory_used ?? null
|
|
const memoryUsagePercent = systemData?.memory_usage ?? null
|
|
const allocatedMemoryGB = Number.parseFloat(totalAllocatedMemoryGB)
|
|
const runningAllocatedGB = Number.parseFloat(runningAllocatedMemoryGB)
|
|
// Overcommit warning should be based on RUNNING VMs allocation, not total
|
|
const isMemoryOvercommit = physicalMemoryGB !== null && runningAllocatedGB > physicalMemoryGB
|
|
|
|
const getMemoryUsageColor = (percent: number | null) => {
|
|
if (percent === null) return "bg-blue-500"
|
|
if (percent >= 95) return "bg-red-500"
|
|
if (percent >= 86) return "bg-orange-500"
|
|
if (percent >= 71) return "bg-yellow-500"
|
|
return "bg-blue-500"
|
|
}
|
|
|
|
const getMemoryPercentTextColor = (percent: number | null) => {
|
|
if (percent === null) return "text-muted-foreground"
|
|
if (percent >= 95) return "text-red-500"
|
|
if (percent >= 86) return "text-orange-500"
|
|
if (percent >= 71) return "text-yellow-500"
|
|
return "text-green-500"
|
|
}
|
|
|
|
const formatCoreCount = (count: number) => {
|
|
const key = count === 1 ? "one" : count >= 2 && count <= 4 ? "few" : "many"
|
|
return t(`vmLxc.coreCount.${key}`, { count })
|
|
}
|
|
|
|
const displayedFirewallLogs = useMemo(() => {
|
|
return firewallLogs.filter((entry) => {
|
|
const text = (entry.t || "").trim().toLowerCase()
|
|
return text.length > 0 && text !== "no content"
|
|
})
|
|
}, [firewallLogs])
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
|
<div className="relative">
|
|
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
|
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
|
</div>
|
|
<div className="text-sm font-medium text-foreground">{t("vmLxc.loadingTitle")}</div>
|
|
<p className="text-xs text-muted-foreground">{t("vmLxc.loadingDescription")}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="text-center py-8 text-red-500">{t("vmLxc.loadingError", { error: error.message })}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Single-pass decode. Proxmox URL-encodes notes exactly once when storing
|
|
// them in `config.description`, so a single `decodeURIComponent` is the
|
|
// correct round-trip. The previous loop decoded up to 5 times, which made
|
|
// it possible to ship a payload like `%253Cscript%253E` past one-pass
|
|
// filters (`%25` → `%` → second decode produces `<script>`). With the
|
|
// dangerouslySetInnerHTML render path already removed (Sprint 4.1) the
|
|
// immediate XSS is gone, but keeping the loop on the editor path keeps
|
|
// the same evasion vector available for future use sites.
|
|
const decodeRecursively = (str: string): string => {
|
|
try {
|
|
return decodeURIComponent(str.replace(/%0A/g, "\n"))
|
|
} catch {
|
|
return str
|
|
}
|
|
}
|
|
|
|
const handleEditNotes = () => {
|
|
if (vmDetails?.config?.description) {
|
|
const decoded = decodeRecursively(vmDetails.config.description)
|
|
setEditedNotes(decoded)
|
|
} else {
|
|
setEditedNotes("") // Ensure editedNotes is empty if no description exists
|
|
}
|
|
setIsEditingNotes(true)
|
|
}
|
|
|
|
const handleSaveNotes = async () => {
|
|
if (!selectedVM || !vmDetails) return
|
|
|
|
setSavingNotes(true)
|
|
try {
|
|
await fetchApi(`/api/vms/${selectedVM.vmid}/description`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
description: editedNotes, // Send as-is, pvesh will handle encoding
|
|
}),
|
|
})
|
|
|
|
setVMDetails({
|
|
...vmDetails,
|
|
config: {
|
|
...vmDetails.config,
|
|
description: editedNotes, // Store unencoded
|
|
},
|
|
})
|
|
setIsEditingNotes(false)
|
|
} catch (error) {
|
|
console.error("Error saving notes:", error)
|
|
alert(t("vmLxc.errors.saveNotesFailed"))
|
|
} finally {
|
|
setSavingNotes(false)
|
|
}
|
|
}
|
|
|
|
const handleCancelEditNotes = () => {
|
|
setIsEditingNotes(false)
|
|
setEditedNotes("")
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/*
|
|
styled-jsx is scoped by default — it adds a hash class to
|
|
selectors so they only match elements rendered by this
|
|
component. Content injected via `dangerouslySetInnerHTML`
|
|
does NOT get the hash, so descendant selectors like
|
|
`div[align="center"]` never matched the helper-script HTML
|
|
and notes rendered left-aligned. Wrapping the descendant
|
|
selectors in `:global(...)` keeps the parent class scoped
|
|
but lets the inner rules apply to the injected HTML.
|
|
*/}
|
|
<style jsx>{`
|
|
.proxmenux-notes {
|
|
all: revert;
|
|
}
|
|
.proxmenux-notes :global(a) {
|
|
display: inline-block;
|
|
margin-right: 4px;
|
|
text-decoration: none;
|
|
}
|
|
.proxmenux-notes :global(img) {
|
|
display: inline-block;
|
|
vertical-align: middle;
|
|
}
|
|
.proxmenux-notes :global(p) {
|
|
margin: 0.5rem 0;
|
|
}
|
|
.proxmenux-notes :global(table) {
|
|
width: auto !important;
|
|
margin: 0 auto;
|
|
}
|
|
.proxmenux-notes :global(div[align="center"]) {
|
|
text-align: center;
|
|
}
|
|
.proxmenux-notes :global(table td:nth-child(2)) {
|
|
text-align: left;
|
|
padding-left: 16px;
|
|
}
|
|
.proxmenux-notes :global(table td:nth-child(2) h1) {
|
|
text-align: left;
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
line-height: 1.2;
|
|
}
|
|
.proxmenux-notes :global(table td:nth-child(2) p) {
|
|
text-align: left;
|
|
}
|
|
.proxmenux-notes :global(table + p) {
|
|
margin-top: 1rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
.proxmenux-notes-plaintext {
|
|
white-space: pre-wrap;
|
|
font-family: monospace;
|
|
}
|
|
`}</style>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6">
|
|
{/* ── Total VMs & LXCs (preview restyle: B-headline + pills, matching Overview) ── */}
|
|
{(() => {
|
|
const running = safeVMData.filter((vm) => vm.status === "running").length
|
|
const stopped = safeVMData.filter((vm) => vm.status === "stopped").length
|
|
const total = safeVMData.length
|
|
const vms = safeVMData.filter((vm) => vm.type === "qemu" || vm.type === "vm").length
|
|
const lxc = safeVMData.filter((vm) => vm.type === "lxc").length
|
|
return (
|
|
<Card className="bg-card border-border">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("vmLxc.totalVmLxc")}</CardTitle>
|
|
<Server className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-end justify-between">
|
|
<div>
|
|
<span className="text-4xl font-bold leading-none text-foreground">{running}</span>
|
|
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {total}</span>
|
|
</div>
|
|
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
|
{running} {t("vmLxc.running")}
|
|
</Badge>
|
|
</div>
|
|
<div className="mt-3 flex gap-1 flex-wrap">
|
|
{vms > 0 && (
|
|
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
|
{vms} {t("vmLxc.vms")}
|
|
</Badge>
|
|
)}
|
|
{lxc > 0 && (
|
|
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">{lxc} LXC</Badge>
|
|
)}
|
|
{stopped > 0 && (
|
|
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
|
|
{stopped} {t("vmLxc.stopped")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})()}
|
|
|
|
{/* ── Total CPU Allocated (preview restyle: donut + Used/Configured/In use) ── */}
|
|
{(() => {
|
|
const allocPct = safeVMData.reduce((sum, vm) => sum + (vm.cpu || 0), 0) * 100
|
|
const configuredVCPU = safeVMData.reduce((sum, vm) => sum + (vm.maxcpu || 0), 0)
|
|
const inUseVCPU = safeVMData
|
|
.filter((vm) => vm.status === "running")
|
|
.reduce((sum, vm) => sum + (vm.maxcpu || 0), 0)
|
|
const hostThreads = systemData?.cpu_threads ?? systemData?.cpu_cores ?? 0
|
|
const stroke = allocPct >= 90 ? '#ef4444' : allocPct >= 75 ? '#f59e0b' : '#3b82f6'
|
|
return (
|
|
<Card className="bg-card border-border">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("vmLxc.totalCpuAllocated")}</CardTitle>
|
|
<Cpu className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center gap-4">
|
|
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
|
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
|
<circle cx="18" cy="18" r="15.9155" fill="none" stroke={stroke} strokeWidth="3"
|
|
strokeDasharray={`${Math.min(100, allocPct)} 100`} strokeLinecap="round"
|
|
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
|
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(allocPct)}%</text>
|
|
</svg>
|
|
<div className="flex-1 space-y-2">
|
|
<div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">{t("vmLxc.used")}</span>
|
|
<span className="font-medium font-mono whitespace-nowrap">{Math.round(allocPct)}%</span>
|
|
</div>
|
|
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
<div className="h-full rounded-full" style={{ width: `${Math.min(100, allocPct)}%`, background: stroke }}/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">{t("vmLxc.configured")}</span>
|
|
<span className="font-medium font-mono whitespace-nowrap">{configuredVCPU || '—'}{hostThreads ? ` / ${hostThreads}` : ''} vCPU</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">{t("vmLxc.inUse")}</span>
|
|
<span className="font-medium font-mono whitespace-nowrap">{inUseVCPU || '—'}{hostThreads ? ` / ${hostThreads}` : ''} vCPU</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})()}
|
|
|
|
{/* ── Total Memory (preview restyle: donut + mini-bars Used/Allocated) ── */}
|
|
{(() => {
|
|
const usedPct = memoryUsagePercent ?? 0
|
|
const usedGB = usedMemoryGB ?? 0
|
|
const totalGB = physicalMemoryGB ?? 0
|
|
const allocPct = totalGB > 0 ? (allocatedMemoryGB / totalGB) * 100 : 0
|
|
const stroke = usedPct >= 90 ? '#ef4444' : usedPct >= 75 ? '#f59e0b' : '#3b82f6'
|
|
return (
|
|
<Card className="bg-card border-border">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("vmLxc.totalMemory")}</CardTitle>
|
|
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center gap-4">
|
|
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
|
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
|
<circle cx="18" cy="18" r="15.9155" fill="none" stroke={stroke} strokeWidth="3"
|
|
strokeDasharray={`${usedPct} 100`} strokeLinecap="round"
|
|
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
|
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(usedPct)}%</text>
|
|
</svg>
|
|
<div className="flex-1 space-y-2">
|
|
<div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">{t("vmLxc.used")}</span>
|
|
<span className="font-medium font-mono whitespace-nowrap">{usedGB.toFixed(1)}</span>
|
|
</div>
|
|
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
<div className="h-full rounded-full" style={{ width: `${usedPct}%`, background: stroke }}/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">{t("vmLxc.allocated")}</span>
|
|
<span className="font-medium font-mono whitespace-nowrap">{allocatedMemoryGB.toFixed(1)}</span>
|
|
</div>
|
|
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
<div className="h-full rounded-full" style={{ width: `${Math.min(100, allocPct)}%`, background: isMemoryOvercommit ? '#f59e0b' : 'rgba(99,102,241,0.55)' }}/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">{t("vmLxc.total")}</span>
|
|
<span className="font-medium font-mono whitespace-nowrap">{totalGB.toFixed(0)} GB</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})()}
|
|
|
|
{/* ── Total Disk (preview restyle: headline + 2-segment stacked bar Used/Alloc-not-Used) ── */}
|
|
{(() => {
|
|
const usedGB = safeVMData.reduce((sum, vm) => sum + (vm.disk || 0), 0) / 1024 ** 3
|
|
const allocGB = safeVMData.reduce((sum, vm) => sum + (vm.maxdisk || 0), 0) / 1024 ** 3
|
|
const utilPct = allocGB > 0 ? (usedGB / allocGB) * 100 : 0
|
|
const idleGB = Math.max(0, allocGB - usedGB)
|
|
const stroke = utilPct >= 90 ? '#ef4444' : utilPct >= 75 ? '#f59e0b' : '#3b82f6'
|
|
const usedSeg = allocGB > 0 ? (usedGB / allocGB) * 100 : 0
|
|
return (
|
|
<Card className="bg-card border-border">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("vmLxc.totalDisk")}</CardTitle>
|
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-end justify-between mb-3">
|
|
<div>
|
|
<span className="text-xl lg:text-2xl font-bold leading-none">{formatStorage(usedGB)}</span>
|
|
<span className="text-sm font-medium ml-1 text-muted-foreground">{t("vmLxc.used").toLowerCase()}</span>
|
|
</div>
|
|
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
|
|
{Math.round(utilPct)}% {t("vmLxc.utilization")}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex h-1.5 rounded-full overflow-hidden gap-[2px]">
|
|
<div style={{ width: `${usedSeg}%`, background: stroke }} title={`${t("vmLxc.used")} ${formatStorage(usedGB)}`}></div>
|
|
<div style={{ flex: 1, background: 'rgba(168,85,247,0.45)' }} title={`${t("vmLxc.idle")} ${formatStorage(idleGB)}`}></div>
|
|
</div>
|
|
<div className="mt-2 flex justify-between text-sm text-muted-foreground">
|
|
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full" style={{ background: stroke }}></span>{t("vmLxc.used")} {formatStorage(usedGB)}</span>
|
|
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full" style={{ background: 'rgba(168,85,247,0.55)' }}></span>{t("vmLxc.allocated")} {formatStorage(allocGB)}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})()}
|
|
</div>
|
|
|
|
<Card className="bg-card border-border">
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2 text-xl lg:text-2xl font-bold text-foreground">
|
|
<Server className="h-6 w-6" />
|
|
{t("vmLxc.listTitle")}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{safeVMData.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">{t("vmLxc.empty")}</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{safeVMData.map((vm) => {
|
|
const cpuPercent = (vm.cpu * 100).toFixed(1)
|
|
const memPercent = vm.maxmem > 0 ? ((vm.mem / vm.maxmem) * 100).toFixed(1) : "0"
|
|
const memGB = (vm.mem / 1024 ** 3).toFixed(1)
|
|
const maxMemGB = (vm.maxmem / 1024 ** 3).toFixed(1)
|
|
const diskPercent = vm.maxdisk > 0 ? ((vm.disk / vm.maxdisk) * 100).toFixed(1) : "0"
|
|
const diskGB = (vm.disk / 1024 ** 3).toFixed(1)
|
|
const maxDiskGB = (vm.maxdisk / 1024 ** 3).toFixed(1)
|
|
const typeBadge = getTypeBadge(vm.type)
|
|
const lxcIP = vm.type === "lxc" ? vmConfigs[vm.vmid] : null
|
|
|
|
return (
|
|
<div key={vm.vmid}>
|
|
<div
|
|
className="hidden sm:block p-4 rounded-lg border border-border bg-card hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer"
|
|
onClick={() => handleVMClick(vm)}
|
|
>
|
|
<div className="flex items-center gap-2 flex-wrap mb-3">
|
|
<Badge variant="outline" className={`flex-shrink-0 ${getStatusColor(vm.status)}`}>
|
|
{getStatusIcon(vm.status)}
|
|
{getStatusLabel(vm.status)}
|
|
</Badge>
|
|
<Badge variant="outline" className={`flex-shrink-0 ${typeBadge.color}`}>
|
|
{typeBadge.icon}
|
|
{typeBadge.label}
|
|
</Badge>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-semibold text-foreground truncate">
|
|
{vm.name}
|
|
<span className="hidden lg:inline text-sm text-muted-foreground ml-2">ID: {vm.vmid}</span>
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground lg:hidden">ID: {vm.vmid}</div>
|
|
</div>
|
|
{lxcIP && (
|
|
<span className={`text-sm ${lxcIP === "DHCP" ? "text-yellow-500" : "text-green-500"}`}>
|
|
IP: {lxcIP}
|
|
</span>
|
|
)}
|
|
<span className="text-sm text-muted-foreground ml-auto">
|
|
{t("vmLxc.uptime", { uptime: formatUptime(vm.uptime, t) })}
|
|
</span>
|
|
{vm.type === "lxc" && renderLxcUpdateBadge(vm.update_check)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.cpuUsage")}</div>
|
|
<div
|
|
className="cursor-pointer hover:opacity-80 transition-opacity"
|
|
onClick={() => {
|
|
setSelectedMetric("cpu") // undeclared variable fix
|
|
}}
|
|
>
|
|
<div
|
|
className={`text-sm font-semibold mb-1 ${getUsageColor(Number.parseFloat(cpuPercent))}`}
|
|
>
|
|
{cpuPercent}%
|
|
</div>
|
|
<Progress
|
|
value={Number.parseFloat(cpuPercent)}
|
|
className={`h-1.5 ${getProgressColor(Number.parseFloat(cpuPercent))}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.memory")}</div>
|
|
<div
|
|
className="cursor-pointer hover:opacity-80 transition-opacity"
|
|
onClick={() => {
|
|
setSelectedMetric("memory")
|
|
}}
|
|
>
|
|
<div
|
|
className={`text-sm font-semibold mb-1 ${getUsageColor(Number.parseFloat(memPercent))}`}
|
|
>
|
|
{memGB} / {maxMemGB} GB
|
|
</div>
|
|
<Progress
|
|
value={Number.parseFloat(memPercent)}
|
|
className={`h-1.5 ${getProgressColor(Number.parseFloat(memPercent))}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.diskUsage")}</div>
|
|
<div
|
|
className="cursor-pointer hover:opacity-80 transition-opacity"
|
|
onClick={() => {
|
|
setSelectedMetric("disk")
|
|
}}
|
|
>
|
|
<div
|
|
className={`text-sm font-semibold mb-1 ${getUsageColor(Number.parseFloat(diskPercent))}`}
|
|
>
|
|
{diskGB} / {maxDiskGB} GB
|
|
</div>
|
|
<Progress
|
|
value={Number.parseFloat(diskPercent)}
|
|
className={`h-1.5 ${getProgressColor(Number.parseFloat(diskPercent))}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hidden md:block">
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.diskIo")}</div>
|
|
<div className="text-sm font-semibold space-y-0.5">
|
|
<div className="flex items-center gap-1">
|
|
<HardDrive className="h-3 w-3 text-green-500" />
|
|
<span className="text-green-500">↓ {formatBytes(vm.diskread, false)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<HardDrive className="h-3 w-3 text-blue-500" />
|
|
<span className="text-blue-500">↑ {formatBytes(vm.diskwrite, false)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.networkIo")}</div>
|
|
<div className="text-sm font-semibold space-y-0.5">
|
|
<div className="flex items-center gap-1">
|
|
<Network className="h-3 w-3 text-green-500" />
|
|
<span className="text-green-500">↓ {formatBytes(vm.netin, true)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Network className="h-3 w-3 text-blue-500" />
|
|
<span className="text-blue-500">↑ {formatBytes(vm.netout, true)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="sm:hidden p-4 rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 transition-colors cursor-pointer"
|
|
onClick={() => handleVMClick(vm)}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{vm.status === "running" ? (
|
|
<Play className="h-5 w-5 text-green-500 fill-current flex-shrink-0" />
|
|
) : (
|
|
<Square className="h-5 w-5 text-red-500 fill-current flex-shrink-0" />
|
|
)}
|
|
|
|
<Badge variant="outline" className={`${getTypeBadge(vm.type).color} flex-shrink-0`}>
|
|
{getTypeBadge(vm.type).label}
|
|
</Badge>
|
|
|
|
{/* Name and ID */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-semibold text-foreground truncate flex items-center gap-1.5">
|
|
<span className="truncate">{vm.name}</span>
|
|
{vm.type === "lxc" && renderLxcUpdateBadge(vm.update_check, true)}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground">ID: {vm.vmid}</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 flex-shrink-0">
|
|
{/* CPU icon with percentage */}
|
|
<div className="flex flex-col items-center gap-0.5">
|
|
{vm.status === "running" && (
|
|
<span className="text-[10px] font-medium text-muted-foreground">{cpuPercent}%</span>
|
|
)}
|
|
<Cpu
|
|
className={`h-4 w-4 ${
|
|
vm.status === "stopped" ? "text-gray-500" : getUsageColor(Number.parseFloat(cpuPercent))
|
|
}`}
|
|
/>
|
|
</div>
|
|
|
|
{/* Memory icon with percentage */}
|
|
<div className="flex flex-col items-center gap-0.5">
|
|
{vm.status === "running" && (
|
|
<span className="text-[10px] font-medium text-muted-foreground">{memPercent}%</span>
|
|
)}
|
|
<MemoryStick
|
|
className={`h-4 w-4 ${
|
|
vm.status === "stopped" ? "text-gray-500" : getUsageColor(Number.parseFloat(memPercent))
|
|
}`}
|
|
/>
|
|
</div>
|
|
|
|
{/* Disk icon with percentage */}
|
|
<div className="flex flex-col items-center gap-0.5">
|
|
{vm.status === "running" && (
|
|
<span className="text-[10px] font-medium text-muted-foreground">{diskPercent}%</span>
|
|
)}
|
|
<HardDrive
|
|
className={`h-4 w-4 ${
|
|
vm.status === "stopped"
|
|
? "text-gray-500"
|
|
: getUsageColor(Number.parseFloat(diskPercent))
|
|
}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog
|
|
open={!!selectedVM}
|
|
onOpenChange={() => {
|
|
setSelectedVM(null)
|
|
setVMDetails(null)
|
|
setCurrentView("main")
|
|
setSelectedMetric(null)
|
|
setShowAdditionalInfo(false)
|
|
setShowNotes(false)
|
|
setIsEditingNotes(false)
|
|
setEditedNotes("")
|
|
setActiveModalTab("status")
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className={`max-w-4xl flex flex-col p-0 overflow-hidden ${
|
|
isStandalone
|
|
? "h-[95vh] sm:h-[90vh]"
|
|
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
|
|
}`}
|
|
key={selectedVM?.vmid || "no-vm"}
|
|
>
|
|
{currentView === "main" ? (
|
|
<>
|
|
<DialogHeader className="pb-4 border-b border-border px-6 pt-6">
|
|
<DialogTitle className="flex flex-col gap-3">
|
|
{/* Desktop layout: Uptime now appears after status badge */}
|
|
<div className="hidden sm:flex items-center gap-3 flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
<Server className="h-5 w-5 flex-shrink-0" />
|
|
<span className="text-lg truncate">{selectedVM?.name}</span>
|
|
{selectedVM && <span className="text-sm text-muted-foreground">ID: {selectedVM.vmid}</span>}
|
|
</div>
|
|
{selectedVM && (
|
|
<>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Badge variant="outline" className={`${getTypeBadge(selectedVM.type).color} flex-shrink-0`}>
|
|
{getTypeBadge(selectedVM.type).icon}
|
|
{getTypeBadge(selectedVM.type).label}
|
|
</Badge>
|
|
<Badge variant="outline" className={`${getStatusColor(selectedVM.status)} flex-shrink-0`}>
|
|
{getStatusLabel(selectedVM.status)}
|
|
</Badge>
|
|
{selectedVM.status === "running" && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{t("vmLxc.uptime", { uptime: formatUptime(selectedVM.uptime, t) })}
|
|
</span>
|
|
)}
|
|
{/* Clickable badge — the sole entry point to
|
|
the Updates panel now that the tab is no
|
|
longer in the nav. Full-size so it reads
|
|
at the same weight as the surrounding
|
|
Uptime / Type / Status chips. */}
|
|
{selectedVM.type === "lxc" &&
|
|
renderLxcUpdateBadge(
|
|
selectedVM.update_check,
|
|
false,
|
|
() => setActiveModalTab("updates"),
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* Mobile layout unchanged */}
|
|
<div className="sm:hidden flex flex-col gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<Server className="h-5 w-5 flex-shrink-0" />
|
|
<span className="text-lg truncate">{selectedVM?.name}</span>
|
|
{selectedVM && <span className="text-sm text-muted-foreground">ID: {selectedVM.vmid}</span>}
|
|
</div>
|
|
{selectedVM && (
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Badge variant="outline" className={`${getTypeBadge(selectedVM.type).color} flex-shrink-0`}>
|
|
{getTypeBadge(selectedVM.type).icon}
|
|
{getTypeBadge(selectedVM.type).label}
|
|
</Badge>
|
|
<Badge variant="outline" className={`${getStatusColor(selectedVM.status)} flex-shrink-0`}>
|
|
{getStatusLabel(selectedVM.status)}
|
|
</Badge>
|
|
{selectedVM.status === "running" && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{t("vmLxc.uptime", { uptime: formatUptime(selectedVM.uptime, t) })}
|
|
</span>
|
|
)}
|
|
{selectedVM.type === "lxc" &&
|
|
renderLxcUpdateBadge(
|
|
selectedVM.update_check,
|
|
false,
|
|
() => setActiveModalTab("updates"),
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{/* Tab Navigation.
|
|
Mobile UX:
|
|
• Only the active tab shows its label; the rest
|
|
collapse to icon-only so 4-5 tabs fit on a phone.
|
|
• Per-tab padding + gap shrink on narrow viewports
|
|
(`px-2.5 sm:px-4`, `gap-1.5 sm:gap-2`) so even with
|
|
two badges showing counts the row doesn't overflow.
|
|
• Container has `overflow-x-auto` as a safety net —
|
|
a CT with all tabs active (Mounts + Backups +
|
|
Updates + Firewall) on a very narrow phone can
|
|
still horizontally scroll the row instead of
|
|
clipping the last tab off-screen.
|
|
• Badges stay visible in both states so the user
|
|
still sees "9 backups" at a glance even when that
|
|
tab isn't active. */}
|
|
<div className="flex border-b border-border px-3 sm:px-6 shrink-0 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
|
<button
|
|
onClick={() => setActiveModalTab("status")}
|
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
|
|
activeModalTab === "status"
|
|
? "border-cyan-500 text-cyan-500"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<Activity className="h-4 w-4" />
|
|
<span className={activeModalTab === "status" ? "" : "hidden sm:inline"}>
|
|
{t("vmLxc.tabs.status")}
|
|
</span>
|
|
</button>
|
|
{/* Sprint 13.29: Mount Points tab — LXC only, and only
|
|
when at least one mp / ad-hoc remote mount exists.
|
|
A CT without mounts gets no empty tab. */}
|
|
{selectedVM?.type === "lxc" && (mountPoints.length > 0 || adHocMounts.length > 0) && (
|
|
<button
|
|
onClick={() => setActiveModalTab("mounts")}
|
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
|
|
activeModalTab === "mounts"
|
|
? "border-blue-500 text-blue-500"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<HardDrive className="h-4 w-4" />
|
|
<span className={activeModalTab === "mounts" ? "" : "hidden sm:inline"}>
|
|
{t("vmLxc.tabs.mounts")}
|
|
</span>
|
|
<Badge variant="secondary" className="text-xs h-5 ml-0.5 sm:ml-1">
|
|
{mountPoints.length + adHocMounts.length}
|
|
</Badge>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setActiveModalTab("backups")}
|
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
|
|
activeModalTab === "backups"
|
|
? "border-amber-500 text-amber-500"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<Archive className="h-4 w-4" />
|
|
<span className={activeModalTab === "backups" ? "" : "hidden sm:inline"}>
|
|
{t("vmLxc.tabs.backups")}
|
|
</span>
|
|
{vmBackups.length > 0 && (
|
|
<Badge variant="secondary" className="text-xs h-5 ml-0.5 sm:ml-1">{vmBackups.length}</Badge>
|
|
)}
|
|
</button>
|
|
{/* Updates tab — re-added as a first-class nav entry now
|
|
that the mobile UX collapses inactive tabs to
|
|
icon-only (so the row no longer overflows on narrow
|
|
viewports the way it did before v1.2.1.3). LXC only,
|
|
rendered only when the managed-installs registry has
|
|
flagged pending updates for this CT, so a CT with
|
|
nothing pending doesn't get an empty tab. The violet
|
|
badge in the header stays as a complementary entry
|
|
point — both routes lead to the same `updates` panel
|
|
below. */}
|
|
{selectedVM?.type === "lxc" && selectedVM?.update_check?.available && (
|
|
<button
|
|
onClick={() => setActiveModalTab("updates")}
|
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
|
|
activeModalTab === "updates"
|
|
? "border-purple-500 text-purple-500"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
<span className={activeModalTab === "updates" ? "" : "hidden sm:inline"}>
|
|
{t("vmLxc.tabs.updates")}
|
|
</span>
|
|
{typeof selectedVM.update_check?.count === "number" && selectedVM.update_check.count > 0 && (
|
|
<Badge variant="secondary" className="text-xs h-5 ml-0.5 sm:ml-1">
|
|
{selectedVM.update_check.count}
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
)}
|
|
{/* Firewall tab — issue #14554 from the helper-scripts
|
|
discussions ("view individual VM/CT firewall logs").
|
|
Always rendered for VMs and CTs; if the guest doesn't
|
|
have firewall enabled in PVE, the panel shows a
|
|
callout explaining how to turn it on. Log fetched
|
|
lazily on first click to avoid hitting pvesh on
|
|
every modal open. */}
|
|
{selectedVM && (
|
|
<button
|
|
onClick={() => {
|
|
setActiveModalTab("firewall")
|
|
fetchFirewallLog(selectedVM.vmid)
|
|
}}
|
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
|
|
activeModalTab === "firewall"
|
|
? "border-orange-500 text-orange-500"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<Shield className="h-4 w-4" />
|
|
<span className={activeModalTab === "firewall" ? "" : "hidden sm:inline"}>
|
|
{t("vmLxc.tabs.firewall")}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
|
{/* Status Tab */}
|
|
{activeModalTab === "status" && (
|
|
<div className="space-y-4">
|
|
{selectedVM && (
|
|
<>
|
|
<div key={`metrics-${selectedVM.vmid}`}>
|
|
<Card
|
|
className="cursor-pointer rounded-lg border border-black/10 dark:border-white/10 sm:border-border max-sm:bg-black/5 max-sm:dark:bg-white/5 sm:bg-card sm:hover:bg-black/5 sm:dark:hover:bg-white/5 transition-colors group"
|
|
onClick={handleMetricsClick}
|
|
>
|
|
<CardContent className="p-4">
|
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{/* CPU Usage */}
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
|
<Cpu className="h-3.5 w-3.5" />
|
|
<span>{t("vmLxc.cpuUsage")}</span>
|
|
{vmDetails?.config?.cores && (
|
|
<span className="text-muted-foreground/60">
|
|
({formatCoreCount(Number(vmDetails.config.cores))})
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className={`text-base font-semibold mb-2 ${getUsageColor(selectedVM.cpu * 100)}`}>
|
|
{(selectedVM.cpu * 100).toFixed(1)}%
|
|
</div>
|
|
<Progress
|
|
value={selectedVM.cpu * 100}
|
|
className={`h-2 max-sm:bg-background sm:group-hover:bg-background/50 transition-colors ${getModalProgressColor(selectedVM.cpu * 100)}`}
|
|
/>
|
|
</div>
|
|
|
|
{/* Memory */}
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
|
<MemoryStick className="h-3.5 w-3.5" />
|
|
<span>{t("vmLxc.memory")}</span>
|
|
</div>
|
|
<div
|
|
className={`text-base font-semibold mb-2 ${getUsageColor((selectedVM.mem / selectedVM.maxmem) * 100)}`}
|
|
>
|
|
{(selectedVM.mem / 1024 ** 3).toFixed(1)} /{" "}
|
|
{(selectedVM.maxmem / 1024 ** 3).toFixed(1)} GB
|
|
</div>
|
|
<Progress
|
|
value={(selectedVM.mem / selectedVM.maxmem) * 100}
|
|
className={`h-2 max-sm:bg-background sm:group-hover:bg-background/50 transition-colors ${getModalProgressColor((selectedVM.mem / selectedVM.maxmem) * 100)}`}
|
|
/>
|
|
</div>
|
|
|
|
{/* Disk */}
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
|
<HardDrive className="h-3.5 w-3.5" />
|
|
<span>{t("vmLxc.disk")}</span>
|
|
</div>
|
|
<div
|
|
className={`text-base font-semibold mb-2 ${getUsageColor((selectedVM.disk / selectedVM.maxdisk) * 100)}`}
|
|
>
|
|
{(selectedVM.disk / 1024 ** 3).toFixed(1)} /{" "}
|
|
{(selectedVM.maxdisk / 1024 ** 3).toFixed(1)} GB
|
|
</div>
|
|
<Progress
|
|
value={(selectedVM.disk / selectedVM.maxdisk) * 100}
|
|
className={`h-2 max-sm:bg-background sm:group-hover:bg-background/50 transition-colors ${getModalProgressColor((selectedVM.disk / selectedVM.maxdisk) * 100)}`}
|
|
/>
|
|
</div>
|
|
|
|
{/* Disk I/O — cumulative counters from Proxmox
|
|
API: bytes read/written since the VM/LXC
|
|
was last started, not a per-period rate. */}
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
|
<HardDrive className="h-3.5 w-3.5" />
|
|
<span>{t("vmLxc.diskIo")} <span className="text-[10px] opacity-70">({t("vmLxc.sinceBoot")})</span></span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="text-sm text-green-500 flex items-center gap-1">
|
|
<span>↓</span>
|
|
<span>{((selectedVM.diskread || 0) / 1024 ** 2).toFixed(2)} MB</span>
|
|
</div>
|
|
<div className="text-sm text-blue-500 flex items-center gap-1">
|
|
<span>↑</span>
|
|
<span>{((selectedVM.diskwrite || 0) / 1024 ** 2).toFixed(2)} MB</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Network I/O — cumulative counters from
|
|
Proxmox API: bytes in/out since the VM/LXC
|
|
was last started. */}
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
|
<Network className="h-3.5 w-3.5" />
|
|
<span>{t("vmLxc.networkIo")} <span className="text-[10px] opacity-70">({t("vmLxc.sinceBoot")})</span></span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="text-sm text-green-500 flex items-center gap-1">
|
|
<span>↓</span>
|
|
<span>{formatNetworkTraffic(selectedVM.netin || 0, networkUnit)}</span>
|
|
</div>
|
|
<div className="text-sm text-blue-500 flex items-center gap-1">
|
|
<span>↑</span>
|
|
<span>{formatNetworkTraffic(selectedVM.netout || 0, networkUnit)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center">
|
|
{getOSIcon(vmDetails?.os_info, selectedVM.type)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{detailsLoading ? (
|
|
<div className="text-center py-8 text-muted-foreground">{t("vmLxc.loadingConfiguration")}</div>
|
|
) : vmDetails?.config ? (
|
|
<>
|
|
<Card className="border border-border bg-card/50" key={`config-${selectedVM.vmid}`}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-1.5 rounded-md bg-blue-500/10">
|
|
<Cpu className="h-4 w-4 text-blue-500" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.resources")}</h3>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowNotes(!showNotes)}
|
|
className="text-xs max-sm:bg-black/5 max-sm:dark:bg-white/5 sm:bg-transparent sm:hover:bg-black/5 sm:dark:hover:bg-white/5"
|
|
>
|
|
{showNotes ? (
|
|
<>
|
|
<ChevronUp className="h-3 w-3 mr-1" />
|
|
{t("vmLxc.hideNotes")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown className="h-3 w-3 mr-1" />
|
|
{t("vmLxc.notes")}
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowAdditionalInfo(!showAdditionalInfo)}
|
|
className="text-xs max-sm:bg-black/5 max-sm:dark:bg-white/5 sm:bg-transparent sm:hover:bg-black/5 sm:dark:hover:bg-white/5"
|
|
>
|
|
{showAdditionalInfo ? (
|
|
<>
|
|
<ChevronUp className="h-3 w-3 mr-1" />
|
|
{t("vmLxc.lessInfo")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown className="h-3 w-3 mr-1" />
|
|
+ {t("vmLxc.info")}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 lg:grid-cols-4 gap-3 lg:gap-4">
|
|
{vmDetails.config.cores && (
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
|
<Cpu className="h-3.5 w-3.5" />
|
|
<span>{t("vmLxc.cpuCores")}</span>
|
|
</div>
|
|
<div className="font-semibold text-blue-500">{vmDetails.config.cores}</div>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.memory && (
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
|
<MemoryStick className="h-3.5 w-3.5" />
|
|
<span>{t("vmLxc.memory")}</span>
|
|
</div>
|
|
<div className="font-semibold text-blue-500">{vmDetails.config.memory} MB</div>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.swap !== undefined && (
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
<span>{t("vmLxc.details.swap")}</span>
|
|
</div>
|
|
<div className="font-semibold text-foreground">{vmDetails.config.swap} MB</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* IP Addresses with proper keys */}
|
|
{selectedVM?.type === "lxc" && vmDetails?.lxc_ip_info && (
|
|
<div className="mt-4 lg:mt-6 pt-4 lg:pt-6 border-t border-border">
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
|
<Network className="h-4 w-4" />
|
|
{t("vmLxc.ipAddresses")}
|
|
</h4>
|
|
<div className="flex flex-wrap gap-2">
|
|
{vmDetails.lxc_ip_info.real_ips.map((ip, index) => (
|
|
<Badge
|
|
key={`real-ip-${selectedVM.vmid}-${ip.replace(/[.:/]/g, "-")}-${index}`}
|
|
variant="outline"
|
|
className="bg-green-500/10 text-green-500 border-green-500/20"
|
|
>
|
|
{ip}
|
|
</Badge>
|
|
))}
|
|
{vmDetails.lxc_ip_info.docker_ips.map((ip, index) => (
|
|
<Badge
|
|
key={`docker-ip-${selectedVM.vmid}-${ip.replace(/[.:/]/g, "-")}-${index}`}
|
|
variant="outline"
|
|
className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
|
>
|
|
{ip} ({t("vmLxc.details.bridge")})
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showNotes && (
|
|
<div className="mt-6 pt-6 border-t border-border">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
|
{t("vmLxc.notes")}
|
|
</h4>
|
|
{!isEditingNotes && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleEditNotes}
|
|
className="text-xs bg-transparent"
|
|
>
|
|
{t("vmLxc.editNotes")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="bg-muted/50 p-4 rounded-lg">
|
|
{isEditingNotes ? (
|
|
<div className="space-y-3">
|
|
<textarea
|
|
value={editedNotes}
|
|
onChange={(e) => setEditedNotes(e.target.value)}
|
|
className="w-full min-h-[200px] p-3 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
|
placeholder={t("vmLxc.enterNotes")}
|
|
/>
|
|
<div className="flex gap-2 justify-end">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleCancelEditNotes}
|
|
disabled={savingNotes}
|
|
>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSaveNotes}
|
|
disabled={savingNotes}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{savingNotes ? t("vmLxc.savingNotes") : t("actions.save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : vmDetails.config.description ? (
|
|
<>
|
|
{(() => {
|
|
// VM/CT notes come in two flavours and we mirror the way
|
|
// the PVE web UI handles each:
|
|
// • HTML (ProxMenux/community-script helper output with
|
|
// <div align='center'>, tables, logos) → render the
|
|
// HTML verbatim. The stable `main` branch did exactly
|
|
// this with dangerouslySetInnerHTML — we keep that
|
|
// behaviour but pipe through DOMPurify so the audit
|
|
// Tier 2 #13 XSS sink stays closed.
|
|
// • Plain text / markdown (e.g. qBittorrent's
|
|
// `## qBittorrent LXC`) → marked turns it into
|
|
// headings + autolinks + line breaks, matching PVE.
|
|
// Mixing the two paths breaks the HTML one because marked
|
|
// collapses indentation / wraps inline runs and the
|
|
// browser then ignores `align="center"`.
|
|
let decoded: string
|
|
try {
|
|
decoded = decodeRecursively(vmDetails.config.description)
|
|
} catch {
|
|
return (
|
|
<div className="text-sm text-red-500">
|
|
{t("vmLxc.notesDecodeError")}
|
|
</div>
|
|
)
|
|
}
|
|
const looksLikeHtml = /<\/?[a-z][\s\S]*?>/i.test(decoded)
|
|
let html: string
|
|
if (looksLikeHtml) {
|
|
html = decoded
|
|
} else {
|
|
try {
|
|
html = marked.parse(decoded, {
|
|
breaks: true,
|
|
gfm: true,
|
|
async: false,
|
|
}) as string
|
|
} catch {
|
|
html = decoded.replace(/\n/g, "<br>")
|
|
}
|
|
}
|
|
// Promote legacy `align` HTML attribute to a real inline
|
|
// `style="text-align: …"` rule. Tailwind / parent CSS,
|
|
// styled-jsx scoping quirks and Safari's UA stylesheet
|
|
// can all swallow the bare `align` attribute on `<div>`
|
|
// (it's HTML4 obsolete syntax). An inline style is
|
|
// bullet-proof: highest specificity, no scope hash needed.
|
|
DOMPurify.removeHook("afterSanitizeAttributes")
|
|
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
|
|
const a = node.getAttribute?.("align")
|
|
if (a && /^(center|left|right)$/i.test(a)) {
|
|
const cur = node.getAttribute("style") || ""
|
|
const sep = cur && !cur.trim().endsWith(";") ? "; " : ""
|
|
node.setAttribute(
|
|
"style",
|
|
`${cur}${sep}text-align: ${a.toLowerCase()}`,
|
|
)
|
|
}
|
|
// Force `target=_blank` links to open in a new tab
|
|
// safely (noopener prevents reverse-tabnabbing).
|
|
if (node.tagName === "A" && node.getAttribute("target") === "_blank") {
|
|
node.setAttribute("rel", "noopener noreferrer")
|
|
}
|
|
})
|
|
const cleanHtml = DOMPurify.sanitize(html, {
|
|
ALLOWED_TAGS: [
|
|
"a", "p", "br", "div", "span",
|
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
"img",
|
|
"table", "thead", "tbody", "tr", "th", "td",
|
|
"ul", "ol", "li",
|
|
"strong", "em", "b", "i", "u", "code", "pre",
|
|
"blockquote", "hr",
|
|
"small", "sub", "sup",
|
|
],
|
|
ALLOWED_ATTR: [
|
|
"href", "src", "alt", "title", "target",
|
|
"rel", "style", "class",
|
|
"align", "width", "height",
|
|
"colspan", "rowspan",
|
|
],
|
|
ALLOWED_URI_REGEXP:
|
|
/^(?:(?:https?|mailto|data:image\/(?:png|jpeg|jpg|gif|svg\+xml|webp)):|\/|#)/i,
|
|
ADD_ATTR: ["target"],
|
|
})
|
|
return (
|
|
<div
|
|
className="text-sm text-foreground proxmenux-notes break-words"
|
|
// eslint-disable-next-line react/no-danger
|
|
dangerouslySetInnerHTML={{ __html: cleanHtml }}
|
|
/>
|
|
)
|
|
})()}
|
|
</>
|
|
) : (
|
|
<div className="text-sm text-muted-foreground italic">
|
|
{t("vmLxc.noNotes")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showAdditionalInfo && (
|
|
<div className="mt-6 pt-6 border-t border-border space-y-6">
|
|
{selectedVM?.type === "lxc" && vmDetails?.hardware_info && (
|
|
<div>
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
|
<Container className="h-4 w-4" />
|
|
{t("vmLxc.containerConfiguration")}
|
|
</h4>
|
|
<div className="space-y-4">
|
|
{/* Privileged Status */}
|
|
{vmDetails.hardware_info.privileged !== null &&
|
|
vmDetails.hardware_info.privileged !== undefined && (
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
|
<Shield className="h-3.5 w-3.5" />
|
|
<span>{t("vmLxc.details.privilegeLevel")}</span>
|
|
</div>
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
vmDetails.hardware_info.privileged
|
|
? "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
|
: "bg-green-500/10 text-green-500 border-green-500/20"
|
|
}
|
|
>
|
|
{vmDetails.hardware_info.privileged ? t("vmLxc.details.privileged") : t("vmLxc.details.unprivileged")}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
|
|
{/* GPU Passthrough with proper keys */}
|
|
{vmDetails.hardware_info.gpu_passthrough &&
|
|
vmDetails.hardware_info.gpu_passthrough.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
|
<Cpu className="h-3.5 w-3.5" />
|
|
<span>{t("vmLxc.details.gpuPassthrough")}</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{vmDetails.hardware_info.gpu_passthrough.map((gpu, index) => (
|
|
<Badge
|
|
key={`gpu-${selectedVM.vmid}-${index}-${gpu.replace(/[^a-zA-Z0-9]/g, "-").substring(0, 30)}`}
|
|
variant="outline"
|
|
className={
|
|
gpu.includes("NVIDIA")
|
|
? "bg-green-500/10 text-green-500 border-green-500/20"
|
|
: "bg-purple-500/10 text-purple-500 border-purple-500/20"
|
|
}
|
|
>
|
|
{gpu}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hardware Devices with proper keys */}
|
|
{vmDetails.hardware_info.devices &&
|
|
vmDetails.hardware_info.devices.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
|
<Server className="h-3.5 w-3.5" />
|
|
<span>{t("vmLxc.details.hardwareDevices")}</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{vmDetails.hardware_info.devices.map((device, index) => (
|
|
<Badge
|
|
key={`device-${selectedVM.vmid}-${index}-${device.replace(/[^a-zA-Z0-9]/g, "-").substring(0, 30)}`}
|
|
variant="outline"
|
|
className="bg-blue-500/10 text-blue-500 border-blue-500/20"
|
|
>
|
|
{device}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hardware Section */}
|
|
<div>
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
|
<Settings2 className="h-4 w-4" />
|
|
{t("vmLxc.details.hardware")}
|
|
</h4>
|
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{vmDetails.config.sockets && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.cpuSockets")}</div>
|
|
<div className="font-medium text-foreground">{vmDetails.config.sockets}</div>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.cpu && (
|
|
<div className="col-span-2">
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.cpuType")}</div>
|
|
<div className="font-medium text-foreground text-sm font-mono">
|
|
{vmDetails.config.cpu}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.numa !== undefined && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.numa")}</div>
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
vmDetails.config.numa
|
|
? "bg-green-500/10 text-green-500 border-green-500/20"
|
|
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
|
}
|
|
>
|
|
{vmDetails.config.numa ? t("vmLxc.details.enabled") : t("vmLxc.details.disabled")}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.bios && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.bios")}</div>
|
|
<div className="font-medium text-foreground">{vmDetails.config.bios}</div>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.machine && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.machineType")}</div>
|
|
<div className="font-medium text-foreground">{vmDetails.config.machine}</div>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.vga && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.vga")}</div>
|
|
<div className="font-medium text-foreground">{vmDetails.config.vga}</div>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.agent !== undefined && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.qemuAgent")}</div>
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
vmDetails.config.agent
|
|
? "bg-green-500/10 text-green-500 border-green-500/20"
|
|
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
|
}
|
|
>
|
|
{vmDetails.config.agent ? t("vmLxc.details.enabled") : t("vmLxc.details.disabled")}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.tablet !== undefined && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.tabletPointer")}</div>
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
vmDetails.config.tablet
|
|
? "bg-green-500/10 text-green-500 border-green-500/20"
|
|
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
|
}
|
|
>
|
|
{vmDetails.config.tablet ? t("vmLxc.details.enabled") : t("vmLxc.details.disabled")}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.localtime !== undefined && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.localTime")}</div>
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
vmDetails.config.localtime
|
|
? "bg-green-500/10 text-green-500 border-green-500/20"
|
|
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
|
}
|
|
>
|
|
{vmDetails.config.localtime ? t("vmLxc.details.enabled") : t("vmLxc.details.disabled")}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Storage Section — human-readable breakdown
|
|
per disk plus the raw config string in a
|
|
collapsible details block, mirroring the
|
|
Network section. */}
|
|
{(() => {
|
|
// Parse a Proxmox disk config string into
|
|
// { storage, volume, path, options }
|
|
// Handles both LVM-style volumes
|
|
// "local-lvm:vm-101-disk-0,size=6G" and
|
|
// passthrough paths "/dev/disk/by-id/...".
|
|
const parseDisk = (raw: string) => {
|
|
const parts = raw.split(",")
|
|
const first = parts[0] || ""
|
|
const options: Record<string, string> = {}
|
|
parts.slice(1).forEach((p) => {
|
|
const eq = p.indexOf("=")
|
|
if (eq > 0) {
|
|
options[p.slice(0, eq).trim()] = p.slice(eq + 1).trim()
|
|
}
|
|
})
|
|
let storage = "", volume = "", path = ""
|
|
if (first.startsWith("/")) {
|
|
path = first
|
|
} else if (first.includes(":")) {
|
|
const [s, v] = first.split(":")
|
|
storage = s
|
|
volume = v
|
|
} else {
|
|
volume = first
|
|
}
|
|
return { storage, volume, path, options }
|
|
}
|
|
// Convert Proxmox size strings ("6G",
|
|
// "3907018584K", "40G", "4M") to a
|
|
// consistent GB/TB display.
|
|
const humanSize = (s: string): string => {
|
|
if (!s) return ""
|
|
const m = s.match(/^(\d+(?:\.\d+)?)([KMGT])?$/i)
|
|
if (!m) return s
|
|
const n = parseFloat(m[1])
|
|
const unit = (m[2] || "").toUpperCase()
|
|
const bytes =
|
|
unit === "K" ? n * 1024 :
|
|
unit === "M" ? n * 1024 ** 2 :
|
|
unit === "G" ? n * 1024 ** 3 :
|
|
unit === "T" ? n * 1024 ** 4 : n
|
|
if (bytes >= 1024 ** 4) return `${(bytes / 1024 ** 4).toFixed(2)} TB`
|
|
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(bytes < 10 * 1024 ** 3 ? 1 : 0)} GB`
|
|
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(0)} MB`
|
|
return s
|
|
}
|
|
const DField = ({ label, value, mono, className }:
|
|
{ label: string; value: string; mono?: boolean; className?: string }) => (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
|
<span className={`text-foreground ${mono ? "font-mono text-xs" : "text-sm"} ${className || ""}`}>{value}</span>
|
|
</div>
|
|
)
|
|
const renderDisk = (label: string, raw: string, keyId: string) => {
|
|
const d = parseDisk(raw)
|
|
return (
|
|
<div key={keyId} className="bg-muted/30 rounded-md p-3 space-y-3">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<HardDrive className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
|
<span className="text-sm font-semibold text-foreground">{label}</span>
|
|
{d.storage && (
|
|
<span className="text-xs text-orange-500 font-mono">
|
|
{d.storage}
|
|
</span>
|
|
)}
|
|
{d.options.size && (
|
|
<span className="text-xs text-cyan-500 font-mono">
|
|
{humanSize(d.options.size)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2">
|
|
{d.volume && <DField label={t("vmLxc.details.volume")} value={d.volume} mono />}
|
|
{d.path && <DField label={t("vmLxc.details.path")} value={d.path} mono className="break-all" />}
|
|
{d.options.ssd === "1" && <DField label={t("vmLxc.details.media")} value="SSD" />}
|
|
{d.options.discard && <DField label={t("vmLxc.details.discard")} value={d.options.discard} />}
|
|
{d.options.iothread === "1" && <DField label="IOThread" value={t("vmLxc.details.on")} />}
|
|
{d.options.cache && <DField label={t("vmLxc.details.cache")} value={d.options.cache} />}
|
|
{d.options.aio && <DField label="AIO" value={d.options.aio} />}
|
|
{d.options.backup === "0" && <DField label={t("vmLxc.details.backup")} value={t("vmLxc.details.excluded")} className="text-red-500" />}
|
|
{d.options.backup === "1" && <DField label={t("vmLxc.details.backup")} value={t("vmLxc.details.included")} />}
|
|
{d.options.replicate === "0" && <DField label={t("vmLxc.details.replicate")} value={t("vmLxc.details.off")} />}
|
|
{d.options.efitype && <DField label={t("vmLxc.details.efiType")} value={d.options.efitype} />}
|
|
{d.options.pre_enrolled_keys && <DField label={t("vmLxc.details.preEnrolledKeys")} value={d.options.pre_enrolled_keys} />}
|
|
{d.options.serial && <DField label={t("vmLxc.details.serial")} value={d.options.serial} mono />}
|
|
{d.options.mp && <DField label={t("vmLxc.details.mountPoint")} value={d.options.mp} mono />}
|
|
{d.options.acl && <DField label="ACL" value={d.options.acl} />}
|
|
</div>
|
|
<details className="text-xs">
|
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">{t("vmLxc.details.rawConfig")}</summary>
|
|
<div className="mt-1 font-mono text-foreground break-all bg-background/50 p-2 rounded">
|
|
{raw}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<div>
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
|
<HardDrive className="h-4 w-4" />
|
|
{t("vmLxc.details.storage")}
|
|
</h4>
|
|
<div className="space-y-3">
|
|
{vmDetails.config.scsihw && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="text-xs uppercase tracking-wide text-muted-foreground">{t("vmLxc.details.scsiController")}:</span>
|
|
<span className="font-mono text-foreground">{vmDetails.config.scsihw}</span>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.rootfs && renderDisk(t("vmLxc.details.rootFilesystem"), vmDetails.config.rootfs as string, "rootfs")}
|
|
{Object.keys(vmDetails.config)
|
|
.filter((key) => key.match(/^(scsi|sata|ide|virtio)\d+$/))
|
|
.sort()
|
|
.map((diskKey) => renderDisk(
|
|
diskKey.toUpperCase().replace(/(\d+)/, " $1"),
|
|
vmDetails.config[diskKey] as string,
|
|
`disk-${selectedVM.vmid}-${diskKey}`,
|
|
))}
|
|
{vmDetails.config.efidisk0 && renderDisk(t("vmLxc.details.efiDisk"), vmDetails.config.efidisk0 as string, "efidisk0")}
|
|
{vmDetails.config.tpmstate0 && renderDisk(t("vmLxc.details.tpmState"), vmDetails.config.tpmstate0 as string, "tpmstate0")}
|
|
{Object.keys(vmDetails.config)
|
|
.filter((key) => key.match(/^mp\d+$/))
|
|
.sort()
|
|
.map((mpKey) => renderDisk(
|
|
t("vmLxc.details.mountPointIndexed", { index: mpKey.replace("mp", "") }),
|
|
vmDetails.config[mpKey] as string,
|
|
`mp-${selectedVM.vmid}-${mpKey}`,
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{/* Network Section */}
|
|
<div>
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
|
<Network className="h-4 w-4" />
|
|
{t("vmLxc.details.network")}
|
|
</h4>
|
|
<div className="space-y-3">
|
|
{/* Network Interfaces with proper keys.
|
|
Renders BOTH a human-readable
|
|
breakdown (bridge, IP, gw, MAC,
|
|
host iface) AND the raw config
|
|
string so power users still see
|
|
the underlying Proxmox config. */}
|
|
{Object.keys(vmDetails.config)
|
|
.filter((key) => key.match(/^net\d+$/))
|
|
.map((netKey) => {
|
|
const raw = vmDetails.config[netKey] as string
|
|
// Parse "name=eth0,bridge=vmbr0,gw=1.2.3.4,..."
|
|
const parsed: Record<string, string> = {}
|
|
raw.split(",").forEach((pair) => {
|
|
const eq = pair.indexOf("=")
|
|
if (eq > 0) {
|
|
parsed[pair.slice(0, eq).trim()] =
|
|
pair.slice(eq + 1).trim()
|
|
} else if (pair && !parsed.model) {
|
|
// bare "virtio" / "e1000" → NIC model
|
|
parsed.model = pair.trim()
|
|
}
|
|
})
|
|
const idx = netKey.replace("net", "")
|
|
// For VMs, the host-side iface is
|
|
// tap<vmid>i<idx>; for LXC it's
|
|
// veth<vmid>i<idx>. Surface it so
|
|
// users can correlate with the
|
|
// "VM & LXC Network Interfaces"
|
|
// card on the network page.
|
|
const hostIface = selectedVM.type === "lxc"
|
|
? `veth${selectedVM.vmid}i${idx}`
|
|
: `tap${selectedVM.vmid}i${idx}`
|
|
const Field = ({ label, value, mono }:
|
|
{ label: string; value: string; mono?: boolean }) => (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
|
<span className={`text-foreground ${mono ? "font-mono text-xs" : "text-sm"}`}>{value}</span>
|
|
</div>
|
|
)
|
|
return (
|
|
<div key={`net-${selectedVM.vmid}-${netKey}`}
|
|
className="bg-muted/30 rounded-md p-3 space-y-3">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<EthernetPort className="h-4 w-4 text-green-500 flex-shrink-0" />
|
|
<span className="text-sm font-semibold text-foreground">
|
|
{t("vmLxc.details.networkInterface", { index: idx })}
|
|
</span>
|
|
{parsed.name && (
|
|
<span className="text-xs text-muted-foreground font-mono">
|
|
({parsed.name})
|
|
</span>
|
|
)}
|
|
<span className="text-xs text-orange-500 font-mono">
|
|
{t("vmLxc.details.hostInterface", { interface: hostIface })}
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2">
|
|
{parsed.bridge && <Field label={t("vmLxc.details.bridge")} value={parsed.bridge} mono />}
|
|
{parsed.ip && <Field label="IP" value={parsed.ip} mono />}
|
|
{parsed.ip6 && <Field label="IPv6" value={parsed.ip6} mono />}
|
|
{parsed.gw && <Field label={t("vmLxc.details.gateway")} value={parsed.gw} mono />}
|
|
{parsed.gw6 && <Field label={t("vmLxc.details.gatewayV6")} value={parsed.gw6} mono />}
|
|
{parsed.hwaddr && <Field label="MAC" value={parsed.hwaddr.toUpperCase()} mono />}
|
|
{parsed.virtio && <Field label="MAC" value={parsed.virtio.toUpperCase()} mono />}
|
|
{parsed.e1000 && <Field label="MAC" value={parsed.e1000.toUpperCase()} mono />}
|
|
{parsed.type && <Field label={t("vmLxc.details.type")} value={parsed.type} mono />}
|
|
{parsed.tag && <Field label="VLAN" value={parsed.tag} mono />}
|
|
{parsed.mtu && <Field label="MTU" value={parsed.mtu} mono />}
|
|
{parsed.rate && <Field label={t("vmLxc.details.rateLimit")} value={`${parsed.rate} MB/s`} mono />}
|
|
{parsed.firewall === "1" && <Field label={t("vmLxc.details.firewall")} value={t("vmLxc.details.enabled")} />}
|
|
</div>
|
|
<details className="text-xs">
|
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">{t("vmLxc.details.rawConfig")}</summary>
|
|
<div className="mt-1 font-mono text-green-500 break-all bg-background/50 p-2 rounded">
|
|
{raw}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
)
|
|
})}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
{vmDetails.config.nameserver && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.dnsNameserver")}</div>
|
|
<div className="font-medium text-foreground font-mono">
|
|
{vmDetails.config.nameserver}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.searchdomain && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.searchDomain")}</div>
|
|
<div className="font-medium text-foreground">
|
|
{vmDetails.config.searchdomain}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{vmDetails.config.hostname && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">{t("vmLxc.details.hostname")}</div>
|
|
<div className="font-medium text-foreground">
|
|
{vmDetails.config.hostname}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* PCI Devices with proper keys */}
|
|
{Object.keys(vmDetails.config).some((key) => key.match(/^hostpci\d+$/)) && (
|
|
<div>
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
|
<Cpu className="h-4 w-4" />
|
|
{t("vmLxc.details.pciPassthrough")}
|
|
</h4>
|
|
<div className="space-y-3">
|
|
{Object.keys(vmDetails.config)
|
|
.filter((key) => key.match(/^hostpci\d+$/))
|
|
.map((pciKey) => (
|
|
<div key={`pci-${selectedVM.vmid}-${pciKey}`}>
|
|
<div className="text-xs text-muted-foreground mb-1">
|
|
{pciKey.toUpperCase().replace(/(\d+)/, " $1")}
|
|
</div>
|
|
<div className="font-medium text-purple-500 text-sm break-all font-mono bg-muted/50 p-2 rounded">
|
|
{vmDetails.config[pciKey]}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* USB Devices with proper keys */}
|
|
{Object.keys(vmDetails.config).some((key) => key.match(/^usb\d+$/)) && (
|
|
<div>
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
|
<Server className="h-4 w-4" />
|
|
{t("vmLxc.details.usbDevices")}
|
|
</h4>
|
|
<div className="space-y-3">
|
|
{Object.keys(vmDetails.config)
|
|
.filter((key) => key.match(/^usb\d+$/))
|
|
.map((usbKey) => (
|
|
<div key={`usb-${selectedVM.vmid}-${usbKey}`}>
|
|
<div className="text-xs text-muted-foreground mb-1">
|
|
{usbKey.toUpperCase().replace(/(\d+)/, " $1")}
|
|
</div>
|
|
<div className="font-medium text-blue-500 text-sm break-all font-mono bg-muted/50 p-2 rounded">
|
|
{vmDetails.config[usbKey]}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Serial Ports with proper keys */}
|
|
{Object.keys(vmDetails.config).some((key) => key.match(/^serial\d+$/)) && (
|
|
<div>
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
|
<Terminal className="h-4 w-4" />
|
|
{t("vmLxc.details.serialPorts")}
|
|
</h4>
|
|
<div className="space-y-3">
|
|
{Object.keys(vmDetails.config)
|
|
.filter((key) => key.match(/^serial\d+$/))
|
|
.map((serialKey) => (
|
|
<div key={`serial-${selectedVM.vmid}-${serialKey}`}>
|
|
<div className="text-xs text-muted-foreground mb-1">
|
|
{serialKey.toUpperCase().replace(/(\d+)/, " $1")}
|
|
</div>
|
|
<div className="font-medium text-foreground font-mono">
|
|
{vmDetails.config[serialKey]}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Updates Tab — LXC only, conditionally rendered.
|
|
Lives in its own tab so the per-package list (up to
|
|
30 rows) doesn't blow up the Status tab on mobile.
|
|
Violet matches the shared "managed updates" theme. */}
|
|
{activeModalTab === "updates" &&
|
|
selectedVM?.type === "lxc" &&
|
|
selectedVM?.update_check?.available && (
|
|
<div className="space-y-4" key={`updates-${selectedVM.vmid}`}>
|
|
<Card className="border border-border bg-card/50">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-1.5 rounded-md bg-violet-500/10">
|
|
<Package className="h-4 w-4 text-violet-400" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-foreground">
|
|
{t("vmLxc.updatesPanel.pendingTitle")}
|
|
</h3>
|
|
</div>
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs bg-violet-500/10 text-violet-400 border-violet-500/30"
|
|
>
|
|
{t("vmLxc.updatesPanel.total", { count: selectedVM.update_check.count })}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
|
{t("vmLxc.updatesPanel.lastChecked")}{" "}
|
|
{selectedVM.update_check.last_check
|
|
? new Date(selectedVM.update_check.last_check).toLocaleString()
|
|
: "—"}
|
|
{" · "}{t("vmLxc.updatesPanel.applyWith")}{" "}
|
|
<code className="text-foreground/80">pct enter {selectedVM.vmid}</code>
|
|
{" → "}
|
|
<code className="text-foreground/80">apt update && apt upgrade</code>
|
|
</div>
|
|
{/* Two render modes:
|
|
• Full list when every pending package fits
|
|
(registry cap is 30 packages per CT — so
|
|
CTs with ≤30 updates show every row).
|
|
• Summary when the CT has more pending than
|
|
the registry stored. Showing 30 random
|
|
rows out of 139 misleads the user — a
|
|
count + security count + "inspect inside"
|
|
hint is honester. */}
|
|
{(() => {
|
|
const stored = selectedVM.update_check.packages?.length || 0
|
|
const total = selectedVM.update_check.count || 0
|
|
const sec = selectedVM.update_check.security_count || 0
|
|
const truncated = total > stored
|
|
if (!truncated && stored > 0) {
|
|
return (
|
|
<div className="border-t border-border divide-y divide-border/50">
|
|
{selectedVM.update_check.packages.map((p) => (
|
|
<div
|
|
key={p.name}
|
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-0.5 sm:gap-2 py-2 text-sm"
|
|
>
|
|
<span className="font-mono text-foreground/90 flex items-center gap-2 min-w-0">
|
|
{p.security && (
|
|
<Shield
|
|
className="h-4 w-4 text-green-500 flex-shrink-0"
|
|
aria-label={t("vmLxc.updatesPanel.securityUpdate")}
|
|
/>
|
|
)}
|
|
<span className="truncate">{p.name}</span>
|
|
</span>
|
|
<span className="flex items-center gap-1.5 text-muted-foreground flex-shrink-0 font-mono text-xs sm:text-sm">
|
|
<span>{p.current || "—"}</span>
|
|
<span>→</span>
|
|
<span className="text-foreground">{p.latest}</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
// Truncated OR no per-package detail — render a summary.
|
|
return (
|
|
<div className="border-t border-border pt-3 space-y-2 text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<Package className="h-4 w-4 text-violet-400 flex-shrink-0" />
|
|
<span>
|
|
{t(total === 1 ? "vmLxc.updatesPanel.packagePending" : "vmLxc.updatesPanel.packagesPending", { count: total })}
|
|
</span>
|
|
</div>
|
|
{sec > 0 && (
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-4 w-4 text-green-500 flex-shrink-0" />
|
|
<span>
|
|
{t(sec === 1 ? "vmLxc.updatesPanel.securityUpdatePending" : "vmLxc.updatesPanel.securityUpdatesPending", { count: sec })}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="text-xs text-muted-foreground pt-1 leading-relaxed">
|
|
{t("vmLxc.updatesPanel.fullListInside")}{" "}
|
|
<code className="text-foreground/80">
|
|
pct enter {selectedVM.vmid}
|
|
</code>{" "}
|
|
→{" "}
|
|
<code className="text-foreground/80">apt list --upgradable</code>
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sprint 13.29: Mount Points Tab — LXC only.
|
|
Renders configured mpX entries first, then any
|
|
ad-hoc NFS/CIFS/SMB mounts found inside the
|
|
container. Capacity comes from the host-side
|
|
source (PVE storage or `df`) so it's available
|
|
even when the CT is stopped. */}
|
|
{activeModalTab === "mounts" && selectedVM?.type === "lxc" && (
|
|
<div className="space-y-4">
|
|
{loadingMounts ? (
|
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
|
{t("vmLxc.updatesPanel.loadingMountPoints")}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{mountPoints.map((mp) => (
|
|
<MountPointCard key={mp.mp_index || mp.target} mp={mp} />
|
|
))}
|
|
{adHocMounts.length > 0 && (
|
|
<>
|
|
<div className="text-sm font-semibold text-muted-foreground pt-2 border-t border-border">
|
|
{t("vmLxc.mountedFromContainer")}
|
|
</div>
|
|
{adHocMounts.map((mp) => (
|
|
<MountPointCard key={`adhoc-${mp.target}`} mp={mp} />
|
|
))}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Backups Tab */}
|
|
{activeModalTab === "backups" && (
|
|
<div className="space-y-4">
|
|
<Card className="border border-border bg-card/50">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-1.5 rounded-md bg-amber-500/10">
|
|
<Archive className="h-4 w-4 text-amber-500" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.backups.title")}</h3>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
className="h-7 text-xs bg-amber-600/20 border border-amber-600/50 text-amber-400 hover:bg-amber-600/30 gap-1"
|
|
onClick={openBackupModal}
|
|
disabled={creatingBackup}
|
|
>
|
|
{creatingBackup ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Plus className="h-3 w-3" />
|
|
)}
|
|
<span>{t("vmLxc.backups.create")}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="border-t border-border/50 mb-4" />
|
|
|
|
{/* Backup List */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
|
|
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
|
|
</div>
|
|
|
|
{loadingBackups ? (
|
|
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
<span className="text-sm">{t("vmLxc.backups.loading")}</span>
|
|
</div>
|
|
) : vmBackups.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
|
<Archive className="h-12 w-12 mb-3 opacity-30" />
|
|
<span className="text-sm">{t("vmLxc.backups.empty")}</span>
|
|
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{vmBackups.map((backup, index) => (
|
|
<div
|
|
key={`backup-${backup.volid}-${index}`}
|
|
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
|
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
<span className="text-sm text-foreground">{backup.date}</span>
|
|
<Badge
|
|
variant="outline"
|
|
className={`text-xs ml-auto flex-shrink-0 ${getStorageColor(backup.storage).bg} ${getStorageColor(backup.storage).text} ${getStorageColor(backup.storage).border}`}
|
|
>
|
|
{backup.storage}
|
|
</Badge>
|
|
</div>
|
|
<Badge variant="outline" className="font-mono ml-2 flex-shrink-0">
|
|
{backup.size_human}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Firewall Logs Tab — issue #14554. Reads the per-VM/CT
|
|
log filtered by PVE directly (no host-wide log
|
|
grep). Loading is lazy and triggered by the tab
|
|
button's onClick. */}
|
|
{activeModalTab === "firewall" && (
|
|
<div className="space-y-4">
|
|
<Card className="border border-border bg-card/50">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-1.5 rounded-md bg-orange-500/10">
|
|
<Shield className="h-4 w-4 text-orange-500" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.firewall.title")}</h3>
|
|
{firewallEnabled && displayedFirewallLogs.length > 0 && (
|
|
<Badge variant="secondary" className="text-xs h-5 ml-1">
|
|
{displayedFirewallLogs.length}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 text-xs gap-1"
|
|
onClick={() => selectedVM && fetchFirewallLog(selectedVM.vmid)}
|
|
disabled={loadingFirewallLog}
|
|
>
|
|
{loadingFirewallLog ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-3 w-3" />
|
|
)}
|
|
<span>{t("vmLxc.firewall.refresh")}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="border-t border-border/50 mb-4" />
|
|
|
|
{loadingFirewallLog ? (
|
|
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
<span className="text-sm">{t("vmLxc.firewall.loading")}</span>
|
|
</div>
|
|
) : !firewallEnabled ? (
|
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-4 text-sm">
|
|
<div className="flex items-start gap-2">
|
|
<Shield className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
|
<div className="space-y-2">
|
|
<p className="font-medium text-amber-500">
|
|
{t("vmLxc.firewall.disabledTitle", {
|
|
type: selectedVM?.type === "lxc" ? t("vmLxc.guestTypes.container") : t("vmLxc.guestTypes.vm"),
|
|
})}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
{t("vmLxc.firewall.disabledHint", {
|
|
type: selectedVM?.type === "lxc" ? t("vmLxc.guestTypes.containerUi") : t("vmLxc.guestTypes.vm"),
|
|
})}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : firewallLogError ? (
|
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm">
|
|
<div className="flex items-start gap-2">
|
|
<Shield className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="font-medium text-red-500 mb-1">{t("vmLxc.firewall.readFailed")}</p>
|
|
<p className="text-xs text-muted-foreground break-all">{firewallLogError}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : displayedFirewallLogs.length === 0 ? (
|
|
<div className="text-center py-6 text-sm text-muted-foreground">
|
|
{t("vmLxc.firewall.empty")}
|
|
<div className="text-xs mt-1">
|
|
{t("vmLxc.firewall.emptyHint")}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-md border border-border bg-background/50 max-h-[480px] overflow-y-auto">
|
|
<pre className="text-[11px] font-mono leading-snug whitespace-pre-wrap break-all p-3">
|
|
{displayedFirewallLogs.map((entry, idx) => {
|
|
const text = entry.t || ""
|
|
// Light colour-coding by the action keyword
|
|
// PVE emits in the line itself — purely
|
|
// visual, parsing stays line-by-line so
|
|
// a malformed entry still renders fine.
|
|
let actionClass = "text-foreground/90"
|
|
if (/\bDROP\b/i.test(text)) actionClass = "text-red-400"
|
|
else if (/\bREJECT\b/i.test(text)) actionClass = "text-orange-400"
|
|
else if (/\bACCEPT\b/i.test(text)) actionClass = "text-green-400"
|
|
return (
|
|
<div key={`${entry.n}-${idx}`} className={actionClass}>
|
|
{text}
|
|
</div>
|
|
)
|
|
})}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border bg-background px-6 py-4 mt-auto shrink-0">
|
|
{/* Terminal button for LXC containers - only when running */}
|
|
{selectedVM?.type === "lxc" && selectedVM?.status === "running" && (
|
|
<div className="mb-3">
|
|
<Button
|
|
className="w-full bg-zinc-600/20 border border-zinc-600/50 text-zinc-300 hover:bg-zinc-600/30"
|
|
onClick={() => selectedVM && openLxcTerminal(selectedVM.vmid, selectedVM.name)}
|
|
>
|
|
<Terminal className="h-4 w-4 mr-2" />
|
|
{t("vmLxc.openTerminal")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Button
|
|
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")}
|
|
>
|
|
<Play className="h-4 w-4 mr-2" />
|
|
{t("vmLxc.actions.start")}
|
|
</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")}
|
|
>
|
|
<Power className="h-4 w-4 mr-2" />
|
|
{t("vmLxc.actions.shutdown")}
|
|
</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 && setConfirmDestructive({
|
|
action: "reboot",
|
|
vmid: selectedVM.vmid,
|
|
vmName: selectedVM.name,
|
|
})}
|
|
>
|
|
<RotateCcw className="h-4 w-4 mr-2" />
|
|
{t("vmLxc.actions.reboot")}
|
|
</Button>
|
|
<Button
|
|
className="w-full bg-red-600/20 border border-red-600/50 text-red-400 hover:bg-red-600/30"
|
|
disabled={selectedVM?.status !== "running" || controlLoading}
|
|
onClick={() => selectedVM && setConfirmDestructive({
|
|
action: "stop",
|
|
vmid: selectedVM.vmid,
|
|
vmName: selectedVM.name,
|
|
})}
|
|
>
|
|
<StopCircle className="h-4 w-4 mr-2" />
|
|
{t("vmLxc.actions.forceStop")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
selectedVM && (
|
|
<MetricsView
|
|
vmid={selectedVM.vmid}
|
|
vmName={selectedVM.name}
|
|
vmType={selectedVM.type as "qemu" | "lxc"}
|
|
onBack={handleBackToMain}
|
|
/>
|
|
)
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Destructive control confirmation (Force Stop / Reboot) */}
|
|
<Dialog
|
|
open={confirmDestructive !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setConfirmDestructive(null)
|
|
setConfirmDestructiveTyped("")
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-[480px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-red-500">
|
|
<StopCircle className="h-5 w-5" />
|
|
{confirmDestructive?.action === "stop" ? t("vmLxc.confirm.forceStopTitle") : t("vmLxc.confirm.rebootTitle")}{" "}
|
|
VMID {confirmDestructive?.vmid}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{confirmDestructive?.action === "stop"
|
|
? t("vmLxc.confirm.forceStopDescription")
|
|
: t("vmLxc.confirm.rebootDescription")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3 py-2">
|
|
<p className="text-sm">
|
|
{t("vmLxc.confirm.typeToConfirm", { vmid: confirmDestructive?.vmid ?? "" })}
|
|
</p>
|
|
<input
|
|
type="text"
|
|
autoFocus
|
|
autoComplete="off"
|
|
inputMode="numeric"
|
|
value={confirmDestructiveTyped}
|
|
onChange={(e) => setConfirmDestructiveTyped(e.target.value)}
|
|
placeholder={String(confirmDestructive?.vmid ?? "")}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-red-500"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("vmLxc.confirm.guest")} <span className="font-medium">{confirmDestructive?.vmName}</span>
|
|
</p>
|
|
</div>
|
|
<DialogFooter className="gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setConfirmDestructive(null)
|
|
setConfirmDestructiveTyped("")
|
|
}}
|
|
disabled={controlLoading}
|
|
>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={
|
|
controlLoading ||
|
|
!confirmDestructive ||
|
|
confirmDestructiveTyped.trim() !== String(confirmDestructive.vmid)
|
|
}
|
|
onClick={async () => {
|
|
if (!confirmDestructive) return
|
|
const { vmid, action } = confirmDestructive
|
|
setConfirmDestructive(null)
|
|
setConfirmDestructiveTyped("")
|
|
await handleVMControl(vmid, action)
|
|
}}
|
|
>
|
|
{controlLoading
|
|
? t("vmLxc.actions.working")
|
|
: confirmDestructive?.action === "stop"
|
|
? t("vmLxc.actions.forceStop")
|
|
: t("vmLxc.actions.reboot")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Backup Configuration Modal */}
|
|
<Dialog open={showBackupModal} onOpenChange={setShowBackupModal}>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-amber-500">
|
|
<Archive className="h-5 w-5" />
|
|
{t("vmLxc.backupModal.title", {
|
|
type: selectedVM?.type?.toUpperCase() ?? "",
|
|
vmid: selectedVM?.vmid ?? "",
|
|
name: selectedVM?.name ?? "",
|
|
})}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("vmLxc.backupModal.description", {
|
|
type: selectedVM?.type === "lxc"
|
|
? t("vmLxc.backupModal.typeContainer")
|
|
: t("vmLxc.backupModal.typeVirtualMachine"),
|
|
})}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-4 py-4">
|
|
{/* Storage & Mode Row */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label className="text-sm flex items-center gap-1.5">
|
|
<Database className="h-3.5 w-3.5" />
|
|
{t("vmLxc.backupModal.storage")}
|
|
</Label>
|
|
<Select value={selectedBackupStorage} onValueChange={setSelectedBackupStorage}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t("vmLxc.backupModal.selectStorage")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{backupStorages.map((storage) => (
|
|
<SelectItem key={`modal-storage-${storage.storage}`} value={storage.storage}>
|
|
{storage.storage} ({storage.avail_human} {t("vmLxc.backupModal.free")})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-sm flex items-center gap-1.5">
|
|
<Settings2 className="h-3.5 w-3.5" />
|
|
{t("vmLxc.backupModal.mode")}
|
|
</Label>
|
|
<Select value={backupMode} onValueChange={setBackupMode}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="snapshot">{t("vmLxc.backupModal.modes.snapshot")}</SelectItem>
|
|
<SelectItem value="suspend">{t("vmLxc.backupModal.modes.suspend")}</SelectItem>
|
|
<SelectItem value="stop">{t("vmLxc.backupModal.modes.stop")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notification Row */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm flex items-center gap-1.5">
|
|
<Bell className="h-3.5 w-3.5" />
|
|
{t("vmLxc.backupModal.notification")}
|
|
</Label>
|
|
<Select value={backupNotification} onValueChange={setBackupNotification}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="auto">{t("vmLxc.backupModal.useGlobalSettings")}</SelectItem>
|
|
<SelectItem value="always">{t("vmLxc.backupModal.alwaysNotify")}</SelectItem>
|
|
<SelectItem value="failure">{t("vmLxc.backupModal.notifyOnFailure")}</SelectItem>
|
|
<SelectItem value="never">{t("vmLxc.backupModal.neverNotify")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Protected Checkbox */}
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="backup-protected"
|
|
checked={backupProtected}
|
|
onCheckedChange={(checked) => setBackupProtected(checked === true)}
|
|
/>
|
|
<Label htmlFor="backup-protected" className="text-sm flex items-center gap-1.5 cursor-pointer">
|
|
<Shield className="h-3.5 w-3.5" />
|
|
{t("vmLxc.backupModal.protected")}
|
|
</Label>
|
|
</div>
|
|
|
|
{/* PBS Change Detection Mode (only for LXC) */}
|
|
{selectedVM?.type === 'lxc' && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm flex items-center gap-1.5">
|
|
<Settings2 className="h-3.5 w-3.5" />
|
|
{t("vmLxc.backupModal.pbsChangeMode")}
|
|
<span className="text-xs text-muted-foreground ml-1">({t("vmLxc.backupModal.forPbsStorage")})</span>
|
|
</Label>
|
|
<Select value={backupPbsChangeMode} onValueChange={setBackupPbsChangeMode}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="default">{t("vmLxc.backupModal.changeModes.default")}</SelectItem>
|
|
<SelectItem value="legacy">{t("vmLxc.backupModal.changeModes.legacy")}</SelectItem>
|
|
<SelectItem value="data">{t("vmLxc.backupModal.changeModes.data")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm flex items-center gap-1.5">
|
|
<FileText className="h-3.5 w-3.5" />
|
|
{t("vmLxc.backupModal.notes")}
|
|
</Label>
|
|
<Textarea
|
|
value={backupNotes}
|
|
onChange={(e) => setBackupNotes(e.target.value)}
|
|
placeholder="{{guestname}}"
|
|
className="min-h-[80px] resize-none"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("vmLxc.backupModal.variables")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 pt-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowBackupModal(false)}
|
|
className="flex-1 bg-zinc-800/50 border-zinc-700 text-zinc-300 hover:bg-zinc-700/50"
|
|
>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button
|
|
onClick={handleCreateBackup}
|
|
disabled={creatingBackup || !selectedBackupStorage}
|
|
className="flex-1 bg-amber-600/20 border border-amber-600/50 text-amber-400 hover:bg-amber-600/30"
|
|
>
|
|
{creatingBackup ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
{t("vmLxc.backupModal.creating")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Archive className="h-4 w-4 mr-2" />
|
|
{t("vmLxc.backupModal.submit")}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* LXC Terminal Modal */}
|
|
{terminalVmid !== null && (
|
|
<LxcTerminalModal
|
|
open={terminalOpen}
|
|
onClose={() => {
|
|
setTerminalOpen(false)
|
|
setTerminalVmid(null)
|
|
setTerminalVmName("")
|
|
}}
|
|
vmid={terminalVmid}
|
|
vmName={terminalVmName}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|