mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-02-19 08:56:23 +00:00
Update virtual-machines.tsx
This commit is contained in:
@@ -8,9 +8,7 @@ import { Badge } from "./ui/badge"
|
|||||||
import { Progress } from "./ui/progress"
|
import { Progress } from "./ui/progress"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, Terminal, Archive, Plus, Loader2, Clock, Database } from 'lucide-react'
|
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, Terminal } from 'lucide-react'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
|
||||||
import { ScrollArea } from "./ui/scroll-area"
|
|
||||||
import useSWR from "swr"
|
import useSWR from "swr"
|
||||||
import { MetricsView } from "./metrics-dialog"
|
import { MetricsView } from "./metrics-dialog"
|
||||||
import { LxcTerminalModal } from "./lxc-terminal-modal"
|
import { LxcTerminalModal } from "./lxc-terminal-modal"
|
||||||
@@ -123,29 +121,6 @@ interface VMDetails extends VMData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetcher = async (url: string) => {
|
const fetcher = async (url: string) => {
|
||||||
return fetchApi(url)
|
return fetchApi(url)
|
||||||
}
|
}
|
||||||
@@ -297,14 +272,6 @@ export function VirtualMachines() {
|
|||||||
const [loadingIPs, setLoadingIPs] = useState(false)
|
const [loadingIPs, setLoadingIPs] = useState(false)
|
||||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes")
|
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)
|
|
||||||
const [modalPage, setModalPage] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchLXCIPs = async () => {
|
const fetchLXCIPs = async () => {
|
||||||
// Only fetch if data exists, not already loaded, and not currently loading
|
// Only fetch if data exists, not already loaded, and not currently loading
|
||||||
@@ -380,13 +347,7 @@ export function VirtualMachines() {
|
|||||||
setShowNotes(false)
|
setShowNotes(false)
|
||||||
setIsEditingNotes(false)
|
setIsEditingNotes(false)
|
||||||
setEditedNotes("")
|
setEditedNotes("")
|
||||||
setModalPage(0)
|
|
||||||
setDetailsLoading(true)
|
setDetailsLoading(true)
|
||||||
|
|
||||||
// Load backups and storages immediately (independent of config)
|
|
||||||
fetchBackupStorages()
|
|
||||||
fetchVmBackups(vm.vmid)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const details = await fetchApi(`/api/vms/${vm.vmid}`)
|
const details = await fetchApi(`/api/vms/${vm.vmid}`)
|
||||||
setVMDetails(details)
|
setVMDetails(details)
|
||||||
@@ -405,54 +366,6 @@ export function VirtualMachines() {
|
|||||||
setCurrentView("main")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateBackup = async () => {
|
|
||||||
if (!selectedVM || !selectedBackupStorage) return
|
|
||||||
|
|
||||||
setCreatingBackup(true)
|
|
||||||
try {
|
|
||||||
await fetchApi(`/api/vms/${selectedVM.vmid}/backup`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ storage: selectedBackupStorage }),
|
|
||||||
})
|
|
||||||
// Refresh backups list after creation
|
|
||||||
setTimeout(() => fetchVmBackups(selectedVM.vmid), 2000)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating backup:", error)
|
|
||||||
} finally {
|
|
||||||
setCreatingBackup(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleVMControl = async (vmid: number, action: string) => {
|
const handleVMControl = async (vmid: number, action: string) => {
|
||||||
setControlLoading(true)
|
setControlLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -613,7 +526,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isHTML = (str: string): boolean => {
|
const isHTML = (str: string): boolean => {
|
||||||
const htmlRegex = new RegExp('<\\/?[a-z][\\s\\S]*>', 'i')
|
const htmlRegex = /<\/?[a-z][\s\S]*>/i
|
||||||
return htmlRegex.test(str)
|
return htmlRegex.test(str)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,242 +1144,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
{/* Mobile carousel */}
|
<div className="space-y-6">
|
||||||
<div className="sm:hidden h-full flex flex-col">
|
|
||||||
<div className="flex-1 relative overflow-hidden">
|
|
||||||
{/* Page 0: Main content */}
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 px-6 py-4 overflow-y-auto transition-all duration-300 ease-in-out ${
|
|
||||||
modalPage === 0
|
|
||||||
? 'opacity-100 translate-x-0'
|
|
||||||
: 'opacity-0 -translate-x-full pointer-events-none'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{selectedVM && (
|
|
||||||
<>
|
|
||||||
<div key={`mobile-metrics-${selectedVM.vmid}`}>
|
|
||||||
<Card
|
|
||||||
className="cursor-pointer rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 transition-colors group"
|
|
||||||
onClick={handleMetricsClick}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{/* CPU */}
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">CPU Usage</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" />
|
|
||||||
</div>
|
|
||||||
{/* Memory */}
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">Memory</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" />
|
|
||||||
</div>
|
|
||||||
{/* Disk */}
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">Disk</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" />
|
|
||||||
</div>
|
|
||||||
{/* Disk I/O */}
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">Disk I/O</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-sm text-green-500">↓ {((selectedVM.diskread || 0) / 1024 ** 2).toFixed(2)} MB</div>
|
|
||||||
<div className="text-sm text-blue-500">↑ {((selectedVM.diskwrite || 0) / 1024 ** 2).toFixed(2)} MB</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Network I/O */}
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">Network I/O</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-sm text-green-500">↓ {formatNetworkTraffic(selectedVM.netin || 0, networkUnit)}</div>
|
|
||||||
<div className="text-sm text-blue-500">↑ {formatNetworkTraffic(selectedVM.netout || 0, networkUnit)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* OS Icon */}
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
{getOSIcon(vmDetails?.os_info, selectedVM.type)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resources Section Mobile */}
|
|
||||||
{vmDetails?.config && (
|
|
||||||
<Card className="border border-border bg-card/50">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<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">Resources</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
{vmDetails.config.cores && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-1">CPU Cores</div>
|
|
||||||
<div className="font-semibold text-blue-500">{vmDetails.config.cores}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{vmDetails.config.memory && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-1">Memory</div>
|
|
||||||
<div className="font-semibold text-blue-500">{vmDetails.config.memory} MB</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{vmDetails.config.swap !== undefined && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-1">Swap</div>
|
|
||||||
<div className="font-semibold text-foreground">{vmDetails.config.swap} MB</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{selectedVM?.type === "lxc" && vmDetails?.lxc_ip_info && vmDetails.lxc_ip_info.real_ips.length > 0 && (
|
|
||||||
<div className="mt-4 pt-4 border-t border-border">
|
|
||||||
<h4 className="text-sm font-semibold text-muted-foreground mb-2">IP Addresses</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{vmDetails.lxc_ip_info.real_ips.map((ip, index) => (
|
|
||||||
<Badge key={`mobile-ip-${ip}-${index}`} variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
|
||||||
{ip}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page 1: Backups */}
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 px-6 py-4 overflow-y-auto transition-all duration-300 ease-in-out ${
|
|
||||||
modalPage === 1
|
|
||||||
? 'opacity-100 translate-x-0'
|
|
||||||
: 'opacity-0 translate-x-full pointer-events-none'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card className="border border-border bg-card/50 h-full">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<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">Backups</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Backup */}
|
|
||||||
<div className="space-y-3 mb-4">
|
|
||||||
<Select value={selectedBackupStorage} onValueChange={setSelectedBackupStorage}>
|
|
||||||
<SelectTrigger className="w-full h-10">
|
|
||||||
<SelectValue placeholder="Select storage" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{backupStorages.map((storage) => (
|
|
||||||
<SelectItem key={`mobile-storage-${storage.storage}`} value={storage.storage}>
|
|
||||||
{storage.storage} ({storage.avail_human} free)
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
className="w-full h-10 bg-amber-600 hover:bg-amber-700 text-white"
|
|
||||||
onClick={handleCreateBackup}
|
|
||||||
disabled={creatingBackup || !selectedBackupStorage}
|
|
||||||
>
|
|
||||||
{creatingBackup ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Create Backup
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-t border-border/50 my-4" />
|
|
||||||
|
|
||||||
{/* Backup List */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-xs text-muted-foreground">Available backups</span>
|
|
||||||
<Badge variant="secondary" className="text-xs">{vmBackups.length}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingBackups ? (
|
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
|
||||||
<span>Loading...</span>
|
|
||||||
</div>
|
|
||||||
) : vmBackups.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
|
||||||
<Archive className="h-10 w-10 mb-2 opacity-30" />
|
|
||||||
<span className="text-sm">No backups found</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{vmBackups.map((backup, index) => (
|
|
||||||
<div
|
|
||||||
key={`mobile-backup-${backup.volid}-${index}`}
|
|
||||||
className="flex items-center justify-between p-3 rounded-lg bg-muted/30"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm">{backup.date}</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-xs font-mono">
|
|
||||||
{backup.size_human}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination dots */}
|
|
||||||
<div className="flex justify-center items-center gap-3 py-3 border-t border-border/50">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setModalPage(0)}
|
|
||||||
className={`w-2.5 h-2.5 rounded-full transition-all ${
|
|
||||||
modalPage === 0 ? 'bg-primary scale-110' : 'bg-muted-foreground/30'
|
|
||||||
}`}
|
|
||||||
aria-label="Main info"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setModalPage(1)}
|
|
||||||
className={`w-2.5 h-2.5 rounded-full transition-all ${
|
|
||||||
modalPage === 1 ? 'bg-primary scale-110' : 'bg-muted-foreground/30'
|
|
||||||
}`}
|
|
||||||
aria-label="Backups"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop layout */}
|
|
||||||
<div className="hidden sm:block overflow-y-auto px-6 py-4" style={{ maxHeight: 'calc(100vh - 260px)' }}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{selectedVM && (
|
{selectedVM && (
|
||||||
<>
|
<>
|
||||||
<div key={`metrics-${selectedVM.vmid}`}>
|
<div key={`metrics-${selectedVM.vmid}`}>
|
||||||
@@ -1556,95 +1235,6 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Backups Section - Always visible, loads independently */}
|
|
||||||
<Card className="border border-border bg-card/50">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
{/* Header */}
|
|
||||||
<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">Backups</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Backup Section */}
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Select value={selectedBackupStorage} onValueChange={setSelectedBackupStorage}>
|
|
||||||
<SelectTrigger className="flex-1 h-9 text-sm">
|
|
||||||
<SelectValue placeholder="Select storage" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{backupStorages.map((storage) => (
|
|
||||||
<SelectItem key={`backup-storage-${storage.storage}`} value={storage.storage}>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Database className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{storage.storage}
|
|
||||||
<span className="text-xs text-muted-foreground">({storage.avail_human} free)</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-9 bg-amber-600 hover:bg-amber-700 text-white gap-1.5"
|
|
||||||
onClick={handleCreateBackup}
|
|
||||||
disabled={creatingBackup || !selectedBackupStorage}
|
|
||||||
>
|
|
||||||
{creatingBackup ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Create</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-t border-border/50 my-3" />
|
|
||||||
|
|
||||||
{/* Backup List */}
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs text-muted-foreground">Available backups</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">Loading backups...</span>
|
|
||||||
</div>
|
|
||||||
) : vmBackups.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground">
|
|
||||||
<Archive className="h-8 w-8 mb-2 opacity-30" />
|
|
||||||
<span className="text-sm">No backups found</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5 max-h-[180px] overflow-y-auto pr-1">
|
|
||||||
{vmBackups.map((backup, index) => (
|
|
||||||
<div
|
|
||||||
key={`backup-${backup.volid}-${index}`}
|
|
||||||
className="flex items-center justify-between p-2.5 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
|
||||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<span className="text-sm text-foreground">{backup.date}</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-xs font-mono">
|
|
||||||
{backup.size_human}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{detailsLoading ? (
|
{detailsLoading ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">Loading configuration...</div>
|
<div className="text-center py-8 text-muted-foreground">Loading configuration...</div>
|
||||||
) : vmDetails?.config ? (
|
) : vmDetails?.config ? (
|
||||||
@@ -1652,12 +1242,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
<Card className="border border-border bg-card/50" key={`config-${selectedVM.vmid}`}>
|
<Card className="border border-border bg-card/50" key={`config-${selectedVM.vmid}`}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
<div className="p-1.5 rounded-md bg-blue-500/10">
|
Resources
|
||||||
<Cpu className="h-4 w-4 text-blue-500" />
|
</h3>
|
||||||
</div>
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">Resources</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
Reference in New Issue
Block a user