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 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()}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export interface Guest {
|
|||
};
|
||||
osName?: string;
|
||||
osVersion?: string;
|
||||
agentVersion?: string;
|
||||
ipAddresses?: string[];
|
||||
networkInterfaces?: GuestNetworkInterface[];
|
||||
template?: boolean;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue