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.
This commit is contained in:
pulse-triage[bot]
2026-09-04 01:29:33 +01:00
parent 53ea86983c
commit 724a8a4960
7 changed files with 305 additions and 37 deletions
@@ -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
@@ -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
+18 -17
View File
@@ -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"
]
}
@@ -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',
+2
View File
@@ -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;
}
@@ -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(
[
@@ -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<string | undefined> => {
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<string> => {
const facetsByHostKey = new Map<string, JsonRecord[]>();
const ambiguous = new Set<string>();
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<string, number>();
const indexesByHostKey = new Map<string, number[]>();
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;