mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-07 21:31:53 +00:00
Extract unified resource table state owner
This commit is contained in:
@@ -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)}
|
||||
|
||||
+9
@@ -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 =');
|
||||
|
||||
Reference in New Issue
Block a user