mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 10:52:08 +00:00
908 lines
25 KiB
Go
908 lines
25 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 = moreSevereConnectionSystemMemberState(existing.State, candidate.State)
|
|
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
|
|
}
|
|
|
|
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 _, agent := range sortedConnectionsByID(connectionByID) {
|
|
if agent.Type != ConnectionTypeAgent {
|
|
continue
|
|
}
|
|
if _, exists := attachments[agent.ID]; exists {
|
|
continue
|
|
}
|
|
if primaryID := directProxmoxHostAttachment(agent, connectionByID); primaryID != "" {
|
|
register(agent.ID, primaryID, false, false)
|
|
}
|
|
}
|
|
|
|
for agentID, primaryID := range attachments {
|
|
if primaryID == "" {
|
|
delete(attachments, agentID)
|
|
}
|
|
}
|
|
|
|
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 directProxmoxHostAttachment(
|
|
agent Connection,
|
|
connectionByID map[string]Connection,
|
|
) string {
|
|
if agent.Type != ConnectionTypeAgent {
|
|
return ""
|
|
}
|
|
|
|
var matchedID string
|
|
for _, primary := range sortedConnectionsByID(connectionByID) {
|
|
if primary.Type != ConnectionTypePVE || !primary.Enabled {
|
|
continue
|
|
}
|
|
if !connectionsShareHost(agent, primary) {
|
|
continue
|
|
}
|
|
if matchedID != "" {
|
|
return ""
|
|
}
|
|
matchedID = primary.ID
|
|
}
|
|
return matchedID
|
|
}
|
|
|
|
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 != "" {
|
|
return "pbs:" + instanceID
|
|
}
|
|
case resource.PMG != nil:
|
|
if instanceID := strings.TrimSpace(resource.PMG.InstanceID); instanceID != "" {
|
|
return "pmg:" + instanceID
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|