From 92bd34a73c7280ff7cdc3b5d307487282e817825 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 21 Mar 2026 22:17:13 +0000 Subject: [PATCH] Extract dashboard workload route owner --- .../subsystems/performance-and-scalability.md | 128 ++--- .../v6/internal/subsystems/registry.json | 2 + .../Dashboard.performance.contract.test.tsx | 13 +- .../components/Dashboard/useDashboardState.ts | 490 ++--------------- .../useDashboardWorkloadRouteState.ts | 502 ++++++++++++++++++ .../frontendResourceTypeBoundaries.test.ts | 13 +- .../release_control/subsystem_lookup_test.py | 27 + 7 files changed, 647 insertions(+), 528 deletions(-) create mode 100644 frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts 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 575d93494..de4b763a2 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -28,57 +28,58 @@ regression protection. 6. `frontend-modern/src/components/Dashboard/Dashboard.tsx` 7. `frontend-modern/src/components/Dashboard/useDashboardState.ts` 8. `frontend-modern/src/components/Dashboard/useDashboardGuestMetadataState.ts` -9. `frontend-modern/src/components/Dashboard/DashboardFilter.tsx` -10. `frontend-modern/src/components/Dashboard/dashboardFilterModel.ts` -11. `frontend-modern/src/components/Dashboard/useDashboardFilterState.ts` -12. `frontend-modern/src/components/Dashboard/ThresholdSlider.tsx` -13. `frontend-modern/src/components/Dashboard/thresholdSliderModel.ts` -14. `frontend-modern/src/components/Dashboard/useThresholdSliderState.ts` -15. `frontend-modern/src/components/Dashboard/StackedDiskBar.tsx` -16. `frontend-modern/src/components/Dashboard/stackedDiskBarModel.ts` -17. `frontend-modern/src/components/Dashboard/useStackedDiskBarState.ts` -18. `frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx` -19. `frontend-modern/src/components/Dashboard/stackedMemoryBarModel.ts` -20. `frontend-modern/src/components/Dashboard/useStackedMemoryBarState.ts` -21. `frontend-modern/src/components/Dashboard/MetricBar.tsx` -22. `frontend-modern/src/components/Dashboard/metricBarModel.ts` -23. `frontend-modern/src/components/Dashboard/useMetricBarState.ts` -24. `frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx` -25. `frontend-modern/src/components/Dashboard/enhancedCpuBarModel.ts` -26. `frontend-modern/src/components/Dashboard/useEnhancedCPUBarState.ts` -27. `frontend-modern/src/components/Dashboard/DiskList.tsx` -28. `frontend-modern/src/components/Dashboard/diskListModel.ts` -29. `frontend-modern/src/components/Dashboard/useDiskListState.ts` -30. `frontend-modern/src/components/Dashboard/GuestRow.tsx` -31. `frontend-modern/src/components/Dashboard/GuestRowCells.tsx` -32. `frontend-modern/src/components/Dashboard/guestRowModel.tsx` -33. `frontend-modern/src/components/Dashboard/useGuestRowState.ts` -34. `frontend-modern/src/components/Dashboard/GuestDrawer.tsx` -35. `frontend-modern/src/components/Dashboard/GuestDrawerOverview.tsx` -36. `frontend-modern/src/components/Dashboard/guestDrawerModel.ts` -37. `frontend-modern/src/components/Dashboard/useGuestDrawerState.ts` -38. `frontend-modern/src/components/Dashboard/workloadSelectors.ts` -39. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` -40. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` -41. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` -42. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` -43. `frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx` -44. `frontend-modern/src/components/Dashboard/__tests__/DashboardFilter.test.tsx` -45. `frontend-modern/src/components/Dashboard/__tests__/useDashboardFilterState.test.ts` -46. `frontend-modern/src/components/Dashboard/MetricBar.test.tsx` -47. `frontend-modern/src/components/Dashboard/__tests__/useMetricBarState.test.tsx` -48. `frontend-modern/src/components/Dashboard/__tests__/EnhancedCPUBar.test.tsx` -49. `frontend-modern/src/components/Dashboard/__tests__/useEnhancedCPUBarState.test.tsx` -50. `frontend-modern/src/components/Dashboard/ThresholdSlider.test.tsx` -51. `frontend-modern/src/components/Dashboard/__tests__/useThresholdSliderState.test.ts` -52. `frontend-modern/src/components/Dashboard/__tests__/StackedDiskBar.test.tsx` -53. `frontend-modern/src/components/Dashboard/__tests__/useStackedDiskBarState.test.tsx` -54. `frontend-modern/src/components/Dashboard/StackedMemoryBar.test.tsx` -55. `frontend-modern/src/components/Dashboard/__tests__/useStackedMemoryBarState.test.tsx` -56. `frontend-modern/src/components/Dashboard/__tests__/DiskList.test.tsx` -57. `frontend-modern/src/components/Dashboard/__tests__/GuestRow.test.tsx` -58. `frontend-modern/src/components/Dashboard/GuestDrawer.test.tsx` -59. `frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx` +9. `frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts` +10. `frontend-modern/src/components/Dashboard/DashboardFilter.tsx` +11. `frontend-modern/src/components/Dashboard/dashboardFilterModel.ts` +12. `frontend-modern/src/components/Dashboard/useDashboardFilterState.ts` +13. `frontend-modern/src/components/Dashboard/ThresholdSlider.tsx` +14. `frontend-modern/src/components/Dashboard/thresholdSliderModel.ts` +15. `frontend-modern/src/components/Dashboard/useThresholdSliderState.ts` +16. `frontend-modern/src/components/Dashboard/StackedDiskBar.tsx` +17. `frontend-modern/src/components/Dashboard/stackedDiskBarModel.ts` +18. `frontend-modern/src/components/Dashboard/useStackedDiskBarState.ts` +19. `frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx` +20. `frontend-modern/src/components/Dashboard/stackedMemoryBarModel.ts` +21. `frontend-modern/src/components/Dashboard/useStackedMemoryBarState.ts` +22. `frontend-modern/src/components/Dashboard/MetricBar.tsx` +23. `frontend-modern/src/components/Dashboard/metricBarModel.ts` +24. `frontend-modern/src/components/Dashboard/useMetricBarState.ts` +25. `frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx` +26. `frontend-modern/src/components/Dashboard/enhancedCpuBarModel.ts` +27. `frontend-modern/src/components/Dashboard/useEnhancedCPUBarState.ts` +28. `frontend-modern/src/components/Dashboard/DiskList.tsx` +29. `frontend-modern/src/components/Dashboard/diskListModel.ts` +30. `frontend-modern/src/components/Dashboard/useDiskListState.ts` +31. `frontend-modern/src/components/Dashboard/GuestRow.tsx` +32. `frontend-modern/src/components/Dashboard/GuestRowCells.tsx` +33. `frontend-modern/src/components/Dashboard/guestRowModel.tsx` +34. `frontend-modern/src/components/Dashboard/useGuestRowState.ts` +35. `frontend-modern/src/components/Dashboard/GuestDrawer.tsx` +36. `frontend-modern/src/components/Dashboard/GuestDrawerOverview.tsx` +37. `frontend-modern/src/components/Dashboard/guestDrawerModel.ts` +38. `frontend-modern/src/components/Dashboard/useGuestDrawerState.ts` +39. `frontend-modern/src/components/Dashboard/workloadSelectors.ts` +40. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` +41. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` +42. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` +43. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` +44. `frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx` +45. `frontend-modern/src/components/Dashboard/__tests__/DashboardFilter.test.tsx` +46. `frontend-modern/src/components/Dashboard/__tests__/useDashboardFilterState.test.ts` +47. `frontend-modern/src/components/Dashboard/MetricBar.test.tsx` +48. `frontend-modern/src/components/Dashboard/__tests__/useMetricBarState.test.tsx` +49. `frontend-modern/src/components/Dashboard/__tests__/EnhancedCPUBar.test.tsx` +50. `frontend-modern/src/components/Dashboard/__tests__/useEnhancedCPUBarState.test.tsx` +51. `frontend-modern/src/components/Dashboard/ThresholdSlider.test.tsx` +52. `frontend-modern/src/components/Dashboard/__tests__/useThresholdSliderState.test.ts` +53. `frontend-modern/src/components/Dashboard/__tests__/StackedDiskBar.test.tsx` +54. `frontend-modern/src/components/Dashboard/__tests__/useStackedDiskBarState.test.tsx` +55. `frontend-modern/src/components/Dashboard/StackedMemoryBar.test.tsx` +56. `frontend-modern/src/components/Dashboard/__tests__/useStackedMemoryBarState.test.tsx` +57. `frontend-modern/src/components/Dashboard/__tests__/DiskList.test.tsx` +58. `frontend-modern/src/components/Dashboard/__tests__/GuestRow.test.tsx` +59. `frontend-modern/src/components/Dashboard/GuestDrawer.test.tsx` +60. `frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx` ## Shared Boundaries @@ -102,12 +103,13 @@ regression protection. 10. Extend dashboard drawer derivations and runtime wiring through `frontend-modern/src/components/Dashboard/guestDrawerModel.ts` and `frontend-modern/src/components/Dashboard/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Dashboard/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Dashboard/GuestDrawer.tsx` 11. Extend dashboard disk-list derivations and fallback runtime wiring through `frontend-modern/src/components/Dashboard/diskListModel.ts` and `frontend-modern/src/components/Dashboard/useDiskListState.ts` rather than rebuilding usage math, progress-state mapping, or tooltip fallback logic inside `frontend-modern/src/components/Dashboard/DiskList.tsx` 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 filter defaults, active-filter counting, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Dashboard/dashboardFilterModel.ts` and `frontend-modern/src/components/Dashboard/useDashboardFilterState.ts`, and keep dashboard-owned filter-config assembly in `frontend-modern/src/components/Dashboard/useDashboardState.ts`, rather than rebuilding filter-local state inside `frontend-modern/src/components/Dashboard/DashboardFilter.tsx` or inline config IIFEs in `frontend-modern/src/components/Dashboard/Dashboard.tsx` -14. 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` -15. Extend stacked disk-bar capacity math, segment/tooltip derivation, and resize-observer runtime through `frontend-modern/src/components/Dashboard/stackedDiskBarModel.ts` and `frontend-modern/src/components/Dashboard/useStackedDiskBarState.ts` rather than rebuilding disk-bar-local state, mode branching, and tooltip shaping inside `frontend-modern/src/components/Dashboard/StackedDiskBar.tsx` -16. 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` -17. 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` -18. 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` +13. Extend dashboard workload route ownership, deep-link normalization, route-driven filter state, and workload filter-config assembly through `frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts` rather than rebuilding query-param parsing, route sync, or host/runtime/namespace filter shaping inside `frontend-modern/src/components/Dashboard/useDashboardState.ts` +14. Extend dashboard filter defaults, active-filter counting, 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` +15. 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` +16. Extend stacked disk-bar capacity math, segment/tooltip derivation, and resize-observer runtime through `frontend-modern/src/components/Dashboard/stackedDiskBarModel.ts` and `frontend-modern/src/components/Dashboard/useStackedDiskBarState.ts` rather than rebuilding disk-bar-local state, mode branching, and tooltip shaping inside `frontend-modern/src/components/Dashboard/StackedDiskBar.tsx` +17. 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` +18. 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` +19. 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` ## Forbidden Paths @@ -136,13 +138,15 @@ are now part of the protected performance surface rather than proof-only context. Future hot-path filter/group/sort/windowing changes must route through the explicit dashboard performance proof policy in the subsystem registry. That runtime is now intentionally split by concern: -`frontend-modern/src/components/Dashboard/useDashboardState.ts` owns workload -route synchronization, grouping/windowing, and filter/sort state, while +`frontend-modern/src/components/Dashboard/useDashboardState.ts` owns grouped +workload derivation, summary fallbacks, sorting, and windowing, while `frontend-modern/src/components/Dashboard/useDashboardGuestMetadataState.ts` owns guest metadata cache persistence, optimistic custom-URL updates, -org-scope switching, and metadata refresh. Future dashboard hot-path changes -must extend through those owners instead of accreting back into -`frontend-modern/src/components/Dashboard/Dashboard.tsx`. +org-scope switching, and metadata refresh, and +`frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts` +owns workload-route synchronization, deep-link normalization, and route-scoped +filter contracts. Future dashboard hot-path changes must extend through those +owners instead of accreting back into `frontend-modern/src/components/Dashboard/Dashboard.tsx`. 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`, diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 1dface781..9c7d362ec 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2543,6 +2543,7 @@ "frontend-modern/src/components/Dashboard/useDashboardFilterState.ts", "frontend-modern/src/components/Dashboard/useDashboardGuestMetadataState.ts", "frontend-modern/src/components/Dashboard/useDashboardState.ts", + "frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts", "frontend-modern/src/components/Dashboard/useDiskListState.ts", "frontend-modern/src/components/Dashboard/useEnhancedCPUBarState.ts", "frontend-modern/src/components/Dashboard/useGuestDrawerState.ts", @@ -2631,6 +2632,7 @@ "frontend-modern/src/components/Dashboard/useDashboardFilterState.ts", "frontend-modern/src/components/Dashboard/useDashboardGuestMetadataState.ts", "frontend-modern/src/components/Dashboard/useDashboardState.ts", + "frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts", "frontend-modern/src/components/Dashboard/useDiskListState.ts", "frontend-modern/src/components/Dashboard/useEnhancedCPUBarState.ts", "frontend-modern/src/components/Dashboard/useGuestDrawerState.ts", diff --git a/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx b/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx index 431a6d0ae..b189c3202 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx +++ b/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx @@ -7,6 +7,7 @@ import dashboardSource from '../Dashboard.tsx?raw'; import dashboardFilterSource from '../DashboardFilter.tsx?raw'; import dashboardFilterModelSource from '../dashboardFilterModel.ts?raw'; import dashboardGuestMetadataStateSource from '../useDashboardGuestMetadataState.ts?raw'; +import dashboardWorkloadRouteStateSource from '../useDashboardWorkloadRouteState.ts?raw'; import dashboardStateSource from '../useDashboardState.ts?raw'; import dashboardFilterStateSource from '../useDashboardFilterState.ts?raw'; import thresholdSliderSource from '../ThresholdSlider.tsx?raw'; @@ -491,14 +492,18 @@ describe('Dashboard performance contract', () => { expect(dashboardSource).toContain('useDashboardState'); expect(dashboardSource).not.toContain('const [search, setSearch] = createSignal('); expect(dashboardStateSource).toContain('useDashboardGuestMetadataState'); + expect(dashboardStateSource).toContain('useDashboardWorkloadRouteState'); expect(dashboardStateSource).toContain('useGroupedTableWindowing'); expect(dashboardStateSource).toContain('createWorkloadSortComparator'); expect(dashboardStateSource).toContain("from './guestRowModel'"); expect(dashboardStateSource).not.toContain("from './GuestRow'"); expect(dashboardStateSource).not.toContain('GuestMetadataAPI.getAllMetadata()'); + expect(dashboardStateSource).not.toContain('buildWorkloadsPath({'); expect(dashboardGuestMetadataStateSource).toContain('GuestMetadataAPI.getAllMetadata()'); expect(dashboardGuestMetadataStateSource).toContain("eventBus.on('org_switched'"); expect(dashboardGuestMetadataStateSource).toContain("window.addEventListener('pulse:metadata-changed'"); + expect(dashboardWorkloadRouteStateSource).toContain('buildWorkloadsPath({'); + expect(dashboardWorkloadRouteStateSource).toContain('normalizeWorkloadViewModeParam'); expect(dashboardSource).toContain('createMemo(() => getCanonicalWorkloadId(guest()))'); expect(dashboardStateSource).not.toContain('const guestId = () => {'); }); @@ -513,9 +518,11 @@ describe('Dashboard performance contract', () => { expect(dashboardFilterStateSource).toContain('useBreakpoint'); expect(dashboardFilterModelSource).toContain('export const countActiveDashboardFilters'); expect(dashboardFilterModelSource).toContain('export const hasActiveDashboardFilters'); - expect(dashboardStateSource).toContain('containerRuntimeFilterConfig'); - expect(dashboardStateSource).toContain('hostFilterConfig'); - expect(dashboardStateSource).toContain('namespaceFilterConfig'); + expect(dashboardStateSource).toContain('useDashboardWorkloadRouteState'); + expect(dashboardStateSource).not.toContain('const containerRuntimeFilterConfig = createMemo'); + expect(dashboardWorkloadRouteStateSource).toContain('containerRuntimeFilterConfig'); + expect(dashboardWorkloadRouteStateSource).toContain('hostFilterConfig'); + expect(dashboardWorkloadRouteStateSource).toContain('namespaceFilterConfig'); }); it('keeps threshold slider runtime and derivations in canonical slider owners', () => { diff --git a/frontend-modern/src/components/Dashboard/useDashboardState.ts b/frontend-modern/src/components/Dashboard/useDashboardState.ts index 50a3a81fb..ff0f7793f 100644 --- a/frontend-modern/src/components/Dashboard/useDashboardState.ts +++ b/frontend-modern/src/components/Dashboard/useDashboardState.ts @@ -1,12 +1,11 @@ -import { createEffect, createMemo, createSignal, onCleanup, untrack } from 'solid-js'; +import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; import { useLocation, useNavigate } from '@solidjs/router'; import type { VM, Container, Node } from '@/types/api'; -import type { WorkloadGuest, ViewMode } from '@/types/workloads'; +import type { WorkloadGuest } from '@/types/workloads'; import { GUEST_COLUMNS, VIEW_MODE_COLUMNS } from './guestRowModel'; import { useWebSocket } from '@/App'; import { useAlertsActivation } from '@/stores/alertsActivation'; import { getNodeDisplayName } from '@/utils/nodes'; -import { logger } from '@/utils/logger'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { useColumnVisibility } from '@/hooks/useColumnVisibility'; import { useBreakpoint } from '@/hooks/useBreakpoint'; @@ -23,20 +22,10 @@ import { } from '@/utils/dashboardEmptyStatePresentation'; import { getCanonicalWorkloadId, - normalizeWorkloadViewModeParam, - resolveWorkloadType, } from '@/utils/workloads'; import { isSummaryTimeRange } from '@/components/shared/summaryTimeRange'; +import { parseWorkloadsLinkSearch } from '@/routing/resourceLinks'; import { - buildWorkloadsPath, - parseWorkloadsLinkSearch, - WORKLOADS_PATH, - WORKLOADS_QUERY_PARAMS, -} from '@/routing/resourceLinks'; -import { areSearchParamsEquivalent } from '@/utils/searchParams'; -import { - workloadNodeScopeId, - getKubernetesContextKey, filterWorkloads, getDiskUsagePercent, createWorkloadSortComparator, @@ -51,8 +40,8 @@ import { } from './workloadSelectors'; import { useGroupedTableWindowing } from './useGroupedTableWindowing'; import { useDashboardGuestMetadataState } from './useDashboardGuestMetadataState'; +import { useDashboardWorkloadRouteState } from './useDashboardWorkloadRouteState'; import type { WorkloadSummarySnapshot } from '@/components/Workloads/WorkloadsSummary'; -import type { DashboardToolbarFilterConfig } from './dashboardFilterModel'; export interface DashboardProps { vms: VM[]; @@ -74,14 +63,13 @@ const workloadMetricPercent = (value: number | null | undefined): number => { }; export function useDashboardState(props: DashboardProps) { - const navigate = useNavigate(); const location = useLocation(); + const navigate = useNavigate(); const ws = useWebSocket(); const { connected, activeAlerts, initialDataReceived, reconnecting, reconnect } = ws; const { isMobile } = useBreakpoint(); const alertsActivation = useAlertsActivation(); const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); - const isWorkloadsRoute = () => location.pathname === WORKLOADS_PATH; const [search, setSearch] = createSignal(''); const kioskMode = useKioskMode(); @@ -91,45 +79,9 @@ export function useDashboardState(props: DashboardProps) { const dashboardDisconnectedState = createMemo(() => getDashboardDisconnectedState(reconnecting())); const [isSearchLocked, setIsSearchLocked] = createSignal(false); - const [selectedNode, setSelectedNode] = createSignal(null); - const [selectedKubernetesContext, setSelectedKubernetesContext] = createSignal( - null, - ); - const [selectedKubernetesNamespace, setSelectedKubernetesNamespace] = createSignal< - string | null - >(null); const [selectedGuestId, setSelectedGuestIdRaw] = createSignal(null); const [hoveredWorkloadId, setHoveredWorkloadId] = createSignal(null); const [handledResourceId, setHandledResourceId] = createSignal(null); - const [handledTypeParam, setHandledTypeParam] = createSignal(''); - const [handledRuntimeParam, setHandledRuntimeParam] = createSignal(''); - const [handledContextParam, setHandledContextParam] = createSignal(''); - const [handledNamespaceParam, setHandledNamespaceParam] = createSignal(''); - const [handledAgentParam, setHandledAgentParam] = createSignal(''); - const [selectedHostHint, setSelectedHostHint] = createSignal(null); - - let pendingUrlSyncHandle: number | null = null; - let pendingUrlSyncPath: string | null = null; - const scheduleUrlSyncNavigate = (nextPath: string) => { - pendingUrlSyncPath = nextPath; - if (pendingUrlSyncHandle !== null) return; - pendingUrlSyncHandle = window.setTimeout(() => { - pendingUrlSyncHandle = null; - const target = pendingUrlSyncPath; - pendingUrlSyncPath = null; - if (!target) return; - const current = `${untrack(() => location.pathname)}${untrack(() => location.search)}`; - if (current === target) return; - navigate(target, { replace: true }); - }, 0); - }; - onCleanup(() => { - if (pendingUrlSyncHandle !== null) { - window.clearTimeout(pendingUrlSyncHandle); - pendingUrlSyncHandle = null; - pendingUrlSyncPath = null; - } - }); let tableRef: HTMLDivElement | undefined; const [tableBodyRef, setTableBodyRef] = createSignal(null); @@ -189,19 +141,6 @@ export function useDashboardState(props: DashboardProps) { workloadsEnabled() ? dedupeGuests(workloads.workloads()) : [], ); - const [viewMode, setViewMode] = usePersistentSignal('dashboardViewMode', 'all', { - deserialize: (raw) => normalizeWorkloadViewModeParam(raw) ?? 'all', - }); - - const [containerRuntime, setContainerRuntime] = usePersistentSignal( - 'dashboardContainerRuntime', - '', - { - deserialize: (raw) => (typeof raw === 'string' ? raw : ''), - serialize: (value) => value, - }, - ); - const [statusMode, setStatusMode] = usePersistentSignal( 'dashboardStatusMode', 'all', @@ -245,312 +184,31 @@ export function useDashboardState(props: DashboardProps) { const [sortKey, setSortKey] = createSignal('type'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); - const workloadNodeOptions = createMemo(() => { - const labelsByScope = new Map(); - const nodeNameCounts = new Map(); - - for (const guest of allGuests()) { - const type = resolveWorkloadType(guest); - if (type === 'pod') continue; - const scope = workloadNodeScopeId(guest); - if (!scope || scope === '-') continue; - const nodeName = (guest.node || '').trim(); - if (!nodeName) continue; - nodeNameCounts.set(nodeName, (nodeNameCounts.get(nodeName) || 0) + 1); - } - - for (const guest of allGuests()) { - const type = resolveWorkloadType(guest); - if (type === 'pod') continue; - const scope = workloadNodeScopeId(guest); - if (!scope || scope === '-' || labelsByScope.has(scope)) continue; - const nodeName = (guest.node || '').trim(); - const instance = (guest.instance || '').trim(); - if (!nodeName) continue; - const hasDuplicateNodeName = (nodeNameCounts.get(nodeName) || 0) > 1; - const label = hasDuplicateNodeName && instance ? `${nodeName} (${instance})` : nodeName; - labelsByScope.set(scope, label); - } - - return Array.from(labelsByScope.entries()) - .map(([value, label]) => ({ value, label })) - .sort((a, b) => a.label.localeCompare(b.label)); - }); - - createEffect(() => { - if (viewMode() === 'pod') return; - const hostHint = selectedHostHint(); - if (!hostHint || selectedNode() !== null) return; - const normalizedHint = hostHint.trim().toLowerCase(); - if (!normalizedHint) return; - const option = workloadNodeOptions().find((candidate) => { - const label = candidate.label.toLowerCase(); - const value = candidate.value.toLowerCase(); - return label === normalizedHint || value === normalizedHint || label.includes(normalizedHint); - }); - if (!option) return; - setSelectedNode(option.value); - setSelectedHostHint(null); - }); - - const kubernetesContextOptions = createMemo(() => { - const contexts = new Set(); - for (const guest of allGuests()) { - if (resolveWorkloadType(guest) !== 'pod') continue; - const context = getKubernetesContextKey(guest); - if (context) { - contexts.add(context); - } - } - return Array.from(contexts).sort((a, b) => a.localeCompare(b)); - }); - - const kubernetesNamespaceOptions = createMemo(() => { - const namespaces = new Set(); - const contextFilter = (selectedKubernetesContext() || '').trim(); - for (const guest of allGuests()) { - if (resolveWorkloadType(guest) !== 'pod') continue; - if (contextFilter && getKubernetesContextKey(guest) !== contextFilter) continue; - const ns = (guest.namespace || '').trim(); - if (ns) namespaces.add(ns); - } - return Array.from(namespaces).sort((a, b) => a.localeCompare(b)); - }); - - createEffect(() => { - if (!isWorkloadsRoute()) return; - if (viewMode() !== 'pod') return; - const selected = (selectedKubernetesNamespace() || '').trim(); - if (!selected) return; - const normalized = selected.toLowerCase(); - const exists = kubernetesNamespaceOptions().some((value) => value.toLowerCase() === normalized); - if (!exists) { - setSelectedKubernetesNamespace(null); - } - }); - - const containerRuntimeOptions = createMemo(() => { - const runtimes = new Set(); - for (const guest of allGuests()) { - if (resolveWorkloadType(guest) !== 'app-container') continue; - const runtime = (guest.containerRuntime || '').trim(); - if (runtime) { - runtimes.add(runtime); - } - } - return Array.from(runtimes).sort((a, b) => a.localeCompare(b)); - }); - - createEffect(() => { - if (!isWorkloadsRoute()) return; - if (viewMode() !== 'app-container') return; - const selected = containerRuntime().trim(); - if (!selected) return; - const normalized = selected.toLowerCase(); - const exists = containerRuntimeOptions().some((value) => value.toLowerCase() === normalized); - if (!exists) { - setContainerRuntime(''); - } - }); - - createEffect(() => { - if (!isWorkloadsRoute()) return; - if (viewMode() === 'pod') { - if (selectedNode() !== null) { - setSelectedNode(null); - } - if (selectedHostHint() !== null) { - setSelectedHostHint(null); - } - return; - } - if (selectedKubernetesContext() !== null) { - setSelectedKubernetesContext(null); - } - if (selectedKubernetesNamespace() !== null) { - setSelectedKubernetesNamespace(null); - } - }); - - createEffect(() => { - if (!isWorkloadsRoute()) return; - if (viewMode() !== 'app-container' && containerRuntime().trim() !== '') { - setContainerRuntime(''); - } - }); - - createEffect(() => { - const parsed = parseWorkloadsLinkSearch(location.search); - const typeParam = parsed.type; - const normalizedType = typeParam ?? ''; - if (normalizedType === handledTypeParam()) return; - - if (!normalizedType) { - setHandledTypeParam(''); - return; - } - - const hasK8sScope = - Boolean((parsed.context ?? '').trim()) || Boolean((parsed.namespace ?? '').trim()); - const nextMode = normalizeWorkloadViewModeParam(normalizedType); - if (!nextMode) { - setHandledTypeParam(normalizedType); - return; - } - if (hasK8sScope && nextMode !== 'pod') { - setHandledTypeParam(normalizedType); - return; - } - - setViewMode(nextMode); - setHandledTypeParam(normalizedType); - }); - - createEffect(() => { - const { context: contextParam } = parseWorkloadsLinkSearch(location.search); - const normalized = contextParam ?? ''; - if (normalized === handledContextParam()) return; - - if (normalized) { - if (viewMode() !== 'pod') { - setViewMode('pod'); - } - setSelectedKubernetesContext(normalized); - if (!showFilters()) { - setShowFilters(true); - } - setHandledContextParam(normalized); - return; - } - - setSelectedKubernetesContext(null); - setHandledContextParam(''); - }); - - createEffect(() => { - const { namespace: namespaceParam } = parseWorkloadsLinkSearch(location.search); - const normalized = namespaceParam ?? ''; - if (normalized === handledNamespaceParam()) return; - - if (normalized) { - if (viewMode() !== 'pod') { - setViewMode('pod'); - } - setSelectedKubernetesNamespace(normalized); - if (!showFilters()) { - setShowFilters(true); - } - setHandledNamespaceParam(normalized); - return; - } - - setSelectedKubernetesNamespace(null); - setHandledNamespaceParam(''); - }); - - createEffect(() => { - const { agent: agentParam } = parseWorkloadsLinkSearch(location.search); - const normalized = agentParam ?? ''; - if (normalized === handledAgentParam()) return; - - if (normalized) { - setSelectedHostHint(normalized); - if (!showFilters()) { - setShowFilters(true); - } - setHandledAgentParam(normalized); - return; - } - - setSelectedHostHint(null); - if (selectedNode() !== null) { - setSelectedNode(null); - } - setHandledAgentParam(''); - }); - - createEffect(() => { - const parsed = parseWorkloadsLinkSearch(location.search); - const urlRuntime = parsed.runtime ?? ''; - if (urlRuntime === handledRuntimeParam()) return; - - const urlContext = parsed.context ?? ''; - const hasContext = Boolean(urlContext.trim()); - const hasNamespace = Boolean((parsed.namespace ?? '').trim()); - const urlType = parsed.type ?? ''; - const nextMode = normalizeWorkloadViewModeParam(urlType); - const runtimeRelevant = - !hasContext && !hasNamespace && (nextMode === 'app-container' || !urlType.trim()); - - if (!runtimeRelevant) { - setHandledRuntimeParam(urlRuntime); - return; - } - - if (!urlRuntime.trim()) { - setContainerRuntime(''); - setHandledRuntimeParam(''); - return; - } - - if (viewMode() !== 'app-container') { - setViewMode('app-container'); - } - setContainerRuntime(urlRuntime); - if (!showFilters()) { - setShowFilters(true); - } - setHandledRuntimeParam(urlRuntime); - }); - - createEffect(() => { - if (!isWorkloadsRoute()) return; - - const parsed = parseWorkloadsLinkSearch(location.search); - const urlType = parsed.type ?? ''; - const urlRuntime = parsed.runtime ?? ''; - const urlContext = parsed.context ?? ''; - const urlNamespace = parsed.namespace ?? ''; - const urlAgent = parsed.agent ?? ''; - const urlResource = parsed.resource ?? ''; - - if (handledTypeParam() !== urlType) return; - if (handledRuntimeParam() !== urlRuntime) return; - if (handledContextParam() !== urlContext) return; - if (handledNamespaceParam() !== urlNamespace) return; - if (handledAgentParam() !== urlAgent) return; - if (urlResource && handledResourceId() !== urlResource) return; - - const currentParams = new URLSearchParams(location.search); - const nextParams = new URLSearchParams(location.search); - const nextType = viewMode() === 'all' ? '' : viewMode(); - const nextRuntime = viewMode() === 'app-container' ? containerRuntime().trim() : ''; - const nextContext = viewMode() === 'pod' ? (selectedKubernetesContext() ?? '') : ''; - const nextNamespace = viewMode() === 'pod' ? (selectedKubernetesNamespace() ?? '') : ''; - const nextAgent = viewMode() === 'pod' ? '' : (selectedNode() ?? selectedHostHint() ?? ''); - - const managedPath = buildWorkloadsPath({ - type: nextType || null, - runtime: nextRuntime || null, - context: nextContext || null, - namespace: nextNamespace || null, - agent: nextAgent || null, - }); - const managedUrl = new URL(managedPath, 'http://pulse.local'); - nextParams.delete(WORKLOADS_QUERY_PARAMS.type); - nextParams.delete(WORKLOADS_QUERY_PARAMS.runtime); - nextParams.delete(WORKLOADS_QUERY_PARAMS.context); - nextParams.delete(WORKLOADS_QUERY_PARAMS.namespace); - nextParams.delete(WORKLOADS_QUERY_PARAMS.agent); - managedUrl.searchParams.forEach((value, key) => { - nextParams.set(key, value); - }); - - if (!areSearchParamsEquivalent(currentParams, nextParams)) { - const nextSearch = nextParams.toString(); - const nextPath = nextSearch ? `${WORKLOADS_PATH}?${nextSearch}` : WORKLOADS_PATH; - scheduleUrlSyncNavigate(nextPath); - } + const { + containerRuntime, + containerRuntimeFilterConfig, + handleNodeSelect, + hostFilterConfig, + isWorkloadsRoute, + kubernetesContextOptions, + kubernetesNamespaceOptions, + namespaceFilterConfig, + resetWorkloadRouteFilters, + selectedHostHint, + selectedKubernetesContext, + selectedKubernetesNamespace, + selectedNode, + setContainerRuntime, + setSelectedKubernetesContext, + setSelectedKubernetesNamespace, + setViewMode, + viewMode, + workloadNodeOptions, + containerRuntimeOptions, + } = useDashboardWorkloadRouteState({ + allGuests, + showFilters, + setShowFilters, }); const relevantColumns = createMemo(() => { @@ -638,12 +296,7 @@ export function useDashboardState(props: DashboardProps) { setIsSearchLocked(false); setSortKey('type'); setSortDirection('asc'); - setSelectedNode(null); - setSelectedHostHint(null); - setSelectedKubernetesContext(null); - setSelectedKubernetesNamespace(null); - setContainerRuntime(''); - setViewMode('all'); + resetWorkloadRouteFilters(); setStatusMode('all'); blurFocusedTypeToSearch(); @@ -879,19 +532,6 @@ export function useDashboardState(props: DashboardProps) { const totalStats = createMemo(() => computeWorkloadStats(filteredGuests())); const workloadIOEmphasis = createMemo(() => computeWorkloadIOEmphasis(filteredGuests())); - const handleNodeSelect = (nodeId: string | null, nodeType: 'pve' | 'pbs' | 'pmg' | null) => { - logger.debug('handleNodeSelect called', { nodeId, nodeType }); - - if (nodeType === 'pve' || nodeType === null) { - setSelectedHostHint(null); - setSelectedNode(nodeId); - logger.debug('Set selected node', { nodeId }); - if (nodeId && !showFilters()) { - setShowFilters(true); - } - } - }; - const handleBeforeAutoFocus = () => { if (aiChatStore.focusInput()) return true; if (!showFilters()) setShowFilters(true); @@ -942,74 +582,6 @@ export function useDashboardState(props: DashboardProps) { onColumnReset: columnVisibility.resetToDefaults, })); - const containerRuntimeFilterConfig = createMemo(() => { - if (!isWorkloadsRoute()) return undefined; - if (viewMode() !== 'app-container') return undefined; - - const options = containerRuntimeOptions(); - if (options.length === 0) return undefined; - - return { - id: 'workloads-container-runtime-filter', - label: 'Runtime', - value: containerRuntime(), - options: [ - { value: '', label: 'All runtimes' }, - ...options.map((value) => ({ value, label: value })), - ], - onChange: (value: string) => setContainerRuntime(value), - }; - }); - - const hostFilterConfig = createMemo(() => { - if (!isWorkloadsRoute()) return undefined; - - if (viewMode() === 'pod') { - return { - id: 'workloads-k8s-context-filter', - label: 'Cluster', - value: selectedKubernetesContext() ?? '', - options: [ - { value: '', label: 'All clusters' }, - ...kubernetesContextOptions().map((context) => ({ - value: context, - label: context, - })), - ], - onChange: (value: string) => setSelectedKubernetesContext(value || null), - }; - } - - return { - id: 'workloads-node-filter', - label: 'Node', - value: selectedNode() ?? '', - options: [{ value: '', label: 'All nodes' }, ...workloadNodeOptions()], - onChange: (value: string) => { - handleNodeSelect(value || null, value ? 'pve' : null); - }, - }; - }); - - const namespaceFilterConfig = createMemo(() => { - if (!isWorkloadsRoute()) return undefined; - if (viewMode() !== 'pod') return undefined; - - const options = kubernetesNamespaceOptions(); - if (options.length === 0) return undefined; - - return { - id: 'workloads-k8s-namespace-filter', - label: 'Namespace', - value: selectedKubernetesNamespace() ?? '', - options: [ - { value: '', label: 'All namespaces' }, - ...options.map((value) => ({ value, label: value })), - ], - onChange: (value: string) => setSelectedKubernetesNamespace(value || null), - }; - }); - return { activeAlerts, alertsEnabled, diff --git a/frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts b/frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts new file mode 100644 index 000000000..388a72618 --- /dev/null +++ b/frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts @@ -0,0 +1,502 @@ +import { + createEffect, + createMemo, + createSignal, + onCleanup, + untrack, + type Accessor, + type Setter, +} from 'solid-js'; +import { useLocation, useNavigate } from '@solidjs/router'; +import type { WorkloadGuest, ViewMode } from '@/types/workloads'; +import { usePersistentSignal } from '@/hooks/usePersistentSignal'; +import { + buildWorkloadsPath, + parseWorkloadsLinkSearch, + WORKLOADS_PATH, + WORKLOADS_QUERY_PARAMS, +} from '@/routing/resourceLinks'; +import { areSearchParamsEquivalent } from '@/utils/searchParams'; +import { normalizeWorkloadViewModeParam, resolveWorkloadType } from '@/utils/workloads'; +import { getKubernetesContextKey, workloadNodeScopeId } from './workloadSelectors'; +import type { DashboardToolbarFilterConfig } from './dashboardFilterModel'; + +export interface DashboardWorkloadRouteStateOptions { + allGuests: Accessor; + showFilters: Accessor; + setShowFilters: Setter; +} + +export function useDashboardWorkloadRouteState(options: DashboardWorkloadRouteStateOptions) { + const navigate = useNavigate(); + const location = useLocation(); + const isWorkloadsRoute = () => location.pathname === WORKLOADS_PATH; + + const [selectedNode, setSelectedNode] = createSignal(null); + const [selectedKubernetesContext, setSelectedKubernetesContext] = createSignal( + null, + ); + const [selectedKubernetesNamespace, setSelectedKubernetesNamespace] = createSignal< + string | null + >(null); + const [handledTypeParam, setHandledTypeParam] = createSignal(''); + const [handledRuntimeParam, setHandledRuntimeParam] = createSignal(''); + const [handledContextParam, setHandledContextParam] = createSignal(''); + const [handledNamespaceParam, setHandledNamespaceParam] = createSignal(''); + const [handledAgentParam, setHandledAgentParam] = createSignal(''); + const [selectedHostHint, setSelectedHostHint] = createSignal(null); + + let pendingUrlSyncHandle: number | null = null; + let pendingUrlSyncPath: string | null = null; + const scheduleUrlSyncNavigate = (nextPath: string) => { + pendingUrlSyncPath = nextPath; + if (pendingUrlSyncHandle !== null) return; + pendingUrlSyncHandle = window.setTimeout(() => { + pendingUrlSyncHandle = null; + const target = pendingUrlSyncPath; + pendingUrlSyncPath = null; + if (!target) return; + const current = `${untrack(() => location.pathname)}${untrack(() => location.search)}`; + if (current === target) return; + navigate(target, { replace: true }); + }, 0); + }; + onCleanup(() => { + if (pendingUrlSyncHandle !== null) { + window.clearTimeout(pendingUrlSyncHandle); + pendingUrlSyncHandle = null; + pendingUrlSyncPath = null; + } + }); + + const [viewMode, setViewMode] = usePersistentSignal('dashboardViewMode', 'all', { + deserialize: (raw) => normalizeWorkloadViewModeParam(raw) ?? 'all', + }); + + const [containerRuntime, setContainerRuntime] = usePersistentSignal( + 'dashboardContainerRuntime', + '', + { + deserialize: (raw) => (typeof raw === 'string' ? raw : ''), + serialize: (value) => value, + }, + ); + + const workloadNodeOptions = createMemo(() => { + const labelsByScope = new Map(); + const nodeNameCounts = new Map(); + + for (const guest of options.allGuests()) { + const type = resolveWorkloadType(guest); + if (type === 'pod') continue; + const scope = workloadNodeScopeId(guest); + if (!scope || scope === '-') continue; + const nodeName = (guest.node || '').trim(); + if (!nodeName) continue; + nodeNameCounts.set(nodeName, (nodeNameCounts.get(nodeName) || 0) + 1); + } + + for (const guest of options.allGuests()) { + const type = resolveWorkloadType(guest); + if (type === 'pod') continue; + const scope = workloadNodeScopeId(guest); + if (!scope || scope === '-' || labelsByScope.has(scope)) continue; + const nodeName = (guest.node || '').trim(); + const instance = (guest.instance || '').trim(); + if (!nodeName) continue; + const hasDuplicateNodeName = (nodeNameCounts.get(nodeName) || 0) > 1; + const label = hasDuplicateNodeName && instance ? `${nodeName} (${instance})` : nodeName; + labelsByScope.set(scope, label); + } + + return Array.from(labelsByScope.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }); + + createEffect(() => { + if (viewMode() === 'pod') return; + const hostHint = selectedHostHint(); + if (!hostHint || selectedNode() !== null) return; + const normalizedHint = hostHint.trim().toLowerCase(); + if (!normalizedHint) return; + const option = workloadNodeOptions().find((candidate) => { + const label = candidate.label.toLowerCase(); + const value = candidate.value.toLowerCase(); + return label === normalizedHint || value === normalizedHint || label.includes(normalizedHint); + }); + if (!option) return; + setSelectedNode(option.value); + setSelectedHostHint(null); + }); + + const kubernetesContextOptions = createMemo(() => { + const contexts = new Set(); + for (const guest of options.allGuests()) { + if (resolveWorkloadType(guest) !== 'pod') continue; + const context = getKubernetesContextKey(guest); + if (context) { + contexts.add(context); + } + } + return Array.from(contexts).sort((a, b) => a.localeCompare(b)); + }); + + const kubernetesNamespaceOptions = createMemo(() => { + const namespaces = new Set(); + const contextFilter = (selectedKubernetesContext() || '').trim(); + for (const guest of options.allGuests()) { + if (resolveWorkloadType(guest) !== 'pod') continue; + if (contextFilter && getKubernetesContextKey(guest) !== contextFilter) continue; + const ns = (guest.namespace || '').trim(); + if (ns) namespaces.add(ns); + } + return Array.from(namespaces).sort((a, b) => a.localeCompare(b)); + }); + + createEffect(() => { + if (!isWorkloadsRoute()) return; + if (viewMode() !== 'pod') return; + const selected = (selectedKubernetesNamespace() || '').trim(); + if (!selected) return; + const normalized = selected.toLowerCase(); + const exists = kubernetesNamespaceOptions().some((value) => value.toLowerCase() === normalized); + if (!exists) { + setSelectedKubernetesNamespace(null); + } + }); + + const containerRuntimeOptions = createMemo(() => { + const runtimes = new Set(); + for (const guest of options.allGuests()) { + if (resolveWorkloadType(guest) !== 'app-container') continue; + const runtime = (guest.containerRuntime || '').trim(); + if (runtime) { + runtimes.add(runtime); + } + } + return Array.from(runtimes).sort((a, b) => a.localeCompare(b)); + }); + + createEffect(() => { + if (!isWorkloadsRoute()) return; + if (viewMode() !== 'app-container') return; + const selected = containerRuntime().trim(); + if (!selected) return; + const normalized = selected.toLowerCase(); + const exists = containerRuntimeOptions().some((value) => value.toLowerCase() === normalized); + if (!exists) { + setContainerRuntime(''); + } + }); + + createEffect(() => { + if (!isWorkloadsRoute()) return; + if (viewMode() === 'pod') { + if (selectedNode() !== null) { + setSelectedNode(null); + } + if (selectedHostHint() !== null) { + setSelectedHostHint(null); + } + return; + } + if (selectedKubernetesContext() !== null) { + setSelectedKubernetesContext(null); + } + if (selectedKubernetesNamespace() !== null) { + setSelectedKubernetesNamespace(null); + } + }); + + createEffect(() => { + if (!isWorkloadsRoute()) return; + if (viewMode() !== 'app-container' && containerRuntime().trim() !== '') { + setContainerRuntime(''); + } + }); + + createEffect(() => { + const parsed = parseWorkloadsLinkSearch(location.search); + const typeParam = parsed.type; + const normalizedType = typeParam ?? ''; + if (normalizedType === handledTypeParam()) return; + + if (!normalizedType) { + setHandledTypeParam(''); + return; + } + + const hasK8sScope = + Boolean((parsed.context ?? '').trim()) || Boolean((parsed.namespace ?? '').trim()); + const nextMode = normalizeWorkloadViewModeParam(normalizedType); + if (!nextMode) { + setHandledTypeParam(normalizedType); + return; + } + if (hasK8sScope && nextMode !== 'pod') { + setHandledTypeParam(normalizedType); + return; + } + + setViewMode(nextMode); + setHandledTypeParam(normalizedType); + }); + + createEffect(() => { + const { context: contextParam } = parseWorkloadsLinkSearch(location.search); + const normalized = contextParam ?? ''; + if (normalized === handledContextParam()) return; + + if (normalized) { + if (viewMode() !== 'pod') { + setViewMode('pod'); + } + setSelectedKubernetesContext(normalized); + if (!options.showFilters()) { + options.setShowFilters(true); + } + setHandledContextParam(normalized); + return; + } + + setSelectedKubernetesContext(null); + setHandledContextParam(''); + }); + + createEffect(() => { + const { namespace: namespaceParam } = parseWorkloadsLinkSearch(location.search); + const normalized = namespaceParam ?? ''; + if (normalized === handledNamespaceParam()) return; + + if (normalized) { + if (viewMode() !== 'pod') { + setViewMode('pod'); + } + setSelectedKubernetesNamespace(normalized); + if (!options.showFilters()) { + options.setShowFilters(true); + } + setHandledNamespaceParam(normalized); + return; + } + + setSelectedKubernetesNamespace(null); + setHandledNamespaceParam(''); + }); + + createEffect(() => { + const { agent: agentParam } = parseWorkloadsLinkSearch(location.search); + const normalized = agentParam ?? ''; + if (normalized === handledAgentParam()) return; + + if (normalized) { + setSelectedHostHint(normalized); + if (!options.showFilters()) { + options.setShowFilters(true); + } + setHandledAgentParam(normalized); + return; + } + + setSelectedHostHint(null); + if (selectedNode() !== null) { + setSelectedNode(null); + } + setHandledAgentParam(''); + }); + + createEffect(() => { + const parsed = parseWorkloadsLinkSearch(location.search); + const urlRuntime = parsed.runtime ?? ''; + if (urlRuntime === handledRuntimeParam()) return; + + const urlContext = parsed.context ?? ''; + const hasContext = Boolean(urlContext.trim()); + const hasNamespace = Boolean((parsed.namespace ?? '').trim()); + const urlType = parsed.type ?? ''; + const nextMode = normalizeWorkloadViewModeParam(urlType); + const runtimeRelevant = + !hasContext && !hasNamespace && (nextMode === 'app-container' || !urlType.trim()); + + if (!runtimeRelevant) { + setHandledRuntimeParam(urlRuntime); + return; + } + + if (!urlRuntime.trim()) { + setContainerRuntime(''); + setHandledRuntimeParam(''); + return; + } + + if (viewMode() !== 'app-container') { + setViewMode('app-container'); + } + setContainerRuntime(urlRuntime); + if (!options.showFilters()) { + options.setShowFilters(true); + } + setHandledRuntimeParam(urlRuntime); + }); + + createEffect(() => { + if (!isWorkloadsRoute()) return; + + const parsed = parseWorkloadsLinkSearch(location.search); + const urlType = parsed.type ?? ''; + const urlRuntime = parsed.runtime ?? ''; + const urlContext = parsed.context ?? ''; + const urlNamespace = parsed.namespace ?? ''; + const urlAgent = parsed.agent ?? ''; + const urlResource = parsed.resource ?? ''; + + if (handledTypeParam() !== urlType) return; + if (handledRuntimeParam() !== urlRuntime) return; + if (handledContextParam() !== urlContext) return; + if (handledNamespaceParam() !== urlNamespace) return; + if (handledAgentParam() !== urlAgent) return; + if (urlResource) return; + + const currentParams = new URLSearchParams(location.search); + const nextParams = new URLSearchParams(location.search); + const nextType = viewMode() === 'all' ? '' : viewMode(); + const nextRuntime = viewMode() === 'app-container' ? containerRuntime().trim() : ''; + const nextContext = viewMode() === 'pod' ? (selectedKubernetesContext() ?? '') : ''; + const nextNamespace = viewMode() === 'pod' ? (selectedKubernetesNamespace() ?? '') : ''; + const nextAgent = viewMode() === 'pod' ? '' : (selectedNode() ?? selectedHostHint() ?? ''); + + const managedPath = buildWorkloadsPath({ + type: nextType || null, + runtime: nextRuntime || null, + context: nextContext || null, + namespace: nextNamespace || null, + agent: nextAgent || null, + }); + const managedUrl = new URL(managedPath, 'http://pulse.local'); + nextParams.delete(WORKLOADS_QUERY_PARAMS.type); + nextParams.delete(WORKLOADS_QUERY_PARAMS.runtime); + nextParams.delete(WORKLOADS_QUERY_PARAMS.context); + nextParams.delete(WORKLOADS_QUERY_PARAMS.namespace); + nextParams.delete(WORKLOADS_QUERY_PARAMS.agent); + managedUrl.searchParams.forEach((value, key) => { + nextParams.set(key, value); + }); + + if (!areSearchParamsEquivalent(currentParams, nextParams)) { + const nextSearch = nextParams.toString(); + const nextPath = nextSearch ? `${WORKLOADS_PATH}?${nextSearch}` : WORKLOADS_PATH; + scheduleUrlSyncNavigate(nextPath); + } + }); + + const handleNodeSelect = (nodeId: string | null, nodeType: 'pve' | 'pbs' | 'pmg' | null) => { + if (nodeType === 'pve' || nodeType === null) { + setSelectedHostHint(null); + setSelectedNode(nodeId); + if (nodeId && !options.showFilters()) { + options.setShowFilters(true); + } + } + }; + + const resetWorkloadRouteFilters = () => { + setSelectedNode(null); + setSelectedHostHint(null); + setSelectedKubernetesContext(null); + setSelectedKubernetesNamespace(null); + setContainerRuntime(''); + setViewMode('all'); + }; + + const containerRuntimeFilterConfig = createMemo(() => { + if (!isWorkloadsRoute()) return undefined; + if (viewMode() !== 'app-container') return undefined; + + const options = containerRuntimeOptions(); + if (options.length === 0) return undefined; + + return { + id: 'workloads-container-runtime-filter', + label: 'Runtime', + value: containerRuntime(), + options: [ + { value: '', label: 'All runtimes' }, + ...options.map((value) => ({ value, label: value })), + ], + onChange: (value: string) => setContainerRuntime(value), + }; + }); + + const hostFilterConfig = createMemo(() => { + if (!isWorkloadsRoute()) return undefined; + + if (viewMode() === 'pod') { + return { + id: 'workloads-k8s-context-filter', + label: 'Cluster', + value: selectedKubernetesContext() ?? '', + options: [ + { value: '', label: 'All clusters' }, + ...kubernetesContextOptions().map((context) => ({ + value: context, + label: context, + })), + ], + onChange: (value: string) => setSelectedKubernetesContext(value || null), + }; + } + + return { + id: 'workloads-node-filter', + label: 'Node', + value: selectedNode() ?? '', + options: [{ value: '', label: 'All nodes' }, ...workloadNodeOptions()], + onChange: (value: string) => { + handleNodeSelect(value || null, value ? 'pve' : null); + }, + }; + }); + + const namespaceFilterConfig = createMemo(() => { + if (!isWorkloadsRoute()) return undefined; + if (viewMode() !== 'pod') return undefined; + + const options = kubernetesNamespaceOptions(); + if (options.length === 0) return undefined; + + return { + id: 'workloads-k8s-namespace-filter', + label: 'Namespace', + value: selectedKubernetesNamespace() ?? '', + options: [ + { value: '', label: 'All namespaces' }, + ...options.map((value) => ({ value, label: value })), + ], + onChange: (value: string) => setSelectedKubernetesNamespace(value || null), + }; + }); + + return { + containerRuntime, + containerRuntimeFilterConfig, + containerRuntimeOptions, + handleNodeSelect, + hostFilterConfig, + isWorkloadsRoute, + kubernetesContextOptions, + kubernetesNamespaceOptions, + namespaceFilterConfig, + resetWorkloadRouteFilters, + selectedHostHint, + selectedKubernetesContext, + selectedKubernetesNamespace, + selectedNode, + setContainerRuntime, + setSelectedKubernetesContext, + setSelectedKubernetesNamespace, + setViewMode, + viewMode, + workloadNodeOptions, + } as const; +} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index ca5d8907e..634450004 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -82,6 +82,7 @@ import dashboardSource from '@/components/Dashboard/Dashboard.tsx?raw'; import dashboardFilterSource from '@/components/Dashboard/DashboardFilter.tsx?raw'; import dashboardFilterModelSource from '@/components/Dashboard/dashboardFilterModel.ts?raw'; import dashboardGuestMetadataStateSource from '@/components/Dashboard/useDashboardGuestMetadataState.ts?raw'; +import dashboardWorkloadRouteStateSource from '@/components/Dashboard/useDashboardWorkloadRouteState.ts?raw'; import dashboardStateSource from '@/components/Dashboard/useDashboardState.ts?raw'; import dashboardFilterStateSource from '@/components/Dashboard/useDashboardFilterState.ts?raw'; import thresholdSliderModelSource from '@/components/Dashboard/thresholdSliderModel.ts?raw'; @@ -511,7 +512,8 @@ describe('frontend resource type boundaries', () => { expect(dashboardSource).toContain('useDashboardState'); expect(dashboardSource).not.toContain('const [search, setSearch] = createSignal('); expect(dashboardStateSource).toContain('useDashboardGuestMetadataState'); - expect(dashboardStateSource).toContain('normalizeWorkloadViewModeParam'); + expect(dashboardStateSource).toContain('useDashboardWorkloadRouteState'); + expect(dashboardWorkloadRouteStateSource).toContain('normalizeWorkloadViewModeParam'); expect(dashboardSource).not.toContain('function normalizeViewModeParam'); expect(dashboardSource).not.toContain('workloadSummaryGuestId'); expect(dashboardSource).toContain('createMemo(() => getCanonicalWorkloadId(guest()))'); @@ -521,6 +523,8 @@ describe('frontend resource type boundaries', () => { expect(dashboardStateSource).not.toContain('GuestMetadataAPI.getAllMetadata()'); expect(dashboardGuestMetadataStateSource).toContain('GuestMetadataAPI.getAllMetadata()'); expect(dashboardGuestMetadataStateSource).toContain("eventBus.on('org_switched'"); + expect(dashboardStateSource).not.toContain('buildWorkloadsPath({'); + expect(dashboardWorkloadRouteStateSource).toContain('buildWorkloadsPath({'); expect(dashboardStateSource).not.toContain('const guestId = () => {'); expect(dashboardFilterSource).toContain('useDashboardFilterState'); expect(dashboardFilterSource).not.toContain('const [filtersOpen, setFiltersOpen] ='); @@ -531,9 +535,10 @@ describe('frontend resource type boundaries', () => { expect(dashboardFilterStateSource).toContain('useBreakpoint'); expect(dashboardFilterModelSource).toContain('export const countActiveDashboardFilters'); expect(dashboardFilterModelSource).toContain('export const hasActiveDashboardFilters'); - expect(dashboardStateSource).toContain('containerRuntimeFilterConfig'); - expect(dashboardStateSource).toContain('hostFilterConfig'); - expect(dashboardStateSource).toContain('namespaceFilterConfig'); + expect(dashboardStateSource).not.toContain('const containerRuntimeFilterConfig = createMemo'); + expect(dashboardWorkloadRouteStateSource).toContain('containerRuntimeFilterConfig'); + expect(dashboardWorkloadRouteStateSource).toContain('hostFilterConfig'); + expect(dashboardWorkloadRouteStateSource).toContain('namespaceFilterConfig'); expect(thresholdSliderSource).toContain('useThresholdSliderState'); expect(thresholdSliderSource).not.toContain('const [thumbPosition, setThumbPosition] ='); expect(thresholdSliderSource).not.toContain('const handleMouseDown = () => {'); diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index ab772d24b..21294a7e7 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -1848,6 +1848,33 @@ class SubsystemLookupTest(unittest.TestCase): "dashboard-workload-hot-path", ) + def test_lookup_paths_assigns_dashboard_workload_route_runtime_to_performance_and_scalability( + self, + ) -> None: + result = lookup_paths( + ["frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.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_guest_drawer_runtime_to_performance_and_scalability(self) -> None: result = lookup_paths( [