Add qemu guest agent version metadata

This commit is contained in:
rcourtman 2025-10-22 15:24:07 +00:00
parent f8b6aa6c97
commit c9543e8a7e
13 changed files with 797 additions and 114 deletions

View file

@ -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) {
}
>
<>
<Show when={hasOsInfo() || ipAddresses().length > 0}>
<Show when={hasOsInfo() || hasAgentInfo() || ipAddresses().length > 0}>
<div class="min-w-[220px] rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Guest Overview</div>
<div class="mt-1 space-y-1">
@ -456,6 +459,16 @@ export function GuestRow(props: GuestRowProps) {
</Show>
</div>
</Show>
<Show when={hasAgentInfo()}>
<div class="flex flex-wrap items-center gap-1 text-[11px] text-gray-500 dark:text-gray-400">
<span class="uppercase tracking-wide text-[10px] text-gray-400 dark:text-gray-500">
Agent
</span>
<span title={`QEMU guest agent ${agentVersion()}`}>
QEMU guest agent {agentVersion()}
</span>
</div>
</Show>
<Show when={ipAddresses().length > 0}>
<div class="flex flex-wrap gap-1">
<For each={ipAddresses()}>

View file

@ -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;

View file

@ -84,6 +84,7 @@ export interface Guest {
};
osName?: string;
osVersion?: string;
agentVersion?: string;
ipAddresses?: string[];
networkInterfaces?: GuestNetworkInterface[];
template?: boolean;

View file

@ -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 {

View file

@ -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()

View file

@ -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
}

View file

@ -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
}

View file

@ -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()

View file

@ -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,
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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"`

View file

@ -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