diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 380e623..a9ab13e 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -6,6 +6,7 @@ import { Badge } from "./ui/badge" import { Progress } from "./ui/progress" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs" import { Server, Play, @@ -13,7 +14,6 @@ import { Monitor, Cpu, MemoryStick, - AlertCircle, HardDrive, Network, Power, @@ -40,6 +40,36 @@ interface VMData { diskwrite?: number } +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 + [key: string]: any +} + +interface VMDetails extends VMData { + config?: VMConfig + node?: string + vm_type?: string +} + const fetcher = async (url: string) => { const response = await fetch(url, { method: "GET", @@ -72,13 +102,31 @@ export function VirtualMachines() { isLoading, mutate, } = useSWR("/api/vms", fetcher, { - refreshInterval: 30000, // Refresh every 30 seconds + refreshInterval: 30000, revalidateOnFocus: false, revalidateOnReconnect: true, }) const [selectedVM, setSelectedVM] = useState(null) + const [vmDetails, setVMDetails] = useState(null) const [controlLoading, setControlLoading] = useState(false) + const [detailsLoading, setDetailsLoading] = useState(false) + + const handleVMClick = async (vm: VMData) => { + setSelectedVM(vm) + setDetailsLoading(true) + try { + const response = await fetch(`/api/vms/${vm.vmid}`) + if (response.ok) { + const details = await response.json() + setVMDetails(details) + } + } catch (error) { + console.error("Error fetching VM details:", error) + } finally { + setDetailsLoading(false) + } + } const handleVMControl = async (vmid: number, action: string) => { setControlLoading(true) @@ -92,9 +140,9 @@ export function VirtualMachines() { }) if (response.ok) { - // Refresh VM data after action mutate() setSelectedVM(null) + setVMDetails(null) } else { console.error("Failed to control VM") } @@ -105,16 +153,35 @@ export function VirtualMachines() { } } - const handleDownloadLogs = async (vmid: number) => { + const handleDownloadLogs = async (vmid: number, vmName: string) => { try { const response = await fetch(`/api/vms/${vmid}/logs`) if (response.ok) { const data = await response.json() - const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }) + + // Format logs as plain text + let logText = `=== Logs for ${vmName} (VMID: ${vmid}) ===\n` + logText += `Node: ${data.node}\n` + logText += `Type: ${data.type}\n` + logText += `Total lines: ${data.log_lines}\n` + logText += `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 = `vm-${vmid}-logs.json` + a.download = `${vmName}-${vmid}-logs.txt` a.click() URL.revokeObjectURL(url) } @@ -123,42 +190,6 @@ export function VirtualMachines() { } } - if (isLoading) { - return ( -
-
-
Loading VM data...
-
-
- ) - } - - if (error || !vmData) { - return ( -
- - -
- -
-
Flask Server Not Available
-
- {error?.message || - "Unable to connect to the Flask server. Please ensure the server is running and try again."} -
-
-
-
-
-
- ) - } - - const runningVMs = vmData.filter((vm) => vm.status === "running").length - const stoppedVMs = vmData.filter((vm) => vm.status === "stopped").length - const totalCPU = vmData.reduce((sum, vm) => sum + (vm.cpu || 0), 0) - const totalMemory = vmData.reduce((sum, vm) => sum + (vm.maxmem || 0), 0) - const getStatusColor = (status: string) => { switch (status) { case "running": @@ -208,10 +239,10 @@ export function VirtualMachines() {
{vmData.length}
- {runningVMs} Running + {vmData.filter((vm) => vm.status === "running").length} Running - {stoppedVMs} Stopped + {vmData.filter((vm) => vm.status === "stopped").length} Stopped

Virtual machines configured

@@ -224,7 +255,9 @@ export function VirtualMachines() { -
{(totalCPU * 100).toFixed(0)}%
+
+ {(vmData.reduce((sum, vm) => sum + (vm.cpu || 0), 0) * 100).toFixed(0)}% +

Allocated CPU usage

@@ -235,7 +268,9 @@ export function VirtualMachines() { -
{(totalMemory / 1024 ** 3).toFixed(1)} GB
+
+ {(vmData.reduce((sum, vm) => sum + (vm.maxmem || 0), 0) / 1024 ** 3).toFixed(1)} GB +

Allocated RAM

@@ -247,7 +282,14 @@ export function VirtualMachines() {
- {runningVMs > 0 ? ((totalCPU / runningVMs) * 100).toFixed(0) : 0}% + {vmData.filter((vm) => vm.status === "running").length > 0 + ? ( + (vmData.reduce((sum, vm) => sum + (vm.cpu || 0), 0) / + vmData.filter((vm) => vm.status === "running").length) * + 100 + ).toFixed(0) + : 0} + %

Average resource utilization

@@ -278,7 +320,7 @@ export function VirtualMachines() {
setSelectedVM(vm)} + onClick={() => handleVMClick(vm)} >
@@ -359,8 +401,14 @@ export function VirtualMachines() { {/* VM Details Modal */} - setSelectedVM(null)}> - + { + setSelectedVM(null) + setVMDetails(null) + }} + > + @@ -369,109 +417,290 @@ export function VirtualMachines() { {selectedVM && ( -
- {/* Basic Information */} -
-

Basic Information

-
-
-
Name
-
{selectedVM.name}
-
-
-
Type
- - {getTypeBadge(selectedVM.type).label} - -
-
-
VMID
-
{selectedVM.vmid}
-
-
-
Status
- - {selectedVM.status.toUpperCase()} - -
-
-
CPU Usage
-
{(selectedVM.cpu * 100).toFixed(1)}%
-
-
-
Memory
-
- {(selectedVM.mem / 1024 ** 3).toFixed(1)} / {(selectedVM.maxmem / 1024 ** 3).toFixed(1)} GB + + + Basic + Resources + Network + Options + + + {/* Basic Information Tab */} + +
+

Basic Information

+
+
+
Name
+
{selectedVM.name}
+
+
+
Type
+ + {getTypeBadge(selectedVM.type).label} + +
+
+
VMID
+
{selectedVM.vmid}
+
+
+
Status
+ + {selectedVM.status.toUpperCase()} + +
+
+
CPU Usage
+
{(selectedVM.cpu * 100).toFixed(1)}%
+
+
+
Memory
+
+ {(selectedVM.mem / 1024 ** 3).toFixed(1)} / {(selectedVM.maxmem / 1024 ** 3).toFixed(1)} GB +
+
+
+
Disk
+
+ {(selectedVM.disk / 1024 ** 3).toFixed(1)} / {(selectedVM.maxdisk / 1024 ** 3).toFixed(1)} GB +
+
+
+
Uptime
+
{formatUptime(selectedVM.uptime)}
-
-
Disk
-
- {(selectedVM.disk / 1024 ** 3).toFixed(1)} / {(selectedVM.maxdisk / 1024 ** 3).toFixed(1)} GB +
+ + {/* Control Actions */} +
+

Control Actions

+
+ + + + +
+
+ + {/* Download Logs */} +
+ +
+ + + {/* Resources Tab */} + + {detailsLoading ? ( +
Loading configuration...
+ ) : vmDetails?.config ? ( +
+
+ {vmDetails.config.cores && ( +
+
CPU Cores
+
{vmDetails.config.cores}
+
+ )} + {vmDetails.config.sockets && ( +
+
CPU Sockets
+
{vmDetails.config.sockets}
+
+ )} + {vmDetails.config.memory && ( +
+
Memory
+
{vmDetails.config.memory} MB
+
+ )} + {vmDetails.config.swap && ( +
+
Swap
+
{vmDetails.config.swap} MB
+
+ )} + {vmDetails.config.rootfs && ( +
+
Root Filesystem
+
{vmDetails.config.rootfs}
+
+ )} + {vmDetails.config.scsi0 && ( +
+
SCSI Disk 0
+
{vmDetails.config.scsi0}
+
+ )} + {vmDetails.config.ide0 && ( +
+
IDE Disk 0
+
{vmDetails.config.ide0}
+
+ )}
-
-
Uptime
-
{formatUptime(selectedVM.uptime)}
+ ) : ( +
No configuration data available
+ )} + + + {/* Network Tab */} + + {detailsLoading ? ( +
Loading configuration...
+ ) : vmDetails?.config ? ( +
+
+ {vmDetails.config.net0 && ( +
+
Network Interface 0
+
{vmDetails.config.net0}
+
+ )} + {vmDetails.config.net1 && ( +
+
Network Interface 1
+
{vmDetails.config.net1}
+
+ )} + {vmDetails.config.net2 && ( +
+
Network Interface 2
+
{vmDetails.config.net2}
+
+ )} + {vmDetails.config.nameserver && ( +
+
DNS Nameserver
+
{vmDetails.config.nameserver}
+
+ )} + {vmDetails.config.searchdomain && ( +
+
Search Domain
+
{vmDetails.config.searchdomain}
+
+ )} + {vmDetails.config.hostname && ( +
+
Hostname
+
{vmDetails.config.hostname}
+
+ )} +
-
-
+ ) : ( +
No network configuration available
+ )} + - {/* Control Actions */} -
-

Control Actions

-
- - - - -
-
- - {/* Download Logs */} -
- -
-
+ {/* Options Tab */} + + {detailsLoading ? ( +
Loading configuration...
+ ) : vmDetails?.config ? ( +
+
+ {vmDetails.config.onboot !== undefined && ( +
+
Start on Boot
+ + {vmDetails.config.onboot ? "Yes" : "No"} + +
+ )} + {vmDetails.config.unprivileged !== undefined && ( +
+
Unprivileged
+ + {vmDetails.config.unprivileged ? "Yes" : "No"} + +
+ )} + {vmDetails.config.ostype && ( +
+
OS Type
+
{vmDetails.config.ostype}
+
+ )} + {vmDetails.config.arch && ( +
+
Architecture
+
{vmDetails.config.arch}
+
+ )} + {vmDetails.config.boot && ( +
+
Boot Order
+
{vmDetails.config.boot}
+
+ )} + {vmDetails.config.features && ( +
+
Features
+
{vmDetails.config.features}
+
+ )} +
+
+ ) : ( +
No options available
+ )} +
+
)}
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 2291054..75fe45c 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -1467,7 +1467,7 @@ def api_vm_details(vmid): @app.route('/api/vms//logs', methods=['GET']) def api_vm_logs(vmid): - """Download logs for a specific VM/LXC""" + """Download real logs for a specific VM/LXC (not task history)""" try: # Get VM type and node result = subprocess.run(['pvesh', 'get', f'/cluster/resources', '--type', 'vm', '--output-format', 'json'], @@ -1487,29 +1487,32 @@ def api_vm_logs(vmid): vm_type = 'lxc' if vm_info.get('type') == 'lxc' else 'qemu' node = vm_info.get('node', 'pve') - # Get task log + # Get real logs from the container/VM (last 1000 lines) log_result = subprocess.run( - ['pvesh', 'get', f'/nodes/{node}/tasks', '--vmid', str(vmid), '--output-format', 'json'], + ['pvesh', 'get', f'/nodes/{node}/{vm_type}/{vmid}/log', '--limit', '1000', '--output-format', 'json'], capture_output=True, text=True, timeout=10 ) logs = [] if log_result.returncode == 0: - tasks = json.loads(log_result.stdout) - for task in tasks[:50]: # Last 50 tasks - logs.append({ - 'upid': task.get('upid'), - 'type': task.get('type'), - 'status': task.get('status'), - 'starttime': task.get('starttime'), - 'endtime': task.get('endtime'), - 'user': task.get('user') - }) + try: + log_data = json.loads(log_result.stdout) + # The API returns an array of log line objects + if isinstance(log_data, list): + logs = log_data + else: + # Fallback: parse as text + logs = [{'n': i, 't': line} for i, line in enumerate(log_result.stdout.split('\n')) if line] + except json.JSONDecodeError: + # Parse as plain text if JSON fails + logs = [{'n': i, 't': line} for i, line in enumerate(log_result.stdout.split('\n')) if line] return jsonify({ 'vmid': vmid, 'name': vm_info.get('name'), 'type': vm_type, + 'node': node, + 'log_lines': len(logs), 'logs': logs }) else: