diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index ac1c9ba54..840f2a30e 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 228c5aea9..f37679024 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 6d39dc921..d8f1a3c4e 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/src/components/Infrastructure/InfrastructureSummary.test.tsx b/frontend-modern/src/components/Infrastructure/InfrastructureSummary.test.tsx index f91e06940..e7c03a758 100644 --- a/frontend-modern/src/components/Infrastructure/InfrastructureSummary.test.tsx +++ b/frontend-modern/src/components/Infrastructure/InfrastructureSummary.test.tsx @@ -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'; diff --git a/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx b/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx index 67df3e473..513379701 100644 --- a/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx @@ -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 => - new Set([ - ...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(); - 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>(); - -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 = (props) => { - // Chart data keyed by resource identifier (node name, resource id, etc.) - const [chartMap, setChartMap] = createSignal>(new Map()); - const [chartRange, setChartRange] = createSignal(null); - const [loadedRange, setLoadedRange] = createSignal(null); - const [oldestDataTimestamp, setOldestDataTimestamp] = createSignal(null); - const [fetchFailed, setFetchFailed] = createSignal(false); - const selectedRange = createMemo(() => 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 | undefined; - let activeFetchController: AbortController | null = null; - let activeFetchRequest = 0; - let activeScopeKey: string | null = null; - - const awaitAbortable = (promise: Promise, signal: AbortSignal): Promise => { - if (signal.aborted) { - return Promise.reject(new DOMException('Aborted', 'AbortError')); - } - return new Promise((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( - [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 — {name}; }; @@ -566,31 +28,31 @@ export const InfrastructureSummary: Component = (pro headerLeft={ <> - {resourceCounts().total} {resourceCounts().total === 1 ? 'resource' : 'resources'} + {state.resourceCounts().total}{' '} + {state.resourceCounts().total === 1 ? 'resource' : 'resources'} - 0}> + 0}> - {resourceCounts().online} online + {state.resourceCounts().online} online - 0}> - {resourceCounts().offline} offline + 0}> + {state.resourceCounts().offline} offline } - timeRange={selectedRange()} + timeRange={state.selectedRange()} onTimeRangeChange={props.onTimeRangeChange} > - {/* CPU Card */} = (pro /> - {/* Memory Card */} = (pro /> - {/* Disk I/O Card */} {focusedLabel()} - + - Capacity: {avgDiskCapacity()}% + Capacity: {state.avgDiskCapacity()}% } - loaded={isCurrentRangeLoaded()} - hasData={hasDiskIOData()} - emptyMessage={emptyMsg()} + loaded={state.isCurrentRangeLoaded()} + hasData={state.hasDiskIOData()} + emptyMessage={state.emptyMessage()} > - {/* 4th Card: Network or Workloads fallback */}
@@ -667,26 +126,33 @@ export const InfrastructureSummary: Component = (pro
- {workloadStats().running} + {state.workloadStats().running} running
0} + when={state.workloadStats().total > 0} fallback={
No workloads detected
} >
- 0}> - {workloadStats().vms} VMs + 0}> + {state.workloadStats().vms} VMs - 0 && workloadStats().containers > 0}> + 0 && + state.workloadStats().containers > 0 + } + > · - 0}> - {workloadStats().containers} containers + 0}> + {state.workloadStats().containers} containers
- 0}> -
{workloadStats().stopped} stopped
+ 0}> +
+ {state.workloadStats().stopped} stopped +
@@ -696,12 +162,12 @@ export const InfrastructureSummary: Component = (pro { 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'] } }), diff --git a/frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts b/frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts new file mode 100644 index 000000000..a7924f5a0 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts @@ -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 => + new Set([ + ...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(); + 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, +): 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, + 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( + [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, + 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; + }); +} diff --git a/frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts b/frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts new file mode 100644 index 000000000..5a4a38c2b --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts @@ -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>(); + +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>(new Map()); + const [chartRange, setChartRange] = createSignal(null); + const [loadedRange, setLoadedRange] = createSignal(null); + const [oldestDataTimestamp, setOldestDataTimestamp] = createSignal(null); + const [fetchFailed, setFetchFailed] = createSignal(false); + const selectedRange = createMemo(() => 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 | undefined; + let activeFetchController: AbortController | null = null; + let activeFetchRequest = 0; + let activeScopeKey: string | null = null; + + const awaitAbortable = (promise: Promise, signal: AbortSignal): Promise => { + if (signal.aborted) { + return Promise.reject(new DOMException('Aborted', 'AbortError')); + } + return new Promise((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; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index f99fcdc82..9931c12bb 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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(