Fix QNAP host/docker monitored-system split

Refs #1421
This commit is contained in:
rcourtman
2026-04-21 22:11:13 +01:00
parent e1c852350e
commit f796efceac
13 changed files with 443 additions and 77 deletions
@@ -23,7 +23,16 @@
- Result:
- self-hosted Community / Relay / Pro no longer carry the stale rc.2 monitored-system cap posture into the GA candidate.
3. `#1429` (`missing docker info including updates`)
3. `#1421` (`[Bug]: QNAP shown as separate Host and Docker agents, even though only one agent is installed`)
- Fixed on the current `pulse/v6-release` candidate by tightening the canonical hostname-equivalence contract used by top-level monitored-system grouping, monitored-system replacement selectors, and Docker host re-identification.
- Verification:
- `go test ./internal/unifiedresources -run 'TestHostnamesEquivalent|TestResolveTopLevelSystemsTopLevelSourceMatrix|TestProjectMonitoredSystemCandidateReplacementMatchesShortAndFQDNHostnames'`
- `go test ./internal/unifiedresources`
- `go test ./internal/monitoring -run 'TestFindMatchingDockerHost'`
- Result:
- the current candidate no longer splits one QNAP box into separate Host and Docker monitored-system rows just because one path reports `qnap` while another reports `qnap.local`.
4. `#1429` (`missing docker info including updates`)
- Treated as an umbrella trust report rather than one atomic bug. The user-visible failures admitted in the thread were decomposed and covered on the current candidate:
- stale self-hosted cap copy: covered by `943389827` and `770cceae5`
- unavailable compare-plans / Pulse Account handoff: covered by `429f12dec` (`Recover unavailable Pulse Account handoffs`)
@@ -37,7 +46,7 @@
- the GA candidate no longer knowingly carries the specific cap, handoff, or trend-state failures raised during the RC2 discussion.
- the remaining GitHub thread hygiene is reporter-confirmation / maintainer-triage work, not an admitted GA product blocker.
4. `#1430` (`Width of the Name column`)
5. `#1430` (`Width of the Name column`)
- Fixed by consolidating the workload table sizing contract into the canonical dashboard column model and removing the legacy global CSS width rules that caused Firefox to expand the table to multi-million-pixel width.
- Verification:
- `cd /Volumes/Development/pulse/repos/pulse/frontend-modern && npx vitest run src/components/Dashboard/__tests__/GuestRow.test.tsx src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx`
@@ -50,14 +59,14 @@
- Result:
- Firefox no longer blows the workloads table out horizontally; the current desktop table fits the shell with the bounded `Name` width contract intact.
5. `#1432` (`Dashbord filter`)
6. `#1432` (`Dashbord filter`)
- Already satisfied on the current candidate by the dashboard/workloads status filter path.
- Verification:
- `cd /Volumes/Development/pulse/repos/pulse/frontend-modern && npm test -- src/components/Dashboard/__tests__/DashboardFilter.test.tsx src/components/Dashboard/__tests__/workloadSelectors.test.ts`
- Result:
- the candidate already supports filtering the workloads slice by status (`All`, `Running`, `Degraded`, `Stopped`), so there is no missing offline-filter blocker to carry into GA.
6. `#1436` (`Better disk i/o reads for LXC containers`)
7. `#1436` (`Better disk i/o reads for LXC containers`)
- Fixed by merging prefetched LXC `status/current` counters into both container polling paths before rate calculation and reusing the same status snapshot for metadata enrichment.
- Verification:
- `go test ./internal/monitoring -run 'TestMergeContainerRuntimeCounters_PrefersHigherStatusCounters|TestBuildContainerFromClusterResource_UsesContainerStatusCountersForRates|TestBuildContainerFromClusterResource_PreservesProxmoxPool|TestEnrichContainerMetadata_DetectsOCIForStoppedContainer|TestMonitor_EnrichContainerMetadata_Extra'`
@@ -613,6 +613,11 @@ host identifier for the `(token, machine-id, hostname)` tuple before it checks
`removedHostAgents` or emits the reconnect-blocking error, so removing one
token-bound host cannot poison a different host that shared the same raw
machine identifier.
Docker host re-identification now shares the same hostname-equivalence rule:
monitoring may treat `qnap` and `qnap.local` as the same host when the token
or machine identity already points at one canonical runtime, but it must not
invent broader short-name collapsing on its own or fork away from the
unified-resource monitored-system contract.
Node disk-source selection now also routes through one canonical resolver
under `internal/monitoring/`. When a Proxmox node has a linked Pulse host
agent, the node summary must prefer the linked host's canonical disk view over
@@ -39,66 +39,68 @@ cross-source deduplication.
17. `internal/unifiedresources/pbs_rollups.go`
18. `internal/unifiedresources/monitored_systems.go`
19. `internal/unifiedresources/top_level_systems.go`
20. `internal/unifiedresources/capabilities.go`
21. `internal/unifiedresources/changes.go`
22. `internal/unifiedresources/relationships.go`
23. `internal/unifiedresources/privacy.go`
24. `internal/unifiedresources/actions.go`
25. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx`
26. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx`
27. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx`
28. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx`
29. `frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx`
30. `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
31. `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
32. `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx`
33. `frontend-modern/src/components/Infrastructure/resourceBadges.ts`
34. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx`
35. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx`
36. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx`
37. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx`
38. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts`
39. `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts`
40. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts`
41. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerServiceModel.ts`
42. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts`
43. `frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts`
44. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts`
45. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts`
46. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts`
47. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
48. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`
49. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts`
50. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx`
51. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts`
52. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
53. `frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts`
54. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
55. `frontend-modern/src/features/infrastructure/infrastructurePageModel.ts`
56. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
57. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
58. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
59. `frontend-modern/src/utils/agentResources.ts`
59. `frontend-modern/src/utils/canonicalResourceTypes.ts`
60. `frontend-modern/src/utils/resourceBadgePresentation.ts`
61. `frontend-modern/src/utils/resourceChangePresentation.ts`
62. `frontend-modern/src/utils/resourceCorrelationPresentation.ts`
63. `frontend-modern/src/utils/resourcePlatformData.ts`
64. `frontend-modern/src/utils/resourcePolicyPresentation.ts`
65. `frontend-modern/src/utils/resourceStateAdapters.ts`
66. `frontend-modern/src/utils/resourceTypeCompat.ts`
67. `frontend-modern/src/utils/resourceTypePresentation.ts`
68. `frontend-modern/src/utils/serviceHealthPresentation.ts`
69. `frontend-modern/src/utils/sourceTypePresentation.ts`
70. `frontend-modern/src/utils/workloadTypePresentation.ts`
71. `frontend-modern/src/components/PMG/ServiceHealthBadge.tsx`
72. `frontend-modern/src/utils/resourceIdentity.ts`
73. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts`
74. `frontend-modern/src/hooks/useDashboardTrends.ts`
75. `frontend-modern/src/hooks/useUnifiedResources.ts`
76. `frontend-modern/src/types/resource.ts`
77. `frontend-modern/src/utils/sourcePlatforms.ts`
78. `internal/unifiedresources/kubernetes_metric_ids.go`
20. `internal/unifiedresources/monitored_system_projection.go`
21. `internal/unifiedresources/hostname_equivalence.go`
22. `internal/unifiedresources/capabilities.go`
23. `internal/unifiedresources/changes.go`
24. `internal/unifiedresources/relationships.go`
25. `internal/unifiedresources/privacy.go`
26. `internal/unifiedresources/actions.go`
27. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx`
28. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx`
29. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx`
30. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx`
31. `frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx`
32. `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
33. `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
34. `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx`
35. `frontend-modern/src/components/Infrastructure/resourceBadges.ts`
36. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx`
37. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx`
38. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx`
39. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx`
40. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts`
41. `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts`
42. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts`
43. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerServiceModel.ts`
44. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts`
45. `frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts`
46. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts`
47. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts`
48. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts`
49. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
50. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`
51. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts`
52. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx`
53. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts`
54. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
55. `frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts`
56. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
57. `frontend-modern/src/features/infrastructure/infrastructurePageModel.ts`
58. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
59. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
60. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
61. `frontend-modern/src/utils/agentResources.ts`
62. `frontend-modern/src/utils/canonicalResourceTypes.ts`
63. `frontend-modern/src/utils/resourceBadgePresentation.ts`
64. `frontend-modern/src/utils/resourceChangePresentation.ts`
65. `frontend-modern/src/utils/resourceCorrelationPresentation.ts`
66. `frontend-modern/src/utils/resourcePlatformData.ts`
67. `frontend-modern/src/utils/resourcePolicyPresentation.ts`
68. `frontend-modern/src/utils/resourceStateAdapters.ts`
69. `frontend-modern/src/utils/resourceTypeCompat.ts`
70. `frontend-modern/src/utils/resourceTypePresentation.ts`
71. `frontend-modern/src/utils/serviceHealthPresentation.ts`
72. `frontend-modern/src/utils/sourceTypePresentation.ts`
73. `frontend-modern/src/utils/workloadTypePresentation.ts`
74. `frontend-modern/src/components/PMG/ServiceHealthBadge.tsx`
75. `frontend-modern/src/utils/resourceIdentity.ts`
76. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts`
77. `frontend-modern/src/hooks/useDashboardTrends.ts`
78. `frontend-modern/src/hooks/useUnifiedResources.ts`
79. `frontend-modern/src/types/resource.ts`
80. `frontend-modern/src/utils/sourcePlatforms.ts`
81. `internal/unifiedresources/kubernetes_metric_ids.go`
## Shared Boundaries
@@ -1601,6 +1603,12 @@ source replacement. Add/update handlers must project candidates or preview
records through shared monitored-system projection helpers, including the
delta from replacing one existing source-owned surface, instead of guessing
from handler-local priority rules or transport-specific counters.
That same monitored-system contract now also owns the safe hostname
equivalence rule for top-level host attachment and replacement selectors.
Shared grouping may attach `qnap` to `qnap.local` when one surface reports the
short hostname and another reports the FQDN, but it must not collapse two
distinct fully-qualified hosts that merely share the same short prefix across
different domains.
That same projection contract now also owns structured replacement selectors
and detailed previews. Shared callers may serialize source-native selector
fields such as hostname, host URL, machine or agent identity, and source-owned
@@ -168,6 +168,22 @@ func TestMonitoredSystemUsageReadinessGuardrailsRemainCanonical(t *testing.T) {
}
}
func TestDockerHostIdentityUsesCanonicalHostnameEquivalence(t *testing.T) {
data, err := os.ReadFile("docker_host_identity.go")
if err != nil {
t.Fatalf("failed to read docker_host_identity.go: %v", err)
}
source := string(data)
for _, snippet := range []string{
"unifiedresources.HostnamesEquivalent(host.Hostname(), hostname)",
} {
if !strings.Contains(source, snippet) {
t.Fatalf("docker_host_identity.go must contain %q", snippet)
}
}
}
func TestGuestMemoryFallbackUsesCanonicalLowTrustSelector(t *testing.T) {
data, err := os.ReadFile("guest_memory_sources.go")
if err != nil {
+6 -3
View File
@@ -97,7 +97,8 @@ func findMatchingDockerHost(hosts []*unifiedresources.DockerHostView, report age
if host == nil {
continue
}
if strings.TrimSpace(host.MachineID()) == machineID && strings.TrimSpace(host.Hostname()) == hostname {
if strings.TrimSpace(host.MachineID()) == machineID &&
unifiedresources.HostnamesEquivalent(host.Hostname(), hostname) {
if tokenID == "" || strings.TrimSpace(host.TokenID()) == tokenID {
return host, true
}
@@ -113,7 +114,8 @@ func findMatchingDockerHost(hosts []*unifiedresources.DockerHostView, report age
if host == nil {
continue
}
if strings.TrimSpace(host.Hostname()) == hostname && strings.TrimSpace(host.TokenID()) == tokenID {
if unifiedresources.HostnamesEquivalent(host.Hostname(), hostname) &&
strings.TrimSpace(host.TokenID()) == tokenID {
return host, true
}
}
@@ -135,7 +137,8 @@ func findMatchingDockerHost(hosts []*unifiedresources.DockerHostView, report age
if host == nil {
continue
}
if strings.TrimSpace(host.Hostname()) == hostname && strings.TrimSpace(host.TokenID()) == "" {
if unifiedresources.HostnamesEquivalent(host.Hostname(), hostname) &&
strings.TrimSpace(host.TokenID()) == "" {
return host, true
}
}
@@ -571,6 +571,26 @@ func TestFindMatchingDockerHost(t *testing.T) {
expectedID: "host4",
expectMatch: true,
},
{
name: "match by machineID and hostname with matching token when hostname differs only by fqdn form",
hosts: []models.DockerHost{
{
ID: "host-fqdn",
TokenID: "token-fqdn",
MachineID: "machine-fqdn",
Hostname: "qnap.local",
},
},
report: agentsdocker.Report{
Host: agentsdocker.HostInfo{
MachineID: "machine-fqdn",
Hostname: "qnap",
},
},
tokenRecord: &config.APITokenRecord{ID: "token-fqdn"},
expectedID: "host-fqdn",
expectMatch: true,
},
{
name: "match by machineID and hostname with no token",
hosts: hosts,
@@ -0,0 +1,38 @@
package unifiedresources
import "strings"
func normalizeComparableHostname(hostname string) string {
host := strings.TrimSpace(strings.ToLower(hostname))
host = strings.TrimSuffix(host, ".")
if host == "" {
return ""
}
if NormalizeIP(host) != "" {
return ""
}
return host
}
// HostnamesEquivalent reports whether two hostnames refer to the same host
// when one side may use the short hostname and the other the fully-qualified
// hostname. Distinct fully-qualified hostnames remain distinct even when they
// share the same short name.
func HostnamesEquivalent(a, b string) bool {
left := normalizeComparableHostname(a)
right := normalizeComparableHostname(b)
if left == "" || right == "" {
return false
}
if left == right {
return true
}
leftShort := NormalizeHostname(left)
rightShort := NormalizeHostname(right)
if leftShort == "" || leftShort != rightShort {
return false
}
return left == leftShort || right == rightShort
}
@@ -0,0 +1,63 @@
package unifiedresources
import "testing"
func TestHostnamesEquivalent(t *testing.T) {
tests := []struct {
name string
left string
right string
expected bool
}{
{
name: "exact short hostname",
left: "qnap",
right: "qnap",
expected: true,
},
{
name: "short and fqdn match",
left: "qnap",
right: "qnap.local",
expected: true,
},
{
name: "fqdn and short match",
left: "qnap.local",
right: "qnap",
expected: true,
},
{
name: "exact fqdn match",
left: "qnap.local",
right: "QNAP.local.",
expected: true,
},
{
name: "different fqdn suffixes stay distinct",
left: "prox97.a.local",
right: "prox97.b.local",
expected: false,
},
{
name: "different hosts do not match",
left: "qnap",
right: "other",
expected: false,
},
{
name: "ip address does not count as hostname",
left: "10.0.0.5",
right: "qnap.local",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := HostnamesEquivalent(tt.left, tt.right); got != tt.expected {
t.Fatalf("HostnamesEquivalent(%q, %q) = %v, want %v", tt.left, tt.right, got, tt.expected)
}
})
}
}
@@ -595,13 +595,17 @@ func monitoredSystemSelectorMatchesHost(
}
for _, candidate := range candidates {
if normalized := topLevelSystemNormalizeHost(candidate); normalized != "" {
if _, ok := selectorHosts[normalized]; ok {
return true
for selectorHost := range selectorHosts {
if HostnamesEquivalent(selectorHost, normalized) {
return true
}
}
}
if normalized := topLevelSystemNormalizeHost(extractHostname(candidate)); normalized != "" {
if _, ok := selectorHosts[normalized]; ok {
return true
for selectorHost := range selectorHosts {
if HostnamesEquivalent(selectorHost, normalized) {
return true
}
}
}
}
@@ -343,6 +343,50 @@ func TestProjectMonitoredSystemCandidateReplacementPreservesOverlappingSources(t
}
}
func TestProjectMonitoredSystemCandidateReplacementMatchesShortAndFQDNHostnames(t *testing.T) {
registry := NewRegistry(nil)
registry.IngestRecords(SourceDocker, []IngestRecord{
{
SourceID: "docker-qnap",
Resource: Resource{
ID: "docker-qnap",
Type: ResourceTypeAgent,
Name: "qnap.local",
Status: StatusOnline,
Docker: &DockerData{
HostSourceID: "docker-qnap",
Hostname: "qnap.local",
},
},
Identity: ResourceIdentity{
Hostnames: []string{"qnap.local"},
},
},
})
projection := ProjectMonitoredSystemCandidateReplacement(registry, MonitoredSystemReplacement{
Source: SourceDocker,
Selector: MonitoredSystemReplacementSelector{
Hostname: "qnap",
},
}, MonitoredSystemCandidate{
Source: SourceDocker,
Type: ResourceTypeAgent,
Name: "qnap",
Hostname: "qnap",
})
if projection.CurrentCount != 1 {
t.Fatalf("CurrentCount = %d, want 1", projection.CurrentCount)
}
if projection.ProjectedCount != 1 {
t.Fatalf("ProjectedCount = %d, want 1", projection.ProjectedCount)
}
if projection.AdditionalCount != 0 {
t.Fatalf("AdditionalCount = %d, want 0", projection.AdditionalCount)
}
}
func TestProjectMonitoredSystemCandidateReplacementRemovesStandaloneSource(t *testing.T) {
registry := NewRegistry(nil)
registry.IngestRecords(SourcePBS, []IngestRecord{
@@ -754,6 +754,64 @@ func TestMonitoredSystemsExplainsStaleGroupedSourceWhileLastSeenStaysFresh(t *te
}
}
func TestMonitoredSystemsMergeShortAndFQDNHostnamesForOneHost(t *testing.T) {
rr := NewRegistry(nil)
now := time.Date(2026, 4, 21, 21, 30, 0, 0, time.UTC)
rr.IngestRecords(SourceAgent, []IngestRecord{
{
SourceID: "agent-qnap",
Resource: Resource{
ID: "agent-qnap",
Type: ResourceTypeAgent,
Name: "qnap.local",
Status: StatusOnline,
LastSeen: now.Add(-2 * time.Minute),
Agent: &AgentData{
AgentID: "agent-qnap",
Hostname: "qnap.local",
},
},
Identity: ResourceIdentity{
Hostnames: []string{"qnap.local"},
},
},
})
rr.IngestRecords(SourceDocker, []IngestRecord{
{
SourceID: "docker-qnap",
Resource: Resource{
ID: "docker-qnap",
Type: ResourceTypeAgent,
Name: "qnap",
Status: StatusOnline,
LastSeen: now,
Docker: &DockerData{
HostSourceID: "docker-qnap",
Hostname: "qnap",
},
},
Identity: ResourceIdentity{
Hostnames: []string{"qnap"},
},
},
})
systems := MonitoredSystems(rr)
if len(systems) != 1 {
t.Fatalf("MonitoredSystems() returned %d systems, want 1", len(systems))
}
if systems[0].Source != "multiple" {
t.Fatalf("MonitoredSystems()[0].Source = %q, want %q", systems[0].Source, "multiple")
}
if systems[0].Explanation.Summary == "" {
t.Fatal("expected grouped monitored-system explanation summary")
}
if !systems[0].LastSeen.Equal(now) {
t.Fatalf("MonitoredSystems()[0].LastSeen = %s, want %s", systems[0].LastSeen, now)
}
}
func TestMonitoredSystemsReportOnlineStatusWithoutUnknownBaselineBias(t *testing.T) {
rr := NewRegistry(nil)
+95 -6
View File
@@ -462,6 +462,10 @@ func uniqueBetterTopLevelSystemTarget(
groups map[int]topLevelSystemResolvedGroup,
) (topLevelSystemFallbackTarget, bool) {
targets := make(map[int]topLevelSystemGroupingEvidence)
groupRoots := make(map[int]struct{}, len(groups))
for root := range groups {
groupRoots[root] = struct{}{}
}
for _, host := range topLevelSystemSortedSet(group.exactHosts) {
for _, targetRoot := range topLevelSystemSortedRoots(hostOwners[host]) {
@@ -482,6 +486,26 @@ func uniqueBetterTopLevelSystemTarget(
}
}
}
for _, targetRoot := range topLevelSystemSortedRoots(groupRoots) {
if targetRoot == groupRoot {
continue
}
if groups[targetRoot].priority >= group.priority {
continue
}
if _, ok := targets[targetRoot]; ok {
continue
}
if value, ok := topLevelSystemShortFormHostMatchValue(group.exactHosts, groups[targetRoot].exactHosts); ok {
targets[targetRoot] = topLevelSystemAttachmentEvidence(
group,
groups[targetRoot],
"hostname-form-attachment",
"short-hostname",
value,
)
}
}
for _, ip := range topLevelSystemSortedSet(group.exactIPs) {
for _, targetRoot := range topLevelSystemSortedRoots(ipOwners[ip]) {
if targetRoot == groupRoot {
@@ -522,6 +546,10 @@ func candidateExactTargetGroups(
priority int,
) map[string]struct{} {
targets := make(map[string]struct{})
groupIDs := make(map[string]struct{}, len(groups))
for groupID := range groups {
groupIDs[groupID] = struct{}{}
}
for host := range monitoredSystemCandidateExactHosts(candidate) {
for groupID := range hostOwners[host] {
if groups[groupID].priority >= priority {
@@ -530,6 +558,18 @@ func candidateExactTargetGroups(
targets[groupID] = struct{}{}
}
}
candidateHosts := monitoredSystemCandidateExactHosts(candidate)
for _, groupID := range topLevelSystemSortedSet(groupIDs) {
if groups[groupID].priority >= priority {
continue
}
if _, ok := targets[groupID]; ok {
continue
}
if _, ok := topLevelSystemShortFormHostMatchValue(candidateHosts, groups[groupID].exactHosts); ok {
targets[groupID] = struct{}{}
}
}
for ip := range monitoredSystemCandidateExactIPs(candidate) {
for groupID := range ipOwners[ip] {
if groups[groupID].priority >= priority {
@@ -737,6 +777,12 @@ func topLevelSystemReasonFromEvidence(
Signal: evidence.signal,
Summary: topLevelSystemAttachmentSummary(evidence.fromType, evidence.toType, "a unique exact hostname match", evidence.value),
}
case "hostname-form-attachment":
return MonitoredSystemGroupingReason{
Kind: evidence.kind,
Signal: evidence.signal,
Summary: topLevelSystemAttachmentSummary(evidence.fromType, evidence.toType, "a unique short-vs-FQDN hostname match", evidence.value),
}
case "exact-ip-attachment":
return MonitoredSystemGroupingReason{
Kind: evidence.kind,
@@ -783,6 +829,8 @@ func topLevelSystemReasonBasis(reason MonitoredSystemGroupingReason) string {
return topLevelSystemIdentityMatchBasis(reason.Signal)
case "exact-host-attachment":
return "a unique exact hostname attachment"
case "hostname-form-attachment":
return "a unique short-vs-FQDN hostname attachment"
case "exact-ip-attachment":
return "a unique exact IP attachment"
default:
@@ -997,14 +1045,55 @@ func addTopLevelSystemOwner(index map[string]map[string]struct{}, key, owner str
}
func topLevelSystemNormalizeHost(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
if trimmed == "" {
return ""
return normalizeComparableHostname(value)
}
func topLevelSystemShortFormHostMatchValue(
leftHosts map[string]struct{},
rightHosts map[string]struct{},
) (string, bool) {
if len(leftHosts) == 0 || len(rightHosts) == 0 {
return "", false
}
if NormalizeIP(trimmed) != "" {
return ""
rightOrdered := topLevelSystemSortedSet(rightHosts)
for _, left := range topLevelSystemSortedSet(leftHosts) {
for _, right := range rightOrdered {
if left == right {
continue
}
if HostnamesEquivalent(left, right) {
return topLevelSystemHostMatchValue(left, right), true
}
}
}
return trimmed
return "", false
}
func topLevelSystemHostMatchValue(left, right string) string {
left = topLevelSystemNormalizeHost(left)
right = topLevelSystemNormalizeHost(right)
if left == "" {
return right
}
if right == "" {
return left
}
if left == right {
return left
}
leftShort := NormalizeHostname(left)
rightShort := NormalizeHostname(right)
if left == leftShort && right != rightShort {
return right
}
if right == rightShort && left != leftShort {
return left
}
if len(left) >= len(right) {
return left
}
return right
}
func topLevelSystemProxmoxHostURL(resource Resource) string {
@@ -19,6 +19,15 @@ func TestResolveTopLevelSystemsTopLevelSourceMatrix(t *testing.T) {
wantCount: 1,
same: [][2]string{{"agent-host", "docker-host"}},
},
{
name: "agent and docker host share one top-level system when hostnames differ only by fqdn form",
resources: []Resource{
topLevelTestAgentWithoutMachineID("agent-host", "qnap.local", "agent-1"),
topLevelTestDockerHost("docker-host", "qnap", "docker-runtime-1", ""),
},
wantCount: 1,
same: [][2]string{{"agent-host", "docker-host"}},
},
{
name: "agent and proxmox node share one top-level system",
resources: []Resource{