Fix vSphere workload first paint

This commit is contained in:
Pulse Test
2026-08-29 23:36:27 +01:00
parent 6d4ee48000
commit 69f4cbeb74
9 changed files with 291 additions and 59 deletions
@@ -71,14 +71,15 @@ shared filter rail must remove that route value along with the other active
facets; the feature must not replace the shared rail with a page-local select.
Large-estate platform pages must keep one canonical inventory snapshot for the
initial read and explicit refresh path. The Proxmox overview owns its unified
resource request and passes that snapshot into the shared workloads state; the
workloads adapter may map the snapshot into the legacy guest boundary, but must
not issue a second workload inventory request or create a second infrastructure
poll. Refreshing the overview must invalidate that owner snapshot and update
both the node and guest regions from the same result, so a large estate cannot
render contradictory counts or pay duplicate transport and reconciliation
costs.
initial read and explicit refresh path. The Proxmox and VMware vSphere
overviews own their source-scoped unified-resource requests and pass those
snapshots into the shared workloads state; the workloads adapter may map the
snapshot into the legacy guest boundary, but must not issue a second workload
inventory request or create a second infrastructure poll. Refreshing an
overview must invalidate that owner snapshot and update both the host/node and
guest regions from the same result, so a large estate cannot render
contradictory counts, flash a false empty workload state, or pay duplicate
transport and reconciliation costs.
The overview's bounded structural summary remains ahead of its long virtualized
inventory at desktop and narrow widths. In particular, Proxmox must show up to
six phone rows or twelve larger-layout rows before the guest list instead of
@@ -1360,6 +1360,19 @@ an interim unknown or never-backed-up state while waiting for a WebSocket
tick. This correction adds no request, subscription, retry, or resource scan;
`useUnifiedResources.test.ts` and `useWorkloads.test.ts` pin the boundary.
### vSphere owner snapshots avoid duplicate estate enumeration
The VMware vSphere overview owns one `type=agent,vm,storage,network` resource
query scoped to `source=vmware-vsphere` and passes the resulting canonical
snapshot into its embedded Workloads state. Host and VM regions therefore
paint from one paged generation and explicit refresh path; the Workloads owner
must not launch the generic all-platform workload pagination or its generic
infrastructure snapshot. The shared unified-resource hook projects the same
type-and-source filter from canonical WebSocket state, so this request
reduction adds no polling fallback or freshness regression. Contract tests pin
the request shape, realtime projection, snapshot injection, and refresh
handoff.
### Command-session liveness lookup stays bounded and in-memory
The connections ledger's command-channel liveness check
@@ -25,6 +25,15 @@ The shared `internal/api/` helper-binary download route is isolated from
storage and recovery semantics. `/download/pulse-agent-helper` serves a signed
Linux runtime artifact only; it cannot read, mutate, restore, export, or select
storage state, recovery timelines, backup providers, or repository scope.
The parallel `/download/pulse-agent-runner` artifact route and
`/api/agents/action-runner/credential` issuance route are also adjacent
API/agent-lifecycle boundaries. A signed runner binary or host-bound typed
action credential is not a backup, snapshot, recovery point, restore grant,
retention decision, or repository selector. Safe-profile inspect/apply/rollback
preserves collector identity and installation files only; it must not rewrite
storage/recovery evidence or reinterpret a restored legacy collector profile as
recovery success. Typed `host.storage_cleanup` remains governed remediation,
not storage-recovery authority inferred from the credential itself.
First-run authentication always writes the canonical `.env` persistence
artifact before runtime state changes. Root systemd installation may also
@@ -53,6 +62,14 @@ workload or infrastructure inventory request. Backup and recovery tables keep
their domain-specific evidence requests, while an overview refresh must update
all overview regions from the same owner snapshot rather than mixing reads
from different inventory generations.
The vSphere overview follows the same read-side ownership rule for its host,
VM, datastore, and network regions. Its canonical snapshot is constrained to
the `vmware-vsphere` source at the resource API boundary, and the embedded
workloads adapter consumes that page-owned snapshot instead of issuing a
second generic inventory request. Source aliases used to project a canonical
websocket snapshot are transport filtering only: a VMware datastore remains
descriptive inventory and does not gain backup, protection, recovery-point,
or restore semantics from that projection.
The physical-disks surface consumes every page in the server-declared unified
resource result, scopes same-named Proxmox nodes by instance, and never hides a
disk behind a client-side page ceiling. Search includes vendor, WWN, transport,
@@ -2046,6 +2046,14 @@ without waiting for a later WebSocket merge; REST and WebSocket delivery must
not disagree about already-authoritative fields. The boundary is pinned by
`useUnifiedResources.test.ts` and `useWorkloads.test.ts`.
Source-scoped platform owners remain canonical realtime consumers. The shared
frontend hook locally projects supported `type` plus `source` filters from the
canonical WebSocket snapshot, including the public `vmware-vsphere` alias for
the raw `vmware` source, rather than degrading a source-scoped page to periodic
REST refreshes. VMware Overview passes that same source-scoped snapshot into
the embedded Workloads state, so hosts and VMs share one inventory generation
and one explicit refresh path.
### Agent libvirt domains use a provider-neutral VM facet
The registry projects a host's validated libvirt domains as `vm` resources
+16 -25
View File
@@ -1,31 +1,23 @@
{
"version": 1,
"base_sha": "6453593b4b984eaa7d1d40727f43e12528212dd7",
"verified_at": "2026-08-29T21:11:53Z",
"base_sha": "6d4ee48000f9266d161af000f88d9d1f10042328",
"verified_at": "2026-08-29T22:35:09Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/agentDiagnostics.ts",
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx",
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts",
"frontend-modern/src/components/Settings/useNodeModalState.ts"
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx",
"frontend-modern/src/hooks/useUnifiedResources.ts"
],
"content_sha256": {
"frontend-modern/src/api/agentDiagnostics.ts": "0bf0f3d0854ebd03ab9d80ccef0e39655aeeb2bd16d18901b527ba3955a086bb",
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx": "0fe6f4cb3f10adbf13306eaa73b390e24b4d586ec71479cb7c8494ce158e6302",
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx": "5aa559093039bc18785b4df88c2c955d67465b969b85979bc2a836a7d64d6dd0",
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "d352500b9f804111fe07602aa3716e9d1ff1a30ac5fe3b1d6a7d0587c36c6171",
"frontend-modern/src/components/Settings/useNodeModalState.ts": "0f198074f88ef8d55a0fdc5142bacee4a39b572b12f94f7ed88a0fa10724c067"
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "659851ff4111f53d15f74d7959ab9e3f21683eecf0e5c55d91e477f47ad93540",
"frontend-modern/src/hooks/useUnifiedResources.ts": "0b5cca6992ae17575e5f689268e152cbdd47f61b3169043cec314b27c11d8c97"
},
"routes": [
"/settings/infrastructure?add=docker",
"/settings/infrastructure?add=pve",
"/settings/infrastructure/agent-doctor"
"/vmware/overview"
],
"viewports": [
{
"width": 1440,
"height": 1000
"width": 1280,
"height": 720
},
{
"width": 390,
@@ -33,15 +25,14 @@
}
],
"states": [
"mock-backed Docker installer before token generation, including the transitional Docker-in-LXC trust notice",
"Proxmox VE onboarding with the API-first strategy selected",
"Proxmox VE onboarding with Host Telemetry Agent selected and monitoring-only credential guidance visible",
"Agent Doctor route with the current fleet diagnostics loaded"
"vSphere overview with seven ESXi hosts and eighteen VMs loaded from one source-scoped canonical inventory snapshot",
"desktop host table, workload filters, grouped VM table, and live-connection status",
"narrow host and VM table projections with the responsive filter control and fixed mobile navigation"
],
"interactions": [
"opened the Docker onboarding deep link and verified monitoring-first copy, token authorization guidance, and install handoff at desktop width",
"narrowed the Docker onboarding dialog to 390 pixels and verified the title, Docker-in-LXC notice, and token action remained visible",
"opened Proxmox VE onboarding, switched from Connect via API to Host Telemetry Agent, and verified the monitoring-only command-authority boundary copy",
"opened Agent Doctor and verified the routed diagnostics surface loaded without console-blocking UI failures"
"reloaded the vSphere overview with browser request tracing enabled and verified the only resource-list request was the page-owned type-and-source-scoped snapshot",
"verified the embedded VM filter and table populated from the owner snapshot without a generic workload or infrastructure resource request",
"set a 390 by 844 viewport and verified the host rows, VM search, Filters action, grouped VM rows, and mobile navigation remained visible and usable",
"reset the temporary viewport override to the browser default after responsive verification"
]
}
@@ -40,6 +40,7 @@ import {
} from '@/components/Workloads/workloadMetricHistoryModel';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { STORAGE_KEYS } from '@/utils/localStorage';
import type { Resource } from '@/types/resource';
import { VsphereHostsTable } from './VsphereHostsTable';
import {
VMWARE_TAB_SPECS,
@@ -57,7 +58,7 @@ import { VsphereNetworksTable } from './VsphereNetworksTable';
// as canonical `vm`, datastores as canonical `storage`, and vCenter networks
// as canonical `network`; provider-native topology stays in VMware metadata
// under those shared resources.
const VMWARE_RESOURCE_QUERY = 'type=agent,vm,storage,network';
const VMWARE_RESOURCE_QUERY = 'type=agent,vm,storage,network&source=vmware-vsphere';
const VALID_TABS = new Set<VmwarePageTabId>(VMWARE_TAB_SPECS.map((tab) => tab.id));
const VMWARE_PLATFORM_FILTER = 'vmware-vsphere';
@@ -276,6 +277,10 @@ export function VmwarePageSurface() {
metricHistoryRange={metricHistoryRange}
setMetricHistoryRange={setMetricHistoryRange}
inventorySourcesQuery={inventorySources}
resourceSnapshot={() =>
loading() && model().resources.length === 0 ? undefined : model().resources
}
resourceSnapshotRefetch={() => refetch()}
/>
</div>
</Show>
@@ -348,6 +353,8 @@ interface VmwareOverviewProps {
metricHistoryRange: Accessor<WorkloadTableMetricHistoryRange>;
setMetricHistoryRange: (value: WorkloadTableMetricHistoryRange) => void;
inventorySourcesQuery: WorkloadsInventorySourcesQuery;
resourceSnapshot: Accessor<Resource[] | undefined>;
resourceSnapshotRefetch: () => Promise<unknown>;
}
function VmwareOverview(props: VmwareOverviewProps) {
@@ -356,6 +363,8 @@ function VmwareOverview(props: VmwareOverviewProps) {
containers: [],
nodes: [],
useWorkloads: true,
resourceSnapshot: props.resourceSnapshot,
resourceSnapshotRefetch: props.resourceSnapshotRefetch,
forcedPlatform: VMWARE_PLATFORM_FILTER,
forcedViewMode: 'vm',
suppressPlatformFilter: true,
@@ -8,6 +8,7 @@ const mockPathname = vi.hoisted(() => vi.fn(() => '/vmware/overview'));
const mockVersionInfo = vi.hoisted(() => vi.fn());
const mockGetGlobalTimeline = vi.hoisted(() => vi.fn());
const mockListInventorySources = vi.hoisted(() => vi.fn());
const mockUseWorkloadsState = vi.hoisted(() => vi.fn());
const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource =>
({
@@ -21,13 +22,18 @@ const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'
...resource,
}) as Resource;
const setResources = (resources: Resource[]) => {
const setResources = (
resources: Resource[],
options: { loading?: boolean; refetch?: ReturnType<typeof vi.fn> } = {},
) => {
const refetch = options.refetch ?? vi.fn().mockResolvedValue(resources);
mockUseUnifiedResources.mockReturnValue({
resources: () => resources,
loading: () => false,
loading: () => options.loading ?? false,
error: () => null,
refetch: vi.fn(),
refetch,
});
return refetch;
};
vi.mock('@/hooks/useUnifiedResources', () => ({
@@ -73,13 +79,7 @@ vi.mock('@/components/Workloads/WorkloadsSurface', () => ({
}));
vi.mock('@/components/Workloads/useWorkloadsState', () => ({
useWorkloadsState: () => ({
surfaceConnected: () => false,
surfaceInitialDataReceived: () => false,
allGuests: () => [],
search: () => '',
setSearch: vi.fn(),
}),
useWorkloadsState: (...args: unknown[]) => mockUseWorkloadsState(...args),
}));
vi.mock('@/features/platformPage/sharedPlatformPage', () => ({
@@ -126,6 +126,13 @@ describe('VmwarePageSurface contract', () => {
mockVersionInfo.mockReturnValue(null);
mockGetGlobalTimeline.mockResolvedValue({ recentChanges: [] });
mockListInventorySources.mockResolvedValue({ sources: [] });
mockUseWorkloadsState.mockReturnValue({
surfaceConnected: () => false,
surfaceInitialDataReceived: () => false,
allGuests: () => [],
search: () => '',
setSearch: vi.fn(),
});
setResources([]);
});
@@ -171,6 +178,49 @@ describe('VmwarePageSurface contract', () => {
);
});
it('reuses the source-scoped page snapshot for the vSphere workload surface', async () => {
const refetch = vi.fn().mockResolvedValue([]);
const resources = [
makeResource({
id: 'esxi-host-1',
type: 'agent',
vmware: { entityType: 'host', managedObjectId: 'host-1' },
}),
makeResource({
id: 'vm-app-01',
type: 'vm',
vmware: { entityType: 'vm', managedObjectId: 'vm-1' },
}),
makeResource({
id: 'other-platform-vm',
type: 'vm',
platformType: 'proxmox-pve',
vmware: undefined,
}),
];
setResources(resources, { refetch });
render(() => <VmwarePageSurface />);
expect(mockUseUnifiedResources).toHaveBeenCalledWith(
expect.objectContaining({
query: 'type=agent,vm,storage,network&source=vmware-vsphere',
cacheKey: 'vmware-workspace',
}),
);
const stateOptions = mockUseWorkloadsState.mock.calls[0]?.[0] as {
resourceSnapshot: () => Resource[] | undefined;
resourceSnapshotRefetch: () => Promise<unknown>;
};
expect(stateOptions.resourceSnapshot()?.map((resource) => resource.id)).toEqual([
'esxi-host-1',
'vm-app-01',
]);
await stateOptions.resourceSnapshotRefetch();
expect(refetch).toHaveBeenCalledTimes(1);
});
it('does not treat vSphere ESXi API host resources as Pulse agent update targets', () => {
mockVersionInfo.mockReturnValue({
version: 'v6.0.0-rc.6',
@@ -663,6 +663,96 @@ describe('useUnifiedResources', () => {
dispose();
});
it('keeps source filters in the canonical resource request', async () => {
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources({
query: 'type=agent,vm,storage,network&source=vmware-vsphere',
cacheKey: 'vmware-overview-contract',
});
});
await flushAsync();
expect(apiFetchMock).toHaveBeenCalledTimes(1);
expect(apiFetchMock).toHaveBeenNthCalledWith(
1,
'/api/resources?type=agent%2Cvm%2Cstorage%2Cnetwork&source=vmware-vsphere&page=1&limit=100',
{ cache: 'no-store' },
);
await waitForResourceCount(() => result!.resources().length);
dispose();
});
it('projects source-scoped queries from the canonical websocket snapshot', async () => {
const vmwareVM = createWsResource({
id: 'vmware-vm-1',
type: 'vm',
name: 'vmware-vm-1',
displayName: 'vmware-vm-1',
platformId: 'vmware-vm-1',
platformType: 'vmware-vsphere',
sourceType: 'api',
sources: ['vmware'],
});
const proxmoxVM = createWsResource({
id: 'proxmox-vm-1',
type: 'vm',
name: 'proxmox-vm-1',
displayName: 'proxmox-vm-1',
platformId: 'proxmox-vm-1',
platformType: 'proxmox-pve',
sourceType: 'api',
sources: ['proxmox'],
});
batch(() => {
setWsState('resources', [vmwareVM, proxmoxVM]);
setWsState('lastUpdate', 1738843200100);
setWsResourceChange({
version: 1738843200100,
changedIds: new Set([vmwareVM.id, proxmoxVM.id]),
});
});
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources({
query: 'type=agent,vm&source=vmware-vsphere',
cacheKey: 'vmware-websocket-projection',
initialHydration: 'prefer-ws',
});
});
await waitForValue(() => result!.resources().length, 1);
expect(result!.resources().map((resource) => resource.id)).toEqual(['vmware-vm-1']);
expect(apiFetchMock).not.toHaveBeenCalled();
dispose();
});
it('keeps unsupported source filters on the canonical REST fallback path', async () => {
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources({
query: 'source=future-provider',
cacheKey: 'unsupported-source-filter',
initialHydration: 'prefer-ws',
});
});
await flushAsync();
expect(apiFetchMock).toHaveBeenCalledTimes(1);
await waitForResourceCount(() => result!.resources().length);
dispose();
});
it('loads physical disks beyond the first resource page without dropping wide-node inventory', async () => {
const firstPage = Array.from({ length: 100 }, (_, index) => ({
...v2Resource,
@@ -1130,7 +1130,36 @@ const enrichCanonicalAllResourcesCache = (
setUnifiedResourcesCache(entry, enriched);
};
const parseUnifiedResourcesTypeFilter = (query: string): Set<ResourceType> | null => {
type UnifiedResourcesLocalFilter = {
types: Set<ResourceType>;
sources: Set<string>;
};
const normalizeUnifiedResourceSource = (source: string): string => {
const normalized = source.trim().toLowerCase();
switch (normalized) {
case 'k8s':
return 'kubernetes';
case 'vmware-vsphere':
return 'vmware';
default:
return normalized;
}
};
const SUPPORTED_UNIFIED_RESOURCE_SOURCES = new Set([
'proxmox',
'agent',
'docker',
'pbs',
'pmg',
'kubernetes',
'truenas',
'vmware',
'availability',
]);
const parseUnifiedResourcesLocalFilter = (query: string): UnifiedResourcesLocalFilter | null => {
const normalizedQuery = normalizeUnifiedResourcesQuery(query);
if (normalizedQuery === '') {
return null;
@@ -1138,9 +1167,10 @@ const parseUnifiedResourcesTypeFilter = (query: string): Set<ResourceType> | nul
const params = new URLSearchParams(normalizedQuery);
const types = new Set<ResourceType>();
const sources = new Set<string>();
for (const [key, value] of params.entries()) {
if (key !== 'type') {
if (key !== 'type' && key !== 'source') {
return null;
}
@@ -1149,26 +1179,50 @@ const parseUnifiedResourcesTypeFilter = (query: string): Set<ResourceType> | nul
.map((candidate) => asTrimmedString(candidate))
.filter((candidate): candidate is string => candidate !== undefined)
.forEach((candidate) => {
types.add(resolveType(candidate));
if (key === 'type') {
types.add(resolveType(candidate));
return;
}
const normalizedSource = normalizeUnifiedResourceSource(candidate);
if (SUPPORTED_UNIFIED_RESOURCE_SOURCES.has(normalizedSource)) {
sources.add(normalizedSource);
}
});
}
return types.size > 0 ? types : null;
return types.size > 0 || sources.size > 0 ? { types, sources } : null;
};
const filterCanonicalUnifiedResources = (
resources: Resource[],
query: string,
typeFilter: Set<ResourceType> | null,
localFilter: UnifiedResourcesLocalFilter | null,
): Resource[] | null => {
const normalizedQuery = normalizeUnifiedResourcesQuery(query);
if (normalizedQuery === '') {
return resources;
}
if (!typeFilter) {
if (!localFilter) {
return null;
}
return resources.filter((resource) => typeFilter.has(resolveType(resource.type)));
return resources.filter((resource) => {
if (localFilter.types.size > 0 && !localFilter.types.has(resolveType(resource.type))) {
return false;
}
if (localFilter.sources.size === 0) {
return true;
}
const platformSources = resource.platformData?.sources;
const resourceSources = [
...(resource.sources ?? []),
...(Array.isArray(platformSources)
? platformSources.filter((source): source is string => typeof source === 'string')
: []),
];
return resourceSources.some((source) =>
localFilter.sources.has(normalizeUnifiedResourceSource(source)),
);
});
};
/**
@@ -1213,8 +1267,8 @@ const seedUnifiedResourcesCacheFromAllResources = (
return entry;
}
const typeFilter = parseUnifiedResourcesTypeFilter(query);
if (!typeFilter) {
const localFilter = parseUnifiedResourcesLocalFilter(query);
if (!localFilter) {
return entry;
}
@@ -1225,9 +1279,8 @@ const seedUnifiedResourcesCacheFromAllResources = (
return entry;
}
entry.resources = allResourcesEntry.resources.filter((resource) =>
typeFilter.has(resolveType(resource.type)),
);
entry.resources =
filterCanonicalUnifiedResources(allResourcesEntry.resources, query, localFilter) ?? [];
entry.policyPosture = allResourcesEntry.policyPosture;
entry.aggregations = allResourcesEntry.aggregations;
entry.hasSnapshot = true;
@@ -1370,8 +1423,8 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
const initialHydration = options?.initialHydration ?? 'immediate';
const enabled = options?.enabled ?? (() => true);
const realtimeEnabled = options?.realtimeEnabled ?? enabled;
const typeFilter = parseUnifiedResourcesTypeFilter(query);
const supportsCanonicalWsHydration = query === '' || typeFilter !== null;
const localFilter = parseUnifiedResourcesLocalFilter(query);
const supportsCanonicalWsHydration = query === '' || localFilter !== null;
const prefersWsInitialHydration =
(initialHydration === 'prefer-ws' || initialHydration === 'prefer-ws-then-rest') &&
supportsCanonicalWsHydration;
@@ -1763,7 +1816,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
const projectedResources = filterCanonicalUnifiedResources(
mergedWsResources,
query,
typeFilter,
localFilter,
);
const now = Date.now();
clearInitialHydrationTimeout();