mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
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:
@@ -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;
|
||||
|
||||
+86
@@ -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');
|
||||
|
||||
+34
-1
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
+35
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -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)' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
+3
-3
@@ -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', () => {
|
||||
|
||||
+2
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+71
@@ -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();
|
||||
});
|
||||
});
|
||||
+4
-4
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user