fix: cluster-aware guest deduplication and multi-agent token binding

- Add cluster-aware guest ID generation (clusterName-VMID instead of instanceName-VMID)
  to prevent duplicate VMs/containers when multiple cluster nodes are monitored

- Add cluster deduplication at registration time - when a node is added that belongs
  to an already-configured cluster, merge as endpoint instead of creating duplicate

- Add startup consolidation to automatically merge duplicate cluster instances

- Change host agent token binding from agent GUID to hostname, allowing:
  - Multiple host agents to share a token (each bound by hostname)
  - Agent reinstalls on same host without token conflicts

- Remove 12-character password minimum requirement

- Remove emoji from auto-registration success message

- Fix grouped view node lookup to support both cluster-aware node IDs
  (clusterName-nodeName) and legacy guest grouping keys (instance-nodeName)

Fixes duplicate guests appearing when agents are installed on multiple
cluster nodes. Also improves multi-agent UX by allowing shared tokens.
This commit is contained in:
rcourtman 2025-12-14 10:16:17 +00:00
parent 1b8a310f2e
commit eb59faaa4a
8 changed files with 358 additions and 91 deletions

View file

@ -353,10 +353,17 @@ export function Dashboard(props: DashboardProps) {
};
// Create a mapping from node ID to node object
// Also maps by instance-nodeName for guest grouping compatibility
const nodeByInstance = createMemo(() => {
const map: Record<string, Node> = {};
props.nodes.forEach((node) => {
// Map by node.id (may be clusterName-nodeName or instance-nodeName)
map[node.id] = node;
// Also map by instance-nodeName for guest grouping (guests use instance-node format)
const legacyKey = `${node.instance}-${node.name}`;
if (!map[legacyKey]) {
map[legacyKey] = node;
}
});
return map;
});

View file

@ -149,8 +149,8 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
showError('Passwords do not match');
return;
}
if (password().length < 12) {
showError('Password must be at least 12 characters');
if (password().length < 1) {
showError('Password cannot be empty');
return;
}
}
@ -426,7 +426,7 @@ IMPORTANT: Keep these credentials secure!
type="text"
value={bootstrapToken()}
onInput={(e) => setBootstrapToken(e.currentTarget.value)}
onKeyPress={(e) => e.key === 'Enter' && handleUnlock()}
onKeyPress={(e) => e.key === 'Enter' && handleUnlock()}
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
placeholder="Paste the token from your host"
autofocus
@ -494,22 +494,20 @@ IMPORTANT: Keep these credentials secure!
<button
type="button"
onClick={() => setUseCustomPassword(false)}
class={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
!useCustomPassword()
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
class={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${!useCustomPassword()
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Generate Secure Password
</button>
<button
type="button"
onClick={() => setUseCustomPassword(true)}
class={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
useCustomPassword()
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
class={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${useCustomPassword()
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Set Custom Password
</button>
@ -522,7 +520,7 @@ IMPORTANT: Keep these credentials secure!
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter password (min 12 characters)"
placeholder="Enter password"
/>
<input
type="password"
@ -556,11 +554,10 @@ IMPORTANT: Keep these credentials secure!
setThemeMode('system');
applyTheme('system');
}}
class={`py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
themeMode() === 'system'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
class={`py-2 px-4 rounded-lg text-sm font-medium transition-colors ${themeMode() === 'system'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
System
</button>
@ -570,11 +567,10 @@ IMPORTANT: Keep these credentials secure!
setThemeMode('light');
applyTheme('light');
}}
class={`py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
themeMode() === 'light'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
class={`py-2 px-4 rounded-lg text-sm font-medium transition-colors ${themeMode() === 'light'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Light
</button>
@ -584,11 +580,10 @@ IMPORTANT: Keep these credentials secure!
setThemeMode('dark');
applyTheme('dark');
}}
class={`py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
themeMode() === 'dark'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
class={`py-2 px-4 rounded-lg text-sm font-medium transition-colors ${themeMode() === 'dark'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Dark
</button>

View file

@ -710,7 +710,7 @@ export function createWebSocketStore(url: string) {
const nodeType = node.type === 'pve' ? 'Proxmox VE' : 'Proxmox Backup Server';
notificationStore.success(
`🎉 ${nodeType} node "${nodeName}" was successfully auto-registered and is now being monitored!`,
`${nodeType} node "${nodeName}" was successfully auto-registered and is now being monitored!`,
8000,
);
logger.info('Node auto-registered:', node);

View file

@ -1311,11 +1311,70 @@ func (h *ConfigHandlers) HandleAddNode(w http.ResponseWriter, r *http.Request) {
isCluster, clusterName, clusterEndpoints = detectPVECluster(clientConfig, req.Name, nil)
}
// CLUSTER DEDUPLICATION: If this node is part of a cluster, check if we already
// have that cluster configured. If so, this is a duplicate - we should merge
// the node as an endpoint to the existing cluster instead of creating a new instance.
// This prevents duplicate VMs/containers when users install agents on multiple cluster nodes.
if isCluster && clusterName != "" {
for i := range h.config.PVEInstances {
existingInstance := &h.config.PVEInstances[i]
if existingInstance.IsCluster && existingInstance.ClusterName == clusterName {
// Found existing cluster with same name - merge endpoints!
log.Info().
Str("cluster", clusterName).
Str("existingInstance", existingInstance.Name).
Str("newNode", req.Name).
Msg("New node belongs to already-configured cluster - merging as endpoint instead of creating duplicate")
// Merge any new endpoints from the detected cluster
existingEndpointMap := make(map[string]bool)
for _, ep := range existingInstance.ClusterEndpoints {
existingEndpointMap[ep.NodeName] = true
}
for _, newEp := range clusterEndpoints {
if !existingEndpointMap[newEp.NodeName] {
existingInstance.ClusterEndpoints = append(existingInstance.ClusterEndpoints, newEp)
log.Info().
Str("cluster", clusterName).
Str("endpoint", newEp.NodeName).
Msg("Added new endpoint to existing cluster")
}
}
// Save the updated configuration
if h.persistence != nil {
if err := h.persistence.SaveNodesConfig(h.config.PVEInstances, h.config.PBSInstances, h.config.PMGInstances); err != nil {
log.Warn().Err(err).Msg("Failed to persist cluster endpoint merge")
}
}
// Reload the monitor to pick up the updated endpoints
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
log.Warn().Err(err).Msg("Failed to reload monitor after cluster merge")
}
}
// Return success - the cluster is now updated with new endpoints
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"merged": true,
"cluster": clusterName,
"existingNode": existingInstance.Name,
"message": fmt.Sprintf("Node merged into existing cluster '%s' (already configured as '%s')", clusterName, existingInstance.Name),
"totalEndpoints": len(existingInstance.ClusterEndpoints),
})
return
}
}
}
if isCluster {
log.Info().
Str("cluster", clusterName).
Int("endpoints", len(clusterEndpoints)).
Msg("Detected Proxmox cluster, auto-discovering all nodes")
Msg("Detected new Proxmox cluster, auto-discovering all nodes")
}
// Use sensible defaults for boolean fields if not provided
@ -5857,6 +5916,61 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, nodeConfig.Name, nil)
// CLUSTER DEDUPLICATION: Check if we already have this cluster configured
// If so, merge this node as an endpoint instead of creating a duplicate instance
if isCluster && clusterName != "" {
for i := range h.config.PVEInstances {
existingInstance := &h.config.PVEInstances[i]
if existingInstance.IsCluster && existingInstance.ClusterName == clusterName {
// Found existing cluster with same name - merge endpoints!
log.Info().
Str("cluster", clusterName).
Str("existingInstance", existingInstance.Name).
Str("newNode", nodeConfig.Name).
Msg("Auto-registered node belongs to already-configured cluster - merging endpoints")
// Merge any new endpoints from the detected cluster
existingEndpointMap := make(map[string]bool)
for _, ep := range existingInstance.ClusterEndpoints {
existingEndpointMap[ep.NodeName] = true
}
for _, newEp := range clusterEndpoints {
if !existingEndpointMap[newEp.NodeName] {
existingInstance.ClusterEndpoints = append(existingInstance.ClusterEndpoints, newEp)
log.Info().
Str("cluster", clusterName).
Str("endpoint", newEp.NodeName).
Msg("Added new endpoint to existing cluster via auto-registration")
}
}
// Save and reload
if h.persistence != nil {
if err := h.persistence.SaveNodesConfig(h.config.PVEInstances, h.config.PBSInstances, h.config.PMGInstances); err != nil {
log.Warn().Err(err).Msg("Failed to persist cluster endpoint merge during auto-registration")
}
}
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
log.Warn().Err(err).Msg("Failed to reload monitor after cluster merge during auto-registration")
}
}
// Return success - merged into existing cluster
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"merged": true,
"cluster": clusterName,
"existingNode": existingInstance.Name,
"message": fmt.Sprintf("Agent merged into existing cluster '%s'", clusterName),
"totalEndpoints": len(existingInstance.ClusterEndpoints),
})
return
}
}
}
monitorVMs := true
if nodeConfig.MonitorVMs != nil {
monitorVMs = *nodeConfig.MonitorVMs
@ -5895,7 +6009,7 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
log.Info().
Str("cluster", clusterName).
Int("endpoints", len(clusterEndpoints)).
Msg("Added Proxmox cluster via auto-registration")
Msg("Added new Proxmox cluster via auto-registration")
}
} else {
verifySSL := false

View file

@ -1267,15 +1267,14 @@ func (s *State) UpdateNodesForInstance(instanceName string, nodes []Node) {
s.mu.Lock()
defer s.mu.Unlock()
// Create a map of existing nodes, excluding those from this instance
// Build a map of ALL existing nodes by ID (not filtered by instance)
// This handles cluster-based IDs where the same node ID comes from multiple instances
// Also preserve LinkedHostAgentID for nodes that are being updated
existingNodeLinks := make(map[string]string) // nodeID -> linkedHostAgentID
nodeMap := make(map[string]Node)
for _, node := range s.Nodes {
if node.Instance != instanceName {
nodeMap[node.ID] = node
} else if node.LinkedHostAgentID != "" {
// Preserve the link for nodes from this instance
nodeMap[node.ID] = node
if node.LinkedHostAgentID != "" {
existingNodeLinks[node.ID] = node.LinkedHostAgentID
}
}

View file

@ -205,6 +205,46 @@ func TestSafeFloat(t *testing.T) {
}
}
func TestMakeGuestID(t *testing.T) {
t.Parallel()
cases := []struct {
name string
instanceName string
clusterName string
isCluster bool
vmid int
want string
}{
// Standalone nodes use instance name
{name: "standalone node", instanceName: "pve-host1", clusterName: "", isCluster: false, vmid: 100, want: "pve-host1-100"},
{name: "standalone with empty cluster name", instanceName: "pve-standalone", clusterName: "", isCluster: false, vmid: 200, want: "pve-standalone-200"},
{name: "non-cluster even with cluster name", instanceName: "pve-node", clusterName: "my-cluster", isCluster: false, vmid: 150, want: "pve-node-150"},
// Cluster nodes use cluster name
{name: "cluster node uses cluster name", instanceName: "pve-host1", clusterName: "my-cluster", isCluster: true, vmid: 100, want: "my-cluster-100"},
{name: "different cluster node same cluster", instanceName: "pve-host2", clusterName: "my-cluster", isCluster: true, vmid: 100, want: "my-cluster-100"},
{name: "cluster with different vmid", instanceName: "pve-host1", clusterName: "production", isCluster: true, vmid: 999, want: "production-999"},
// Edge case: isCluster true but no cluster name falls back to instance name
{name: "cluster flag but no name", instanceName: "pve-node", clusterName: "", isCluster: true, vmid: 300, want: "pve-node-300"},
// Edge case: special characters and spaces (shouldn't happen but test anyway)
{name: "cluster name with hyphen", instanceName: "pve-node1", clusterName: "my-prod-cluster", isCluster: true, vmid: 101, want: "my-prod-cluster-101"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := makeGuestID(tc.instanceName, tc.clusterName, tc.isCluster, tc.vmid); got != tc.want {
t.Fatalf("makeGuestID(%q, %q, %v, %d) = %q, want %q", tc.instanceName, tc.clusterName, tc.isCluster, tc.vmid, got, tc.want)
}
})
}
}
func TestConvertPoolInfoToModel(t *testing.T) {
t.Parallel()

View file

@ -686,6 +686,22 @@ func safeFloat(val float64) float64 {
return val
}
// makeGuestID generates a stable guest ID that is cluster-aware.
// When the instance is part of a cluster, the cluster name is used as the primary identifier
// to prevent duplicate guests when multiple cluster nodes are configured as separate PVE instances.
// Format when in cluster: clusterName-VMID (e.g., "my-cluster-100")
// Format when standalone: instanceName-VMID (e.g., "pve-host1-100")
// This ensures VMs/containers are properly deduplicated across multiple agents in the same cluster.
func makeGuestID(instanceName string, clusterName string, isCluster bool, vmid int) string {
// Use cluster name as the identifier when the instance is part of a cluster
// This ensures guests are identified by their cluster, not by which node reported them
if isCluster && clusterName != "" {
return fmt.Sprintf("%s-%d", clusterName, vmid)
}
// For standalone nodes, use the instance name
return fmt.Sprintf("%s-%d", instanceName, vmid)
}
// parseDurationEnv parses a duration from an environment variable, returning defaultVal if not set or invalid
func parseDurationEnv(key string, defaultVal time.Duration) time.Duration {
val := os.Getenv(key)
@ -1925,43 +1941,27 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
if m.hostTokenBindings == nil {
m.hostTokenBindings = make(map[string]string)
}
if boundID, exists := m.hostTokenBindings[tokenID]; exists && boundID != bindingID {
m.mu.Unlock()
conflictingHost := "unknown"
for _, candidate := range existingHosts {
if candidate.TokenID == tokenID || candidate.ID == boundID {
conflictingHost = candidate.Hostname
if candidate.DisplayName != "" {
conflictingHost = candidate.DisplayName
}
break
}
}
tokenHint := tokenHintFromRecord(tokenRecord)
if tokenHint != "" {
tokenHint = " (" + tokenHint + ")"
}
log.Warn().
// Bind tokens by hostname rather than agent ID. This allows:
// - Same host to reconnect after agent reinstall (agent ID changes but hostname doesn't)
// - Multiple hosts to use the same token (each hostname gets its own binding entry)
// - Prevents a stolen token from being used on a different hostname
bindingKey := fmt.Sprintf("%s:%s", tokenID, hostname)
if boundID, exists := m.hostTokenBindings[bindingKey]; exists && boundID != bindingID {
// Same token+hostname but different agent ID - this is a reinstall, allow it
log.Info().
Str("tokenID", tokenID).
Str("tokenHint", tokenHint).
Str("reportingAgentID", bindingID).
Str("boundAgentID", boundID).
Str("conflictingHost", conflictingHost).
Msg("Rejecting host report: token already bound to different agent")
return models.Host{}, fmt.Errorf("API token%s is already in use by host %q (agent: %s). Generate a new token or set --agent-id before reusing it", tokenHint, conflictingHost, boundID)
}
if _, exists := m.hostTokenBindings[tokenID]; !exists {
m.hostTokenBindings[tokenID] = bindingID
Str("hostname", hostname).
Str("oldAgentID", boundID).
Str("newAgentID", bindingID).
Msg("Host agent reinstalled - updating token binding")
m.hostTokenBindings[bindingKey] = bindingID
} else if !exists {
m.hostTokenBindings[bindingKey] = bindingID
log.Debug().
Str("tokenID", tokenID).
Str("agentID", bindingID).
Str("hostname", hostname).
Msg("Bound host agent token to agent identity")
Msg("Bound host agent token to hostname")
}
m.mu.Unlock()
}
@ -3536,8 +3536,109 @@ func (m *Monitor) getConfiguredHostIPs() []string {
return ips
}
// consolidateDuplicateClusters detects and merges duplicate cluster instances.
// When multiple PVE instances belong to the same Proxmox cluster (determined by ClusterName),
// they should be merged into a single instance with all endpoints combined.
// This prevents duplicate VMs/containers in the UI.
func (m *Monitor) consolidateDuplicateClusters() {
if m == nil || m.config == nil || len(m.config.PVEInstances) < 2 {
return
}
// Group instances by cluster name
clusterGroups := make(map[string][]int) // clusterName -> indices of instances
for i, instance := range m.config.PVEInstances {
if instance.IsCluster && instance.ClusterName != "" {
clusterGroups[instance.ClusterName] = append(clusterGroups[instance.ClusterName], i)
}
}
// Find clusters that have duplicates
var mergedAny bool
for clusterName, indices := range clusterGroups {
if len(indices) < 2 {
continue // No duplicates for this cluster
}
log.Warn().
Str("cluster", clusterName).
Int("duplicates", len(indices)).
Msg("Detected duplicate cluster instances - consolidating")
// Keep the first instance and merge all others into it
primaryIdx := indices[0]
primary := &m.config.PVEInstances[primaryIdx]
// Build a set of existing endpoint node names
existingEndpoints := make(map[string]bool)
for _, ep := range primary.ClusterEndpoints {
existingEndpoints[ep.NodeName] = true
}
// Merge endpoints from all duplicate instances
for _, dupIdx := range indices[1:] {
duplicate := m.config.PVEInstances[dupIdx]
log.Info().
Str("cluster", clusterName).
Str("primary", primary.Name).
Str("duplicate", duplicate.Name).
Msg("Merging duplicate cluster instance")
for _, ep := range duplicate.ClusterEndpoints {
if !existingEndpoints[ep.NodeName] {
primary.ClusterEndpoints = append(primary.ClusterEndpoints, ep)
existingEndpoints[ep.NodeName] = true
log.Info().
Str("cluster", clusterName).
Str("endpoint", ep.NodeName).
Msg("Added endpoint from duplicate instance")
}
}
}
mergedAny = true
}
if !mergedAny {
return
}
// Remove duplicate instances (keeping only the primary for each cluster)
var newInstances []config.PVEInstance
seenClusters := make(map[string]bool)
for _, instance := range m.config.PVEInstances {
if instance.IsCluster && instance.ClusterName != "" {
if seenClusters[instance.ClusterName] {
log.Info().
Str("cluster", instance.ClusterName).
Str("instance", instance.Name).
Msg("Removing duplicate cluster instance")
continue // Skip duplicates
}
seenClusters[instance.ClusterName] = true
}
newInstances = append(newInstances, instance)
}
m.config.PVEInstances = newInstances
// Persist the consolidated configuration
if m.persistence != nil {
if err := m.persistence.SaveNodesConfig(m.config.PVEInstances, m.config.PBSInstances, m.config.PMGInstances); err != nil {
log.Error().Err(err).Msg("Failed to persist cluster consolidation")
} else {
log.Info().Msg("Persisted consolidated cluster configuration")
}
}
}
// Start begins the monitoring loop
func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
// Consolidate any duplicate cluster instances before starting
// This fixes the case where multiple agents registered from the same cluster
m.consolidateDuplicateClusters()
pollingInterval := m.effectivePVEPollingInterval()
log.Info().
Dur("pollingInterval", pollingInterval).
@ -5177,10 +5278,10 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
pollErr = ctx.Err()
return
default:
// Always try the efficient cluster/resources endpoint first
// Always try the efficient cluster/resources endpoint first
// This endpoint works on both clustered and standalone nodes
// Testing confirmed it works on standalone nodes like pimox
useClusterEndpoint := m.pollVMsAndContainersEfficient(ctx, instanceName, client, nodeEffectiveStatus)
useClusterEndpoint := m.pollVMsAndContainersEfficient(ctx, instanceName, instanceCfg.ClusterName, instanceCfg.IsCluster, client, nodeEffectiveStatus)
if !useClusterEndpoint {
// Fall back to traditional polling only if cluster/resources not available
@ -5202,10 +5303,10 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
// Use optimized parallel polling for better performance
if instanceCfg.MonitorVMs {
m.pollVMsWithNodes(ctx, instanceName, client, nodes, nodeEffectiveStatus)
m.pollVMsWithNodes(ctx, instanceName, instanceCfg.ClusterName, instanceCfg.IsCluster, client, nodes, nodeEffectiveStatus)
}
if instanceCfg.MonitorContainers {
m.pollContainersWithNodes(ctx, instanceName, client, nodes, nodeEffectiveStatus)
m.pollContainersWithNodes(ctx, instanceName, instanceCfg.ClusterName, instanceCfg.IsCluster, client, nodes, nodeEffectiveStatus)
}
}
}
@ -5666,8 +5767,14 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
// pollVMsAndContainersEfficient uses the cluster/resources endpoint to get all VMs and containers in one call
// This works on both clustered and standalone nodes for efficient polling
func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceName string, client PVEClientInterface, nodeEffectiveStatus map[string]string) bool {
log.Info().Str("instance", instanceName).Msg("Polling VMs and containers using efficient cluster/resources endpoint")
// When the instance is part of a cluster, the cluster name is used for guest IDs to prevent duplicates
// when multiple cluster nodes are configured as separate PVE instances.
func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceName string, clusterName string, isCluster bool, client PVEClientInterface, nodeEffectiveStatus map[string]string) bool {
log.Info().
Str("instance", instanceName).
Str("clusterName", clusterName).
Bool("isCluster", isCluster).
Msg("Polling VMs and containers using efficient cluster/resources endpoint")
// Get all resources in a single API call
resources, err := client.GetClusterResources(ctx, "vm")
@ -5696,13 +5803,8 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
var allContainers []models.Container
for _, res := range resources {
// Avoid duplicating node name in ID when instance name equals node name
var guestID string
if instanceName == res.Node {
guestID = fmt.Sprintf("%s-%d", res.Node, res.VMID)
} else {
guestID = fmt.Sprintf("%s-%s-%d", instanceName, res.Node, res.VMID)
}
// Use cluster-aware guest ID to prevent duplicates when multiple cluster nodes are configured
guestID := makeGuestID(instanceName, clusterName, isCluster, res.VMID)
// Debug log the resource type
log.Debug().

View file

@ -185,7 +185,9 @@ func convertPoolInfoToModel(poolInfo *proxmox.ZFSPoolInfo) *models.ZFSPool {
}
// pollVMsWithNodes polls VMs from all nodes in parallel using goroutines
func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, client PVEClientInterface, nodes []proxmox.Node, nodeEffectiveStatus map[string]string) {
// When the instance is part of a cluster, the cluster name is used for guest IDs to prevent duplicates
// when multiple cluster nodes are configured as separate PVE instances.
func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clusterName string, isCluster bool, client PVEClientInterface, nodes []proxmox.Node, nodeEffectiveStatus map[string]string) {
startTime := time.Now()
// Channel to collect VM results from each node
@ -253,9 +255,8 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
tags = strings.Split(vm.Tags, ";")
}
// Create guest ID (stable across node migrations)
// Format: instance-VMID
guestID := fmt.Sprintf("%s-%d", instanceName, vm.VMID)
// Use cluster-aware guest ID to prevent duplicates when multiple cluster nodes are configured
guestID := makeGuestID(instanceName, clusterName, isCluster, vm.VMID)
guestRaw := VMMemoryRaw{
ListingMem: vm.Mem,
@ -873,7 +874,9 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
}
// pollContainersWithNodes polls containers from all nodes in parallel using goroutines
func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName string, client PVEClientInterface, nodes []proxmox.Node, nodeEffectiveStatus map[string]string) {
// When the instance is part of a cluster, the cluster name is used for guest IDs to prevent duplicates
// when multiple cluster nodes are configured as separate PVE instances.
func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName string, clusterName string, isCluster bool, client PVEClientInterface, nodes []proxmox.Node, nodeEffectiveStatus map[string]string) {
startTime := time.Now()
// Channel to collect container results from each node
@ -967,9 +970,8 @@ func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName stri
tags = strings.Split(container.Tags, ";")
}
// Create guest ID (stable across node migrations)
// Format: instance-VMID
guestID := fmt.Sprintf("%s-%d", instanceName, container.VMID)
// Use cluster-aware guest ID to prevent duplicates when multiple cluster nodes are configured
guestID := makeGuestID(instanceName, clusterName, isCluster, int(container.VMID))
// Calculate I/O rates
currentMetrics := IOMetrics{
@ -1713,7 +1715,15 @@ func (m *Monitor) pollPVENode(
}
// Apply grace period for node status to prevent flapping
nodeID := instanceName + "-" + node.Node
// For clustered nodes, use clusterName-nodeName as the ID to deduplicate
// when the same cluster is registered via multiple entry points
// (e.g., agent installed with --enable-proxmox on multiple cluster nodes)
var nodeID string
if instanceCfg.IsCluster && instanceCfg.ClusterName != "" {
nodeID = instanceCfg.ClusterName + "-" + node.Node
} else {
nodeID = instanceName + "-" + node.Node
}
effectiveStatus := node.Status
now := time.Now()