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 a5feb5852..6f1124290 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -28,17 +28,19 @@ regression protection. 6. `frontend-modern/src/components/Dashboard/Dashboard.tsx` 7. `frontend-modern/src/components/Dashboard/workloadSelectors.ts` 8. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` -9. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` -10. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` -11. `frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx` -12. `frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx` +9. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` +10. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` +11. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` +12. `frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx` +13. `frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx` ## Shared Boundaries 1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `unified-resources`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 3. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` shared with `unified-resources`: the unified resource table is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -4. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. +4. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` shared with `unified-resources`: unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. +5. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. ## Extension Points @@ -82,6 +84,10 @@ The unified resource table hot path is now also governed as explicit performance-owned runtime, with shared ownership against the unified-resource consumer boundary. The remaining performance work is no longer top-level ownership ambiguity on the main dashboard or infrastructure tables. +The table's sort, grouping, row-windowing, and viewport-sync owner now lives +in `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`, +so future hot-path table-state changes must route through that state owner +instead of rebuilding selector and scroll coordination inside the render shell. That hot-path contract now includes policy badge rendering on resource rows. It now also includes the compact resource-facet summary chips rendered next to policy metadata, and those chips must stay within the same bounded diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 7c25beb43..0c897eb8b 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -81,6 +81,14 @@ "unified-resources" ] }, + { + "path": "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", + "rationale": "unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", + "subsystems": [ + "performance-and-scalability", + "unified-resources" + ] + }, { "path": "frontend-modern/src/components/Settings/APITokenManager.tsx", "rationale": "the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary", @@ -2114,6 +2122,7 @@ "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts", "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", + "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", "internal/api/router_bench_test.go", "internal/api/slo.go", "internal/api/slo_bench_test.go" @@ -2156,7 +2165,8 @@ "frontend-modern/src/components/Dashboard/workloadSelectors.ts", "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts", "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", - "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx" + "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", + "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts" ], "allow_same_subsystem_tests": false, "test_prefixes": [], @@ -2499,6 +2509,7 @@ "frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx", "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts", + "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", "frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx", "frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts", "frontend-modern/src/hooks/useDashboardTrends.ts", @@ -2570,6 +2581,7 @@ "frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx", "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts", + "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", "frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx", "frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts", "frontend-modern/src/hooks/useDashboardTrends.ts", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 913c4f186..28f3ee168 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -46,15 +46,17 @@ cross-source deduplication. 24. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx` 25. `frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx` 26. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts` -27. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx` -28. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts` +27. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` +28. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx` +29. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts` ## Shared Boundaries 1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `performance-and-scalability`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `performance-and-scalability`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 3. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` shared with `performance-and-scalability`: the unified resource table is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -4. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. +4. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` shared with `performance-and-scalability`: unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. +5. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. ## Extension Points @@ -199,6 +201,11 @@ The shared node-state adapter also routes Proxmox cluster labels through that same helper, so infrastructure summary projections keep the same canonical cluster name as the rest of the unified resource model instead of rewriting the label locally. +The unified resource table now routes sort state, grouped host/service +projection, canonical resource-label resolution, and row-windowing through +`frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`, +so the shared consumer model is owned by one state boundary instead of +staying interleaved with the render shell. The canonical unified-resource change and relationship presenters now also share the same elapsed-time and "ago" wording utilities, so `observed`, `last seen`, and `ago` fragments stay consistent without each formatter diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx index d27b7d504..a0d389856 100644 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx +++ b/frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx @@ -1,4 +1,4 @@ -import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; +import { Component, For, Show, createMemo } from 'solid-js'; import type { Resource } from '@/types/resource'; import { getCpuPercent, getMemoryPercent, getDiskPercent } from '@/types/resource'; import { formatBytes, formatUptime, formatSpeed, normalizeDiskArray } from '@/utils/format'; @@ -22,7 +22,6 @@ import { getSourceBadge, getUnifiedSourceBadges, } from '@/utils/resourceBadgePresentation'; -import { useBreakpoint } from '@/hooks/useBreakpoint'; import { getAgentStatusIndicator } from '@/utils/status'; import { getServiceHealthSummaryPresentation } from '@/utils/serviceHealthPresentation'; import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity'; @@ -31,43 +30,16 @@ import { shouldShowResourceAlternateName, } from '@/utils/resourcePolicyPresentation'; import type { Disk } from '@/types/api'; -import { - splitPrimaryAndServiceResources, - sortResources, - groupResources, - computeIOScale, - type IODistributionStats, - type ResourceGroup, -} from '@/components/Infrastructure/infrastructureSelectors'; +import type { IODistributionStats } from '@/components/Infrastructure/infrastructureSelectors'; import { ResourceDetailDrawer } from './ResourceDetailDrawer'; import { buildWorkloadsHref } from './workloadsLink'; import { buildServiceDetailLinks } from './serviceDetailLinks'; -import { useTableWindowing } from './useTableWindowing'; import { ClusterDeployBanner } from './ClusterDeployBanner'; import { ResourceFacetSummary } from './ResourceFacetSummary'; - -interface UnifiedResourceTableProps { - resources: Resource[]; - expandedResourceId: string | null; - highlightedResourceId?: string | null; - hoveredResourceId?: string | null; - onExpandedResourceChange: (id: string | null) => void; - onHoverChange?: (id: string | null) => void; - groupingMode?: 'grouped' | 'flat'; - onDeployCluster?: (clusterId: string, clusterName: string) => void; -} - -type SortKey = - | 'default' - | 'name' - | 'uptime' - | 'cpu' - | 'memory' - | 'disk' - | 'network' - | 'diskio' - | 'source' - | 'temp'; +import { + useUnifiedResourceTableState, + type UnifiedResourceTableProps, +} from './useUnifiedResourceTableState'; type PBSServiceData = { datastoreCount?: number; @@ -103,22 +75,6 @@ type PMGTableRow = { tone: ReturnType['tone']; }; -type HostTableHeaderItem = { - type: 'header'; - group: ResourceGroup; -}; - -type HostTableResourceItem = { - type: 'row'; - group: ResourceGroup; - resource: Resource; -}; - -type HostTableItem = HostTableHeaderItem | HostTableResourceItem; - -const HOST_TABLE_ESTIMATED_ROW_HEIGHT = 40; -const HOST_TABLE_WINDOW_SIZE = 137; - const isResourceOnline = (resource: Resource) => { const status = resource.status?.toLowerCase(); return status !== 'offline' && status !== 'stopped'; @@ -208,225 +164,37 @@ const getOutlierEmphasis = (value: number, stats: IODistributionStats): IOEmphas }; export const UnifiedResourceTable: Component = (props) => { - const { isMobile, isVisible } = useBreakpoint(); - const [sortKey, setSortKey] = createSignal('default'); - const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); + const { + isMobile, + isVisible, + handleSort, + renderSortIndicator, + resolveResourceLabel, + visibleHostTableItems, + hostTopSpacerHeight, + hostBottomSpacerHeight, + hostWindowing, + sortedPBSResources, + sortedPMGResources, + ioScale, + registerRowRef, + setHostBodyRef, + resourceColumnStyle, + metricColumnStyle, + ioColumnStyle, + sourceColumnStyle, + uptimeColumnStyle, + tempColumnStyle, + showHostTable, + serviceCountColumnStyle, + serviceQueueColumnStyle, + serviceHealthColumnStyle, + serviceActionColumnStyle, + toggleExpand, + getUnifiedSources, + } = useUnifiedResourceTableState(props); + const setExpandedResourceId = (id: string | null) => props.onExpandedResourceChange(id); - const rowRefs = new Map(); - const [hostBodyRef, setHostBodyRef] = createSignal(null); - - const handleSort = (key: Exclude) => { - if (sortKey() === key) { - if (sortDirection() === 'asc') { - setSortDirection('desc'); - } else { - setSortKey('default'); - setSortDirection('asc'); - } - } else { - setSortKey(key); - setSortDirection(key === 'name' || key === 'source' ? 'asc' : 'desc'); - } - }; - - const split = createMemo(() => splitPrimaryAndServiceResources(props.resources)); - const primaryResources = createMemo(() => split().primaryResources); - const serviceResources = createMemo(() => split().services); - - const sortedResources = createMemo(() => - sortResources(primaryResources(), sortKey(), sortDirection()), - ); - const resourceLabelById = createMemo(() => { - const map = new Map(); - for (const resource of props.resources) { - map.set(resource.id, getPreferredResourceDisplayName(resource)); - } - return map; - }); - const resolveResourceLabel = (resourceId: string): string | undefined => - resourceLabelById().get(resourceId); - - const groupedResources = createMemo(() => - groupResources(sortedResources(), props.groupingMode ?? 'grouped'), - ); - - const hostTableItems = createMemo(() => { - const items: HostTableItem[] = []; - const showGroupHeaders = props.groupingMode === 'grouped'; - - for (const group of groupedResources()) { - if (showGroupHeaders) { - items.push({ type: 'header', group }); - } - for (const resource of group.resources) { - items.push({ type: 'row', group, resource }); - } - } - - return items; - }); - - const hostRowIndexById = createMemo(() => { - const map = new Map(); - hostTableItems().forEach((item, index) => { - if (item.type === 'row') { - map.set(item.resource.id, index); - } - }); - return map; - }); - - const hostRevealTargetIndex = createMemo(() => { - const targetId = props.expandedResourceId ?? props.highlightedResourceId ?? null; - if (!targetId) return null; - return hostRowIndexById().get(targetId) ?? null; - }); - - const hostWindowing = useTableWindowing({ - totalCount: () => hostTableItems().length, - windowSize: HOST_TABLE_WINDOW_SIZE, - revealIndex: hostRevealTargetIndex, - }); - - const visibleHostTableItems = createMemo(() => { - if (!hostWindowing.isWindowed()) return hostTableItems(); - return hostTableItems().slice(hostWindowing.startIndex(), hostWindowing.endIndex()); - }); - - const hostTopSpacerHeight = createMemo(() => - hostWindowing.isWindowed() ? hostWindowing.startIndex() * HOST_TABLE_ESTIMATED_ROW_HEIGHT : 0, - ); - - const hostBottomSpacerHeight = createMemo(() => - hostWindowing.isWindowed() - ? Math.max( - 0, - (hostTableItems().length - hostWindowing.endIndex()) * HOST_TABLE_ESTIMATED_ROW_HEIGHT, - ) - : 0, - ); - - const syncHostWindowToViewport = () => { - if (!hostWindowing.isWindowed() || typeof window === 'undefined') return; - const body = hostBodyRef(); - if (!body) return; - const rect = body.getBoundingClientRect(); - const scrollTop = Math.max(0, -rect.top); - hostWindowing.onScroll(scrollTop, window.innerHeight, HOST_TABLE_ESTIMATED_ROW_HEIGHT); - }; - - const sortedPBSResources = createMemo(() => - sortResources( - serviceResources().filter((resource) => resource.type === 'pbs'), - 'default', - 'asc', - ), - ); - const sortedPMGResources = createMemo(() => - sortResources( - serviceResources().filter((resource) => resource.type === 'pmg'), - 'default', - 'asc', - ), - ); - - const ioScale = createMemo(() => computeIOScale(primaryResources())); - - const renderSortIndicator = (key: SortKey) => { - if (sortKey() !== key) return null; - return sortDirection() === 'asc' ? '▲' : '▼'; - }; - - const toggleExpand = (resourceId: string) => { - setExpandedResourceId(props.expandedResourceId === resourceId ? null : resourceId); - }; - - createEffect(() => { - const selectedId = props.expandedResourceId; - if (!selectedId) return; - hostWindowing.startIndex(); - hostWindowing.endIndex(); - const row = rowRefs.get(selectedId); - if (row) { - row.scrollIntoView({ block: 'center', behavior: 'smooth' }); - } - }); - - createEffect(() => { - if (typeof window === 'undefined') return; - hostTableItems().length; - if (!hostWindowing.isWindowed()) return; - if (!hostBodyRef()) return; - - const handleViewportChange = () => { - syncHostWindowToViewport(); - }; - - handleViewportChange(); - window.addEventListener('scroll', handleViewportChange, { passive: true }); - window.addEventListener('resize', handleViewportChange); - onCleanup(() => { - window.removeEventListener('scroll', handleViewportChange); - window.removeEventListener('resize', handleViewportChange); - }); - }); - const resourceColumnStyle = createMemo(() => - isMobile() ? { width: '100%', 'min-width': '120px' } : { 'min-width': '220px' }, - ); - const metricColumnStyle = createMemo(() => - isMobile() - ? { width: '70px', 'min-width': '65px' } - : { 'min-width': '140px', 'max-width': '180px' }, - ); - const ioColumnStyle = createMemo(() => - isMobile() - ? { width: '180px', 'min-width': '180px' } - : { width: '160px', 'min-width': '160px', 'max-width': '180px' }, - ); - const sourceColumnStyle = createMemo(() => - isMobile() - ? { width: '140px', 'min-width': '140px' } - : { width: '160px', 'min-width': '160px' }, - ); - const uptimeColumnStyle = createMemo(() => - isMobile() - ? { width: '70px', 'min-width': '70px', 'max-width': '80px' } - : { width: '80px', 'min-width': '80px', 'max-width': '80px' }, - ); - const tempColumnStyle = createMemo(() => - isMobile() - ? { width: '50px', 'min-width': '50px', 'max-width': '60px' } - : { width: '60px', 'min-width': '60px', 'max-width': '70px' }, - ); - - const getUnifiedSources = (resource: Resource): string[] => { - const platformData = resource.platformData as { sources?: string[] } | undefined; - return platformData?.sources ?? []; - }; - - const showHostTable = createMemo( - () => primaryResources().length > 0 || serviceResources().length === 0, - ); - const serviceCountColumnStyle = createMemo(() => - isMobile() - ? { width: '80px', 'min-width': '80px', 'max-width': '90px' } - : { width: '110px', 'min-width': '110px', 'max-width': '130px' }, - ); - const serviceQueueColumnStyle = createMemo(() => - isMobile() - ? { width: '88px', 'min-width': '88px', 'max-width': '104px' } - : { width: '120px', 'min-width': '120px', 'max-width': '140px' }, - ); - const serviceHealthColumnStyle = createMemo(() => - isMobile() - ? { width: '100px', 'min-width': '100px', 'max-width': '120px' } - : { width: '140px', 'min-width': '140px', 'max-width': '170px' }, - ); - const serviceActionColumnStyle = createMemo(() => - isMobile() - ? { width: '82px', 'min-width': '82px', 'max-width': '96px' } - : { width: '120px', 'min-width': '120px', 'max-width': '140px' }, - ); return (
@@ -627,13 +395,7 @@ export const UnifiedResourceTable: Component = (props return ( <> { - if (el) { - rowRefs.set(resource.id, el); - } else { - rowRefs.delete(resource.id); - } - }} + ref={(el) => registerRowRef(resource.id, el)} class={rowClass()} style={{ 'min-height': '32px' }} onClick={() => toggleExpand(resource.id)} @@ -1094,13 +856,7 @@ export const UnifiedResourceTable: Component = (props return ( <> { - if (el) { - rowRefs.set(resource.id, el); - } else { - rowRefs.delete(resource.id); - } - }} + ref={(el) => registerRowRef(resource.id, el)} class={rowClass()} style={{ 'min-height': '32px' }} onClick={() => toggleExpand(resource.id)} @@ -1402,13 +1158,7 @@ export const UnifiedResourceTable: Component = (props return ( <> { - if (el) { - rowRefs.set(resource.id, el); - } else { - rowRefs.delete(resource.id); - } - }} + ref={(el) => registerRowRef(resource.id, el)} class={rowClass()} style={{ 'min-height': '32px' }} onClick={() => toggleExpand(resource.id)} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx index b6fef7cb5..2535a8f34 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx @@ -5,6 +5,8 @@ import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourc import { ResourceFacetSummary } from '@/components/Infrastructure/ResourceFacetSummary'; import { formatSensorName } from '@/components/Infrastructure/resourceDetailMappers'; import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity'; +import unifiedResourceTableSource from '@/components/Infrastructure/UnifiedResourceTable.tsx?raw'; +import unifiedResourceTableStateSource from '@/components/Infrastructure/useUnifiedResourceTableState.ts?raw'; import { buildStatusOptions, filterResources, @@ -175,6 +177,13 @@ describe('UnifiedResourceTable performance contract', () => { expect(formatSensorName('psu_temp')).toBe('Temp'); }); + it('keeps hot-path table state and windowing in the shared table state owner', () => { + expect(unifiedResourceTableSource).toContain('useUnifiedResourceTableState'); + expect(unifiedResourceTableSource).not.toContain('const sortedPBSResources = createMemo(() =>'); + expect(unifiedResourceTableStateSource).toContain('splitPrimaryAndServiceResources'); + expect(unifiedResourceTableStateSource).toContain('useTableWindowing'); + }); + it('keeps source filtering on the shared canonical source-platform helper', () => { const resources = [ makeResource(0, { platformData: { sources: ['proxmox'] } }), diff --git a/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts b/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts new file mode 100644 index 000000000..6b059acac --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts @@ -0,0 +1,311 @@ +import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; +import type { Resource } from '@/types/resource'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity'; +import { + splitPrimaryAndServiceResources, + sortResources, + groupResources, + computeIOScale, + type ResourceGroup, +} from '@/components/Infrastructure/infrastructureSelectors'; +import { useTableWindowing } from './useTableWindowing'; + +export interface UnifiedResourceTableProps { + resources: Resource[]; + expandedResourceId: string | null; + highlightedResourceId?: string | null; + hoveredResourceId?: string | null; + onExpandedResourceChange: (id: string | null) => void; + onHoverChange?: (id: string | null) => void; + groupingMode?: 'grouped' | 'flat'; + onDeployCluster?: (clusterId: string, clusterName: string) => void; +} + +type SortKey = + | 'default' + | 'name' + | 'uptime' + | 'cpu' + | 'memory' + | 'disk' + | 'network' + | 'diskio' + | 'source' + | 'temp'; + +type HostTableHeaderItem = { + type: 'header'; + group: ResourceGroup; +}; + +type HostTableResourceItem = { + type: 'row'; + group: ResourceGroup; + resource: Resource; +}; + +export type HostTableItem = HostTableHeaderItem | HostTableResourceItem; + +const HOST_TABLE_ESTIMATED_ROW_HEIGHT = 40; +const HOST_TABLE_WINDOW_SIZE = 137; + +export function useUnifiedResourceTableState(props: UnifiedResourceTableProps) { + const { isMobile, isVisible } = useBreakpoint(); + const [sortKey, setSortKey] = createSignal('default'); + const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); + const [hostBodyRef, setHostBodyRef] = createSignal(null); + const rowRefs = new Map(); + + const split = createMemo(() => splitPrimaryAndServiceResources(props.resources)); + const primaryResources = createMemo(() => split().primaryResources); + const serviceResources = createMemo(() => split().services); + + const sortedResources = createMemo(() => + sortResources(primaryResources(), sortKey(), sortDirection()), + ); + const resourceLabelById = createMemo(() => { + const map = new Map(); + for (const resource of props.resources) { + map.set(resource.id, getPreferredResourceDisplayName(resource)); + } + return map; + }); + const resolveResourceLabel = (resourceId: string): string | undefined => + resourceLabelById().get(resourceId); + + const groupedResources = createMemo(() => + groupResources(sortedResources(), props.groupingMode ?? 'grouped'), + ); + + const hostTableItems = createMemo(() => { + const items: HostTableItem[] = []; + const showGroupHeaders = props.groupingMode === 'grouped'; + + for (const group of groupedResources()) { + if (showGroupHeaders) { + items.push({ type: 'header', group }); + } + for (const resource of group.resources) { + items.push({ type: 'row', group, resource }); + } + } + + return items; + }); + + const hostRowIndexById = createMemo(() => { + const map = new Map(); + hostTableItems().forEach((item, index) => { + if (item.type === 'row') { + map.set(item.resource.id, index); + } + }); + return map; + }); + + const hostRevealTargetIndex = createMemo(() => { + const targetId = props.expandedResourceId ?? props.highlightedResourceId ?? null; + if (!targetId) return null; + return hostRowIndexById().get(targetId) ?? null; + }); + + const hostWindowing = useTableWindowing({ + totalCount: () => hostTableItems().length, + windowSize: HOST_TABLE_WINDOW_SIZE, + revealIndex: hostRevealTargetIndex, + }); + + const visibleHostTableItems = createMemo(() => { + if (!hostWindowing.isWindowed()) return hostTableItems(); + return hostTableItems().slice(hostWindowing.startIndex(), hostWindowing.endIndex()); + }); + + const hostTopSpacerHeight = createMemo(() => + hostWindowing.isWindowed() ? hostWindowing.startIndex() * HOST_TABLE_ESTIMATED_ROW_HEIGHT : 0, + ); + + const hostBottomSpacerHeight = createMemo(() => + hostWindowing.isWindowed() + ? Math.max( + 0, + (hostTableItems().length - hostWindowing.endIndex()) * HOST_TABLE_ESTIMATED_ROW_HEIGHT, + ) + : 0, + ); + + const syncHostWindowToViewport = () => { + if (!hostWindowing.isWindowed() || typeof window === 'undefined') return; + const body = hostBodyRef(); + if (!body) return; + const rect = body.getBoundingClientRect(); + const scrollTop = Math.max(0, -rect.top); + hostWindowing.onScroll(scrollTop, window.innerHeight, HOST_TABLE_ESTIMATED_ROW_HEIGHT); + }; + + const sortedPBSResources = createMemo(() => + sortResources( + serviceResources().filter((resource) => resource.type === 'pbs'), + 'default', + 'asc', + ), + ); + const sortedPMGResources = createMemo(() => + sortResources( + serviceResources().filter((resource) => resource.type === 'pmg'), + 'default', + 'asc', + ), + ); + + const ioScale = createMemo(() => computeIOScale(primaryResources())); + + const handleSort = (key: Exclude) => { + if (sortKey() === key) { + if (sortDirection() === 'asc') { + setSortDirection('desc'); + } else { + setSortKey('default'); + setSortDirection('asc'); + } + } else { + setSortKey(key); + setSortDirection(key === 'name' || key === 'source' ? 'asc' : 'desc'); + } + }; + + const renderSortIndicator = (key: SortKey) => { + if (sortKey() !== key) return null; + return sortDirection() === 'asc' ? '▲' : '▼'; + }; + + const toggleExpand = (resourceId: string) => { + props.onExpandedResourceChange(props.expandedResourceId === resourceId ? null : resourceId); + }; + + const registerRowRef = (resourceId: string, element?: HTMLTableRowElement) => { + if (element) { + rowRefs.set(resourceId, element); + return; + } + rowRefs.delete(resourceId); + }; + + createEffect(() => { + const selectedId = props.expandedResourceId; + if (!selectedId) return; + hostWindowing.startIndex(); + hostWindowing.endIndex(); + const row = rowRefs.get(selectedId); + if (row) { + row.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }); + + createEffect(() => { + if (typeof window === 'undefined') return; + hostTableItems().length; + if (!hostWindowing.isWindowed()) return; + if (!hostBodyRef()) return; + + const handleViewportChange = () => { + syncHostWindowToViewport(); + }; + + handleViewportChange(); + window.addEventListener('scroll', handleViewportChange, { passive: true }); + window.addEventListener('resize', handleViewportChange); + onCleanup(() => { + window.removeEventListener('scroll', handleViewportChange); + window.removeEventListener('resize', handleViewportChange); + }); + }); + + const resourceColumnStyle = createMemo(() => + isMobile() ? { width: '100%', 'min-width': '120px' } : { 'min-width': '220px' }, + ); + const metricColumnStyle = createMemo(() => + isMobile() + ? { width: '70px', 'min-width': '65px' } + : { 'min-width': '140px', 'max-width': '180px' }, + ); + const ioColumnStyle = createMemo(() => + isMobile() + ? { width: '180px', 'min-width': '180px' } + : { width: '160px', 'min-width': '160px', 'max-width': '180px' }, + ); + const sourceColumnStyle = createMemo(() => + isMobile() + ? { width: '140px', 'min-width': '140px' } + : { width: '160px', 'min-width': '160px' }, + ); + const uptimeColumnStyle = createMemo(() => + isMobile() + ? { width: '70px', 'min-width': '70px', 'max-width': '80px' } + : { width: '80px', 'min-width': '80px', 'max-width': '80px' }, + ); + const tempColumnStyle = createMemo(() => + isMobile() + ? { width: '50px', 'min-width': '50px', 'max-width': '60px' } + : { width: '60px', 'min-width': '60px', 'max-width': '70px' }, + ); + + const showHostTable = createMemo( + () => primaryResources().length > 0 || serviceResources().length === 0, + ); + const serviceCountColumnStyle = createMemo(() => + isMobile() + ? { width: '80px', 'min-width': '80px', 'max-width': '90px' } + : { width: '110px', 'min-width': '110px', 'max-width': '130px' }, + ); + const serviceQueueColumnStyle = createMemo(() => + isMobile() + ? { width: '88px', 'min-width': '88px', 'max-width': '104px' } + : { width: '120px', 'min-width': '120px', 'max-width': '140px' }, + ); + const serviceHealthColumnStyle = createMemo(() => + isMobile() + ? { width: '100px', 'min-width': '100px', 'max-width': '120px' } + : { width: '140px', 'min-width': '140px', 'max-width': '170px' }, + ); + const serviceActionColumnStyle = createMemo(() => + isMobile() + ? { width: '82px', 'min-width': '82px', 'max-width': '96px' } + : { width: '120px', 'min-width': '120px', 'max-width': '140px' }, + ); + + const getUnifiedSources = (resource: Resource): string[] => { + const platformData = resource.platformData as { sources?: string[] } | undefined; + return platformData?.sources ?? []; + }; + + return { + isMobile, + isVisible, + handleSort, + renderSortIndicator, + resolveResourceLabel, + visibleHostTableItems, + hostTopSpacerHeight, + hostBottomSpacerHeight, + hostWindowing, + sortedPBSResources, + sortedPMGResources, + ioScale, + registerRowRef, + setHostBodyRef, + resourceColumnStyle, + metricColumnStyle, + ioColumnStyle, + sourceColumnStyle, + uptimeColumnStyle, + tempColumnStyle, + showHostTable, + serviceCountColumnStyle, + serviceQueueColumnStyle, + serviceHealthColumnStyle, + serviceActionColumnStyle, + toggleExpand, + getUnifiedSources, + }; +} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 1a9e72043..14fbda572 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -170,6 +170,7 @@ import infrastructureSummarySource from '@/components/Infrastructure/Infrastruct import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw'; import resourceDetailDrawerStateSource from '@/components/Infrastructure/useResourceDetailDrawerState.ts?raw'; import unifiedResourceTableSource from '@/components/Infrastructure/UnifiedResourceTable.tsx?raw'; +import unifiedResourceTableStateSource from '@/components/Infrastructure/useUnifiedResourceTableState.ts?raw'; import useUnifiedResourcesSource from '@/hooks/useUnifiedResources.ts?raw'; import useWorkloadsSource from '@/hooks/useWorkloads.ts?raw'; import findingsPanelSource from '@/components/AI/FindingsPanel.tsx?raw'; @@ -1993,8 +1994,15 @@ describe('frontend resource type boundaries', () => { expect(resourceDetailMappersSource).toContain('titleCaseDelimitedLabel'); expect(resourceDetailMappersSource).not.toContain('export const normalizeHealthLabel'); expect(resourceDetailMappersSource).not.toContain('export const healthToneClass'); + expect(unifiedResourceTableSource).toContain('useUnifiedResourceTableState'); + expect(unifiedResourceTableSource).not.toContain('const split = createMemo(() =>'); + expect(unifiedResourceTableSource).not.toContain('const sortedPBSResources = createMemo(() =>'); + expect(unifiedResourceTableSource).not.toContain('const resourceColumnStyle = createMemo(() =>'); expect(unifiedResourceTableSource).toContain('getServiceHealthSummaryPresentation'); expect(unifiedResourceTableSource).not.toContain('const summarizeServiceHealthTone ='); + expect(unifiedResourceTableStateSource).toContain('export function useUnifiedResourceTableState'); + expect(unifiedResourceTableStateSource).toContain('splitPrimaryAndServiceResources'); + expect(unifiedResourceTableStateSource).toContain('useTableWindowing'); expect(exploreStatusBlockSource).toContain('getAIExploreStatusPresentation'); expect(exploreStatusBlockSource).not.toContain('const phaseLabel ='); expect(exploreStatusBlockSource).not.toContain('const phaseClasses =');