Pulse/internal/resources/converters.go
rcourtman 7e8611e0ae cleanup: consolidate unified resources adapters into useResourcesAsLegacy hook
This cleanup addresses transition debt from the unified resources migration:

Frontend cleanup:
- Move all Resource→Legacy type conversions to useResourcesAsLegacy() hook
- Add asNodes() and asDockerHosts() adapter functions to the hook
- Simplify DockerRoute, HostsRoute, DashboardView to use the centralized hook
- Remove ~300 lines of duplicated adapter code from App.tsx
- Remove debug console.log statements from Dashboard.tsx
- Fix CPU value conversion (divide by 100) for Dashboard compatibility

Backend fixes (from previous session):
- Fix parentID format in converters (VM, Container, Storage) to match Node.ID
- Format changed from 'instance/node/nodename' to 'instance-nodename'
- Update tests to match new parentID format

This consolidates all legacy type conversion logic in one place,
making future cleanup easier when components are migrated to use
unified resources directly.
2025-12-08 09:16:26 +00:00

847 lines
20 KiB
Go

package resources
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
// FromNode converts a Proxmox Node to a unified Resource.
func FromNode(n models.Node) Resource {
// Calculate primary temperature if available
var temp *float64
if n.Temperature != nil && n.Temperature.Available && n.Temperature.HasCPU {
// Use CPU package temperature if available, otherwise average core temps
if n.Temperature.CPUPackage > 0 {
temp = &n.Temperature.CPUPackage
} else if len(n.Temperature.Cores) > 0 {
avg := 0.0
for _, c := range n.Temperature.Cores {
avg += c.Temp
}
avg /= float64(len(n.Temperature.Cores))
temp = &avg
}
}
// Build memory metric
var memory *MetricValue
if n.Memory.Total > 0 {
memory = &MetricValue{
Current: n.Memory.Usage,
Total: &n.Memory.Total,
Used: &n.Memory.Used,
Free: &n.Memory.Free,
}
}
// Build disk metric
var disk *MetricValue
if n.Disk.Total > 0 {
disk = &MetricValue{
Current: n.Disk.Usage,
Total: &n.Disk.Total,
Used: &n.Disk.Used,
Free: &n.Disk.Free,
}
}
// Build platform data
platformData := NodePlatformData{
Instance: n.Instance,
Host: n.Host,
GuestURL: n.GuestURL,
PVEVersion: n.PVEVersion,
KernelVersion: n.KernelVersion,
LoadAverage: n.LoadAverage,
IsClusterMember: n.IsClusterMember,
ClusterName: n.ClusterName,
ConnectionHealth: n.ConnectionHealth,
CPUInfo: CPUInfo{
Model: n.CPUInfo.Model,
Cores: n.CPUInfo.Cores,
Sockets: n.CPUInfo.Sockets,
},
}
platformDataJSON, _ := json.Marshal(platformData)
// Determine cluster ID
clusterID := ""
if n.IsClusterMember && n.ClusterName != "" {
clusterID = fmt.Sprintf("pve-cluster/%s", n.ClusterName)
}
return Resource{
ID: n.ID,
Type: ResourceTypeNode,
Name: n.Name,
DisplayName: n.DisplayName,
PlatformID: n.Instance,
PlatformType: PlatformProxmoxPVE,
SourceType: SourceAPI,
ClusterID: clusterID,
Status: mapNodeStatus(n.Status),
CPU: &MetricValue{
Current: n.CPU * 100, // Node CPU is 0-1, convert to percentage
},
Memory: memory,
Disk: disk,
Temperature: temp,
Uptime: &n.Uptime,
LastSeen: n.LastSeen,
PlatformData: platformDataJSON,
Identity: &ResourceIdentity{
Hostname: n.Name,
},
SchemaVersion: CurrentSchemaVersion,
}
}
// FromVM converts a Proxmox VM to a unified Resource.
func FromVM(vm models.VM) Resource {
// Build memory metric
var memory *MetricValue
if vm.Memory.Total > 0 {
memory = &MetricValue{
Current: vm.Memory.Usage,
Total: &vm.Memory.Total,
Used: &vm.Memory.Used,
Free: &vm.Memory.Free,
}
}
// Build disk metric
var disk *MetricValue
if vm.Disk.Total > 0 {
disk = &MetricValue{
Current: vm.Disk.Usage,
Total: &vm.Disk.Total,
Used: &vm.Disk.Used,
Free: &vm.Disk.Free,
}
}
// Build network metric
network := &NetworkMetric{
RXBytes: vm.NetworkIn,
TXBytes: vm.NetworkOut,
}
// Build platform data
var lastBackup *time.Time
if !vm.LastBackup.IsZero() {
lastBackup = &vm.LastBackup
}
platformData := VMPlatformData{
VMID: vm.VMID,
Node: vm.Node,
Instance: vm.Instance,
CPUs: vm.CPUs,
Template: vm.Template,
Lock: vm.Lock,
AgentVersion: vm.AgentVersion,
OSName: vm.OSName,
OSVersion: vm.OSVersion,
IPAddresses: vm.IPAddresses,
NetworkIn: vm.NetworkIn,
NetworkOut: vm.NetworkOut,
DiskRead: vm.DiskRead,
DiskWrite: vm.DiskWrite,
LastBackup: lastBackup,
}
platformDataJSON, _ := json.Marshal(platformData)
// Parent is the node - format matches Node.ID: instance-nodename
parentID := fmt.Sprintf("%s-%s", vm.Instance, vm.Node)
return Resource{
ID: vm.ID,
Type: ResourceTypeVM,
Name: vm.Name,
PlatformID: vm.Instance,
PlatformType: PlatformProxmoxPVE,
SourceType: SourceAPI,
ParentID: parentID,
Status: mapGuestStatus(vm.Status),
CPU: &MetricValue{
Current: vm.CPU * 100, // VM CPU is 0-1, convert to percentage
},
Memory: memory,
Disk: disk,
Network: network,
Uptime: &vm.Uptime,
Tags: vm.Tags,
LastSeen: vm.LastSeen,
PlatformData: platformDataJSON,
SchemaVersion: CurrentSchemaVersion,
}
}
// FromContainer converts a Proxmox LXC Container to a unified Resource.
func FromContainer(ct models.Container) Resource {
// Build memory metric
var memory *MetricValue
if ct.Memory.Total > 0 {
memory = &MetricValue{
Current: ct.Memory.Usage,
Total: &ct.Memory.Total,
Used: &ct.Memory.Used,
Free: &ct.Memory.Free,
}
}
// Build disk metric
var disk *MetricValue
if ct.Disk.Total > 0 {
disk = &MetricValue{
Current: ct.Disk.Usage,
Total: &ct.Disk.Total,
Used: &ct.Disk.Used,
Free: &ct.Disk.Free,
}
}
// Build network metric
network := &NetworkMetric{
RXBytes: ct.NetworkIn,
TXBytes: ct.NetworkOut,
}
// Build platform data
var lastBackup *time.Time
if !ct.LastBackup.IsZero() {
lastBackup = &ct.LastBackup
}
platformData := ContainerPlatformData{
VMID: ct.VMID,
Node: ct.Node,
Instance: ct.Instance,
CPUs: ct.CPUs,
Template: ct.Template,
Lock: ct.Lock,
OSName: ct.OSName,
IPAddresses: ct.IPAddresses,
NetworkIn: ct.NetworkIn,
NetworkOut: ct.NetworkOut,
DiskRead: ct.DiskRead,
DiskWrite: ct.DiskWrite,
LastBackup: lastBackup,
}
platformDataJSON, _ := json.Marshal(platformData)
// Parent is the node - format matches Node.ID: instance-nodename
parentID := fmt.Sprintf("%s-%s", ct.Instance, ct.Node)
return Resource{
ID: ct.ID,
Type: ResourceTypeContainer,
Name: ct.Name,
PlatformID: ct.Instance,
PlatformType: PlatformProxmoxPVE,
SourceType: SourceAPI,
ParentID: parentID,
Status: mapGuestStatus(ct.Status),
CPU: &MetricValue{
Current: ct.CPU * 100, // Container CPU is 0-1, convert to percentage
},
Memory: memory,
Disk: disk,
Network: network,
Uptime: &ct.Uptime,
Tags: ct.Tags,
LastSeen: ct.LastSeen,
PlatformData: platformDataJSON,
SchemaVersion: CurrentSchemaVersion,
}
}
// FromHost converts a Host agent to a unified Resource.
func FromHost(h models.Host) Resource {
// Build memory metric
var memory *MetricValue
if h.Memory.Total > 0 {
memory = &MetricValue{
Current: h.Memory.Usage,
Total: &h.Memory.Total,
Used: &h.Memory.Used,
Free: &h.Memory.Free,
}
}
// Combine disk metrics from multiple disks
var disk *MetricValue
var totalDisk, usedDisk, freeDisk int64
for _, d := range h.Disks {
totalDisk += d.Total
usedDisk += d.Used
freeDisk += d.Free
}
if totalDisk > 0 {
usage := float64(usedDisk) / float64(totalDisk) * 100
disk = &MetricValue{
Current: usage,
Total: &totalDisk,
Used: &usedDisk,
Free: &freeDisk,
}
}
// Calculate network totals
var rxTotal, txTotal int64
for _, iface := range h.NetworkInterfaces {
rxTotal += int64(iface.RXBytes)
txTotal += int64(iface.TXBytes)
}
network := &NetworkMetric{
RXBytes: rxTotal,
TXBytes: txTotal,
}
// Get primary temperature
var temp *float64
if len(h.Sensors.TemperatureCelsius) > 0 {
// Pick the first available temperature
for _, t := range h.Sensors.TemperatureCelsius {
temp = &t
break
}
}
// Build platform data
disks := make([]DiskInfo, len(h.Disks))
for i, d := range h.Disks {
disks[i] = DiskInfo{
Mountpoint: d.Mountpoint,
Device: d.Device,
Type: d.Type,
Total: d.Total,
Used: d.Used,
Free: d.Free,
Usage: d.Usage,
}
}
interfaces := make([]NetworkInterface, len(h.NetworkInterfaces))
for i, iface := range h.NetworkInterfaces {
interfaces[i] = NetworkInterface{
Name: iface.Name,
MAC: iface.MAC,
Addresses: iface.Addresses,
RXBytes: iface.RXBytes,
TXBytes: iface.TXBytes,
SpeedMbps: iface.SpeedMbps,
}
}
diskIO := make([]DiskIOStats, len(h.DiskIO))
for i, d := range h.DiskIO {
diskIO[i] = DiskIOStats{
Device: d.Device,
ReadBytes: d.ReadBytes,
WriteBytes: d.WriteBytes,
ReadOps: d.ReadOps,
WriteOps: d.WriteOps,
ReadTimeMs: d.ReadTime,
WriteTimeMs: d.WriteTime,
IOTimeMs: d.IOTime,
}
}
raid := make([]HostRAIDArray, len(h.RAID))
for i, r := range h.RAID {
devices := make([]HostRAIDDevice, len(r.Devices))
for j, d := range r.Devices {
devices[j] = HostRAIDDevice{
Device: d.Device,
State: d.State,
Slot: d.Slot,
}
}
raid[i] = HostRAIDArray{
Device: r.Device,
Name: r.Name,
Level: r.Level,
State: r.State,
TotalDevices: r.TotalDevices,
ActiveDevices: r.ActiveDevices,
WorkingDevices: r.WorkingDevices,
FailedDevices: r.FailedDevices,
SpareDevices: r.SpareDevices,
UUID: r.UUID,
Devices: devices,
RebuildPercent: r.RebuildPercent,
RebuildSpeed: r.RebuildSpeed,
}
}
platformData := HostPlatformData{
Platform: h.Platform,
OSName: h.OSName,
OSVersion: h.OSVersion,
KernelVersion: h.KernelVersion,
Architecture: h.Architecture,
CPUCount: h.CPUCount,
LoadAverage: h.LoadAverage,
AgentVersion: h.AgentVersion,
IsLegacy: h.IsLegacy,
Disks: disks,
Interfaces: interfaces,
DiskIO: diskIO,
RAID: raid,
TokenID: h.TokenID,
TokenName: h.TokenName,
TokenHint: h.TokenHint,
TokenLastUsedAt: h.TokenLastUsedAt,
Sensors: HostSensorSummary{
TemperatureCelsius: h.Sensors.TemperatureCelsius,
FanRPM: h.Sensors.FanRPM,
Additional: h.Sensors.Additional,
},
}
platformDataJSON, _ := json.Marshal(platformData)
// Collect IPs for identity
var ips []string
for _, iface := range h.NetworkInterfaces {
ips = append(ips, iface.Addresses...)
}
return Resource{
ID: h.ID,
Type: ResourceTypeHost,
Name: h.Hostname,
DisplayName: h.DisplayName,
PlatformID: "host-agent", // No specific platform ID for host agents
PlatformType: PlatformHostAgent,
SourceType: SourceAgent,
Status: mapHostStatus(h.Status),
CPU: &MetricValue{
Current: h.CPUUsage,
},
Memory: memory,
Disk: disk,
Network: network,
Temperature: temp,
Uptime: &h.UptimeSeconds,
Tags: h.Tags,
LastSeen: h.LastSeen,
PlatformData: platformDataJSON,
Identity: &ResourceIdentity{
Hostname: h.Hostname,
IPs: ips,
},
SchemaVersion: CurrentSchemaVersion,
}
}
// FromDockerHost converts a DockerHost to a unified Resource.
func FromDockerHost(dh models.DockerHost) Resource {
// Build memory metric
var memory *MetricValue
if dh.Memory.Total > 0 {
memory = &MetricValue{
Current: dh.Memory.Usage,
Total: &dh.Memory.Total,
Used: &dh.Memory.Used,
Free: &dh.Memory.Free,
}
}
// Combine disk metrics
var disk *MetricValue
var totalDisk, usedDisk, freeDisk int64
for _, d := range dh.Disks {
totalDisk += d.Total
usedDisk += d.Used
freeDisk += d.Free
}
if totalDisk > 0 {
usage := float64(usedDisk) / float64(totalDisk) * 100
disk = &MetricValue{
Current: usage,
Total: &totalDisk,
Used: &usedDisk,
Free: &freeDisk,
}
}
// Calculate network totals
var rxTotal, txTotal int64
for _, iface := range dh.NetworkInterfaces {
rxTotal += int64(iface.RXBytes)
txTotal += int64(iface.TXBytes)
}
network := &NetworkMetric{
RXBytes: rxTotal,
TXBytes: txTotal,
}
// Build platform data
disks := make([]DiskInfo, len(dh.Disks))
for i, d := range dh.Disks {
disks[i] = DiskInfo{
Mountpoint: d.Mountpoint,
Device: d.Device,
Type: d.Type,
Total: d.Total,
Used: d.Used,
Free: d.Free,
Usage: d.Usage,
}
}
interfaces := make([]NetworkInterface, len(dh.NetworkInterfaces))
for i, iface := range dh.NetworkInterfaces {
interfaces[i] = NetworkInterface{
Name: iface.Name,
MAC: iface.MAC,
Addresses: iface.Addresses,
RXBytes: iface.RXBytes,
TXBytes: iface.TXBytes,
SpeedMbps: iface.SpeedMbps,
}
}
var swarm *DockerSwarmInfo
if dh.Swarm != nil {
swarm = &DockerSwarmInfo{
NodeID: dh.Swarm.NodeID,
NodeRole: dh.Swarm.NodeRole,
LocalState: dh.Swarm.LocalState,
ControlAvailable: dh.Swarm.ControlAvailable,
ClusterID: dh.Swarm.ClusterID,
ClusterName: dh.Swarm.ClusterName,
Scope: dh.Swarm.Scope,
Error: dh.Swarm.Error,
}
}
platformData := DockerHostPlatformData{
AgentID: dh.AgentID,
MachineID: dh.MachineID,
OS: dh.OS,
KernelVersion: dh.KernelVersion,
Architecture: dh.Architecture,
Runtime: dh.Runtime,
RuntimeVersion: dh.RuntimeVersion,
DockerVersion: dh.DockerVersion,
LoadAverage: dh.LoadAverage,
AgentVersion: dh.AgentVersion,
CPUs: dh.CPUs,
IsLegacy: dh.IsLegacy,
Disks: disks,
Interfaces: interfaces,
CustomDisplayName: dh.CustomDisplayName,
Hidden: dh.Hidden,
PendingUninstall: dh.PendingUninstall,
Swarm: swarm,
TokenID: dh.TokenID,
TokenName: dh.TokenName,
TokenHint: dh.TokenHint,
TokenLastUsedAt: dh.TokenLastUsedAt,
}
platformDataJSON, _ := json.Marshal(platformData)
// Collect IPs for identity
var ips []string
for _, iface := range dh.NetworkInterfaces {
ips = append(ips, iface.Addresses...)
}
// Determine display name
displayName := dh.DisplayName
if dh.CustomDisplayName != "" {
displayName = dh.CustomDisplayName
}
// Determine cluster ID from swarm
clusterID := ""
if dh.Swarm != nil && dh.Swarm.ClusterID != "" {
clusterID = fmt.Sprintf("docker-swarm/%s", dh.Swarm.ClusterID)
}
return Resource{
ID: dh.ID,
Type: ResourceTypeDockerHost,
Name: dh.Hostname,
DisplayName: displayName,
PlatformID: dh.AgentID,
PlatformType: PlatformDocker,
SourceType: SourceAgent,
ClusterID: clusterID,
Status: mapDockerHostStatus(dh.Status),
CPU: &MetricValue{
Current: dh.CPUUsage,
},
Memory: memory,
Disk: disk,
Network: network,
Uptime: &dh.UptimeSeconds,
LastSeen: dh.LastSeen,
PlatformData: platformDataJSON,
Identity: &ResourceIdentity{
Hostname: dh.Hostname,
MachineID: dh.MachineID,
IPs: ips,
},
SchemaVersion: CurrentSchemaVersion,
}
}
// FromDockerContainer converts a DockerContainer to a unified Resource.
func FromDockerContainer(dc models.DockerContainer, hostID, hostName string) Resource {
// Build memory metric
memTotal := dc.MemoryLimit
memUsed := dc.MemoryUsage
var memory *MetricValue
if memTotal > 0 {
memory = &MetricValue{
Current: dc.MemoryPercent,
Total: &memTotal,
Used: &memUsed,
}
}
// Build platform data
ports := make([]ContainerPort, len(dc.Ports))
for i, p := range dc.Ports {
ports[i] = ContainerPort{
PrivatePort: p.PrivatePort,
PublicPort: p.PublicPort,
Protocol: p.Protocol,
IP: p.IP,
}
}
networks := make([]ContainerNetwork, len(dc.Networks))
for i, n := range dc.Networks {
networks[i] = ContainerNetwork{
Name: n.Name,
IPv4: n.IPv4,
IPv6: n.IPv6,
}
}
var podman *PodmanContainerInfo
if dc.Podman != nil {
podman = &PodmanContainerInfo{
PodName: dc.Podman.PodName,
PodID: dc.Podman.PodID,
Infra: dc.Podman.Infra,
ComposeProject: dc.Podman.ComposeProject,
ComposeService: dc.Podman.ComposeService,
}
}
platformData := DockerContainerPlatformData{
HostID: hostID,
HostName: hostName,
Image: dc.Image,
State: dc.State,
Status: dc.Status,
Health: dc.Health,
RestartCount: dc.RestartCount,
ExitCode: dc.ExitCode,
CreatedAt: dc.CreatedAt,
StartedAt: dc.StartedAt,
FinishedAt: dc.FinishedAt,
Labels: dc.Labels,
Ports: ports,
Networks: networks,
Podman: podman,
}
platformDataJSON, _ := json.Marshal(platformData)
// Create unique ID combining host and container
resourceID := fmt.Sprintf("%s/%s", hostID, dc.ID)
return Resource{
ID: resourceID,
Type: ResourceTypeDockerContainer,
Name: dc.Name,
PlatformID: hostID,
PlatformType: PlatformDocker,
SourceType: SourceAgent,
ParentID: hostID,
Status: mapDockerContainerStatus(dc.State),
CPU: &MetricValue{
Current: dc.CPUPercent,
},
Memory: memory,
Uptime: &dc.UptimeSeconds,
Labels: dc.Labels,
LastSeen: time.Now(), // Containers don't have their own LastSeen
PlatformData: platformDataJSON,
SchemaVersion: CurrentSchemaVersion,
}
}
// FromPBSInstance converts a PBS instance to a unified Resource.
func FromPBSInstance(pbs models.PBSInstance) Resource {
// Build memory metric
var memory *MetricValue
if pbs.MemoryTotal > 0 {
memory = &MetricValue{
Current: pbs.Memory,
Total: &pbs.MemoryTotal,
Used: &pbs.MemoryUsed,
}
}
platformData := PBSPlatformData{
Host: pbs.Host,
Version: pbs.Version,
ConnectionHealth: pbs.ConnectionHealth,
MemoryUsed: pbs.MemoryUsed,
MemoryTotal: pbs.MemoryTotal,
NumDatastores: len(pbs.Datastores),
}
platformDataJSON, _ := json.Marshal(platformData)
return Resource{
ID: pbs.ID,
Type: ResourceTypePBS,
Name: pbs.Name,
PlatformID: pbs.Host,
PlatformType: PlatformProxmoxPBS,
SourceType: SourceAPI,
Status: mapPBSStatus(pbs.Status, pbs.ConnectionHealth),
CPU: &MetricValue{
Current: pbs.CPU,
},
Memory: memory,
Uptime: &pbs.Uptime,
LastSeen: pbs.LastSeen,
PlatformData: platformDataJSON,
SchemaVersion: CurrentSchemaVersion,
}
}
// FromStorage converts a Proxmox Storage to a unified Resource.
func FromStorage(s models.Storage) Resource {
var disk *MetricValue
if s.Total > 0 {
disk = &MetricValue{
Current: s.Usage,
Total: &s.Total,
Used: &s.Used,
Free: &s.Free,
}
}
platformData := StoragePlatformData{
Instance: s.Instance,
Node: s.Node,
Nodes: s.Nodes,
Type: s.Type,
Content: s.Content,
Shared: s.Shared,
Enabled: s.Enabled,
Active: s.Active,
}
platformDataJSON, _ := json.Marshal(platformData)
status := StatusOnline
if !s.Active {
status = StatusOffline
} else if !s.Enabled {
status = StatusStopped
}
return Resource{
ID: s.ID,
Type: ResourceTypeStorage,
Name: s.Name,
PlatformID: s.Instance,
PlatformType: PlatformProxmoxPVE,
SourceType: SourceAPI,
ParentID: fmt.Sprintf("%s-%s", s.Instance, s.Node), // Format matches Node.ID: instance-nodename
Status: status,
Disk: disk,
LastSeen: time.Now(), // Storage doesn't have LastSeen
PlatformData: platformDataJSON,
SchemaVersion: CurrentSchemaVersion,
}
}
// Status mapping helpers
func mapNodeStatus(status string) ResourceStatus {
switch strings.ToLower(status) {
case "online":
return StatusOnline
case "offline":
return StatusOffline
default:
return StatusUnknown
}
}
func mapGuestStatus(status string) ResourceStatus {
switch strings.ToLower(status) {
case "running":
return StatusRunning
case "stopped":
return StatusStopped
case "paused":
return StatusPaused
default:
return StatusUnknown
}
}
func mapHostStatus(status string) ResourceStatus {
switch strings.ToLower(status) {
case "online":
return StatusOnline
case "offline":
return StatusOffline
case "degraded":
return StatusDegraded
default:
return StatusUnknown
}
}
func mapDockerHostStatus(status string) ResourceStatus {
switch strings.ToLower(status) {
case "online":
return StatusOnline
case "offline":
return StatusOffline
default:
return StatusUnknown
}
}
func mapDockerContainerStatus(state string) ResourceStatus {
switch strings.ToLower(state) {
case "running":
return StatusRunning
case "exited", "dead":
return StatusStopped
case "paused":
return StatusPaused
case "restarting", "created":
return StatusUnknown
default:
return StatusUnknown
}
}
func mapPBSStatus(status, connectionHealth string) ResourceStatus {
if connectionHealth != "healthy" {
return StatusDegraded
}
switch strings.ToLower(status) {
case "online":
return StatusOnline
case "offline":
return StatusOffline
default:
return StatusUnknown
}
}