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.
This commit is contained in:
rcourtman 2026-10-10 13:05:50 +01:00
parent 9a32396fe2
commit 0cebf6f51f
15 changed files with 207 additions and 85 deletions

View file

@ -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`

View file

@ -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,

View file

@ -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

View file

@ -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;

View file

@ -28,7 +28,7 @@
"AIIntelligence": {
"baselineGzip": 63097
},
"WorkloadsSurface": {
"useWorkloadsState": {
"baselineGzip": 62538
},
"Docker": {

View file

@ -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"
]
}

View file

@ -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.

View file

@ -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<WorkloadsSurfaceProps, 'state'>,
) {
const state = useWorkloadsState(props);
createEffect(() => {
lastHostFilter = state.hostFilterConfig();
});
return <WorkloadsSurface {...props} state={state} />;
return (
<WorkloadsSurface
state={state}
forcedPlatform={props.forcedPlatform}
forcedViewMode={props.forcedViewMode}
emptyStateTitle={props.emptyStateTitle}
emptyStateDescription={props.emptyStateDescription}
tableTitle={props.tableTitle}
/>
);
}
describe('Workloads pod workloads integration', () => {
@ -257,7 +268,7 @@ describe('Workloads pod workloads integration', () => {
];
render(() => (
<WorkloadsSurface
<PageOwnedWorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
@ -278,10 +289,6 @@ describe('Workloads pod workloads integration', () => {
it('uses filtered-empty copy for table-only surfaces when filters remove every workload', () => {
render(() => (
<WorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
forcedViewMode="vm"
emptyStateTitle="No vSphere VMs"
emptyStateDescription="Virtual machines appear here once the vCenter connection enumerates them."
@ -327,10 +334,6 @@ describe('Workloads pod workloads integration', () => {
it('keeps page-owned table-only empty copy when no operator filters are active', () => {
render(() => (
<WorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
forcedViewMode="vm"
emptyStateTitle="No vSphere VMs"
emptyStateDescription="Virtual machines appear here once the vCenter connection enumerates them."
@ -396,7 +399,7 @@ describe('Workloads pod workloads integration', () => {
mockLocationSearch = '?type=pod&resource=legacy:pve1:101';
const { getByText } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -436,7 +439,7 @@ describe('Workloads pod workloads integration', () => {
];
const { getByText, queryByText } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -585,7 +588,7 @@ describe('Workloads pod workloads integration', () => {
];
const { getByText, queryByText } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -649,7 +652,7 @@ describe('Workloads pod workloads integration', () => {
];
const { getByText, queryByText } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -741,7 +744,7 @@ describe('Workloads pod workloads integration', () => {
];
const { queryByText, getByTestId } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -813,7 +816,7 @@ describe('Workloads pod workloads integration', () => {
];
const { getByText, getAllByText } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -830,7 +833,7 @@ describe('Workloads pod workloads integration', () => {
mockLocationSearch = '?type=all';
mockWorkloads = [];
render(() => <WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
render(() => <PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalled();
@ -846,7 +849,7 @@ describe('Workloads pod workloads integration', () => {
mockLocationSearch = '?type=all&context=cluster-a';
mockWorkloads = [];
render(() => <WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
render(() => <PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalled();

View file

@ -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<WorkloadsSurfaceProps, 'state'>,
) {
const state = useWorkloadsState(props);
return (
<WorkloadsSurface
state={state}
forcedPlatform={props.forcedPlatform}
forcedViewMode={props.forcedViewMode}
emptyStateTitle={props.emptyStateTitle}
emptyStateDescription={props.emptyStateDescription}
tableTitle={props.tableTitle}
/>
);
}
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(() => <WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
render(() => <PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
await waitFor(() => {
expect(
@ -541,7 +558,7 @@ describe('Workloads performance contract', () => {
() => new Promise(() => undefined),
);
render(() => <WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
render(() => <PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
await waitFor(() => {
expect(
@ -566,7 +583,7 @@ describe('Workloads performance contract', () => {
],
});
render(() => <WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
render(() => <PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
await waitFor(() => expect(runtimeInventorySourcesApiMocks.list).toHaveBeenCalledTimes(1));
expect(connectionsApiMocks.list).not.toHaveBeenCalled();
@ -581,10 +598,6 @@ describe('Workloads performance contract', () => {
path="/"
component={() => (
<WorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
forcedPlatform="proxmox-pve"
emptyStateTitle="No Proxmox workloads"
emptyStateDescription="Proxmox VMs and LXCs appear here when inventory is available."
@ -673,10 +686,6 @@ describe('Workloads performance contract', () => {
path="/"
component={() => (
<WorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
state={
{
setClearSurfaceRootRef: vi.fn(),
@ -745,7 +754,7 @@ describe('Workloads performance contract', () => {
];
const { getByTestId } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -805,7 +814,7 @@ describe('Workloads performance contract', () => {
}),
];
render(() => <WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
render(() => <PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
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(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -853,7 +862,7 @@ describe('Workloads performance contract', () => {
mockWorkloads = [makeGuest(1, { name: 'reconnect-workload' })];
const { container } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -902,7 +911,7 @@ describe('Workloads performance contract', () => {
mockWorkloads = makeGuests(PROFILES.M);
const { container } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -920,7 +929,7 @@ describe('Workloads performance contract', () => {
mockWorkloads = makeGuests(PROFILES.L);
const { container } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -941,7 +950,7 @@ describe('Workloads performance contract', () => {
mockWorkloads = makeGuests(PROFILES.S);
const { container } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -957,7 +966,7 @@ describe('Workloads performance contract', () => {
mockWorkloads = guests;
const { container } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -996,7 +1005,7 @@ describe('Workloads performance contract', () => {
mockWorkloads = profileGuests;
const { container, unmount } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -1114,7 +1123,7 @@ describe('Workloads performance contract', () => {
);
const { container } = render(() => (
<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
<PageOwnedWorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />
));
await waitFor(() => {
@ -1148,7 +1157,7 @@ describe('Workloads performance contract', () => {
];
const { container } = render(() => (
<WorkloadsSurface
<PageOwnedWorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
@ -1192,6 +1201,9 @@ describe('Workloads performance contract', () => {
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');

View file

@ -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;

View file

@ -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('<DockerHostsTable');
expect(dockerPageSurfaceSource).toContain('<DockerContainersTable');
expect(dockerPageSurfaceSource).toContain('<DockerImagesTable');
@ -835,6 +831,22 @@ describe('platform overview layout guardrails', () => {
);
}
// 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

View file

@ -623,13 +623,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
</Show>
<WorkloadsSurface
state={workloadsState}
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
forcedPlatform={PROXMOX_PLATFORM_FILTER}
excludedWorkloadTypes={PROXMOX_WORKLOAD_EXCLUDED_TYPES}
showNestedExcludedWorkloads
emptyStateTitle="No Proxmox workloads"
emptyStateDescription="Proxmox VMs and LXCs appear here when inventory is available."
tableTitle={

View file

@ -714,10 +714,6 @@ describe('ProxmoxBackupsTable', () => {
'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');
});

View file

@ -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: () => <div data-testid="workloads-surface" />,
WorkloadsSurface: (props: Record<string, unknown>) => {
mockWorkloadsSurfaceProps(props);
return <div data-testid="workloads-surface" />;
},
}));
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<string, unknown>;
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('<section>\n <ProxmoxNodesTable');
expect(proxmoxPageSurfaceSource).toContain('class="space-y-3 scroll-mt-4"');

View file

@ -533,10 +533,6 @@ function VmwareOverview(props: VmwareOverviewProps) {
</Show>
<WorkloadsSurface
state={workloadsState}
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
forcedPlatform={VMWARE_PLATFORM_FILTER}
forcedViewMode="vm"
emptyStateTitle="No vSphere VMs"