Split infrastructure summary runtime owners

This commit is contained in:
rcourtman
2026-03-23 00:55:26 +00:00
parent 45c9f0a9c2
commit 4f72152b70
9 changed files with 766 additions and 607 deletions
@@ -113,20 +113,26 @@ regression protection.
90. `frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx`
91. `frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts`
92. `frontend-modern/src/components/Dashboard/__tests__/useDashboardWorkloadViewportSync.test.tsx`
93. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
94. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
95. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
## 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/UnifiedResourceHostTableCard.tsx` shared with `unified-resources`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
4. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` shared with `unified-resources`: the unified resource PBS section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
5. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` shared with `unified-resources`: the unified resource PMG section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
6. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` shared with `unified-resources`: the unified resource service infrastructure card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
7. `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.
8. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` shared with `unified-resources`: unified resource service row shaping and I/O emphasis are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
9. `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.
10. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `unified-resources`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
11. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
2. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx` shared with `unified-resources`: the infrastructure summary surface is both a canonical unified-resource consumer and a fleet-scale summary chart hot-path boundary.
3. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts` shared with `unified-resources`: infrastructure summary chart matching and series derivation are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary.
4. `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.
5. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `unified-resources`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
6. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` shared with `unified-resources`: the unified resource PBS section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
7. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` shared with `unified-resources`: the unified resource PMG section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
8. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` shared with `unified-resources`: the unified resource service infrastructure card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
9. `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.
10. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` shared with `unified-resources`: unified resource service row shaping and I/O emphasis are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
11. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` shared with `unified-resources`: infrastructure summary chart polling, cache hydration, and summary-state orchestration are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary.
12. `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.
13. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `unified-resources`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
14. `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
@@ -320,6 +326,12 @@ while viewport-sync and selected-row reveal behavior now live in
so future hot-path table-state changes must not fold selector and scroll
coordination back into one mixed owner or the render shell.
That hot-path contract now includes policy badge rendering on resource rows.
The infrastructure summary hot path is now explicit shared ownership too:
`InfrastructureSummary.tsx` stays a render shell,
`useInfrastructureSummaryState.ts` owns chart polling and cache lifecycle, and
`infrastructureSummaryModel.ts` owns chart matching plus summary-series
derivation. Future summary-chart work must not put polling, cache hydration,
and series math back into the shell.
It now also includes the compact resource-facet summary chips rendered next
to policy metadata, and those chips must stay within the same bounded
windowing and mounted-row budget proved by
@@ -65,6 +65,22 @@
"unified-resources"
]
},
{
"path": "frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx",
"rationale": "the infrastructure summary surface is both a canonical unified-resource consumer and a fleet-scale summary chart hot-path boundary",
"subsystems": [
"performance-and-scalability",
"unified-resources"
]
},
{
"path": "frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts",
"rationale": "infrastructure summary chart matching and series derivation are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary",
"subsystems": [
"performance-and-scalability",
"unified-resources"
]
},
{
"path": "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
"rationale": "resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary",
@@ -121,6 +137,14 @@
"unified-resources"
]
},
{
"path": "frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts",
"rationale": "infrastructure summary chart polling, cache hydration, and summary-state orchestration are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary",
"subsystems": [
"performance-and-scalability",
"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",
@@ -2663,6 +2687,8 @@
"frontend-modern/src/components/Dashboard/WorkloadTableHeader.tsx",
"frontend-modern/src/components/Dashboard/workloadTopology.ts",
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
"frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx",
"frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts",
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
"frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx",
"frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx",
@@ -2670,6 +2696,7 @@
"frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx",
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
"frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts",
"frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts",
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts",
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts",
"internal/api/router_bench_test.go",
@@ -2784,6 +2811,8 @@
"frontend-modern/src/components/Dashboard/WorkloadTableHeader.tsx",
"frontend-modern/src/components/Dashboard/workloadTopology.ts",
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
"frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx",
"frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts",
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
"frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx",
"frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx",
@@ -2791,6 +2820,7 @@
"frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx",
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
"frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts",
"frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts",
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts",
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts"
],
@@ -3164,6 +3194,8 @@
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
"frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx",
"frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
@@ -3176,6 +3208,7 @@
"frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx",
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
"frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts",
"frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts",
@@ -3251,6 +3284,8 @@
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
"frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx",
"frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
@@ -3263,6 +3298,7 @@
"frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx",
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
"frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts",
"frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts",
@@ -64,20 +64,26 @@ cross-source deduplication.
42. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
43. `frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts`
44. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
45. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
46. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
47. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.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/UnifiedResourceHostTableCard.tsx` shared with `performance-and-scalability`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
4. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` shared with `performance-and-scalability`: the unified resource PBS section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
5. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` shared with `performance-and-scalability`: the unified resource PMG section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
6. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` shared with `performance-and-scalability`: the unified resource service infrastructure card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
7. `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.
8. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` shared with `performance-and-scalability`: unified resource service row shaping and I/O emphasis are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
9. `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.
10. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `performance-and-scalability`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
11. `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.
2. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx` shared with `performance-and-scalability`: the infrastructure summary surface is both a canonical unified-resource consumer and a fleet-scale summary chart hot-path boundary.
3. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts` shared with `performance-and-scalability`: infrastructure summary chart matching and series derivation are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary.
4. `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.
5. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `performance-and-scalability`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
6. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` shared with `performance-and-scalability`: the unified resource PBS section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
7. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` shared with `performance-and-scalability`: the unified resource PMG section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
8. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` shared with `performance-and-scalability`: the unified resource service infrastructure card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
9. `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.
10. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` shared with `performance-and-scalability`: unified resource service row shaping and I/O emphasis are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
11. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` shared with `performance-and-scalability`: infrastructure summary chart polling, cache hydration, and summary-state orchestration are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary.
12. `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.
13. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `performance-and-scalability`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
14. `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
@@ -159,6 +165,12 @@ canonical drawer derivation layer,
`useResourceDetailDrawerDockerActionsState.ts` owns Docker action runtime, and
the overview/debug render-heavy surfaces live in dedicated drawer-local owners
instead of staying inline in the shell.
The infrastructure summary surface now follows the same shell/runtime/model
shape: `InfrastructureSummary.tsx` is the render shell,
`useInfrastructureSummaryState.ts` owns chart polling, cache hydration,
org-scope lifecycle, and focused-summary state, and
`infrastructureSummaryModel.ts` owns chart matching plus summary-series
derivation.
The backend AI and Patrol context renderers now derive their canonical change
kind, source type, source adapter, actor, reason, and related-resource
fragments from `internal/unifiedresources/change_presentation.go`, so the
@@ -306,6 +318,14 @@ while Docker update mutations route through
and `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
stays the composition owner, so unified-resource history, investigation, and
drawer-local action runtime no longer accumulate inline beside the model layer.
The infrastructure summary path now routes chart polling, cache hydration, and
org-scope lifecycle through
`frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`,
while chart matching and summary-series derivation route through
`frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
and `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
stays the render shell, so summary charts are no longer an unowned mixed
resource consumer surface.
The shared `ResourceFacetSummary` consumer now omits capability and
relationship badges from the default table/detail surface entirely, while the
backend contract keeps capability and relationship data on the owned resource
@@ -1,7 +1,8 @@
import { createSignal } from 'solid-js';
import { render, waitFor, cleanup } from '@solidjs/testing-library';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { InfrastructureSummary, __resetInMemoryChartCacheForTests } from './InfrastructureSummary';
import { InfrastructureSummary } from './InfrastructureSummary';
import { __resetInMemoryChartCacheForTests } from './useInfrastructureSummaryState';
import type { Resource } from '@/types/resource';
import type { TimeRange } from '@/api/charts';
import { __resetInfrastructureSummaryFetchesForTests } from '@/utils/infrastructureSummaryCache';
@@ -1,559 +1,21 @@
import { Component, Show, createMemo, createEffect, createSignal, onCleanup } from 'solid-js';
import type { Component } from 'solid-js';
import { Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { InteractiveSparkline } from '@/components/shared/InteractiveSparkline';
import { DensityMap } from '@/components/shared/DensityMap';
import { SummaryPanel } from '@/components/shared/SummaryPanel';
import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard';
import type { Resource } from '@/types/resource';
import { getDiskPercent } from '@/types/resource';
import type { MetricPoint, ChartData, TimeRange } from '@/api/charts';
import { useResources } from '@/hooks/useResources';
import {
fetchInfrastructureSummaryAndCache,
readInfrastructureSummaryCache,
} from '@/utils/infrastructureSummaryCache';
import {
getActionableAgentIdFromResource,
getMetricsChartKeyCandidatesFromResource,
getPlatformAgentRecord,
getPlatformDataRecord,
hasAgentFacet,
} from '@/utils/agentResources';
import {
getPreferredResourceDisplayName,
getPreferredResourceHostname,
getResourceIdentityAliases,
getNormalizedIdentityLookupVariants,
} from '@/utils/resourceIdentity';
import { formatThroughputRate } from '@/utils/throughputPresentation';
import { asTrimmedString } from '@/utils/stringUtils';
import { getOrgID } from '@/utils/apiClient';
import { normalizeOrgScope } from '@/utils/orgScope';
import { eventBus } from '@/stores/events';
import { getChartSeriesColor } from '@/utils/chartSeriesPresentation';
const getNormalizedResourceIdentifiers = (resource: Resource): Set<string> =>
new Set<string>([
...getNormalizedIdentityLookupVariants(resource.id),
...getNormalizedIdentityLookupVariants(resource.platformId),
...getNormalizedIdentityLookupVariants(getPreferredResourceDisplayName(resource)),
...getNormalizedIdentityLookupVariants(getPreferredResourceHostname(resource)),
...getResourceIdentityAliases(resource).flatMap((value) =>
getNormalizedIdentityLookupVariants(value),
),
]);
const getLinkedNodeIdFromResource = (resource: Resource): string | null =>
asTrimmedString(getPlatformDataRecord(resource)?.linkedNodeId) ||
asTrimmedString(getPlatformAgentRecord(resource)?.linkedNodeId) ||
null;
// Combine a resource's net in/out into a single throughput series.
// Buckets points into 30-second windows and sums rates from both directions.
function combineResourceThroughputSeries(
inSeries: MetricPoint[],
outSeries: MetricPoint[],
): MetricPoint[] {
const bucketSize = 30_000; // 30 seconds
const buckets = new Map<number, number>();
for (const p of inSeries) {
const bucket = Math.round(p.timestamp / bucketSize) * bucketSize;
buckets.set(bucket, (buckets.get(bucket) || 0) + p.value);
}
for (const p of outSeries) {
const bucket = Math.round(p.timestamp / bucketSize) * bucketSize;
buckets.set(bucket, (buckets.get(bucket) || 0) + p.value);
}
return Array.from(buckets.entries())
.sort((a, b) => a[0] - b[0])
.map(([timestamp, value]) => ({ timestamp, value }));
}
interface InfrastructureSummaryProps {
resources: Resource[];
timeRange?: TimeRange;
hoveredResourceId?: string | null;
focusedResourceId?: string | null;
onTimeRangeChange?: (range: TimeRange) => void;
}
// In-memory full-resolution cache keyed by "org::range".
// Survives component unmount/remount (page navigation) without the
// downsampling artifacts that localStorage cache introduces.
// Capped at MAX_IN_MEMORY_ENTRIES to prevent unbounded growth.
const MAX_IN_MEMORY_INFRA_ENTRIES = 20;
const inMemoryChartCache = new Map<string, Map<string, ChartData>>();
function inMemoryCacheKey(range: TimeRange): string {
return `${normalizeOrgScope(getOrgID())}::${range}`;
}
/** @internal Test-only reset for in-memory chart cache. */
export function __resetInMemoryChartCacheForTests(): void {
inMemoryChartCache.clear();
}
// Clear in-memory cache on org switch to prevent cross-org data leakage.
const unsubscribeInfraOrgSwitch = eventBus.on('org_switched', () => {
inMemoryChartCache.clear();
});
if (import.meta.hot) {
import.meta.hot.dispose(() => {
unsubscribeInfraOrgSwitch();
});
}
import type { InfrastructureSummaryProps } from './infrastructureSummaryModel';
import { useInfrastructureSummaryState } from './useInfrastructureSummaryState';
export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (props) => {
// Chart data keyed by resource identifier (node name, resource id, etc.)
const [chartMap, setChartMap] = createSignal<Map<string, ChartData>>(new Map());
const [chartRange, setChartRange] = createSignal<TimeRange | null>(null);
const [loadedRange, setLoadedRange] = createSignal<TimeRange | null>(null);
const [oldestDataTimestamp, setOldestDataTimestamp] = createSignal<number | null>(null);
const [fetchFailed, setFetchFailed] = createSignal(false);
const selectedRange = createMemo<TimeRange>(() => props.timeRange || '1h');
const hasCurrentRangeCharts = createMemo(() => chartRange() === selectedRange());
const isCurrentRangeLoaded = createMemo(() => loadedRange() === selectedRange());
const state = useInfrastructureSummaryState(props);
const { workloads, resources } = useResources();
const agentResources = createMemo(() =>
resources().filter(
(resource) =>
(resource.type === 'agent' ||
resource.type === 'pbs' ||
resource.type === 'pmg' ||
resource.type === 'truenas') &&
hasAgentFacet(resource),
),
);
// Track org switches so the effect re-runs when the org changes.
const [orgVersion, setOrgVersion] = createSignal(0);
const unsubscribeOrgSwitch = eventBus.on('org_switched', () => {
setOrgVersion((v) => v + 1);
});
// Fetch charts data directly — no dependency on dashboard sparkline store
let refreshTimer: ReturnType<typeof setInterval> | undefined;
let activeFetchController: AbortController | null = null;
let activeFetchRequest = 0;
let activeScopeKey: string | null = null;
const awaitAbortable = <T,>(promise: Promise<T>, signal: AbortSignal): Promise<T> => {
if (signal.aborted) {
return Promise.reject(new DOMException('Aborted', 'AbortError'));
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
reject(new DOMException('Aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort);
resolve(value);
},
(error) => {
signal.removeEventListener('abort', onAbort);
reject(error);
},
);
});
};
const fetchCharts = async (options?: { prioritize?: boolean }) => {
if (props.resources.length === 0) {
return;
}
const prioritize = options?.prioritize === true;
if (activeFetchController && !prioritize) {
// Keep the current request; next timer tick will retry if needed.
return;
}
if (activeFetchController && prioritize) {
activeFetchController.abort();
}
const requestedRange = selectedRange();
const controller = new AbortController();
const requestId = ++activeFetchRequest;
activeFetchController = controller;
try {
const fetched = await awaitAbortable(
fetchInfrastructureSummaryAndCache(requestedRange, { caller: 'InfrastructureSummary' }),
controller.signal,
);
if (requestId !== activeFetchRequest) {
return;
}
const map = fetched.map;
// If the backend returns an empty payload transiently, keep the last
// good map to avoid flashing the "no history / static" fallbacks.
const currentMapMatchesRequestedRange = chartRange() === requestedRange;
if (map.size > 0 || chartMap().size === 0 || !currentMapMatchesRequestedRange) {
const cacheKey = inMemoryCacheKey(requestedRange);
if (
!inMemoryChartCache.has(cacheKey) &&
inMemoryChartCache.size >= MAX_IN_MEMORY_INFRA_ENTRIES
) {
const oldest = inMemoryChartCache.keys().next().value;
if (oldest !== undefined) inMemoryChartCache.delete(oldest);
}
inMemoryChartCache.set(cacheKey, map);
setChartMap(map);
setChartRange(requestedRange);
}
setOldestDataTimestamp(fetched.oldestDataTimestamp);
setFetchFailed(false);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
if (requestId === activeFetchRequest) {
setFetchFailed(true);
// Fall back to localStorage cache on fetch failure.
if (chartRange() !== requestedRange) {
const cached = readInfrastructureSummaryCache(requestedRange);
if (cached) {
setChartMap(cached.map);
setChartRange(requestedRange);
setOldestDataTimestamp(cached.oldestDataTimestamp);
}
}
}
} finally {
if (activeFetchController === controller) {
activeFetchController = null;
}
if (requestId === activeFetchRequest) {
setLoadedRange(requestedRange);
}
}
};
createEffect(() => {
// Start polling when there are resources to show. Crucially, do NOT tear down
// and recreate the interval on every props update, or we end up refetching
// charts at the websocket update cadence (causing visible UI flashes).
const hasResources = props.resources.length > 0;
// Read orgVersion to subscribe to org switches.
const currentOrg = orgVersion();
if (!hasResources) {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = undefined;
}
if (activeFetchController) {
activeFetchController.abort();
activeFetchController = null;
}
activeScopeKey = null;
setChartMap(new Map());
setChartRange(null);
setLoadedRange(null);
setOldestDataTimestamp(null);
return;
}
if (!refreshTimer) {
refreshTimer = setInterval(() => void fetchCharts(), 30_000);
}
const nextRange = selectedRange();
const nextScopeKey = `${currentOrg}::${nextRange}`;
if (activeScopeKey !== nextScopeKey) {
activeScopeKey = nextScopeKey;
// Hydrate from in-memory cache (full-resolution, no curve shift).
// Only falls back to skeleton if this range was never fetched in this session.
const memCached = inMemoryChartCache.get(inMemoryCacheKey(nextRange));
if (memCached && memCached.size > 0) {
setChartMap(memCached);
setChartRange(nextRange);
setLoadedRange(nextRange);
const cached = readInfrastructureSummaryCache(nextRange);
setOldestDataTimestamp(cached?.oldestDataTimestamp ?? null);
} else {
setChartMap(new Map());
setChartRange(null);
setLoadedRange(null);
setOldestDataTimestamp(null);
}
setFetchFailed(false);
void fetchCharts({ prioritize: true });
}
});
onCleanup(() => {
if (refreshTimer) clearInterval(refreshTimer);
if (activeFetchController) {
activeFetchController.abort();
activeFetchController = null;
}
unsubscribeOrgSwitch();
});
// Match a unified resource to its chart data.
// Chart data is keyed by backend composite IDs (e.g. "cluster-pve01" or "instance-pve01")
// but unified resources have hashed IDs. We reconstruct the composite key or use suffix matching.
const findChartData = (resource: Resource): ChartData | undefined => {
if (!hasCurrentRangeCharts()) return undefined;
const map = chartMap();
if (map.size === 0) return undefined;
// 1. Agent ID match from unified platform data (most reliable for agent resources).
for (const key of getMetricsChartKeyCandidatesFromResource(resource)) {
const match = map.get(key);
if (match) return match;
}
// 2. Direct matches (works for agent resources where IDs may align)
// Reconstruct composite key for clustered Proxmox nodes: "clusterName-nodeName"
if (resource.clusterId && resource.platformId) {
const clusterKey = `${resource.clusterId}-${resource.platformId}`;
const clusterMatch = map.get(clusterKey);
if (clusterMatch) return clusterMatch;
}
// 3. Suffix match for standalone Proxmox nodes: key ends with "-{nodeName}"
// Handles cases where the instance name prefix is unknown to the frontend
const nameToMatch =
getPreferredResourceHostname(resource) || resource.platformId || resource.name;
if (nameToMatch) {
const suffix = `-${nameToMatch}`;
for (const [key, data] of map) {
if (key.endsWith(suffix)) return data;
}
}
return undefined;
};
// Find chart data from a linked agent when the primary chart data
// (typically from nodeData) doesn't include agent-specific metrics like
// netin/netout/diskread/diskwrite.
// Agent resources have internal IDs that match agentData chart keys, and
// platformData.linkedNodeId + identity.hostname fields that let us correlate
// with infrastructure resources.
const findAgentChartData = (resource: Resource): ChartData | undefined => {
if (!hasCurrentRangeCharts()) return undefined;
const map = chartMap();
if (map.size === 0) return undefined;
const directAgentCandidates: string[] = [];
const actionableAgentId = getActionableAgentIdFromResource(resource);
if (actionableAgentId) {
directAgentCandidates.push(actionableAgentId);
}
if (resource.platformType === 'agent') {
const discoveryResourceId = asTrimmedString(resource.discoveryTarget?.resourceId);
const discoveryHostId = asTrimmedString(resource.discoveryTarget?.agentId);
if (discoveryResourceId) directAgentCandidates.push(discoveryResourceId);
if (discoveryHostId) directAgentCandidates.push(discoveryHostId);
}
for (const key of Array.from(new Set(directAgentCandidates))) {
const direct = map.get(key);
if (direct) return direct;
}
const agentFacetResources = agentResources();
if (!agentFacetResources || agentFacetResources.length === 0) return undefined;
const nodeRefCandidates = new Set<string>(
[resource.id, resource.platformId, getPreferredResourceHostname(resource)]
.map((value) => value?.trim().toLowerCase())
.filter((value): value is string => Boolean(value)),
);
const resourceNameCandidates = getNormalizedResourceIdentifiers(resource);
// Find an agent resource that matches this infrastructure resource
// by linked node ID, hostname, or name
for (const agentResource of agentFacetResources) {
const linkedNodeId = getLinkedNodeIdFromResource(agentResource);
const normalizedLinkedNodeId = linkedNodeId?.toLowerCase();
// Match by linked node: agent is linked to a PVE node matching this resource
const linkedMatch = normalizedLinkedNodeId
? nodeRefCandidates.has(normalizedLinkedNodeId)
: false;
// Match by hostname: agent hostname matches this resource
const agentResourceNames = Array.from(getNormalizedResourceIdentifiers(agentResource));
const hostnameMatch = agentResourceNames.some((candidate) =>
resourceNameCandidates.has(candidate),
);
if (linkedMatch || hostnameMatch) {
for (const key of getMetricsChartKeyCandidatesFromResource(agentResource)) {
const agentData = map.get(key);
if (agentData) return agentData;
}
}
}
return undefined;
};
// Build sparkline series for all resources
const resourceSeries = createMemo(() => {
void chartMap(); // reactive dependency
return props.resources.map((resource, i) => {
const primaryData = findChartData(resource);
const agentData = findAgentChartData(resource);
const seriesId = resource.id || resource.platformId || resource.name || `resource-${i}`;
const metricSeries = (metric: keyof ChartData): MetricPoint[] => {
const primary = primaryData?.[metric];
if (primary && primary.length > 0) return primary;
const fallback = agentData?.[metric];
if (fallback && fallback.length > 0) return fallback;
return [];
};
return {
key: seriesId,
id: seriesId,
cpu: metricSeries('cpu'),
memory: metricSeries('memory'),
disk: metricSeries('disk'),
netin: metricSeries('netin'),
netout: metricSeries('netout'),
network: combineResourceThroughputSeries(metricSeries('netin'), metricSeries('netout')),
diskio: combineResourceThroughputSeries(
metricSeries('diskread'),
metricSeries('diskwrite'),
),
color: getChartSeriesColor(i),
name: getPreferredResourceDisplayName(resource),
};
});
});
// When a resource drawer is open, filter sparklines to show only that resource
const displaySeries = createMemo(() => {
const focused = props.focusedResourceId;
const all = resourceSeries();
if (!focused) return all;
const match = all.find((s) => s.id === focused);
return match ? [match] : all;
});
const focusedResourceName = createMemo(() => {
const focused = props.focusedResourceId;
if (!focused) return null;
const match = resourceSeries().find((s) => s.id === focused);
return match?.name ?? null;
});
const singleDisplayedOnlineResource = createMemo(() => {
if (displaySeries().length !== 1 || props.resources.length !== 1) return null;
const [resource] = props.resources;
if (!resource) return null;
return resource.status?.toLowerCase() === 'online' ? resource : null;
});
const isAwaitingFirstSample = createMemo(() => {
const resource = singleDisplayedOnlineResource();
if (!resource || !isCurrentRangeLoaded() || fetchFailed()) return false;
const oldest = oldestDataTimestamp();
if (oldest === null) return true;
return resource.lastSeen >= oldest;
});
const emptyHistoryLabel = createMemo(() =>
isAwaitingFirstSample() ? 'Waiting for first sample' : 'No history yet',
);
const hasData = (metric: 'cpu' | 'memory' | 'disk') =>
displaySeries().some((s) => s[metric].length >= 1);
const networkSeries = createMemo(() =>
displaySeries().map((s) => ({
id: s.id,
data: s.network,
color: s.color,
name: s.name,
})),
);
const hasNetData = () => displaySeries().some((s) => s.network.length >= 1);
const diskioSeries = createMemo(() =>
displaySeries().map((s) => ({
id: s.id,
data: s.diskio,
color: s.color,
name: s.name,
})),
);
const hasDiskIOData = () => displaySeries().some((s) => s.diskio.length >= 1);
const avgDiskCapacity = createMemo(() => {
const diskResources = props.resources.filter(
(resource) => resource.disk && resource.disk.total,
);
if (diskResources.length === 0) return null;
const avg =
diskResources.reduce((sum, resource) => sum + getDiskPercent(resource), 0) /
diskResources.length;
return Math.round(avg);
});
// Keep the network card visible when we have capability but limited history.
const hasNetworkCapability = createMemo(() =>
props.resources.some((resource) => {
if (resource.type === 'docker-host' || hasAgentFacet(resource)) return true;
// If current-rate metrics are present, treat as network-capable.
const rx = resource.network?.rxBytes ?? 0;
const tx = resource.network?.txBytes ?? 0;
return rx > 0 || tx > 0;
}),
);
const shouldShowNetworkCard = createMemo(() => hasNetData() || hasNetworkCapability());
const seriesFor = (metric: 'cpu' | 'memory' | 'disk') =>
displaySeries().map((s) => ({ id: s.id, data: s[metric], color: s.color, name: s.name }));
const rangeLabel = () => props.timeRange || '1h';
const workloadStats = createMemo(() => {
const all = workloads();
let running = 0;
let stopped = 0;
let vms = 0;
let containers = 0;
for (const w of all) {
if (w.status === 'running' || w.status === 'online') {
running++;
} else {
stopped++;
}
if (w.type === 'vm') {
vms++;
} else {
containers++;
}
}
return { total: all.length, running, stopped, vms, containers };
});
const resourceCounts = createMemo(() => {
const total = props.resources.length;
const online = props.resources.filter((resource) => resource.status === 'online').length;
const offline = total - online;
return { total, online, offline };
});
const emptyMsg = () => (fetchFailed() ? 'Trend data unavailable' : emptyHistoryLabel());
const focusedLabel = () => {
const name = focusedResourceName();
const name = state.focusedResourceName();
if (!name) return undefined;
return <span class="text-xs text-muted ml-1.5 truncate">&mdash; {name}</span>;
};
@@ -566,31 +28,31 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
headerLeft={
<>
<span class="font-medium text-base-content">
{resourceCounts().total} {resourceCounts().total === 1 ? 'resource' : 'resources'}
{state.resourceCounts().total}{' '}
{state.resourceCounts().total === 1 ? 'resource' : 'resources'}
</span>
<Show when={resourceCounts().online > 0}>
<Show when={state.resourceCounts().online > 0}>
<span class="text-emerald-600 dark:text-emerald-400">
{resourceCounts().online} online
{state.resourceCounts().online} online
</span>
</Show>
<Show when={resourceCounts().offline > 0}>
<span class="text-muted">{resourceCounts().offline} offline</span>
<Show when={state.resourceCounts().offline > 0}>
<span class="text-muted">{state.resourceCounts().offline} offline</span>
</Show>
</>
}
timeRange={selectedRange()}
timeRange={state.selectedRange()}
onTimeRangeChange={props.onTimeRangeChange}
>
{/* CPU Card */}
<SummaryMetricCard
label="CPU"
secondaryLabel={focusedLabel()}
loaded={isCurrentRangeLoaded()}
hasData={hasData('cpu')}
emptyMessage={emptyMsg()}
loaded={state.isCurrentRangeLoaded()}
hasData={state.hasData('cpu')}
emptyMessage={state.emptyMessage()}
>
<InteractiveSparkline
series={seriesFor('cpu')}
series={state.seriesFor('cpu')}
rangeLabel={rangeLabel()}
timeRange={props.timeRange}
yMode="percent"
@@ -599,16 +61,15 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
/>
</SummaryMetricCard>
{/* Memory Card */}
<SummaryMetricCard
label="Memory"
secondaryLabel={focusedLabel()}
loaded={isCurrentRangeLoaded()}
hasData={hasData('memory')}
emptyMessage={emptyMsg()}
loaded={state.isCurrentRangeLoaded()}
hasData={state.hasData('memory')}
emptyMessage={state.emptyMessage()}
>
<InteractiveSparkline
series={seriesFor('memory')}
series={state.seriesFor('memory')}
rangeLabel={rangeLabel()}
timeRange={props.timeRange}
yMode="percent"
@@ -617,34 +78,32 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
/>
</SummaryMetricCard>
{/* Disk I/O Card */}
<SummaryMetricCard
label="Disk I/O"
secondaryLabel={
<>
{focusedLabel()}
<Show when={!focusedResourceName() && avgDiskCapacity() !== null}>
<Show when={!state.focusedResourceName() && state.avgDiskCapacity() !== null}>
<span class="text-[10px] text-muted ml-auto shrink-0">
Capacity: {avgDiskCapacity()}%
Capacity: {state.avgDiskCapacity()}%
</span>
</Show>
</>
}
loaded={isCurrentRangeLoaded()}
hasData={hasDiskIOData()}
emptyMessage={emptyMsg()}
loaded={state.isCurrentRangeLoaded()}
hasData={state.hasDiskIOData()}
emptyMessage={state.emptyMessage()}
>
<DensityMap
series={diskioSeries()}
series={state.diskioSeries()}
rangeLabel={rangeLabel()}
timeRange={props.timeRange}
formatValue={formatThroughputRate}
/>
</SummaryMetricCard>
{/* 4th Card: Network or Workloads fallback */}
<Show
when={shouldShowNetworkCard()}
when={state.shouldShowNetworkCard()}
fallback={
<Card padding="sm" class="h-full">
<div class="flex flex-col h-full">
@@ -667,26 +126,33 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
</svg>
</div>
<div class="text-xl sm:text-2xl font-bold text-base-content">
{workloadStats().running}
{state.workloadStats().running}
<span class="text-sm font-normal text-muted ml-1">running</span>
</div>
<Show
when={workloadStats().total > 0}
when={state.workloadStats().total > 0}
fallback={<div class="text-[10px] text-muted mt-1">No workloads detected</div>}
>
<div class="text-[10px] text-muted mt-1">
<Show when={workloadStats().vms > 0}>
<span>{workloadStats().vms} VMs</span>
<Show when={state.workloadStats().vms > 0}>
<span>{state.workloadStats().vms} VMs</span>
</Show>
<Show when={workloadStats().vms > 0 && workloadStats().containers > 0}>
<Show
when={
state.workloadStats().vms > 0 &&
state.workloadStats().containers > 0
}
>
<span class="mx-0.5">&middot;</span>
</Show>
<Show when={workloadStats().containers > 0}>
<span>{workloadStats().containers} containers</span>
<Show when={state.workloadStats().containers > 0}>
<span>{state.workloadStats().containers} containers</span>
</Show>
</div>
<Show when={workloadStats().stopped > 0}>
<div class="text-[10px] text-muted">{workloadStats().stopped} stopped</div>
<Show when={state.workloadStats().stopped > 0}>
<div class="text-[10px] text-muted">
{state.workloadStats().stopped} stopped
</div>
</Show>
</Show>
</div>
@@ -696,12 +162,12 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
<SummaryMetricCard
label="Network"
secondaryLabel={focusedLabel()}
loaded={isCurrentRangeLoaded()}
hasData={hasNetData()}
emptyMessage={emptyHistoryLabel()}
loaded={state.isCurrentRangeLoaded()}
hasData={state.hasNetData()}
emptyMessage={state.emptyHistoryLabel()}
>
<DensityMap
series={networkSeries()}
series={state.networkSeries()}
rangeLabel={rangeLabel()}
timeRange={props.timeRange}
formatValue={formatThroughputRate}
@@ -9,6 +9,9 @@ import unifiedResourceTableSource from '@/components/Infrastructure/UnifiedResou
import unifiedResourceTableStateSource from '@/components/Infrastructure/useUnifiedResourceTableState.ts?raw';
import unifiedResourceTableViewportSyncSource from '@/components/Infrastructure/useUnifiedResourceTableViewportSync.ts?raw';
import unifiedResourceTableModelSource from '@/components/Infrastructure/unifiedResourceTableModel.ts?raw';
import infrastructureSummarySource from '@/components/Infrastructure/InfrastructureSummary.tsx?raw';
import infrastructureSummaryStateSource from '@/components/Infrastructure/useInfrastructureSummaryState.ts?raw';
import infrastructureSummaryModelSource from '@/components/Infrastructure/infrastructureSummaryModel.ts?raw';
import {
buildStatusOptions,
filterResources,
@@ -200,6 +203,25 @@ describe('UnifiedResourceTable performance contract', () => {
expect(unifiedResourceTableModelSource).toContain('export const getOutlierEmphasis');
});
it('keeps infrastructure summary fetch runtime out of the render shell', () => {
expect(infrastructureSummarySource).toContain('useInfrastructureSummaryState');
expect(infrastructureSummarySource).not.toContain('fetchInfrastructureSummaryAndCache');
expect(infrastructureSummarySource).not.toContain('readInfrastructureSummaryCache');
expect(infrastructureSummarySource).not.toContain('setInterval(');
expect(infrastructureSummarySource).not.toContain('AbortController');
expect(infrastructureSummaryStateSource).toContain('fetchInfrastructureSummaryAndCache');
expect(infrastructureSummaryStateSource).toContain('readInfrastructureSummaryCache');
expect(infrastructureSummaryStateSource).toContain('setInterval(');
expect(infrastructureSummaryStateSource).toContain('AbortController');
expect(infrastructureSummaryStateSource).toContain("eventBus.on('org_switched'");
expect(infrastructureSummaryModelSource).toContain(
'export function buildInfrastructureSummarySeries',
);
expect(infrastructureSummaryModelSource).toContain(
'export function combineResourceThroughputSeries',
);
});
it('keeps source filtering on the shared canonical source-platform helper', () => {
const resources = [
makeResource(0, { platformData: { sources: ['proxmox'] } }),
@@ -0,0 +1,267 @@
import type { ChartData, MetricPoint, TimeRange } from '@/api/charts';
import {
getActionableAgentIdFromResource,
getMetricsChartKeyCandidatesFromResource,
getPlatformAgentRecord,
getPlatformDataRecord,
hasAgentFacet,
} from '@/utils/agentResources';
import {
getPreferredResourceDisplayName,
getPreferredResourceHostname,
getResourceIdentityAliases,
getNormalizedIdentityLookupVariants,
} from '@/utils/resourceIdentity';
import { asTrimmedString } from '@/utils/stringUtils';
import { getChartSeriesColor } from '@/utils/chartSeriesPresentation';
import type { Resource } from '@/types/resource';
import { getDiskPercent } from '@/types/resource';
export interface InfrastructureSummaryProps {
resources: Resource[];
timeRange?: TimeRange;
hoveredResourceId?: string | null;
focusedResourceId?: string | null;
onTimeRangeChange?: (range: TimeRange) => void;
}
export interface InfrastructureSummarySeries {
key: string;
id: string;
cpu: MetricPoint[];
memory: MetricPoint[];
disk: MetricPoint[];
netin: MetricPoint[];
netout: MetricPoint[];
network: MetricPoint[];
diskio: MetricPoint[];
color: string;
name: string;
}
export type InfrastructureSummarySparkMetric = 'cpu' | 'memory' | 'disk';
export interface InfrastructureWorkloadStats {
total: number;
running: number;
stopped: number;
vms: number;
containers: number;
}
export interface InfrastructureResourceCounts {
total: number;
online: number;
offline: number;
}
const getNormalizedResourceIdentifiers = (resource: Resource): Set<string> =>
new Set<string>([
...getNormalizedIdentityLookupVariants(resource.id),
...getNormalizedIdentityLookupVariants(resource.platformId),
...getNormalizedIdentityLookupVariants(getPreferredResourceDisplayName(resource)),
...getNormalizedIdentityLookupVariants(getPreferredResourceHostname(resource)),
...getResourceIdentityAliases(resource).flatMap((value) =>
getNormalizedIdentityLookupVariants(value),
),
]);
const getLinkedNodeIdFromResource = (resource: Resource): string | null =>
asTrimmedString(getPlatformDataRecord(resource)?.linkedNodeId) ||
asTrimmedString(getPlatformAgentRecord(resource)?.linkedNodeId) ||
null;
// Combine a resource's net in/out into a single throughput series.
// Buckets points into 30-second windows and sums rates from both directions.
export function combineResourceThroughputSeries(
inSeries: MetricPoint[],
outSeries: MetricPoint[],
): MetricPoint[] {
const bucketSize = 30_000; // 30 seconds
const buckets = new Map<number, number>();
for (const point of inSeries) {
const bucket = Math.round(point.timestamp / bucketSize) * bucketSize;
buckets.set(bucket, (buckets.get(bucket) || 0) + point.value);
}
for (const point of outSeries) {
const bucket = Math.round(point.timestamp / bucketSize) * bucketSize;
buckets.set(bucket, (buckets.get(bucket) || 0) + point.value);
}
return Array.from(buckets.entries())
.sort((left, right) => left[0] - right[0])
.map(([timestamp, value]) => ({ timestamp, value }));
}
export function findInfrastructureChartData(
resource: Resource,
chartMap: Map<string, ChartData>,
): ChartData | undefined {
if (chartMap.size === 0) return undefined;
for (const key of getMetricsChartKeyCandidatesFromResource(resource)) {
const match = chartMap.get(key);
if (match) return match;
}
if (resource.clusterId && resource.platformId) {
const clusterKey = `${resource.clusterId}-${resource.platformId}`;
const clusterMatch = chartMap.get(clusterKey);
if (clusterMatch) return clusterMatch;
}
const nameToMatch =
getPreferredResourceHostname(resource) || resource.platformId || resource.name;
if (nameToMatch) {
const suffix = `-${nameToMatch}`;
for (const [key, data] of chartMap) {
if (key.endsWith(suffix)) return data;
}
}
return undefined;
}
export function findInfrastructureAgentChartData(
resource: Resource,
chartMap: Map<string, ChartData>,
agentFacetResources: Resource[],
): ChartData | undefined {
if (chartMap.size === 0) return undefined;
const directAgentCandidates: string[] = [];
const actionableAgentId = getActionableAgentIdFromResource(resource);
if (actionableAgentId) {
directAgentCandidates.push(actionableAgentId);
}
if (resource.platformType === 'agent') {
const discoveryResourceId = asTrimmedString(resource.discoveryTarget?.resourceId);
const discoveryHostId = asTrimmedString(resource.discoveryTarget?.agentId);
if (discoveryResourceId) directAgentCandidates.push(discoveryResourceId);
if (discoveryHostId) directAgentCandidates.push(discoveryHostId);
}
for (const key of Array.from(new Set(directAgentCandidates))) {
const direct = chartMap.get(key);
if (direct) return direct;
}
if (agentFacetResources.length === 0) return undefined;
const nodeRefCandidates = new Set<string>(
[resource.id, resource.platformId, getPreferredResourceHostname(resource)]
.map((value) => value?.trim().toLowerCase())
.filter((value): value is string => Boolean(value)),
);
const resourceNameCandidates = getNormalizedResourceIdentifiers(resource);
for (const agentResource of agentFacetResources) {
const linkedNodeId = getLinkedNodeIdFromResource(agentResource);
const normalizedLinkedNodeId = linkedNodeId?.toLowerCase();
const linkedMatch = normalizedLinkedNodeId
? nodeRefCandidates.has(normalizedLinkedNodeId)
: false;
const agentResourceNames = Array.from(getNormalizedResourceIdentifiers(agentResource));
const hostnameMatch = agentResourceNames.some((candidate) =>
resourceNameCandidates.has(candidate),
);
if (linkedMatch || hostnameMatch) {
for (const key of getMetricsChartKeyCandidatesFromResource(agentResource)) {
const agentData = chartMap.get(key);
if (agentData) return agentData;
}
}
}
return undefined;
}
export function buildInfrastructureSummarySeries(
resources: Resource[],
chartMap: Map<string, ChartData>,
agentFacetResources: Resource[],
): InfrastructureSummarySeries[] {
return resources.map((resource, index) => {
const primaryData = findInfrastructureChartData(resource, chartMap);
const agentData = findInfrastructureAgentChartData(resource, chartMap, agentFacetResources);
const seriesId = resource.id || resource.platformId || resource.name || `resource-${index}`;
const metricSeries = (metric: keyof ChartData): MetricPoint[] => {
const primary = primaryData?.[metric];
if (primary && primary.length > 0) return primary;
const fallback = agentData?.[metric];
if (fallback && fallback.length > 0) return fallback;
return [];
};
return {
key: seriesId,
id: seriesId,
cpu: metricSeries('cpu'),
memory: metricSeries('memory'),
disk: metricSeries('disk'),
netin: metricSeries('netin'),
netout: metricSeries('netout'),
network: combineResourceThroughputSeries(metricSeries('netin'), metricSeries('netout')),
diskio: combineResourceThroughputSeries(
metricSeries('diskread'),
metricSeries('diskwrite'),
),
color: getChartSeriesColor(index),
name: getPreferredResourceDisplayName(resource),
};
});
}
export function buildInfrastructureWorkloadStats(
workloads: Resource[],
): InfrastructureWorkloadStats {
let running = 0;
let stopped = 0;
let vms = 0;
let containers = 0;
for (const workload of workloads) {
if (workload.status === 'running' || workload.status === 'online') {
running++;
} else {
stopped++;
}
if (workload.type === 'vm') {
vms++;
} else {
containers++;
}
}
return { total: workloads.length, running, stopped, vms, containers };
}
export function buildInfrastructureResourceCounts(
resources: Resource[],
): InfrastructureResourceCounts {
const total = resources.length;
const online = resources.filter((resource) => resource.status === 'online').length;
return {
total,
online,
offline: total - online,
};
}
export function getAverageDiskCapacity(resources: Resource[]): number | null {
const diskResources = resources.filter((resource) => resource.disk && resource.disk.total);
if (diskResources.length === 0) return null;
const average =
diskResources.reduce((sum, resource) => sum + getDiskPercent(resource), 0) /
diskResources.length;
return Math.round(average);
}
export function hasInfrastructureNetworkCapability(resources: Resource[]): boolean {
return resources.some((resource) => {
if (resource.type === 'docker-host' || hasAgentFacet(resource)) return true;
const rx = resource.network?.rxBytes ?? 0;
const tx = resource.network?.txBytes ?? 0;
return rx > 0 || tx > 0;
});
}
@@ -0,0 +1,329 @@
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import type { ChartData, TimeRange } from '@/api/charts';
import { useResources } from '@/hooks/useResources';
import {
fetchInfrastructureSummaryAndCache,
readInfrastructureSummaryCache,
} from '@/utils/infrastructureSummaryCache';
import { hasAgentFacet } from '@/utils/agentResources';
import { getOrgID } from '@/utils/apiClient';
import { normalizeOrgScope } from '@/utils/orgScope';
import { eventBus } from '@/stores/events';
import {
type InfrastructureSummaryProps,
type InfrastructureSummarySparkMetric,
buildInfrastructureResourceCounts,
buildInfrastructureSummarySeries,
buildInfrastructureWorkloadStats,
getAverageDiskCapacity,
hasInfrastructureNetworkCapability,
} from './infrastructureSummaryModel';
// In-memory full-resolution cache keyed by "org::range".
// Survives component unmount/remount (page navigation) without the
// downsampling artifacts that localStorage cache introduces.
// Capped at MAX_IN_MEMORY_INFRA_ENTRIES to prevent unbounded growth.
const MAX_IN_MEMORY_INFRA_ENTRIES = 20;
const inMemoryChartCache = new Map<string, Map<string, ChartData>>();
function inMemoryCacheKey(range: TimeRange): string {
return `${normalizeOrgScope(getOrgID())}::${range}`;
}
/** @internal Test-only reset for in-memory chart cache. */
export function __resetInMemoryChartCacheForTests(): void {
inMemoryChartCache.clear();
}
const unsubscribeInfraOrgSwitch = eventBus.on('org_switched', () => {
inMemoryChartCache.clear();
});
if (import.meta.hot) {
import.meta.hot.dispose(() => {
unsubscribeInfraOrgSwitch();
});
}
export function useInfrastructureSummaryState(props: InfrastructureSummaryProps) {
const [chartMap, setChartMap] = createSignal<Map<string, ChartData>>(new Map());
const [chartRange, setChartRange] = createSignal<TimeRange | null>(null);
const [loadedRange, setLoadedRange] = createSignal<TimeRange | null>(null);
const [oldestDataTimestamp, setOldestDataTimestamp] = createSignal<number | null>(null);
const [fetchFailed, setFetchFailed] = createSignal(false);
const selectedRange = createMemo<TimeRange>(() => props.timeRange || '1h');
const hasCurrentRangeCharts = createMemo(() => chartRange() === selectedRange());
const isCurrentRangeLoaded = createMemo(() => loadedRange() === selectedRange());
const { workloads, resources } = useResources();
const agentResources = createMemo(() =>
resources().filter(
(resource) =>
(resource.type === 'agent' ||
resource.type === 'pbs' ||
resource.type === 'pmg' ||
resource.type === 'truenas') &&
hasAgentFacet(resource),
),
);
const [orgVersion, setOrgVersion] = createSignal(0);
const unsubscribeOrgSwitch = eventBus.on('org_switched', () => {
setOrgVersion((value) => value + 1);
});
let refreshTimer: ReturnType<typeof setInterval> | undefined;
let activeFetchController: AbortController | null = null;
let activeFetchRequest = 0;
let activeScopeKey: string | null = null;
const awaitAbortable = <T,>(promise: Promise<T>, signal: AbortSignal): Promise<T> => {
if (signal.aborted) {
return Promise.reject(new DOMException('Aborted', 'AbortError'));
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
reject(new DOMException('Aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort);
resolve(value);
},
(error) => {
signal.removeEventListener('abort', onAbort);
reject(error);
},
);
});
};
const fetchCharts = async (options?: { prioritize?: boolean }) => {
if (props.resources.length === 0) {
return;
}
const prioritize = options?.prioritize === true;
if (activeFetchController && !prioritize) {
return;
}
if (activeFetchController && prioritize) {
activeFetchController.abort();
}
const requestedRange = selectedRange();
const controller = new AbortController();
const requestId = ++activeFetchRequest;
activeFetchController = controller;
try {
const fetched = await awaitAbortable(
fetchInfrastructureSummaryAndCache(requestedRange, { caller: 'InfrastructureSummary' }),
controller.signal,
);
if (requestId !== activeFetchRequest) {
return;
}
const fetchedMap = fetched.map;
const currentMapMatchesRequestedRange = chartRange() === requestedRange;
if (fetchedMap.size > 0 || chartMap().size === 0 || !currentMapMatchesRequestedRange) {
const cacheKey = inMemoryCacheKey(requestedRange);
if (
!inMemoryChartCache.has(cacheKey) &&
inMemoryChartCache.size >= MAX_IN_MEMORY_INFRA_ENTRIES
) {
const oldest = inMemoryChartCache.keys().next().value;
if (oldest !== undefined) inMemoryChartCache.delete(oldest);
}
inMemoryChartCache.set(cacheKey, fetchedMap);
setChartMap(fetchedMap);
setChartRange(requestedRange);
}
setOldestDataTimestamp(fetched.oldestDataTimestamp);
setFetchFailed(false);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
if (requestId === activeFetchRequest) {
setFetchFailed(true);
if (chartRange() !== requestedRange) {
const cached = readInfrastructureSummaryCache(requestedRange);
if (cached) {
setChartMap(cached.map);
setChartRange(requestedRange);
setOldestDataTimestamp(cached.oldestDataTimestamp);
}
}
}
} finally {
if (activeFetchController === controller) {
activeFetchController = null;
}
if (requestId === activeFetchRequest) {
setLoadedRange(requestedRange);
}
}
};
createEffect(() => {
const hasResources = props.resources.length > 0;
const currentOrg = orgVersion();
if (!hasResources) {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = undefined;
}
if (activeFetchController) {
activeFetchController.abort();
activeFetchController = null;
}
activeScopeKey = null;
setChartMap(new Map());
setChartRange(null);
setLoadedRange(null);
setOldestDataTimestamp(null);
return;
}
if (!refreshTimer) {
refreshTimer = setInterval(() => void fetchCharts(), 30_000);
}
const nextRange = selectedRange();
const nextScopeKey = `${currentOrg}::${nextRange}`;
if (activeScopeKey !== nextScopeKey) {
activeScopeKey = nextScopeKey;
const inMemoryCached = inMemoryChartCache.get(inMemoryCacheKey(nextRange));
if (inMemoryCached && inMemoryCached.size > 0) {
setChartMap(inMemoryCached);
setChartRange(nextRange);
setLoadedRange(nextRange);
const cached = readInfrastructureSummaryCache(nextRange);
setOldestDataTimestamp(cached?.oldestDataTimestamp ?? null);
} else {
setChartMap(new Map());
setChartRange(null);
setLoadedRange(null);
setOldestDataTimestamp(null);
}
setFetchFailed(false);
void fetchCharts({ prioritize: true });
}
});
onCleanup(() => {
if (refreshTimer) clearInterval(refreshTimer);
if (activeFetchController) {
activeFetchController.abort();
activeFetchController = null;
}
unsubscribeOrgSwitch();
});
const resourceSeries = createMemo(() =>
hasCurrentRangeCharts()
? buildInfrastructureSummarySeries(props.resources, chartMap(), agentResources())
: buildInfrastructureSummarySeries(props.resources, new Map(), agentResources()),
);
const displaySeries = createMemo(() => {
const focused = props.focusedResourceId;
const allSeries = resourceSeries();
if (!focused) return allSeries;
const match = allSeries.find((series) => series.id === focused);
return match ? [match] : allSeries;
});
const focusedResourceName = createMemo(() => {
const focused = props.focusedResourceId;
if (!focused) return null;
const match = resourceSeries().find((series) => series.id === focused);
return match?.name ?? null;
});
const singleDisplayedOnlineResource = createMemo(() => {
if (displaySeries().length !== 1 || props.resources.length !== 1) return null;
const [resource] = props.resources;
if (!resource) return null;
return resource.status?.toLowerCase() === 'online' ? resource : null;
});
const isAwaitingFirstSample = createMemo(() => {
const resource = singleDisplayedOnlineResource();
if (!resource || !isCurrentRangeLoaded() || fetchFailed()) return false;
const oldest = oldestDataTimestamp();
if (oldest === null) return true;
return resource.lastSeen >= oldest;
});
const emptyHistoryLabel = createMemo(() =>
isAwaitingFirstSample() ? 'Waiting for first sample' : 'No history yet',
);
const hasData = (metric: InfrastructureSummarySparkMetric) =>
displaySeries().some((series) => series[metric].length >= 1);
const networkSeries = createMemo(() =>
displaySeries().map((series) => ({
id: series.id,
data: series.network,
color: series.color,
name: series.name,
})),
);
const hasNetData = createMemo(() => displaySeries().some((series) => series.network.length >= 1));
const diskioSeries = createMemo(() =>
displaySeries().map((series) => ({
id: series.id,
data: series.diskio,
color: series.color,
name: series.name,
})),
);
const hasDiskIOData = createMemo(() =>
displaySeries().some((series) => series.diskio.length >= 1),
);
const avgDiskCapacity = createMemo(() => getAverageDiskCapacity(props.resources));
const shouldShowNetworkCard = createMemo(
() => hasNetData() || hasInfrastructureNetworkCapability(props.resources),
);
const workloadStats = createMemo(() => buildInfrastructureWorkloadStats(workloads()));
const resourceCounts = createMemo(() => buildInfrastructureResourceCounts(props.resources));
const emptyMessage = createMemo(() =>
fetchFailed() ? 'Trend data unavailable' : emptyHistoryLabel(),
);
const seriesFor = (metric: InfrastructureSummarySparkMetric) =>
displaySeries().map((series) => ({
id: series.id,
data: series[metric],
color: series.color,
name: series.name,
}));
return {
selectedRange,
isCurrentRangeLoaded,
emptyHistoryLabel,
emptyMessage,
focusedResourceName,
resourceCounts,
workloadStats,
avgDiskCapacity,
shouldShowNetworkCard,
networkSeries,
hasNetData,
diskioSeries,
hasDiskIOData,
hasData,
seriesFor,
};
}
export type InfrastructureSummaryState = ReturnType<typeof useInfrastructureSummaryState>;
@@ -246,6 +246,8 @@ import resourceDetailDrawerShellSource from '@/components/Infrastructure/Resourc
import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw';
import resourceDetailDrawerDebugSource from '@/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx?raw';
import infrastructureSummarySource from '@/components/Infrastructure/InfrastructureSummary.tsx?raw';
import infrastructureSummaryStateSource from '@/components/Infrastructure/useInfrastructureSummaryState.ts?raw';
import infrastructureSummaryModelSource from '@/components/Infrastructure/infrastructureSummaryModel.ts?raw';
import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw';
import resourceDetailDrawerHistoryStateSource from '@/components/Infrastructure/useResourceDetailDrawerHistoryState.ts?raw';
import resourceDetailDrawerDerivedStateSource from '@/components/Infrastructure/useResourceDetailDrawerDerivedState.ts?raw';
@@ -854,8 +856,12 @@ describe('frontend resource type boundaries', () => {
expect(useWorkloadsSource).not.toContain('const normalizeOrgScope =');
expect(useUnifiedResourcesSource).toContain('normalizeOrgScope(getOrgID())');
expect(useUnifiedResourcesSource).not.toContain('const normalizeOrgScope =');
expect(infrastructureSummarySource).toContain('normalizeOrgScope(getOrgID())');
expect(infrastructureSummarySource).not.toContain("getOrgID() || 'default'");
expect(infrastructureSummarySource).toContain('useInfrastructureSummaryState');
expect(infrastructureSummarySource).not.toContain('fetchInfrastructureSummaryAndCache');
expect(infrastructureSummarySource).not.toContain('setInterval(');
expect(infrastructureSummarySource).not.toContain('AbortController');
expect(infrastructureSummaryStateSource).toContain('normalizeOrgScope(getOrgID())');
expect(infrastructureSummaryStateSource).not.toContain("getOrgID() || 'default'");
expect(storageSummarySource).toContain('normalizeOrgScope(getOrgID())');
expect(storageSummarySource).not.toContain("getOrgID() || 'default'");
expect(guestRowSource).toContain('useGuestRowState');
@@ -2581,14 +2587,14 @@ describe('frontend resource type boundaries', () => {
expect(aiChatSource).not.toContain('const normalizeMentionKeyPart =');
expect(chatIdentifiersSource).toContain('normalizeChatMentionKeyPart');
expect(chatIdentifiersSource).toContain('normalizeChatToolName');
expect(infrastructureSummarySource).toContain('getNormalizedIdentityLookupVariants');
expect(infrastructureSummaryModelSource).toContain('getNormalizedIdentityLookupVariants');
expect(infrastructureSummaryTableSource).toContain('getNormalizedIdentityLookupVariants');
expect(resourceIdentitySource).toContain('getNormalizedIdentityLookupVariants');
expect(stringUtilsSource).toContain('export const asTrimmedString');
expect(resourceIdentitySource).not.toContain(
'const asTrimmedString = (value: unknown): string | undefined => {',
);
expect(infrastructureSummarySource).not.toContain(
expect(infrastructureSummaryModelSource).not.toContain(
'const asTrimmedString = (value: unknown): string | null => {',
);
expect(infrastructureSummaryTableSource).not.toContain(