mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 14:07:31 +00:00
16c81784b9
An agent on a Proxmox cluster node reports SMART data for the node's disks, and the registry stamps each disk with its node's Proxmox identity so it stays discoverable in the Proxmox workspace. The /api/connections member projection accepted any resource carrying that identity, so each disk merged into its node's member row, adding its model name to the aliases and its health to the node state. A disk with a risk warning (for example 60C or more) therefore made an actively reporting node show Stale. Since members are merged by freshness, the row flipped every time the agent report and the Proxmox poll swapped places, several times a minute on a customer's three-node cluster running 6.4.5. Only node resources now compose cluster members. The disk keeps its ownership facet, risk and storage evidence unchanged.
1065 lines
31 KiB
Go
1065 lines
31 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
|
|
}
|
|
// Disks an agent reports on a cluster node carry the node's Proxmox
|
|
// identity so they stay discoverable in the Proxmox workspace. Only the
|
|
// node itself is a member; a disk's health is not the node's liveness.
|
|
if unified.CanonicalResourceType(resource.Type) != unified.ResourceTypeAgent {
|
|
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
|
|
}
|
|
}
|