Extract dashboard workload viewport sync owner

This commit is contained in:
rcourtman 2026-03-22 10:29:55 +00:00
parent b613a629f6
commit ac86d6d72e
8 changed files with 198 additions and 31 deletions

View file

@ -105,6 +105,8 @@ regression protection.
83. `frontend-modern/src/components/Dashboard/GuestDrawer.test.tsx`
84. `frontend-modern/src/components/Dashboard/__tests__/useGroupedTableWindowing.test.ts`
85. `frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx`
86. `frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts`
87. `frontend-modern/src/components/Dashboard/__tests__/useDashboardWorkloadViewportSync.test.tsx`
## Shared Boundaries
@ -130,7 +132,7 @@ regression protection.
12. Extend dashboard guest metadata cache persistence, metadata refresh, org-scope switching, and optimistic custom-URL updates through `frontend-modern/src/components/Dashboard/useDashboardGuestMetadataState.ts` rather than rebuilding dashboard-local storage caches, event listeners, or guest metadata API wiring inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`
13. Extend dashboard deep-link selection and hovered-row continuity semantics through `frontend-modern/src/components/Dashboard/dashboardSelectionModel.ts`, and extend table scroll preservation plus reactive selection state through `frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts`, rather than rebuilding resource-query parsing, selected-row scroll pinning, or hovered-row invalidation inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`
14. Extend dashboard workload route ownership, route-driven option catalogs, and toolbar filter config through `frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts`, `frontend-modern/src/components/Dashboard/useDashboardWorkloadFilterOptions.ts`, `frontend-modern/src/components/Dashboard/dashboardWorkloadRouteModel.ts`, `frontend-modern/src/components/Dashboard/dashboardWorkloadFilterConfigModel.ts`, and `frontend-modern/src/components/Dashboard/dashboardWorkloadRouteStateModel.ts`, and extend query-param synchronization plus managed workload URL semantics through `frontend-modern/src/components/Dashboard/useDashboardWorkloadUrlSync.ts` and `frontend-modern/src/components/Dashboard/dashboardWorkloadUrlSyncModel.ts`, rather than rebuilding route sync, alias parsing, option derivation, toolbar callback/config wiring, reset policy, node-selection compatibility rules, param precedence, or managed workload URLs inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`
15. Extend grouped dashboard workload derivation, summary fallbacks, and grouped/windowed table presentation through `frontend-modern/src/components/Dashboard/useDashboardWorkloadDerivedState.ts`, and extend node parent mapping through `frontend-modern/src/components/Dashboard/workloadTopology.ts`, rather than rebuilding grouped selectors, summary snapshot math, or topology lookups inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`
15. Extend grouped dashboard workload derivation, summary fallbacks, and grouped/windowed table presentation through `frontend-modern/src/components/Dashboard/useDashboardWorkloadDerivedState.ts`, extend viewport-driven grouped table synchronization through `frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts`, and extend node parent mapping through `frontend-modern/src/components/Dashboard/workloadTopology.ts`, rather than rebuilding grouped selectors, summary snapshot math, scroll listeners, or topology lookups inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`
16. Extend dashboard control defaults, persistent view preferences, keyboard reset behavior, column-visibility ownership, and tag-search flow through `frontend-modern/src/components/Dashboard/useDashboardControlsState.ts` and `frontend-modern/src/components/Dashboard/dashboardFilterModel.ts` rather than rebuilding sort/search/grouping state, reset drift, or column-toggle plumbing inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`
17. Extend dashboard filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Dashboard/dashboardFilterModel.ts` and `frontend-modern/src/components/Dashboard/useDashboardFilterState.ts`, rather than rebuilding filter-local state inside `frontend-modern/src/components/Dashboard/DashboardFilter.tsx`
18. Extend threshold-slider value-position math, title/label derivation, and drag scroll-lock runtime through `frontend-modern/src/components/Dashboard/thresholdSliderModel.ts` and `frontend-modern/src/components/Dashboard/useThresholdSliderState.ts` rather than rebuilding slider-local state and pointer lifecycle inside `frontend-modern/src/components/Dashboard/ThresholdSlider.tsx`
@ -138,7 +140,7 @@ regression protection.
20. Extend stacked memory-bar capacity math, balloon/swap derivation, and resize-observer runtime through `frontend-modern/src/components/Dashboard/stackedMemoryBarModel.ts` and `frontend-modern/src/components/Dashboard/useStackedMemoryBarState.ts` rather than rebuilding memory-bar-local state, tooltip shaping, and label-fit logic inside `frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx`
21. Extend metric-bar width, label-fit logic, and resize-observer runtime through `frontend-modern/src/components/Dashboard/metricBarModel.ts` and `frontend-modern/src/components/Dashboard/useMetricBarState.ts` rather than rebuilding metric-local state and threshold mapping inside `frontend-modern/src/components/Dashboard/MetricBar.tsx`
22. Extend enhanced CPU bar usage/anomaly presentation and tooltip runtime through `frontend-modern/src/components/Dashboard/enhancedCpuBarModel.ts` and `frontend-modern/src/components/Dashboard/useEnhancedCPUBarState.ts` rather than rebuilding tooltip-local state and CPU-threshold formatting inside `frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx`
23. Extend grouped dashboard row windowing, reveal-index clamping, overscan math, and per-group visible-slice derivation through `frontend-modern/src/components/Dashboard/useGroupedTableWindowing.ts` rather than rebuilding scroll handlers, mounted-row budgets, or group-slice math inside `frontend-modern/src/components/Dashboard/useDashboardWorkloadDerivedState.ts`
23. Extend grouped dashboard row windowing, reveal-index clamping, overscan math, and per-group visible-slice derivation through `frontend-modern/src/components/Dashboard/useGroupedTableWindowing.ts`, and extend viewport event wiring through `frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts` rather than rebuilding scroll handlers, mounted-row budgets, viewport listeners, or group-slice math inside `frontend-modern/src/components/Dashboard/useDashboardWorkloadDerivedState.ts`
24. Extend dashboard shell rendering through `frontend-modern/src/components/Dashboard/DashboardStateCards.tsx`, `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx`, and `frontend-modern/src/components/Dashboard/DashboardStatsStrip.tsx` rather than accreting loading cards, workload table markup, or stats-strip presentation back into `frontend-modern/src/components/Dashboard/Dashboard.tsx`
25. Extend dashboard workload table shell ownership through `frontend-modern/src/components/Dashboard/WorkloadTableHeader.tsx` and `frontend-modern/src/components/Dashboard/WorkloadPanel.tsx` rather than rebuilding sortable header markup, grouped node rows, row expansion, or guest-drawer rendering inside `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx`
@ -178,6 +180,9 @@ behavior, column visibility, and summary display preferences, while
`frontend-modern/src/components/Dashboard/useDashboardWorkloadDerivedState.ts`
owns grouped workload derivation, summary fallbacks, parent-node mapping,
and grouped/windowed table math, while
`frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts`
owns grouped workload viewport synchronization and the scroll/resize listener
lifecycle, while
`frontend-modern/src/components/Dashboard/useDashboardGuestMetadataState.ts`
owns guest metadata cache persistence, optimistic custom-URL updates,
org-scope switching, and metadata refresh, and
@ -191,6 +196,12 @@ owns row-window thresholds, overscan behavior, reveal-index clamping, and
per-group visible-slice derivation. Future dashboard table windowing changes
must extend through that hook instead of rebuilding scroll math or mounted-row
budgets inline inside `frontend-modern/src/components/Dashboard/useDashboardWorkloadDerivedState.ts`.
Viewport-driven grouped table synchronization now also routes through
`frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts`,
which owns the dashboard table body measurement and the scroll/resize listener
lifecycle. Future viewport sync changes must extend through that hook rather
than rebuilding browser-event wiring or table-body geometry reads inside
`frontend-modern/src/components/Dashboard/useDashboardWorkloadDerivedState.ts`.
The dashboard guest-row path now follows the same pattern: the render shell
stays in `frontend-modern/src/components/Dashboard/GuestRow.tsx`, tooltip-backed
cell presentation lives in `frontend-modern/src/components/Dashboard/GuestRowCells.tsx`,

View file

@ -2591,6 +2591,7 @@
"frontend-modern/src/components/Dashboard/useDashboardWorkloadFilterOptions.ts",
"frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts",
"frontend-modern/src/components/Dashboard/useDashboardWorkloadUrlSync.ts",
"frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts",
"frontend-modern/src/components/Dashboard/useDiskListState.ts",
"frontend-modern/src/components/Dashboard/useEnhancedCPUBarState.ts",
"frontend-modern/src/components/Dashboard/useGroupedTableWindowing.ts",
@ -2628,6 +2629,7 @@
"frontend-modern/src/components/Dashboard/__tests__/StackedDiskBar.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/useDashboardFilterState.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/useDashboardSelectionState.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/useDashboardWorkloadViewportSync.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/useEnhancedCPUBarState.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/useGroupedTableWindowing.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/useMetricBarState.test.tsx",
@ -2704,6 +2706,7 @@
"frontend-modern/src/components/Dashboard/useDashboardWorkloadFilterOptions.ts",
"frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts",
"frontend-modern/src/components/Dashboard/useDashboardWorkloadUrlSync.ts",
"frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts",
"frontend-modern/src/components/Dashboard/useDiskListState.ts",
"frontend-modern/src/components/Dashboard/useEnhancedCPUBarState.ts",
"frontend-modern/src/components/Dashboard/useGroupedTableWindowing.ts",
@ -2738,6 +2741,7 @@
"frontend-modern/src/components/Dashboard/__tests__/StackedDiskBar.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/useDashboardFilterState.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/useDashboardSelectionState.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/useDashboardWorkloadViewportSync.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/useEnhancedCPUBarState.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/useGroupedTableWindowing.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/useMetricBarState.test.tsx",

View file

@ -21,6 +21,7 @@ import dashboardWorkloadFilterConfigModelSource from '../dashboardWorkloadFilter
import dashboardWorkloadRouteModelSource from '../dashboardWorkloadRouteModel.ts?raw';
import dashboardWorkloadRouteStateModelSource from '../dashboardWorkloadRouteStateModel.ts?raw';
import dashboardWorkloadUrlSyncModelSource from '../dashboardWorkloadUrlSyncModel.ts?raw';
import dashboardWorkloadViewportSyncSource from '../useDashboardWorkloadViewportSync.ts?raw';
import dashboardWorkloadRouteStateSource from '../useDashboardWorkloadRouteState.ts?raw';
import dashboardWorkloadUrlSyncSource from '../useDashboardWorkloadUrlSync.ts?raw';
import dashboardStateSource from '../useDashboardState.ts?raw';
@ -646,6 +647,13 @@ describe('Dashboard performance contract', () => {
expect(dashboardWorkloadDerivedStateSource).toContain('buildNodeByInstance(');
expect(dashboardWorkloadDerivedStateSource).toContain('buildGuestParentNodeMap(');
expect(dashboardWorkloadDerivedStateSource).toContain('useGroupedTableWindowing');
expect(dashboardWorkloadDerivedStateSource).toContain('useDashboardWorkloadViewportSync');
expect(dashboardWorkloadDerivedStateSource).not.toContain('window.addEventListener');
expect(dashboardWorkloadDerivedStateSource).not.toContain('getBoundingClientRect');
expect(dashboardWorkloadViewportSyncSource).toContain('window.addEventListener');
expect(dashboardWorkloadViewportSyncSource).toContain('window.removeEventListener');
expect(dashboardWorkloadViewportSyncSource).toContain('getBoundingClientRect');
expect(dashboardWorkloadViewportSyncSource).toContain('groupedWindowing.onScroll');
expect(dashboardWorkloadRouteStateSource).not.toContain("from './workloadTopology'");
expect(dashboardWorkloadRouteModelSource).toContain("from './workloadTopology'");
expect(dashboardWorkloadRouteModelSource).toContain('workloadNodeScopeId');
@ -701,6 +709,7 @@ describe('Dashboard performance contract', () => {
expect(dashboardWorkloadRouteStateSource).toContain('hostFilterConfig');
expect(dashboardWorkloadRouteStateSource).toContain('namespaceFilterConfig');
expect(dashboardWorkloadDerivedStateSource).toContain('useGroupedTableWindowing');
expect(dashboardWorkloadDerivedStateSource).toContain('useDashboardWorkloadViewportSync');
});
it('keeps threshold slider runtime and derivations in canonical slider owners', () => {

View file

@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { useDashboardWorkloadViewportSync } from '@/components/Dashboard/useDashboardWorkloadViewportSync';
import type { UseGroupedTableWindowingResult } from '@/components/Dashboard/useGroupedTableWindowing';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('useDashboardWorkloadViewportSync', () => {
it('owns grouped workload viewport sync and listener cleanup', async () => {
const onScroll = vi.fn();
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const groupedWindowing: UseGroupedTableWindowingResult = {
endIndex: () => 10,
getVisibleSlice: (_groupKey, guests) => guests,
isWindowed: () => true,
mountedCount: () => 10,
onScroll,
revealIndex: vi.fn(),
startIndex: () => 0,
};
const Harness = () => {
const [bodyRef, setBodyRef] = createSignal<HTMLTableSectionElement | null>(null);
useDashboardWorkloadViewportSync({
filteredGuestCount: () => 640,
groupedWindowing,
rowHeight: 32,
tableBodyRef: bodyRef,
});
return (
<table>
<tbody
ref={(element) => {
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
bottom: 400,
height: 320,
left: 0,
right: 0,
toJSON: () => ({}),
top: -96,
width: 800,
x: 0,
y: -96,
} as DOMRect);
setBodyRef(element);
}}
/>
</table>
);
};
const { unmount } = render(() => <Harness />);
await waitFor(() => {
expect(onScroll).toHaveBeenCalledWith(96, window.innerHeight, 32);
});
expect(addEventListenerSpy).toHaveBeenCalledWith(
'scroll',
expect.any(Function),
{ passive: true },
);
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
window.dispatchEvent(new Event('scroll'));
await waitFor(() => {
expect(onScroll).toHaveBeenCalledTimes(2);
});
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
});
});

View file

@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup, type Accessor } from 'solid-js';
import { createMemo, type Accessor } from 'solid-js';
import type { Node } from '@/types/api';
import type { WorkloadGuest } from '@/types/workloads';
@ -17,6 +17,7 @@ import {
buildNodeByInstance,
buildGuestParentNodeMap,
} from './workloadTopology';
import { useDashboardWorkloadViewportSync } from './useDashboardWorkloadViewportSync';
import { useGroupedTableWindowing } from './useGroupedTableWindowing';
type GroupingMode = 'grouped' | 'flat';
@ -212,38 +213,16 @@ export function useDashboardWorkloadDerivedState(
groupedWindowing.isWindowed()
? Math.max(
0,
(options.filteredGuests().length - groupedWindowing.endIndex()) *
DASHBOARD_TABLE_ESTIMATED_ROW_HEIGHT,
(options.filteredGuests().length - groupedWindowing.endIndex()) * 32,
)
: 0,
);
const syncGuestWindowToViewport = () => {
if (!groupedWindowing.isWindowed() || typeof window === 'undefined') return;
const body = options.tableBodyRef();
if (!body) return;
const rect = body.getBoundingClientRect();
const scrollTop = Math.max(0, -rect.top);
groupedWindowing.onScroll(scrollTop, window.innerHeight, DASHBOARD_TABLE_ESTIMATED_ROW_HEIGHT);
};
createEffect(() => {
if (typeof window === 'undefined') return;
options.filteredGuests().length;
if (!groupedWindowing.isWindowed()) return;
if (!options.tableBodyRef()) return;
const handleViewportChange = () => {
syncGuestWindowToViewport();
};
handleViewportChange();
window.addEventListener('scroll', handleViewportChange, { passive: true });
window.addEventListener('resize', handleViewportChange);
onCleanup(() => {
window.removeEventListener('scroll', handleViewportChange);
window.removeEventListener('resize', handleViewportChange);
});
useDashboardWorkloadViewportSync({
filteredGuestCount: () => options.filteredGuests().length,
groupedWindowing,
rowHeight: DASHBOARD_TABLE_ESTIMATED_ROW_HEIGHT,
tableBodyRef: options.tableBodyRef,
});
const totalStats = createMemo(() => computeWorkloadStats(options.filteredGuests()));

View file

@ -0,0 +1,46 @@
import { createEffect, onCleanup, type Accessor } from 'solid-js';
import type { UseGroupedTableWindowingResult } from './useGroupedTableWindowing';
interface DashboardWorkloadViewportSyncOptions {
filteredGuestCount: Accessor<number>;
groupedWindowing: UseGroupedTableWindowingResult;
rowHeight: number;
tableBodyRef: Accessor<HTMLTableSectionElement | null>;
}
export function useDashboardWorkloadViewportSync(
options: DashboardWorkloadViewportSyncOptions,
) {
const syncGuestWindowToViewport = () => {
if (!options.groupedWindowing.isWindowed() || typeof window === 'undefined') return;
const body = options.tableBodyRef();
if (!body) return;
const rect = body.getBoundingClientRect();
const scrollTop = Math.max(0, -rect.top);
options.groupedWindowing.onScroll(
scrollTop,
window.innerHeight,
options.rowHeight,
);
};
createEffect(() => {
if (typeof window === 'undefined') return;
options.filteredGuestCount();
if (!options.groupedWindowing.isWindowed()) return;
if (!options.tableBodyRef()) return;
const handleViewportChange = () => {
syncGuestWindowToViewport();
};
handleViewportChange();
window.addEventListener('scroll', handleViewportChange, { passive: true });
window.addEventListener('resize', handleViewportChange);
onCleanup(() => {
window.removeEventListener('scroll', handleViewportChange);
window.removeEventListener('resize', handleViewportChange);
});
});
}

View file

@ -92,6 +92,7 @@ import dashboardGuestMetadataStateSource from '@/components/Dashboard/useDashboa
import dashboardSelectionModelSource from '@/components/Dashboard/dashboardSelectionModel.ts?raw';
import dashboardSelectionStateSource from '@/components/Dashboard/useDashboardSelectionState.ts?raw';
import dashboardWorkloadDerivedStateSource from '@/components/Dashboard/useDashboardWorkloadDerivedState.ts?raw';
import dashboardWorkloadViewportSyncSource from '@/components/Dashboard/useDashboardWorkloadViewportSync.ts?raw';
import dashboardWorkloadFilterOptionsSource from '@/components/Dashboard/useDashboardWorkloadFilterOptions.ts?raw';
import dashboardWorkloadFilterConfigModelSource from '@/components/Dashboard/dashboardWorkloadFilterConfigModel.ts?raw';
import dashboardWorkloadRouteModelSource from '@/components/Dashboard/dashboardWorkloadRouteModel.ts?raw';
@ -698,6 +699,9 @@ describe('frontend resource type boundaries', () => {
expect(dashboardWorkloadRouteStateSource).toContain('hostFilterConfig');
expect(dashboardWorkloadRouteStateSource).toContain('namespaceFilterConfig');
expect(dashboardWorkloadDerivedStateSource).toContain('useGroupedTableWindowing');
expect(dashboardWorkloadDerivedStateSource).toContain('useDashboardWorkloadViewportSync');
expect(dashboardWorkloadDerivedStateSource).not.toContain('window.addEventListener');
expect(dashboardWorkloadDerivedStateSource).not.toContain('getBoundingClientRect');
expect(dashboardStateSource).not.toContain('const DEFAULT_WINDOW_SIZE =');
expect(dashboardStateSource).not.toContain('const DEFAULT_ENABLE_THRESHOLD =');
expect(dashboardStateSource).not.toContain('const DEFAULT_OVERSCAN_ROWS =');
@ -707,6 +711,10 @@ describe('frontend resource type boundaries', () => {
expect(groupedTableWindowingSource).toContain('getVisibleSlice');
expect(groupedTableWindowingSource).toContain('onScroll');
expect(groupedTableWindowingSource).toContain('revealIndex');
expect(dashboardWorkloadViewportSyncSource).toContain('window.addEventListener');
expect(dashboardWorkloadViewportSyncSource).toContain('window.removeEventListener');
expect(dashboardWorkloadViewportSyncSource).toContain('getBoundingClientRect');
expect(dashboardWorkloadViewportSyncSource).toContain('groupedWindowing.onScroll');
expect(workloadTopologySource).toContain('export const workloadNodeScopeId');
expect(workloadTopologySource).toContain('export const getKubernetesContextKey');
expect(workloadTopologySource).toContain('export const getWorkloadDockerHostId');

View file

@ -2160,6 +2160,33 @@ class SubsystemLookupTest(unittest.TestCase):
"dashboard-workload-hot-path",
)
def test_lookup_paths_assigns_dashboard_workload_viewport_sync_runtime_to_performance_and_scalability(
self,
) -> None:
result = lookup_paths(
["frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts"]
)
self.assertEqual(result["unowned_runtime_files"], [])
self.assertEqual(
{item["subsystem"] for item in result["impacted_subsystems"]},
{"performance-and-scalability"},
)
file_entry = result["files"][0]
self.assertEqual(file_entry["classification"], "runtime")
self.assertEqual(
{match["subsystem"] for match in file_entry["matches"]},
{"performance-and-scalability"},
)
match = file_entry["matches"][0]
self.assertEqual(
match["contract"],
"docs/release-control/v6/internal/subsystems/performance-and-scalability.md",
)
self.assertEqual(
match["verification_requirement"]["id"],
"dashboard-workload-hot-path",
)
def test_lookup_paths_assigns_dashboard_controls_state_runtime_to_performance_and_scalability(
self,
) -> None: