From aac3dacd63795989910ff29e9b854d34c6ce6559 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Oct 2025 22:24:33 +0000 Subject: [PATCH] Improve LXC guest metrics visibility (#596) --- internal/models/converters.go | 100 +++++ internal/models/models.go | 46 +-- internal/models/models_frontend.go | 87 +++-- internal/monitoring/monitor.go | 509 +++++++++++++++++++++++++ internal/monitoring/monitor_polling.go | 2 + pkg/proxmox/client.go | 69 +++- 6 files changed, 747 insertions(+), 66 deletions(-) diff --git a/internal/models/converters.go b/internal/models/converters.go index 9995600..60116f2 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -181,6 +181,15 @@ func (c Container) ToFrontend() ContainerFrontend { ct.Disks = c.Disks } + if len(c.IPAddresses) > 0 { + ct.IPAddresses = append([]string(nil), c.IPAddresses...) + } + + if len(c.NetworkInterfaces) > 0 { + ct.NetworkInterfaces = make([]GuestNetworkInterface, len(c.NetworkInterfaces)) + copy(ct.NetworkInterfaces, c.NetworkInterfaces) + } + return ct } @@ -233,6 +242,68 @@ func (d DockerHost) ToFrontend() DockerHostFrontend { return h } +// ToFrontend converts a Host to HostFrontend. +func (h Host) ToFrontend() HostFrontend { + host := HostFrontend{ + ID: h.ID, + Hostname: h.Hostname, + DisplayName: h.DisplayName, + Platform: h.Platform, + OSName: h.OSName, + OSVersion: h.OSVersion, + KernelVersion: h.KernelVersion, + Architecture: h.Architecture, + CPUCount: h.CPUCount, + CPUUsage: h.CPUUsage, + Status: h.Status, + UptimeSeconds: h.UptimeSeconds, + IntervalSeconds: h.IntervalSeconds, + AgentVersion: h.AgentVersion, + TokenID: h.TokenID, + TokenName: h.TokenName, + TokenHint: h.TokenHint, + Tags: append([]string(nil), h.Tags...), + LastSeen: h.LastSeen.Unix() * 1000, + } + + if host.DisplayName == "" { + if h.DisplayName != "" { + host.DisplayName = h.DisplayName + } else if h.Hostname != "" { + host.DisplayName = h.Hostname + } + } + + if len(h.LoadAverage) > 0 { + host.LoadAverage = append([]float64(nil), h.LoadAverage...) + } + + if (h.Memory != Memory{}) { + mem := h.Memory + host.Memory = &mem + } + + if len(h.Disks) > 0 { + host.Disks = append([]Disk(nil), h.Disks...) + } + + if len(h.NetworkInterfaces) > 0 { + host.NetworkInterfaces = make([]HostNetworkInterface, len(h.NetworkInterfaces)) + copy(host.NetworkInterfaces, h.NetworkInterfaces) + } + + if s := hostSensorSummaryToFrontend(h.Sensors); s != nil { + host.Sensors = s + } + + if h.TokenLastUsedAt != nil && !h.TokenLastUsedAt.IsZero() { + ts := h.TokenLastUsedAt.Unix() * 1000 + host.TokenLastUsedAt = &ts + } + + return host +} + // ToFrontend converts a DockerContainer to DockerContainerFrontend func (c DockerContainer) ToFrontend() DockerContainerFrontend { container := DockerContainerFrontend{ @@ -291,6 +362,35 @@ func (c DockerContainer) ToFrontend() DockerContainerFrontend { return container } +func hostSensorSummaryToFrontend(src HostSensorSummary) *HostSensorSummaryFrontend { + if len(src.TemperatureCelsius) == 0 && len(src.FanRPM) == 0 && len(src.Additional) == 0 { + return nil + } + + dest := &HostSensorSummaryFrontend{} + if len(src.TemperatureCelsius) > 0 { + dest.TemperatureCelsius = copyStringFloatMap(src.TemperatureCelsius) + } + if len(src.FanRPM) > 0 { + dest.FanRPM = copyStringFloatMap(src.FanRPM) + } + if len(src.Additional) > 0 { + dest.Additional = copyStringFloatMap(src.Additional) + } + return dest +} + +func copyStringFloatMap(src map[string]float64) map[string]float64 { + if len(src) == 0 { + return nil + } + dest := make(map[string]float64, len(src)) + for k, v := range src { + dest[k] = v + } + return dest +} + func toDockerHostCommandFrontend(cmd DockerHostCommandStatus) *DockerHostCommandFrontend { result := &DockerHostCommandFrontend{ ID: cmd.ID, diff --git a/internal/models/models.go b/internal/models/models.go index 107c694..bbef570 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -116,28 +116,30 @@ type VM struct { // Container represents an LXC container type Container struct { - ID string `json:"id"` - VMID int `json:"vmid"` - Name string `json:"name"` - Node string `json:"node"` - Instance string `json:"instance"` - Status string `json:"status"` - Type string `json:"type"` - CPU float64 `json:"cpu"` - 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"` - DiskWrite int64 `json:"diskWrite"` - Uptime int64 `json:"uptime"` - Template bool `json:"template"` - LastBackup time.Time `json:"lastBackup,omitempty"` - Tags []string `json:"tags,omitempty"` - Lock string `json:"lock,omitempty"` - LastSeen time.Time `json:"lastSeen"` + ID string `json:"id"` + VMID int `json:"vmid"` + Name string `json:"name"` + Node string `json:"node"` + Instance string `json:"instance"` + Status string `json:"status"` + Type string `json:"type"` + CPU float64 `json:"cpu"` + 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"` + DiskWrite int64 `json:"diskWrite"` + Uptime int64 `json:"uptime"` + Template bool `json:"template"` + LastBackup time.Time `json:"lastBackup,omitempty"` + Tags []string `json:"tags,omitempty"` + Lock string `json:"lock,omitempty"` + LastSeen time.Time `json:"lastSeen"` + IPAddresses []string `json:"ipAddresses,omitempty"` + NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"` } // Host represents a generic infrastructure host reporting via external agents. diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index db5d6d9..5d87e45 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -67,30 +67,32 @@ type VMFrontend struct { // ContainerFrontend represents a Container with frontend-friendly field names type ContainerFrontend struct { - ID string `json:"id"` - VMID int `json:"vmid"` - Name string `json:"name"` - Node string `json:"node"` - Instance string `json:"instance"` - Status string `json:"status"` - Type string `json:"type"` - CPU float64 `json:"cpu"` - CPUs int `json:"cpus"` - Memory *Memory `json:"memory,omitempty"` // Full memory object - Mem int64 `json:"mem"` // Maps to Memory.Used - MaxMem int64 `json:"maxmem"` // Maps to Memory.Total - DiskObj *Disk `json:"disk,omitempty"` // Full disk object - Disks []Disk `json:"disks,omitempty"` // Individual filesystem/disk usage - NetIn int64 `json:"networkIn"` // Maps to NetworkIn (camelCase for frontend) - NetOut int64 `json:"networkOut"` // Maps to NetworkOut (camelCase for frontend) - DiskRead int64 `json:"diskRead"` // Maps to DiskRead (camelCase for frontend) - DiskWrite int64 `json:"diskWrite"` // Maps to DiskWrite (camelCase for frontend) - Uptime int64 `json:"uptime"` - Template bool `json:"template"` - LastBackup int64 `json:"lastBackup,omitempty"` // Unix timestamp - Tags string `json:"tags,omitempty"` // Joined string - Lock string `json:"lock,omitempty"` - LastSeen int64 `json:"lastSeen"` // Unix timestamp + ID string `json:"id"` + VMID int `json:"vmid"` + Name string `json:"name"` + Node string `json:"node"` + Instance string `json:"instance"` + Status string `json:"status"` + Type string `json:"type"` + CPU float64 `json:"cpu"` + CPUs int `json:"cpus"` + Memory *Memory `json:"memory,omitempty"` // Full memory object + Mem int64 `json:"mem"` // Maps to Memory.Used + MaxMem int64 `json:"maxmem"` // Maps to Memory.Total + DiskObj *Disk `json:"disk,omitempty"` // Full disk object + Disks []Disk `json:"disks,omitempty"` // Individual filesystem/disk usage + NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"` + IPAddresses []string `json:"ipAddresses,omitempty"` + NetIn int64 `json:"networkIn"` // Maps to NetworkIn (camelCase for frontend) + NetOut int64 `json:"networkOut"` // Maps to NetworkOut (camelCase for frontend) + DiskRead int64 `json:"diskRead"` // Maps to DiskRead (camelCase for frontend) + DiskWrite int64 `json:"diskWrite"` // Maps to DiskWrite (camelCase for frontend) + Uptime int64 `json:"uptime"` + Template bool `json:"template"` + LastBackup int64 `json:"lastBackup,omitempty"` // Unix timestamp + Tags string `json:"tags,omitempty"` // Joined string + Lock string `json:"lock,omitempty"` + LastSeen int64 `json:"lastSeen"` // Unix timestamp } // DockerHostFrontend represents a Docker host with frontend-friendly fields @@ -174,6 +176,42 @@ type DockerHostCommandFrontend struct { ExpiresAt *int64 `json:"expiresAt,omitempty"` } +// HostFrontend represents a generic infrastructure host exposed to the UI. +type HostFrontend struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + DisplayName string `json:"displayName"` + Platform string `json:"platform,omitempty"` + OSName string `json:"osName,omitempty"` + OSVersion string `json:"osVersion,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + CPUCount int `json:"cpuCount,omitempty"` + CPUUsage float64 `json:"cpuUsage,omitempty"` + LoadAverage []float64 `json:"loadAverage,omitempty"` + Memory *Memory `json:"memory,omitempty"` + Disks []Disk `json:"disks,omitempty"` + NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"` + Sensors *HostSensorSummaryFrontend `json:"sensors,omitempty"` + Status string `json:"status"` + UptimeSeconds int64 `json:"uptimeSeconds,omitempty"` + LastSeen int64 `json:"lastSeen"` + IntervalSeconds int `json:"intervalSeconds,omitempty"` + AgentVersion string `json:"agentVersion,omitempty"` + TokenID string `json:"tokenId,omitempty"` + TokenName string `json:"tokenName,omitempty"` + TokenHint string `json:"tokenHint,omitempty"` + TokenLastUsedAt *int64 `json:"tokenLastUsedAt,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +// HostSensorSummaryFrontend mirrors HostSensorSummary with primitives for the frontend. +type HostSensorSummaryFrontend struct { + TemperatureCelsius map[string]float64 `json:"temperatureCelsius,omitempty"` + FanRPM map[string]float64 `json:"fanRpm,omitempty"` + Additional map[string]float64 `json:"additional,omitempty"` +} + // StorageFrontend represents Storage with frontend-friendly field names type StorageFrontend struct { ID string `json:"id"` @@ -263,6 +301,7 @@ type StateFrontend struct { VMs []VMFrontend `json:"vms"` Containers []ContainerFrontend `json:"containers"` DockerHosts []DockerHostFrontend `json:"dockerHosts"` + Hosts []HostFrontend `json:"hosts"` Storage []StorageFrontend `json:"storage"` CephClusters []CephClusterFrontend `json:"cephClusters"` PhysicalDisks []PhysicalDisk `json:"physicalDisks"` diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 137b5ea..240ee03 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -31,6 +31,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/notifications" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" + agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" "github.com/rcourtman/pulse-go-rewrite/pkg/pbs" "github.com/rcourtman/pulse-go-rewrite/pkg/pmg" "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" @@ -509,6 +510,17 @@ func safeFloat(val float64) float64 { return val } +func cloneStringFloatMap(src map[string]float64) map[string]float64 { + if len(src) == 0 { + return nil + } + out := make(map[string]float64, len(src)) + for k, v := range src { + out[k] = v + } + return out +} + // shouldRunBackupPoll determines whether a backup polling cycle should execute. // Returns whether polling should run, a human-readable skip reason, and the timestamp to record. func (m *Monitor) shouldRunBackupPoll(last time.Time, now time.Time) (bool, string, time.Time) { @@ -547,6 +559,7 @@ func (m *Monitor) shouldRunBackupPoll(last time.Time, now time.Time) (bool, stri const ( dockerConnectionPrefix = "docker-" + hostConnectionPrefix = "host-" dockerOfflineGraceMultiplier = 4 dockerMinimumHealthWindow = 30 * time.Second dockerMaximumHealthWindow = 10 * time.Minute @@ -1266,6 +1279,170 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con return host, nil } +// ApplyHostReport ingests a host agent report into the shared state. +func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.APITokenRecord) (models.Host, error) { + hostname := strings.TrimSpace(report.Host.Hostname) + if hostname == "" { + return models.Host{}, fmt.Errorf("host report missing hostname") + } + + identifier := strings.TrimSpace(report.Host.ID) + if identifier != "" { + identifier = sanitizeDockerHostSuffix(identifier) + } + if identifier == "" { + if machine := sanitizeDockerHostSuffix(report.Host.MachineID); machine != "" { + identifier = machine + } + } + if identifier == "" { + if agentID := sanitizeDockerHostSuffix(report.Agent.ID); agentID != "" { + identifier = agentID + } + } + if identifier == "" { + if hostName := sanitizeDockerHostSuffix(hostname); hostName != "" { + identifier = hostName + } + } + if identifier == "" { + seedParts := uniqueNonEmptyStrings( + report.Host.MachineID, + report.Agent.ID, + report.Host.Hostname, + ) + if len(seedParts) == 0 { + seedParts = []string{hostname} + } + seed := strings.Join(seedParts, "|") + sum := sha1.Sum([]byte(seed)) + identifier = fmt.Sprintf("host-%s", hex.EncodeToString(sum[:6])) + } + + existingHosts := m.state.GetHosts() + var previous models.Host + var hasPrevious bool + for _, candidate := range existingHosts { + if candidate.ID == identifier { + previous = candidate + hasPrevious = true + break + } + } + + displayName := strings.TrimSpace(report.Host.DisplayName) + if displayName == "" { + displayName = hostname + } + + timestamp := report.Timestamp + if timestamp.IsZero() { + timestamp = time.Now().UTC() + } + + memory := models.Memory{ + Total: report.Metrics.Memory.TotalBytes, + Used: report.Metrics.Memory.UsedBytes, + Free: report.Metrics.Memory.FreeBytes, + Usage: safeFloat(report.Metrics.Memory.Usage), + SwapTotal: report.Metrics.Memory.SwapTotal, + SwapUsed: report.Metrics.Memory.SwapUsed, + } + if memory.Usage <= 0 && memory.Total > 0 { + memory.Usage = safePercentage(float64(memory.Used), float64(memory.Total)) + } + + disks := make([]models.Disk, 0, len(report.Disks)) + for _, disk := range report.Disks { + usage := safeFloat(disk.Usage) + if usage <= 0 && disk.TotalBytes > 0 { + usage = safePercentage(float64(disk.UsedBytes), float64(disk.TotalBytes)) + } + disks = append(disks, models.Disk{ + Total: disk.TotalBytes, + Used: disk.UsedBytes, + Free: disk.FreeBytes, + Usage: usage, + Mountpoint: disk.Mountpoint, + Type: disk.Type, + Device: disk.Device, + }) + } + + network := make([]models.HostNetworkInterface, 0, len(report.Network)) + for _, nic := range report.Network { + network = append(network, models.HostNetworkInterface{ + Name: nic.Name, + MAC: nic.MAC, + Addresses: append([]string(nil), nic.Addresses...), + RXBytes: nic.RXBytes, + TXBytes: nic.TXBytes, + SpeedMbps: nic.SpeedMbps, + }) + } + + host := models.Host{ + ID: identifier, + Hostname: hostname, + DisplayName: displayName, + Platform: strings.TrimSpace(strings.ToLower(report.Host.Platform)), + OSName: strings.TrimSpace(report.Host.OSName), + OSVersion: strings.TrimSpace(report.Host.OSVersion), + KernelVersion: strings.TrimSpace(report.Host.KernelVersion), + Architecture: strings.TrimSpace(report.Host.Architecture), + CPUCount: report.Host.CPUCount, + CPUUsage: safeFloat(report.Metrics.CPUUsagePercent), + LoadAverage: append([]float64(nil), report.Host.LoadAverage...), + Memory: memory, + Disks: disks, + NetworkInterfaces: network, + Sensors: models.HostSensorSummary{ + TemperatureCelsius: cloneStringFloatMap(report.Sensors.TemperatureCelsius), + FanRPM: cloneStringFloatMap(report.Sensors.FanRPM), + Additional: cloneStringFloatMap(report.Sensors.Additional), + }, + Status: "online", + UptimeSeconds: report.Host.UptimeSeconds, + IntervalSeconds: report.Agent.IntervalSeconds, + LastSeen: timestamp, + AgentVersion: strings.TrimSpace(report.Agent.Version), + Tags: append([]string(nil), report.Tags...), + } + + if len(host.LoadAverage) == 0 { + host.LoadAverage = nil + } + if len(host.Disks) == 0 { + host.Disks = nil + } + if len(host.NetworkInterfaces) == 0 { + host.NetworkInterfaces = nil + } + + if tokenRecord != nil { + host.TokenID = tokenRecord.ID + host.TokenName = tokenRecord.Name + host.TokenHint = tokenHintFromRecord(tokenRecord) + if tokenRecord.LastUsedAt != nil { + t := tokenRecord.LastUsedAt.UTC() + host.TokenLastUsedAt = &t + } else { + now := time.Now().UTC() + host.TokenLastUsedAt = &now + } + } else if hasPrevious { + host.TokenID = previous.TokenID + host.TokenName = previous.TokenName + host.TokenHint = previous.TokenHint + host.TokenLastUsedAt = previous.TokenLastUsedAt + } + + m.state.UpsertHost(host) + m.state.SetConnectionHealth(hostConnectionPrefix+host.ID, true) + + return host, nil +} + const ( removedDockerHostsTTL = 24 * time.Hour // Clean up removed hosts tracking after 24 hours ) @@ -1659,6 +1836,336 @@ func anyToInt64(val interface{}) int64 { return 0 } +func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientInterface, instanceName, nodeName string, container *models.Container) { + if container == nil { + return + } + + ensureContainerRootDiskEntry(container) + + if client == nil || container.Status != "running" { + return + } + + statusCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + status, err := client.GetContainerStatus(statusCtx, nodeName, container.VMID) + cancel() + if err != nil { + log.Debug(). + Err(err). + Str("instance", instanceName). + Str("node", nodeName). + Str("container", container.Name). + Int("vmid", container.VMID). + Msg("Container status metadata unavailable") + return + } + if status == nil { + return + } + + addressSet := make(map[string]struct{}) + addressOrder := make([]string, 0, 4) + + addAddress := func(addr string) { + addr = strings.TrimSpace(addr) + if addr == "" { + return + } + if _, exists := addressSet[addr]; exists { + return + } + addressSet[addr] = struct{}{} + addressOrder = append(addressOrder, addr) + } + + for _, addr := range sanitizeGuestAddressStrings(status.IP) { + addAddress(addr) + } + for _, addr := range sanitizeGuestAddressStrings(status.IP6) { + addAddress(addr) + } + for _, addr := range parseContainerRawIPs(status.IPv4) { + addAddress(addr) + } + for _, addr := range parseContainerRawIPs(status.IPv6) { + addAddress(addr) + } + + networkIfaces := make([]models.GuestNetworkInterface, 0, len(status.Network)) + for rawName, cfg := range status.Network { + if cfg == (proxmox.ContainerNetworkConfig{}) { + continue + } + + iface := models.GuestNetworkInterface{} + name := strings.TrimSpace(cfg.Name) + if name == "" { + name = strings.TrimSpace(rawName) + } + if name != "" { + iface.Name = name + } + if mac := strings.TrimSpace(cfg.HWAddr); mac != "" { + iface.MAC = mac + } + + addrCandidates := make([]string, 0, 4) + addrCandidates = append(addrCandidates, collectIPsFromInterface(cfg.IP)...) + addrCandidates = append(addrCandidates, collectIPsFromInterface(cfg.IP6)...) + addrCandidates = append(addrCandidates, collectIPsFromInterface(cfg.IPv4)...) + addrCandidates = append(addrCandidates, collectIPsFromInterface(cfg.IPv6)...) + + if len(addrCandidates) > 0 { + deduped := dedupeStringsPreserveOrder(addrCandidates) + if len(deduped) > 0 { + iface.Addresses = deduped + for _, addr := range deduped { + addAddress(addr) + } + } + } + + if iface.Name != "" || iface.MAC != "" || len(iface.Addresses) > 0 { + networkIfaces = append(networkIfaces, iface) + } + } + + if len(networkIfaces) > 1 { + sort.SliceStable(networkIfaces, func(i, j int) bool { + left := strings.TrimSpace(networkIfaces[i].Name) + right := strings.TrimSpace(networkIfaces[j].Name) + return left < right + }) + } + + if len(addressOrder) > 1 { + sort.Strings(addressOrder) + } + + if len(addressOrder) > 0 { + container.IPAddresses = addressOrder + } + + if len(networkIfaces) > 0 { + container.NetworkInterfaces = networkIfaces + } + + if disks := convertContainerDiskInfo(status); len(disks) > 0 { + container.Disks = disks + } + + ensureContainerRootDiskEntry(container) +} + +func ensureContainerRootDiskEntry(container *models.Container) { + if container == nil || len(container.Disks) > 0 { + return + } + + total := container.Disk.Total + used := container.Disk.Used + if total <= 0 && used <= 0 { + return + } + if total > 0 && used > total { + used = total + } + + free := total - used + if free < 0 { + free = 0 + } + + usage := container.Disk.Usage + if total > 0 && usage <= 0 { + usage = safePercentage(float64(used), float64(total)) + } + + container.Disks = []models.Disk{ + { + Total: total, + Used: used, + Free: free, + Usage: usage, + Mountpoint: "/", + Type: "rootfs", + }, + } +} + +func convertContainerDiskInfo(status *proxmox.Container) []models.Disk { + if status == nil || len(status.DiskInfo) == 0 { + return nil + } + + disks := make([]models.Disk, 0, len(status.DiskInfo)) + for name, info := range status.DiskInfo { + total := clampToInt64(info.Total) + used := clampToInt64(info.Used) + if total <= 0 && used <= 0 { + continue + } + if total > 0 && used > total { + used = total + } + free := total - used + if free < 0 { + free = 0 + } + + disk := models.Disk{ + Total: total, + Used: used, + Free: free, + } + + if total > 0 { + disk.Usage = safePercentage(float64(used), float64(total)) + } + + label := strings.TrimSpace(name) + if strings.EqualFold(label, "rootfs") || label == "" { + disk.Mountpoint = "/" + disk.Type = "rootfs" + if device := sanitizeRootFSDevice(status.RootFS); device != "" { + disk.Device = device + } + } else { + disk.Mountpoint = label + disk.Type = strings.ToLower(label) + } + + disks = append(disks, disk) + } + + if len(disks) > 1 { + sort.SliceStable(disks, func(i, j int) bool { + return disks[i].Mountpoint < disks[j].Mountpoint + }) + } + + return disks +} + +func sanitizeRootFSDevice(root string) string { + root = strings.TrimSpace(root) + if root == "" { + return "" + } + if idx := strings.Index(root, ","); idx != -1 { + root = root[:idx] + } + return root +} + +func parseContainerRawIPs(raw json.RawMessage) []string { + if len(raw) == 0 { + return nil + } + var data interface{} + if err := json.Unmarshal(raw, &data); err != nil { + return nil + } + return collectIPsFromInterface(data) +} + +func collectIPsFromInterface(value interface{}) []string { + switch v := value.(type) { + case nil: + return nil + case string: + return sanitizeGuestAddressStrings(v) + case []interface{}: + results := make([]string, 0, len(v)) + for _, item := range v { + results = append(results, collectIPsFromInterface(item)...) + } + return results + case []string: + results := make([]string, 0, len(v)) + for _, item := range v { + results = append(results, sanitizeGuestAddressStrings(item)...) + } + return results + case map[string]interface{}: + results := make([]string, 0) + for _, key := range []string{"ip", "ip6", "ipv4", "ipv6", "address", "value"} { + if val, ok := v[key]; ok { + results = append(results, collectIPsFromInterface(val)...) + } + } + return results + case json.Number: + return sanitizeGuestAddressStrings(v.String()) + default: + return nil + } +} + +func sanitizeGuestAddressStrings(value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + + lower := strings.ToLower(value) + switch lower { + case "dhcp", "manual", "static", "auto", "none", "n/a", "unknown", "0.0.0.0", "::", "::1": + return nil + } + + parts := strings.FieldsFunc(value, func(r rune) bool { + return unicode.IsSpace(r) || r == ',' || r == ';' + }) + + if len(parts) > 1 { + results := make([]string, 0, len(parts)) + for _, part := range parts { + results = append(results, sanitizeGuestAddressStrings(part)...) + } + return results + } + + if strings.HasPrefix(value, "127.") || strings.HasPrefix(strings.ToLower(value), "0.0.0.0") { + return nil + } + + lower = strings.ToLower(value) + if strings.HasPrefix(lower, "fe80") { + return nil + } + + if strings.HasPrefix(lower, "::1") { + return nil + } + + return []string{value} +} + +func dedupeStringsPreserveOrder(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + result = append(result, v) + } + if len(result) == 0 { + return nil + } + return result +} + // GetConnectionStatuses returns the current connection status for all nodes func (m *Monitor) GetConnectionStatuses() map[string]bool { if mock.IsMockEnabled() { @@ -5185,6 +5692,8 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam } } + m.enrichContainerMetadata(ctx, client, instanceName, res.Node, &container) + allContainers = append(allContainers, container) // For non-running containers, zero out resource usage metrics to prevent false alerts diff --git a/internal/monitoring/monitor_polling.go b/internal/monitoring/monitor_polling.go index 8473fa6..c8ce252 100644 --- a/internal/monitoring/monitor_polling.go +++ b/internal/monitoring/monitor_polling.go @@ -975,6 +975,8 @@ func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName stri modelContainer.Disk.Usage = safePercentage(float64(modelContainer.Disk.Used), float64(modelContainer.Disk.Total)) } + m.enrichContainerMetadata(ctx, client, instanceName, n.Node, &modelContainer) + // Zero out metrics for non-running containers if container.Status != "running" { modelContainer.CPU = 0 diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 7906e70..5267d2c 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -734,26 +734,55 @@ type VM struct { // Container represents a Proxmox VE LXC container type Container struct { - VMID FlexInt `json:"vmid"` // Changed to FlexInt to handle string VMIDs from some Proxmox versions - Name string `json:"name"` - Node string `json:"node"` - Status string `json:"status"` - CPU float64 `json:"cpu"` - CPUs FlexInt `json:"cpus"` - Mem uint64 `json:"mem"` - MaxMem uint64 `json:"maxmem"` - Swap uint64 `json:"swap"` - MaxSwap uint64 `json:"maxswap"` - Disk uint64 `json:"disk"` - MaxDisk uint64 `json:"maxdisk"` - NetIn uint64 `json:"netin"` - NetOut uint64 `json:"netout"` - DiskRead uint64 `json:"diskread"` - DiskWrite uint64 `json:"diskwrite"` - Uptime uint64 `json:"uptime"` - Template int `json:"template"` - Tags string `json:"tags"` - Lock string `json:"lock"` + VMID FlexInt `json:"vmid"` // Changed to FlexInt to handle string VMIDs from some Proxmox versions + Name string `json:"name"` + Node string `json:"node"` + Status string `json:"status"` + CPU float64 `json:"cpu"` + CPUs FlexInt `json:"cpus"` + Mem uint64 `json:"mem"` + MaxMem uint64 `json:"maxmem"` + Swap uint64 `json:"swap"` + MaxSwap uint64 `json:"maxswap"` + Disk uint64 `json:"disk"` + MaxDisk uint64 `json:"maxdisk"` + NetIn uint64 `json:"netin"` + NetOut uint64 `json:"netout"` + DiskRead uint64 `json:"diskread"` + DiskWrite uint64 `json:"diskwrite"` + Uptime uint64 `json:"uptime"` + Template int `json:"template"` + Tags string `json:"tags"` + Lock string `json:"lock"` + Hostname string `json:"hostname,omitempty"` + IP string `json:"ip,omitempty"` + IP6 string `json:"ip6,omitempty"` + IPv4 json.RawMessage `json:"ipv4,omitempty"` + IPv6 json.RawMessage `json:"ipv6,omitempty"` + Network map[string]ContainerNetworkConfig `json:"network,omitempty"` + DiskInfo map[string]ContainerDiskUsage `json:"diskinfo,omitempty"` + RootFS string `json:"rootfs,omitempty"` +} + +// ContainerNetworkConfig captures basic container network status information. +type ContainerNetworkConfig struct { + Name string `json:"name,omitempty"` + HWAddr string `json:"hwaddr,omitempty"` + Bridge string `json:"bridge,omitempty"` + Method string `json:"method,omitempty"` + Type string `json:"type,omitempty"` + IP interface{} `json:"ip,omitempty"` + IP6 interface{} `json:"ip6,omitempty"` + IPv4 interface{} `json:"ipv4,omitempty"` + IPv6 interface{} `json:"ipv6,omitempty"` + Firewall interface{} `json:"firewall,omitempty"` + Tag interface{} `json:"tag,omitempty"` +} + +// ContainerDiskUsage captures disk usage details returned by the LXC status API. +type ContainerDiskUsage struct { + Total uint64 `json:"total,omitempty"` + Used uint64 `json:"used,omitempty"` } // Storage represents a Proxmox VE storage