From 15d32adb10bb6bd7d9145d8837ae189285ee7cb0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 18 Nov 2025 22:57:20 +0000 Subject: [PATCH] feat: surface LXC mountpoints in UI (related to #715) --- internal/monitoring/monitor.go | 110 +++++++++++++-- internal/monitoring/monitor_container_test.go | 133 ++++++++++++++++++ 2 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 internal/monitoring/monitor_container_test.go diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index fef26ed7b..807405a42 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2873,6 +2873,7 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI } rootDeviceHint := "" + var mountMetadata map[string]containerMountMetadata addressSet := make(map[string]struct{}) addressOrder := make([]string, 0, 4) @@ -2952,8 +2953,16 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI Int("vmid", container.VMID). Msg("Container config metadata unavailable") } else if len(configData) > 0 { - if hint := extractContainerRootDeviceFromConfig(configData); hint != "" { - rootDeviceHint = hint + mountMetadata = parseContainerMountMetadata(configData) + if rootDeviceHint == "" { + if meta, ok := mountMetadata["rootfs"]; ok && meta.Source != "" { + rootDeviceHint = meta.Source + } + } + if rootDeviceHint == "" { + if hint := extractContainerRootDeviceFromConfig(configData); hint != "" { + rootDeviceHint = hint + } } for _, detail := range parseContainerConfigNetworks(configData) { if len(detail.Addresses) > 0 { @@ -3043,7 +3052,7 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI container.NetworkInterfaces = networkIfaces } - if disks := convertContainerDiskInfo(status); len(disks) > 0 { + if disks := convertContainerDiskInfo(status, mountMetadata); len(disks) > 0 { container.Disks = disks } @@ -3091,7 +3100,7 @@ func ensureContainerRootDiskEntry(container *models.Container) { } } -func convertContainerDiskInfo(status *proxmox.Container) []models.Disk { +func convertContainerDiskInfo(status *proxmox.Container, metadata map[string]containerMountMetadata) []models.Disk { if status == nil || len(status.DiskInfo) == 0 { return nil } @@ -3119,15 +3128,39 @@ func convertContainerDiskInfo(status *proxmox.Container) []models.Disk { } label := strings.TrimSpace(name) + lowerLabel := strings.ToLower(label) + mountpoint := "" + device := "" + + if metadata != nil { + if meta, ok := metadata[lowerLabel]; ok { + mountpoint = strings.TrimSpace(meta.Mountpoint) + device = strings.TrimSpace(meta.Source) + } + } + if strings.EqualFold(label, "rootfs") || label == "" { - disk.Mountpoint = "/" + if mountpoint == "" { + mountpoint = "/" + } disk.Type = "rootfs" - if device := sanitizeRootFSDevice(status.RootFS); device != "" { - disk.Device = device + if device == "" { + device = sanitizeRootFSDevice(status.RootFS) } } else { - disk.Mountpoint = label - disk.Type = strings.ToLower(label) + if mountpoint == "" { + mountpoint = label + } + if lowerLabel != "" { + disk.Type = lowerLabel + } else { + disk.Type = "disk" + } + } + + disk.Mountpoint = mountpoint + if disk.Device == "" && device != "" { + disk.Device = device } disks = append(disks, disk) @@ -3276,6 +3309,12 @@ type containerNetworkDetails struct { Addresses []string } +type containerMountMetadata struct { + Key string + Mountpoint string + Source string +} + func parseContainerConfigNetworks(config map[string]interface{}) []containerNetworkDetails { if len(config) == 0 { return nil @@ -3338,6 +3377,59 @@ func parseContainerConfigNetworks(config map[string]interface{}) []containerNetw return results } +func parseContainerMountMetadata(config map[string]interface{}) map[string]containerMountMetadata { + if len(config) == 0 { + return nil + } + + results := make(map[string]containerMountMetadata) + for rawKey, rawValue := range config { + key := strings.ToLower(strings.TrimSpace(rawKey)) + if key != "rootfs" && !strings.HasPrefix(key, "mp") { + continue + } + + value := strings.TrimSpace(fmt.Sprint(rawValue)) + if value == "" { + continue + } + + meta := containerMountMetadata{ + Key: key, + } + + parts := strings.Split(value, ",") + if len(parts) > 0 { + meta.Source = strings.TrimSpace(parts[0]) + } + + for _, part := range parts[1:] { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + continue + } + k := strings.ToLower(strings.TrimSpace(kv[0])) + v := strings.TrimSpace(kv[1]) + switch k { + case "mp", "mountpoint": + meta.Mountpoint = v + } + } + + if meta.Mountpoint == "" && key == "rootfs" { + meta.Mountpoint = "/" + } + + results[key] = meta + } + + if len(results) == 0 { + return nil + } + + return results +} + func mergeContainerNetworkInterface(target *[]models.GuestNetworkInterface, detail containerNetworkDetails) { if target == nil { return diff --git a/internal/monitoring/monitor_container_test.go b/internal/monitoring/monitor_container_test.go new file mode 100644 index 000000000..cab5b4d4f --- /dev/null +++ b/internal/monitoring/monitor_container_test.go @@ -0,0 +1,133 @@ +package monitoring + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" +) + +func TestParseContainerMountMetadata(t *testing.T) { + config := map[string]interface{}{ + "rootfs": "local-lvm:vm-100-disk-0,size=8G", + "mp0": "/mnt/pve/media/subvol-100-disk-1,mp=/mnt/media,acl=1", + "MP1": "local:100/vm-100-disk-2,mp=/srv/backup", + "unused": "ignored", + } + + meta := parseContainerMountMetadata(config) + if len(meta) != 3 { + t.Fatalf("expected 3 entries, got %d", len(meta)) + } + + root, ok := meta["rootfs"] + if !ok { + t.Fatalf("missing rootfs metadata") + } + if root.Mountpoint != "/" { + t.Fatalf("expected root mountpoint '/', got %q", root.Mountpoint) + } + if root.Source != "local-lvm:vm-100-disk-0" { + t.Fatalf("unexpected root source %q", root.Source) + } + + mp0, ok := meta["mp0"] + if !ok { + t.Fatalf("missing mp0 metadata") + } + if mp0.Mountpoint != "/mnt/media" { + t.Fatalf("expected mp0 mountpoint '/mnt/media', got %q", mp0.Mountpoint) + } + if mp0.Source != "/mnt/pve/media/subvol-100-disk-1" { + t.Fatalf("unexpected mp0 source %q", mp0.Source) + } + + mp1, ok := meta["mp1"] + if !ok { + t.Fatalf("missing mp1 metadata") + } + if mp1.Mountpoint != "/srv/backup" { + t.Fatalf("expected mp1 mountpoint '/srv/backup', got %q", mp1.Mountpoint) + } + if mp1.Source != "local:100/vm-100-disk-2" { + t.Fatalf("unexpected mp1 source %q", mp1.Source) + } +} + +func TestConvertContainerDiskInfoUsesMountMetadata(t *testing.T) { + status := &proxmox.Container{ + RootFS: "local-lvm:vm-200-disk-0,size=20G", + DiskInfo: map[string]proxmox.ContainerDiskUsage{ + "rootfs": {Total: 20 * 1024 * 1024 * 1024, Used: 10 * 1024 * 1024 * 1024}, + "mp0": {Total: 100 * 1024 * 1024 * 1024, Used: 50 * 1024 * 1024 * 1024}, + }, + } + + meta := map[string]containerMountMetadata{ + "rootfs": {Key: "rootfs", Mountpoint: "/", Source: "local-lvm:vm-200-disk-0"}, + "mp0": {Key: "mp0", Mountpoint: "/mnt/media", Source: "/mnt/pve/media/subvol-200-disk-1"}, + } + + disks := convertContainerDiskInfo(status, meta) + if len(disks) != 2 { + t.Fatalf("expected 2 disks, got %d", len(disks)) + } + + var rootDisk, mpDisk *models.Disk + for i := range disks { + if disks[i].Mountpoint == "/" { + rootDisk = &disks[i] + } + if disks[i].Mountpoint == "/mnt/media" { + mpDisk = &disks[i] + } + } + + if rootDisk == nil { + t.Fatalf("root disk not found in %+v", disks) + } + if rootDisk.Device != "local-lvm:vm-200-disk-0" { + t.Fatalf("unexpected root device %q", rootDisk.Device) + } + if rootDisk.Type != "rootfs" { + t.Fatalf("expected root type rootfs, got %q", rootDisk.Type) + } + + if mpDisk == nil { + t.Fatalf("mp0 disk not found in %+v", disks) + } + if mpDisk.Device != "/mnt/pve/media/subvol-200-disk-1" { + t.Fatalf("unexpected mp0 device %q", mpDisk.Device) + } + if mpDisk.Type != "mp0" { + t.Fatalf("expected mp0 type 'mp0', got %q", mpDisk.Type) + } +} + +func TestConvertContainerDiskInfoFallsBackWithoutMetadata(t *testing.T) { + status := &proxmox.Container{ + RootFS: "local-lvm:vm-300-disk-0,size=16G", + DiskInfo: map[string]proxmox.ContainerDiskUsage{ + "rootfs": {Total: 16 * 1024 * 1024 * 1024, Used: 8 * 1024 * 1024 * 1024}, + "mp1": {Total: 5 * 1024 * 1024 * 1024, Used: 1 * 1024 * 1024 * 1024}, + }, + } + + disks := convertContainerDiskInfo(status, nil) + if len(disks) != 2 { + t.Fatalf("expected 2 disks, got %d", len(disks)) + } + + var mpDisk *models.Disk + for i := range disks { + if disks[i].Type == "mp1" { + mpDisk = &disks[i] + } + } + if mpDisk == nil { + t.Fatalf("mp1 disk not found in %+v", disks) + } + if mpDisk.Mountpoint != "mp1" { + t.Fatalf("expected fallback mountpoint 'mp1', got %q", mpDisk.Mountpoint) + } +}