From 0cebf6f51f252a1fcccf18b8bd8b0e17cd33d587 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:05:50 +0100 Subject: [PATCH] Require page-owned state on WorkloadsSurface WorkloadsSurface built its own workloads state when no `state` prop arrived, and its props type extended every useWorkloadsState option. Both production mounts, the Proxmox and vSphere overviews, already build that state themselves (it also drives their one WorkloadsFilter) and pass it in, so the fallback only ran in tests. The mounts still passed vms, containers, nodes and useWorkloads, and Proxmox passed its app-container exclusion, all of which the surface ignored once it had a state. The surface now requires `state` and accepts only presentation props: forcedPlatform, forcedViewMode, the empty-state copy and tableTitle. The hook's options type is renamed WorkloadsStateOptions so it no longer shares a name with the surface's props. The pages drop the ignored props. Tests that leaned on the fallback build state through a page-owned harness, as the pages do. The Proxmox page contract test pins the exact props its mount passes. The overview guardrails check that each surface repeats the platform and view scope its page gave the state, and that the surface imports only the state's type, so it cannot call the hook again. Because the surface no longer imports the hook at runtime, Rollup now names the chunk the Proxmox and vSphere pages share after its last module, useWorkloadsState, and the bundle check reported the old WorkloadsSurface key as missing. The chunk is the same code at the same size, so the budget key follows it with its baseline and limit unchanged. --- .../subsystems/frontend-primitives.md | 3 +- .../subsystems/performance-and-scalability.md | 25 ++++++-- .../internal/subsystems/storage-recovery.md | 6 +- .../internal/subsystems/unified-resources.md | 5 +- frontend-modern/.bundlesize.json | 2 +- .../15e69fc7d9247236.json | 46 ++++++++++++++ .../components/Workloads/WorkloadsSurface.tsx | 21 +++---- .../__tests__/WorkloadsSurface.k8s.test.tsx | 45 +++++++------- ...loadsSurface.performance.contract.test.tsx | 60 +++++++++++-------- .../components/Workloads/useWorkloadsState.ts | 4 +- .../platformOverviewLayout.guardrails.test.ts | 27 +++++++-- .../features/proxmox/ProxmoxPageSurface.tsx | 6 -- .../__tests__/ProxmoxBackupsTable.test.tsx | 4 -- .../ProxmoxPageSurface.contract.test.tsx | 34 ++++++++++- .../src/features/vmware/VmwarePageSurface.tsx | 4 -- 15 files changed, 207 insertions(+), 85 deletions(-) create mode 100644 frontend-modern/browser-verification/15e69fc7d9247236.json diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4732537065..39f74e7e93 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -3538,7 +3538,8 @@ default` instead of fusing provider and badge text such as panels. Three tabs embed a cross-platform canonical surface with the platform scope locked: the Proxmox and vSphere Overview tabs embed `WorkloadsSurface` with `forcedPlatform` (the page owns the workloads - state and the one `WorkloadsFilter` toolbar), and the Proxmox Storage tab + state and the one `WorkloadsFilter` toolbar; the surface requires that + state and takes only presentation props besides it), and the Proxmox Storage tab embeds `StorageSurface` with `forcedSourceFilter`. Those tabs must keep their guests, VMs, and storage rows on those embeds rather than a platform-local copy. Neither surface has an `embedded` or `tableOnly` diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 351ec00504..c93a73d8cd 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1342,6 +1342,14 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. (`diskPresentation.ts`) loads with an open agent guest's drawer instead of joining the WorkloadsSurface chunk. `WorkloadsSurface.performance.contract.test.tsx` pins the dynamic import. + Prose and tests call the table chunk the Proxmox and vSphere pages share + the WorkloadsSurface chunk, but the build names it after its last module + in execution order, and `WorkloadsSurface.tsx` imports only the state's + type, so `frontend-modern/.bundlesize.json` tracks it as + `useWorkloadsState` with the same limit. A `MISSING` report only says the + name is absent, which a split or a removal also causes: when inspection + confirms the same shared chunk merely changed its name, rename the key + and leave its baseline alone. Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`. A current metric may remain visible in a chart legend only when labelled `current`; it must never be expanded into synthetic timestamps or a flat @@ -1838,7 +1846,16 @@ shell clickable behind another overlay. display, and columns on the shared `FilterBar`) from that state, directly above the surface, and hands the same state to `WorkloadsSurface` through its `state` prop. The surface renders no filter row of its own, so the - page never stacks two toolbars wired to one state. + page never stacks two toolbars wired to one state. That `state` prop is + required and the surface never builds a workloads state itself: it + accepts only `state` plus presentation props (`forcedPlatform`, + `forcedViewMode`, the empty-state title and description, and + `tableTitle`). Every `useWorkloadsState` option + (`WorkloadsStateOptions` in `useWorkloadsState.ts`) belongs on the + page's own state call. The surface repeats only `forcedPlatform` and + `forcedViewMode` and declares none of the hook-only options, so a mount + that names one as an attribute fails type-checking. It imports only the + state's type, so it can never become a second state owner on the page. Proxmox also passes that state's search, plus its page-owned metric mode and history range, to the nodes table above the guests, so one toolbar drives both tables. `WorkloadsSurface` has no `tableOnly` mode, no @@ -2119,9 +2136,9 @@ changes without deduplicating requests. A reader therefore must not poll a summary its rows do not render. In Trends the vSphere overview polls only the workloads history, and the Proxmox overview polls the workloads history and the infrastructure summary once -each. Standalone -WorkloadsSurface callers (no override props) keep the original -persistent-signal-backed behavior. +each. A `useWorkloadsState` owner that omits these overrides keeps the +original persistent-signal-backed behavior; `WorkloadsSurface` builds no +state of its own, so it always renders the owner its page passes. Every toolbar that controls a `WorkloadsSurface` consumes `getWorkloadsMetricFilterProps` as one atomic binding. Those toolbars are the page-owned `WorkloadsFilter` instances on the Proxmox and vSphere overviews, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 6c77bc65ef..ae2c4364ac 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1035,7 +1035,11 @@ The workload filter belongs immediately before the workload table it controls, after the node preview, and its committed search term must also flow into the node table. `ProxmoxPageSurface` renders that one `WorkloadsFilter` itself and passes the embedded `WorkloadsSurface` the same workload state; the surface -renders no filter row of its own. The node table must use the shared Proxmox search model so a +renders no filter row of its own. That state is the surface's only data input: +the app-container exclusion and its nested-context cue +(`excludedWorkloadTypes`, `showNestedExcludedWorkloads`) live on the page's +`useWorkloadsState` call, and the surface mount carries only the state, the +locked platform, the empty-state copy and the `Guests` title. The node table must use the shared Proxmox search model so a matching guest retains its owning node while unrelated nodes disappear; a node search must likewise retain the directly matching node. The table still receives the real already-loaded PVE node collection as its source, so the diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 710e2f90b5..8f687c6ad6 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -3249,7 +3249,10 @@ Its page-owned workload toolbar, the only one on the page because the embedded Trends, Details, History, range, and first-use discovery contract as the Proxmox overview, the only other `WorkloadsSurface` consumer. The page must not select only display and range props while silently dropping row-hover state from that shared resource -projection. +projection. The vSphere surface mount carries only that page-owned state, the +locked platform, the `vm` view and its empty-state copy; every resource and +workload option stays on the page's `useWorkloadsState` call, the surface's +only data input. Docker and Kubernetes page owners use the same source-aware projection for their complete provider type families. Their linked agent rows remain visible because canonical merge preserves the provider source beside the agent source; diff --git a/frontend-modern/.bundlesize.json b/frontend-modern/.bundlesize.json index 67b9ad8ea6..99f102778b 100644 --- a/frontend-modern/.bundlesize.json +++ b/frontend-modern/.bundlesize.json @@ -28,7 +28,7 @@ "AIIntelligence": { "baselineGzip": 63097 }, - "WorkloadsSurface": { + "useWorkloadsState": { "baselineGzip": 62538 }, "Docker": { diff --git a/frontend-modern/browser-verification/15e69fc7d9247236.json b/frontend-modern/browser-verification/15e69fc7d9247236.json new file mode 100644 index 0000000000..82d821da8f --- /dev/null +++ b/frontend-modern/browser-verification/15e69fc7d9247236.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "base_sha": "9a32396fe2c806c6ed1856b74a8fa7a03f050984", + "verified_at": "2026-10-10T11:53:55Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx", + "frontend-modern/src/features/vmware/VmwarePageSurface.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "e30d3fb2517361aae2d3e4f071a6d0b6319caea856351fdfd99c5c071c7f74cb", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "9689d48b35b220c814eaeac7b1d51b2873b25a11372505904873ea966811c516", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "358b94a95910dff33fbf444117f69fd52f7b680f2ac20713e1e6b31dac6685e0", + "frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "299336a8b019690f735557cbde005d63382dc177074f2e4c235d0b34e363eb26" + }, + "routes": [ + "/proxmox/overview", + "/vmware/overview" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Proxmox overview Guests table populated on the mock estate (929 guests, 8 host groups and 140 rendered rows at 1440, 2 groups and 36 rows at 390) with the Guests title and count", + "vSphere overview VMs table populated (7 host groups, 18 rendered rows, no table title) at both widths", + "Filtered-empty table after a no-match search on both overviews: same copy, Guests 0 title on Proxmox, no inventory-issue list, no action links", + "Back to top control after scrolling the vSphere overviews at both widths", + "Guest inline drawer open on both overviews at both widths", + "Exactly one search toolbar per page and none inside the workloads surface" + ], + "interactions": [ + "Typed a no-match term into each overview search box, read the empty state, cleared it and saw the rows return", + "Expanded the first guest row (click at 1440, programmatic click at 390 because a backup-status button overlaps the chevron in both builds) and pressed Escape", + "Scrolled each overview and used Back to top", + "Ran the same probe twice against a build of origin/main 9a32396fe2 and twice against this change on pulse-dev: 90 of 90 readings identical in both pairs; an unminified build of each differs only by the renamed shared chunk, three lines inside it and the removed mount props in Proxmox.js and Vmware.js" + ] +} diff --git a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx index 6a8c84dccc..66299c985b 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx @@ -8,22 +8,23 @@ import { ActionIconButton, ButtonLink } from '@/components/shared/Button'; import { TableCard } from '@/components/shared/TableCard'; import { TableCardHeader } from '@/components/shared/TableCardHeader'; import { sessionCanReadInfrastructureSettings } from '@/stores/sessionSettingsCapabilities'; +import type { ViewMode } from '@/types/workloads'; import { getWorkloadsNoInventoryState } from '@/utils/workloadEmptyStatePresentation'; import { DEFAULT_WORKLOADS_VIEW_MODE, hasActiveWorkloadsFilters } from './workloadsFilterModel'; import { WorkloadsTable } from './WorkloadsTable'; import { WorkloadWebLinksAction } from './WorkloadWebLinksAction'; import type { WorkloadInventorySourceIssue } from './workloadInventorySourceIssues'; -import { - useWorkloadsState, - type WorkloadsState, - type WorkloadsSurfaceProps, -} from './useWorkloadsState'; -export type { WorkloadsSurfaceProps } from './useWorkloadsState'; +import type { WorkloadsState } from './useWorkloadsState'; -interface WorkloadsSurfaceComponentProps extends WorkloadsSurfaceProps { +// The owning platform page builds the one workload state (it also feeds the +// page's WorkloadsFilter) and passes it here; the surface only renders it. +// forcedPlatform and forcedViewMode repeat the scope the page gave that state. +export interface WorkloadsSurfaceProps { + state: WorkloadsState; + forcedPlatform?: string; + forcedViewMode?: ViewMode; emptyStateDescription?: string; emptyStateTitle?: string; - state?: WorkloadsState; tableTitle?: JSX.Element; } @@ -69,8 +70,8 @@ function WorkloadInventoryIssueList(props: { issues: readonly WorkloadInventoryS ); } -export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) { - const state = props.state ?? useWorkloadsState(props); +export function WorkloadsSurface(props: WorkloadsSurfaceProps) { + const state = props.state; const visibleInventoryIssues = createMemo(() => { // A filtered-empty table is not an inventory outage. Once inventory is // present, unrelated source failures cannot explain an empty selection. diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx index 7f23bb9262..a8b2d4a9e4 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@solidjs/testing-library'; import { createEffect } from 'solid-js'; -import { WorkloadsSurface } from '../WorkloadsSurface'; -import { useWorkloadsState, type WorkloadsSurfaceProps } from '../useWorkloadsState'; +import { WorkloadsSurface, type WorkloadsSurfaceProps } from '../WorkloadsSurface'; +import { useWorkloadsState, type WorkloadsStateOptions } from '../useWorkloadsState'; import type { WorkloadsToolbarFilterConfig } from '../workloadsFilterModel'; import type { State } from '@/types/api'; @@ -170,12 +170,23 @@ vi.mock('../GuestRow', () => { // Platform pages own the workload toolbar: they build the state, pass it to // the surface, and hand state.hostFilterConfig() to their WorkloadsFilter. // This harness reads that same config. -function PageOwnedWorkloadsSurface(props: WorkloadsSurfaceProps) { +function PageOwnedWorkloadsSurface( + props: WorkloadsStateOptions & Omit, +) { const state = useWorkloadsState(props); createEffect(() => { lastHostFilter = state.hostFilterConfig(); }); - return ; + return ( + + ); } describe('Workloads pod workloads integration', () => { @@ -257,7 +268,7 @@ describe('Workloads pod workloads integration', () => { ]; render(() => ( - { it('uses filtered-empty copy for table-only surfaces when filters remove every workload', () => { render(() => ( { it('keeps page-owned table-only empty copy when no operator filters are active', () => { render(() => ( { mockLocationSearch = '?type=pod&resource=legacy:pve1:101'; const { getByText } = render(() => ( - + )); await waitFor(() => { @@ -436,7 +439,7 @@ describe('Workloads pod workloads integration', () => { ]; const { getByText, queryByText } = render(() => ( - + )); await waitFor(() => { @@ -585,7 +588,7 @@ describe('Workloads pod workloads integration', () => { ]; const { getByText, queryByText } = render(() => ( - + )); await waitFor(() => { @@ -649,7 +652,7 @@ describe('Workloads pod workloads integration', () => { ]; const { getByText, queryByText } = render(() => ( - + )); await waitFor(() => { @@ -741,7 +744,7 @@ describe('Workloads pod workloads integration', () => { ]; const { queryByText, getByTestId } = render(() => ( - + )); await waitFor(() => { @@ -813,7 +816,7 @@ describe('Workloads pod workloads integration', () => { ]; const { getByText, getAllByText } = render(() => ( - + )); await waitFor(() => { @@ -830,7 +833,7 @@ describe('Workloads pod workloads integration', () => { mockLocationSearch = '?type=all'; mockWorkloads = []; - render(() => ); + render(() => ); await waitFor(() => { expect(navigateSpy).toHaveBeenCalled(); @@ -846,7 +849,7 @@ describe('Workloads pod workloads integration', () => { mockLocationSearch = '?type=all&context=cluster-a'; mockWorkloads = []; - render(() => ); + render(() => ); await waitFor(() => { expect(navigateSpy).toHaveBeenCalled(); diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx index 325efab808..5b140d3122 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx @@ -4,8 +4,8 @@ import { createSignal, onCleanup, onMount } from 'solid-js'; 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 { WorkloadsSurface, type WorkloadsSurfaceProps } from '../WorkloadsSurface'; +import { useWorkloadsState, type WorkloadsStateOptions } from '../useWorkloadsState'; import type { Node } from '@/types/api'; import { WORKLOAD_TABLE_CONTAINER_TABLET_WIDTH, getGuestColumnStyle } from '../guestRowModel'; import workloadsSource from '../WorkloadsSurface.tsx?raw'; @@ -391,6 +391,23 @@ const flushEffects = async () => { await Promise.resolve(); }; +// Platform pages build the one workload state and pass it to the surface. +function PageOwnedWorkloadsSurface( + props: WorkloadsStateOptions & Omit, +) { + const state = useWorkloadsState(props); + return ( + + ); +} + describe('Workloads platform-page embed contract', () => { it('exposes metric-display-mode + history-range override hooks so platform pages can share the toggle across multiple tables', async () => { const stateSource = (await import('../useWorkloadsState.ts?raw')).default; @@ -522,7 +539,7 @@ describe('Workloads performance contract', () => { wsReconnecting = true; mockWorkloads = [makeGuest(1, { name: 'route-owned-workload' })]; - render(() => ); + render(() => ); await waitFor(() => { expect( @@ -541,7 +558,7 @@ describe('Workloads performance contract', () => { () => new Promise(() => undefined), ); - render(() => ); + render(() => ); await waitFor(() => { expect( @@ -566,7 +583,7 @@ describe('Workloads performance contract', () => { ], }); - render(() => ); + render(() => ); await waitFor(() => expect(runtimeInventorySourcesApiMocks.list).toHaveBeenCalledTimes(1)); expect(connectionsApiMocks.list).not.toHaveBeenCalled(); @@ -581,10 +598,6 @@ describe('Workloads performance contract', () => { path="/" component={() => ( { path="/" component={() => ( { ]; const { getByTestId } = render(() => ( - + )); await waitFor(() => { @@ -805,7 +814,7 @@ describe('Workloads performance contract', () => { }), ]; - render(() => ); + render(() => ); await waitFor(() => { expect(screen.getByText('vm-missing-agent')).toBeInTheDocument(); @@ -834,7 +843,7 @@ describe('Workloads performance contract', () => { mockWorkloads = [makeGuest(1, { name: 'first-connect-workload' })]; const { container } = render(() => ( - + )); await waitFor(() => { @@ -853,7 +862,7 @@ describe('Workloads performance contract', () => { mockWorkloads = [makeGuest(1, { name: 'reconnect-workload' })]; const { container } = render(() => ( - + )); await waitFor(() => { @@ -902,7 +911,7 @@ describe('Workloads performance contract', () => { mockWorkloads = makeGuests(PROFILES.M); const { container } = render(() => ( - + )); await waitFor(() => { @@ -920,7 +929,7 @@ describe('Workloads performance contract', () => { mockWorkloads = makeGuests(PROFILES.L); const { container } = render(() => ( - + )); await waitFor(() => { @@ -941,7 +950,7 @@ describe('Workloads performance contract', () => { mockWorkloads = makeGuests(PROFILES.S); const { container } = render(() => ( - + )); await waitFor(() => { @@ -957,7 +966,7 @@ describe('Workloads performance contract', () => { mockWorkloads = guests; const { container } = render(() => ( - + )); await waitFor(() => { @@ -996,7 +1005,7 @@ describe('Workloads performance contract', () => { mockWorkloads = profileGuests; const { container, unmount } = render(() => ( - + )); await waitFor(() => { @@ -1114,7 +1123,7 @@ describe('Workloads performance contract', () => { ); const { container } = render(() => ( - + )); await waitFor(() => { @@ -1148,7 +1157,7 @@ describe('Workloads performance contract', () => { ]; const { container } = render(() => ( - { it('keeps hot-path workloads state in the shared workloads state owner', () => { expect(workloadsSource).toContain('useWorkloadsState'); + // The owning page builds the state and the surface requires it; the + // overview layout guardrails check the surface imports only its type. + expect(workloadsSource).toContain(' state: WorkloadsState;'); expect(workloadsSource).toContain('WorkloadsTable'); expect(workloadsSource).not.toContain('const [search, setSearch] = createSignal('); expect(workloadsStateSource).toContain('useWorkloadsControlsState'); diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index 913ae96e90..24fe972cab 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -65,7 +65,7 @@ const isProxmoxNodeResource = (resource: Resource): boolean => Boolean(resource.proxmox) || Boolean(resource.platformData?.proxmox)); -export interface WorkloadsSurfaceProps { +export interface WorkloadsStateOptions { vms: VM[]; containers: Container[]; nodes: Node[]; @@ -133,7 +133,7 @@ export interface WorkloadGroupLabelBadge { title?: string; } -export function useWorkloadsState(props: WorkloadsSurfaceProps) { +export function useWorkloadsState(props: WorkloadsStateOptions) { const navigate = useNavigate(); const ws = useWebSocket(); const { connected, activeAlerts, initialDataReceived, reconnecting, reconnect } = ws; diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 2276ebb7b5..da5df929d9 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -630,10 +630,6 @@ describe('platform overview layout guardrails', () => { 'excludedWorkloadTypes: PROXMOX_WORKLOAD_EXCLUDED_TYPES', ); expect(proxmoxPageSurfaceSource).toContain('showNestedExcludedWorkloads: true'); - expect(proxmoxPageSurfaceSource).toContain( - 'excludedWorkloadTypes={PROXMOX_WORKLOAD_EXCLUDED_TYPES}', - ); - expect(proxmoxPageSurfaceSource).toContain('showNestedExcludedWorkloads'); expect(dockerPageSurfaceSource).toContain(' { ); } + // WorkloadsSurface renders the state its page passes. It imports only that + // state's type, so it has no hook to call and cannot build a second state. + const workloadsSurfaceFile = parseTsx(workloadsSurfaceSource); + const stateModuleImports = findNodes(workloadsSurfaceFile, ts.isImportDeclaration).filter( + (node) => + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === './useWorkloadsState', + ); + expect(stateModuleImports.length).toBeGreaterThan(0); + for (const node of stateModuleImports) { + expect(node.importClause?.isTypeOnly, node.getText()).toBe(true); + } + expect( + findNodes(workloadsSurfaceFile, ts.isCallExpression).map((call) => call.expression.getText()), + ).not.toContain('useWorkloadsState'); + // Each Workloads embed sits on its page's Overview tab, owns the state and // the one toolbar, and locks the platform: the state is scoped to it and // the Platform chip never shows as a removable filter. @@ -858,6 +870,13 @@ describe('platform overview layout guardrails', () => { expect(jsxAttr(toolbar, 'forcedPlatform'), name).toBe(platform); const surface = onlyJsxTag(overviewFn, 'WorkloadsSurface'); expect(jsxAttr(surface, 'state'), name).toBe('workloadsState'); + // The surface renders only that state; the scope it repeats for its + // empty-state copy must be the scope the state was built with. + const unquote = (text?: string) => text?.replace(/^(['"])(.*)\1$/, '$2'); + expect(jsxAttr(surface, 'forcedPlatform'), name).toBe(platform); + expect(unquote(jsxAttr(surface, 'forcedViewMode')), name).toBe( + unquote(state.get('forcedViewMode')), + ); } // StorageSurface sits on the Proxmox Storage tab with the source locked and diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index c874cc48ee..0e811ff886 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -623,13 +623,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) { { 'excludedWorkloadTypes: PROXMOX_WORKLOAD_EXCLUDED_TYPES', ); expect(proxmoxPageSurfaceSource).toContain('showNestedExcludedWorkloads: true'); - expect(proxmoxPageSurfaceSource).toContain( - 'excludedWorkloadTypes={PROXMOX_WORKLOAD_EXCLUDED_TYPES}', - ); - expect(proxmoxPageSurfaceSource).toContain('showNestedExcludedWorkloads'); expect(proxmoxPageSurfaceSource).toContain('workloads={model().guests}'); expect(proxmoxPageSurfaceSource).not.toContain('workloads={workloadsState.allGuests'); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx index a3159483a8..8fbe4f5157 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx @@ -21,6 +21,8 @@ const mockWorkloadSearch = vi.hoisted(() => vi.fn(() => '')); const mockSelectedNode = vi.hoisted(() => vi.fn<() => string | null>(() => null)); const mockHandleNodeSelect = vi.hoisted(() => vi.fn()); const mockWorkloadsOptions = vi.hoisted(() => vi.fn()); +const mockWorkloadsSurfaceProps = vi.hoisted(() => vi.fn()); +const mockWorkloadsStateRef = vi.hoisted(() => ({ current: undefined as unknown })); const mockWorkloadsReady = vi.hoisted(() => vi.fn(() => false)); const mockFetchReplicationJobs = vi.hoisted(() => vi.fn((_signal?: AbortSignal) => Promise.resolve([] as unknown[])), @@ -91,13 +93,16 @@ vi.mock('@/components/Workloads/WorkloadsFilter', () => ({ })); vi.mock('@/components/Workloads/WorkloadsSurface', () => ({ - WorkloadsSurface: () =>
, + WorkloadsSurface: (props: Record) => { + mockWorkloadsSurfaceProps(props); + return
; + }, })); vi.mock('@/components/Workloads/useWorkloadsState', () => ({ useWorkloadsState: (options: unknown) => { mockWorkloadsOptions(options); - return { + const state = { surfaceConnected: mockWorkloadsReady, surfaceInitialDataReceived: mockWorkloadsReady, allGuests: () => (mockWorkloadsReady() ? [{ id: 'vm-1' }] : []), @@ -108,6 +113,8 @@ vi.mock('@/components/Workloads/useWorkloadsState', () => ({ search: mockWorkloadSearch, setSearch: vi.fn(), }; + mockWorkloadsStateRef.current = state; + return state; }, })); @@ -613,6 +620,29 @@ describe('ProxmoxPageSurface contract', () => { ).toBeTruthy(); }); + it('hands the guests table only its page-owned state and presentation props', () => { + setResources([makeResource({ id: 'vm-1', type: 'vm' })]); + renderSurface(); + + const props = mockWorkloadsSurfaceProps.mock.lastCall?.[0] as Record; + expect(Object.keys(props).sort()).toEqual([ + 'emptyStateDescription', + 'emptyStateTitle', + 'forcedPlatform', + 'state', + 'tableTitle', + ]); + expect(props.state).toBe(mockWorkloadsStateRef.current); + const options = mockWorkloadsOptions.mock.lastCall?.[0] as { forcedPlatform?: string }; + expect(props.forcedPlatform).toBe(options.forcedPlatform); + expect(options).toEqual( + expect.objectContaining({ + excludedWorkloadTypes: ['app-container'], + showNestedExcludedWorkloads: true, + }), + ); + }); + it('keeps the bounded node preview before guests at every viewport', () => { expect(proxmoxPageSurfaceSource).toContain('
\n