Keep workload node attribution separate across installations

Preserve supplied instance/node tuples through grouping, filters and parent resolution, including colliding native IDs. Use validated row parents for power and host-relative memory, retain removable ambiguous bookmarks, and preserve native chart transport and canonical resource identity.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-09 21:31:07 +01:00
parent 0c2a59986f
commit ebc80160ca
24 changed files with 696 additions and 73 deletions
@@ -15,6 +15,38 @@
## Purpose
### Workloads node attribution preserves instance/node boundaries
Workloads uses an encoded instance/node tuple for UI node scopes, grouping and
parent lookup. Concatenated legacy aliases may resolve existing bookmarks only
when unique; neither a partial host hint nor an ambiguous alias may choose the
first node. An unresolved bookmark remains a visible, removable text filter
and uses filtered-empty copy, not an inventory-outage message. Supplied parent
fields take precedence over inferred legacy guest-ID prefixes, whose positive VMID suffix must match the supplied VMID and which
may fill missing fields but cannot contradict them. Supplied node snapshots
overlay only the same native ID and tuple; a shared native ID cannot erase
another tuple. Ambiguous tuples or native-ID aliases do not provide parent
evidence. Row status and host-relative memory use the validated row parent in grouped and flat modes;
a group label is not authority to supply another row's parent. Unrelated
platforms do not inherit Proxmox parent state from matching labels.
UI scope tokens are not collector or metric IDs. The existing charts transport
uses a uniquely resolved native node ID, or its bounded global summary when
that parent cannot be resolved uniquely, retaining canonical per-resource chart
ownership and existing polling/point limits. This presentation boundary changes
no server ingestion, guest ID, alert identity, collector, native History or
monitoring permissions. Node removal or ambiguous supplied inventory leaves the
host-memory denominator unavailable, never zero or another node's capacity.
Verification: `workloadTopology.attribution.test.ts` and
`workloadTopology.scope.test.ts` cover collided scopes, parent ambiguity,
source-field precedence, legacy hints and unrelated platforms.
`WorkloadsSurface.performance.contract.test.tsx` checks both supplied inventory
orders and same-node snapshot overlays; `useWorkloadTableMetricHistory.test.tsx`
checks the native-ID transport boundary. Parent-bound browser proof checks real
Workloads grouping, row status, host-memory basis and removable bookmarks on
desktop and phone. It does not establish server ingestion or native recovery.
### Workloads disk decorations follow the selected filesystem source
@@ -0,0 +1,62 @@
{
"version": 1,
"base_sha": "0c2a59986f88995f450a6613f54be5dfb5c51de0",
"verified_at": "2026-10-09T20:28:36.254Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts",
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts",
"frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts",
"frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/components/Workloads/workloadRouteModel.ts",
"frontend-modern/src/components/Workloads/workloadSelectors.ts",
"frontend-modern/src/components/Workloads/workloadTopology.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "4ad3f504a50e548b7e85c2fe2dda20c4b1e1378b83b9abaf21d134e8126aa599",
"frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts": "5208ac125a2fc5b74c72e3cdf9f633ef43165b361acb1fa92a2b59161ca5ca4f",
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts": "31839101d1d05429ade678e14eea951f2e2f90918f58d7862c6e1089faeae922",
"frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts": "a94083c394865dc4d3a3c86020712846723e33765fe6b43e11c785ca65494aef",
"frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts": "5abc4ebb20cb91ab9e2cde2872b8c0e2a6173338bc188317d0ad5848f4db6dc1",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "991fbadea0c5340c91e3c14d430dcd44a79c0ccc5cc601b99646e70437dd8d9b",
"frontend-modern/src/components/Workloads/workloadRouteModel.ts": "afd1778bf31cd50bdaf4fb2918271781081e8ee65fe4314885b9d8c012715ea2",
"frontend-modern/src/components/Workloads/workloadSelectors.ts": "cbbb572ef0a6f8dce2441352cf192a07a1e7ea6dc734e92d8240bb0218bba783",
"frontend-modern/src/components/Workloads/workloadTopology.ts": "7ec4c6a86abbf537ca40464e877038d87dc46e8ab1a2147fed96fd1c25310e4f"
},
"routes": [
"/proxmox?tab=workloads",
"/proxmox?tab=workloads&agent=node%7Clab%7Ceast-pve1",
"/proxmox?tab=workloads&agent=lab-east-pve1",
"/proxmox?tab=workloads&agent=east-pve1"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Synthetic canonical resources and supplied nodes through real useWorkloadsState/WorkloadsFilter/WorkloadsSurface/WorkloadPanel/GuestRow with production CSS; not full authenticated App, backend or native acceptance.",
"Two supplied nodes share native ID lab-east-pve1 but have distinct instance/node tuples; grouped and flat guest rows retain own offline/running state and 13% of 8 GiB versus 3% of 32 GiB host-memory basis.",
"Light/dark desktop Chromium and mobile WebKit; ten final captures visually inspected.",
"Missing or duplicated parent withholds host-memory share as N/A; restoration resumes exact parent.",
"Ambiguous legacy bookmark remains visible removable Node filter with filtered-empty copy."
],
"interactions": [
"distinct tuple groups; each row uses its own node power and host-memory denominator",
"inventory order cannot change parent attribution or remount the canonical row",
"flat rows retain the same validated parent",
"real Node filter selects only its tuple and serialises an unambiguous bookmark",
"scoped bookmark survives reload through production URL sync",
"missing/duplicated parent withholds host share; exact-parent restoration restores it",
"ambiguous legacy bookmark stays a visible removable filter, not a first-node selection or inventory outage; clearing restores both rows",
"unambiguous existing node-name hint still selects its node",
"Inspect all ten final screenshots; no horizontal overflow, browser/page errors or mutating synthetic API requests; browser and loopback server closed."
]
}
@@ -145,7 +145,9 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
);
const metadata = () =>
getWorkloadGuestMetadataRecord(guest(), props.guestMetadata());
const parentNode = () => node() ?? props.guestParentNodeMap()[guestId()];
// A group label is not parent evidence. Every row follows
// the validated source-owned parent, including flat mode.
const parentNode = () => props.guestParentNodeMap()[guestId()];
const parentNodeOnline = () => {
const pn = parentNode();
return pn ? isNodeOnline(pn) : true;
@@ -5,6 +5,8 @@ import { Route, Router } from '@solidjs/router';
import type { Resource } from '@/types/resource';
import { syncSessionSettingsCapabilities } from '@/stores/sessionSettingsCapabilities';
import { WorkloadsSurface } from '../WorkloadsSurface';
import { useWorkloadsState } from '../useWorkloadsState';
import type { Node } from '@/types/api';
import { WORKLOAD_TABLE_CONTAINER_TABLET_WIDTH, getGuestColumnStyle } from '../guestRowModel';
import workloadsSource from '../WorkloadsSurface.tsx?raw';
import proxmoxPageSurfaceSource from '@/features/proxmox/ProxmoxPageSurface.tsx?raw';
@@ -1009,6 +1011,90 @@ describe('Workloads performance contract', () => {
});
describe('Workload derivation contracts', () => {
it.each([false, true])(
'retains separate supplied tuples sharing a native ID (reversed=%s)',
(reversed) => {
const makeNode = (instance: string, name: string, capacity: number) =>
({
id: 'lab-east-pve1',
instance,
name,
status: 'online',
cpu: 0,
memory: { total: capacity, used: 1, free: capacity - 1, usage: 1 / capacity },
disk: { total: 100, used: 1, free: 99, usage: 1 },
}) as Node;
const nodes = [makeNode('lab-east', 'pve1', 8), makeNode('lab', 'east-pve1', 32)];
mockWorkloads = [
makeGuest(1, {
id: 'raw-a',
instance: 'lab-east',
node: 'pve1',
type: 'vm',
workloadType: 'vm',
}),
makeGuest(2, {
id: 'raw-b',
instance: 'lab',
node: 'east-pve1',
type: 'vm',
workloadType: 'vm',
}),
];
// The canonical refresh overlays only B, not A despite the same native ID.
mockInfrastructureResources = [
{
id: 'lab-east-pve1',
type: 'agent',
name: 'east-pve1',
platformType: 'proxmox-pve',
status: 'offline',
proxmox: { instance: 'lab', node: 'east-pve1' },
memory: { current: 1, total: 64, used: 1 },
} as Resource,
];
function SuppliedNodes() {
const state = useWorkloadsState({
vms: [],
containers: [],
nodes: reversed ? [...nodes].reverse() : nodes,
useWorkloads: true,
});
return (
<pre data-testid="supplied-node-parents">
{JSON.stringify({
count: state.infrastructureNodes().length,
parents: state.allGuests().map((guest) => {
const parent = state.guestParentNodeMap()[getCanonicalWorkloadId(guest)];
return {
guest: guest.instance,
instance: parent?.instance,
node: parent?.name,
capacity: parent?.memory.total,
status: parent?.status,
};
}),
})}
</pre>
);
}
render(() => <SuppliedNodes />);
expect(JSON.parse(screen.getByTestId('supplied-node-parents').textContent!)).toEqual({
count: 2,
parents: [
{
guest: 'lab-east',
instance: 'lab-east',
node: 'pve1',
capacity: 8,
status: 'online',
},
{ guest: 'lab', instance: 'lab', node: 'east-pve1', capacity: 64, status: 'offline' },
],
});
},
);
it('normalizes workloads view mode aliases through the shared workload helper', () => {
expect(normalizeWorkloadViewModeParam('all')).toBe('all');
expect(normalizeWorkloadViewModeParam('container')).toBe('container');
@@ -105,8 +105,41 @@ describe('useWorkloadFilterOptions', () => {
expect(result.workloadNodeOptions()).toEqual([
{ value: 'docker-prod-a', label: 'docker-prod-a' },
{ value: 'core-fabric-pve-prod-a', label: 'pve-prod-a' },
{ value: 'node|core-fabric|pve-prod-a', label: 'pve-prod-a' },
]);
expect(result.containerRuntimeOptions()).toEqual(['docker']);
});
it('keeps an unresolved bookmark visible and removable as an active node filter', () => {
const handleNodeSelect = vi.fn();
const [hint, setHint] = createSignal<string | null>('lab-east-pve1');
const { result } = renderHook(() =>
useWorkloadFilterOptions({
allGuests: () => [
makeGuest({ instance: 'lab-east', node: 'pve1' }),
makeGuest({ instance: 'lab', node: 'east-pve1' }),
],
isWorkloadsRoute: () => true,
allowEmbeddedScopeFilters: () => true,
viewMode: () => 'vm',
containerRuntime: () => '',
selectedPlatform: () => null,
selectedNode: () => null,
selectedHostHint: hint,
selectedKubernetesContext: () => null,
selectedKubernetesNamespace: () => null,
selectedCluster: () => null,
setContainerRuntime: vi.fn(),
setSelectedPlatform: vi.fn(),
setSelectedKubernetesContext: vi.fn(),
setSelectedKubernetesNamespace: vi.fn(),
setSelectedCluster: vi.fn(),
handleNodeSelect,
}),
);
expect(result.hostFilterConfig()?.value).toBe('lab-east-pve1');
result.hostFilterConfig()?.onChange('');
expect(handleNodeSelect).toHaveBeenCalledWith(null, null);
setHint(null);
expect(result.hostFilterConfig()?.value).toBe('');
});
});
@@ -317,4 +317,39 @@ describe('useWorkloadTableMetricHistory', () => {
expect(screen.getByTestId('active-history-value')).toHaveTextContent('20');
});
});
it('resolves scoped selections to native chart IDs and drops ambiguous native filters', async () => {
const scopedNode = { ...node, id: 'native-b', instance: 'lab', name: 'east-pve1' };
const [inventory, setInventory] = createSignal<Node[]>([scopedNode]);
const workloadSpy = vi.spyOn(ChartsAPI, 'getWorkloadCharts').mockResolvedValue({
data: {},
dockerData: {},
guestTypes: {},
timestamp: 2,
stats: { oldestDataTimestamp: 1 },
});
render(() => {
useWorkloadTableMetricHistory({
enabled: () => true,
range: () => '1h',
series: 'guests',
selectedNode: () => 'node|lab|east-pve1',
nodes: inventory,
});
return <div />;
});
await waitFor(() =>
expect(workloadSpy).toHaveBeenCalledWith('1h', expect.any(AbortSignal), {
maxPoints: WORKLOAD_TABLE_HISTORY_MAX_POINTS,
nodeId: 'native-b',
}),
);
setInventory([scopedNode, { ...scopedNode, instance: 'lab-east', name: 'pve1' }]);
await waitFor(() =>
expect(workloadSpy).toHaveBeenLastCalledWith('1h', expect.any(AbortSignal), {
maxPoints: WORKLOAD_TABLE_HISTORY_MAX_POINTS,
nodeId: undefined,
}),
);
expect(workloadSpy).toHaveBeenCalledTimes(2);
});
});
@@ -110,7 +110,7 @@ describe('workloadRouteModel (branch coverage 2)', () => {
makeGuest({ id: 'vm-real', node: 'node-a', instance: 'cluster-a' }),
]);
expect(options).toEqual([{ value: 'cluster-a-node-a', label: 'node-a' }]);
expect(options).toEqual([{ value: 'node|cluster-a|node-a', label: 'node-a' }]);
});
it('skips app-container guests whose host scope is empty string (all host-id candidates blank)', () => {
@@ -149,7 +149,7 @@ describe('workloadRouteModel (branch coverage 2)', () => {
makeGuest({ id: 'with-node', node: 'node-a', instance: 'c2' }),
]);
expect(options).toEqual([{ value: 'c2-node-a', label: 'node-a' }]);
expect(options).toEqual([{ value: 'node|c2|node-a', label: 'node-a' }]);
});
it('falls back to the bare node name when a duplicate node has no instance to disambiguate with', () => {
@@ -165,8 +165,8 @@ describe('workloadRouteModel (branch coverage 2)', () => {
]);
expect(options).toEqual([
{ value: '-shared', label: 'shared' },
{ value: 'c1-shared', label: 'shared (c1)' },
{ value: 'node||shared', label: 'shared' },
{ value: 'node|c1|shared', label: 'shared (c1)' },
]);
});
@@ -182,7 +182,7 @@ describe('workloadRouteModel (branch coverage 2)', () => {
makeGuest({ id: 'vm-a', node: 'node-a', instance: 'cluster-a' }),
]);
expect(options).toEqual([{ value: 'cluster-a-node-a', label: 'node-a' }]);
expect(options).toEqual([{ value: 'node|cluster-a|node-a', label: 'node-a' }]);
});
it('returns an empty array for an empty guest list', () => {
@@ -56,9 +56,9 @@ describe('workloadRouteModel', () => {
]);
expect(options).toEqual([
{ value: 'cluster-a-node-a', label: 'node-a (cluster-a)' },
{ value: 'cluster-b-node-a', label: 'node-a (cluster-b)' },
{ value: 'cluster-a-node-z', label: 'node-z' },
{ value: 'node|cluster-a|node-a', label: 'node-a (cluster-a)' },
{ value: 'node|cluster-b|node-a', label: 'node-a (cluster-b)' },
{ value: 'node|cluster-a|node-z', label: 'node-z' },
]);
});
@@ -72,9 +72,9 @@ describe('workloadRouteModel', () => {
],
);
expect(options).toEqual([
{ value: 'cluster-a-empty', label: 'empty' },
{ value: 'cluster-a-node-a', label: 'node-a (cluster-a)' },
{ value: 'cluster-b-node-a', label: 'node-a (cluster-b)' },
{ value: 'node|cluster-a|empty', label: 'empty' },
{ value: 'node|cluster-a|node-a', label: 'node-a (cluster-a)' },
{ value: 'node|cluster-b|node-a', label: 'node-a (cluster-b)' },
]);
});
@@ -642,7 +642,7 @@ describe('workloadSelectors (branch coverage)', () => {
instance: 'inst-1',
node: 'node-1',
});
expect(getWorkloadGroupKey(guest)).toBe('inst-1-node-1');
expect(getWorkloadGroupKey(guest)).toBe('node|inst-1|node-1');
});
it('falls back through contextLabel -> node -> instance -> namespace -> id', () => {
@@ -790,9 +790,9 @@ describe('workloadSelectors (branch coverage)', () => {
const result = groupWorkloads(guests, 'grouped', null);
expect(Object.keys(result)).toEqual(['inst-nd']);
expect(Object.keys(result)).toEqual(['node|inst|nd']);
// Original insertion order preserved (no comparator)
expect(result['inst-nd'].map((g) => g.id)).toEqual(['b-id', 'a-id']);
expect(result['node|inst|nd'].map((g) => g.id)).toEqual(['b-id', 'a-id']);
});
it('returns an empty object for empty guests in grouped mode', () => {
@@ -339,7 +339,7 @@ describe('workloadSelectors (branch coverage 2)', () => {
});
describe('getWorkloadGroupLabel', () => {
it('returns empty type and the raw context for a vm guest with no node and no cluster', () => {
it('uses the instance label rather than the opaque scope for a vm without node or cluster', () => {
const guests = [
makeGuest(1, {
id: 'solo',
@@ -354,7 +354,7 @@ describe('workloadSelectors (branch coverage 2)', () => {
// groupKey = 'solo-' (no colon) -> prefix not recognized -> first guest has
// no node and no cluster -> final fallback { type: '', name: context }.
expect(getWorkloadGroupLabel('', guests)).toStrictEqual({ type: '', name: 'solo-' });
expect(getWorkloadGroupLabel('', guests)).toStrictEqual({ type: '', name: 'solo' });
});
it('joins a multi-segment context after a recognized prefix for an empty guests array', () => {
@@ -763,8 +763,8 @@ describe('workloadSelectors', () => {
const comparator = createWorkloadSortComparator('name', 'asc');
const grouped = groupWorkloads(guests, 'grouped', comparator);
expect(Object.keys(grouped).sort()).toEqual(['app-container:edge', 'cluster-a-node-a']);
expect(grouped['cluster-a-node-a'].map((g) => g.id)).toEqual(['vm-a', 'vm-b']);
expect(Object.keys(grouped).sort()).toEqual(['app-container:edge', 'node|cluster-a|node-a']);
expect(grouped['node|cluster-a|node-a'].map((g) => g.id)).toEqual(['vm-a', 'vm-b']);
});
});
@@ -920,7 +920,7 @@ describe('workloadSelectors', () => {
instance: 'cluster-z',
});
expect(getWorkloadGroupKey(vm)).toBe('inst-a-node-a');
expect(getWorkloadGroupKey(vm)).toBe('node|inst-a|node-a');
expect(getWorkloadGroupKey(docker)).toBe('app-container:docker-edge');
expect(getWorkloadGroupKey(k8s)).toBe('pod:worker-2');
});
@@ -948,9 +948,9 @@ describe('workloadSelectors', () => {
});
describe('workloadNodeScopeId', () => {
it('builds node scope as instance-node with trimming', () => {
it('builds an encoded instance/node scope with trimming', () => {
const guest = makeGuest(1, { instance: ' cluster-a ', node: ' node-a ' });
expect(workloadNodeScopeId(guest)).toBe('cluster-a-node-a');
expect(workloadNodeScopeId(guest)).toBe('node|cluster-a|node-a');
});
});
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import type { Node } from '@/types/api';
import type { WorkloadGuest } from '@/types/workloads';
import { buildGuestParentNodeMapFromNodes } from '../workloadTopology';
import { groupWorkloads } from '../workloadSelectors';
import { buildWorkloadNodeOptions } from '../workloadRouteModel';
const guests = [
{
id: 'lab-east-pve1-101',
vmid: 101,
node: 'pve1',
instance: 'lab-east',
type: 'qemu',
workloadType: 'vm',
platformScopes: ['proxmox-pve'],
},
{
id: 'lab-east-pve1-101',
vmid: 101,
node: 'east-pve1',
instance: 'lab',
type: 'qemu',
workloadType: 'vm',
platformScopes: ['proxmox-pve'],
},
] as WorkloadGuest[];
const nodes = [
{
id: 'native-a',
name: 'pve1',
instance: 'lab-east',
status: 'offline',
memory: { total: 8192 },
},
{
id: 'native-b',
name: 'east-pve1',
instance: 'lab',
status: 'online',
memory: { total: 32768 },
},
] as Node[];
describe('Supplied node attribution (existing API regression controls)', () => {
it('does not combine distinct supplied instance/node pairs into one group', () => {
expect(
Object.values(groupWorkloads(guests, 'grouped', null)).map((group) => group.length),
).toEqual([1, 1]);
});
it('keeps both independently selectable nodes instead of losing an option', () => {
expect(buildWorkloadNodeOptions(guests, nodes)).toHaveLength(2);
});
it.each([nodes, [...nodes].reverse()])(
'preserves each supplied parent regardless of inventory order',
(...inventory) => {
const parents = buildGuestParentNodeMapFromNodes(guests, inventory);
expect(parents['lab-east:pve1:101']).toBe(nodes[0]);
expect(parents['lab:east-pve1:101']).toBe(nodes[1]);
},
);
it('does not substitute an inferred guest-ID parent when its explicit source fields disagree', () => {
const contradictory = { ...guests[1], id: 'native-a-101' };
const parents = buildGuestParentNodeMapFromNodes([contradictory], nodes);
expect(parents['lab:east-pve1:101']).toBe(nodes[1]);
});
it('cannot give a vSphere VM Proxmox parent state from matching labels', () => {
const vm = { ...guests[0], platformScopes: ['vmware-vsphere'] };
expect(buildGuestParentNodeMapFromNodes([vm], nodes)['lab-east:pve1:101']).toBeUndefined();
});
});
@@ -41,24 +41,24 @@ describe('workloadTopology (branch coverage 2)', () => {
it('exercises both `|| ""` fallback arms when instance and node are empty strings', () => {
const guest = makeGuest(1, { instance: '', node: '' });
// (guest.instance || '') and (guest.node || '') both take the falsy arm.
expect(workloadNodeScopeId(guest)).toBe('-');
expect(workloadNodeScopeId(guest)).toBe('');
});
it('keeps a present instance and falls back to empty for a missing node', () => {
const guest = makeGuest(1, { instance: 'only-inst', node: '' });
// instance truthy arm + node `|| ""` fallback arm.
expect(workloadNodeScopeId(guest)).toBe('only-inst-');
expect(workloadNodeScopeId(guest)).toBe('node|only-inst|');
});
it('falls back to empty for a missing instance and keeps a present node', () => {
const guest = makeGuest(1, { instance: '', node: 'only-node' });
// instance `|| ""` fallback arm + node truthy arm.
expect(workloadNodeScopeId(guest)).toBe('-only-node');
expect(workloadNodeScopeId(guest)).toBe('node||only-node');
});
it('trims whitespace on both segments when both are present', () => {
const guest = makeGuest(1, { instance: ' inst ', node: ' \tnode\t' });
expect(workloadNodeScopeId(guest)).toBe('inst-node');
expect(workloadNodeScopeId(guest)).toBe('node|inst|node');
});
});
@@ -0,0 +1,189 @@
import { describe, expect, it } from 'vitest';
import type { Node } from '@/types/api';
import type { WorkloadGuest } from '@/types/workloads';
import { getCanonicalWorkloadId } from '@/utils/workloads';
import {
buildNodeByInstance,
buildGuestParentNodeMapFromNodes,
getWorkloadHistoryNodeId,
legacyWorkloadNodeScopeId,
readWorkloadNodeScope,
workloadNodeScopeId,
} from '../workloadTopology';
import { filterWorkloads, getWorkloadGroupKey, groupWorkloads } from '../workloadSelectors';
import { buildWorkloadNodeOptions, resolveWorkloadNodeHint } from '../workloadRouteModel';
const guest = (instance: string, node: string, extra: Partial<WorkloadGuest> = {}) =>
({
id: 'lab-east-pve1-101',
instance,
node,
vmid: 101,
name: `${instance}/${node}`,
type: 'qemu',
workloadType: 'vm',
status: 'running',
platformScopes: ['proxmox-pve'],
...extra,
}) as WorkloadGuest;
const node = (id: string, instance: string, name: string, total: number, status = 'online') =>
({
id,
instance,
name,
status,
uptime: status === 'online' ? 100 : 0,
memory: { total, used: 0, free: total, usage: 0 },
}) as Node;
const guests = [guest('lab-east', 'pve1'), guest('lab', 'east-pve1')];
const nodes = [
node('native-a', 'lab-east', 'pve1', 8192, 'offline'),
node('native-b', 'lab', 'east-pve1', 32768),
];
const filtered = (inventory: WorkloadGuest[], selectedNode: string) =>
filterWorkloads({
guests: inventory,
selectedNode,
viewMode: 'all',
statusMode: 'all',
searchTerm: '',
selectedHostHint: null,
selectedKubernetesContext: null,
});
describe('Workloads node attribution', () => {
it.each([guests, [...guests].reverse()])(
'keeps delimiter-colliding groups and filters separate in either order',
(...inventory) => {
const groups = groupWorkloads(inventory, 'grouped', null);
expect(Object.keys(groups)).toHaveLength(2);
for (const original of guests) {
const scope = workloadNodeScopeId(original);
expect(groups[scope]).toEqual([original]);
expect(filtered(inventory, scope)).toEqual([original]);
expect(original.id).toBe('lab-east-pve1-101');
expect(getCanonicalWorkloadId(original)).toBe(`${original.instance}:${original.node}:101`);
}
expect(filtered(inventory, 'lab-east-pve1')).toEqual([]);
expect(filtered([inventory[0]], 'lab-east-pve1')).toEqual([inventory[0]]);
},
);
it.each([nodes, [...nodes].reverse()])(
'assigns status and host memory to the exact tuple, not the first legacy alias',
(...inventory) => {
const parents = buildGuestParentNodeMapFromNodes(guests, inventory);
expect(parents['lab-east:pve1:101']).toBe(nodes[0]);
expect(parents['lab:east-pve1:101']).toBe(nodes[1]);
expect(parents['lab-east:pve1:101']?.status).toBe('offline');
expect(parents['lab:east-pve1:101']?.memory.total).toBe(32768);
},
);
it('does not let a contradictory inferred ID override the supplied parent fields', () => {
const wrongPrefix = guest('lab', 'east-pve1', { id: 'native-a-101' });
expect(buildGuestParentNodeMapFromNodes([wrongPrefix], nodes)['lab:east-pve1:101']).toBe(
nodes[1],
);
const missing = guest('other', 'absent', { id: 'native-a-101' });
expect(buildGuestParentNodeMapFromNodes([missing], nodes)['other:absent:101']).toBeUndefined();
});
it('retains a unique consistent legacy-ID fallback for missing parent fields', () => {
const legacy = guest('', '', { id: 'native-a-101' });
expect(buildGuestParentNodeMapFromNodes([legacy], nodes)[getCanonicalWorkloadId(legacy)]).toBe(
nodes[0],
);
for (const id of ['native-a-102', 'native-a-not-a-vmid']) {
const unrelatedId = { ...legacy, id };
expect(
buildGuestParentNodeMapFromNodes([unrelatedId], nodes)[getCanonicalWorkloadId(unrelatedId)],
).toBeUndefined();
}
});
it('leaves duplicated tuples and IDs unknown instead of choosing by inventory order', () => {
const duplicate = node('native-c', 'lab-east', 'pve1', 65536);
const map = buildNodeByInstance([...nodes, duplicate]);
expect(map[workloadNodeScopeId(guests[0])]).toBeUndefined();
expect(
buildGuestParentNodeMapFromNodes([guests[0]], [...nodes, duplicate])['lab-east:pve1:101'],
).toBeUndefined();
const shared = nodes.map((n) => ({ ...n, id: 'same-native-id' }));
expect(buildNodeByInstance(shared)['same-native-id']).toBeUndefined();
expect(buildGuestParentNodeMapFromNodes(guests, shared)['lab:east-pve1:101']).toBe(shared[1]);
});
it('cannot assign a Proxmox node to an unrelated platform or container by matching labels', () => {
const vsphere = guest('lab-east', 'pve1', { platformScopes: ['vmware-vsphere'] });
const container = guest('lab-east', 'pve1', {
type: 'app-container',
workloadType: 'app-container',
});
expect(buildGuestParentNodeMapFromNodes([vsphere, container], nodes)).toEqual({});
expect(getWorkloadGroupKey(vsphere)).not.toBe(getWorkloadGroupKey(guests[0]));
});
it('encodes delimiters, percent escapes and case without normalising independent identities', () => {
const pairs = [
{ instance: 'a|b', node: 'c' },
{ instance: 'a', node: 'b|c' },
{ instance: 'a%7Cb', node: 'c' },
{ instance: 'A|b', node: 'c' },
{ instance: 'a::b', node: 'c/d' },
{ instance: '', node: 'pve1' },
];
expect(new Set(pairs.map(workloadNodeScopeId)).size).toBe(pairs.length);
for (const pair of pairs)
expect(readWorkloadNodeScope(workloadNodeScopeId(pair))).toEqual(pair);
expect(workloadNodeScopeId({ instance: ' ', node: '' })).toBe('');
for (const invalid of ['node|a|%XX', 'node|a|b|c', 'node||', 'node|a|b%20']) {
expect(readWorkloadNodeScope(invalid)).toBeNull();
}
expect(legacyWorkloadNodeScopeId(workloadNodeScopeId(guests[0]))).toBe('lab-east-pve1');
});
it('keeps filter options distinct and only promotes unambiguous host hints or bookmarks', () => {
const options = buildWorkloadNodeOptions(guests, nodes);
expect(options).toHaveLength(2);
for (const original of guests) {
expect(resolveWorkloadNodeHint(options, workloadNodeScopeId(original))?.value).toBe(
workloadNodeScopeId(original),
);
}
expect(resolveWorkloadNodeHint(options, 'lab-east-pve1')).toBeNull();
expect(resolveWorkloadNodeHint(options, 'pve1')).toEqual(
options.find((o) => o.label === 'pve1'),
);
expect(resolveWorkloadNodeHint(options, 'pve')).toBeNull();
expect(
resolveWorkloadNodeHint(buildWorkloadNodeOptions([guests[0]]), 'lab-east-pve1')?.value,
).toBe(workloadNodeScopeId(guests[0]));
const repeated = buildWorkloadNodeOptions([guest('one', 'pve1'), guest('two', 'pve1')]);
expect(resolveWorkloadNodeHint(repeated, 'pve1')).toBeNull();
expect(resolveWorkloadNodeHint(repeated, 'pve1 (two)')?.value).toBe(
workloadNodeScopeId({ instance: 'two', node: 'pve1' }),
);
});
it('maps UI selections to the existing native charts ID without choosing an ambiguous native ID', () => {
expect(getWorkloadHistoryNodeId(workloadNodeScopeId(guests[1]), nodes)).toBe('native-b');
expect(
getWorkloadHistoryNodeId(
workloadNodeScopeId(guests[1]),
nodes.map((n) => ({ ...n, id: 'same' })),
),
).toBeUndefined();
expect(getWorkloadHistoryNodeId(workloadNodeScopeId(guests[1]), [])).toBeUndefined();
expect(getWorkloadHistoryNodeId('native-b', nodes)).toBe('native-b');
expect(getWorkloadHistoryNodeId('')).toBeUndefined();
});
it('treats prototype-like native IDs as own entries, not inherited parents', () => {
const unusual = node('__proto__', 'lab-east', 'pve1', 8192);
const map = buildNodeByInstance([unusual]);
expect(Object.getPrototypeOf(map)).toBeNull();
expect(map.__proto__).toBe(unusual);
expect(map.toString).toBeUndefined();
});
});
@@ -61,7 +61,7 @@ const makeNode = (id: string, instance: string, name: string): Node => ({
describe('workloadTopology', () => {
describe('buildNodeByInstance and buildGuestParentNodeMap', () => {
it('maps nodes by id and legacy instance-name key without overriding first legacy key', () => {
it('maps unique native IDs without aliasing duplicate instance/node tuples', () => {
const nodeA = makeNode('cluster-a-node-a', 'cluster-a', 'node-a');
const nodeAAlt = makeNode('custom-node-id', 'cluster-a', 'node-a');
const nodeB = makeNode('cluster-b-node-b', 'cluster-b', 'node-b');
@@ -116,9 +116,9 @@ describe('workloadTopology', () => {
});
describe('workload identity helpers', () => {
it('builds node scope as instance-node with trimming', () => {
it('builds an encoded instance/node scope with trimming', () => {
const guest = makeGuest(1, { instance: ' cluster-a ', node: ' node-a ' });
expect(workloadNodeScopeId(guest)).toBe('cluster-a-node-a');
expect(workloadNodeScopeId(guest)).toBe('node|cluster-a|node-a');
});
it('returns first non-empty kubernetes context candidate', () => {
@@ -34,6 +34,7 @@ interface WorkloadsWorkloadFilterOptionsOptions {
containerRuntime: Accessor<string>;
selectedPlatform: Accessor<string | null>;
selectedNode: Accessor<string | null>;
selectedHostHint?: Accessor<string | null>;
selectedKubernetesContext: Accessor<string | null>;
selectedKubernetesNamespace: Accessor<string | null>;
selectedCluster: Accessor<string | null>;
@@ -116,7 +117,9 @@ export function useWorkloadFilterOptions(options: WorkloadsWorkloadFilterOptions
viewMode: options.viewMode(),
selectedKubernetesContext: options.selectedKubernetesContext(),
kubernetesContextOptions: kubernetesContextOptions(),
selectedNode: options.selectedNode(),
// An unresolved bookmark is still an active text filter. Keep its
// normal removable chip and filtered-empty copy, not an inventory outage.
selectedNode: options.selectedNode() ?? options.selectedHostHint?.() ?? null,
workloadNodeOptions: workloadNodeOptions(),
onContextChange: (value) => options.setSelectedKubernetesContext(value || null),
onNodeChange: (value) => options.handleNodeSelect(value || null, value ? 'pve' : null),
@@ -134,6 +134,7 @@ export function useWorkloadRouteState(options: WorkloadRouteStateOptions) {
containerRuntime,
selectedPlatform,
selectedNode,
selectedHostHint,
selectedKubernetesContext,
selectedKubernetesNamespace,
selectedCluster,
@@ -13,6 +13,7 @@ import type { WorkloadGuest } from '@/types/workloads';
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
import { getCanonicalWorkloadId } from '@/utils/workloads';
import { getWorkloadMetricHistoryTarget } from '@/utils/workloadMetricHistoryTarget';
import { getWorkloadHistoryNodeId } from './workloadTopology';
import {
buildInfrastructureHistoryChartMap,
@@ -35,6 +36,7 @@ interface WorkloadTableMetricHistoryOptions {
prefetchGuests?: Accessor<readonly WorkloadGuest[]>;
range: Accessor<WorkloadTableMetricHistoryRange>;
selectedNode?: Accessor<string | null | undefined>;
nodes?: Accessor<Node[]>;
// The rows this caller draws while `enabled`. Each estate-wide history is a
// polled summary read, so a reader polls only the one its rows consume:
// guest rows read the workloads summary, node rows the infrastructure
@@ -114,7 +116,10 @@ export function useWorkloadTableMetricHistory(
const selectedNodeScope = createMemo(() => normalizeNodeScope(options.selectedNode?.()));
const workloadHistoryScope = createMemo(() => {
if (options.series !== 'guests' || !options.enabled()) return null;
return buildHistoryScope(options.range(), selectedNodeScope());
return buildHistoryScope(
options.range(),
getWorkloadHistoryNodeId(selectedNodeScope(), options.nodes?.()),
);
});
const infrastructureHistoryScope = createMemo(() =>
options.series === 'nodes' && options.enabled() ? buildHistoryScope(options.range()) : null,
@@ -11,7 +11,7 @@ import { useLocation, useNavigate } from '@solidjs/router';
import type { ViewMode } from '@/types/workloads';
import { createRouteStateNavigateScheduler } from '@/utils/routeStateNavigation';
import { isContainerWorkloadViewMode } from '@/utils/workloads';
import type { WorkloadNodeOption } from './workloadRouteModel';
import { resolveWorkloadNodeHint, type WorkloadNodeOption } from './workloadRouteModel';
import {
parseWorkloadsWorkloadUrlParams,
resolveWorkloadsManagedWorkloadsNavigateTarget,
@@ -75,13 +75,7 @@ export function useWorkloadUrlSync(options: WorkloadsWorkloadUrlSyncOptions) {
if (options.viewMode() === 'pod') return;
const hostHint = options.selectedHostHint();
if (!hostHint || options.selectedNode() !== null) return;
const normalizedHint = hostHint.trim().toLowerCase();
if (!normalizedHint) return;
const workloadNode = options.workloadNodeOptions().find((candidate) => {
const label = candidate.label.toLowerCase();
const value = candidate.value.toLowerCase();
return label === normalizedHint || value === normalizedHint || label.includes(normalizedHint);
});
const workloadNode = resolveWorkloadNodeHint(options.workloadNodeOptions(), hostHint);
if (!workloadNode) return;
options.setSelectedNode(workloadNode.value);
options.setSelectedHostHint(null);
@@ -50,7 +50,7 @@ import {
buildNestedWorkloadContextByGuestId,
type NestedWorkloadContextByGuestId,
} from './nestedWorkloadContext';
import { buildGuestParentNodeMapFromNodes } from './workloadTopology';
import { buildGuestParentNodeMapFromNodes, workloadNodeScopeId } from './workloadTopology';
const WORKLOADS_INFRASTRUCTURE_SOURCES_QUERY =
'type=agent,docker-host,k8s-cluster,k8s-node,pbs,pmg,storage,physical_disk,ceph';
@@ -234,17 +234,24 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
const infrastructureNodes = createMemo<Node[]>(() => {
const merged = new Map<string, Node>();
props.nodes.forEach((node) => merged.set(node.id, node));
const mergeNode = (node: Node) => {
// Two supplied nodes can share a lossy native ID. Keep their source
// tuples separate; only overlay snapshots for the same actual node.
const key = JSON.stringify([
node.id,
workloadNodeScopeId({ instance: node.instance, node: node.name }),
]);
const existing = merged.get(key);
merged.set(key, existing ? { ...existing, ...node } : node);
};
props.nodes.forEach(mergeNode);
if (workloadsEnabled()) {
infrastructureResources()
.filter(isProxmoxNodeResource)
.map(nodeFromResource)
.filter((node): node is Node => Boolean(node))
.forEach((node) => {
const existing = merged.get(node.id);
merged.set(node.id, existing ? { ...existing, ...node } : node);
});
.forEach(mergeNode);
}
return Array.from(merged.values());
@@ -546,6 +553,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
// Grouped node rows carry no metric cells, so this reader never polls
// the infrastructure summary.
series: 'guests',
nodes: infrastructureNodes,
});
const [workloadHistoryHintSeen, setWorkloadHistoryHintSeen] = usePersistentSignal<boolean>(
STORAGE_KEYS.WORKLOADS_HISTORY_HINT_SEEN,
@@ -13,10 +13,30 @@ import {
getWorkloadHostLabel,
workloadHostScopeId,
workloadNodeScopeId,
legacyWorkloadNodeScopeId,
} from './workloadTopology';
export type WorkloadNodeOption = WorkloadsFilterSelectOption;
// A full scope wins over a label. Lossy legacy aliases and broad host hints
// may select a node only when unique; an ambiguous hint stays a text filter.
export const resolveWorkloadNodeHint = (
options: WorkloadNodeOption[],
hint: string,
): WorkloadNodeOption | null => {
const value = hint.trim();
if (!value) return null;
const exact = options.filter((option) => option.value === value);
if (exact.length) return exact.length === 1 ? exact[0] : null;
const legacy = options.filter((option) => legacyWorkloadNodeScopeId(option.value) === value);
if (legacy.length) return legacy.length === 1 ? legacy[0] : null;
const normalized = value.toLowerCase();
const labels = options.filter((option) => option.label.toLowerCase() === normalized);
if (labels.length) return labels.length === 1 ? labels[0] : null;
const partial = options.filter((option) => option.label.toLowerCase().includes(normalized));
return partial.length === 1 ? partial[0] : null;
};
export const deserializeWorkloadViewMode = (raw: unknown): ViewMode => {
if (typeof raw !== 'string') return 'all';
return normalizeWorkloadViewModeParam(raw) ?? 'all';
@@ -27,6 +27,8 @@ import {
getKubernetesContextKey,
getWorkloadHostHintCandidates,
workloadHostScopeId,
workloadNodeScopeId,
resolveWorkloadHostScope,
} from './workloadTopology';
export interface FilterWorkloadsParams {
@@ -174,7 +176,8 @@ export const filterWorkloads = ({
const nodeScope = selectedNode;
if (nodeScope && viewMode !== 'pod') {
guests = guests.filter((g) => workloadHostScopeId(g) === nodeScope);
const resolvedScope = resolveWorkloadHostScope(allGuests, nodeScope);
guests = resolvedScope ? guests.filter((g) => workloadHostScopeId(g) === resolvedScope) : [];
}
const hostHint = (selectedHostHint || '').trim().toLowerCase();
@@ -398,7 +401,11 @@ export const createWorkloadSortComparator = (
export const getWorkloadGroupKey = (guest: WorkloadGuest): string => {
const type = resolveWorkloadType(guest);
if (type === 'vm' || type === 'system-container') {
return `${guest.instance}-${guest.node}`;
const scope = workloadNodeScopeId(guest);
return guest.platformScopes?.includes('vmware-vsphere') &&
!guest.platformScopes.includes('proxmox-pve')
? `vsphere|${scope}`
: scope;
}
const context = guest.contextLabel || guest.node || guest.instance || guest.namespace || guest.id;
return `${type}:${context}`;
@@ -435,6 +442,8 @@ export const getWorkloadGroupLabel = (
const nodeName = (first.node || '').trim();
if (nodeName && cluster) return { type: cluster, name: nodeName };
if (nodeName) return { type: '', name: nodeName };
const instance = (first.instance || '').trim();
if (instance) return { type: '', name: instance };
}
return { type: '', name: context };
};
@@ -31,8 +31,42 @@ const dedupeTrimmed = (values: Array<string | null | undefined>): string[] => {
return result;
};
export const workloadNodeScopeId = (guest: Pick<WorkloadGuest, 'instance' | 'node'>): string =>
`${(guest.instance || '').trim()}-${(guest.node || '').trim()}`;
// UI scope, not a collector/metric ID. Encode each component before joining:
// `lab-east` / `pve1` and `lab` / `east-pve1` are different parents.
export const workloadNodeScopeId = (guest: Pick<WorkloadGuest, 'instance' | 'node'>): string => {
const instance = (guest.instance || '').trim();
const node = (guest.node || '').trim();
return instance || node ? `node|${encodeURIComponent(instance)}|${encodeURIComponent(node)}` : '';
};
export const readWorkloadNodeScope = (scope: string): { instance: string; node: string } | null => {
const parts = scope.split('|');
if (parts.length !== 3 || parts[0] !== 'node') return null;
try {
const result = { instance: decodeURIComponent(parts[1]), node: decodeURIComponent(parts[2]) };
return workloadNodeScopeId(result) === scope ? result : null;
} catch {
return null;
}
};
// Old bookmarks remain usable only when their lossy alias resolves uniquely.
// Never use this alias for grouping or parent attribution.
export const legacyWorkloadNodeScopeId = (scope: string): string => {
const node = readWorkloadNodeScope(scope);
return node ? `${node.instance}-${node.node}` : scope;
};
export const resolveWorkloadHostScope = (guests: WorkloadGuest[], scope: string): string | null => {
const exact = guests.find((guest) => workloadHostScopeId(guest) === scope);
if (exact) return scope;
const candidates = new Set(
guests
.map(workloadHostScopeId)
.filter((candidate) => legacyWorkloadNodeScopeId(candidate) === scope),
);
return candidates.size === 1 ? [...candidates][0] : null;
};
export const getKubernetesContextKey = (guest: WorkloadGuest): string => {
const candidates = [guest.contextLabel, guest.instance, guest.node];
@@ -145,13 +179,20 @@ export const getDiscoveryResourceIdForWorkload = (guest: WorkloadGuest): string
};
export const buildNodeByInstance = (nodes: Node[]): Record<string, Node> => {
const map: Record<string, Node> = {};
nodes.forEach((node) => {
map[node.id] = node;
const instanceNameKey = `${node.instance}-${node.name}`;
if (!map[instanceNameKey]) {
map[instanceNameKey] = node;
const map: Record<string, Node> = Object.create(null);
const ambiguous = new Set<string>();
const add = (key: string, node: Node) => {
if (!key || ambiguous.has(key)) return;
if (map[key] && map[key] !== node) {
delete map[key];
ambiguous.add(key);
} else {
map[key] = node;
}
};
nodes.forEach((node) => {
add(node.id, node);
add(workloadNodeScopeId({ instance: node.instance, node: node.name }), node);
});
return map;
};
@@ -160,25 +201,42 @@ export const buildGuestParentNodeMap = (
guests: WorkloadGuest[],
nodeMap: Record<string, Node>,
): Record<string, Node | undefined> => {
const mapping: Record<string, Node | undefined> = {};
const mapping: Record<string, Node | undefined> = Object.create(null);
guests.forEach((guest) => {
const canonicalGuestId = getCanonicalWorkloadId(guest);
if (guest.id) {
const lastDash = guest.id.lastIndexOf('-');
if (lastDash > 0) {
const nodeId = guest.id.slice(0, lastDash);
if (nodeMap[nodeId]) {
mapping[canonicalGuestId] = nodeMap[nodeId];
return;
}
}
const type = resolveWorkloadType(guest);
if (type !== 'vm' && type !== 'system-container') return;
// A vSphere VM's matching labels are not evidence of a Proxmox parent.
const scopes = getWorkloadPlatformScopes(guest);
if (scopes.length > 0 && !scopes.includes('proxmox-pve')) return;
const parent = nodeMap[workloadNodeScopeId(guest)];
if (parent) {
mapping[canonicalGuestId] = parent;
return;
}
const compositeKey = `${guest.instance}-${guest.node}`;
if (nodeMap[compositeKey]) {
mapping[canonicalGuestId] = nodeMap[compositeKey];
// Legacy IDs may supply an otherwise missing parent, but must not
// contradict source-owned instance/node fields or resolve an ID collision.
if (guest.id) {
const lastDash = guest.id.lastIndexOf('-');
if (
lastDash > 0 &&
Number.isSafeInteger(guest.vmid) &&
guest.vmid > 0 &&
guest.id.slice(lastDash + 1) === String(guest.vmid)
) {
const nodeId = guest.id.slice(0, lastDash);
const candidate = nodeMap[nodeId];
if (
candidate &&
(!guest.instance?.trim() || guest.instance.trim() === candidate.instance?.trim()) &&
(!guest.node?.trim() || guest.node.trim() === candidate.name?.trim())
) {
mapping[canonicalGuestId] = candidate;
}
}
}
});
@@ -189,3 +247,18 @@ export const buildGuestParentNodeMapFromNodes = (
guests: WorkloadGuest[],
nodes: Node[],
): Record<string, Node | undefined> => buildGuestParentNodeMap(guests, buildNodeByInstance(nodes));
// The existing charts API consumes native node IDs, not UI scope tokens. If a
// native ID is shared, omit its first-match filter rather than choosing another
// node. The bounded summary then keeps its existing per-resource chart keys.
export const getWorkloadHistoryNodeId = (scope: string, nodes?: Node[]): string | undefined => {
const tuple = readWorkloadNodeScope(scope);
if (!tuple) return scope || undefined;
if (!nodes) return legacyWorkloadNodeScopeId(scope);
const matches = nodes.filter(
(node) => workloadNodeScopeId({ instance: node.instance, node: node.name }) === scope,
);
if (matches.length !== 1 || !matches[0].id) return undefined;
const id = matches[0].id;
return nodes.filter((node) => node.id === id).length === 1 ? id : undefined;
};
@@ -282,16 +282,16 @@ describe('ProxmoxPageSurface contract', () => {
const revealFrame = vi.spyOn(window, 'requestAnimationFrame');
onShowGuests(node);
expect(mockHandleNodeSelect).toHaveBeenLastCalledWith('lab-pve1', 'pve');
expect(mockHandleNodeSelect).toHaveBeenLastCalledWith('node|lab|pve1', 'pve');
expect(revealFrame).toHaveBeenCalledTimes(1);
revealFrame.mockClear();
mockSelectedNode.mockReturnValue('lab-pve1');
mockSelectedNode.mockReturnValue('node|lab|pve1');
onShowGuests(node);
expect(mockHandleNodeSelect).toHaveBeenLastCalledWith(null, null);
expect(revealFrame).not.toHaveBeenCalled();
mockSelectedNode.mockReturnValue('lab-other');
mockSelectedNode.mockReturnValue('node|lab|other');
onShowGuests(node);
expect(mockHandleNodeSelect).toHaveBeenLastCalledWith('lab-pve1', 'pve');
expect(mockHandleNodeSelect).toHaveBeenLastCalledWith('node|lab|pve1', 'pve');
expect(revealFrame).toHaveBeenCalledTimes(1);
revealFrame.mockRestore();
});