Extract unified resource table state owner

This commit is contained in:
rcourtman
2026-03-20 18:52:21 +00:00
parent eb456319e7
commit b48b258e98
7 changed files with 401 additions and 298 deletions
@@ -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
@@ -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",
@@ -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
@@ -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<typeof getServiceHealthSummaryPresentation>['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<UnifiedResourceTableProps> = (props) => {
const { isMobile, isVisible } = useBreakpoint();
const [sortKey, setSortKey] = createSignal<SortKey>('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<string, HTMLTableRowElement>();
const [hostBodyRef, setHostBodyRef] = createSignal<HTMLTableSectionElement | null>(null);
const handleSort = (key: Exclude<SortKey, 'default'>) => {
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<string, string>();
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<ResourceGroup[]>(() =>
groupResources(sortedResources(), props.groupingMode ?? 'grouped'),
);
const hostTableItems = createMemo<HostTableItem[]>(() => {
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<string, number>();
hostTableItems().forEach((item, index) => {
if (item.type === 'row') {
map.set(item.resource.id, index);
}
});
return map;
});
const hostRevealTargetIndex = createMemo<number | null>(() => {
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 (
<div class="space-y-4">
@@ -627,13 +395,7 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
return (
<>
<TableRow
ref={(el) => {
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<UnifiedResourceTableProps> = (props
return (
<>
<TableRow
ref={(el) => {
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<UnifiedResourceTableProps> = (props
return (
<>
<TableRow
ref={(el) => {
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)}
@@ -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'] } }),
@@ -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<SortKey>('default');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const [hostBodyRef, setHostBodyRef] = createSignal<HTMLTableSectionElement | null>(null);
const rowRefs = new Map<string, HTMLTableRowElement>();
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<string, string>();
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<ResourceGroup[]>(() =>
groupResources(sortedResources(), props.groupingMode ?? 'grouped'),
);
const hostTableItems = createMemo<HostTableItem[]>(() => {
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<string, number>();
hostTableItems().forEach((item, index) => {
if (item.type === 'row') {
map.set(item.resource.id, index);
}
});
return map;
});
const hostRevealTargetIndex = createMemo<number | null>(() => {
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<SortKey, 'default'>) => {
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,
};
}
@@ -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 =');