diff --git a/internal/models/models.go b/internal/models/models.go index cf82b88..4d70352 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -87,6 +87,9 @@ type Node struct { ConnectionHealth string `json:"connectionHealth"` IsClusterMember bool `json:"isClusterMember"` // True if part of a cluster ClusterName string `json:"clusterName"` // Name of cluster (empty if standalone) + + // Linking: When a host agent is running on this PVE node, link them together + LinkedHostAgentID string `json:"linkedHostAgentId,omitempty"` // ID of the host agent running on this node } // VM represents a virtual machine @@ -184,6 +187,11 @@ type Host struct { TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"` Tags []string `json:"tags,omitempty"` IsLegacy bool `json:"isLegacy,omitempty"` + + // Linking: When this host agent is running on a known PVE node/VM/container + LinkedNodeID string `json:"linkedNodeId,omitempty"` // ID of the PVE node this agent is running on + LinkedVMID string `json:"linkedVmId,omitempty"` // ID of the VM this agent is running inside + LinkedContainerID string `json:"linkedContainerId,omitempty"` // ID of the container this agent is running inside } // HostNetworkInterface describes a host network adapter summary. @@ -1260,15 +1268,47 @@ func (s *State) UpdateNodesForInstance(instanceName string, nodes []Node) { defer s.mu.Unlock() // Create a map of existing nodes, excluding those from this instance + // 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 + existingNodeLinks[node.ID] = node.LinkedHostAgentID + } + } + + // Build hostname-to-hostAgentID map for linking new nodes to existing host agents + hostAgentByHostname := make(map[string]string) // lowercase hostname -> hostAgentID + for _, host := range s.Hosts { + if host.ID != "" { + hostAgentByHostname[strings.ToLower(host.Hostname)] = host.ID + // Also index by short hostname + if idx := strings.Index(host.Hostname, "."); idx > 0 { + hostAgentByHostname[strings.ToLower(host.Hostname[:idx])] = host.ID + } } } // Add or update nodes from this instance for _, node := range nodes { + // Preserve existing link if we had one + if existingLink, ok := existingNodeLinks[node.ID]; ok { + node.LinkedHostAgentID = existingLink + } + // If no existing link, try to match by hostname + if node.LinkedHostAgentID == "" { + nodeName := strings.ToLower(node.Name) + if hostID, ok := hostAgentByHostname[nodeName]; ok { + node.LinkedHostAgentID = hostID + } else if idx := strings.Index(nodeName, "."); idx > 0 { + if hostID, ok := hostAgentByHostname[nodeName[:idx]]; ok { + node.LinkedHostAgentID = hostID + } + } + } nodeMap[node.ID] = node } @@ -1930,6 +1970,22 @@ func (s *State) ClearAllHosts() int { return count } +// LinkNodeToHostAgent updates a PVE node to link to its host agent. +// This is called when a host agent registers and matches a known PVE node by hostname. +func (s *State) LinkNodeToHostAgent(nodeID, hostAgentID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + for i, node := range s.Nodes { + if node.ID == nodeID { + s.Nodes[i].LinkedHostAgentID = hostAgentID + s.LastUpdate = time.Now() + return true + } + } + return false +} + // UpsertCephCluster inserts or updates a Ceph cluster in the state. // Uses ID (typically the FSID) for matching. func (s *State) UpsertCephCluster(cluster CephCluster) { diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 0f52edf..a0277ec 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2147,9 +2147,42 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. host.TokenLastUsedAt = previous.TokenLastUsedAt } + // Link host agent to matching PVE node/VM/container by hostname + // This prevents duplication when users install agents on PVE cluster nodes + linkedNodeID, linkedVMID, linkedContainerID := m.findLinkedProxmoxEntity(hostname) + if linkedNodeID != "" { + host.LinkedNodeID = linkedNodeID + log.Info(). + Str("hostId", identifier). + Str("hostname", hostname). + Str("linkedNodeId", linkedNodeID). + Msg("Linked host agent to PVE node") + } + if linkedVMID != "" { + host.LinkedVMID = linkedVMID + log.Info(). + Str("hostId", identifier). + Str("hostname", hostname). + Str("linkedVmId", linkedVMID). + Msg("Linked host agent to VM") + } + if linkedContainerID != "" { + host.LinkedContainerID = linkedContainerID + log.Info(). + Str("hostId", identifier). + Str("hostname", hostname). + Str("linkedContainerId", linkedContainerID). + Msg("Linked host agent to container") + } + m.state.UpsertHost(host) m.state.SetConnectionHealth(hostConnectionPrefix+host.ID, true) + // Update the linked PVE node to point back to this host agent + if host.LinkedNodeID != "" { + m.linkNodeToHostAgent(host.LinkedNodeID, host.ID) + } + // If host reports Ceph data, also update the global CephClusters state if report.Ceph != nil { cephCluster := convertAgentCephToGlobalCluster(report.Ceph, hostname, identifier, timestamp) @@ -2170,6 +2203,65 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. return host, nil } +// findLinkedProxmoxEntity searches for a PVE node, VM, or container with a matching hostname. +// Returns the IDs of matched entities (empty string if no match). +func (m *Monitor) findLinkedProxmoxEntity(hostname string) (nodeID, vmID, containerID string) { + if hostname == "" { + return "", "", "" + } + + // Normalize hostname for comparison (lowercase, strip domain) + normalizedHostname := strings.ToLower(hostname) + shortHostname := normalizedHostname + if idx := strings.Index(normalizedHostname, "."); idx > 0 { + shortHostname = normalizedHostname[:idx] + } + + matchHostname := func(name string) bool { + normalized := strings.ToLower(name) + if normalized == normalizedHostname || normalized == shortHostname { + return true + } + // Also check short version of the candidate + if idx := strings.Index(normalized, "."); idx > 0 { + if normalized[:idx] == shortHostname { + return true + } + } + return false + } + + state := m.GetState() + + // Check PVE nodes first + for _, node := range state.Nodes { + if matchHostname(node.Name) { + return node.ID, "", "" + } + } + + // Check VMs + for _, vm := range state.VMs { + if matchHostname(vm.Name) { + return "", vm.ID, "" + } + } + + // Check containers + for _, ct := range state.Containers { + if matchHostname(ct.Name) { + return "", "", ct.ID + } + } + + return "", "", "" +} + +// linkNodeToHostAgent updates a PVE node to link to its host agent. +func (m *Monitor) linkNodeToHostAgent(nodeID, hostAgentID string) { + m.state.LinkNodeToHostAgent(nodeID, hostAgentID) +} + const ( removedDockerHostsTTL = 24 * time.Hour // Clean up removed hosts tracking after 24 hours )