Files
pulse/internal/api/connections_grouping.go
T
Pulse Test 54c3cdc061 Let fresher evidence decide merged connection member state
When the Proxmox node inventory and the unified resource registry both
project the same machine as a system member, the merge paired the most
severe state from one plane with the newest LastSeen from the other. A
lapsed projection, such as an orphaned registry entry left behind by a
remove/re-enroll cycle, could therefore permanently render a member as
Stale beside a heartbeat seconds old, and the cluster rollup inherited
the contradiction onto the parent row.

The plane that has seen the machine more recently now decides liveness.
Severity still decides when neither side carries evidence or the
timestamps tie, so genuinely offline members keep their state.

Refs #1728
2026-08-30 00:02:15 +01:00

1059 lines
30 KiB
Go

package api
import (
"net"
"net/url"
"sort"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
type connectionSystemGroup struct {
id string
typ ConnectionType
name string
clusterName string
components map[string]ConnectionSystemComponent
}
func buildConnectionSystems(
connections []Connection,
monitor *monitoring.Monitor,
) []ConnectionSystem {
if len(connections) == 0 {
return nil
}
connectionByID := make(map[string]Connection, len(connections))
for _, connection := range connections {
connectionByID[connection.ID] = connection
}
clusterNames := buildProxmoxClusterNames(connectionByID, monitor)
hostIndex := buildConnectionHostIndex(connections)
agentAttachments := resolveAgentAttachments(connectionByID, hostIndex, monitor)
systemMembers := buildConnectionSystemMembers(connectionByID, agentAttachments, monitor)
groups := make(map[string]*connectionSystemGroup, len(connections))
ensureGroup := func(primary Connection) *connectionSystemGroup {
group := groups[primary.ID]
if group != nil {
return group
}
clusterName := strings.TrimSpace(clusterNames[primary.ID])
sortName := strings.ToLower(primary.Name)
if clusterName != "" {
sortName = strings.ToLower(clusterName)
}
group = &connectionSystemGroup{
id: primary.ID,
typ: primary.Type,
name: sortName,
clusterName: clusterName,
components: make(map[string]ConnectionSystemComponent, 2),
}
group.components[primary.ID] = ConnectionSystemComponent{
ConnectionID: primary.ID,
Type: primary.Type,
Role: ConnectionSystemComponentRolePrimary,
}
groups[primary.ID] = group
return group
}
for _, connection := range connections {
if connection.Type == ConnectionTypeAgent {
if primaryID, ok := agentAttachments[connection.ID]; ok && primaryID != "" {
primary, exists := connectionByID[primaryID]
if !exists {
continue
}
group := ensureGroup(primary)
group.components[connection.ID] = ConnectionSystemComponent{
ConnectionID: connection.ID,
Type: connection.Type,
Role: ConnectionSystemComponentRoleAttachment,
}
continue
}
}
ensureGroup(connection)
}
out := make([]ConnectionSystem, 0, len(groups))
for _, group := range groups {
components := make([]ConnectionSystemComponent, 0, len(group.components))
for _, component := range group.components {
components = append(components, component)
}
sort.Slice(components, func(i, j int) bool {
if components[i].Role != components[j].Role {
return connectionComponentRolePriority(components[i].Role) <
connectionComponentRolePriority(components[j].Role)
}
if components[i].Type != components[j].Type {
return connectionTypePriority(components[i].Type) < connectionTypePriority(components[j].Type)
}
return components[i].ConnectionID < components[j].ConnectionID
})
out = append(out, ConnectionSystem{
ID: group.id,
Type: group.typ,
ClusterName: group.clusterName,
Components: components,
Members: systemMembers[group.id],
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Type != out[j].Type {
return connectionTypePriority(out[i].Type) < connectionTypePriority(out[j].Type)
}
leftName := groups[out[i].ID].name
rightName := groups[out[j].ID].name
if leftName == rightName {
return out[i].ID < out[j].ID
}
return leftName < rightName
})
return out
}
func buildConnectionSystemMembers(
connectionByID map[string]Connection,
agentAttachments map[string]string,
monitor *monitoring.Monitor,
) map[string][]ConnectionSystemMember {
systemMembers := make(map[string][]ConnectionSystemMember)
if monitor == nil {
return systemMembers
}
bySystemKey := make(map[string]map[string]ConnectionSystemMember)
register := func(primaryID string, member ConnectionSystemMember) {
primaryID = strings.TrimSpace(primaryID)
member.Name = strings.TrimSpace(member.Name)
if primaryID == "" || member.Name == "" {
return
}
if _, ok := connectionByID[primaryID]; !ok {
return
}
memberKey := connectionSystemMemberKey(member)
if memberKey == "" {
return
}
member.ID = strings.TrimSpace(member.ID)
if member.ID == "" {
member.ID = memberKey
}
bucket := bySystemKey[primaryID]
if bucket == nil {
bucket = make(map[string]ConnectionSystemMember)
bySystemKey[primaryID] = bucket
}
existing, exists := bucket[memberKey]
if !exists {
bucket[memberKey] = member
return
}
bucket[memberKey] = mergeConnectionSystemMembers(existing, member)
}
for _, node := range monitor.NodesSnapshot() {
member, primaryID, ok := connectionSystemMemberFromNode(node, connectionByID, agentAttachments)
if !ok {
continue
}
register(primaryID, member)
}
resources, _ := monitor.UnifiedResourceSnapshot()
for _, resource := range resources {
member, primaryID, ok := connectionSystemMemberFromResource(resource, connectionByID, agentAttachments)
if !ok {
continue
}
register(primaryID, member)
}
for primaryID, membersByKey := range bySystemKey {
members := make([]ConnectionSystemMember, 0, len(membersByKey))
for _, member := range membersByKey {
members = append(members, member)
}
sort.Slice(members, func(i, j int) bool {
if members[i].Primary != members[j].Primary {
return members[i].Primary
}
leftName := strings.ToLower(strings.TrimSpace(members[i].Name))
rightName := strings.ToLower(strings.TrimSpace(members[j].Name))
if leftName == rightName {
return members[i].ID < members[j].ID
}
return leftName < rightName
})
systemMembers[primaryID] = members
}
return systemMembers
}
func connectionSystemMemberFromNode(
node models.Node,
connectionByID map[string]Connection,
agentAttachments map[string]string,
) (ConnectionSystemMember, string, bool) {
if !node.IsClusterMember {
return ConnectionSystemMember{}, "", false
}
primaryID := "pve:" + strings.TrimSpace(node.Instance)
primary, ok := connectionByID[primaryID]
if !ok || primary.Type != ConnectionTypePVE {
return ConnectionSystemMember{}, "", false
}
var lastSeen *time.Time
if !node.LastSeen.IsZero() {
t := node.LastSeen
lastSeen = &t
}
return ConnectionSystemMember{
ID: strings.TrimSpace(node.ID),
NodeIdentity: firstNonEmptyTrimmed(node.NodeIdentity, node.ID),
Name: firstNonEmptyTrimmed(node.DisplayName, node.Name),
NativeName: strings.TrimSpace(node.Name),
Endpoint: strings.TrimSpace(node.Host),
HostAliases: connectionSystemMemberHostAliasesFromNode(node),
State: connectionSystemMemberStateFromNode(node),
LastSeen: lastSeen,
Primary: isPrimaryProxmoxSystemMember(primary, node.Name, node.Host),
AgentConnectionID: attachedAgentConnectionID(node.LinkedAgentID, primaryID, connectionByID, agentAttachments),
}, primaryID, true
}
func connectionSystemMemberFromResource(
resource unified.Resource,
connectionByID map[string]Connection,
agentAttachments map[string]string,
) (ConnectionSystemMember, string, bool) {
if member, primaryID, ok := connectionSystemMemberFromVMwareHost(resource, connectionByID); ok {
return member, primaryID, true
}
if resource.Proxmox == nil || !resource.Proxmox.IsClusterMember {
return ConnectionSystemMember{}, "", false
}
nodeName := strings.TrimSpace(resource.Proxmox.NodeName)
if nodeName == "" {
return ConnectionSystemMember{}, "", false
}
primaryID := "pve:" + strings.TrimSpace(resource.Proxmox.Instance)
primary, ok := connectionByID[primaryID]
if !ok || primary.Type != ConnectionTypePVE {
return ConnectionSystemMember{}, "", false
}
linkedAgentID := resource.Proxmox.LinkedAgentID
if linkedAgentID == "" && resource.Agent != nil {
linkedAgentID = resource.Agent.AgentID
}
var lastSeen *time.Time
if !resource.LastSeen.IsZero() {
t := resource.LastSeen
lastSeen = &t
}
return ConnectionSystemMember{
ID: strings.TrimSpace(resource.ID),
NodeIdentity: firstNonEmptyTrimmed(resource.Proxmox.NodeIdentity, resource.Proxmox.SourceID),
Name: firstNonEmptyTrimmed(resource.Proxmox.NodeDisplayName, resource.Name, nodeName),
NativeName: nodeName,
Endpoint: strings.TrimSpace(resource.Proxmox.HostURL),
HostAliases: connectionSystemMemberHostAliasesFromResource(resource),
State: connectionSystemMemberStateFromResource(resource),
LastSeen: lastSeen,
Primary: isPrimaryProxmoxSystemMember(primary, nodeName, resource.Proxmox.HostURL),
AgentConnectionID: attachedAgentConnectionID(linkedAgentID, primaryID, connectionByID, agentAttachments),
}, primaryID, true
}
// connectionSystemMemberFromVMwareHost projects an ESXi host resource as a
// member of its owning vCenter connection, mirroring how Proxmox cluster
// nodes compose under their cluster source. One vCenter connection spans many
// hosts; single-machine sources (TrueNAS) have no member composition because
// the connection row already is the machine.
func connectionSystemMemberFromVMwareHost(
resource unified.Resource,
connectionByID map[string]Connection,
) (ConnectionSystemMember, string, bool) {
if resource.VMware == nil || !strings.EqualFold(strings.TrimSpace(resource.VMware.EntityType), "host") {
return ConnectionSystemMember{}, "", false
}
connectionID := strings.TrimSpace(resource.VMware.ConnectionID)
if connectionID == "" {
return ConnectionSystemMember{}, "", false
}
primaryID := "vmware:" + connectionID
primary, ok := connectionByID[primaryID]
if !ok || primary.Type != ConnectionTypeVMware {
return ConnectionSystemMember{}, "", false
}
var lastSeen *time.Time
if !resource.LastSeen.IsZero() {
t := resource.LastSeen
lastSeen = &t
}
return ConnectionSystemMember{
ID: strings.TrimSpace(resource.ID),
Name: firstNonEmptyTrimmed(resource.Name, resource.VMware.ManagedObjectID),
HostAliases: connectionSystemMemberHostAliasesFromResource(resource),
State: connectionSystemMemberStateFromResource(resource),
LastSeen: lastSeen,
}, primaryID, true
}
func mergeConnectionSystemMembers(
existing ConnectionSystemMember,
candidate ConnectionSystemMember,
) ConnectionSystemMember {
if strings.TrimSpace(existing.ID) == "" {
existing.ID = candidate.ID
}
if strings.TrimSpace(existing.Name) == "" {
existing.Name = candidate.Name
}
if strings.TrimSpace(existing.NodeIdentity) == "" {
existing.NodeIdentity = candidate.NodeIdentity
}
if strings.TrimSpace(existing.NativeName) == "" {
existing.NativeName = candidate.NativeName
}
if strings.TrimSpace(existing.Endpoint) == "" {
existing.Endpoint = candidate.Endpoint
}
existing.State = mergedConnectionSystemMemberState(existing, candidate)
if existing.LastSeen == nil ||
(candidate.LastSeen != nil && candidate.LastSeen.After(*existing.LastSeen)) {
existing.LastSeen = candidate.LastSeen
}
if !existing.Primary && candidate.Primary {
existing.Primary = true
}
if strings.TrimSpace(existing.AgentConnectionID) == "" {
existing.AgentConnectionID = candidate.AgentConnectionID
}
existing.HostAliases = appendNormalizedHosts(existing.HostAliases, candidate.HostAliases...)
return existing
}
// mergedConnectionSystemMemberState resolves the state when the same machine
// is projected as a member by more than one observation plane (the Proxmox
// node inventory and the unified resource registry). The plane that has seen
// the machine more recently decides liveness; severity decides only when
// recency cannot (neither side carries evidence, or the timestamps tie).
// Merged LastSeen already keeps the newest timestamp, so this also guarantees
// the rendered badge and the rendered "Ns ago" come from the same plane — a
// lapsed projection, such as an orphaned registry entry left behind by a
// remove/re-enroll cycle, must not overrule a plane that heard from the
// machine seconds ago. Pairing worst state with newest timestamp rendered
// rows as "Stale · 5s ago".
func mergedConnectionSystemMemberState(
existing ConnectionSystemMember,
candidate ConnectionSystemMember,
) ConnectionState {
switch {
case existing.LastSeen == nil && candidate.LastSeen == nil:
return moreSevereConnectionSystemMemberState(existing.State, candidate.State)
case existing.LastSeen == nil:
return candidate.State
case candidate.LastSeen == nil:
return existing.State
case candidate.LastSeen.After(*existing.LastSeen):
return candidate.State
case existing.LastSeen.After(*candidate.LastSeen):
return existing.State
default:
return moreSevereConnectionSystemMemberState(existing.State, candidate.State)
}
}
func moreSevereConnectionSystemMemberState(
left ConnectionState,
right ConnectionState,
) ConnectionState {
if connectionSystemMemberStateSeverity(right) > connectionSystemMemberStateSeverity(left) {
return right
}
return left
}
func connectionSystemMemberStateSeverity(state ConnectionState) int {
switch state {
case ConnectionStateActive:
return 0
case ConnectionStatePaused:
return 1
case ConnectionStatePending:
return 2
case ConnectionStateStale:
return 3
case ConnectionStateUnauthorized:
return 4
case ConnectionStateUnreachable:
return 5
default:
return 2
}
}
func connectionSystemMemberHostAliasesFromNode(node models.Node) []string {
values := []string{
node.Name,
node.DisplayName,
node.Host,
}
values = append(values, node.NativeNameAliases...)
return appendNormalizedHosts(nil, values...)
}
func connectionSystemMemberHostAliasesFromResource(resource unified.Resource) []string {
values := []string{
resource.Name,
canonicalResourceHostname(resource),
}
if resource.Proxmox != nil {
values = append(values, resource.Proxmox.NodeName, resource.Proxmox.NodeDisplayName, resource.Proxmox.HostURL)
values = append(values, resource.Proxmox.NodeAliases...)
}
if resource.Agent != nil {
values = append(values, resource.Agent.Hostname)
}
values = append(values, resource.Identity.Hostnames...)
values = append(values, resource.Identity.IPAddresses...)
return appendNormalizedHosts(nil, values...)
}
func connectionSystemMemberKey(member ConnectionSystemMember) string {
for _, candidate := range []string{
strings.TrimSpace(member.NodeIdentity),
strings.TrimSpace(member.ID),
strings.TrimSpace(member.NativeName),
strings.TrimSpace(member.Endpoint),
} {
if normalized := normalizeHost(candidate); normalized != "" {
return normalized
}
candidate = strings.ToLower(strings.TrimSpace(candidate))
if candidate != "" {
return candidate
}
}
return ""
}
func connectionSystemMemberStateFromNode(node models.Node) ConnectionState {
status := strings.ToLower(strings.TrimSpace(node.Status))
health := strings.ToLower(strings.TrimSpace(node.ConnectionHealth))
switch {
case status == "offline" || health == "error" || health == "offline" || health == "unhealthy":
return ConnectionStateUnreachable
case health == "degraded" || health == "unknown":
return ConnectionStateStale
case node.LastSeen.IsZero():
return ConnectionStatePending
default:
return ConnectionStateActive
}
}
func connectionSystemMemberStateFromResource(resource unified.Resource) ConnectionState {
switch resource.Status {
case unified.StatusOffline:
return ConnectionStateUnreachable
case unified.StatusWarning:
return ConnectionStateStale
case unified.StatusUnknown:
return ConnectionStatePending
default:
if resource.LastSeen.IsZero() {
return ConnectionStatePending
}
return ConnectionStateActive
}
}
func attachedAgentConnectionID(
rawAgentID string,
primaryID string,
connectionByID map[string]Connection,
agentAttachments map[string]string,
) string {
agentID := strings.TrimSpace(rawAgentID)
if agentID == "" {
return ""
}
connectionID := "agent:" + agentID
connection, ok := connectionByID[connectionID]
if !ok || connection.Type != ConnectionTypeAgent {
return ""
}
if attachedPrimary := strings.TrimSpace(agentAttachments[connectionID]); attachedPrimary != "" &&
attachedPrimary != primaryID {
return ""
}
return connectionID
}
func isPrimaryProxmoxSystemMember(primary Connection, nodeName, endpoint string) bool {
nodeName = strings.TrimSpace(nodeName)
if nodeName != "" {
for _, candidate := range []string{
primary.Name,
strings.TrimPrefix(primary.ID, "pve:"),
} {
if strings.EqualFold(nodeName, strings.TrimSpace(candidate)) {
return true
}
}
}
return connectionsShareHost(
Connection{Name: nodeName, Address: strings.TrimSpace(endpoint)},
primary,
)
}
func connectionComponentRolePriority(role ConnectionSystemComponentRole) int {
switch role {
case ConnectionSystemComponentRolePrimary:
return 0
case ConnectionSystemComponentRoleAttachment:
return 1
default:
return 100
}
}
func resolveAgentAttachments(
connectionByID map[string]Connection,
hostIndex connectionHostIndex,
monitor *monitoring.Monitor,
) map[string]string {
attachments := make(map[string]string)
if monitor == nil {
return attachments
}
register := func(agentID, primaryID string, allowOverride, allowClusterAttach bool) {
agentID = strings.TrimSpace(agentID)
primaryID = strings.TrimSpace(primaryID)
if agentID == "" || primaryID == "" || agentID == primaryID {
return
}
agent, ok := connectionByID[agentID]
if !ok {
return
}
primary, ok := connectionByID[primaryID]
if !ok || primary.Type == ConnectionTypeAgent {
return
}
if !allowClusterAttach && !connectionsShareHost(agent, primary) {
return
}
existing, exists := attachments[agentID]
if !exists || existing == "" {
attachments[agentID] = primaryID
return
}
if existing == primaryID {
return
}
if allowOverride {
attachments[agentID] = primaryID
return
}
attachments[agentID] = ""
}
for _, node := range monitor.NodesSnapshot() {
if strings.TrimSpace(node.LinkedAgentID) == "" || strings.TrimSpace(node.Instance) == "" {
continue
}
register(
"agent:"+strings.TrimSpace(node.LinkedAgentID),
"pve:"+strings.TrimSpace(node.Instance),
false,
true,
)
}
resources, _ := monitor.UnifiedResourceSnapshot()
for _, resource := range resources {
if resource.Agent == nil {
continue
}
agentID := "agent:" + strings.TrimSpace(resource.Agent.AgentID)
if _, ok := connectionByID[agentID]; !ok {
continue
}
if _, exists := attachments[agentID]; exists {
continue
}
if primaryID := primaryConnectionIDForResource(resource, hostIndex); primaryID != "" {
allowClusterAttach := resource.Proxmox != nil &&
strings.TrimSpace(resource.Agent.LinkedNodeID) != ""
register(agentID, primaryID, false, allowClusterAttach)
}
}
for _, attachment := range identityGroupedAgentAttachments(connectionByID, hostIndex, resources) {
if _, exists := attachments[attachment.agentID]; exists {
continue
}
register(attachment.agentID, attachment.primaryID, false, true)
}
for _, agent := range sortedConnectionsByID(connectionByID) {
if agent.Type != ConnectionTypeAgent {
continue
}
if _, exists := attachments[agent.ID]; exists {
continue
}
if primaryID := directPlatformHostAttachment(agent, connectionByID); primaryID != "" {
register(agent.ID, primaryID, false, false)
}
}
for agentID, primaryID := range attachments {
if primaryID == "" {
delete(attachments, agentID)
}
}
return attachments
}
type identityGroupedAgentAttachment struct {
agentID string
primaryID string
}
// identityGroupedAgentAttachments pairs agent connections with the single
// PBS, PMG, or TrueNAS connection whose host resolves into the same
// top-level system group. The shared resolver holds the canonical
// same-machine contract (machine identity, short-form hostname equivalence,
// unique IPs), so a PBS host running a Pulse agent pairs here even when the
// configured API address shares no literal host string with anything the
// agent reports — the case the direct host fallback below cannot see.
// Proxmox stays with the LinkedAgentID passes, which carry the
// cluster-membership semantics this group view does not.
func identityGroupedAgentAttachments(
connectionByID map[string]Connection,
hostIndex connectionHostIndex,
resources []unified.Resource,
) []identityGroupedAgentAttachment {
// Mirror the monitored-system root set (infrastructure parents plus
// PBS, PMG, and Kubernetes cluster roots) so grouping resolves with the
// same ambiguity evidence the Infrastructure surfaces use.
roots := make([]unified.Resource, 0, len(resources))
for _, resource := range resources {
switch unified.CanonicalResourceType(resource.Type) {
case unified.ResourceTypeAgent, unified.ResourceTypePBS, unified.ResourceTypePMG,
unified.ResourceTypeK8sCluster:
roots = append(roots, resource)
}
}
if len(roots) == 0 {
return nil
}
resolver := unified.ResolveTopLevelSystems(roots)
type groupBucket struct {
agentIDs []string
primaries map[string]struct{}
}
buckets := make(map[string]*groupBucket)
bucketFor := func(groupID string) *groupBucket {
bucket := buckets[groupID]
if bucket == nil {
bucket = &groupBucket{primaries: make(map[string]struct{})}
buckets[groupID] = bucket
}
return bucket
}
for _, resource := range roots {
groupID := resolver.GroupIDForResource(resource)
if groupID == "" {
continue
}
if resource.Agent != nil {
agentID := "agent:" + strings.TrimSpace(resource.Agent.AgentID)
if agent, ok := connectionByID[agentID]; ok && agent.Type == ConnectionTypeAgent {
bucketFor(groupID).agentIDs = append(bucketFor(groupID).agentIDs, agentID)
}
}
if resource.Proxmox != nil || resource.VMware != nil {
continue
}
primaryID := primaryConnectionIDForResource(resource, hostIndex)
if primaryID == "" {
continue
}
primary, ok := connectionByID[primaryID]
if !ok || primary.Type == ConnectionTypePVE || !isSingleHostPlatformConnectionType(primary.Type) {
continue
}
bucketFor(groupID).primaries[primaryID] = struct{}{}
}
groupIDs := make([]string, 0, len(buckets))
for groupID := range buckets {
groupIDs = append(groupIDs, groupID)
}
sort.Strings(groupIDs)
attachments := make([]identityGroupedAgentAttachment, 0)
for _, groupID := range groupIDs {
bucket := buckets[groupID]
// A group naming more than one platform connection is ambiguous
// evidence; fail closed the same way the direct host fallback does.
if len(bucket.primaries) != 1 || len(bucket.agentIDs) == 0 {
continue
}
var primaryID string
for id := range bucket.primaries {
primaryID = id
}
sort.Strings(bucket.agentIDs)
for _, agentID := range bucket.agentIDs {
attachments = append(attachments, identityGroupedAgentAttachment{
agentID: agentID,
primaryID: primaryID,
})
}
}
return attachments
}
func sortedConnectionsByID(connectionByID map[string]Connection) []Connection {
connections := make([]Connection, 0, len(connectionByID))
for _, connection := range connectionByID {
connections = append(connections, connection)
}
sort.Slice(connections, func(i, j int) bool {
return connections[i].ID < connections[j].ID
})
return connections
}
func directPlatformHostAttachment(
agent Connection,
connectionByID map[string]Connection,
) string {
if agent.Type != ConnectionTypeAgent {
return ""
}
var matchedID string
for _, primary := range sortedConnectionsByID(connectionByID) {
if !isSingleHostPlatformConnectionType(primary.Type) || !primary.Enabled {
continue
}
if !connectionsShareHost(agent, primary) {
continue
}
if matchedID != "" {
return ""
}
matchedID = primary.ID
}
return matchedID
}
func isSingleHostPlatformConnectionType(typ ConnectionType) bool {
switch typ {
case ConnectionTypePVE, ConnectionTypePBS, ConnectionTypePMG, ConnectionTypeTrueNAS:
return true
default:
return false
}
}
func buildProxmoxClusterNames(
connectionByID map[string]Connection,
monitor *monitoring.Monitor,
) map[string]string {
clusterNames := make(map[string]string)
if monitor == nil {
return clusterNames
}
record := func(primaryID, clusterName string) {
primaryID = strings.TrimSpace(primaryID)
clusterName = strings.TrimSpace(clusterName)
if primaryID == "" || clusterName == "" {
return
}
primary, ok := connectionByID[primaryID]
if !ok || primary.Type != ConnectionTypePVE {
return
}
if _, exists := clusterNames[primaryID]; exists {
return
}
clusterNames[primaryID] = clusterName
}
for _, node := range monitor.NodesSnapshot() {
if !node.IsClusterMember {
continue
}
record("pve:"+strings.TrimSpace(node.Instance), node.ClusterName)
}
resources, _ := monitor.UnifiedResourceSnapshot()
for _, resource := range resources {
if resource.Proxmox == nil || !resource.Proxmox.IsClusterMember {
continue
}
record("pve:"+strings.TrimSpace(resource.Proxmox.Instance), resource.Proxmox.ClusterName)
}
return clusterNames
}
type connectionHostIndex struct {
byType map[ConnectionType]map[string][]string
}
func buildConnectionHostIndex(connections []Connection) connectionHostIndex {
index := connectionHostIndex{byType: make(map[ConnectionType]map[string][]string)}
for _, connection := range connections {
host := normalizedConnectionHost(connection)
if host == "" {
continue
}
typeBucket := index.byType[connection.Type]
if typeBucket == nil {
typeBucket = make(map[string][]string)
index.byType[connection.Type] = typeBucket
}
typeBucket[host] = append(typeBucket[host], connection.ID)
}
return index
}
func primaryConnectionIDForResource(
resource unified.Resource,
hostIndex connectionHostIndex,
) string {
switch {
case resource.Proxmox != nil:
if instance := strings.TrimSpace(resource.Proxmox.Instance); instance != "" {
return "pve:" + instance
}
case resource.VMware != nil:
if connectionID := strings.TrimSpace(resource.VMware.ConnectionID); connectionID != "" {
return "vmware:" + connectionID
}
case resource.TrueNAS != nil:
for _, candidate := range []string{
strings.TrimSpace(resource.TrueNAS.Hostname),
canonicalResourceHostname(resource),
strings.TrimSpace(resource.Name),
} {
if matched := hostIndex.uniqueMatch(ConnectionTypeTrueNAS, candidate); matched != "" {
return matched
}
}
case resource.PBS != nil:
if instanceID := strings.TrimSpace(resource.PBS.InstanceID); instanceID != "" {
// The PBS poller writes "pbs-<name>" instance IDs while
// connection IDs carry the bare configured instance name.
return "pbs:" + strings.TrimPrefix(instanceID, "pbs-")
}
case resource.PMG != nil:
if instanceID := strings.TrimSpace(resource.PMG.InstanceID); instanceID != "" {
return "pmg:" + strings.TrimPrefix(instanceID, "pmg-")
}
}
return ""
}
func (i connectionHostIndex) uniqueMatch(typ ConnectionType, raw string) string {
host := normalizeHost(raw)
if host == "" {
return ""
}
typeBucket := i.byType[typ]
if typeBucket == nil {
return ""
}
matches := typeBucket[host]
if len(matches) != 1 {
return ""
}
return matches[0]
}
func normalizedConnectionHost(connection Connection) string {
for _, candidate := range []string{connection.Address, connection.Name} {
if normalized := normalizeHost(candidate); normalized != "" {
return normalized
}
}
return ""
}
// connectionHostCandidates returns every distinct normalized host string a
// connection could be reached as. Connections frequently carry both a name
// (often a hostname) and an address (often a URL or IP), and we want any
// overlap to count as "same host" for grouping purposes.
func connectionHostCandidates(connection Connection) []string {
seen := make(map[string]struct{}, 2)
out := make([]string, 0, 2)
candidates := []string{connection.Address, connection.Name}
candidates = append(candidates, connection.HostAliases...)
for _, candidate := range candidates {
normalized := normalizeHost(candidate)
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
return out
}
// connectionsShareHost reports whether two connections appear to point at the
// same physical host. Used to gate agent→primary attachment so that sibling
// cluster members (e.g. a separate Proxmox node sharing the cluster API
// connection) stay visible as their own systems instead of being absorbed
// into the connection's primary row.
func connectionsShareHost(a, b Connection) bool {
aHosts := connectionHostCandidates(a)
if len(aHosts) == 0 {
return false
}
bHosts := connectionHostCandidates(b)
if len(bHosts) == 0 {
return false
}
bSet := make(map[string]struct{}, len(bHosts))
for _, host := range bHosts {
bSet[host] = struct{}{}
}
for _, host := range aHosts {
if _, ok := bSet[host]; ok {
return true
}
}
return false
}
func normalizeHost(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
if parsedURL, err := url.Parse(value); err == nil && parsedURL.Host != "" {
value = parsedURL.Hostname()
} else if host, _, err := net.SplitHostPort(value); err == nil {
value = host
}
if parsedIP, _, err := net.ParseCIDR(value); err == nil && parsedIP != nil {
return parsedIP.String()
}
if parsedIP := net.ParseIP(value); parsedIP != nil {
return parsedIP.String()
}
value = strings.TrimSpace(strings.ToLower(value))
value = strings.TrimSuffix(value, ".")
return value
}
func canonicalResourceHostname(resource unified.Resource) string {
if resource.Canonical == nil {
return ""
}
return strings.TrimSpace(resource.Canonical.Hostname)
}
func appendNormalizedHosts(existing []string, candidates ...string) []string {
seen := make(map[string]struct{}, len(existing)+len(candidates))
out := make([]string, 0, len(existing)+len(candidates))
for _, value := range existing {
normalized := normalizeHost(value)
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
for _, candidate := range candidates {
normalized := normalizeHost(candidate)
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
return out
}
func connectionTypePriority(typ ConnectionType) int {
switch typ {
case ConnectionTypePVE:
return 0
case ConnectionTypeVMware:
return 1
case ConnectionTypeTrueNAS:
return 2
case ConnectionTypeDocker:
return 3
case ConnectionTypeAgent:
return 4
case ConnectionTypeAvailability:
return 5
case ConnectionTypePBS:
return 10
case ConnectionTypePMG:
return 11
case ConnectionTypeKubernetes:
return 12
default:
return 100
}
}