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