diff --git a/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-2026-04-21.md b/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-2026-04-21.md index 2aabab928..8bfa65ad6 100644 --- a/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-2026-04-21.md +++ b/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-2026-04-21.md @@ -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'` diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 30e281e1e..11b5447a7 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 4aa96989e..0722d2f2f 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index bf9ca472e..9c5fc0882 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -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 { diff --git a/internal/monitoring/docker_host_identity.go b/internal/monitoring/docker_host_identity.go index 14f5d2b0a..7a922d3a1 100644 --- a/internal/monitoring/docker_host_identity.go +++ b/internal/monitoring/docker_host_identity.go @@ -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 } } diff --git a/internal/monitoring/docker_host_identity_test.go b/internal/monitoring/docker_host_identity_test.go index 07e46c6ba..c808a3b73 100644 --- a/internal/monitoring/docker_host_identity_test.go +++ b/internal/monitoring/docker_host_identity_test.go @@ -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, diff --git a/internal/unifiedresources/hostname_equivalence.go b/internal/unifiedresources/hostname_equivalence.go new file mode 100644 index 000000000..3f3f9008a --- /dev/null +++ b/internal/unifiedresources/hostname_equivalence.go @@ -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 +} diff --git a/internal/unifiedresources/hostname_equivalence_test.go b/internal/unifiedresources/hostname_equivalence_test.go new file mode 100644 index 000000000..e51c5315f --- /dev/null +++ b/internal/unifiedresources/hostname_equivalence_test.go @@ -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) + } + }) + } +} diff --git a/internal/unifiedresources/monitored_system_projection.go b/internal/unifiedresources/monitored_system_projection.go index 554a0ebd7..554e90b64 100644 --- a/internal/unifiedresources/monitored_system_projection.go +++ b/internal/unifiedresources/monitored_system_projection.go @@ -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 + } } } } diff --git a/internal/unifiedresources/monitored_systems_test.go b/internal/unifiedresources/monitored_systems_test.go index 9fd5e3179..043e74f03 100644 --- a/internal/unifiedresources/monitored_systems_test.go +++ b/internal/unifiedresources/monitored_systems_test.go @@ -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{ diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index 24230ab97..57b952729 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -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) diff --git a/internal/unifiedresources/top_level_systems.go b/internal/unifiedresources/top_level_systems.go index 212d2fe9a..2422b6885 100644 --- a/internal/unifiedresources/top_level_systems.go +++ b/internal/unifiedresources/top_level_systems.go @@ -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 { diff --git a/internal/unifiedresources/top_level_systems_test.go b/internal/unifiedresources/top_level_systems_test.go index dd45d532b..4f4b61ef6 100644 --- a/internal/unifiedresources/top_level_systems_test.go +++ b/internal/unifiedresources/top_level_systems_test.go @@ -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{