From 2baa05c4e4845f13f0429ba7906db220f0858a88 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 8 May 2026 12:51:39 +0100 Subject: [PATCH] Fix platform identity source canonicalization --- .../internal/subsystems/unified-resources.md | 10 + .../resourceBadgePresentation.test.ts | 70 ++++++ .../__tests__/resourceStateAdapters.test.ts | 155 +++++++++++++ .../src/utils/resourceBadgePresentation.ts | 85 ++++++- .../src/utils/resourceStateAdapters.ts | 214 +++++++++++++++--- 5 files changed, 492 insertions(+), 42 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 3eb3e059d..933795e43 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -194,6 +194,11 @@ cross-source deduplication. no-parity posture, but those labels must derive from canonical incident or storage-risk summaries already present on the resource rather than local platform heuristics or generic status-only wording. + Frontend canonicalization must treat top-level source facets as source + evidence when `platformData.sources` is absent, but it must not invent + provider facets from generic agent telemetry: flat agent disk payloads stay + under the agent source and do not become Proxmox facets without explicit + Proxmox source, node, or workload-shape evidence. Resource detail mappers now reuse the shared `frontend-modern/src/utils/textPresentation.ts` title-case helper for sensor @@ -272,6 +277,11 @@ AI-only summary payloads, or page-local heuristics. must merge into the existing canonical resource snapshot instead of downgrading richer REST-only infrastructure details such as disk I/O, source metadata, or platform summary fields after first hydrate. For default + Source lists and their source-specific facets are the exception: a current + snapshot with canonical source evidence replaces stale source lists and + removes provider facets that no longer have matching source evidence, so + rows do not keep displaying a previous platform identity after websocket + refreshes. For default `initialHydration: 'immediate'` consumers, that same path must not paint the thinner websocket transport before the first canonical REST snapshot exists; only explicit `prefer-ws` consumers may render directly from the realtime diff --git a/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts index ed2c102d7..ecb457ba2 100644 --- a/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts @@ -92,6 +92,54 @@ describe('resourceBadgePresentation', () => { ).toEqual(['Docker / Podman']); }); + it('shows Unraid as the system identity for agent resources that also report Docker', () => { + expect( + getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'agent', + platformType: 'docker', + sourceType: 'hybrid', + platformData: { + sources: ['docker', 'agent'], + docker: { + runtime: 'docker', + }, + agent: { + platform: 'linux', + hostProfile: 'unraid', + osName: 'Unraid', + osVersion: '7.2.2', + }, + }, + }), + ).map((badge) => badge.label), + ).toEqual(['Unraid']); + }); + + it('does not let stale platform sources override explicit agent host profiles', () => { + expect( + getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'agent', + platformType: 'proxmox-pve', + sourceType: 'hybrid', + platformData: { + sources: ['proxmox', 'docker', 'agent'], + docker: { + runtime: 'docker', + }, + agent: { + platform: 'linux', + hostProfile: 'unraid', + osName: 'Unraid', + osVersion: '7.2.2', + }, + }, + }), + ).map((badge) => badge.label), + ).toEqual(['Unraid']); + }); + it('uses governed host identity tokens for legacy agent profile reports', () => { expect( getInfrastructureSystemIdentityBadges( @@ -151,6 +199,28 @@ describe('resourceBadgePresentation', () => { expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('PVE'); }); + it('keeps top-level platform facets ahead of collection method identity', () => { + const resource = makeResource({ + platformType: 'agent', + sourceType: 'agent', + proxmox: { + nodeName: 'pi', + instance: 'pi', + }, + agent: { + hostname: 'pi', + platform: 'debian', + osName: 'Debian GNU/Linux', + osVersion: '12', + }, + } as Partial); + + expect(getInfrastructureSystemIdentityBadges(resource).map((badge) => badge.label)).toEqual([ + 'PVE', + ]); + expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('PVE'); + }); + it('uses probe protocol identity for agentless network endpoints', () => { const resource = makeResource({ type: 'network-endpoint', diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index d2e958af0..3d1d5c507 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -304,6 +304,161 @@ describe('resourceStateAdapters nodeFromResource', () => { expect((resource.platformData as Record).sources).toEqual(['availability']); }); + it('canonicalizes top-level Proxmox and agent facets as one hybrid PVE resource', () => { + const [resource] = mergeCanonicalResourceSnapshot( + [ + { + id: 'agent-pi', + type: 'agent', + name: 'pi', + displayName: 'pi', + platformId: 'pi', + platformType: 'agent', + sourceType: 'agent', + status: 'online', + lastSeen: Date.now(), + proxmox: { + nodeName: 'pi', + instance: 'pi', + }, + agent: { + hostname: 'pi', + platform: 'debian', + osName: 'Debian GNU/Linux', + osVersion: '12', + }, + } as Resource, + ], + [], + ); + + expect(resource.platformType).toBe('proxmox-pve'); + expect(resource.sourceType).toBe('hybrid'); + expect(resource.proxmox).toMatchObject({ nodeName: 'pi', instance: 'pi' }); + expect(resource.agent).toMatchObject({ hostname: 'pi', osName: 'Debian GNU/Linux' }); + expect((resource.platformData as Record).sources).toEqual([ + 'proxmox', + 'agent', + ]); + expect((resource.platformData as Record).proxmox).toMatchObject({ + nodeName: 'pi', + }); + expect((resource.platformData as Record).agent).toMatchObject({ + hostname: 'pi', + }); + }); + + it('replaces stale platform source facets with the current snapshot sources', () => { + const existing = { + id: 'agent-tower', + type: 'agent', + name: 'Tower', + displayName: 'Tower', + platformId: 'tower', + platformType: 'proxmox-pve', + sourceType: 'hybrid', + status: 'degraded', + lastSeen: Date.now(), + proxmox: { + nodeName: 'Tower', + }, + agent: { + hostname: 'Tower', + hostProfile: 'unraid', + osName: 'Unraid', + }, + platformData: { + sources: ['proxmox', 'docker', 'agent'], + proxmox: { + nodeName: 'Tower', + }, + docker: { + runtime: 'docker', + }, + agent: { + hostname: 'Tower', + hostProfile: 'unraid', + osName: 'Unraid', + }, + }, + } as Resource; + + const [resource] = mergeCanonicalResourceSnapshot( + [ + { + id: 'agent-tower', + type: 'agent', + name: 'Tower', + displayName: 'Tower', + platformId: 'tower', + platformType: 'agent', + sourceType: 'hybrid', + status: 'degraded', + lastSeen: Date.now(), + agent: { + hostname: 'Tower', + hostProfile: 'unraid', + osName: 'Unraid', + }, + docker: { + runtime: 'docker', + }, + } as Resource, + ], + [existing], + ); + + expect(resource.platformType).toBe('docker'); + expect(resource.sourceType).toBe('hybrid'); + expect(resource.proxmox).toBeUndefined(); + expect(resource.agent).toMatchObject({ hostProfile: 'unraid', osName: 'Unraid' }); + expect((resource.platformData as Record).sources).toEqual(['docker', 'agent']); + expect((resource.platformData as Record).proxmox).toBeUndefined(); + }); + + it('does not synthesize a Proxmox facet from flat agent disk telemetry', () => { + const [resource] = mergeCanonicalResourceSnapshot( + [ + { + id: 'agent-tower', + type: 'agent', + name: 'Tower', + displayName: 'Tower', + platformId: 'tower', + platformType: 'agent', + sourceType: 'hybrid', + status: 'degraded', + lastSeen: Date.now(), + agent: { + hostname: 'Tower', + hostProfile: 'unraid', + osName: 'Unraid', + }, + docker: { + runtime: 'docker', + }, + platformData: { + osName: 'Unraid', + osVersion: '7.2.2', + platform: 'linux', + disks: [ + { + device: 'rootfs', + mountpoint: '/', + filesystem: 'rootfs', + }, + ], + }, + } as Resource, + ], + [], + ); + + expect(resource.platformType).toBe('docker'); + expect((resource.platformData as Record).sources).toEqual(['docker', 'agent']); + expect((resource.platformData as Record).proxmox).toBeUndefined(); + }); + it('preserves richer existing resource details when realtime updates are thinner', () => { const existing: Resource = { id: 'node-1', diff --git a/frontend-modern/src/utils/resourceBadgePresentation.ts b/frontend-modern/src/utils/resourceBadgePresentation.ts index 1464f2ada..df5d4fa1d 100644 --- a/frontend-modern/src/utils/resourceBadgePresentation.ts +++ b/frontend-modern/src/utils/resourceBadgePresentation.ts @@ -64,6 +64,41 @@ const hostOsLabelPatterns: Array<{ pattern: RegExp; label: string }> = [ const trimString = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); +const asRecord = (value: unknown): Record | undefined => + value && typeof value === 'object' ? (value as Record) : undefined; + +const getResourceRecord = (resource: Resource): Record => + resource as unknown as Record; + +const getFacetRecord = ( + resource: Resource, + platformData: Record | undefined, + key: string, +): Record | undefined => + asRecord(getResourceRecord(resource)[key]) || asRecord(platformData?.[key]); + +const deriveSourceKeysFromFacets = ( + resource: Resource, + platformData: Record | undefined, +): string[] => { + const sources: string[] = []; + for (const [key, source] of [ + ['proxmox', 'proxmox'], + ['pbs', 'pbs'], + ['pmg', 'pmg'], + ['vmware', 'vmware'], + ['kubernetes', 'kubernetes'], + ['docker', 'docker'], + ['availability', 'availability'], + ['agent', 'agent'], + ] as const) { + if (getFacetRecord(resource, platformData, key)) { + sources.push(source); + } + } + return sources; +}; + const titleFromParts = (...parts: Array): string | undefined => { const title = parts .map((part) => (part || '').trim()) @@ -220,6 +255,23 @@ const getKnownHostIdentitySource = (...values: string[]): KnownSourcePlatform | return null; }; +const getAgentRecord = (resource: Resource): Record | undefined => + getPlatformAgentRecord(resource) ?? asRecord(getResourceRecord(resource).agent); + +const hasExplicitAgentHostProfile = (resource: Resource): boolean => { + const agent = getAgentRecord(resource); + const hostProfile = trimString(agent?.hostProfile); + return Boolean(hostProfile && getAgentHostProfileManifestEntry(hostProfile)); +}; + +const hasPrimarySystemFacet = ( + resource: Resource, + platformData: Record | undefined, +): boolean => + ['proxmox', 'pbs', 'pmg', 'vmware', 'kubernetes'].some((key) => + Boolean(getFacetRecord(resource, platformData, key)), + ); + const getHostOsLabel = (...values: string[]): string | null => { for (const value of values) { if (!value) continue; @@ -230,7 +282,7 @@ const getHostOsLabel = (...values: string[]): string | null => { }; const getAgentSystemIdentityBadge = (resource: Resource): ResourceBadge | null => { - const agent = getPlatformAgentRecord(resource); + const agent = getAgentRecord(resource); if (!agent) return null; const hostProfile = trimString(agent.hostProfile); @@ -277,15 +329,16 @@ const getAgentSystemIdentityBadge = (resource: Resource): ResourceBadge | null = const getAvailabilitySystemIdentityBadge = ( resource: Resource, platformData: Record | undefined, + rawSources: string[], ): ResourceBadge | null => { - const availability = platformData?.availability as + const availability = (asRecord(platformData?.availability) || + asRecord(getResourceRecord(resource).availability)) as | { address?: string; protocol?: string; port?: number } | undefined; - const sources = (platformData?.sources as string[] | undefined) ?? []; const isAvailabilityEndpoint = resource.type === 'network-endpoint' || Boolean(availability) || - sources.some((source) => source.trim().toLowerCase() === 'availability'); + rawSources.some((source) => source.trim().toLowerCase() === 'availability'); if (!isAvailabilityEndpoint) return null; @@ -316,18 +369,34 @@ export function getInfrastructureSystemIdentityBadges(resource: Resource): Resou const platformData = getPlatformDataRecord(resource) as | (Record & { sources?: string[] }) | undefined; - const sources = normalizeUnifiedSourceKeys(platformData?.sources); - const availabilityIdentityBadge = getAvailabilitySystemIdentityBadge(resource, platformData); + const rawSources = [ + ...(Array.isArray(platformData?.sources) ? platformData.sources : []), + ...deriveSourceKeysFromFacets(resource, platformData), + ]; + const sources = normalizeUnifiedSourceKeys(rawSources); + const availabilityIdentityBadge = getAvailabilitySystemIdentityBadge( + resource, + platformData, + rawSources, + ); if (availabilityIdentityBadge) { return [availabilityIdentityBadge]; } + const agentIdentityBadge = getAgentSystemIdentityBadge(resource); + if ( + agentIdentityBadge && + hasExplicitAgentHostProfile(resource) && + !hasPrimarySystemFacet(resource, platformData) + ) { + return [agentIdentityBadge]; + } + const systemSource = firstSystemSource(sources, resource.platformType); if (systemSource) { return buildUnifiedSourceBadges([systemSource]); } - const agentIdentityBadge = getAgentSystemIdentityBadge(resource); if (agentIdentityBadge) { return [agentIdentityBadge]; } @@ -345,7 +414,7 @@ export function getInfrastructureSystemIdentityBadges(resource: Resource): Resou return [platformBadge]; } - return getInfrastructurePlatformBadges(platformData?.sources); + return getInfrastructurePlatformBadges(rawSources); } export function getInfrastructureSystemIdentitySortLabel(resource: Resource): string { diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index 45deb0c91..d7da58780 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -31,6 +31,7 @@ import { getPreferredResourceHostname, } from '@/utils/resourceIdentity'; import { + normalizeSourcePlatformKey, resolvePlatformTypeFromSources, resolveSourceTypeFromSources, } from '@/utils/sourcePlatforms'; @@ -77,6 +78,28 @@ const mergeStringArrays = (incoming?: string[], existing?: string[]): string[] | return merged.length > 0 ? Array.from(new Set(merged)) : undefined; }; +const readStringArray = (value: unknown): string[] | undefined => { + if (!Array.isArray(value)) return undefined; + const normalized = value + .map((entry) => asString(entry)) + .filter((entry): entry is string => Boolean(entry)); + return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined; +}; + +const normalizeSourceToken = (value: string): string => + normalizeSourcePlatformKey(value) || value.trim().toLowerCase(); + +const sourceListHas = (sources: string[] | undefined, ...candidates: string[]): boolean => { + if (!sources || sources.length === 0) return false; + const sourceSet = new Set(sources.map((source) => normalizeSourceToken(source))); + return candidates.some((candidate) => sourceSet.has(normalizeSourceToken(candidate))); +}; + +const shouldKeepSourceFacet = ( + authoritativeSources: string[] | undefined, + ...sourceCandidates: string[] +): boolean => !authoritativeSources || sourceListHas(authoritativeSources, ...sourceCandidates); + const mergeRecord = (incoming?: T, existing?: T): T | undefined => { if (!incoming) return existing; if (!existing) return incoming; @@ -92,22 +115,32 @@ const mergePlatformData = ( if (!incoming) return existingValue; if (!existing) return incomingValue; + const incomingSources = readStringArray(incoming.sources); + const existingSources = readStringArray(existing.sources); + const sources = incomingSources ?? existingSources; const merged: JsonRecord = { ...existing, ...incoming }; - for (const key of [ - 'agent', - 'docker', - 'proxmox', - 'pbs', - 'pmg', - 'kubernetes', - 'vmware', - 'storage', - 'availability', - 'physicalDisk', - 'ceph', - 'metrics', - 'discoveryTarget', - ]) { + + for (const [key, ...sourceCandidates] of [ + ['agent', 'agent'], + ['docker', 'docker'], + ['proxmox', 'proxmox-pve'], + ['pbs', 'proxmox-pbs'], + ['pmg', 'proxmox-pmg'], + ['kubernetes', 'kubernetes'], + ['vmware', 'vmware-vsphere'], + ['availability', 'availability'], + ] as const) { + if (!shouldKeepSourceFacet(incomingSources, ...sourceCandidates)) { + delete merged[key]; + continue; + } + const nested = mergeRecord(asRecord(incoming[key]), asRecord(existing[key])); + if (nested) { + merged[key] = nested; + } + } + + for (const key of ['storage', 'physicalDisk', 'ceph', 'metrics', 'discoveryTarget']) { const nested = mergeRecord(asRecord(incoming[key]), asRecord(existing[key])); if (nested) { merged[key] = nested; @@ -122,10 +155,6 @@ const mergePlatformData = ( merged.sourceStatus = sourceStatus; } - const sources = mergeStringArrays( - Array.isArray(incoming.sources) ? (incoming.sources as string[]) : undefined, - Array.isArray(existing.sources) ? (existing.sources as string[]) : undefined, - ); if (sources) { merged.sources = sources; } @@ -133,7 +162,35 @@ const mergePlatformData = ( return merged; }; -const deriveLegacySourceList = (resource: Resource): string[] | undefined => { +const getResourceRecord = (resource: Resource): JsonRecord => resource as unknown as JsonRecord; + +const getFacetRecord = ( + resource: Resource, + platformData: Resource['platformData'] | undefined, + key: string, +): JsonRecord | undefined => { + const resourceRecord = getResourceRecord(resource); + const platformRecord = asRecord(platformData); + return asRecord(resourceRecord[key]) || asRecord(platformRecord?.[key]); +}; + +const deriveLegacySourceList = ( + resource: Resource, + platformData: Resource['platformData'] | undefined = resource.platformData, +): string[] | undefined => { + const sources: string[] = []; + if (getFacetRecord(resource, platformData, 'proxmox')) sources.push('proxmox'); + if (getFacetRecord(resource, platformData, 'pbs')) sources.push('pbs'); + if (getFacetRecord(resource, platformData, 'pmg')) sources.push('pmg'); + if (getFacetRecord(resource, platformData, 'vmware')) sources.push('vmware'); + if (getFacetRecord(resource, platformData, 'kubernetes')) sources.push('kubernetes'); + if (getFacetRecord(resource, platformData, 'docker')) sources.push('docker'); + if (getFacetRecord(resource, platformData, 'availability')) sources.push('availability'); + if (getFacetRecord(resource, platformData, 'agent')) sources.push('agent'); + if (sources.length > 0) { + return Array.from(new Set(sources)); + } + if ( resource.type === 'network-endpoint' || resource.platformType === 'availability' || @@ -163,22 +220,81 @@ const deriveLegacySourceList = (resource: Resource): string[] | undefined => { } }; +const hasLegacyProxmoxShape = ( + resource: Resource, + platformData: JsonRecord, + sources?: string[], +): boolean => + resource.platformType === 'proxmox-pve' || + sourceListHas(sources, 'proxmox-pve') || + [ + 'instance', + 'node', + 'clusterName', + 'vmid', + 'cpus', + 'template', + 'swapUsed', + 'swapTotal', + 'balloon', + ].some((key) => platformData[key] !== undefined); + const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformData'] => { const platformData = asRecord(resource.platformData); if (!platformData) { - return resource.platformData; + const normalizedSources = deriveLegacySourceList(resource); + if (!normalizedSources || normalizedSources.length === 0) { + return resource.platformData; + } + const normalized: JsonRecord = { sources: normalizedSources }; + const resourceRecord = getResourceRecord(resource); + for (const [key, value] of [ + ['agent', resourceRecord.agent], + ['docker', resourceRecord.docker], + ['proxmox', resourceRecord.proxmox], + ['pbs', resourceRecord.pbs], + ['pmg', resourceRecord.pmg], + ['kubernetes', resourceRecord.kubernetes], + ['vmware', resourceRecord.vmware], + ['storage', resourceRecord.storage], + ['availability', resourceRecord.availability], + ['physicalDisk', resourceRecord.physicalDisk], + ] as const) { + if (value !== undefined) { + normalized[key] = value; + } + } + return normalized; } const normalized: JsonRecord = { ...platformData }; + const resourceRecord = getResourceRecord(resource); + for (const key of [ + 'agent', + 'docker', + 'proxmox', + 'pbs', + 'pmg', + 'kubernetes', + 'vmware', + 'storage', + 'availability', + 'physicalDisk', + ] as const) { + if (!asRecord(normalized[key]) && asRecord(resourceRecord[key])) { + normalized[key] = resourceRecord[key]; + } + } + const normalizedSources = Array.isArray(platformData.sources) && platformData.sources.length > 0 ? (platformData.sources as string[]) - : deriveLegacySourceList(resource); + : deriveLegacySourceList(resource, platformData); if (normalizedSources && normalizedSources.length > 0) { normalized.sources = normalizedSources; } - if (!asRecord(platformData.agent)) { + if (!asRecord(normalized.agent)) { const agentPayload: JsonRecord = {}; for (const [legacyKey, nextKey] of [ ['agentId', 'agentId'], @@ -204,7 +320,7 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD } } - if (!asRecord(platformData.docker)) { + if (!asRecord(normalized.docker)) { const dockerPayload: JsonRecord = {}; for (const [legacyKey, nextKey] of [ ['agentId', 'agentId'], @@ -237,7 +353,10 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD } } - if (!asRecord(platformData.proxmox)) { + if ( + !asRecord(normalized.proxmox) && + hasLegacyProxmoxShape(resource, platformData, normalizedSources) + ) { const proxmoxPayload: JsonRecord = {}; for (const [legacyKey, nextKey] of [ ['instance', 'instance'], @@ -260,7 +379,7 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD } } - if (!asRecord(platformData.pbs)) { + if (!asRecord(normalized.pbs)) { const pbsPayload: JsonRecord = {}; if (platformData.host !== undefined) pbsPayload.hostname = platformData.host; if (platformData.version !== undefined) pbsPayload.version = platformData.version; @@ -275,7 +394,7 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD } } - if (!asRecord(platformData.pmg)) { + if (!asRecord(normalized.pmg)) { const pmgPayload: JsonRecord = {}; if (platformData.host !== undefined) pmgPayload.hostname = platformData.host; if (platformData.version !== undefined) pmgPayload.version = platformData.version; @@ -299,7 +418,7 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD } } - if (!asRecord(platformData.kubernetes)) { + if (!asRecord(normalized.kubernetes)) { const kubernetesPayload: JsonRecord = {}; for (const [legacyKey, nextKey] of [ ['agentId', 'agentId'], @@ -329,7 +448,7 @@ const getCanonicalSourceList = ( const platformRecord = asRecord(platformData); return Array.isArray(platformRecord?.sources) && platformRecord.sources.length > 0 ? (platformRecord.sources as string[]) - : deriveLegacySourceList({ ...resource, platformData }); + : deriveLegacySourceList(resource, platformData); }; const hasAvailabilityFacet = ( @@ -393,11 +512,22 @@ const mergeCanonicalIdentity = ( }; }; +const mergeCanonicalSourceFacet = ( + incomingFacet: T | undefined, + existingFacet: T | undefined, + incomingSources: string[] | undefined, + ...sourceCandidates: string[] +): T | undefined => + shouldKeepSourceFacet(incomingSources, ...sourceCandidates) + ? mergeRecord(incomingFacet, existingFacet) + : incomingFacet; + export const mergeCanonicalResource = (incoming: Resource, existing?: Resource): Resource => { if (!existing) { return incoming; } const existingCanonical = canonicalizeRealtimeResource(existing); + const incomingSources = getCanonicalSourceList(incoming, incoming.platformData); return { ...existingCanonical, ...incoming, @@ -413,26 +543,42 @@ export const mergeCanonicalResource = (incoming: Resource, existing?: Resource): recentChanges: incoming.recentChanges ?? existingCanonical.recentChanges, facetCounts: incoming.facetCounts ?? existingCanonical.facetCounts, diskIO: incoming.diskIO ?? existingCanonical.diskIO, - agent: mergeRecord( + agent: mergeCanonicalSourceFacet( incoming.agent as JsonRecord | undefined, existingCanonical.agent as JsonRecord | undefined, + incomingSources, + 'agent', ) as Resource['agent'], - proxmox: mergeRecord( + proxmox: mergeCanonicalSourceFacet( incoming.proxmox as JsonRecord | undefined, existingCanonical.proxmox as JsonRecord | undefined, + incomingSources, + 'proxmox-pve', ) as Resource['proxmox'], - pbs: mergeRecord( + pbs: mergeCanonicalSourceFacet( incoming.pbs as JsonRecord | undefined, existingCanonical.pbs as JsonRecord | undefined, + incomingSources, + 'proxmox-pbs', ) as Resource['pbs'], - kubernetes: mergeRecord( + kubernetes: mergeCanonicalSourceFacet( incoming.kubernetes as JsonRecord | undefined, existingCanonical.kubernetes as JsonRecord | undefined, + incomingSources, + 'kubernetes', ) as Resource['kubernetes'], - vmware: mergeRecord( + vmware: mergeCanonicalSourceFacet( incoming.vmware as JsonRecord | undefined, existingCanonical.vmware as JsonRecord | undefined, + incomingSources, + 'vmware-vsphere', ) as Resource['vmware'], + availability: mergeCanonicalSourceFacet( + incoming.availability as JsonRecord | undefined, + existingCanonical.availability as JsonRecord | undefined, + incomingSources, + 'availability', + ) as Resource['availability'], storage: mergeRecord( incoming.storage as JsonRecord | undefined, existingCanonical.storage as JsonRecord | undefined,