Add qemu guest agent version metadata
This commit is contained in:
parent
f8b6aa6c97
commit
c9543e8a7e
13 changed files with 797 additions and 114 deletions
|
|
@ -58,7 +58,9 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
const hasNetworkInterfaces = createMemo(() => networkInterfaces().length > 0);
|
const hasNetworkInterfaces = createMemo(() => networkInterfaces().length > 0);
|
||||||
const osName = createMemo(() => props.guest.osName?.trim() ?? '');
|
const osName = createMemo(() => props.guest.osName?.trim() ?? '');
|
||||||
const osVersion = createMemo(() => props.guest.osVersion?.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 hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0);
|
||||||
|
const hasAgentInfo = createMemo(() => agentVersion().length > 0);
|
||||||
|
|
||||||
// Update custom URL when prop changes
|
// Update custom URL when prop changes
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -99,6 +101,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
const hasDrawerContent = createMemo(
|
const hasDrawerContent = createMemo(
|
||||||
() =>
|
() =>
|
||||||
hasOsInfo() ||
|
hasOsInfo() ||
|
||||||
|
hasAgentInfo() ||
|
||||||
ipAddresses().length > 0 ||
|
ipAddresses().length > 0 ||
|
||||||
(memoryExtraLines()?.length ?? 0) > 0 ||
|
(memoryExtraLines()?.length ?? 0) > 0 ||
|
||||||
hasFilesystemDetails() ||
|
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="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="text-[11px] font-medium text-gray-700 dark:text-gray-200">Guest Overview</div>
|
||||||
<div class="mt-1 space-y-1">
|
<div class="mt-1 space-y-1">
|
||||||
|
|
@ -456,6 +459,16 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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}>
|
<Show when={ipAddresses().length > 0}>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<For each={ipAddresses()}>
|
<For each={ipAddresses()}>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ export interface State {
|
||||||
vms: VM[];
|
vms: VM[];
|
||||||
containers: Container[];
|
containers: Container[];
|
||||||
dockerHosts: DockerHost[];
|
dockerHosts: DockerHost[];
|
||||||
|
hosts: Host[];
|
||||||
|
replicationJobs: ReplicationJob[];
|
||||||
storage: Storage[];
|
storage: Storage[];
|
||||||
cephClusters: CephCluster[];
|
cephClusters: CephCluster[];
|
||||||
physicalDisks: PhysicalDisk[];
|
physicalDisks: PhysicalDisk[];
|
||||||
|
|
@ -71,6 +73,7 @@ export interface VM {
|
||||||
ipAddresses?: string[];
|
ipAddresses?: string[];
|
||||||
osName?: string;
|
osName?: string;
|
||||||
osVersion?: string;
|
osVersion?: string;
|
||||||
|
agentVersion?: string;
|
||||||
networkInterfaces?: GuestNetworkInterface[];
|
networkInterfaces?: GuestNetworkInterface[];
|
||||||
networkIn: number;
|
networkIn: number;
|
||||||
networkOut: number;
|
networkOut: number;
|
||||||
|
|
@ -100,6 +103,7 @@ export interface Container {
|
||||||
ipAddresses?: string[];
|
ipAddresses?: string[];
|
||||||
osName?: string;
|
osName?: string;
|
||||||
osVersion?: string;
|
osVersion?: string;
|
||||||
|
agentVersion?: string;
|
||||||
networkInterfaces?: GuestNetworkInterface[];
|
networkInterfaces?: GuestNetworkInterface[];
|
||||||
networkIn: number;
|
networkIn: number;
|
||||||
networkOut: number;
|
networkOut: number;
|
||||||
|
|
@ -190,6 +194,42 @@ export interface DockerContainerNetwork {
|
||||||
ipv6?: string;
|
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 {
|
export interface Storage {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ export interface Guest {
|
||||||
};
|
};
|
||||||
osName?: string;
|
osName?: string;
|
||||||
osVersion?: string;
|
osVersion?: string;
|
||||||
|
agentVersion?: string;
|
||||||
ipAddresses?: string[];
|
ipAddresses?: string[];
|
||||||
networkInterfaces?: GuestNetworkInterface[];
|
networkInterfaces?: GuestNetworkInterface[];
|
||||||
template?: boolean;
|
template?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToFrontend converts a State to StateFrontend
|
// ToFrontend converts a State to StateFrontend
|
||||||
|
|
@ -119,6 +120,10 @@ func (v VM) ToFrontend() VMFrontend {
|
||||||
vm.OSVersion = v.OSVersion
|
vm.OSVersion = v.OSVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v.AgentVersion != "" {
|
||||||
|
vm.AgentVersion = v.AgentVersion
|
||||||
|
}
|
||||||
|
|
||||||
if len(v.NetworkInterfaces) > 0 {
|
if len(v.NetworkInterfaces) > 0 {
|
||||||
vm.NetworkInterfaces = make([]GuestNetworkInterface, len(v.NetworkInterfaces))
|
vm.NetworkInterfaces = make([]GuestNetworkInterface, len(v.NetworkInterfaces))
|
||||||
copy(vm.NetworkInterfaces, v.NetworkInterfaces)
|
copy(vm.NetworkInterfaces, v.NetworkInterfaces)
|
||||||
|
|
@ -381,6 +386,58 @@ func (c CephCluster) ToFrontend() CephClusterFrontend {
|
||||||
return frontend
|
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)
|
// zeroIfNegative returns 0 for negative values (used for I/O metrics)
|
||||||
func zeroIfNegative(val int64) int64 {
|
func zeroIfNegative(val int64) int64 {
|
||||||
if val < 0 {
|
if val < 0 {
|
||||||
|
|
|
||||||
|
|
@ -10,27 +10,28 @@ import (
|
||||||
// State represents the current state of all monitored resources
|
// State represents the current state of all monitored resources
|
||||||
type State struct {
|
type State struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
Nodes []Node `json:"nodes"`
|
Nodes []Node `json:"nodes"`
|
||||||
VMs []VM `json:"vms"`
|
VMs []VM `json:"vms"`
|
||||||
Containers []Container `json:"containers"`
|
Containers []Container `json:"containers"`
|
||||||
DockerHosts []DockerHost `json:"dockerHosts"`
|
DockerHosts []DockerHost `json:"dockerHosts"`
|
||||||
Hosts []Host `json:"hosts"`
|
Hosts []Host `json:"hosts"`
|
||||||
Storage []Storage `json:"storage"`
|
Storage []Storage `json:"storage"`
|
||||||
CephClusters []CephCluster `json:"cephClusters"`
|
CephClusters []CephCluster `json:"cephClusters"`
|
||||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||||
PBSInstances []PBSInstance `json:"pbs"`
|
PBSInstances []PBSInstance `json:"pbs"`
|
||||||
PMGInstances []PMGInstance `json:"pmg"`
|
PMGInstances []PMGInstance `json:"pmg"`
|
||||||
PBSBackups []PBSBackup `json:"pbsBackups"`
|
PBSBackups []PBSBackup `json:"pbsBackups"`
|
||||||
PMGBackups []PMGBackup `json:"pmgBackups"`
|
PMGBackups []PMGBackup `json:"pmgBackups"`
|
||||||
Backups Backups `json:"backups"`
|
Backups Backups `json:"backups"`
|
||||||
Metrics []Metric `json:"metrics"`
|
ReplicationJobs []ReplicationJob `json:"replicationJobs"`
|
||||||
PVEBackups PVEBackups `json:"pveBackups"`
|
Metrics []Metric `json:"metrics"`
|
||||||
Performance Performance `json:"performance"`
|
PVEBackups PVEBackups `json:"pveBackups"`
|
||||||
ConnectionHealth map[string]bool `json:"connectionHealth"`
|
Performance Performance `json:"performance"`
|
||||||
Stats Stats `json:"stats"`
|
ConnectionHealth map[string]bool `json:"connectionHealth"`
|
||||||
ActiveAlerts []Alert `json:"activeAlerts"`
|
Stats Stats `json:"stats"`
|
||||||
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
|
ActiveAlerts []Alert `json:"activeAlerts"`
|
||||||
LastUpdate time.Time `json:"lastUpdate"`
|
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
|
||||||
|
LastUpdate time.Time `json:"lastUpdate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alert represents an active alert (simplified for State)
|
// Alert represents an active alert (simplified for State)
|
||||||
|
|
@ -99,6 +100,7 @@ type VM struct {
|
||||||
IPAddresses []string `json:"ipAddresses,omitempty"`
|
IPAddresses []string `json:"ipAddresses,omitempty"`
|
||||||
OSName string `json:"osName,omitempty"`
|
OSName string `json:"osName,omitempty"`
|
||||||
OSVersion string `json:"osVersion,omitempty"`
|
OSVersion string `json:"osVersion,omitempty"`
|
||||||
|
AgentVersion string `json:"agentVersion,omitempty"`
|
||||||
NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"`
|
NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"`
|
||||||
NetworkIn int64 `json:"networkIn"`
|
NetworkIn int64 `json:"networkIn"`
|
||||||
NetworkOut int64 `json:"networkOut"`
|
NetworkOut int64 `json:"networkOut"`
|
||||||
|
|
@ -724,6 +726,43 @@ type GuestSnapshot struct {
|
||||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
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
|
// Performance represents performance metrics
|
||||||
type Performance struct {
|
type Performance struct {
|
||||||
APICallDuration map[string]float64 `json:"apiCallDuration"`
|
APICallDuration map[string]float64 `json:"apiCallDuration"`
|
||||||
|
|
@ -766,6 +805,7 @@ func NewState() *State {
|
||||||
PBS: make([]PBSBackup, 0),
|
PBS: make([]PBSBackup, 0),
|
||||||
PMG: make([]PMGBackup, 0),
|
PMG: make([]PMGBackup, 0),
|
||||||
},
|
},
|
||||||
|
ReplicationJobs: make([]ReplicationJob, 0),
|
||||||
Metrics: make([]Metric, 0),
|
Metrics: make([]Metric, 0),
|
||||||
PVEBackups: pveBackups,
|
PVEBackups: pveBackups,
|
||||||
ConnectionHealth: make(map[string]bool),
|
ConnectionHealth: make(map[string]bool),
|
||||||
|
|
@ -1446,6 +1486,55 @@ func (s *State) UpdateStorageBackupsForInstance(instanceName string, backups []S
|
||||||
s.LastUpdate = time.Now()
|
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
|
// UpdateGuestSnapshotsForInstance updates guest snapshots for a specific instance, merging with existing snapshots
|
||||||
func (s *State) UpdateGuestSnapshotsForInstance(instanceName string, snapshots []GuestSnapshot) {
|
func (s *State) UpdateGuestSnapshotsForInstance(instanceName string, snapshots []GuestSnapshot) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ type VMFrontend struct {
|
||||||
DiskStatusReason string `json:"diskStatusReason,omitempty"` // Why disk stats are unavailable
|
DiskStatusReason string `json:"diskStatusReason,omitempty"` // Why disk stats are unavailable
|
||||||
OSName string `json:"osName,omitempty"`
|
OSName string `json:"osName,omitempty"`
|
||||||
OSVersion string `json:"osVersion,omitempty"`
|
OSVersion string `json:"osVersion,omitempty"`
|
||||||
|
AgentVersion string `json:"agentVersion,omitempty"`
|
||||||
NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"`
|
NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"`
|
||||||
IPAddresses []string `json:"ipAddresses,omitempty"`
|
IPAddresses []string `json:"ipAddresses,omitempty"`
|
||||||
NetIn int64 `json:"networkIn"` // Maps to NetworkIn (camelCase for frontend)
|
NetIn int64 `json:"networkIn"` // Maps to NetworkIn (camelCase for frontend)
|
||||||
|
|
@ -219,25 +220,63 @@ type CephClusterFrontend struct {
|
||||||
LastUpdated int64 `json:"lastUpdated"`
|
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
|
// StateFrontend represents the state with frontend-friendly field names
|
||||||
type StateFrontend struct {
|
type StateFrontend struct {
|
||||||
Nodes []NodeFrontend `json:"nodes"`
|
Nodes []NodeFrontend `json:"nodes"`
|
||||||
VMs []VMFrontend `json:"vms"`
|
VMs []VMFrontend `json:"vms"`
|
||||||
Containers []ContainerFrontend `json:"containers"`
|
Containers []ContainerFrontend `json:"containers"`
|
||||||
DockerHosts []DockerHostFrontend `json:"dockerHosts"`
|
DockerHosts []DockerHostFrontend `json:"dockerHosts"`
|
||||||
Storage []StorageFrontend `json:"storage"`
|
Storage []StorageFrontend `json:"storage"`
|
||||||
CephClusters []CephClusterFrontend `json:"cephClusters"`
|
CephClusters []CephClusterFrontend `json:"cephClusters"`
|
||||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||||
PBS []PBSInstance `json:"pbs"` // Keep as is
|
PBS []PBSInstance `json:"pbs"` // Keep as is
|
||||||
PMG []PMGInstance `json:"pmg"`
|
PMG []PMGInstance `json:"pmg"`
|
||||||
PBSBackups []PBSBackup `json:"pbsBackups"`
|
PBSBackups []PBSBackup `json:"pbsBackups"`
|
||||||
PMGBackups []PMGBackup `json:"pmgBackups"`
|
PMGBackups []PMGBackup `json:"pmgBackups"`
|
||||||
Backups Backups `json:"backups"`
|
Backups Backups `json:"backups"`
|
||||||
ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts
|
ReplicationJobs []ReplicationJobFrontend `json:"replicationJobs"`
|
||||||
Metrics map[string]any `json:"metrics"` // Empty object for now
|
ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts
|
||||||
PVEBackups PVEBackups `json:"pveBackups"` // Keep as is
|
Metrics map[string]any `json:"metrics"` // Empty object for now
|
||||||
Performance map[string]any `json:"performance"` // Empty object for now
|
PVEBackups PVEBackups `json:"pveBackups"` // Keep as is
|
||||||
ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is
|
Performance map[string]any `json:"performance"` // Empty object for now
|
||||||
Stats map[string]any `json:"stats"` // Empty object for now
|
ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is
|
||||||
LastUpdate int64 `json:"lastUpdate"` // Unix timestamp
|
Stats map[string]any `json:"stats"` // Empty object for now
|
||||||
|
LastUpdate int64 `json:"lastUpdate"` // Unix timestamp
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) GetAllStorage(ctx context.Context) ([]proxmox.Storage, error) { return nil, nil }
|
||||||
func (noopPVEClient) GetBackupTasks(ctx context.Context) ([]proxmox.Task, 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) {
|
func (noopPVEClient) GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error) {
|
||||||
return nil, nil
|
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) {
|
func (noopPVEClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) {
|
||||||
return map[string]interface{}{}, nil
|
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) {
|
func (noopPVEClient) GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ type PVEClientInterface interface {
|
||||||
GetStorage(ctx context.Context, node string) ([]proxmox.Storage, error)
|
GetStorage(ctx context.Context, node string) ([]proxmox.Storage, error)
|
||||||
GetAllStorage(ctx context.Context) ([]proxmox.Storage, error)
|
GetAllStorage(ctx context.Context) ([]proxmox.Storage, error)
|
||||||
GetBackupTasks(ctx context.Context) ([]proxmox.Task, 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)
|
GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error)
|
||||||
GetVMSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error)
|
GetVMSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error)
|
||||||
GetContainerSnapshots(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)
|
GetVMFSInfo(ctx context.Context, node string, vmid int) ([]proxmox.VMFileSystem, error)
|
||||||
GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.VMNetworkInterface, error)
|
GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.VMNetworkInterface, error)
|
||||||
GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, 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)
|
GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error)
|
||||||
GetZFSPoolsWithDetails(ctx context.Context, node string) ([]proxmox.ZFSPoolInfo, error)
|
GetZFSPoolsWithDetails(ctx context.Context, node string) ([]proxmox.ZFSPoolInfo, error)
|
||||||
GetDisks(ctx context.Context, node string) ([]proxmox.Disk, error)
|
GetDisks(ctx context.Context, node string) ([]proxmox.Disk, error)
|
||||||
|
|
@ -255,6 +257,34 @@ func isLikelyIPAddress(value string) bool {
|
||||||
return false
|
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.
|
// PollExecutor defines the contract for executing polling tasks.
|
||||||
type PollExecutor interface {
|
type PollExecutor interface {
|
||||||
Execute(ctx context.Context, task PollTask)
|
Execute(ctx context.Context, task PollTask)
|
||||||
|
|
@ -528,6 +558,7 @@ type guestMetadataCacheEntry struct {
|
||||||
networkInterfaces []models.GuestNetworkInterface
|
networkInterfaces []models.GuestNetworkInterface
|
||||||
osName string
|
osName string
|
||||||
osVersion string
|
osVersion string
|
||||||
|
agentVersion string
|
||||||
fetchedAt time.Time
|
fetchedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1283,15 +1314,15 @@ func sortContent(content string) string {
|
||||||
return strings.Join(parts, ",")
|
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 {
|
if vmStatus == nil || client == nil {
|
||||||
m.clearGuestMetadataCache(instanceName, nodeName, vmid)
|
m.clearGuestMetadataCache(instanceName, nodeName, vmid)
|
||||||
return nil, nil, "", ""
|
return nil, nil, "", "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if vmStatus.Agent <= 0 {
|
if vmStatus.Agent <= 0 {
|
||||||
m.clearGuestMetadataCache(instanceName, nodeName, vmid)
|
m.clearGuestMetadataCache(instanceName, nodeName, vmid)
|
||||||
return nil, nil, "", ""
|
return nil, nil, "", "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
key := guestMetadataCacheKey(instanceName, nodeName, vmid)
|
key := guestMetadataCacheKey(instanceName, nodeName, vmid)
|
||||||
|
|
@ -1302,7 +1333,7 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI
|
||||||
m.guestMetadataMu.RUnlock()
|
m.guestMetadataMu.RUnlock()
|
||||||
|
|
||||||
if ok && now.Sub(cached.fetchedAt) < guestMetadataCacheTTL {
|
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
|
// 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)
|
networkIfaces := cloneGuestNetworkInterfaces(cached.networkInterfaces)
|
||||||
osName := cached.osName
|
osName := cached.osName
|
||||||
osVersion := cached.osVersion
|
osVersion := cached.osVersion
|
||||||
|
agentVersion := cached.agentVersion
|
||||||
|
|
||||||
ifaceCtx, cancelIface := context.WithTimeout(ctx, 5*time.Second)
|
ifaceCtx, cancelIface := context.WithTimeout(ctx, 5*time.Second)
|
||||||
interfaces, err := client.GetVMNetworkInterfaces(ifaceCtx, nodeName, vmid)
|
interfaces, err := client.GetVMNetworkInterfaces(ifaceCtx, nodeName, vmid)
|
||||||
|
|
@ -1345,11 +1377,28 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI
|
||||||
osVersion = ""
|
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{
|
entry := guestMetadataCacheEntry{
|
||||||
ipAddresses: cloneStringSlice(ipAddresses),
|
ipAddresses: cloneStringSlice(ipAddresses),
|
||||||
networkInterfaces: cloneGuestNetworkInterfaces(networkIfaces),
|
networkInterfaces: cloneGuestNetworkInterfaces(networkIfaces),
|
||||||
osName: osName,
|
osName: osName,
|
||||||
osVersion: osVersion,
|
osVersion: osVersion,
|
||||||
|
agentVersion: agentVersion,
|
||||||
fetchedAt: time.Now(),
|
fetchedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1360,7 +1409,7 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI
|
||||||
m.guestMetadataCache[key] = entry
|
m.guestMetadataCache[key] = entry
|
||||||
m.guestMetadataMu.Unlock()
|
m.guestMetadataMu.Unlock()
|
||||||
|
|
||||||
return ipAddresses, networkIfaces, osName, osVersion
|
return ipAddresses, networkIfaces, osName, osVersion, agentVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
func guestMetadataCacheKey(instanceName, nodeName string, vmid int) string {
|
func guestMetadataCacheKey(instanceName, nodeName string, vmid int) string {
|
||||||
|
|
@ -1875,7 +1924,7 @@ func New(cfg *config.Config) (*Monitor, error) {
|
||||||
Bool("hasToken", pve.TokenName != "").
|
Bool("hasToken", pve.TokenName != "").
|
||||||
Msg("Configuring PVE instance")
|
Msg("Configuring PVE instance")
|
||||||
|
|
||||||
// Check if this is a cluster
|
// Check if this is a cluster
|
||||||
if pve.IsCluster && len(pve.ClusterEndpoints) > 0 {
|
if pve.IsCluster && len(pve.ClusterEndpoints) > 0 {
|
||||||
// For clusters, check if endpoints have IPs/resolvable hosts
|
// For clusters, check if endpoints have IPs/resolvable hosts
|
||||||
// If not, use the main host for all connections (Proxmox will route cluster API calls)
|
// 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))
|
endpoints := make([]string, 0, len(pve.ClusterEndpoints))
|
||||||
|
|
||||||
for _, ep := range pve.ClusterEndpoints {
|
for _, ep := range pve.ClusterEndpoints {
|
||||||
// Use IP if available, otherwise use host
|
effectiveURL := clusterEndpointEffectiveURL(ep)
|
||||||
host := ep.IP
|
if effectiveURL == "" {
|
||||||
if host == "" {
|
|
||||||
host = ep.Host
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if no host information
|
|
||||||
if host == "" {
|
|
||||||
log.Warn().
|
log.Warn().
|
||||||
Str("node", ep.NodeName).
|
Str("node", ep.NodeName).
|
||||||
Msg("Skipping cluster endpoint with no host/IP")
|
Msg("Skipping cluster endpoint with no host/IP")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have a valid IP or a fully qualified hostname (with dots)
|
if parsed, err := url.Parse(effectiveURL); err == nil {
|
||||||
if strings.Contains(host, ".") || net.ParseIP(host) != nil {
|
hostname := parsed.Hostname()
|
||||||
hasValidEndpoints = true
|
if hostname != "" && (strings.Contains(hostname, ".") || net.ParseIP(hostname) != nil) {
|
||||||
}
|
hasValidEndpoints = true
|
||||||
|
}
|
||||||
// Ensure we have the full URL
|
} else {
|
||||||
if !strings.HasPrefix(host, "http") {
|
hostname := normalizeEndpointHost(effectiveURL)
|
||||||
if pve.VerifySSL {
|
if hostname != "" && (strings.Contains(hostname, ".") || net.ParseIP(hostname) != nil) {
|
||||||
host = fmt.Sprintf("https://%s:8006", host)
|
hasValidEndpoints = true
|
||||||
} else {
|
|
||||||
host = fmt.Sprintf("https://%s:8006", host)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
endpoints = append(endpoints, host)
|
|
||||||
|
endpoints = append(endpoints, effectiveURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If endpoints are just node names (not FQDNs or IPs), use main host only
|
// 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("instance", pve.Name).
|
||||||
Str("mainHost", pve.Host).
|
Str("mainHost", pve.Host).
|
||||||
Msg("Cluster endpoints are not resolvable, using main host for all cluster operations")
|
Msg("Cluster endpoints are not resolvable, using main host for all cluster operations")
|
||||||
endpoints = []string{pve.Host}
|
fallback := ensureClusterEndpointURL(pve.Host)
|
||||||
if !strings.HasPrefix(endpoints[0], "http") {
|
if fallback == "" {
|
||||||
endpoints[0] = fmt.Sprintf("https://%s:8006", endpoints[0])
|
fallback = ensureClusterEndpointURL(pve.Host)
|
||||||
}
|
}
|
||||||
|
endpoints = []string{fallback}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
|
|
@ -3468,13 +3511,24 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
nodeStart := time.Now()
|
nodeStart := time.Now()
|
||||||
displayName := getNodeDisplayName(instanceCfg, node.Node)
|
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{
|
modelNode := models.Node{
|
||||||
ID: instanceName + "-" + node.Node,
|
ID: instanceName + "-" + node.Node,
|
||||||
Name: node.Node,
|
Name: node.Node,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
Instance: instanceName,
|
Instance: instanceName,
|
||||||
Host: instanceCfg.Host, // Add the actual host URL
|
Host: connectionHost,
|
||||||
Status: node.Status,
|
Status: node.Status,
|
||||||
Type: "node",
|
Type: "node",
|
||||||
CPU: safeFloat(node.CPU), // Already in percentage
|
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
|
tempCtx, tempCancel := context.WithTimeout(ctx, 30*time.Second) // Increased to accommodate SSH operations via proxy
|
||||||
|
|
||||||
// Determine SSH hostname to use (most robust approach):
|
// Determine SSH hostname to use (most robust approach):
|
||||||
// 1. For cluster nodes: Try to find the node's specific IP or host from ClusterEndpoints
|
// Prefer the resolved host for this node, with cluster overrides when available.
|
||||||
// 2. For standalone nodes: Use the Host URL from config
|
sshHost := modelNode.Host
|
||||||
// 3. Fallback: Use node name (works for simple DNS/hosts setups)
|
|
||||||
sshHost := node.Node // Default fallback
|
|
||||||
|
|
||||||
if modelNode.IsClusterMember && instanceCfg.IsCluster {
|
if modelNode.IsClusterMember && instanceCfg.IsCluster {
|
||||||
// Look up this specific node in cluster endpoints to get its individual address
|
|
||||||
for _, ep := range instanceCfg.ClusterEndpoints {
|
for _, ep := range instanceCfg.ClusterEndpoints {
|
||||||
if ep.NodeName == node.Node {
|
if strings.EqualFold(ep.NodeName, node.Node) {
|
||||||
// Prefer IP address for reliability
|
if effective := clusterEndpointEffectiveURL(ep); effective != "" {
|
||||||
if ep.IP != "" {
|
sshHost = effective
|
||||||
sshHost = ep.IP
|
|
||||||
} else if ep.Host != "" {
|
|
||||||
sshHost = ep.Host
|
|
||||||
}
|
}
|
||||||
break
|
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)
|
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 individualDisks []models.Disk // Store individual filesystems for multi-disk monitoring
|
||||||
var ipAddresses []string
|
var ipAddresses []string
|
||||||
var networkInterfaces []models.GuestNetworkInterface
|
var networkInterfaces []models.GuestNetworkInterface
|
||||||
var osName, osVersion string
|
var osName, osVersion, agentVersion string
|
||||||
|
|
||||||
if res.Type == "qemu" {
|
if res.Type == "qemu" {
|
||||||
// Skip templates if configured
|
// Skip templates if configured
|
||||||
|
|
@ -4593,7 +4642,7 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gather guest metadata from the agent when available
|
// 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 {
|
if len(guestIPs) > 0 {
|
||||||
ipAddresses = guestIPs
|
ipAddresses = guestIPs
|
||||||
}
|
}
|
||||||
|
|
@ -4606,6 +4655,9 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
|
||||||
if guestOSVersion != "" {
|
if guestOSVersion != "" {
|
||||||
osVersion = guestOSVersion
|
osVersion = guestOSVersion
|
||||||
}
|
}
|
||||||
|
if guestAgentVersion != "" {
|
||||||
|
agentVersion = guestAgentVersion
|
||||||
|
}
|
||||||
|
|
||||||
// Always try to get filesystem info if agent is enabled
|
// Always try to get filesystem info if agent is enabled
|
||||||
// Prefer guest agent data over cluster/resources data for accuracy
|
// Prefer guest agent data over cluster/resources data for accuracy
|
||||||
|
|
@ -4951,6 +5003,7 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
|
||||||
IPAddresses: ipAddresses,
|
IPAddresses: ipAddresses,
|
||||||
OSName: osName,
|
OSName: osName,
|
||||||
OSVersion: osVersion,
|
OSVersion: osVersion,
|
||||||
|
AgentVersion: agentVersion,
|
||||||
NetworkInterfaces: networkInterfaces,
|
NetworkInterfaces: networkInterfaces,
|
||||||
NetworkIn: maxInt64(0, int64(netInRate)),
|
NetworkIn: maxInt64(0, int64(netInRate)),
|
||||||
NetworkOut: maxInt64(0, int64(netOutRate)),
|
NetworkOut: maxInt64(0, int64(netOutRate)),
|
||||||
|
|
@ -5111,6 +5164,8 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
|
||||||
m.state.UpdateContainersForInstance(instanceName, allContainers)
|
m.state.UpdateContainersForInstance(instanceName, allContainers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.pollReplicationStatus(ctx, instanceName, client, allVMs)
|
||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
Str("instance", instanceName).
|
Str("instance", instanceName).
|
||||||
Int("vms", len(allVMs)).
|
Int("vms", len(allVMs)).
|
||||||
|
|
@ -5163,6 +5218,169 @@ func (m *Monitor) pollBackupTasks(ctx context.Context, instanceName string, clie
|
||||||
m.state.UpdateBackupTasksForInstance(instanceName, backupTasks)
|
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
|
// pollPBSInstance polls a single PBS instance
|
||||||
func (m *Monitor) pollPBSInstance(ctx context.Context, instanceName string, client *pbs.Client) {
|
func (m *Monitor) pollPBSInstance(ctx context.Context, instanceName string, client *pbs.Client) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
|
||||||
|
|
@ -65,21 +65,21 @@ func (m *Monitor) describeInstancesForScheduler() []InstanceDescriptor {
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: InstanceTypePBS,
|
Type: InstanceTypePBS,
|
||||||
}
|
}
|
||||||
if m.scheduler != nil {
|
if m.scheduler != nil {
|
||||||
if last, ok := m.scheduler.LastScheduled(InstanceTypePBS, name); ok {
|
if last, ok := m.scheduler.LastScheduled(InstanceTypePBS, name); ok {
|
||||||
desc.LastScheduled = last.NextRun
|
desc.LastScheduled = last.NextRun
|
||||||
desc.LastInterval = last.Interval
|
desc.LastInterval = last.Interval
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if m.stalenessTracker != nil {
|
||||||
if m.stalenessTracker != nil {
|
if snap, ok := m.stalenessTracker.snapshot(InstanceTypePBS, name); ok {
|
||||||
if snap, ok := m.stalenessTracker.snapshot(InstanceTypePBS, name); ok {
|
desc.LastSuccess = snap.LastSuccess
|
||||||
desc.LastSuccess = snap.LastSuccess
|
desc.LastFailure = snap.LastError
|
||||||
desc.LastFailure = snap.LastError
|
desc.Metadata = map[string]any{"changeHash": snap.ChangeHash}
|
||||||
desc.Metadata = map[string]any{"changeHash": snap.ChangeHash}
|
}
|
||||||
}
|
}
|
||||||
|
descriptors = append(descriptors, desc)
|
||||||
}
|
}
|
||||||
descriptors = append(descriptors, desc)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(m.pmgClients) > 0 {
|
if len(m.pmgClients) > 0 {
|
||||||
|
|
@ -93,21 +93,21 @@ func (m *Monitor) describeInstancesForScheduler() []InstanceDescriptor {
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: InstanceTypePMG,
|
Type: InstanceTypePMG,
|
||||||
}
|
}
|
||||||
if m.scheduler != nil {
|
if m.scheduler != nil {
|
||||||
if last, ok := m.scheduler.LastScheduled(InstanceTypePMG, name); ok {
|
if last, ok := m.scheduler.LastScheduled(InstanceTypePMG, name); ok {
|
||||||
desc.LastScheduled = last.NextRun
|
desc.LastScheduled = last.NextRun
|
||||||
desc.LastInterval = last.Interval
|
desc.LastInterval = last.Interval
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if m.stalenessTracker != nil {
|
||||||
if m.stalenessTracker != nil {
|
if snap, ok := m.stalenessTracker.snapshot(InstanceTypePMG, name); ok {
|
||||||
if snap, ok := m.stalenessTracker.snapshot(InstanceTypePMG, name); ok {
|
desc.LastSuccess = snap.LastSuccess
|
||||||
desc.LastSuccess = snap.LastSuccess
|
desc.LastFailure = snap.LastError
|
||||||
desc.LastFailure = snap.LastError
|
desc.Metadata = map[string]any{"changeHash": snap.ChangeHash}
|
||||||
desc.Metadata = map[string]any{"changeHash": snap.ChangeHash}
|
}
|
||||||
}
|
}
|
||||||
|
descriptors = append(descriptors, desc)
|
||||||
}
|
}
|
||||||
descriptors = append(descriptors, desc)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return descriptors
|
return descriptors
|
||||||
|
|
@ -280,7 +280,7 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
|
||||||
var vmStatus *proxmox.VMStatus
|
var vmStatus *proxmox.VMStatus
|
||||||
var ipAddresses []string
|
var ipAddresses []string
|
||||||
var networkInterfaces []models.GuestNetworkInterface
|
var networkInterfaces []models.GuestNetworkInterface
|
||||||
var osName, osVersion string
|
var osName, osVersion, guestAgentVersion string
|
||||||
|
|
||||||
if vm.Status == "running" {
|
if vm.Status == "running" {
|
||||||
// Try to get detailed VM status (but don't wait too long)
|
// 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 {
|
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 {
|
if len(guestIPs) > 0 {
|
||||||
ipAddresses = guestIPs
|
ipAddresses = guestIPs
|
||||||
}
|
}
|
||||||
|
|
@ -407,6 +407,9 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
|
||||||
if guestOSVersion != "" {
|
if guestOSVersion != "" {
|
||||||
osVersion = guestOSVersion
|
osVersion = guestOSVersion
|
||||||
}
|
}
|
||||||
|
if agentVersion != "" {
|
||||||
|
guestAgentVersion = agentVersion
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate I/O rates after we have the actual values
|
// 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,
|
IPAddresses: ipAddresses,
|
||||||
OSName: osName,
|
OSName: osName,
|
||||||
OSVersion: osVersion,
|
OSVersion: osVersion,
|
||||||
|
AgentVersion: guestAgentVersion,
|
||||||
NetworkInterfaces: networkInterfaces,
|
NetworkInterfaces: networkInterfaces,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ func (f fakeSnapshotClient) GetAllStorage(ctx context.Context) ([]proxmox.Storag
|
||||||
func (f fakeSnapshotClient) GetBackupTasks(ctx context.Context) ([]proxmox.Task, error) {
|
func (f fakeSnapshotClient) GetBackupTasks(ctx context.Context) ([]proxmox.Task, error) {
|
||||||
return nil, nil
|
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) {
|
func (f fakeSnapshotClient) GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error) {
|
||||||
if storageContents, ok := f.contents[node]; ok {
|
if storageContents, ok := f.contents[node]; ok {
|
||||||
return storageContents[storage], nil
|
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) {
|
func (f fakeSnapshotClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) {
|
||||||
return map[string]interface{}{}, nil
|
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) {
|
func (f fakeSnapshotClient) GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ func (f *fakeStorageClient) GetBackupTasks(ctx context.Context) ([]proxmox.Task,
|
||||||
return nil, nil
|
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) {
|
func (f *fakeStorageClient) GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +97,10 @@ func (f *fakeStorageClient) GetVMAgentInfo(ctx context.Context, node string, vmi
|
||||||
return nil, nil
|
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) {
|
func (f *fakeStorageClient) GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -198,4 +206,3 @@ func TestPollStorageWithNodesOptimizedRecordsMetricsAndAlerts(t *testing.T) {
|
||||||
t.Fatalf("expected storage usage alert to be active")
|
t.Fatalf("expected storage usage alert to be active")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -52,6 +53,70 @@ func (f *FlexInt) UnmarshalJSON(data []byte) error {
|
||||||
return nil
|
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
|
// Client represents a Proxmox VE API client
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
|
|
@ -431,6 +496,69 @@ type MemoryStatus struct {
|
||||||
Shared uint64 `json:"shared"` // Shared memory (informational)
|
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.
|
// EffectiveAvailable returns the best-effort estimate of reclaimable memory.
|
||||||
// Prefer the dedicated "available"/"avail" fields when present, otherwise derive
|
// Prefer the dedicated "available"/"avail" fields when present, otherwise derive
|
||||||
// from free + buffers + cached which mirrors Linux's MemAvailable calculation.
|
// 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
|
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
|
// VMFileSystem represents filesystem information from QEMU guest agent
|
||||||
type VMFileSystem struct {
|
type VMFileSystem struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
|
||||||
|
|
@ -814,6 +814,19 @@ func (cc *ClusterClient) GetBackupTasks(ctx context.Context) ([]Task, error) {
|
||||||
return result, err
|
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) {
|
func (cc *ClusterClient) GetStorageContent(ctx context.Context, node, storage string) ([]StorageContent, error) {
|
||||||
var result []StorageContent
|
var result []StorageContent
|
||||||
err := cc.executeWithFailover(ctx, func(client *Client) error {
|
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
|
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
|
// GetVMFSInfo returns filesystem information from QEMU guest agent
|
||||||
func (cc *ClusterClient) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]VMFileSystem, error) {
|
func (cc *ClusterClient) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]VMFileSystem, error) {
|
||||||
var result []VMFileSystem
|
var result []VMFileSystem
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue