mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user