From 724a8a4960c2ecd3b88ad5df96c19a532646f641 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:29:33 +0100 Subject: [PATCH] Keep same-name Proxmox sites separate in realtime The browser compatibility coalescer could undo the server's provider-scoped split after websocket reconciliation. Honour machine and provider identity before merging same-hostname rows. --- .../subsystems/performance-and-scalability.md | 4 + .../internal/subsystems/unified-resources.md | 6 + frontend-modern/browser-verification.json | 35 ++-- .../src/types/__tests__/resource.test.ts | 17 ++ frontend-modern/src/types/resource.ts | 2 + .../__tests__/resourceStateAdapters.test.ts | 92 +++++++++ .../src/utils/resourceStateAdapters.ts | 186 ++++++++++++++++-- 7 files changed, 305 insertions(+), 37 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index cee02dde6..b76e8e6a3 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -229,6 +229,10 @@ its members (a flagged id absent from the incoming snapshot conservatively refreshes every group), and a tick that flags no member preserves the cached merged host row by object identity. Unchanged non-host display resources retain object identity. +Same-hostname provider disambiguation must retain that locality: candidate +rows are indexed by normalized host key, and machine, DMI, cluster, endpoint, +and linked-agent comparisons may inspect only the matching key's bucket rather +than rescan unrelated estate rows. `frontend-modern/src/stores/websocket.ts` publishes the changed-ID set and a monotonic resource revision with each reconciliation. The shared `useUnifiedResources` owner applies that revision to the process-wide canonical diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ec63e8a36..af0f71be9 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -91,6 +91,12 @@ presentation coalescing. A repeated cluster label is operator-authored grouping metadata, not same-machine evidence; cross-instance equality requires the same node identity, the exact configured endpoint, or independently corroborated host evidence. +Frontend REST and realtime resource mirrors must retain the canonical +`hostnames`, `machineId`, and `dmiUuid` evidence used for that decision. When +independent Proxmox connections expose the same native node hostname, browser +compatibility coalescing must preserve the server-authored provider split; in +an ambiguous hostname bucket, a separate agent row may join a provider node +only through its explicit linked-agent identity rather than hostname alone. The same boundary carries cluster-node presentation without weakening canonical identity. `ProxmoxData` preserves the immutable connection-scoped node identity, current native node name, prior native-name aliases, and diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 916740a36..643be15f1 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,38 +1,39 @@ { "version": 1, - "base_sha": "fa006785c59a442da1f988d4f916bd97286d564d", - "verified_at": "2026-09-03T23:23:14Z", + "base_sha": "53ea86983c7c6786eb1a6fe1f3681e4566bc237e", + "verified_at": "2026-09-04T00:29:07Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx" + "frontend-modern/src/types/resource.ts", + "frontend-modern/src/utils/resourceStateAdapters.ts" ], "content_sha256": { - "frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx": "ed65c454d88801bf7a24d8d063bea38fca248c4b90b29f083b81ba51ba4181bb" + "frontend-modern/src/types/resource.ts": "c43048a9796babd38f338cff14c21ed2b6dbbf8b16f2bb1fddf89529ef5a65f5", + "frontend-modern/src/utils/resourceStateAdapters.ts": "328efccc8b88b8e590bef9e56568b2f969a06df314d464b5f44b757a2acb189f" }, "routes": [ - "/settings/infrastructure (managed local backend with deterministic Proxmox cluster and connection-ledger fixtures)" + "/proxmox (managed hot-dev mock with two provider-scoped standalone sites sharing the native node name pve)" ], "viewports": [ { "width": 1280, - "height": 720 + "height": 800 }, { - "width": 393, - "height": 727 + "width": 390, + "height": 844 } ], "states": [ - "Expanded Proxmox cluster members with the production cluster Manage trigger visible at desktop and narrow widths", - "Manage production dialog showing endpoint and cluster-member fields at desktop and narrow widths", - "Infrastructure workspace after dialog dismissal with focus returned to Manage at both widths", - "Reopened Manage dialog with the display-name save path available at both widths" + "Initial Proxmox overview showing Nodes 2 and two standalone rows named pve with distinct provider endpoint links", + "Proxmox overview after six mock websocket update intervals with both provider-scoped rows still present", + "Reloaded Proxmox overview with both same-native-name rows restored from the REST bootstrap snapshot", + "Narrow Proxmox overview showing Nodes 2 and two separate pve rows without clipping or navigation overlap" ], "interactions": [ - "Expanded the deterministic Proxmox cluster and opened its Manage dialog", - "Closed Manage and verified both the shared dialog and delayed stable-row focus restorations used preventScroll", - "Verified the application scroll offset did not change and keyboard focus returned to the originating Manage trigger", - "Reopened Manage, edited a cluster member display name, and saved through the immutable node identity path", - "Inspected open and dismissed screenshots for placement, clipping, overflow, focus indication, and fixed-navigation overlap at both widths" + "Authenticated to the managed development runtime and navigated to the Proxmox overview", + "Verified both distinct provider endpoints and waited 12 seconds across repeated realtime updates", + "Reloaded the route and rechecked both resource rows", + "Resized from 1280 by 800 to 390 by 844 and inspected full-page screenshots for row count, clipping, overflow, and fixed-navigation overlap" ] } diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts index b24a7ab2a..5ed8dfcc1 100644 --- a/frontend-modern/src/types/__tests__/resource.test.ts +++ b/frontend-modern/src/types/__tests__/resource.test.ts @@ -70,6 +70,23 @@ describe('Resource Type Guards', () => { expect(resource.id).toBe('test-1'); }); + it('retains canonical host identity evidence on frontend resource consumers', () => { + const resource = createResource({ + type: 'agent', + identity: { + hostnames: ['pve', 'pve.example.test'], + machineId: 'machine-provider-a', + dmiUuid: 'dmi-provider-a', + }, + }); + + expect(resource.identity).toEqual({ + hostnames: ['pve', 'pve.example.test'], + machineId: 'machine-provider-a', + dmiUuid: 'dmi-provider-a', + }); + }); + it('retains three-state UDP availability evidence on canonical resources', () => { const resource = createResource({ type: 'network-endpoint', diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 0483b706f..d0d1263ce 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -164,7 +164,9 @@ export interface ResourceIncident { // Identity information for deduplication export interface ResourceIdentity { hostname?: string; + hostnames?: string[]; machineId?: string; + dmiUuid?: string; ips?: string[]; clusterName?: string; } diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index 55a0b948d..c75f55195 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -714,6 +714,98 @@ describe('resourceStateAdapters nodeFromResource', () => { } }); + it('keeps same-hostname standalone Proxmox provider rows separate across realtime merges', () => { + const providerRow = ( + id: string, + displayName: string, + instance: string, + host: string, + agentId?: string, + ): Resource => + ({ + id, + type: 'agent', + name: displayName, + displayName, + platformId: 'pve', + platformType: 'proxmox-pve', + sourceType: agentId ? 'hybrid' : 'api', + sources: agentId ? ['proxmox', 'agent'] : ['proxmox'], + status: 'online', + lastSeen: Date.now(), + canonicalIdentity: { + displayName, + hostname: 'pve', + platformId: 'pve', + }, + identity: { + hostnames: ['pve'], + ...(agentId ? { machineId: `machine-${agentId}` } : {}), + }, + proxmox: { + sourceId: id, + nodeIdentity: id, + nodeName: 'pve', + nodeDisplayName: displayName, + instance, + host, + }, + ...(agentId + ? { + agent: { + agentId, + hostname: 'pve', + machineId: `machine-${agentId}`, + }, + } + : {}), + }) as Resource; + + // Mirrors #1753 after one same-name site's agent has authenticated while + // the other is still represented by its independent provider poll. The + // browser must not undo the server's provider-scoped split on a later + // realtime reconciliation. + const staging = providerRow( + 'staging-pve', + 'Tripper Staging', + 'hema-staging', + 'https://pve.hemastaging.hot:8006', + 'host-staging', + ); + const production = providerRow( + 'production-pve', + 'VV Staging', + 'hema-production', + 'https://pve.hemaproduction.hot:8006', + ); + + const full = mergeCanonicalResourceSnapshot([staging, production], []); + expect(full).toHaveLength(2); + expect(full.map((resource) => resource.displayName).sort()).toEqual([ + 'Tripper Staging', + 'VV Staging', + ]); + + const delta = mergeCanonicalResourceDeltaSnapshot( + [structuredClone(staging), structuredClone(production)], + full, + new Set(['production-pve']), + ); + expect(delta).toHaveLength(2); + expect(delta.map((resource) => resource.proxmox?.instance).sort()).toEqual([ + 'hema-production', + 'hema-staging', + ]); + + const duplicateEndpoint = providerRow( + 'duplicate-pve', + 'Duplicate connection', + 'duplicate-instance', + 'pve.hemastaging.hot:8006', + ); + expect(mergeCanonicalResourceSnapshot([staging, duplicateEndpoint], [])).toHaveLength(1); + }); + it('does not coalesce same-name agent-only records without a platform source bridge', () => { const resources = mergeCanonicalResourceSnapshot( [ diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index 16845039f..1872fa1ba 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -521,27 +521,164 @@ const getCanonicalSourceList = ( const sourceListContainsRuntimePlatform = (sources: string[] | undefined): boolean => sourceListHas(sources, 'proxmox-pve', 'docker', 'kubernetes', 'vmware-vsphere', 'truenas'); -const getHostResourceMergeKey = (resource: Resource): string | undefined => { - if (resource.type !== 'agent') return undefined; +const getRealtimeAgentFacet = (resource: Resource): JsonRecord | undefined => + asRecord(resource.agent) ?? asRecord(asRecord(resource.platformData)?.agent); + +const getRealtimeProxmoxFacet = (resource: Resource): JsonRecord | undefined => + asRecord(resource.proxmox) ?? asRecord(asRecord(resource.platformData)?.proxmox); + +const getRealtimeHostIdentity = (resource: Resource): JsonRecord | undefined => + asRecord(resource.identity); + +const getRealtimeHostnameCandidates = (resource: Resource): Array => { const platform = asRecord(resource.platformData); - const canonical = resource.canonicalIdentity; - const candidates = [ - canonical?.platformId, - canonical?.hostname, + const identity = getRealtimeHostIdentity(resource); + const identityHostnames = readStringArray(identity?.hostnames) ?? []; + return [ + resource.canonicalIdentity?.platformId, + resource.canonicalIdentity?.hostname, resource.platformId, - asString(asRecord(resource.agent)?.hostname), + ...identityHostnames, + asString(identity?.hostname), + asString(getRealtimeAgentFacet(resource)?.hostname), + asString(getRealtimeProxmoxFacet(resource)?.nodeName), asString(asRecord(platform?.agent)?.hostname), - asString(asRecord(resource.proxmox)?.nodeName), asString(asRecord(platform?.proxmox)?.nodeName), getPreferredResourceHostname(resource), getPreferredInfrastructureDisplayName(resource), resource.displayName, resource.name, ]; - const hostKey = candidates.map(normalizeResourceIdentityToken).find(Boolean); +}; + +const getHostResourceMergeKey = (resource: Resource): string | undefined => { + if (resource.type !== 'agent') return undefined; + const hostKey = getRealtimeHostnameCandidates(resource) + .map(normalizeResourceIdentityToken) + .find(Boolean); return hostKey ? `agent:${hostKey}` : undefined; }; +const realtimeIdentityValuesEqual = (left: unknown, right: unknown): boolean => { + const leftValue = asString(left)?.toLowerCase(); + const rightValue = asString(right)?.toLowerCase(); + return Boolean(leftValue && rightValue && leftValue === rightValue); +}; + +const realtimeIdentityValuesConflict = (left: unknown, right: unknown): boolean => { + const leftValue = asString(left)?.toLowerCase(); + const rightValue = asString(right)?.toLowerCase(); + return Boolean(leftValue && rightValue && leftValue !== rightValue); +}; + +const extractRealtimeEndpointHostname = (value: unknown): string | undefined => { + const raw = asString(value); + if (!raw) return undefined; + try { + const parsed = asString(new URL(raw).hostname)?.toLowerCase(); + if (parsed) return parsed; + } catch { + // Fall through to the host[:port][/path] compatibility form below. + } + return asString(raw.split('/', 1)[0]?.split(':', 1)[0])?.toLowerCase(); +}; + +// Two hand-added standalone Proxmox sites can legitimately expose the same +// native short node name. Instance, immutable node identity, and endpoint are +// provider-scope evidence; the browser must preserve the server's split when +// those scopes differ (#1753). +const realtimeProxmoxNodeScopesDistinct = ( + left: JsonRecord | undefined, + right: JsonRecord | undefined, +): boolean => { + if (!left || !right || !asString(left.nodeName) || !asString(right.nodeName)) return false; + if (!realtimeIdentityValuesConflict(left.instance, right.instance)) return false; + if (realtimeIdentityValuesEqual(left.nodeIdentity, right.nodeIdentity)) return false; + if ( + realtimeIdentityValuesEqual( + extractRealtimeEndpointHostname(left.host), + extractRealtimeEndpointHostname(right.host), + ) + ) { + return false; + } + return true; +}; + +const getRealtimeMachineIds = (resource: Resource): string[] => { + const identity = getRealtimeHostIdentity(resource); + const agent = getRealtimeAgentFacet(resource); + return [asString(identity?.machineId), asString(agent?.machineId)].filter( + (value): value is string => Boolean(value), + ); +}; + +const realtimeMachineIdsOverlap = (left: string[], right: string[]): boolean => + left.some((leftId) => right.some((rightId) => leftId.toLowerCase() === rightId.toLowerCase())); + +const realtimeHostIdentitiesDistinct = (left: Resource, right: Resource): boolean => { + const leftMachineIds = getRealtimeMachineIds(left); + const rightMachineIds = getRealtimeMachineIds(right); + if ( + leftMachineIds.length > 0 && + rightMachineIds.length > 0 && + !realtimeMachineIdsOverlap(leftMachineIds, rightMachineIds) + ) { + return true; + } + + if ( + realtimeIdentityValuesConflict( + getRealtimeHostIdentity(left)?.dmiUuid, + getRealtimeHostIdentity(right)?.dmiUuid, + ) + ) { + return true; + } + + const leftProxmox = getRealtimeProxmoxFacet(left); + const rightProxmox = getRealtimeProxmoxFacet(right); + if (realtimeIdentityValuesConflict(leftProxmox?.clusterName, rightProxmox?.clusterName)) { + return true; + } + return realtimeProxmoxNodeScopesDistinct(leftProxmox, rightProxmox); +}; + +const getAmbiguousRealtimeProxmoxHostKeys = (resources: Resource[]): Set => { + const facetsByHostKey = new Map(); + const ambiguous = new Set(); + for (const resource of resources) { + const facet = getRealtimeProxmoxFacet(resource); + const hostKey = getHostResourceMergeKey(resource); + if (!facet || !asString(facet.nodeName) || !hostKey || ambiguous.has(hostKey)) continue; + const existingFacets = facetsByHostKey.get(hostKey) ?? []; + if (existingFacets.some((existing) => realtimeProxmoxNodeScopesDistinct(existing, facet))) { + ambiguous.add(hostKey); + } + existingFacets.push(facet); + facetsByHostKey.set(hostKey, existingFacets); + } + return ambiguous; +}; + +const realtimeGuardedMergeAllowed = (left: Resource, right: Resource): boolean => { + const leftProxmox = getRealtimeProxmoxFacet(left); + const rightProxmox = getRealtimeProxmoxFacet(right); + const leftIsNode = Boolean(asString(leftProxmox?.nodeName)); + const rightIsNode = Boolean(asString(rightProxmox?.nodeName)); + if (leftIsNode === rightIsNode) return true; + + const node = leftIsNode ? left : right; + const agent = leftIsNode ? right : left; + const nodeProxmox = getRealtimeProxmoxFacet(node); + const nodePlatform = asRecord(node.platformData); + const linkedAgentId = + asString(nodeProxmox?.linkedAgentId) ?? + asString(nodePlatform?.linkedAgentId) ?? + asString(getRealtimeAgentFacet(node)?.agentId); + return realtimeIdentityValuesEqual(linkedAgentId, getRealtimeAgentFacet(agent)?.agentId); +}; + const shouldMergeRealtimeHostResources = (incoming: Resource, existing: Resource): boolean => { if (incoming.type !== 'agent' || existing.type !== 'agent') return false; const incomingSources = getCanonicalSourceList(incoming, incoming.platformData); @@ -592,7 +729,8 @@ const mergeRealtimeHostResources = (incoming: Resource, existing: Resource): Res const coalesceCanonicalRealtimeResourceSnapshot = (resources: Resource[]): Resource[] => { const coalesced: Resource[] = []; - const indexByHostKey = new Map(); + const indexesByHostKey = new Map(); + const ambiguousProxmoxHostKeys = getAmbiguousRealtimeProxmoxHostKeys(resources); for (const resource of resources) { const hostKey = getHostResourceMergeKey(resource); @@ -601,20 +739,28 @@ const coalesceCanonicalRealtimeResourceSnapshot = (resources: Resource[]): Resou continue; } - const existingIndex = indexByHostKey.get(hostKey); - if (existingIndex === undefined) { - indexByHostKey.set(hostKey, coalesced.length); - coalesced.push(resource); - continue; + let merged = false; + for (const existingIndex of indexesByHostKey.get(hostKey) ?? []) { + const existing = coalesced[existingIndex]; + if (realtimeHostIdentitiesDistinct(resource, existing)) continue; + if ( + ambiguousProxmoxHostKeys.has(hostKey) && + !realtimeGuardedMergeAllowed(resource, existing) + ) { + continue; + } + if (!shouldMergeRealtimeHostResources(resource, existing)) continue; + coalesced[existingIndex] = mergeRealtimeHostResources(resource, existing); + merged = true; + break; } - const existing = coalesced[existingIndex]; - if (!shouldMergeRealtimeHostResources(resource, existing)) { + if (!merged) { + const indexes = indexesByHostKey.get(hostKey) ?? []; + indexes.push(coalesced.length); + indexesByHostKey.set(hostKey, indexes); coalesced.push(resource); - continue; } - - coalesced[existingIndex] = mergeRealtimeHostResources(resource, existing); } return coalesced;