From e15f54f851a715ceeea52fcd283d89784da7e38c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 1 Oct 2025 21:33:59 +0000 Subject: [PATCH] Polish node row styling and restore disk detail support --- .../src/components/Dashboard/Dashboard.tsx | 28 +--- .../src/components/Dashboard/DiskList.tsx | 121 ++++++++++++++++++ .../src/components/Dashboard/GuestRow.tsx | 45 +++++-- .../src/components/Storage/Storage.tsx | 61 ++++++--- frontend-modern/src/types/api.ts | 5 + internal/models/models.go | 13 +- internal/monitoring/monitor.go | 13 +- internal/monitoring/monitor_optimized.go | 11 ++ 8 files changed, 237 insertions(+), 60 deletions(-) create mode 100644 frontend-modern/src/components/Dashboard/DiskList.tsx diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index ba1721711..651a4b752 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -956,7 +956,7 @@ export function Dashboard(props: DashboardProps) {
-
diff --git a/frontend-modern/src/components/Dashboard/DiskList.tsx b/frontend-modern/src/components/Dashboard/DiskList.tsx new file mode 100644 index 000000000..08d2907c8 --- /dev/null +++ b/frontend-modern/src/components/Dashboard/DiskList.tsx @@ -0,0 +1,121 @@ +import { For, Show, createSignal } from 'solid-js'; +import type { Disk } from '@/types/api'; +import { formatBytes } from '@/utils/format'; +import { MetricBar } from './MetricBar'; + +interface DiskListProps { + disks: Disk[]; + diskStatusReason?: string; +} + +export function DiskList(props: DiskListProps) { + const [expanded, setExpanded] = createSignal(false); + + const getDiskStatusTooltip = () => { + const reason = props.diskStatusReason; + + switch (reason) { + case 'agent-not-running': + return 'Guest agent not running. Install and start qemu-guest-agent in the VM.'; + case 'agent-timeout': + return 'Guest agent timeout. Agent may need to be restarted.'; + case 'permission-denied': + return 'Permission denied. Check that your Pulse user/token has VM.Monitor permission (PVE 8) or VM.GuestAgent.Audit permission (PVE 9).'; + case 'agent-disabled': + return 'Guest agent is disabled in VM configuration. Enable it in VM Options.'; + case 'no-filesystems': + return 'No filesystems found. VM may be booting or using a Live ISO.'; + case 'special-filesystems-only': + return 'Only special filesystems detected (ISO/squashfs). This is normal for Live systems.'; + case 'agent-error': + return 'Error communicating with guest agent.'; + case 'no-data': + return 'No disk data available from Proxmox API.'; + default: + return 'Disk stats unavailable. Guest agent may not be installed.'; + } + }; + + return ( + 0} + fallback={ + + - + + } + > +
+ {/* Show first disk or aggregated view when collapsed */} + + + + + 1}> +
+ acc + d.used, 0) / + props.disks.reduce((acc, d) => acc + d.total, 0)) * + 100 + } + label={`${( + (props.disks.reduce((acc, d) => acc + d.used, 0) / + props.disks.reduce((acc, d) => acc + d.total, 0)) * + 100 + ).toFixed(0)}%`} + sublabel={`${formatBytes( + props.disks.reduce((acc, d) => acc + d.used, 0) + )}/${formatBytes(props.disks.reduce((acc, d) => acc + d.total, 0))}`} + type="disk" + /> + +
+
+ + {/* Expanded view showing all individual disks */} + +
+ + {(disk) => ( +
+
+ +
+ + {disk.mountpoint || disk.device || 'Unknown'} + +
+ )} +
+ +
+
+
+
+ ); +} diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 74e65a6af..2bae70017 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -4,6 +4,8 @@ import { formatBytes, formatUptime } from '@/utils/format'; import { MetricBar } from './MetricBar'; import { IOMetric } from './IOMetric'; import { TagBadges } from './TagBadges'; +import { DiskList } from './DiskList'; +import { GuestMetadataAPI } from '@/api/guestMetadata'; type Guest = VM | Container; @@ -29,6 +31,13 @@ interface GuestRowProps { export function GuestRow(props: GuestRowProps) { const [customUrl, setCustomUrl] = createSignal(props.customUrl); + const guestId = createMemo(() => { + if (props.guest.id) return props.guest.id; + if (props.guest.instance === props.guest.node) { + return `${props.guest.node}-${props.guest.vmid}`; + } + return `${props.guest.instance}-${props.guest.node}-${props.guest.vmid}`; + }); // Update custom URL when prop changes createEffect(() => { @@ -236,22 +245,32 @@ export function GuestRow(props: GuestRowProps) { {/* Disk */} 0 && diskPercent() !== -1} + when={props.guest.disks && props.guest.disks.length > 0} fallback={ - - - - + 0 && diskPercent() !== -1} + fallback={ + + - + + } + > + + } > - diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 0d5a6b8fb..542bc1e65 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -572,24 +572,49 @@ const Storage: Component = () => { <> {/* Group Header */} - {(validNode) => ( - - -
- {validNode().name} - - - - )} + {(validNode) => { + const nodeData = validNode(); + const isOnline = + nodeData.status === 'online' && (nodeData.uptime || 0) > 0; + + return ( + + +
+
+
+ + {nodeData.name} + + + + {nodeData.isClusterMember ? nodeData.clusterName : 'Standalone'} + + +
+
+ + + ); + }} {/* Storage Rows */} diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 956a80ce5..b21f54ccc 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -52,6 +52,7 @@ export interface VM { cpus: number; memory: Memory; disk: Disk; + disks?: Disk[]; diskStatusReason?: string; networkIn: number; networkOut: number; @@ -77,6 +78,7 @@ export interface Container { cpus: number; memory: Memory; disk: Disk; + disks?: Disk[]; networkIn: number; networkOut: number; diskRead: number; @@ -248,6 +250,9 @@ export interface Disk { used: number; free: number; usage: number; + mountpoint?: string; + type?: string; + device?: string; } export interface PhysicalDisk { diff --git a/internal/models/models.go b/internal/models/models.go index 1418f6d54..68cef3ba8 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -85,6 +85,7 @@ type VM struct { CPUs int `json:"cpus"` Memory Memory `json:"memory"` Disk Disk `json:"disk"` + Disks []Disk `json:"disks,omitempty"` DiskStatusReason string `json:"diskStatusReason,omitempty"` // Why disk stats are unavailable NetworkIn int64 `json:"networkIn"` NetworkOut int64 `json:"networkOut"` @@ -111,6 +112,7 @@ type Container struct { CPUs int `json:"cpus"` Memory Memory `json:"memory"` Disk Disk `json:"disk"` + Disks []Disk `json:"disks,omitempty"` NetworkIn int64 `json:"networkIn"` NetworkOut int64 `json:"networkOut"` DiskRead int64 `json:"diskRead"` @@ -305,10 +307,13 @@ type Memory struct { // Disk represents disk usage type Disk struct { - Total int64 `json:"total"` - Used int64 `json:"used"` - Free int64 `json:"free"` - Usage float64 `json:"usage"` + Total int64 `json:"total"` + Used int64 `json:"used"` + Free int64 `json:"free"` + Usage float64 `json:"usage"` + Mountpoint string `json:"mountpoint,omitempty"` + Type string `json:"type,omitempty"` + Device string `json:"device,omitempty"` } // CPUInfo represents CPU information diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 827d6908e..b795efb40 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2131,7 +2131,8 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli diskTotal := uint64(vm.MaxDisk) diskFree := diskTotal - diskUsed diskUsage := safePercentage(float64(diskUsed), float64(diskTotal)) - diskStatusReason := "" // Empty string means we have data + diskStatusReason := "" // Empty string means we have data + var individualDisks []models.Disk // Store individual filesystems for multi-disk monitoring // If VM shows 0 disk usage but has allocated disk, it's likely guest agent issue // Set to -1 to indicate "unknown" rather than showing misleading 0% @@ -2239,6 +2240,15 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli if fs.TotalBytes > 0 { totalBytes += fs.TotalBytes usedBytes += fs.UsedBytes + individualDisks = append(individualDisks, models.Disk{ + Total: int64(fs.TotalBytes), + Used: int64(fs.UsedBytes), + Free: int64(fs.TotalBytes - fs.UsedBytes), + Usage: safePercentage(float64(fs.UsedBytes), float64(fs.TotalBytes)), + Mountpoint: fs.Mountpoint, + Type: fs.Type, + Device: fs.Disk, + }) log.Debug(). Str("instance", instanceName). Str("vm", vm.Name). @@ -2334,6 +2344,7 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli Free: int64(diskFree), Usage: diskUsage, }, + Disks: individualDisks, DiskStatusReason: diskStatusReason, NetworkIn: maxInt64(0, int64(netInRate)), NetworkOut: maxInt64(0, int64(netOutRate)), diff --git a/internal/monitoring/monitor_optimized.go b/internal/monitoring/monitor_optimized.go index 30f1b6ce9..354be21b9 100644 --- a/internal/monitoring/monitor_optimized.go +++ b/internal/monitoring/monitor_optimized.go @@ -199,6 +199,7 @@ func (m *Monitor) pollVMsWithNodesOptimized(ctx context.Context, instanceName st diskFree := diskTotal - diskUsed diskUsage := safePercentage(float64(diskUsed), float64(diskTotal)) diskStatusReason := "" + var individualDisks []models.Disk // For stopped VMs, we can't get guest agent data if vm.Status != "running" { @@ -331,6 +332,15 @@ func (m *Monitor) pollVMsWithNodesOptimized(ctx context.Context, instanceName st totalBytes += fs.TotalBytes usedBytes += fs.UsedBytes + individualDisks = append(individualDisks, models.Disk{ + Total: int64(fs.TotalBytes), + Used: int64(fs.UsedBytes), + Free: int64(fs.TotalBytes - fs.UsedBytes), + Usage: safePercentage(float64(fs.UsedBytes), float64(fs.TotalBytes)), + Mountpoint: fs.Mountpoint, + Type: fs.Type, + Device: fs.Disk, + }) log.Debug(). Str("vm", vm.Name). Str("mountpoint", fs.Mountpoint). @@ -412,6 +422,7 @@ func (m *Monitor) pollVMsWithNodesOptimized(ctx context.Context, instanceName st Free: int64(diskFree), Usage: diskUsage, }, + Disks: individualDisks, DiskStatusReason: diskStatusReason, NetworkIn: maxInt64(0, int64(netInRate)), NetworkOut: maxInt64(0, int64(netOutRate)),