diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index f2742fb..a572c44 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -58,7 +58,9 @@ export function GuestRow(props: GuestRowProps) { const hasNetworkInterfaces = createMemo(() => networkInterfaces().length > 0); const osName = createMemo(() => props.guest.osName?.trim() ?? ''); const osVersion = createMemo(() => props.guest.osVersion?.trim() ?? ''); + const agentVersion = createMemo(() => props.guest.agentVersion?.trim() ?? ''); const hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0); + const hasAgentInfo = createMemo(() => agentVersion().length > 0); // Update custom URL when prop changes createEffect(() => { @@ -99,6 +101,7 @@ export function GuestRow(props: GuestRowProps) { const hasDrawerContent = createMemo( () => hasOsInfo() || + hasAgentInfo() || ipAddresses().length > 0 || (memoryExtraLines()?.length ?? 0) > 0 || hasFilesystemDetails() || @@ -439,7 +442,7 @@ export function GuestRow(props: GuestRowProps) { } > <> - 0}> + 0}>
Guest Overview
@@ -456,6 +459,16 @@ export function GuestRow(props: GuestRowProps) {
+ +
+ + Agent + + + QEMU guest agent {agentVersion()} + +
+
0}>
diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index e1084dd..63c1003 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -5,6 +5,8 @@ export interface State { vms: VM[]; containers: Container[]; dockerHosts: DockerHost[]; + hosts: Host[]; + replicationJobs: ReplicationJob[]; storage: Storage[]; cephClusters: CephCluster[]; physicalDisks: PhysicalDisk[]; @@ -71,6 +73,7 @@ export interface VM { ipAddresses?: string[]; osName?: string; osVersion?: string; + agentVersion?: string; networkInterfaces?: GuestNetworkInterface[]; networkIn: number; networkOut: number; @@ -100,6 +103,7 @@ export interface Container { ipAddresses?: string[]; osName?: string; osVersion?: string; + agentVersion?: string; networkInterfaces?: GuestNetworkInterface[]; networkIn: number; networkOut: number; @@ -190,6 +194,42 @@ export interface DockerContainerNetwork { ipv6?: string; } +export interface ReplicationJob { + id: string; + instance: string; + jobId: string; + jobNumber?: number; + guest?: string; + guestId?: number; + guestName?: string; + guestType?: string; + guestNode?: string; + sourceNode?: string; + sourceStorage?: string; + targetNode?: string; + targetStorage?: string; + schedule?: string; + type?: string; + enabled: boolean; + state?: string; + status?: string; + lastSyncStatus?: string; + lastSyncTime?: number; + lastSyncUnix?: number; + lastSyncDurationSeconds?: number; + lastSyncDurationHuman?: string; + nextSyncTime?: number; + nextSyncUnix?: number; + durationSeconds?: number; + durationHuman?: string; + failCount?: number; + error?: string; + comment?: string; + removeJob?: string; + rateLimitMbps?: number; + polledAt?: number; +} + export interface Storage { id: string; name: string; diff --git a/frontend-modern/src/types/monitoring.ts b/frontend-modern/src/types/monitoring.ts index 6e93d48..c7775ec 100644 --- a/frontend-modern/src/types/monitoring.ts +++ b/frontend-modern/src/types/monitoring.ts @@ -84,6 +84,7 @@ export interface Guest { }; osName?: string; osVersion?: string; + agentVersion?: string; ipAddresses?: string[]; networkInterfaces?: GuestNetworkInterface[]; template?: boolean; diff --git a/internal/models/converters.go b/internal/models/converters.go index 679e005..9995600 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -2,6 +2,7 @@ package models import ( "strings" + "time" ) // ToFrontend converts a State to StateFrontend @@ -119,6 +120,10 @@ func (v VM) ToFrontend() VMFrontend { vm.OSVersion = v.OSVersion } + if v.AgentVersion != "" { + vm.AgentVersion = v.AgentVersion + } + if len(v.NetworkInterfaces) > 0 { vm.NetworkInterfaces = make([]GuestNetworkInterface, len(v.NetworkInterfaces)) copy(vm.NetworkInterfaces, v.NetworkInterfaces) @@ -381,6 +386,58 @@ func (c CephCluster) ToFrontend() CephClusterFrontend { return frontend } +// ToFrontend converts a replication job to a frontend representation. +func (r ReplicationJob) ToFrontend() ReplicationJobFrontend { + frontend := ReplicationJobFrontend{ + ID: r.ID, + Instance: r.Instance, + JobID: r.JobID, + JobNumber: r.JobNumber, + Guest: r.Guest, + GuestID: r.GuestID, + GuestName: r.GuestName, + GuestType: r.GuestType, + GuestNode: r.GuestNode, + SourceNode: r.SourceNode, + SourceStorage: r.SourceStorage, + TargetNode: r.TargetNode, + TargetStorage: r.TargetStorage, + Schedule: r.Schedule, + Type: r.Type, + Enabled: r.Enabled, + State: r.State, + Status: r.Status, + LastSyncStatus: r.LastSyncStatus, + LastSyncUnix: r.LastSyncUnix, + LastSyncDurationSeconds: r.LastSyncDurationSeconds, + LastSyncDurationHuman: r.LastSyncDurationHuman, + NextSyncUnix: r.NextSyncUnix, + DurationSeconds: r.DurationSeconds, + DurationHuman: r.DurationHuman, + FailCount: r.FailCount, + Error: r.Error, + Comment: r.Comment, + RemoveJob: r.RemoveJob, + RateLimitMbps: r.RateLimitMbps, + } + + if r.LastSyncTime != nil { + frontend.LastSyncTime = r.LastSyncTime.UnixMilli() + } + + if r.NextSyncTime != nil { + frontend.NextSyncTime = r.NextSyncTime.UnixMilli() + } + + polledAt := r.LastPolled + if polledAt.IsZero() { + polledAt = time.Now() + } + frontend.PolledAt = polledAt.UnixMilli() + + return frontend +} + // zeroIfNegative returns 0 for negative values (used for I/O metrics) func zeroIfNegative(val int64) int64 { if val < 0 { diff --git a/internal/models/models.go b/internal/models/models.go index 11c5d6c..107c694 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -10,27 +10,28 @@ import ( // State represents the current state of all monitored resources type State struct { mu sync.RWMutex - Nodes []Node `json:"nodes"` - VMs []VM `json:"vms"` - Containers []Container `json:"containers"` - DockerHosts []DockerHost `json:"dockerHosts"` - Hosts []Host `json:"hosts"` - Storage []Storage `json:"storage"` - CephClusters []CephCluster `json:"cephClusters"` - PhysicalDisks []PhysicalDisk `json:"physicalDisks"` - PBSInstances []PBSInstance `json:"pbs"` - PMGInstances []PMGInstance `json:"pmg"` - PBSBackups []PBSBackup `json:"pbsBackups"` - PMGBackups []PMGBackup `json:"pmgBackups"` - Backups Backups `json:"backups"` - Metrics []Metric `json:"metrics"` - PVEBackups PVEBackups `json:"pveBackups"` - Performance Performance `json:"performance"` - ConnectionHealth map[string]bool `json:"connectionHealth"` - Stats Stats `json:"stats"` - ActiveAlerts []Alert `json:"activeAlerts"` - RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` - LastUpdate time.Time `json:"lastUpdate"` + Nodes []Node `json:"nodes"` + VMs []VM `json:"vms"` + Containers []Container `json:"containers"` + DockerHosts []DockerHost `json:"dockerHosts"` + Hosts []Host `json:"hosts"` + Storage []Storage `json:"storage"` + CephClusters []CephCluster `json:"cephClusters"` + PhysicalDisks []PhysicalDisk `json:"physicalDisks"` + PBSInstances []PBSInstance `json:"pbs"` + PMGInstances []PMGInstance `json:"pmg"` + PBSBackups []PBSBackup `json:"pbsBackups"` + PMGBackups []PMGBackup `json:"pmgBackups"` + Backups Backups `json:"backups"` + ReplicationJobs []ReplicationJob `json:"replicationJobs"` + Metrics []Metric `json:"metrics"` + PVEBackups PVEBackups `json:"pveBackups"` + Performance Performance `json:"performance"` + ConnectionHealth map[string]bool `json:"connectionHealth"` + Stats Stats `json:"stats"` + ActiveAlerts []Alert `json:"activeAlerts"` + RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` + LastUpdate time.Time `json:"lastUpdate"` } // Alert represents an active alert (simplified for State) @@ -99,6 +100,7 @@ type VM struct { IPAddresses []string `json:"ipAddresses,omitempty"` OSName string `json:"osName,omitempty"` OSVersion string `json:"osVersion,omitempty"` + AgentVersion string `json:"agentVersion,omitempty"` NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"` NetworkIn int64 `json:"networkIn"` NetworkOut int64 `json:"networkOut"` @@ -724,6 +726,43 @@ type GuestSnapshot struct { SizeBytes int64 `json:"sizeBytes,omitempty"` } +// ReplicationJob represents the status of a Proxmox storage replication job. +type ReplicationJob struct { + ID string `json:"id"` + Instance string `json:"instance"` + JobID string `json:"jobId"` + JobNumber int `json:"jobNumber,omitempty"` + Guest string `json:"guest,omitempty"` + GuestID int `json:"guestId,omitempty"` + GuestName string `json:"guestName,omitempty"` + GuestType string `json:"guestType,omitempty"` + GuestNode string `json:"guestNode,omitempty"` + SourceNode string `json:"sourceNode,omitempty"` + SourceStorage string `json:"sourceStorage,omitempty"` + TargetNode string `json:"targetNode,omitempty"` + TargetStorage string `json:"targetStorage,omitempty"` + Schedule string `json:"schedule,omitempty"` + Type string `json:"type,omitempty"` + Enabled bool `json:"enabled"` + State string `json:"state,omitempty"` + Status string `json:"status,omitempty"` + LastSyncStatus string `json:"lastSyncStatus,omitempty"` + LastSyncTime *time.Time `json:"lastSyncTime,omitempty"` + LastSyncUnix int64 `json:"lastSyncUnix,omitempty"` + LastSyncDurationSeconds int `json:"lastSyncDurationSeconds,omitempty"` + LastSyncDurationHuman string `json:"lastSyncDurationHuman,omitempty"` + NextSyncTime *time.Time `json:"nextSyncTime,omitempty"` + NextSyncUnix int64 `json:"nextSyncUnix,omitempty"` + DurationSeconds int `json:"durationSeconds,omitempty"` + DurationHuman string `json:"durationHuman,omitempty"` + FailCount int `json:"failCount,omitempty"` + Error string `json:"error,omitempty"` + Comment string `json:"comment,omitempty"` + RemoveJob string `json:"removeJob,omitempty"` + RateLimitMbps *float64 `json:"rateLimitMbps,omitempty"` + LastPolled time.Time `json:"lastPolled"` +} + // Performance represents performance metrics type Performance struct { APICallDuration map[string]float64 `json:"apiCallDuration"` @@ -766,6 +805,7 @@ func NewState() *State { PBS: make([]PBSBackup, 0), PMG: make([]PMGBackup, 0), }, + ReplicationJobs: make([]ReplicationJob, 0), Metrics: make([]Metric, 0), PVEBackups: pveBackups, ConnectionHealth: make(map[string]bool), @@ -1446,6 +1486,55 @@ func (s *State) UpdateStorageBackupsForInstance(instanceName string, backups []S s.LastUpdate = time.Now() } +// UpdateReplicationJobsForInstance updates replication jobs for a specific instance. +func (s *State) UpdateReplicationJobsForInstance(instanceName string, jobs []ReplicationJob) { + s.mu.Lock() + defer s.mu.Unlock() + + filtered := make([]ReplicationJob, 0, len(s.ReplicationJobs)) + for _, job := range s.ReplicationJobs { + if job.Instance != instanceName { + filtered = append(filtered, job) + } + } + + now := time.Now() + for _, job := range jobs { + if job.Instance == "" { + job.Instance = instanceName + } + if job.JobID == "" { + job.JobID = job.ID + } + if job.LastPolled.IsZero() { + job.LastPolled = now + } + filtered = append(filtered, job) + } + + sort.Slice(filtered, func(i, j int) bool { + if filtered[i].Instance == filtered[j].Instance { + if filtered[i].GuestID == filtered[j].GuestID { + if filtered[i].JobNumber == filtered[j].JobNumber { + if filtered[i].JobID == filtered[j].JobID { + return filtered[i].ID < filtered[j].ID + } + return filtered[i].JobID < filtered[j].JobID + } + return filtered[i].JobNumber < filtered[j].JobNumber + } + if filtered[i].GuestID == 0 || filtered[j].GuestID == 0 { + return filtered[i].Guest < filtered[j].Guest + } + return filtered[i].GuestID < filtered[j].GuestID + } + return filtered[i].Instance < filtered[j].Instance + }) + + s.ReplicationJobs = filtered + s.LastUpdate = now +} + // UpdateGuestSnapshotsForInstance updates guest snapshots for a specific instance, merging with existing snapshots func (s *State) UpdateGuestSnapshotsForInstance(instanceName string, snapshots []GuestSnapshot) { s.mu.Lock() diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index 7bc2166..db5d6d9 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -50,6 +50,7 @@ type VMFrontend struct { DiskStatusReason string `json:"diskStatusReason,omitempty"` // Why disk stats are unavailable OSName string `json:"osName,omitempty"` OSVersion string `json:"osVersion,omitempty"` + AgentVersion string `json:"agentVersion,omitempty"` NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"` IPAddresses []string `json:"ipAddresses,omitempty"` NetIn int64 `json:"networkIn"` // Maps to NetworkIn (camelCase for frontend) @@ -219,25 +220,63 @@ type CephClusterFrontend struct { LastUpdated int64 `json:"lastUpdated"` } +// ReplicationJobFrontend represents a replication job for the frontend. +type ReplicationJobFrontend struct { + ID string `json:"id"` + Instance string `json:"instance"` + JobID string `json:"jobId"` + JobNumber int `json:"jobNumber,omitempty"` + Guest string `json:"guest,omitempty"` + GuestID int `json:"guestId,omitempty"` + GuestName string `json:"guestName,omitempty"` + GuestType string `json:"guestType,omitempty"` + GuestNode string `json:"guestNode,omitempty"` + SourceNode string `json:"sourceNode,omitempty"` + SourceStorage string `json:"sourceStorage,omitempty"` + TargetNode string `json:"targetNode,omitempty"` + TargetStorage string `json:"targetStorage,omitempty"` + Schedule string `json:"schedule,omitempty"` + Type string `json:"type,omitempty"` + Enabled bool `json:"enabled"` + State string `json:"state,omitempty"` + Status string `json:"status,omitempty"` + LastSyncStatus string `json:"lastSyncStatus,omitempty"` + LastSyncTime int64 `json:"lastSyncTime,omitempty"` + LastSyncUnix int64 `json:"lastSyncUnix,omitempty"` + LastSyncDurationSeconds int `json:"lastSyncDurationSeconds,omitempty"` + LastSyncDurationHuman string `json:"lastSyncDurationHuman,omitempty"` + NextSyncTime int64 `json:"nextSyncTime,omitempty"` + NextSyncUnix int64 `json:"nextSyncUnix,omitempty"` + DurationSeconds int `json:"durationSeconds,omitempty"` + DurationHuman string `json:"durationHuman,omitempty"` + FailCount int `json:"failCount,omitempty"` + Error string `json:"error,omitempty"` + Comment string `json:"comment,omitempty"` + RemoveJob string `json:"removeJob,omitempty"` + RateLimitMbps *float64 `json:"rateLimitMbps,omitempty"` + PolledAt int64 `json:"polledAt,omitempty"` +} + // StateFrontend represents the state with frontend-friendly field names type StateFrontend struct { - Nodes []NodeFrontend `json:"nodes"` - VMs []VMFrontend `json:"vms"` - Containers []ContainerFrontend `json:"containers"` - DockerHosts []DockerHostFrontend `json:"dockerHosts"` - Storage []StorageFrontend `json:"storage"` - CephClusters []CephClusterFrontend `json:"cephClusters"` - PhysicalDisks []PhysicalDisk `json:"physicalDisks"` - PBS []PBSInstance `json:"pbs"` // Keep as is - PMG []PMGInstance `json:"pmg"` - PBSBackups []PBSBackup `json:"pbsBackups"` - PMGBackups []PMGBackup `json:"pmgBackups"` - Backups Backups `json:"backups"` - ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts - Metrics map[string]any `json:"metrics"` // Empty object for now - PVEBackups PVEBackups `json:"pveBackups"` // Keep as is - Performance map[string]any `json:"performance"` // Empty object for now - ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is - Stats map[string]any `json:"stats"` // Empty object for now - LastUpdate int64 `json:"lastUpdate"` // Unix timestamp + Nodes []NodeFrontend `json:"nodes"` + VMs []VMFrontend `json:"vms"` + Containers []ContainerFrontend `json:"containers"` + DockerHosts []DockerHostFrontend `json:"dockerHosts"` + Storage []StorageFrontend `json:"storage"` + CephClusters []CephClusterFrontend `json:"cephClusters"` + PhysicalDisks []PhysicalDisk `json:"physicalDisks"` + PBS []PBSInstance `json:"pbs"` // Keep as is + PMG []PMGInstance `json:"pmg"` + PBSBackups []PBSBackup `json:"pbsBackups"` + PMGBackups []PMGBackup `json:"pmgBackups"` + Backups Backups `json:"backups"` + ReplicationJobs []ReplicationJobFrontend `json:"replicationJobs"` + ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts + Metrics map[string]any `json:"metrics"` // Empty object for now + PVEBackups PVEBackups `json:"pveBackups"` // Keep as is + Performance map[string]any `json:"performance"` // Empty object for now + ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is + Stats map[string]any `json:"stats"` // Empty object for now + LastUpdate int64 `json:"lastUpdate"` // Unix timestamp } diff --git a/internal/monitoring/harness_integration.go b/internal/monitoring/harness_integration.go index 91eb70d..acb860f 100644 --- a/internal/monitoring/harness_integration.go +++ b/internal/monitoring/harness_integration.go @@ -393,6 +393,9 @@ func (noopPVEClient) GetStorage(ctx context.Context, node string) ([]proxmox.Sto } func (noopPVEClient) GetAllStorage(ctx context.Context) ([]proxmox.Storage, error) { return nil, nil } func (noopPVEClient) GetBackupTasks(ctx context.Context) ([]proxmox.Task, error) { return nil, nil } +func (noopPVEClient) GetReplicationStatus(ctx context.Context) ([]proxmox.ReplicationJob, error) { + return nil, nil +} func (noopPVEClient) GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error) { return nil, nil } @@ -421,6 +424,10 @@ func (noopPVEClient) GetVMNetworkInterfaces(ctx context.Context, node string, vm func (noopPVEClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { return map[string]interface{}{}, nil } + +func (noopPVEClient) GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) { + return "", nil +} func (noopPVEClient) GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error) { return nil, nil } diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 0184054..48d1bd1 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -48,6 +48,7 @@ type PVEClientInterface interface { GetStorage(ctx context.Context, node string) ([]proxmox.Storage, error) GetAllStorage(ctx context.Context) ([]proxmox.Storage, error) GetBackupTasks(ctx context.Context) ([]proxmox.Task, error) + GetReplicationStatus(ctx context.Context) ([]proxmox.ReplicationJob, error) GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error) GetVMSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error) GetContainerSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error) @@ -58,6 +59,7 @@ type PVEClientInterface interface { GetVMFSInfo(ctx context.Context, node string, vmid int) ([]proxmox.VMFileSystem, error) GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.VMNetworkInterface, error) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) + GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error) GetZFSPoolsWithDetails(ctx context.Context, node string) ([]proxmox.ZFSPoolInfo, error) GetDisks(ctx context.Context, node string) ([]proxmox.Disk, error) @@ -255,6 +257,34 @@ func isLikelyIPAddress(value string) bool { return false } +func ensureClusterEndpointURL(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + + lower := strings.ToLower(value) + if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + return value + } + + if _, _, err := net.SplitHostPort(value); err == nil { + return "https://" + value + } + + return "https://" + net.JoinHostPort(value, "8006") +} + +func clusterEndpointEffectiveURL(endpoint config.ClusterEndpoint) string { + if endpoint.Host != "" { + return ensureClusterEndpointURL(endpoint.Host) + } + if endpoint.IP != "" { + return ensureClusterEndpointURL(endpoint.IP) + } + return "" +} + // PollExecutor defines the contract for executing polling tasks. type PollExecutor interface { Execute(ctx context.Context, task PollTask) @@ -528,6 +558,7 @@ type guestMetadataCacheEntry struct { networkInterfaces []models.GuestNetworkInterface osName string osVersion string + agentVersion string fetchedAt time.Time } @@ -1283,15 +1314,15 @@ func sortContent(content string) string { return strings.Join(parts, ",") } -func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientInterface, instanceName, nodeName, vmName string, vmid int, vmStatus *proxmox.VMStatus) ([]string, []models.GuestNetworkInterface, string, string) { +func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientInterface, instanceName, nodeName, vmName string, vmid int, vmStatus *proxmox.VMStatus) ([]string, []models.GuestNetworkInterface, string, string, string) { if vmStatus == nil || client == nil { m.clearGuestMetadataCache(instanceName, nodeName, vmid) - return nil, nil, "", "" + return nil, nil, "", "", "" } if vmStatus.Agent <= 0 { m.clearGuestMetadataCache(instanceName, nodeName, vmid) - return nil, nil, "", "" + return nil, nil, "", "", "" } key := guestMetadataCacheKey(instanceName, nodeName, vmid) @@ -1302,7 +1333,7 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI m.guestMetadataMu.RUnlock() if ok && now.Sub(cached.fetchedAt) < guestMetadataCacheTTL { - return cloneStringSlice(cached.ipAddresses), cloneGuestNetworkInterfaces(cached.networkInterfaces), cached.osName, cached.osVersion + return cloneStringSlice(cached.ipAddresses), cloneGuestNetworkInterfaces(cached.networkInterfaces), cached.osName, cached.osVersion, cached.agentVersion } // Start with cached values as fallback in case new calls fail @@ -1310,6 +1341,7 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI networkIfaces := cloneGuestNetworkInterfaces(cached.networkInterfaces) osName := cached.osName osVersion := cached.osVersion + agentVersion := cached.agentVersion ifaceCtx, cancelIface := context.WithTimeout(ctx, 5*time.Second) interfaces, err := client.GetVMNetworkInterfaces(ifaceCtx, nodeName, vmid) @@ -1345,11 +1377,28 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI osVersion = "" } + versionCtx, cancelVersion := context.WithTimeout(ctx, 3*time.Second) + version, err := client.GetVMAgentVersion(versionCtx, nodeName, vmid) + cancelVersion() + if err != nil { + log.Debug(). + Str("instance", instanceName). + Str("vm", vmName). + Int("vmid", vmid). + Err(err). + Msg("Guest agent version unavailable") + } else if version != "" { + agentVersion = version + } else { + agentVersion = "" + } + entry := guestMetadataCacheEntry{ ipAddresses: cloneStringSlice(ipAddresses), networkInterfaces: cloneGuestNetworkInterfaces(networkIfaces), osName: osName, osVersion: osVersion, + agentVersion: agentVersion, fetchedAt: time.Now(), } @@ -1360,7 +1409,7 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI m.guestMetadataCache[key] = entry m.guestMetadataMu.Unlock() - return ipAddresses, networkIfaces, osName, osVersion + return ipAddresses, networkIfaces, osName, osVersion, agentVersion } func guestMetadataCacheKey(instanceName, nodeName string, vmid int) string { @@ -1875,7 +1924,7 @@ func New(cfg *config.Config) (*Monitor, error) { Bool("hasToken", pve.TokenName != ""). Msg("Configuring PVE instance") - // Check if this is a cluster + // Check if this is a cluster if pve.IsCluster && len(pve.ClusterEndpoints) > 0 { // For clusters, check if endpoints have IPs/resolvable hosts // If not, use the main host for all connections (Proxmox will route cluster API calls) @@ -1883,34 +1932,27 @@ func New(cfg *config.Config) (*Monitor, error) { endpoints := make([]string, 0, len(pve.ClusterEndpoints)) for _, ep := range pve.ClusterEndpoints { - // Use IP if available, otherwise use host - host := ep.IP - if host == "" { - host = ep.Host - } - - // Skip if no host information - if host == "" { + effectiveURL := clusterEndpointEffectiveURL(ep) + if effectiveURL == "" { log.Warn(). Str("node", ep.NodeName). Msg("Skipping cluster endpoint with no host/IP") continue } - // Check if we have a valid IP or a fully qualified hostname (with dots) - if strings.Contains(host, ".") || net.ParseIP(host) != nil { - hasValidEndpoints = true - } - - // Ensure we have the full URL - if !strings.HasPrefix(host, "http") { - if pve.VerifySSL { - host = fmt.Sprintf("https://%s:8006", host) - } else { - host = fmt.Sprintf("https://%s:8006", host) + if parsed, err := url.Parse(effectiveURL); err == nil { + hostname := parsed.Hostname() + if hostname != "" && (strings.Contains(hostname, ".") || net.ParseIP(hostname) != nil) { + hasValidEndpoints = true + } + } else { + hostname := normalizeEndpointHost(effectiveURL) + if hostname != "" && (strings.Contains(hostname, ".") || net.ParseIP(hostname) != nil) { + hasValidEndpoints = true } } - endpoints = append(endpoints, host) + + endpoints = append(endpoints, effectiveURL) } // If endpoints are just node names (not FQDNs or IPs), use main host only @@ -1920,10 +1962,11 @@ func New(cfg *config.Config) (*Monitor, error) { Str("instance", pve.Name). Str("mainHost", pve.Host). Msg("Cluster endpoints are not resolvable, using main host for all cluster operations") - endpoints = []string{pve.Host} - if !strings.HasPrefix(endpoints[0], "http") { - endpoints[0] = fmt.Sprintf("https://%s:8006", endpoints[0]) + fallback := ensureClusterEndpointURL(pve.Host) + if fallback == "" { + fallback = ensureClusterEndpointURL(pve.Host) } + endpoints = []string{fallback} } log.Info(). @@ -3468,13 +3511,24 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie for _, node := range nodes { nodeStart := time.Now() displayName := getNodeDisplayName(instanceCfg, node.Node) + connectionHost := instanceCfg.Host + if instanceCfg.IsCluster && len(instanceCfg.ClusterEndpoints) > 0 { + for _, ep := range instanceCfg.ClusterEndpoints { + if strings.EqualFold(ep.NodeName, node.Node) { + if effective := clusterEndpointEffectiveURL(ep); effective != "" { + connectionHost = effective + } + break + } + } + } modelNode := models.Node{ ID: instanceName + "-" + node.Node, Name: node.Node, DisplayName: displayName, Instance: instanceName, - Host: instanceCfg.Host, // Add the actual host URL + Host: connectionHost, Status: node.Status, Type: "node", CPU: safeFloat(node.CPU), // Already in percentage @@ -3829,27 +3883,22 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie tempCtx, tempCancel := context.WithTimeout(ctx, 30*time.Second) // Increased to accommodate SSH operations via proxy // Determine SSH hostname to use (most robust approach): - // 1. For cluster nodes: Try to find the node's specific IP or host from ClusterEndpoints - // 2. For standalone nodes: Use the Host URL from config - // 3. Fallback: Use node name (works for simple DNS/hosts setups) - sshHost := node.Node // Default fallback + // Prefer the resolved host for this node, with cluster overrides when available. + sshHost := modelNode.Host if modelNode.IsClusterMember && instanceCfg.IsCluster { - // Look up this specific node in cluster endpoints to get its individual address for _, ep := range instanceCfg.ClusterEndpoints { - if ep.NodeName == node.Node { - // Prefer IP address for reliability - if ep.IP != "" { - sshHost = ep.IP - } else if ep.Host != "" { - sshHost = ep.Host + if strings.EqualFold(ep.NodeName, node.Node) { + if effective := clusterEndpointEffectiveURL(ep); effective != "" { + sshHost = effective } break } } - } else if !modelNode.IsClusterMember { - // Standalone node: use the Host URL from config - sshHost = modelNode.Host + } + + if strings.TrimSpace(sshHost) == "" { + sshHost = node.Node } temp, err := m.tempCollector.CollectTemperature(tempCtx, sshHost, node.Node) @@ -4487,7 +4536,7 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam var individualDisks []models.Disk // Store individual filesystems for multi-disk monitoring var ipAddresses []string var networkInterfaces []models.GuestNetworkInterface - var osName, osVersion string + var osName, osVersion, agentVersion string if res.Type == "qemu" { // Skip templates if configured @@ -4593,7 +4642,7 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam } // Gather guest metadata from the agent when available - guestIPs, guestIfaces, guestOSName, guestOSVersion := m.fetchGuestAgentMetadata(ctx, client, instanceName, res.Node, res.Name, res.VMID, detailedStatus) + guestIPs, guestIfaces, guestOSName, guestOSVersion, guestAgentVersion := m.fetchGuestAgentMetadata(ctx, client, instanceName, res.Node, res.Name, res.VMID, detailedStatus) if len(guestIPs) > 0 { ipAddresses = guestIPs } @@ -4606,6 +4655,9 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam if guestOSVersion != "" { osVersion = guestOSVersion } + if guestAgentVersion != "" { + agentVersion = guestAgentVersion + } // Always try to get filesystem info if agent is enabled // Prefer guest agent data over cluster/resources data for accuracy @@ -4951,6 +5003,7 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam IPAddresses: ipAddresses, OSName: osName, OSVersion: osVersion, + AgentVersion: agentVersion, NetworkInterfaces: networkInterfaces, NetworkIn: maxInt64(0, int64(netInRate)), NetworkOut: maxInt64(0, int64(netOutRate)), @@ -5111,6 +5164,8 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam m.state.UpdateContainersForInstance(instanceName, allContainers) } + m.pollReplicationStatus(ctx, instanceName, client, allVMs) + log.Info(). Str("instance", instanceName). Int("vms", len(allVMs)). @@ -5163,6 +5218,169 @@ func (m *Monitor) pollBackupTasks(ctx context.Context, instanceName string, clie m.state.UpdateBackupTasksForInstance(instanceName, backupTasks) } +// pollReplicationStatus polls storage replication jobs for a PVE instance. +func (m *Monitor) pollReplicationStatus(ctx context.Context, instanceName string, client PVEClientInterface, vms []models.VM) { + log.Debug().Str("instance", instanceName).Msg("Polling replication status") + + jobs, err := client.GetReplicationStatus(ctx) + if err != nil { + errMsg := err.Error() + lowerMsg := strings.ToLower(errMsg) + if strings.Contains(errMsg, "501") || strings.Contains(errMsg, "404") || strings.Contains(lowerMsg, "not implemented") || strings.Contains(lowerMsg, "not supported") { + log.Debug(). + Str("instance", instanceName). + Msg("Replication API not available on this Proxmox instance") + m.state.UpdateReplicationJobsForInstance(instanceName, []models.ReplicationJob{}) + return + } + + monErr := errors.WrapAPIError("get_replication_status", instanceName, err, 0) + log.Warn(). + Err(monErr). + Str("instance", instanceName). + Msg("Failed to get replication status") + return + } + + if len(jobs) == 0 { + m.state.UpdateReplicationJobsForInstance(instanceName, []models.ReplicationJob{}) + return + } + + vmByID := make(map[int]models.VM, len(vms)) + for _, vm := range vms { + vmByID[vm.VMID] = vm + } + + converted := make([]models.ReplicationJob, 0, len(jobs)) + now := time.Now() + + for idx, job := range jobs { + guestID := job.GuestID + if guestID == 0 { + if parsed, err := strconv.Atoi(strings.TrimSpace(job.Guest)); err == nil { + guestID = parsed + } + } + + guestName := "" + guestType := "" + guestNode := "" + if guestID > 0 { + if vm, ok := vmByID[guestID]; ok { + guestName = vm.Name + guestType = vm.Type + guestNode = vm.Node + } + } + if guestNode == "" { + guestNode = strings.TrimSpace(job.Source) + } + + sourceNode := strings.TrimSpace(job.Source) + if sourceNode == "" { + sourceNode = guestNode + } + + targetNode := strings.TrimSpace(job.Target) + + var lastSyncTime *time.Time + if job.LastSyncTime != nil && !job.LastSyncTime.IsZero() { + t := job.LastSyncTime.UTC() + lastSyncTime = &t + } + + var nextSyncTime *time.Time + if job.NextSyncTime != nil && !job.NextSyncTime.IsZero() { + t := job.NextSyncTime.UTC() + nextSyncTime = &t + } + + lastSyncDurationHuman := job.LastSyncDurationHuman + if lastSyncDurationHuman == "" && job.LastSyncDurationSeconds > 0 { + lastSyncDurationHuman = formatSeconds(job.LastSyncDurationSeconds) + } + durationHuman := job.DurationHuman + if durationHuman == "" && job.DurationSeconds > 0 { + durationHuman = formatSeconds(job.DurationSeconds) + } + + rateLimit := copyFloatPointer(job.RateLimitMbps) + + status := job.Status + if status == "" { + status = job.State + } + + jobID := strings.TrimSpace(job.ID) + if jobID == "" { + if job.JobNumber > 0 && guestID > 0 { + jobID = fmt.Sprintf("%d-%d", guestID, job.JobNumber) + } else { + jobID = fmt.Sprintf("job-%s-%d", instanceName, idx) + } + } + + uniqueID := fmt.Sprintf("%s-%s", instanceName, jobID) + + converted = append(converted, models.ReplicationJob{ + ID: uniqueID, + Instance: instanceName, + JobID: jobID, + JobNumber: job.JobNumber, + Guest: job.Guest, + GuestID: guestID, + GuestName: guestName, + GuestType: guestType, + GuestNode: guestNode, + SourceNode: sourceNode, + SourceStorage: job.SourceStorage, + TargetNode: targetNode, + TargetStorage: job.TargetStorage, + Schedule: job.Schedule, + Type: job.Type, + Enabled: job.Enabled, + State: job.State, + Status: status, + LastSyncStatus: job.LastSyncStatus, + LastSyncTime: lastSyncTime, + LastSyncUnix: job.LastSyncUnix, + LastSyncDurationSeconds: job.LastSyncDurationSeconds, + LastSyncDurationHuman: lastSyncDurationHuman, + NextSyncTime: nextSyncTime, + NextSyncUnix: job.NextSyncUnix, + DurationSeconds: job.DurationSeconds, + DurationHuman: durationHuman, + FailCount: job.FailCount, + Error: job.Error, + Comment: job.Comment, + RemoveJob: job.RemoveJob, + RateLimitMbps: rateLimit, + LastPolled: now, + }) + } + + m.state.UpdateReplicationJobsForInstance(instanceName, converted) +} + +func formatSeconds(total int) string { + if total <= 0 { + return "" + } + hours := total / 3600 + minutes := (total % 3600) / 60 + seconds := total % 60 + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) +} + +func copyFloatPointer(src *float64) *float64 { + if src == nil { + return nil + } + val := *src + return &val +} + // pollPBSInstance polls a single PBS instance func (m *Monitor) pollPBSInstance(ctx context.Context, instanceName string, client *pbs.Client) { start := time.Now() diff --git a/internal/monitoring/monitor_polling.go b/internal/monitoring/monitor_polling.go index 30b5968..8473fa6 100644 --- a/internal/monitoring/monitor_polling.go +++ b/internal/monitoring/monitor_polling.go @@ -65,21 +65,21 @@ func (m *Monitor) describeInstancesForScheduler() []InstanceDescriptor { Name: name, Type: InstanceTypePBS, } - if m.scheduler != nil { - if last, ok := m.scheduler.LastScheduled(InstanceTypePBS, name); ok { - desc.LastScheduled = last.NextRun - desc.LastInterval = last.Interval + if m.scheduler != nil { + if last, ok := m.scheduler.LastScheduled(InstanceTypePBS, name); ok { + desc.LastScheduled = last.NextRun + desc.LastInterval = last.Interval + } } - } - if m.stalenessTracker != nil { - if snap, ok := m.stalenessTracker.snapshot(InstanceTypePBS, name); ok { - desc.LastSuccess = snap.LastSuccess - desc.LastFailure = snap.LastError - desc.Metadata = map[string]any{"changeHash": snap.ChangeHash} + if m.stalenessTracker != nil { + if snap, ok := m.stalenessTracker.snapshot(InstanceTypePBS, name); ok { + desc.LastSuccess = snap.LastSuccess + desc.LastFailure = snap.LastError + desc.Metadata = map[string]any{"changeHash": snap.ChangeHash} + } } + descriptors = append(descriptors, desc) } - descriptors = append(descriptors, desc) - } } if len(m.pmgClients) > 0 { @@ -93,21 +93,21 @@ func (m *Monitor) describeInstancesForScheduler() []InstanceDescriptor { Name: name, Type: InstanceTypePMG, } - if m.scheduler != nil { - if last, ok := m.scheduler.LastScheduled(InstanceTypePMG, name); ok { - desc.LastScheduled = last.NextRun - desc.LastInterval = last.Interval + if m.scheduler != nil { + if last, ok := m.scheduler.LastScheduled(InstanceTypePMG, name); ok { + desc.LastScheduled = last.NextRun + desc.LastInterval = last.Interval + } } - } - if m.stalenessTracker != nil { - if snap, ok := m.stalenessTracker.snapshot(InstanceTypePMG, name); ok { - desc.LastSuccess = snap.LastSuccess - desc.LastFailure = snap.LastError - desc.Metadata = map[string]any{"changeHash": snap.ChangeHash} + if m.stalenessTracker != nil { + if snap, ok := m.stalenessTracker.snapshot(InstanceTypePMG, name); ok { + desc.LastSuccess = snap.LastSuccess + desc.LastFailure = snap.LastError + desc.Metadata = map[string]any{"changeHash": snap.ChangeHash} + } } + descriptors = append(descriptors, desc) } - descriptors = append(descriptors, desc) - } } return descriptors @@ -280,7 +280,7 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli var vmStatus *proxmox.VMStatus var ipAddresses []string var networkInterfaces []models.GuestNetworkInterface - var osName, osVersion string + var osName, osVersion, guestAgentVersion string if vm.Status == "running" { // Try to get detailed VM status (but don't wait too long) @@ -394,7 +394,7 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli } if vm.Status == "running" && vmStatus != nil { - guestIPs, guestIfaces, guestOSName, guestOSVersion := m.fetchGuestAgentMetadata(ctx, client, instanceName, n.Node, vm.Name, vm.VMID, vmStatus) + guestIPs, guestIfaces, guestOSName, guestOSVersion, agentVersion := m.fetchGuestAgentMetadata(ctx, client, instanceName, n.Node, vm.Name, vm.VMID, vmStatus) if len(guestIPs) > 0 { ipAddresses = guestIPs } @@ -407,6 +407,9 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli if guestOSVersion != "" { osVersion = guestOSVersion } + if agentVersion != "" { + guestAgentVersion = agentVersion + } } // Calculate I/O rates after we have the actual values @@ -716,6 +719,7 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli IPAddresses: ipAddresses, OSName: osName, OSVersion: osVersion, + AgentVersion: guestAgentVersion, NetworkInterfaces: networkInterfaces, } diff --git a/internal/monitoring/monitor_snapshots_test.go b/internal/monitoring/monitor_snapshots_test.go index 7649d44..78fe69d 100644 --- a/internal/monitoring/monitor_snapshots_test.go +++ b/internal/monitoring/monitor_snapshots_test.go @@ -35,6 +35,9 @@ func (f fakeSnapshotClient) GetAllStorage(ctx context.Context) ([]proxmox.Storag func (f fakeSnapshotClient) GetBackupTasks(ctx context.Context) ([]proxmox.Task, error) { return nil, nil } +func (f fakeSnapshotClient) GetReplicationStatus(ctx context.Context) ([]proxmox.ReplicationJob, error) { + return nil, nil +} func (f fakeSnapshotClient) GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error) { if storageContents, ok := f.contents[node]; ok { return storageContents[storage], nil @@ -66,6 +69,10 @@ func (f fakeSnapshotClient) GetVMNetworkInterfaces(ctx context.Context, node str func (f fakeSnapshotClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { return map[string]interface{}{}, nil } + +func (f fakeSnapshotClient) GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) { + return "", nil +} func (f fakeSnapshotClient) GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error) { return nil, nil } diff --git a/internal/monitoring/monitor_storage_test.go b/internal/monitoring/monitor_storage_test.go index 9f68ed6..7b4d701 100644 --- a/internal/monitoring/monitor_storage_test.go +++ b/internal/monitoring/monitor_storage_test.go @@ -53,6 +53,10 @@ func (f *fakeStorageClient) GetBackupTasks(ctx context.Context) ([]proxmox.Task, return nil, nil } +func (f *fakeStorageClient) GetReplicationStatus(ctx context.Context) ([]proxmox.ReplicationJob, error) { + return nil, nil +} + func (f *fakeStorageClient) GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error) { return nil, nil } @@ -93,6 +97,10 @@ func (f *fakeStorageClient) GetVMAgentInfo(ctx context.Context, node string, vmi return nil, nil } +func (f *fakeStorageClient) GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) { + return "", nil +} + func (f *fakeStorageClient) GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error) { return nil, nil } @@ -198,4 +206,3 @@ func TestPollStorageWithNodesOptimizedRecordsMetricsAndAlerts(t *testing.T) { t.Fatalf("expected storage usage alert to be active") } } - diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 393e2b2..7906e70 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" "net/url" "strconv" @@ -52,6 +53,70 @@ func (f *FlexInt) UnmarshalJSON(data []byte) error { return nil } +func coerceUint64(field string, value interface{}) (uint64, error) { + switch v := value.(type) { + case nil: + return 0, nil + case float64: + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0, fmt.Errorf("invalid float value for %s", field) + } + if v <= 0 { + return 0, nil + } + if v >= math.MaxUint64 { + return math.MaxUint64, nil + } + return uint64(math.Round(v)), nil + case int: + if v < 0 { + return 0, nil + } + return uint64(v), nil + case int64: + if v < 0 { + return 0, nil + } + return uint64(v), nil + case int32: + if v < 0 { + return 0, nil + } + return uint64(v), nil + case uint32: + return uint64(v), nil + case uint64: + return v, nil + case json.Number: + return coerceUint64(field, string(v)) + case string: + s := strings.TrimSpace(v) + if s == "" || strings.EqualFold(s, "null") { + return 0, nil + } + s = strings.Trim(s, "\"'") + s = strings.TrimSpace(s) + if s == "" || strings.EqualFold(s, "null") { + return 0, nil + } + s = strings.ReplaceAll(s, ",", "") + if strings.ContainsAny(s, ".eE") { + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse float for %s: %w", field, err) + } + return coerceUint64(field, f) + } + val, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse uint for %s: %w", field, err) + } + return val, nil + default: + return 0, fmt.Errorf("unsupported type %T for field %s", value, field) + } +} + // Client represents a Proxmox VE API client type Client struct { baseURL string @@ -431,6 +496,69 @@ type MemoryStatus struct { Shared uint64 `json:"shared"` // Shared memory (informational) } +func (m *MemoryStatus) UnmarshalJSON(data []byte) error { + type rawMemoryStatus struct { + Total interface{} `json:"total"` + Used interface{} `json:"used"` + Free interface{} `json:"free"` + Available interface{} `json:"available"` + Avail interface{} `json:"avail"` + Buffers interface{} `json:"buffers"` + Cached interface{} `json:"cached"` + Shared interface{} `json:"shared"` + } + + var raw rawMemoryStatus + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + total, err := coerceUint64("total", raw.Total) + if err != nil { + return err + } + used, err := coerceUint64("used", raw.Used) + if err != nil { + return err + } + free, err := coerceUint64("free", raw.Free) + if err != nil { + return err + } + available, err := coerceUint64("available", raw.Available) + if err != nil { + return err + } + avail, err := coerceUint64("avail", raw.Avail) + if err != nil { + return err + } + buffers, err := coerceUint64("buffers", raw.Buffers) + if err != nil { + return err + } + cached, err := coerceUint64("cached", raw.Cached) + if err != nil { + return err + } + shared, err := coerceUint64("shared", raw.Shared) + if err != nil { + return err + } + + *m = MemoryStatus{ + Total: total, + Used: used, + Free: free, + Available: available, + Avail: avail, + Buffers: buffers, + Cached: cached, + Shared: shared, + } + return nil +} + // EffectiveAvailable returns the best-effort estimate of reclaimable memory. // Prefer the dedicated "available"/"avail" fields when present, otherwise derive // from free + buffers + cached which mirrors Linux's MemAvailable calculation. @@ -1039,6 +1167,52 @@ func (c *Client) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map return result.Data, nil } +// GetVMAgentVersion returns the guest agent version information for a VM if available. +func (c *Client) GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) { + resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/info", node, vmid)) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result struct { + Data struct { + Result map[string]interface{} `json:"result"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + extractVersion := func(val interface{}) string { + switch v := val.(type) { + case string: + return strings.TrimSpace(v) + case map[string]interface{}: + if ver, ok := v["version"]; ok { + if s, ok := ver.(string); ok { + return strings.TrimSpace(s) + } + } + } + return "" + } + + if result.Data.Result != nil { + if version := extractVersion(result.Data.Result["version"]); version != "" { + return version, nil + } + if qemuGA, ok := result.Data.Result["qemu-ga"]; ok { + if version := extractVersion(qemuGA); version != "" { + return version, nil + } + } + } + + return "", nil +} + // VMFileSystem represents filesystem information from QEMU guest agent type VMFileSystem struct { Name string `json:"name"` diff --git a/pkg/proxmox/cluster_client.go b/pkg/proxmox/cluster_client.go index c8915e3..0d6dae0 100644 --- a/pkg/proxmox/cluster_client.go +++ b/pkg/proxmox/cluster_client.go @@ -814,6 +814,19 @@ func (cc *ClusterClient) GetBackupTasks(ctx context.Context) ([]Task, error) { return result, err } +func (cc *ClusterClient) GetReplicationStatus(ctx context.Context) ([]ReplicationJob, error) { + var result []ReplicationJob + err := cc.executeWithFailover(ctx, func(client *Client) error { + jobs, err := client.GetReplicationStatus(ctx) + if err != nil { + return err + } + result = jobs + return nil + }) + return result, err +} + func (cc *ClusterClient) GetStorageContent(ctx context.Context, node, storage string) ([]StorageContent, error) { var result []StorageContent err := cc.executeWithFailover(ctx, func(client *Client) error { @@ -920,6 +933,20 @@ func (cc *ClusterClient) GetVMAgentInfo(ctx context.Context, node string, vmid i return result, err } +// GetVMAgentVersion returns the guest agent version for the VM. +func (cc *ClusterClient) GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) { + var version string + err := cc.executeWithFailover(ctx, func(client *Client) error { + v, err := client.GetVMAgentVersion(ctx, node, vmid) + if err != nil { + return err + } + version = v + return nil + }) + return version, err +} + // GetVMFSInfo returns filesystem information from QEMU guest agent func (cc *ClusterClient) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]VMFileSystem, error) { var result []VMFileSystem