diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index bf1638080e..c0b641abd2 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -4986,28 +4986,14 @@ Realtime resource adapters must defensively coalesce split host identities by the same source-bridge rule as the API boundary so a transient backend rebuild cannot surface duplicate infrastructure rows while the next canonical REST snapshot is settling. -Infrastructure summary and detail surfaces now also use the shared normalized -identity lookup helper from `frontend-modern/src/utils/resourceIdentity.ts` -so dotted hostnames and alias variants stay consistent between the shared -table, drawer, and detail views instead of each component carrying its own -identifier-variant logic. -Those same surfaces also share the trimmed-string helper from -`frontend-modern/src/utils/stringUtils.ts` so shared components do not keep +Shared identity helpers in `frontend-modern/src/utils/resourceIdentity.ts` +and `frontend-modern/src/utils/agentResources.ts` use the trimmed-string helper +from `frontend-modern/src/utils/stringUtils.ts` so shared components do not keep their own copy of the same whitespace-trimming identity logic. -The shared infrastructure summary table now also follows the same -shell/runtime/model shape as the rest of the modernized primitives. -`frontend-modern/src/components/shared/InfrastructureSummaryTable.tsx` stays -the table shell, `frontend-modern/src/components/shared/useInfrastructureSummaryTableState.ts` -owns alert wiring, sort state, breakpoint state, and expanded-row lifecycle, -`frontend-modern/src/components/shared/infrastructureSummaryTableModel.ts` -owns sorting, count, identity-alias, and linked-agent derivation, and -`frontend-modern/src/components/shared/InfrastructureSummaryTableRow.tsx` -owns the per-row render/runtime surface. Future work should extend those -owners instead of pushing websocket, alert, or identity plumbing back into the -shared table shell. -The shared infrastructure summary row may consume alert-backed metric -thresholds from the table state, but threshold selection itself remains -alerts-owned through `frontend-modern/src/stores/alertsActivation.ts` and +Shared metric cells such as +`frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx` +take resolved warning/critical thresholds from the table that renders them, +but threshold selection itself remains alerts-owned through `frontend-modern/src/stores/alertsActivation.ts` and `frontend-modern/src/utils/metricThresholds.ts`; shared primitive cells and rows must only pass resolved warning/critical values into metric presentation. That alerts-owned boundary distinguishes detector state from external @@ -5017,31 +5003,6 @@ applicable, but they must not derive visibility or threshold presentation from `activationState`. The activation control is presented as notification delivery only, and its localized paused copy must state that detection and in-product active-alert visibility continue. -The shared infrastructure selector now follows that same owner split. -`frontend-modern/src/components/shared/InfrastructureSelector.tsx` stays the -render shell, `frontend-modern/src/components/shared/useInfrastructureSelectorState.ts` -owns selected-node state, tab-reset and escape-key lifecycle, plus hook-backed -resource and recovery composition, and -`frontend-modern/src/components/shared/infrastructureSelectorModel.ts` owns -resource-family counts, agent-backed node-summary projection, unified-node and -PBS-instance projection, and recovery backup-count derivation. Future -infrastructure-selector work should extend those owners instead of pushing -resource aggregation or selection lifecycle back into the shared shell. -That shared selector projection must also preserve canonical local operator -identity for agent-backed infrastructure labels. Governed or AI-safe resource -summaries may inform policy/detail surfaces, but the selector's summary and -drawer-facing agent labels must continue to use the same local instance -identity boundary as the operator-facing infrastructure tables so multiple PBS, -PMG, or other governed resources remain distinguishable. -The shared infrastructure details drawer now follows that same owner split. -`frontend-modern/src/components/shared/InfrastructureDetailsDrawer.tsx` stays -the render shell, `frontend-modern/src/components/shared/useInfrastructureDetailsDrawerState.ts` -owns tab-selection runtime, and -`frontend-modern/src/components/shared/infrastructureDetailsDrawerModel.ts` -owns canonical metadata-id and discovery-hostname derivation. Future -infrastructure-details-drawer work should extend those owners instead of -pushing tab state or resource-identity normalization back into the shared -shell. Object detail drawers follow one operator-first information hierarchy across platform implementations. Overview must begin with `DrawerAttentionSection` when active alert or health evidence exists and show the actual problem text, @@ -5153,8 +5114,8 @@ That shell must also stay passive with respect to data ownership: future overview trend cards may render summary-range controls and operator-facing empty or error copy only after they have a governed owner, and they must not reintroduce route-local metrics-history fetch loops for CPU and memory -sparklines when the canonical infrastructure summary surface already owns the -chart contract. +sparklines; the infrastructure and workloads summary chart routes already own +that chart contract. The shared density map now follows that same owner split. `frontend-modern/src/components/shared/DensityMap.tsx` stays the render shell, `frontend-modern/src/components/shared/useDensityMapState.ts` owns hover @@ -7538,10 +7499,8 @@ storage-only live chart primitive for the same telemetry. The shared shell boundary now also includes `frontend-modern/src/contexts/appRuntime.ts` as the only neutral owner for app-level websocket and dark-mode consumption. Shared shells and primitives -such as `frontend-modern/src/components/Settings/Settings.tsx`, -`frontend-modern/src/components/shared/TagBadges.tsx`, and -`frontend-modern/src/components/shared/useInfrastructureSummaryTableState.ts` -may consume that module, but they must not import `@/App` or recreate shell +such as `frontend-modern/src/components/Settings/Settings.tsx` and +`frontend-modern/src/components/shared/TagBadges.tsx` may consume that module, but they must not import `@/App` or recreate shell providers. `frontend-modern/src/App.tsx` owns provider placement; primitives own reusable consumption only. That same shared settings-shell and banner boundary now also owns demo-mode 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 9ac7408aa9..653e10909b 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -510,7 +510,7 @@ in-place click and chevron activation over a selection. 96. `frontend-modern/src/components/Workloads/__tests__/useGroupedTableWindowing.test.ts` 98. `frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts` 99. `frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx` -100. `frontend-modern/src/utils/workloadsSummaryCache.ts` +100. `frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts` 101. `frontend-modern/src/routing/routePreload.ts` 102. `frontend-modern/src/useAppRuntimeState.ts` 103. `frontend-modern/src/utils/storageSummaryCache.ts` @@ -1525,11 +1525,11 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. other per-request version discovery on activation, legacy exchange, or grant-refresh traffic just to stamp authenticated install metadata. 30. Keep retired dashboard summary-chart paths absent rather than replacing -them with new hot-path fetches. Infrastructure summary cards must continue to -hydrate through `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` -and the canonical infrastructure-summary route owned by -`internal/api/router_routes_monitoring.go` and `internal/api/router.go`; storage -summary cards must use the storage summary cache owners. The authenticated +them with new hot-path fetches. Node rows that draw infrastructure history +must read the canonical infrastructure-summary route owned by +`internal/api/router_routes_monitoring.go` and `internal/api/router.go`, today +through `frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts`; +storage summary cards must use the storage summary cache owners. The authenticated root path in `frontend-modern/src/App.tsx` must not prewarm a deleted dashboard-specific overview, trend, or summary transport. The same hot path must keep mock/demo chart identity on the canonical @@ -1614,10 +1614,9 @@ path. `frontend-modern/src/pages/Dashboard.tsx`, surfaces for KPI cards, problem-resource rows, or top-infrastructure identities. New summary cards must live on their owning product route and prove their data path there. -32. Keep infrastructure and assistant consumers off deleted dashboard summary -state. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` -and globally mounted helpers such as `frontend-modern/src/components/AI/Chat/index.tsx` -must read the live websocket snapshot or existing unified-resource cache +32. Keep assistant and other globally mounted consumers off deleted dashboard +summary state. Globally mounted helpers such as +`frontend-modern/src/components/AI/Chat/index.tsx` must read the live websocket snapshot or existing unified-resource cache rather than forcing root navigation to pay for a replacement dashboard overview transport. When the assistant shell changes presentation, `frontend-modern/src/utils/aiChatPresentation.ts` must remain the canonical @@ -1950,9 +1949,9 @@ re-instantiate `useWorkloadTableMetricHistory` and name the rows they draw through its required `series` option. The Proxmox nodes table passes `'nodes'` and polls only the infrastructure summary; the WorkloadsSurface reader passes `'guests'` and polls only the workloads history. Two readers -on one page do not share reads: each forwards its own abort signal, which -bypasses the summary caches' in-flight dedupe, and retained query values -seed matching mounts and source changes without deduplicating requests. A +on one page do not share reads: each passes its own abort signal straight to +the charts API, and retained query values seed matching mounts and source +changes without deduplicating requests. A reader therefore must not poll a summary its rows do not render. In Trends the vSphere overview polls only the workloads history, and the Proxmox overview polls the workloads history and the infrastructure summary once @@ -2049,10 +2048,11 @@ must not restart a read. A range change or leaving the lens mode cancels pending and in-flight reads, while leaving a row lets its read settle into the bounded cache. Per-row query owners, estate-wide fan-out, and polling remain forbidden. Persistent Trends retains one cache-keyed -`fetchWorkloadsSummaryAndCache` reader for all rendered guest rows. Grouped +`ChartsAPI.getWorkloadCharts` reader for all rendered guest rows. Grouped host rows render no metric cells, so the WorkloadsSurface reader never polls -`fetchInfrastructureSummaryAndCache`; node series belong to the page's hosts -table reader. Range-sensitive readers clear prior-range data when no +`ChartsAPI.getInfrastructureSummaryCharts`; node series belong to the page's +hosts table reader. Both readers keep fetched series only in the query layer's +retained cache; neither persists a summary to browser storage. Range-sensitive readers clear prior-range data when no exact cache entry exists and forward cancellation so superseded range work does not continue occupying browser connections. Sparkline ranges must stay bounded to the governed compact table windows. Expanded history belongs in the existing guest drawer chart @@ -2211,15 +2211,10 @@ Operator-state suppression therefore adds at most one read per new finding, not per existing-finding update, and not per operator-set flag. -Summary cards for Infrastructure, Storage, Workloads, and Recovery now surface -health-state counts (offline, degraded, alerting) instead of raw online/offline -splits. `InfrastructureSummary.tsx` and `infrastructureSummaryModel.ts` add -`degraded` and `alerting` resource counts; `StorageSummary.tsx` and -`useStoragePageSummary.ts` add `poolsDegraded` and `disksFailing` indicators; -`WorkloadsSummary.tsx` and `useWorkloadsDerivedState.ts` add an -alerting count derived from `activeAlerts`. These additions must remain -read-only projections from existing websocket state — they must not introduce -new polling loops or widen fetch scope on the hot-path boundary. +A route summary that surfaces health-state counts (offline, degraded, +alerting) must derive them as read-only projections from existing websocket +state. It must not introduce new polling loops or widen fetch scope on the +hot-path boundary. Agentless availability endpoints participate in the same unified-resource consumer hot path as other infrastructure resources. Adding `network-endpoint` to resource queries, filters, and summary counts must reuse @@ -2475,23 +2470,22 @@ helper must still take over whenever the opened detail would land below the fold, marking that movement as deliberate so route-state restore does not replay over it and then scrolling only enough to keep the row header plus the top of the detail visible instead of hard-centering every expansion. -That same hot-path ownership now includes summary cache invalidation. -Infrastructure and workload summary caches may hydrate charts for fast remounts, -but when the summary chart timeline contract changes, the cache version must -advance and stale payloads must be purged on read so long-lived browser sessions -cannot keep rendering pre-fix sparkline shapes after the backend timeline model -has been corrected. -That same cache contract also applies to same-tab in-memory hydration on the -protected summary hot path. Infrastructure and workload summary shells must -version their module-scoped remount caches alongside the chart contract so a -hot-reloaded or long-lived browser tab cannot immediately rehydrate an older -summary series set before the next live fetch completes. -That same hot-path rule now applies to infrastructure summary resource -filtering: `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` -must include API-backed systems such as top-level TrueNAS appliances through -the shared `isAgentFacetInfrastructureResource(...)` helper instead of a local -`resource.type` branch, so the summary poll/cache path stays on one canonical -infrastructure selector contract. +That same hot-path ownership now includes summary cache invalidation. A +summary chart cache that hydrates charts for fast remounts, today the storage +page's module-scoped `frontend-modern/src/utils/storageSummaryCache.ts`, must +carry the chart contract version in its key and advance it when the summary +chart timeline contract changes, so a hot-reloaded or long-lived browser tab +cannot rehydrate an older series set before the next live fetch completes. +The table history reader keeps no summary cache of its own: +`useWorkloadTableMetricHistory.ts` holds the infrastructure and workloads +summaries only in the query layer's retained cache, which expires entries after +five minutes and clears on org switch. A new persistent summary cache needs a +reader in the same change: after their readers were deleted, the infrastructure +and workloads summary caches kept serializing every polled response on the main +thread (on the 50-node, 929-guest mock estate at the 1h range, about 9.6 MB of +workloads and 0.9 MB of infrastructure JSON per 30-second poll) only to write +it to browser storage, or drop it when over the size cap, with nothing reading +it. That same protected hot path keeps storage trend loading route-owned after the dashboard overview retirement. `internal/api/router.go` must continue serving the compact `/api/charts/storage-summary` request backed by @@ -2526,12 +2520,6 @@ query only if the runtime preserves ascending timestamps plus correct avg/min/max bucket aggregates within every returned resource/metric series, and the hot-path proof keeps latency, ordering, and aggregate correctness guarded together in the explicit metrics SLO surface. That protected surface should -That same workload-summary hot path now also owns chart-cache invalidation -whenever the shaped timeline contract changes. `frontend-modern/src/components/Workloads/WorkloadsSummary.tsx` -must version-bust cached summary payloads in the same slice that changes -workload chart bucket semantics or timestamp precision, so operators are not -served stale mixed-cadence chart shapes after the backend timeline model has -already been corrected. stay on one ordered index scan plus Go-side bucket aggregation rather than forcing SQLite to `GROUP BY` computed buckets through a temp B-tree on the fleet-scale workload path. @@ -2942,17 +2930,16 @@ summary and detail surfaces iterate the bounded attached set from the same payload. Neither plural presentation nor freshness evaluation may issue a check-specific request, reconstruct the set from the settings API, or add a second websocket hydration path. -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, focused-summary display -selection, empty-state wording, and summary-series/metric derivation. Future -summary-chart work must not put polling, cache hydration, and series math -back into the shell. -The summary API feeding that hot path must also normalize mixed-resolution -history into equal-time summary buckets before it reaches the shell/runtime -owners, so long-range cards do not bunch recent higher-resolution samples at -the right edge. +The infrastructure summary API (`/api/charts/infrastructure`, shaped in +`internal/api/chartapi/service.go`) must normalize mixed-resolution history into +equal-time summary buckets before it reaches its consumers, today the Proxmox +nodes table's Trends sparklines (`frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx`, +reading through `useWorkloadTableMetricHistory` with `series: 'nodes'`), so +long-range series do not bunch recent higher-resolution samples at the right +edge. On the frontend, +`useWorkloadTableMetricHistory.ts` owns polling for both estate summaries and +`workloadMetricHistoryModel.ts` owns series folding and chart-key matching; +table rows must not grow their own summary polling or series math. Compact resource-facet summary chips render through the shared `ResourceFacetSummary` component, whose only production consumer is the detail drawer's change history, so it renders every chip. A table-row use must @@ -3010,10 +2997,11 @@ default `Internal` + `Cloud Summary` policy pair. That baseline posture still belongs to the canonical policy contract, but repeating it on every row burns row-density budget without adding operator-grade signal. No platform table renders policy chips today. -The shared node adapter also uses that same cluster-name helper for the -infrastructure summary surface, so Proxmox node projections stay aligned with -the same canonical cluster label instead of carrying a raw adapter-local -cluster string. +The shared node adapter (`nodeFromResource` in +`frontend-modern/src/utils/resourceStateAdapters.ts`) also uses that same +cluster-name helper for the node's `clusterName`, so the Proxmox host drawer +(`frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx`) shows the +same canonical cluster label instead of a raw adapter-local cluster string. The drawer's Kubernetes namespace/deployment tabs use the canonical cluster-name helper for fetch keys, so the visible navigation label stays separate from the backend cluster lookup contract. @@ -3049,30 +3037,20 @@ series key. Pod chart and history consumers must normalize bare pod IDs onto `k8s::pod:` before lookup; otherwise demo and mock workloads pay repeated empty-store misses and redundant refetch churn even while the shared workload charts already hold the same pod metrics in memory. -The infrastructure and workload summary cards now share a canonical -throughput-rate formatter in `frontend-modern/src/utils/throughputPresentation.ts`, -so bytes-per-second labels stay consistent between the two summary surfaces -instead of each component carrying its own rate string builder. -That shared throughput boundary is now also explicitly governed here. -`frontend-modern/src/components/Workloads/WorkloadsSummary.tsx` is the -canonical workload-summary hot-path surface, and -`frontend-modern/src/utils/throughputPresentation.ts` is the canonical -bytes-per-second formatter shared by workload and infrastructure summary -cards. Future throughput wording or workload-summary hot-path changes must -extend these performance-owned surfaces instead of leaving the formatter or -summary shell unowned in registry coverage. - Resource detail mappers now also use the shared `frontend-modern/src/utils/textPresentation.ts` title-case helper for sensor labels, so the canonical presentation layer owns that wording instead of the mapper carrying its own title-casing branch. -Dashboard, workload-summary, infrastructure-summary, and org-scoped cache-key -paths now normalize org scope through the shared -`frontend-modern/src/utils/orgScope.ts` helper instead of each file carrying -its own `getOrgID() || 'default'` fallback. That keeps cache isolation and -multi-tenant row-scoping aligned across the dashboard and resource-summary hot -paths. +Org-scoped cache-key paths (`frontend-modern/src/hooks/useWorkloads.ts`, +`frontend-modern/src/hooks/useUnifiedResources.ts`, and +`frontend-modern/src/utils/storageSummaryCache.ts`) normalize org scope through +the shared `frontend-modern/src/utils/orgScope.ts` helper instead of each file +carrying its own `getOrgID() || 'default'` fallback. That keeps cache isolation +and multi-tenant row-scoping aligned across the resource and storage-summary hot +paths. Readers on `createNonSuspendingQuery` need no org key: on `org_switched` +the query layer clears retained and live values, then re-reads every reader +with a non-null source; a disabled reader fetches when it is enabled. GitHub-hosted runner proof for the API performance surface now intentionally uses a looser budget envelope than local/staging benchmark runs for the @@ -3163,8 +3141,8 @@ runtime-capabilities response, but route shells must treat them as already loaded runtime identity facts. They must not start polling billing entitlements, checkout, or commercial posture endpoints just to decide whether a community runtime should show private Pulse Pro download guidance. -Workloads and infrastructure summary consumers now also keep null-tolerant read -models on the shared hot path. Guest rows, stacked bars, anomaly summaries, and +Workloads consumers now also keep null-tolerant read models on the shared hot +path. Guest rows, stacked bars, anomaly summaries, and resource detail mappers may accept partial platform metadata or undefined ratios, but they must normalize those values once in the shared model layer instead of scattering non-null assertions or per-component count coercion diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index cb267e319a..95466d4ebd 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -7073,6 +7073,7 @@ "frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts", "frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts", "frontend-modern/src/components/Workloads/useWorkloadsState.ts", + "frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts", "frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts", "frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts", "frontend-modern/src/components/Workloads/workloadFilterConfigModel.ts", @@ -7105,7 +7106,6 @@ "frontend-modern/src/utils/searchQuery.ts", "frontend-modern/src/utils/thresholdSliderPresentation.ts", "frontend-modern/src/utils/workloads.ts", - "frontend-modern/src/utils/workloadsSummaryCache.ts", "internal/api/router_bench_test.go", "internal/api/slo.go", "internal/api/slo_bench_test.go", @@ -7232,6 +7232,7 @@ "frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts", "frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts", "frontend-modern/src/components/Workloads/useWorkloadsState.ts", + "frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts", "frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts", "frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts", "frontend-modern/src/components/Workloads/workloadFilterConfigModel.ts", @@ -7258,8 +7259,7 @@ "frontend-modern/src/types/workloads.ts", "frontend-modern/src/utils/searchQuery.ts", "frontend-modern/src/utils/thresholdSliderPresentation.ts", - "frontend-modern/src/utils/workloads.ts", - "frontend-modern/src/utils/workloadsSummaryCache.ts" + "frontend-modern/src/utils/workloads.ts" ], "allow_same_subsystem_tests": false, "test_prefixes": [], @@ -7279,6 +7279,7 @@ "frontend-modern/src/components/Workloads/__tests__/useStackedMemoryBarState.test.tsx", "frontend-modern/src/components/Workloads/__tests__/useThresholdSliderState.test.ts", "frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts", + "frontend-modern/src/components/Workloads/__tests__/useWorkloadTableMetricHistory.test.tsx", "frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx", "frontend-modern/src/components/Workloads/__tests__/workloadFilterConfigModel.test.ts", "frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.branchcov2.test.ts", @@ -7303,8 +7304,7 @@ "frontend-modern/src/hooks/__tests__/useWorkloads.test.ts", "frontend-modern/src/utils/__tests__/searchQuery.test.ts", "frontend-modern/src/utils/__tests__/thresholdSliderPresentation.test.ts", - "frontend-modern/src/utils/__tests__/workloads.test.ts", - "frontend-modern/src/utils/__tests__/workloadsSummaryCache.test.ts" + "frontend-modern/src/utils/__tests__/workloads.test.ts" ] }, { diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index c191173944..84472d8cba 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2560,41 +2560,24 @@ application resource-provider or WebSocket lifecycle. `vmware` metadata plus shared `resource-incident` timeline entries on canonical `agent`, `vm`, `storage`, and `network` resources instead of creating provider-only resource kinds, identities, or history schemas. -9. Keep summary-surface emphasis on canonical resource IDs. Infrastructure - summary row-hover, chart-hover, and route-focus behavior must keep using the - same unified-resource IDs that power the table rows, chart series, and - detail-route handoffs instead of introducing page-local summary IDs or - provider-local hover aliases when the selected series is highlighted. -10. Keep infrastructure contextual focus route-backed and page-scoped. When an - infrastructure row opens its detail drawer, the selection must stay on the - same route through canonical resource query state, and the shared - summary-table focus/runtime contract plus the root app-shell restore path - must let direct row toggles open that drawer in place instead of looking - like a page refresh or remount. The - summary must keep `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx` - rendering the full page-level series set while only the focused label and - highlight state change. -11. Keep infrastructure summary hover scope on canonical unified-resource ids - even when one metric is empty. Shared chart hover may synchronize one - timestamp across all four infrastructure cards, but the active emphasis - must still resolve through the same unified-resource id that powers the - table row, line charts, density maps, and drawer route state instead of - dropping the highlight or inventing a metric-local summary identity when - disk or network data is missing in range. When a sibling infrastructure - card can resolve that same resource id locally, it should surface the - synchronized value through the shared summary-card readout instead of - spawning a second chart tooltip. - Infrastructure summary numeric readouts that reflect canonical resource - counts or capacity must use the shared `AnimatedNumber` primitive rather - than page-local counter state, so readout motion stays presentation-only and - canonical unified-resource identity and scope stay unchanged. -12. Keep infrastructure summary visibility as display preference, not a - unified-resource filter. Platform/runtime pages and shared infrastructure - summary consumers may hide or restore chart sections through shared - presentation controls, but those controls must not mutate resource +9. Keep summary-surface emphasis on canonical resource IDs. A page that pairs + summary charts with resource rows must keep row-hover, chart-hover, and + route-focus behavior on the same unified-resource IDs that power the table + rows, chart series, and detail-route handoffs instead of introducing + page-local summary IDs or provider-local hover aliases when the selected + series is highlighted. +10. Keep row contextual focus page-scoped. Pages whose rows share summary focus + through `useSummaryPageInteractionState` in + `frontend-modern/src/components/shared/summaryTableFocus.ts` (today the + Workloads and Storage pages) must let a direct row toggle open its detail + in place through that shared runtime and the root app-shell restore path, + instead of looking like a page refresh or remount. +12. Keep summary-chart visibility a display preference, not a unified-resource + filter. Platform/runtime pages may hide or restore chart sections through + shared presentation controls, but those controls must not mutate resource identity, table membership, source scope, or summary-hover state. The - retired top-level `/infrastructure` page and its saved-view/route-state - machinery must not be reintroduced for this purpose. + retired top-level `/infrastructure` page, its summary chart strip, and its + saved-view/route-state machinery must not be reintroduced for this purpose. 15. Keep operator-local resource search on the operator's own names. Resource-policy redaction is a transmission boundary (`docs/PRIVACY.md`), so local search over unified resources, such as the `ResourcePicker` and @@ -3391,12 +3374,10 @@ to the exact resource-owned lifecycle capability and consume the canonical Proxmox source status/timestamp; it must not reconstruct action availability or freshness from guest power state or a locally hard-coded polling window. -`InfrastructureSummary.tsx` and `infrastructureSummaryModel.ts` now surface -`degraded` and `alerting` resource counts alongside the existing `online` and -`offline` totals. `buildInfrastructureResourceCounts` is the canonical owner of -this health-state projection: it must derive counts only from the resources -accessor already available to the summary state, not from a separate API call or -a re-projection of websocket state outside the shared summary pipeline. +A page summary that shows `degraded` or `alerting` resource counts beside +`online` and `offline` totals must derive them from the unified resources its +page already holds, not from a separate API call or a re-projection of +websocket state outside the shared resource pipeline. `resourceBadgePresentation.ts` owns the canonical system identity resolver, `getInfrastructureSystemIdentityBadges(...)`, which feeds the resource drawer's @@ -3905,12 +3886,6 @@ unambiguous: `ResourceID` scopes saved vCenter connection replacement to `ConnectionID`, while host UUID / DMI identity matching belongs to the machine-identity selector path, so editing one connection cannot strip an unrelated VMware host whose UUID happens to equal that connection ID. -That shared consumer ownership now includes same-tab summary hydration too. -`frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` -may keep an in-memory remount cache for canonical resource charts, but the key -must carry an explicit summary contract version so a long-lived browser tab -cannot resurrect older unified-resource summary shapes after the chart model or -identity mapping changes. That same consumer ownership now also includes infrastructure drawer history fetches. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts` may hydrate canonical facet and intelligence data for the selected resource, @@ -4021,19 +3996,6 @@ provider facet must be merged onto the shared resource so source identity, health, routing, and detail payloads cannot disagree. Operator-facing source filters may accept the `vmware-vsphere` alias for the VMware platform, but the emitted shared source family remains canonical `vmware`. -That same canonical identity boundary now also applies to infrastructure -summary emphasis. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx` -may vary card presentation, but the active sparkline or density-map series must -still resolve through the shared resource ID already emitted by unified -resources so table hover, focused detail state, and summary highlight all point -to the same resource identity. -That same unified-resource boundary now also applies to infrastructure -contextual focus plumbing. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` -must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for -interactive-series filtering, focused-resource naming, and active-series -resolution so the infrastructure summary stays page-scoped while still -highlighting the same canonical resource IDs used by the unified-resource -table and route state. TrueNAS application resources now carry the same shared source boundary. `internal/truenas/provider.go` projects native app API records into `TrueNASData.App`, while `internal/unifiedresources/types.go`, @@ -4473,20 +4435,14 @@ used to list one ID four times), `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, focused-summary display -selection, empty-state wording, and summary-series/metric derivation. The retired dashboard overview trend hook must not return as a second -infrastructure sparkline consumer. Infrastructure summary surfaces must consume -the infrastructure summary chart cache and shared unified-resource series -matching logic instead of issuing bespoke per-resource -`/api/metrics-store/history` fetches for overview cards. That keeps summary -sparklines aligned with canonical resource identity matching, agent-facet -fallback behavior, and first-sample empty-state semantics already owned by the -infrastructure summary surface. +infrastructure sparkline consumer. Node-row sparklines read the infrastructure +summary route through +`frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts` and +match series to rows through the chart-key candidates in +`frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts` +(node ID, linked agent ID, and node name variants) instead of issuing bespoke +per-node `/api/metrics-store/history` fetches. 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 @@ -4621,10 +4577,11 @@ The same shared agent-resource module now also owns the canonical cluster-name helpers, so Kubernetes context prefixes, Proxmox cluster labels, and cluster-name fetch keys stay aligned instead of each surface rebuilding its own pod, namespace, and VM routing fallbacks. -The shared node-state adapter also routes Proxmox cluster labels through that -same helper, so infrastructure summary projections keep the same canonical -cluster name as the rest of the unified resource model instead of rewriting -the label locally. +The shared node-state adapter (`nodeFromResource` in +`frontend-modern/src/utils/resourceStateAdapters.ts`) also routes Proxmox +cluster labels through that same helper into the node's `clusterName`, so the +Proxmox host drawer keeps the same canonical cluster name as the rest of the +unified resource model instead of rewriting the label locally. Native provider tables keep a phone projection: prioritized identity and operational columns sized to the available width, with no local or document-level horizontal scrolling, for Proxmox backup, coverage, recoverable, Ceph, Mail Gateway, and replication rows; Docker native @@ -4768,18 +4725,10 @@ 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 summary chart contract now also requires the backend feed to -normalize tiered infrastructure history into equal-time summary buckets, so -long-range unified-resource cards do not expose storage-tier density changes as -right-edge chart compression. +The shared summary chart contract requires the backend feed to normalize +tiered infrastructure history into equal-time summary buckets, so long-range +node sparklines do not expose storage-tier density changes as right-edge chart +compression. 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 @@ -5406,9 +5355,8 @@ validation flows, `frontend-modern/src/utils/resourceTypeCompat.ts` owns frontend canonicalization from legacy or alias type tokens, and `frontend-modern/src/utils/resourceTypePresentation.ts` owns canonical resource-type labels and badge styling. `frontend-modern/src/utils/resourceBadgePresentation.ts` -and `frontend-modern/src/components/Infrastructure/resourceBadges.ts` then -own the shared resource/source/platform badge composition used by unified -resource tables, drawers, and infrastructure summary cards. Future resource +then owns the shared resource/source/platform badge composition used by +resource tables and drawers. Future resource type or badge wording changes should extend these unified-resource owners instead of rebuilding type-label or badge mapping logic inside infrastructure, settings, alerts, recovery, or dashboard-local helpers. @@ -5620,12 +5568,7 @@ ReadState resource-resolution lookups must also normalize surrounding whitespace on the incoming name before matching canonical resources. A valid resource must not look missing just because a consumer asked for `" myserver "` instead of `"myserver"`. -The infrastructure summary surfaces now use the shared normalized identity -lookup helper for these matches, so dotted hostnames such as -`tower.example.local` collapse to the same canonical lookup variants as the -resource table and resource detail surfaces instead of each view inventing -its own comparison rule. -The same identity surfaces also share the trimmed-string helper from +Frontend identity helpers share the trimmed-string helper from `frontend-modern/src/utils/stringUtils.ts` so resource-id, hostname, and linked-node normalization keep the same fail-closed whitespace trimming rules instead of reimplementing ad hoc local string cleanups. diff --git a/frontend-modern/browser-verification/8365b44861c837c3.json b/frontend-modern/browser-verification/8365b44861c837c3.json new file mode 100644 index 0000000000..e0a12f9e46 --- /dev/null +++ b/frontend-modern/browser-verification/8365b44861c837c3.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "base_sha": "bd4f6e36c40f2f47658adaf92051694f73ef5fe0", + "verified_at": "2026-10-07T11:50:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts", + "frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts", + "frontend-modern/src/utils/infrastructureSummaryCache.ts", + "frontend-modern/src/utils/workloadsSummaryCache.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts": "f5906a562d0a029a67cc64b82b57525ce1ca42753f2163a694513861b4f5f4d3", + "frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts": "cf481a5bfea04e278750a9238b64f3583e03ae9e1ce764d28216206a3c214d85", + "frontend-modern/src/utils/infrastructureSummaryCache.ts": "deleted", + "frontend-modern/src/utils/workloadsSummaryCache.ts": "deleted" + }, + "routes": [ + "/proxmox/overview" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Embedded production build of this change, served by a private DEMO_MODE mock backend (50 Proxmox nodes, 929 guests, PULSE_TELEMETRY=false); an embedded build of the unchanged base ran beside it for comparison. The build base differs from bd4f6e36c4 only by two unused helpers in utils/format.ts and utils/status.ts, which tree-shaking drops", + "Proxmox overview in Trends mode at 1440x900: 12 of 12 visible node rows draw CPU, memory and disk sparklines (36 with data) and 140 of 140 rendered guest rows draw history sparklines (420 with data), identical to the base build", + "Proxmox overview in Trends mode at 390x844: 6 of 6 node rows (18 sparklines) and 36 of 36 guest rows (108 sparklines) draw history, identical to the base build", + "Network: one /api/charts/workloads and one /api/charts/infrastructure read on load and one of each on the next 30-second poll, matching the base build", + "Storage: no pulse.infrastructureSummaryCharts.* or pulse.workloadsSummaryCharts.* localStorage keys, no Storage.setItem/removeItem calls on those prefixes and no JSON.stringify output over 200k characters during 75 seconds; the base build serialized 924,249 and 9,629,902 characters per poll (5-31 ms each) and then removed both keys because they exceeded the 900k cap" + ], + "interactions": [ + "Demo sign-in, then load /proxmox with the Workloads display mode stored as Trends (sparklines)", + "Hold the page through a 30-second history poll and re-count sparklines, chart requests and localStorage keys", + "Wrap Storage.setItem/removeItem and JSON.stringify before load to record summary-cache storage traffic and large serializations on both builds", + "Screenshot of the node table sparklines at 1440x900" + ] +} diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index b34926525a..2d3b2accc9 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -417,8 +417,8 @@ describe('App architecture', () => { expect(appRuntimeStateSource).not.toContain("import('@/components/Settings/Settings')"); expect(appSource).toContain('const timeoutId = window.setTimeout(() => {'); expect(appSource).toContain('void preloadAppShellRoutes();'); - expect(appRuntimeStateSource).not.toContain('fetchInfrastructureSummaryAndCache'); - expect(appRuntimeStateSource).not.toContain('fetchWorkloadsSummaryAndCache'); + expect(appRuntimeStateSource).not.toContain('getInfrastructureSummaryCharts'); + expect(appRuntimeStateSource).not.toContain('getWorkloadCharts'); expect(appRuntimeStateSource).not.toContain('requestIdleCallback'); expect(appRuntimeStateSource).toContain('ssoSessionDisplayName?: string;'); expect(appRuntimeStateSource).toContain( diff --git a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts index eee99b62d3..6c10038452 100644 --- a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts +++ b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts @@ -1052,8 +1052,8 @@ describe('useAppRuntimeState', () => { }); it('keeps retired chart cache prewarm out of the authenticated app shell', () => { - expect(useAppRuntimeStateSource).not.toContain('fetchInfrastructureSummaryAndCache'); - expect(useAppRuntimeStateSource).not.toContain('fetchWorkloadsSummaryAndCache'); + expect(useAppRuntimeStateSource).not.toContain('getInfrastructureSummaryCharts'); + expect(useAppRuntimeStateSource).not.toContain('getWorkloadCharts'); expect(useAppRuntimeStateSource).not.toContain('requestIdleCallback'); expect(useAppRuntimeStateSource).not.toContain('App prewarm'); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx index 5fcd8d24b3..c481cfd535 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx @@ -1408,11 +1408,12 @@ describe('Workloads performance contract', () => { // surface must not poll the infrastructure summary. expect(workloadsStateSource).toContain("series: 'guests',"); expect(workloadTableMetricHistoryStateSource).toContain( - 'fetchWorkloadsSummaryAndCache(parsed.range', + 'ChartsAPI.getWorkloadCharts(parsed.range, signal', ); expect(workloadTableMetricHistoryStateSource).toContain( - 'fetchInfrastructureSummaryAndCache(parsed.range', + 'ChartsAPI.getInfrastructureSummaryCharts(parsed.range, signal', ); + expect(workloadTableMetricHistoryStateSource).not.toContain('localStorage'); expect(workloadTableMetricHistoryStateSource).toContain( "if (options.series !== 'guests' || !options.enabled()) return null;", ); diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadTableMetricHistory.test.tsx b/frontend-modern/src/components/Workloads/__tests__/useWorkloadTableMetricHistory.test.tsx index a176ba845b..4afee9d469 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadTableMetricHistory.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadTableMetricHistory.test.tsx @@ -6,10 +6,11 @@ import { ChartsAPI, type AllMetricsHistoryResponse } from '@/api/charts'; import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; import type { Node } from '@/types/api'; import type { WorkloadGuest } from '@/types/workloads'; -import { __resetInfrastructureSummaryFetchesForTests } from '@/utils/infrastructureSummaryCache'; import { useWorkloadTableMetricHistory } from '../useWorkloadTableMetricHistory'; import { + WORKLOAD_TABLE_HISTORY_INFRA_METRICS, + WORKLOAD_TABLE_HISTORY_MAX_POINTS, WORKLOAD_TABLE_HISTORY_POLL_MS, type WorkloadTableMetricHistoryRange, } from '../workloadMetricHistoryModel'; @@ -87,7 +88,6 @@ function SeriesProbe(props: { series: 'guests' | 'nodes' }) { afterEach(() => { cleanup(); resetCreateNonSuspendingQueryCacheForTest(); - __resetInfrastructureSummaryFetchesForTests(); vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -132,6 +132,49 @@ describe('useWorkloadTableMetricHistory', () => { }, ); + it('reads both estate summaries straight from the charts API without persisting them', async () => { + const workloadSpy = vi.spyOn(ChartsAPI, 'getWorkloadCharts').mockResolvedValue({ + data: { [guest.id]: { cpu: point(11) } }, + dockerData: {}, + guestTypes: {}, + timestamp: 2, + stats: { oldestDataTimestamp: 1 }, + }); + const infrastructureSpy = vi + .spyOn(ChartsAPI, 'getInfrastructureSummaryCharts') + .mockResolvedValue({ + nodeData: {}, + agentData: { [node.id]: { cpu: point(33) } }, + timestamp: 2, + stats: { oldestDataTimestamp: 1 }, + }); + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + + render(() => ( + <> + + + + )); + + await waitFor(() => { + expect(screen.getAllByTestId('series-values').map((el) => el.textContent)).toEqual([ + 'guest=11 node=none', + 'guest=none node=33', + ]); + }); + expect(workloadSpy).toHaveBeenCalledWith('1h', expect.any(AbortSignal), { + maxPoints: WORKLOAD_TABLE_HISTORY_MAX_POINTS, + nodeId: undefined, + }); + expect(infrastructureSpy).toHaveBeenCalledWith('1h', expect.any(AbortSignal), { + metrics: WORKLOAD_TABLE_HISTORY_INFRA_METRICS, + }); + // The query layer retains each read across remounts; nothing reads a + // persisted copy, so the summaries must not be written to storage. + expect(setItemSpy).not.toHaveBeenCalled(); + }); + it('bounds slow-estate warming and prioritizes a distant active guest', async () => { const guests = Array.from({ length: 500 }, (_, index) => ({ ...guest, diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadMetricHistoryModel.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadMetricHistoryModel.test.ts index 4a1bfe826b..5d172ebaa9 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadMetricHistoryModel.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadMetricHistoryModel.test.ts @@ -4,6 +4,7 @@ import type { Node } from '@/types/api'; import type { WorkloadGuest } from '@/types/workloads'; import { + buildInfrastructureHistoryChartMap, buildMetricMiniSparklinePath, computeMetricMiniSparklineHoverState, findChartDataForCandidates, @@ -67,6 +68,82 @@ describe('workloadMetricHistoryModel', () => { expect(findChartDataForCandidates(['pve-101'], [{ 'pve:pve:101': chartData }])).toBe(chartData); }); + it('folds node, agent and Docker host summary series into one node lookup', () => { + const point = (timestamp: number, value: number) => ({ timestamp, value }); + const map = buildInfrastructureHistoryChartMap({ + nodeData: { + 'cluster-a-pve1': { cpu: [point(1, 10)] }, + 'shared-host': { + cpu: [point(1, 10)], + netin: [point(1, 1), point(2, 2)], + netout: [], + }, + }, + agentData: { + 'agent-1': { memory: [point(1, 40)] }, + 'shared-host': { + cpu: [point(1, 20), point(2, 30)], + netin: [point(1, 5)], + netout: [point(1, 6), point(2, 7)], + }, + }, + dockerHostData: { + 'docker-1': { disk: [point(1, 70)] }, + }, + }); + + expect([...map.keys()].sort()).toEqual([ + 'agent-1', + 'cluster-a-pve1', + 'docker-1', + 'shared-host', + ]); + expect(map.get('cluster-a-pve1')?.cpu).toEqual([point(1, 10)]); + expect(map.get('agent-1')?.memory).toEqual([point(1, 40)]); + expect(map.get('docker-1')?.disk).toEqual([point(1, 70)]); + // A key reported by more than one source keeps the longer series per metric. + const shared = map.get('shared-host'); + expect(shared?.cpu).toEqual([point(1, 20), point(2, 30)]); + expect(shared?.netin).toEqual([point(1, 1), point(2, 2)]); + expect(shared?.netout).toEqual([point(1, 6), point(2, 7)]); + expect(shared?.memory).toBeUndefined(); + expect( + findChartDataForCandidates(getNodeChartKeyCandidates({ id: 'agent:agent-1' } as Node), [map]) + ?.memory, + ).toEqual([point(1, 40)]); + }); + + it('merges every summary metric and lets the later source win a tie', () => { + const metrics = ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'] as const; + const series = (value: number, length: number) => + Array.from({ length }, (_, index) => ({ timestamp: index + 1, value })); + // Node data is longer for even-indexed metrics, agent data for odd ones; + // Docker host data ties the winner and must replace it. + const nodeData = Object.fromEntries(metrics.map((m, i) => [m, series(1, i % 2 === 0 ? 3 : 1)])); + const agentData = Object.fromEntries( + metrics.map((m, i) => [m, series(2, i % 2 === 0 ? 1 : 3)]), + ); + const dockerHostData = Object.fromEntries(metrics.map((m) => [m, series(3, 3)])); + const map = buildInfrastructureHistoryChartMap({ + nodeData: { host: nodeData }, + agentData: { host: agentData }, + dockerHostData: { host: dockerHostData }, + }); + const withoutDocker = buildInfrastructureHistoryChartMap({ + nodeData: { host: nodeData }, + agentData: { host: agentData }, + }); + + for (const [index, metric] of metrics.entries()) { + expect(map.get('host')?.[metric]).toEqual(series(3, 3)); + expect(withoutDocker.get('host')?.[metric]).toEqual(series(index % 2 === 0 ? 1 : 2, 3)); + } + }); + + it('builds an empty node lookup when the infrastructure summary has no series', () => { + expect(buildInfrastructureHistoryChartMap({ nodeData: {} }).size).toBe(0); + }); + it('returns paired I/O series from chart data', () => { const series = getMetricSparklineSeriesFromChartData( { diff --git a/frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts b/frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts index 768fdc4b55..829dcd31f4 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts @@ -3,6 +3,7 @@ import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from import { ChartsAPI, type AllMetricsHistoryResponse, + type ChartData, type ResourceType, type SingleMetricHistoryResponse, type WorkloadChartsResponse, @@ -12,13 +13,9 @@ import type { WorkloadGuest } from '@/types/workloads'; import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; import { getCanonicalWorkloadId } from '@/utils/workloads'; import { getWorkloadMetricHistoryTarget } from '@/utils/workloadMetricHistoryTarget'; -import { - fetchInfrastructureSummaryAndCache, - type InfrastructureSummaryFetchResult, -} from '@/utils/infrastructureSummaryCache'; -import { fetchWorkloadsSummaryAndCache } from '@/utils/workloadsSummaryCache'; import { + buildInfrastructureHistoryChartMap, findChartDataForCandidates, getMetricSparklineSeriesFromChartData, getNodeChartKeyCandidates, @@ -61,10 +58,7 @@ const EMPTY_WORKLOAD_CHARTS_RESPONSE: WorkloadChartsResponse = { stats: { oldestDataTimestamp: 0 }, }; -const EMPTY_INFRASTRUCTURE_CHARTS_RESPONSE: InfrastructureSummaryFetchResult = { - map: new Map(), - oldestDataTimestamp: null, -}; +const EMPTY_INFRASTRUCTURE_CHART_MAP = new Map(); const ROW_HISTORY_PREFETCH_CONCURRENCY = 4; const ROW_HISTORY_INITIAL_PREFETCH_COUNT = 6; @@ -130,11 +124,9 @@ export function useWorkloadTableMetricHistory( source: workloadHistoryScope, fetcher: (scope, signal) => { const parsed = parseHistoryScope(scope); - return fetchWorkloadsSummaryAndCache(parsed.range, { - caller: 'WorkloadTableMetricHistory', + return ChartsAPI.getWorkloadCharts(parsed.range, signal, { maxPoints: WORKLOAD_TABLE_HISTORY_MAX_POINTS, - nodeId: parsed.nodeScope === '__all__' ? null : parsed.nodeScope, - signal, + nodeId: parsed.nodeScope === '__all__' ? undefined : parsed.nodeScope, }); }, initialValue: EMPTY_WORKLOAD_CHARTS_RESPONSE, @@ -143,17 +135,15 @@ export function useWorkloadTableMetricHistory( retainPreviousValueOnSourceChange: false, }); - const infrastructureHistory = createNonSuspendingQuery({ + const infrastructureHistory = createNonSuspendingQuery, string>({ source: infrastructureHistoryScope, fetcher: (scope, signal) => { const parsed = parseHistoryScope(scope); - return fetchInfrastructureSummaryAndCache(parsed.range, { - caller: 'WorkloadTableMetricHistory', + return ChartsAPI.getInfrastructureSummaryCharts(parsed.range, signal, { metrics: WORKLOAD_TABLE_HISTORY_INFRA_METRICS, - signal, - }); + }).then(buildInfrastructureHistoryChartMap); }, - initialValue: EMPTY_INFRASTRUCTURE_CHARTS_RESPONSE, + initialValue: EMPTY_INFRASTRUCTURE_CHART_MAP, cacheKey: (scope) => `workload-table-infra-history:${scope}`, pollMs: WORKLOAD_TABLE_HISTORY_POLL_MS, retainPreviousValueOnSourceChange: false, @@ -340,7 +330,7 @@ export function useWorkloadTableMetricHistory( const getNodeMetricSeries = (node: Node, metric: WorkloadTableMetric) => { const chartData = findChartDataForCandidates(getNodeChartKeyCandidates(node), [ - infrastructureHistory.value().map, + infrastructureHistory.value(), ]); return getMetricSparklineSeriesFromChartData(chartData, metric); }; diff --git a/frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts b/frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts index 054a51971b..e4c67e5717 100644 --- a/frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts +++ b/frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts @@ -1,4 +1,4 @@ -import type { ChartData, MetricPoint, TimeRange } from '@/api/charts'; +import type { ChartData, InfrastructureChartsResponse, MetricPoint, TimeRange } from '@/api/charts'; import type { Node } from '@/types/api'; import type { WorkloadGuest } from '@/types/workloads'; import { getCanonicalWorkloadId } from '@/utils/workloads'; @@ -200,6 +200,49 @@ export const getNodeChartKeyCandidates = (node: Node): string[] => { type ChartDataMap = Record | Map | undefined; +const pickLongerSeries = ( + incoming: MetricPoint[] | undefined, + existing: MetricPoint[] | undefined, +): MetricPoint[] | undefined => { + const incomingLength = incoming?.length ?? 0; + const existingLength = existing?.length ?? 0; + if (incomingLength === 0 && existingLength === 0) return undefined; + return incomingLength >= existingLength ? incoming : existing; +}; + +const mergeInfrastructureChartData = ( + existing: ChartData | undefined, + incoming: ChartData, +): ChartData => { + if (!existing) return incoming; + return { + cpu: pickLongerSeries(incoming.cpu, existing.cpu), + memory: pickLongerSeries(incoming.memory, existing.memory), + disk: pickLongerSeries(incoming.disk, existing.disk), + diskread: pickLongerSeries(incoming.diskread, existing.diskread), + diskwrite: pickLongerSeries(incoming.diskwrite, existing.diskwrite), + netin: pickLongerSeries(incoming.netin, existing.netin), + netout: pickLongerSeries(incoming.netout, existing.netout), + }; +}; + +// The infrastructure summary reports node, host-agent and Docker-host series +// in separate maps. Node rows look them up by one key set, so fold the maps +// together; where a key appears in more than one, each metric keeps the +// longer series. +export const buildInfrastructureHistoryChartMap = ( + response: Pick, +): Map => { + const map = new Map(); + for (const source of [response.nodeData, response.agentData, response.dockerHostData]) { + if (!source) continue; + for (const [id, data] of Object.entries(source)) { + map.set(id, mergeInfrastructureChartData(map.get(id), data)); + } + } + return map; +}; + const readChartData = (source: ChartDataMap, key: string): ChartData | undefined => { if (!source || !key) return undefined; if (source instanceof Map) return source.get(key); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 0007cbf67c..99c4583106 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -176,7 +176,6 @@ import guestDrawerStateSource from '@/components/Workloads/useGuestDrawerState.t import workloadGuestPresentationSource from '@/utils/workloadGuestPresentation.ts?raw'; import orgScopeSource from '@/utils/orgScope.ts?raw'; import workloadsUtilitySource from '@/utils/workloads.ts?raw'; -import infrastructureSummaryCacheSource from '@/utils/infrastructureSummaryCache.ts?raw'; import workloadEmptyStatePresentationSource from '@/utils/workloadEmptyStatePresentation.ts?raw'; import resourceChangeSummarySource from '@/components/Infrastructure/ResourceChangeSummary.tsx?raw'; import resourceChangePresentationSource from '@/utils/resourceChangePresentation.ts?raw'; @@ -819,8 +818,6 @@ describe('frontend resource type boundaries', () => { expect(enhancedCpuBarStateSource).toContain('useTooltip'); expect(enhancedCpuBarModelSource).toContain('export function buildEnhancedCPUBarPresentation'); expect(enhancedCpuBarModelSource).toContain('tooltipUsageClass'); - expect(infrastructureSummaryCacheSource).toContain('normalizeOrgScope(getOrgID())'); - expect(infrastructureSummaryCacheSource).not.toContain('const normalizeOrgScope ='); expect(useWorkloadsSource).toContain('normalizeOrgScope(getOrgID())'); expect(useWorkloadsSource).not.toContain('const normalizeOrgScope ='); expect(useUnifiedResourcesSource).toContain('normalizeOrgScope(getOrgID())'); diff --git a/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.branchcov.test.ts b/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.branchcov.test.ts deleted file mode 100644 index 7334c62937..0000000000 --- a/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.branchcov.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ChartStats, InfrastructureChartsResponse, TimeRange } from '@/api/charts'; -import { setOrgID } from '@/utils/apiClient'; -import { - __resetInfrastructureSummaryFetchesForTests, - extractInfrastructureSummaryChartMapFromInfrastructureResponse, - fetchInfrastructureSummaryAndCache, - persistInfrastructureSummaryCache, - readInfrastructureSummaryCache, -} from '@/utils/infrastructureSummaryCache'; - -// ChartsAPI is mocked so fetchInfrastructureSummaryAndCache is deterministic and -// offline. apiClient stays real (setOrgID) and orgScope/events stay real, exactly -// like the sibling infrastructureSummaryCache.test.ts. -const mockGetCharts = vi.fn(); - -vi.mock('@/api/charts', async () => { - const actual = await vi.importActual('@/api/charts'); - return { - ...actual, - ChartsAPI: { - ...actual.ChartsAPI, - getInfrastructureSummaryCharts: (...args: unknown[]) => mockGetCharts(...args), - }, - }; -}); - -const DEFAULT_METRICS_KEY = 'cpu,memory,disk,diskread,diskwrite,netin,netout'; - -const cacheKeyForRange = (range: TimeRange, orgScope = 'default', metrics = DEFAULT_METRICS_KEY) => - `pulse.infrastructureSummaryCharts.${encodeURIComponent(orgScope)}::${range}::${metrics}`; - -interface MetricPoint { - timestamp: number; - value: number; -} - -const metricPoint = (timestamp: number, value: number): MetricPoint => ({ timestamp, value }); - -// Build a series of N points starting at `start`, stepping by `step` ms. -const makeSeries = (count: number, start = 0, step = 30_000): MetricPoint[] => - Array.from({ length: count }, (_, i) => metricPoint(start + i * step, i)); - -const now = () => Date.now(); - -// A minimal, type-correct InfrastructureChartsResponse fixture. -const makeInfraResponse = ( - overrides: Partial = {}, -): InfrastructureChartsResponse => ({ - nodeData: {}, - timestamp: now(), - stats: { oldestDataTimestamp: now() - 60_000 }, - ...overrides, -}); - -// Shape of the JSON payload persistInfrastructureSummaryCache writes. Used to -// hand-craft localStorage entries that exercise readInfrastructureSummaryCache's -// invalidation / coercion branches directly. -interface StoredPayload { - version: number; - range: TimeRange; - cachedAt: number; - metrics: string[]; - oldestDataTimestamp: number | string | null; - charts: Record> | undefined; -} - -const storePayload = ( - range: TimeRange, - payload: StoredPayload, - orgScope = 'default', - metrics = DEFAULT_METRICS_KEY, -) => { - window.localStorage.setItem(cacheKeyForRange(range, orgScope, metrics), JSON.stringify(payload)); -}; - -const buildPayload = (overrides: Partial = {}): StoredPayload => ({ - version: 5, - range: '1h', - cachedAt: now(), - metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'], - oldestDataTimestamp: null, - charts: {}, - ...overrides, -}); - -describe('extractInfrastructureSummaryChartMapFromInfrastructureResponse', () => { - it('returns an empty map when every data field is absent', () => { - const map = extractInfrastructureSummaryChartMapFromInfrastructureResponse( - makeInfraResponse({ nodeData: undefined }), - ); - expect(map.size).toBe(0); - }); - - it('defaults missing nodeData to an empty object while still indexing agentData', () => { - const map = extractInfrastructureSummaryChartMapFromInfrastructureResponse( - makeInfraResponse({ - nodeData: undefined, - agentData: { - 'agent-1': { cpu: makeSeries(2) }, - }, - }), - ); - expect(map.size).toBe(1); - expect(map.get('agent-1')?.cpu?.length).toBe(2); - }); - - it('indexes node, dockerHost and agent entries as distinct keys', () => { - const map = extractInfrastructureSummaryChartMapFromInfrastructureResponse( - makeInfraResponse({ - nodeData: { 'node-1': { cpu: makeSeries(1) } }, - dockerHostData: { 'docker-1': { memory: makeSeries(1) } }, - agentData: { 'agent-1': { disk: makeSeries(1) } }, - }), - ); - expect([...map.keys()].sort()).toEqual(['agent-1', 'docker-1', 'node-1']); - expect(map.get('node-1')?.cpu?.length).toBe(1); - expect(map.get('docker-1')?.memory?.length).toBe(1); - expect(map.get('agent-1')?.disk?.length).toBe(1); - }); -}); - -describe('readInfrastructureSummaryCache invalidation', () => { - beforeEach(() => { - setOrgID('default'); - localStorage.clear(); - }); - - it('returns null on a cache miss (no raw entry)', () => { - expect(readInfrastructureSummaryCache('1h')).toBeNull(); - }); - - it('evicts and returns null when the stored version mismatches', () => { - storePayload('1h', buildPayload({ version: 4 })); - expect(readInfrastructureSummaryCache('1h')).toBeNull(); - expect(localStorage.getItem(cacheKeyForRange('1h'))).toBeNull(); - }); - - it('evicts and returns null when the stored range mismatches', () => { - storePayload('1h', buildPayload({ range: '24h' })); - expect(readInfrastructureSummaryCache('1h')).toBeNull(); - expect(localStorage.getItem(cacheKeyForRange('1h'))).toBeNull(); - }); - - it('evicts and returns null when the stored metrics mismatch', () => { - storePayload('1h', buildPayload({ metrics: ['cpu'] })); - expect(readInfrastructureSummaryCache('1h')).toBeNull(); - expect(localStorage.getItem(cacheKeyForRange('1h'))).toBeNull(); - }); - - it('evicts and returns null when cachedAt is not a number', () => { - storePayload('1h', buildPayload({ cachedAt: 'recent' as unknown as number })); - expect(readInfrastructureSummaryCache('1h')).toBeNull(); - expect(localStorage.getItem(cacheKeyForRange('1h'))).toBeNull(); - }); - - it('evicts and returns null when the entry is older than a custom maxAgeMs', () => { - storePayload('1h', buildPayload({ cachedAt: now() - 100_000 })); - // default TTL (5m) still fresh - expect(readInfrastructureSummaryCache('1h')).not.toBeNull(); - localStorage.clear(); - storePayload('1h', buildPayload({ cachedAt: now() - 100_000 })); - expect(readInfrastructureSummaryCache('1h', 50_000)).toBeNull(); - expect(localStorage.getItem(cacheKeyForRange('1h'))).toBeNull(); - }); - - it('returns an empty map when charts is missing', () => { - storePayload('1h', buildPayload({ charts: undefined })); - const cached = readInfrastructureSummaryCache('1h'); - expect(cached).not.toBeNull(); - expect(cached?.map.size).toBe(0); - expect(cached?.cachedAt).toBeTypeOf('number'); - }); - - it('coerces non-array series fields to empty arrays', () => { - storePayload( - '1h', - buildPayload({ - charts: { - 'node-1': { - cpu: makeSeries(1), - memory: 123, - disk: 'not-an-array', - diskread: null, - diskwrite: undefined, - netin: {}, - netout: [], - }, - }, - }), - ); - const cached = readInfrastructureSummaryCache('1h'); - const series = cached?.map.get('node-1'); - expect(series).toBeDefined(); - expect(series?.cpu?.length).toBe(1); - expect(series?.memory).toEqual([]); - expect(series?.disk).toEqual([]); - expect(series?.diskread).toEqual([]); - expect(series?.diskwrite).toEqual([]); - expect(series?.netin).toEqual([]); - expect(series?.netout).toEqual([]); - }); - - it('returns null oldestDataTimestamp when the stored value is not a finite number', () => { - storePayload('1h', buildPayload({ oldestDataTimestamp: 'not-a-number', charts: undefined })); - const cached = readInfrastructureSummaryCache('1h'); - expect(cached).not.toBeNull(); - expect(cached?.oldestDataTimestamp).toBeNull(); - }); - - it('returns the numeric oldestDataTimestamp on the happy path', () => { - const ts = now() - 5_000; - storePayload('1h', buildPayload({ oldestDataTimestamp: ts, charts: undefined })); - expect(readInfrastructureSummaryCache('1h')?.oldestDataTimestamp).toBe(ts); - }); - - it('swallows malformed JSON and returns null', () => { - window.localStorage.setItem(cacheKeyForRange('1h'), '{not valid json'); - expect(readInfrastructureSummaryCache('1h')).toBeNull(); - }); -}); - -describe('persistInfrastructureSummaryCache write-failure path', () => { - beforeEach(() => { - setOrgID('default'); - localStorage.clear(); - }); - - it('swallows a localStorage.setItem failure without throwing', () => { - const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new Error('QuotaExceededError'); - }); - try { - expect(() => - persistInfrastructureSummaryCache( - '1h', - new Map([['node-1', { cpu: makeSeries(2) }]]), - now() - 60_000, - ), - ).not.toThrow(); - expect(spy).toHaveBeenCalled(); - } finally { - spy.mockRestore(); - } - }); -}); - -describe('toCachedChartData / trimPoints coverage via persist', () => { - beforeEach(() => { - setOrgID('default'); - localStorage.clear(); - }); - - it('fills absent series with empty arrays (?? [] arm) and keeps present series', () => { - persistInfrastructureSummaryCache( - '1h', - new Map([ - // cpu present, every other series absent -> exercises the `data.X ?? []` - // fallback arm in toCachedChartData for all optional fields. - ['node-1', { cpu: makeSeries(3) }], - ]), - null, - ); - const series = readInfrastructureSummaryCache('1h')?.map.get('node-1'); - expect(series).toBeDefined(); - expect(series?.cpu?.length).toBe(3); - expect(series?.memory).toEqual([]); - expect(series?.disk).toEqual([]); - expect(series?.diskread).toEqual([]); - expect(series?.diskwrite).toEqual([]); - expect(series?.netin).toEqual([]); - expect(series?.netout).toEqual([]); - }); - - it('returns the trimmed series as-is when it fits within the budget (result.length <= max arm)', () => { - // 361 points (> 360 max) exercises the stride-sampling path but produces a - // result shorter than max, so the final `result.length > max ? slice : result` - // ternary takes its `result` arm rather than re-slicing. - const series = makeSeries(361, now() - 361 * 30_000); - persistInfrastructureSummaryCache('1h', new Map([['node-1', { cpu: series }]]), null); - const cached = readInfrastructureSummaryCache('1h')?.map.get('node-1'); - expect(cached?.cpu).toBeDefined(); - // Stride of 2 over 361 points keeps the newest point and yields 181 samples. - const cachedCpu = cached?.cpu ?? []; - expect(cachedCpu.length).toBe(181); - expect(cachedCpu[cachedCpu.length - 1]?.timestamp).toBe(series[series.length - 1]?.timestamp); - }); -}); - -describe('countChartMapPoints coverage via fetch', () => { - beforeEach(() => { - mockGetCharts.mockReset(); - __resetInfrastructureSummaryFetchesForTests(); - setOrgID('default'); - localStorage.clear(); - }); - - it('counts points across mixed partial-series entries without dropping undefined fields', async () => { - // node-1 has only `cpu` (truthy length) and every other field undefined, so - // countChartMapPoints' `data.X?.length ?? 0` expression evaluates both the - // length arm (cpu) and the `?? 0` fallback (memory/disk/...). The perf-log - // data object is built eagerly on every successful fetch, so this exercises - // countChartMapPoints even though perf logging is disabled in tests. - const response = makeInfraResponse({ - nodeData: { 'node-1': { cpu: makeSeries(4) } }, - }); - mockGetCharts.mockResolvedValueOnce(response); - - const result = await fetchInfrastructureSummaryAndCache('1h', { caller: 'branchcov' }); - - expect(result.map.get('node-1')?.cpu?.length).toBe(4); - expect(mockGetCharts).toHaveBeenCalledTimes(1); - }); -}); - -describe('fetchInfrastructureSummaryAndCache error + caller + stats branches', () => { - beforeEach(() => { - mockGetCharts.mockReset(); - __resetInfrastructureSummaryFetchesForTests(); - setOrgID('default'); - localStorage.clear(); - }); - - it('rethrows when ChartsAPI rejects (covers .catch perf-log + rethrow)', async () => { - mockGetCharts.mockRejectedValueOnce(new Error('boom')); - await expect(fetchInfrastructureSummaryAndCache('1h')).rejects.toThrow('boom'); - // in-flight entry is cleared by .finally, so a second call issues a new request - mockGetCharts.mockRejectedValueOnce(new Error('boom2')); - await expect(fetchInfrastructureSummaryAndCache('1h')).rejects.toThrow('boom2'); - expect(mockGetCharts).toHaveBeenCalledTimes(2); - }); - - it('falls back to null oldestDataTimestamp when stats.oldestDataTimestamp is missing', async () => { - const stats = {} as ChartStats; - mockGetCharts.mockResolvedValueOnce( - makeInfraResponse({ nodeData: { 'node-1': { cpu: makeSeries(1) } }, stats }), - ); - const result = await fetchInfrastructureSummaryAndCache('1h'); - expect(result.oldestDataTimestamp).toBeNull(); - expect(result.map.get('node-1')?.cpu?.length).toBe(1); - }); - - it('falls back to null oldestDataTimestamp when stats.oldestDataTimestamp is non-numeric', async () => { - const stats = { oldestDataTimestamp: 'nope' } as unknown as ChartStats; - mockGetCharts.mockResolvedValueOnce( - makeInfraResponse({ nodeData: { 'node-1': { cpu: makeSeries(1) } }, stats }), - ); - const result = await fetchInfrastructureSummaryAndCache('1h'); - expect(result.oldestDataTimestamp).toBeNull(); - }); - - it('defaults caller to unknown when options are omitted', async () => { - mockGetCharts.mockResolvedValueOnce(makeInfraResponse()); - // No caller -> the `options?.caller || 'unknown'` expression takes its - // 'unknown' arm; the call still resolves and persists a cache entry. - await fetchInfrastructureSummaryAndCache('1h'); - expect(readInfrastructureSummaryCache('1h')).not.toBeNull(); - }); -}); diff --git a/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.branchcov0717.test.ts b/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.branchcov0717.test.ts deleted file mode 100644 index f0c710e8d8..0000000000 --- a/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.branchcov0717.test.ts +++ /dev/null @@ -1,721 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { - ChartData, - ChartsResponse, - InfrastructureChartsResponse, - InfrastructureSummaryMetric, - TimeRange, -} from '@/api/charts'; -import { setOrgID } from '@/utils/apiClient'; -import { eventBus } from '@/stores/events'; -import { - __resetInfrastructureSummaryFetchesForTests, - extractInfrastructureSummaryChartMap, - extractInfrastructureSummaryChartMapFromInfrastructureResponse, - fetchInfrastructureSummaryAndCache, - persistInfrastructureSummaryCache, - readInfrastructureSummaryCache, -} from '@/utils/infrastructureSummaryCache'; - -// ChartsAPI is mocked (keeping `apiClient`, `orgScope` and `events` real) so the -// fetch entry point is deterministic and offline — the same isolation strategy -// the sibling `infrastructureSummaryCache.branchcov.test.ts` uses. Helpers below -// mirror its fixture shape so the assertions stay concrete. -const mockGetCharts = vi.fn(); - -vi.mock('@/api/charts', async () => { - const actual = await vi.importActual('@/api/charts'); - return { - ...actual, - ChartsAPI: { - ...actual.ChartsAPI, - getInfrastructureSummaryCharts: (...args: unknown[]) => mockGetCharts(...args), - }, - }; -}); - -const DEFAULT_METRICS_KEY = 'cpu,memory,disk,diskread,diskwrite,netin,netout'; - -const cacheKeyForRange = (range: TimeRange, orgScope = 'default', metrics = DEFAULT_METRICS_KEY) => - `pulse.infrastructureSummaryCharts.${encodeURIComponent(orgScope)}::${range}::${metrics}`; - -interface MetricPoint { - timestamp: number; - value: number; -} - -const metricPoint = (timestamp: number, value: number): MetricPoint => ({ timestamp, value }); - -// Series of N points starting at `start`, stepping by `step` ms. -const makeSeries = (count: number, start = 0, step = 30_000): MetricPoint[] => - Array.from({ length: count }, (_, i) => metricPoint(start + i * step, i)); - -const now = () => Date.now(); - -const makeInfraResponse = ( - overrides: Partial = {}, -): InfrastructureChartsResponse => ({ - nodeData: {}, - timestamp: now(), - stats: { oldestDataTimestamp: now() - 60_000 }, - ...overrides, -}); - -// Shape of the JSON payload persistInfrastructureSummaryCache writes; used to -// hand-craft localStorage entries that exercise readInfrastructureSummaryCache's -// coercion branches directly. -interface StoredPayload { - version: number; - range: TimeRange; - cachedAt: number; - metrics: InfrastructureSummaryMetric[]; - oldestDataTimestamp: number | null; - charts: Record> | undefined; -} - -const buildPayload = (overrides: Partial = {}): StoredPayload => ({ - version: 5, - range: '1h', - cachedAt: now(), - metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'], - oldestDataTimestamp: null, - charts: {}, - ...overrides, -}); - -const storePayload = ( - range: TimeRange, - payload: StoredPayload, - orgScope = 'default', - metrics = DEFAULT_METRICS_KEY, -) => { - window.localStorage.setItem(cacheKeyForRange(range, orgScope, metrics), JSON.stringify(payload)); -}; - -// Replace a global property with `undefined` for the duration of a callback, then -// restore the original descriptor. Used to exercise the `typeof window === -// 'undefined'` and `typeof performance === 'undefined'` early-return / fallback -// arms that the jsdom test environment otherwise keeps firmly on the happy path. -const withGlobalUndefined = (name: 'window' | 'performance', fn: () => T): T => { - const descriptor = Object.getOwnPropertyDescriptor(globalThis, name); - Object.defineProperty(globalThis, name, { - value: undefined, - configurable: true, - writable: true, - }); - try { - return fn(); - } finally { - if (descriptor) { - Object.defineProperty(globalThis, name, descriptor); - } else { - // No pre-existing descriptor: leave the property undefined (matches the - // pre-test state we just established) but ensure it stays configurable. - Object.defineProperty(globalThis, name, { - value: undefined, - configurable: true, - writable: true, - }); - } - } -}; - -describe('extractInfrastructureSummaryChartMap — direct call coverage (uncovered getter)', () => { - // extractInfrastructureSummaryChartMap is only reached in production via - // extractInfrastructureSummaryChartMapFromInfrastructureResponse, which always - // supplies `nodeData: response.nodeData ?? {}`. That keeps the inner - // `if (response.nodeData)` arm firmly truthy. Calling the getter directly - // with an absent nodeData covers the falsy arm of all three optional fields. - - it('returns an empty map when called directly with every data field absent', () => { - const empty = { - data: {}, - storageData: {}, - timestamp: now(), - stats: { oldestDataTimestamp: now() }, - } as unknown as ChartsResponse; - const map = extractInfrastructureSummaryChartMap(empty); - expect(map.size).toBe(0); - }); - - it('returns an empty map when only nodeData is absent (covers the falsy nodeData arm directly)', () => { - // agentData/dockerHostData falsy arms are already reached via the wrapper, - // but the wrapper never forwards an absent nodeData, so this is the only - // way to take the `if (response.nodeData)` false branch. - const response = { - data: {}, - nodeData: undefined, - storageData: {}, - agentData: { 'agent-1': { cpu: makeSeries(1) } }, - timestamp: now(), - stats: { oldestDataTimestamp: now() }, - } as unknown as ChartsResponse; - const map = extractInfrastructureSummaryChartMap(response); - expect([...map.keys()]).toEqual(['agent-1']); - expect(map.get('agent-1')?.cpu?.length).toBe(1); - }); - - it('exposes ChartsResponse.data verbatim because the extractor ignores that field', () => { - // Documents a real-behaviour fact: data on the ChartsResponse is *not* - // indexed, only nodeData/agentData/dockerHostData are. A direct call with - // populated `data` and nothing else yields an empty map. - const response = { - data: { 'vm-1': { cpu: makeSeries(3) } }, - nodeData: {}, - storageData: {}, - timestamp: now(), - stats: { oldestDataTimestamp: now() }, - } as unknown as ChartsResponse; - expect(extractInfrastructureSummaryChartMap(response).size).toBe(0); - }); -}); - -describe('extractInfrastructureSummaryChartMapFromInfrastructureResponse — uncovered optional-forwarding arms', () => { - it('forwards an absent nodeData through the ?? and still extracts agent/docker data', () => { - // The `response.nodeData ?? {}` expression's right operand is only reached - // when nodeData is absent; the wrapper test only exercised populated nodeData. - const map = extractInfrastructureSummaryChartMapFromInfrastructureResponse( - makeInfraResponse({ - nodeData: undefined, - agentData: { 'agent-1': { memory: makeSeries(2) } }, - dockerHostData: { 'docker-1': { disk: makeSeries(2) } }, - }), - ); - expect([...map.keys()].sort()).toEqual(['agent-1', 'docker-1']); - expect(map.get('agent-1')?.memory?.length).toBe(2); - expect(map.get('docker-1')?.disk?.length).toBe(2); - }); -}); - -describe('readInfrastructureSummaryCache — series coercion coverage', () => { - beforeEach(() => { - setOrgID('default'); - localStorage.clear(); - }); - - it('coerces a non-array cpu field to an empty array (Array.isArray false arm for cpu)', () => { - // The sibling test sets `cpu: makeSeries(1)` so the cpu cond-expr's false - // arm stays open; here we store a non-array cpu and assert the coercion. - storePayload( - '1h', - buildPayload({ - charts: { - 'node-1': { - cpu: 'not-an-array', - memory: [], - disk: [], - diskread: [], - diskwrite: [], - netin: [], - netout: [], - }, - }, - }), - ); - const series = readInfrastructureSummaryCache('1h')?.map.get('node-1'); - expect(series?.cpu).toEqual([]); - }); - - it('coerces a non-array netout field to an empty array (Array.isArray false arm for netout)', () => { - // The sibling test sets `netout: []` which is still an array, leaving the - // netout cond-expr false arm uncovered. - storePayload( - '1h', - buildPayload({ - charts: { - 'node-1': { - cpu: [], - memory: [], - disk: [], - diskread: [], - diskwrite: [], - netin: [], - netout: { length: 5 }, - }, - }, - }), - ); - const series = readInfrastructureSummaryCache('1h')?.map.get('node-1'); - expect(series?.netout).toEqual([]); - }); - - it('returns null oldestDataTimestamp for a non-finite numeric value (Infinity)', () => { - // typeof Infinity === 'number' is true, so the only way to reach the null - // fallback past the typeof guard is a number that fails Number.isFinite. - storePayload( - '1h', - buildPayload({ - oldestDataTimestamp: Infinity as unknown as number, - charts: undefined, - }), - ); - expect(readInfrastructureSummaryCache('1h')?.oldestDataTimestamp).toBeNull(); - }); - - it('returns null oldestDataTimestamp for NaN', () => { - storePayload( - '1h', - buildPayload({ - oldestDataTimestamp: NaN, - charts: undefined, - }), - ); - expect(readInfrastructureSummaryCache('1h')?.oldestDataTimestamp).toBeNull(); - }); - - it('falls back to an empty entries list when charts is a non-object truthy value', () => { - // `parsed.charts && typeof parsed.charts === 'object'`: a truthy non-object - // (string) takes the && left-operand true / right-operand false path, - // producing [] rather than Object.entries throwing. - storePayload( - '1h', - buildPayload({ charts: 'not-an-object' as unknown as Record }), - ); - const cached = readInfrastructureSummaryCache('1h'); - expect(cached).not.toBeNull(); - expect(cached?.map.size).toBe(0); - }); - - it('treats a null charts field as the emptyentries branch', () => { - // null && ... -> false -> [] arm. Distinct from undefined (also falsy) and - // from the non-object truthy case above. - storePayload('1h', buildPayload({ charts: null as unknown as undefined })); - const cached = readInfrastructureSummaryCache('1h'); - expect(cached).not.toBeNull(); - expect(cached?.map.size).toBe(0); - }); -}); - -describe('readInfrastructureSummaryCache — hit / miss / expiry / empty boundaries', () => { - beforeEach(() => { - setOrgID('default'); - localStorage.clear(); - }); - - it('returns a cache hit with the exact cachedAt timestamp on the happy path', () => { - // Capture both timestamps once: calling now() again in the assertion - // fails whenever a millisecond ticks between the write and the expect. - const cachedAt = now() - 1_000; - const oldestDataTimestamp = now() - 5_000; - storePayload( - '1h', - buildPayload({ - cachedAt, - oldestDataTimestamp, - charts: { - 'node-1': { - cpu: makeSeries(2), - memory: [], - disk: [], - diskread: [], - diskwrite: [], - netin: [], - netout: [], - }, - }, - }), - ); - const hit = readInfrastructureSummaryCache('1h'); - expect(hit).not.toBeNull(); - expect(hit?.cachedAt).toBe(cachedAt); - expect(hit?.oldestDataTimestamp).toBe(oldestDataTimestamp); - expect(hit?.map.get('node-1')?.cpu?.length).toBe(2); - }); - - it('returns a cache miss for a never-written key', () => { - expect(readInfrastructureSummaryCache('1h')).toBeNull(); - }); - - it('returns a cache hit when age equals maxAgeMs exactly (strict > boundary)', () => { - // `Date.now() - parsed.cachedAt > maxAgeMs` uses strict >, so an entry that - // is exactly maxAgeMs old must still be a hit (boundary is inclusive). - // Frozen clock: with real timers a millisecond tick between the write and - // the read pushes the age past maxAgeMs and flips the verdict. - vi.useFakeTimers(); - try { - const maxAge = 60_000; - const cachedAt = now() - maxAge; - storePayload('1h', buildPayload({ cachedAt, charts: undefined })); - expect(readInfrastructureSummaryCache('1h', maxAge)).not.toBeNull(); - } finally { - vi.useRealTimers(); - } - }); - - it('returns an empty map (cache hit, but empty) when charts is absent', () => { - storePayload('1h', buildPayload({ charts: undefined })); - const hit = readInfrastructureSummaryCache('1h'); - expect(hit).not.toBeNull(); - expect(hit?.map.size).toBe(0); - expect(hit?.oldestDataTimestamp).toBeNull(); - }); - - it('returns early with null when window is undefined', () => { - // Covers the `if (typeof window === 'undefined') return null;` true arm in - // readInfrastructureSummaryCache. - storePayload('1h', buildPayload({ charts: undefined })); - const hit = withGlobalUndefined('window', () => readInfrastructureSummaryCache('1h')); - expect(hit).toBeNull(); - }); -}); - -describe('persistInfrastructureSummaryCache — write-boundary coverage', () => { - beforeEach(() => { - setOrgID('default'); - localStorage.clear(); - }); - - it('returns early without writing when window is undefined', () => { - // Covers the `if (typeof window === 'undefined') return;` true arm. - localStorage.setItem('sentinel', 'keep-me'); - withGlobalUndefined('window', () => { - persistInfrastructureSummaryCache( - '1h', - new Map([['node-1', { cpu: makeSeries(2) }]]), - now() - 60_000, - ); - }); - // No infra cache key was created. - const keys: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i); - if (k) keys.push(k); - } - expect(keys.some((k) => k.startsWith('pulse.infrastructureSummaryCharts.'))).toBe(false); - expect(localStorage.getItem('sentinel')).toBe('keep-me'); - }); - - it('uses an explicit orgScope argument rather than falling back to getOrgID()', () => { - // Covers the left operand of `orgScope ?? getOrgID()` in the persist path: - // when orgScope is supplied, getOrgID() must NOT be consulted, so the entry - // lands under the explicit scope even though the ambient org ID differs. - setOrgID('ambient-org'); - persistInfrastructureSummaryCache( - '1h', - new Map([['node-x', { cpu: makeSeries(1) }]]), - null, - 'explicit-scope', - ); - expect(localStorage.getItem(cacheKeyForRange('1h', 'explicit-scope'))).not.toBeNull(); - // Nothing landed under the ambient org scope. - expect(localStorage.getItem(cacheKeyForRange('1h', 'ambient-org'))).toBeNull(); - }); - - it('triggers the data.cpu ?? [] fallback in toCachedChartData (cpu field absent)', () => { - // The sibling test always supplies cpu, so the `?? []` right operand for - // cpu stays uncovered. Persist a ChartData without cpu and verify the - // cached entry round-trips with cpu === []. - persistInfrastructureSummaryCache('1h', new Map([['node-1', { memory: makeSeries(2) }]]), null); - const series = readInfrastructureSummaryCache('1h')?.map.get('node-1'); - expect(series?.cpu).toEqual([]); - expect(series?.memory?.length).toBe(2); - }); -}); - -describe('countChartMapPoints — uncovered ?? 0 fallbacks via fetch', () => { - beforeEach(() => { - mockGetCharts.mockReset(); - __resetInfrastructureSummaryFetchesForTests(); - setOrgID('default'); - localStorage.clear(); - }); - - it('counts zero points for an entry whose cpu field is absent (data.cpu?.length ?? 0)', () => { - // The perf-log data object is built eagerly on every successful fetch, so - // countChartMapPoints runs even with perf disabled. Supplying a node with - // no cpu takes the `?? 0` right operand for the cpu line. - mockGetCharts.mockResolvedValueOnce( - makeInfraResponse({ - nodeData: { 'node-1': { memory: makeSeries(3) } }, - }), - ); - // Assertion is on the observable round-trip: the fetch resolved and the - // entry round-tripped through persist/read without dropping memory. - return fetchInfrastructureSummaryAndCache('1h').then((result) => { - expect(result.map.get('node-1')?.memory?.length).toBe(3); - expect(result.map.get('node-1')?.cpu).toBeUndefined(); - }); - }); -}); - -describe('fetchInfrastructureSummaryAndCache — error and caller edge arms', () => { - beforeEach(() => { - mockGetCharts.mockReset(); - __resetInfrastructureSummaryFetchesForTests(); - setOrgID('default'); - localStorage.clear(); - }); - - it('rethrows a non-Error rejection as-is after the String(error) fallback arm', () => { - // `error instanceof Error ? error.message : String(error)`: a string throw - // takes the false arm and String('boom') === 'boom' is fed to the perf log. - mockGetCharts.mockRejectedValueOnce('boom'); - return expect(fetchInfrastructureSummaryAndCache('1h')).rejects.toBe('boom'); - }); - - it('rethrows a numeric rejection (String(42) fallback shape)', () => { - mockGetCharts.mockRejectedValueOnce(42); - return expect(fetchInfrastructureSummaryAndCache('1h')).rejects.toBe(42); - }); - - it("falls back to 'unknown' when options.caller is an empty string (|| right arm)", () => { - // `options?.caller || 'unknown'`: empty string is falsy, so the right arm - // is taken. The fetch still resolves and persists a cache entry, which is - // the observable proof the call completed normally. - mockGetCharts.mockResolvedValueOnce(makeInfraResponse()); - return fetchInfrastructureSummaryAndCache('1h', { caller: '' }).then(() => { - expect(readInfrastructureSummaryCache('1h')).not.toBeNull(); - // Caller normalisation must not bleed into the persisted metrics list. - expect(mockGetCharts).toHaveBeenNthCalledWith(1, '1h', undefined, { - metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'], - }); - }); - }); - - it('passes a null metrics option through normalize -> default metrics list', () => { - // `normalizeInfrastructureSummaryMetrics(null)`: Array.isArray(null) is - // false, so the !Array.isArray arm returns the default list rather than - // constructing from the (null) input. - mockGetCharts.mockResolvedValueOnce(makeInfraResponse()); - return fetchInfrastructureSummaryAndCache('1h', { metrics: null }).then(() => { - expect(mockGetCharts).toHaveBeenNthCalledWith(1, '1h', undefined, { - metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'], - }); - }); - }); - - it('deduplicates a custom metric list (separate inFlightKey) from the default list', () => { - // Two different metric lists produce two different inFlightKeys, so they - // issue two distinct requests. This pins the inFlightKeyFor metrics - // participation: changing the metrics list must NOT be deduped against the - // default list. - mockGetCharts.mockResolvedValueOnce(makeInfraResponse()); - mockGetCharts.mockResolvedValueOnce(makeInfraResponse()); - return Promise.all([ - fetchInfrastructureSummaryAndCache('1h', { metrics: ['cpu'] }), - fetchInfrastructureSummaryAndCache('1h'), - ]).then(() => { - expect(mockGetCharts).toHaveBeenCalledTimes(2); - }); - }); -}); - -describe('infraSummaryPerfNow — Date.now() fallback arm', () => { - beforeEach(() => { - mockGetCharts.mockReset(); - __resetInfrastructureSummaryFetchesForTests(); - setOrgID('default'); - localStorage.clear(); - }); - - it('still records a finite ms duration when performance is unavailable', async () => { - // `typeof performance !== 'undefined' && typeof performance.now === 'function'`: - // hiding performance takes the && false arm, so infraSummaryPerfNow falls - // back to Date.now(). The fetch path calls perfNow at start and end, so a - // successful round-trip with performance masked proves the fallback works. - mockGetCharts.mockResolvedValueOnce( - makeInfraResponse({ nodeData: { n: { cpu: makeSeries(1) } } }), - ); - const result = await withGlobalUndefined('performance', () => - fetchInfrastructureSummaryAndCache('1h'), - ); - expect(result.map.get('n')?.cpu?.length).toBe(1); - }); -}); - -describe('org_switched cache invalidation handler — uncovered getter + branches', () => { - // The org_switched handler registered at module load (and its inner forEach - // callback) are never invoked by the existing tests. Emitting the event here - // exercises the whole invalidation closure: the `typeof window` guard, the - // key/prefix filter, the keysToRemove forEach, and the catch-all. - beforeEach(() => { - setOrgID('default'); - localStorage.clear(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('removes every prefixed cache entry on org_switched and preserves unrelated keys', () => { - const prefixedA = `${'pulse.infrastructureSummaryCharts.'}default::1h::${DEFAULT_METRICS_KEY}`; - const prefixedB = `${'pulse.infrastructureSummaryCharts.'}default::24h::cpu`; - const unrelated = 'pulse.somethingElse.key'; - localStorage.setItem(prefixedA, 'a'); - localStorage.setItem(prefixedB, 'b'); - localStorage.setItem(unrelated, 'c'); - - eventBus.emit('org_switched', 'new-org'); - - expect(localStorage.getItem(prefixedA)).toBeNull(); - expect(localStorage.getItem(prefixedB)).toBeNull(); - expect(localStorage.getItem(unrelated)).toBe('c'); - }); - - it('skips keys for which localStorage.key returns null (key && ... false arm)', () => { - // The && left operand is reachable only if a stored key is null; that never - // happens with the in-memory storage used in tests, so mock key() to force - // a null return and confirm the handler completes without pushing anything. - const prefixed = `${'pulse.infrastructureSummaryCharts.'}default::1h::${DEFAULT_METRICS_KEY}`; - localStorage.setItem(prefixed, 'a'); - // Stub Storage.prototype.key to return null for the first slot, which makes - // the `key && key.startsWith(...)` short-circuit on the && left operand. - const keySpy = vi.spyOn(Storage.prototype, 'key').mockReturnValue(null); - try { - expect(() => eventBus.emit('org_switched', 'newer-org')).not.toThrow(); - } finally { - keySpy.mockRestore(); - } - // Because key() returned null, the real prefixed entry was never matched - // and is still present. - expect(localStorage.getItem(prefixed)).toBe('a'); - }); - - it('no-ops when there are no prefixed entries to remove (empty keysToRemove)', () => { - // Covers the forEach callback's "zero iterations" path: keysToRemove is - // empty because no key starts with the prefix. - const unrelated = 'completely.unrelated'; - localStorage.setItem(unrelated, 'keep'); - expect(() => eventBus.emit('org_switched', 'empty-org')).not.toThrow(); - expect(localStorage.getItem(unrelated)).toBe('keep'); - }); - - it('does not throw when the localStorage iteration itself throws (catch arm)', () => { - // Covers the try/catch around the invalidation loop: if localStorage.length - // throws, the handler must swallow it and return silently. - const lengthSpy = vi.spyOn(Storage.prototype, 'length', 'get').mockImplementation(() => { - throw new Error('storage poisoned'); - }); - try { - expect(() => eventBus.emit('org_switched', 'broken-org')).not.toThrow(); - } finally { - lengthSpy.mockRestore(); - } - }); - - it('returns early without iterating when window is undefined (typeof window guard true arm)', () => { - // Covers the `if (typeof window === 'undefined') return;` true arm of the - // org_switched handler. A prefixed entry must survive because the handler - // bails out before touching localStorage. - const prefixed = `${'pulse.infrastructureSummaryCharts.'}default::1h::${DEFAULT_METRICS_KEY}`; - localStorage.setItem(prefixed, 'survives'); - withGlobalUndefined('window', () => { - expect(() => eventBus.emit('org_switched', 'no-window-org')).not.toThrow(); - }); - expect(localStorage.getItem(prefixed)).toBe('survives'); - }); -}); - -describe('infraSummaryPerfLog — perf-enabled branches via module reload', () => { - // `infraSummaryPerfEnabled` is captured once at module load from - // `import.meta.env.DEV && import.meta.env.VITE_INFRA_SUMMARY_PERF === '1'`. - // Reaching the perf-log body therefore requires stubbing the env var AND - // re-evaluating the module (vi.resetModules + dynamic import) so the constant - // is re-read with the stub in place. The static top-level imports that every - // other describe block uses keep pointing at the original (perf-disabled) - // module instance, so this isolation does not leak. - - beforeEach(() => { - setOrgID('default'); - localStorage.clear(); - }); - - afterEach(() => { - vi.resetModules(); - vi.unstubAllEnvs(); - }); - - it('emits [InfraSummaryPerf] console.debug lines when VITE_INFRA_SUMMARY_PERF=1', async () => { - vi.stubEnv('VITE_INFRA_SUMMARY_PERF', '1'); - vi.resetModules(); - const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); - try { - const mod = await import('@/utils/infrastructureSummaryCache'); - mod.__resetInfrastructureSummaryFetchesForTests(); - mockGetCharts.mockReset(); - mockGetCharts.mockResolvedValueOnce( - makeInfraResponse({ nodeData: { n: { cpu: makeSeries(1) } } }), - ); - await mod.fetchInfrastructureSummaryAndCache('1h', { caller: 'perf-enabled' }); - - const prefixLines = debugSpy.mock.calls - .map((call) => (typeof call[0] === 'string' ? call[0] : '')) - .filter((msg) => msg.startsWith('[InfraSummaryPerf]')); - // fetch start, fetch done (data present -> truthy arm of `if (data)`). - expect(prefixLines.some((m) => m.endsWith('fetch start'))).toBe(true); - expect(prefixLines.some((m) => m.endsWith('fetch done'))).toBe(true); - // The data object was forwarded as the second console.debug argument. - const doneCall = debugSpy.mock.calls.find( - ([msg]) => typeof msg === 'string' && msg.endsWith('fetch done'), - ); - expect(doneCall?.[1]).toMatchObject({ caller: 'perf-enabled', range: '1h' }); - } finally { - debugSpy.mockRestore(); - } - }); - - it('logs the fetch-error path with a stringified error when perf is enabled', async () => { - // Covers the perf-log call inside .catch (still gated on perfEnabled) with - // a non-Error rejection so `error instanceof Error ? ... : String(error)` - // takes its String(error) arm under perf-enabled conditions. - vi.stubEnv('VITE_INFRA_SUMMARY_PERF', '1'); - vi.resetModules(); - const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); - try { - const mod = await import('@/utils/infrastructureSummaryCache'); - mod.__resetInfrastructureSummaryFetchesForTests(); - mockGetCharts.mockReset(); - mockGetCharts.mockRejectedValueOnce('perf-boom'); - await expect(mod.fetchInfrastructureSummaryAndCache('1h')).rejects.toBe('perf-boom'); - - const errorLine = debugSpy.mock.calls - .map((call) => (typeof call[0] === 'string' ? call[0] : '')) - .find((msg) => msg.endsWith('fetch error')); - expect(errorLine).toBeDefined(); - const dataArg = debugSpy.mock.calls.find( - ([msg]) => typeof msg === 'string' && msg.endsWith('fetch error'), - )?.[1]; - expect(dataArg).toMatchObject({ error: 'perf-boom' }); - } finally { - debugSpy.mockRestore(); - } - }); -}); - -describe('readInfrastructureSummaryCache — read failure path', () => { - beforeEach(() => { - setOrgID('default'); - localStorage.clear(); - }); - - it('swallows a localStorage.getItem failure and returns null (catch arm)', () => { - // Covers the outer try/catch in readInfrastructureSummaryCache: a throw out - // of getItem must be swallowed and surface as a null result. - const getItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { - throw new Error('getItem exploded'); - }); - try { - expect(readInfrastructureSummaryCache('1h')).toBeNull(); - } finally { - getItemSpy.mockRestore(); - } - }); -}); - -describe('persistInfrastructureSummaryCache — JSON.stringify failure path', () => { - beforeEach(() => { - setOrgID('default'); - localStorage.clear(); - }); - - it('swallows a JSON.stringify cycle error without throwing', () => { - // Build a value that JSON.stringify cannot serialize (a self-referencing - // object smuggled in as a ChartData field). The try/catch around the - // persist body must swallow the TypeError. - const cyclic = { cpu: [] as MetricPoint[] } as unknown as ChartData; - (cyclic as { self: unknown }).self = cyclic; - expect(() => - persistInfrastructureSummaryCache('1h', new Map([['node-1', cyclic]]), null), - ).not.toThrow(); - }); -}); diff --git a/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.test.ts b/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.test.ts deleted file mode 100644 index 70dc906997..0000000000 --- a/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ChartData, TimeRange } from '@/api/charts'; -import { setOrgID } from '@/utils/apiClient'; -import infrastructureSummaryCacheSource from '../infrastructureSummaryCache.ts?raw'; -import { - __resetInfrastructureSummaryFetchesForTests, - extractInfrastructureSummaryChartMapFromInfrastructureResponse, - fetchInfrastructureSummaryAndCache, - persistInfrastructureSummaryCache, - readInfrastructureSummaryCache, -} from '@/utils/infrastructureSummaryCache'; - -const mockGetCharts = vi.fn(); - -vi.mock('@/api/charts', async () => { - const actual = await vi.importActual('@/api/charts'); - return { - ...actual, - ChartsAPI: { - ...actual.ChartsAPI, - getInfrastructureSummaryCharts: (...args: unknown[]) => mockGetCharts(...args), - }, - }; -}); - -const makeResponse = () => ({ - nodeData: { - 'node-1': { - cpu: [ - { timestamp: Date.now() - 30_000, value: 10 }, - { timestamp: Date.now(), value: 15 }, - ], - memory: [ - { timestamp: Date.now() - 30_000, value: 30 }, - { timestamp: Date.now(), value: 35 }, - ], - disk: [ - { timestamp: Date.now() - 30_000, value: 40 }, - { timestamp: Date.now(), value: 45 }, - ], - }, - }, - dockerHostData: {}, - agentData: {}, - timestamp: Date.now(), - stats: { - oldestDataTimestamp: Date.now() - 30_000, - }, -}); - -const makeMetricSeries = (count: number, start = Date.now() - count * 30_000) => - Array.from({ length: count }, (_, i) => ({ - timestamp: start + i * 30_000, - value: i % 100, - })); - -const cacheKeyForRange = ( - range: TimeRange, - orgScope = 'default', - metrics = 'cpu,memory,disk,diskread,diskwrite,netin,netout', -) => `pulse.infrastructureSummaryCharts.${encodeURIComponent(orgScope)}::${range}::${metrics}`; - -describe('infrastructureSummaryCache fetch dedupe', () => { - beforeEach(() => { - mockGetCharts.mockReset(); - __resetInfrastructureSummaryFetchesForTests(); - setOrgID('default'); - localStorage.clear(); - }); - - it('deduplicates concurrent requests for the same range', async () => { - let resolveFetch: ((value: ReturnType) => void) | undefined; - mockGetCharts.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFetch = resolve as (value: ReturnType) => void; - }), - ); - - const first = fetchInfrastructureSummaryAndCache('1h'); - const second = fetchInfrastructureSummaryAndCache('1h'); - - expect(mockGetCharts).toHaveBeenCalledTimes(1); - expect(mockGetCharts).toHaveBeenCalledWith('1h', undefined, { - metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'], - }); - - resolveFetch?.(makeResponse()); - - const [firstResult, secondResult] = await Promise.all([first, second]); - expect(firstResult.map.size).toBe(1); - expect(secondResult.map.size).toBe(1); - }); - - it('fetches separately per range and persists cache entries', async () => { - mockGetCharts.mockImplementation((_range: TimeRange) => Promise.resolve(makeResponse())); - - await fetchInfrastructureSummaryAndCache('1h'); - await fetchInfrastructureSummaryAndCache('24h'); - - expect(mockGetCharts).toHaveBeenCalledTimes(2); - expect(readInfrastructureSummaryCache('1h')).not.toBeNull(); - expect(readInfrastructureSummaryCache('24h')).not.toBeNull(); - }); - - it('separates dashboard metric-filter fetches from the full infrastructure cache', async () => { - mockGetCharts.mockImplementation((_range: TimeRange) => Promise.resolve(makeResponse())); - - await fetchInfrastructureSummaryAndCache('1h', { metrics: ['cpu', 'memory'] }); - await fetchInfrastructureSummaryAndCache('1h'); - - expect(mockGetCharts).toHaveBeenCalledTimes(2); - expect(mockGetCharts).toHaveBeenNthCalledWith(1, '1h', undefined, { - metrics: ['cpu', 'memory'], - }); - expect(mockGetCharts).toHaveBeenNthCalledWith(2, '1h', undefined, { - metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'], - }); - expect( - readInfrastructureSummaryCache('1h', undefined, undefined, ['cpu', 'memory']), - ).not.toBeNull(); - expect(readInfrastructureSummaryCache('1h')).not.toBeNull(); - }); - - it('merges overlapping agentData and dockerHostData keys without dropping richer network series', () => { - const now = Date.now(); - const response = { - nodeData: {}, - agentData: { - 'shared-host': { - cpu: [ - { timestamp: now - 60_000, value: 10 }, - { timestamp: now, value: 20 }, - ], - netin: [ - { timestamp: now - 60_000, value: 1000 }, - { timestamp: now, value: 2000 }, - ], - netout: [ - { timestamp: now - 60_000, value: 500 }, - { timestamp: now, value: 1500 }, - ], - }, - }, - dockerHostData: { - 'shared-host': { - cpu: [ - { timestamp: now - 60_000, value: 30 }, - { timestamp: now, value: 40 }, - ], - netin: [], - netout: [], - }, - }, - timestamp: now, - stats: { - oldestDataTimestamp: now - 60_000, - }, - }; - - const map = extractInfrastructureSummaryChartMapFromInfrastructureResponse(response); - const merged = map.get('shared-host'); - expect(merged).toBeDefined(); - expect(merged?.cpu?.length).toBe(2); - expect(merged?.netin?.length).toBe(2); - expect(merged?.netout?.length).toBe(2); - }); -}); - -describe('infrastructureSummaryCache persistence', () => { - beforeEach(() => { - setOrgID('default'); - localStorage.clear(); - }); - - it('returns cache miss before persist and cache hit after persist', () => { - expect(readInfrastructureSummaryCache('1h')).toBeNull(); - - const now = Date.now(); - persistInfrastructureSummaryCache( - '1h', - new Map([ - [ - 'node-1', - { - cpu: makeMetricSeries(2, now - 60_000), - memory: makeMetricSeries(2, now - 60_000), - disk: makeMetricSeries(2, now - 60_000), - netin: makeMetricSeries(2, now - 60_000), - netout: makeMetricSeries(2, now - 60_000), - }, - ], - ]), - now - 60_000, - ); - - const cached = readInfrastructureSummaryCache('1h'); - expect(cached).not.toBeNull(); - expect(cached?.map.size).toBe(1); - expect(cached?.map.get('node-1')?.cpu?.length).toBe(2); - }); - - it('evicts stale cache entries when ttl has expired', () => { - vi.useFakeTimers(); - try { - const start = new Date('2026-01-01T00:00:00.000Z'); - vi.setSystemTime(start); - - persistInfrastructureSummaryCache( - '1h', - new Map([ - [ - 'node-1', - { - cpu: makeMetricSeries(2, start.getTime() - 60_000), - memory: makeMetricSeries(2, start.getTime() - 60_000), - disk: makeMetricSeries(2, start.getTime() - 60_000), - netin: makeMetricSeries(2, start.getTime() - 60_000), - netout: makeMetricSeries(2, start.getTime() - 60_000), - }, - ], - ]), - start.getTime() - 60_000, - ); - - vi.setSystemTime(new Date('2026-01-01T00:06:01.000Z')); - expect(readInfrastructureSummaryCache('1h')).toBeNull(); - expect(localStorage.getItem(cacheKeyForRange('1h'))).toBeNull(); - } finally { - vi.useRealTimers(); - } - }); - - it('trims cached metric series to bounded point counts', () => { - const sourceSeries = makeMetricSeries(1200); - persistInfrastructureSummaryCache( - '1h', - new Map([ - [ - 'node-1', - { - cpu: sourceSeries, - memory: sourceSeries, - disk: sourceSeries, - netin: sourceSeries, - netout: sourceSeries, - }, - ], - ]), - null, - ); - - const cached = readInfrastructureSummaryCache('1h'); - const series = cached?.map.get('node-1'); - expect(series).toBeDefined(); - expect(series?.cpu?.length).toBeLessThanOrEqual(360); - expect(series?.memory?.length).toBeLessThanOrEqual(360); - expect(series?.disk?.length).toBeLessThanOrEqual(360); - expect(series?.netin?.length).toBeLessThanOrEqual(360); - expect(series?.netout?.length).toBeLessThanOrEqual(360); - const cachedCpu = series?.cpu ?? []; - expect(cachedCpu[cachedCpu.length - 1]?.timestamp).toBe( - sourceSeries[sourceSeries.length - 1]?.timestamp, - ); - }); - - it('drops oversized cache payloads to keep storage bounded', () => { - const denseSeries = makeMetricSeries(360); - const oversizedMap = new Map(); - for (let i = 0; i < 40; i++) { - oversizedMap.set(`node-${i}`, { - cpu: denseSeries, - memory: denseSeries, - disk: denseSeries, - netin: denseSeries, - netout: denseSeries, - }); - } - - persistInfrastructureSummaryCache('1h', oversizedMap, null); - expect(localStorage.getItem(cacheKeyForRange('1h'))).toBeNull(); - }); - - it('keeps cache entries isolated per org scope', () => { - const now = Date.now(); - - setOrgID('org-a'); - persistInfrastructureSummaryCache( - '1h', - new Map([ - [ - 'node-a', - { - cpu: makeMetricSeries(2, now - 60_000), - memory: makeMetricSeries(2, now - 60_000), - disk: makeMetricSeries(2, now - 60_000), - netin: makeMetricSeries(2, now - 60_000), - netout: makeMetricSeries(2, now - 60_000), - }, - ], - ]), - now - 60_000, - ); - - setOrgID('org-b'); - persistInfrastructureSummaryCache( - '1h', - new Map([ - [ - 'node-b', - { - cpu: makeMetricSeries(2, now - 60_000), - memory: makeMetricSeries(2, now - 60_000), - disk: makeMetricSeries(2, now - 60_000), - netin: makeMetricSeries(2, now - 60_000), - netout: makeMetricSeries(2, now - 60_000), - }, - ], - ]), - now - 60_000, - ); - - expect(readInfrastructureSummaryCache('1h')?.map.get('node-b')).toBeDefined(); - expect(readInfrastructureSummaryCache('1h')?.map.get('node-a')).toBeUndefined(); - - expect( - readInfrastructureSummaryCache('1h', undefined, 'org-a')?.map.get('node-a'), - ).toBeDefined(); - expect( - readInfrastructureSummaryCache('1h', undefined, 'org-a')?.map.get('node-b'), - ).toBeUndefined(); - expect(localStorage.getItem(cacheKeyForRange('1h', 'org-a'))).not.toBeNull(); - expect(localStorage.getItem(cacheKeyForRange('1h', 'org-b'))).not.toBeNull(); - }); - - it('normalizes org scope through the shared helper', () => { - expect(infrastructureSummaryCacheSource).toContain('normalizeOrgScope(getOrgID())'); - expect(infrastructureSummaryCacheSource).not.toContain("const DEFAULT_ORG_SCOPE = 'default'"); - expect(infrastructureSummaryCacheSource).not.toContain('const normalizeOrgScope ='); - }); -}); diff --git a/frontend-modern/src/utils/__tests__/workloadsSummaryCache.branchcov0717.test.ts b/frontend-modern/src/utils/__tests__/workloadsSummaryCache.branchcov0717.test.ts deleted file mode 100644 index 4e5fe31647..0000000000 --- a/frontend-modern/src/utils/__tests__/workloadsSummaryCache.branchcov0717.test.ts +++ /dev/null @@ -1,729 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { MetricPoint, TimeRange, WorkloadChartsResponse } from '@/api/charts'; -import { eventBus } from '@/stores/events'; -import { - __resetWorkloadsSummaryCacheForTests, - fetchWorkloadsSummaryAndCache, - hasFreshWorkloadsSummaryCache, - persistWorkloadsSummaryCache, - readInMemoryWorkloadsSummaryCache, - readWorkloadsSummaryCache, - WORKLOADS_SUMMARY_CACHE_VERSION, - workloadsSummaryCacheScopeKey, -} from '@/utils/workloadsSummaryCache'; - -// Module-private helpers (`normalizeNodeScope`, `normalizeMaxPoints`, -// `resolveOrgScope`, `storageCacheKeyFor`, `trimPoints`, `toCachedChartData`, -// `writeInMemoryWorkloadsSummaryCache`) are not exported, so they are exercised -// transitively through the exported entry points below, asserting on their -// observable outputs (cache keys, API call args, trimmed persisted arrays). - -const mockGetWorkloadCharts = vi.fn(); - -vi.mock('@/api/charts', async () => { - const actual = await vi.importActual('@/api/charts'); - return { - ...actual, - ChartsAPI: { - ...actual.ChartsAPI, - getWorkloadCharts: (...args: unknown[]) => mockGetWorkloadCharts(...args), - }, - }; -}); - -// `getOrgID` is the fallback inside `resolveOrgScope` (`orgScope ?? getOrgID()`). -// Mocking it to a sentinel lets us pin the nullish-orgScope arm deterministically. -vi.mock('@/utils/apiClient', () => ({ - getOrgID: () => 'test-org', -})); - -// NOTE: `@/stores/events` is deliberately NOT mocked so the module's real -// `eventBus.on('org_switched', ...)` side-effect registers, letting us exercise -// the org-switch cache-invalidation handler by emitting the event. - -const ORG_FROM_GETORGID = 'test-org'; -const VERSION = WORKLOADS_SUMMARY_CACHE_VERSION; -const PREFIX = 'pulse.workloadsSummaryCharts.'; - -// Mirrors the module's private `resolveOrgScope` (with the mocked getOrgID) for -// setup-only key construction; assertions are on observable values, never on -// this helper's output. -const resolveOrgScopeForSetup = (orgScope?: string | null): string => { - const raw = orgScope ?? ORG_FROM_GETORGID; - const trimmed = (raw || '').trim(); - return trimmed || 'default'; -}; - -const storageKeyFor = ( - range: TimeRange, - normalizedNodeScope: string, - orgScope?: string | null, -): string => - `${PREFIX}${encodeURIComponent(resolveOrgScopeForSetup(orgScope))}::${range}::${encodeURIComponent(normalizedNodeScope || '__all__')}`; - -const metric = (value: number): MetricPoint => ({ timestamp: value, value }); - -const points = (n: number): MetricPoint[] => Array.from({ length: n }, (_, i) => metric(i)); - -const makeResponse = (cpu?: MetricPoint[]): WorkloadChartsResponse => ({ - data: { - w1: { - cpu: cpu ?? [metric(1), metric(2)], - memory: [metric(3), metric(4)], - memoryused: [metric(15), metric(16)], - disk: [metric(5), metric(6)], - diskread: [metric(7), metric(8)], - diskwrite: [metric(9), metric(10)], - netin: [metric(11), metric(12)], - netout: [metric(13), metric(14)], - }, - }, - dockerData: {}, - guestTypes: {}, - timestamp: 1, - stats: { oldestDataTimestamp: 1 }, -}); - -const emptyResponse = (): WorkloadChartsResponse => ({ - data: {}, - dockerData: {}, - guestTypes: {}, - timestamp: 1, - stats: { oldestDataTimestamp: 0 }, -}); - -describe('workloadsSummaryCache branch coverage (supplemental)', () => { - beforeEach(() => { - mockGetWorkloadCharts.mockReset(); - mockGetWorkloadCharts.mockResolvedValue(emptyResponse()); - __resetWorkloadsSummaryCacheForTests(); - localStorage.clear(); - }); - - describe('normalizeNodeScope (via workloadsSummaryCacheScopeKey)', () => { - it('trims surrounding whitespace from a defined node scope', () => { - expect(workloadsSummaryCacheScopeKey('1h', ' node-1 ', 'acme')).toBe( - `${VERSION}::acme::1h::node-1`, - ); - }); - - it('coerces undefined node scope to empty string via the ?. ?? chain', () => { - expect(workloadsSummaryCacheScopeKey('1h', undefined, 'acme')).toBe(`${VERSION}::acme::1h::`); - }); - - it('coerces null node scope to empty string (optional chaining treats null as nullish)', () => { - expect(workloadsSummaryCacheScopeKey('1h', null, 'acme')).toBe(`${VERSION}::acme::1h::`); - }); - - it('collapses a whitespace-only node scope to empty string', () => { - expect(workloadsSummaryCacheScopeKey('1h', ' ', 'acme')).toBe(`${VERSION}::acme::1h::`); - }); - - it('uses the defaulted params when only range is supplied', () => { - // nodeScope defaults '' and orgScope defaults undefined -> getOrgID(). - expect(workloadsSummaryCacheScopeKey('1h')).toBe(`${VERSION}::${ORG_FROM_GETORGID}::1h::`); - }); - }); - - describe('resolveOrgScope (via workloadsSummaryCacheScopeKey)', () => { - it('honours an explicitly provided org scope without calling getOrgID', () => { - expect(workloadsSummaryCacheScopeKey('1h', '', 'custom')).toBe(`${VERSION}::custom::1h::`); - }); - - it('falls back to getOrgID() when orgScope is undefined (?? right operand)', () => { - expect(workloadsSummaryCacheScopeKey('1h', '', undefined)).toBe( - `${VERSION}::${ORG_FROM_GETORGID}::1h::`, - ); - }); - - it('falls back to getOrgID() when orgScope is null (?? right operand)', () => { - expect(workloadsSummaryCacheScopeKey('1h', '', null)).toBe( - `${VERSION}::${ORG_FROM_GETORGID}::1h::`, - ); - }); - - it('normalises an empty-string orgScope to the default org (empty string is not nullish)', () => { - // '' ?? getOrgID() -> '' (not nullish), then normalizeOrgScope('') -> 'default'. - expect(workloadsSummaryCacheScopeKey('1h', '', '')).toBe(`${VERSION}::default::1h::`); - }); - - it('trims and keeps a whitespace-padded org scope', () => { - expect(workloadsSummaryCacheScopeKey('1h', '', ' acme ')).toBe(`${VERSION}::acme::1h::`); - }); - }); - - describe('storageCacheKeyFor (observable via persisted localStorage key)', () => { - it('substitutes "__all__" when nodeScope is empty (|| arm false)', () => { - persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse()); - expect(localStorage.getItem(`${PREFIX}acme::1h::__all__`)).not.toBeNull(); - }); - - it('keeps the concrete node scope when it is non-empty (|| arm true)', () => { - persistWorkloadsSummaryCache('1h', 'node-1', 'acme', makeResponse()); - expect(localStorage.getItem(`${PREFIX}acme::1h::node-1`)).not.toBeNull(); - }); - - it('URL-encodes special characters in both org scope and node scope', () => { - persistWorkloadsSummaryCache('1h', 'a/b', 'a b', makeResponse()); - expect(localStorage.getItem(`${PREFIX}a%20b::1h::a%2Fb`)).not.toBeNull(); - }); - }); - - describe('normalizeMaxPoints (via fetchWorkloadsSummaryAndCache call args)', () => { - const expectCall = async (maxPoints: unknown, expected: number | undefined): Promise => { - await fetchWorkloadsSummaryAndCache('1h', { - maxPoints: maxPoints as number | null, - }); - expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, { - nodeId: undefined, - maxPoints: expected, - }); - }; - - it('rounds a positive fractional maxPoints to the nearest integer', async () => { - await expectCall(180.7, 181); - }); - - it('passes a positive integer maxPoints through unchanged', async () => { - await expectCall(360, 360); - }); - - it('returns undefined for a non-number type (string)', async () => { - await expectCall('big', undefined); - }); - - it('returns undefined for null', async () => { - await expectCall(null, undefined); - }); - - it('returns undefined for NaN (non-finite)', async () => { - await expectCall(NaN, undefined); - }); - - it('returns undefined for Infinity (non-finite)', async () => { - await expectCall(Infinity, undefined); - }); - - it('returns undefined for zero (maxPoints <= 0 guard)', async () => { - await expectCall(0, undefined); - }); - - it('returns undefined for a negative number (maxPoints <= 0 guard)', async () => { - await expectCall(-5, undefined); - }); - }); - - describe('normalizeNodeScope via fetch (nodeId arg normalization)', () => { - it('passes the trimmed nodeId to the API', async () => { - await fetchWorkloadsSummaryAndCache('1h', { nodeId: ' node-1 ' }); - expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, { - nodeId: 'node-1', - maxPoints: undefined, - }); - }); - - it('converts an empty/whitespace nodeId to undefined (nodeScope || undefined)', async () => { - await fetchWorkloadsSummaryAndCache('1h', { nodeId: ' ' }); - expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, { - nodeId: undefined, - maxPoints: undefined, - }); - }); - - it('converts a null nodeId to undefined', async () => { - await fetchWorkloadsSummaryAndCache('1h', { nodeId: null }); - expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, { - nodeId: undefined, - maxPoints: undefined, - }); - }); - }); - - describe('resolveOrgScope via fetch (org routing)', () => { - it('persists under the explicitly provided org scope', async () => { - const response = makeResponse(); - mockGetWorkloadCharts.mockResolvedValueOnce(response); - await fetchWorkloadsSummaryAndCache('1h', { orgScope: 'acme' }); - expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBe(response); - }); - - it('persists under getOrgID() when orgScope is omitted', async () => { - const response = makeResponse(); - mockGetWorkloadCharts.mockResolvedValueOnce(response); - await fetchWorkloadsSummaryAndCache('1h', {}); - expect(readInMemoryWorkloadsSummaryCache('1h', '', ORG_FROM_GETORGID)).toBe(response); - }); - }); - - describe('trimPoints (via persisted+read storage arrays)', () => { - const persistAndReadCpu = async (cpu: MetricPoint[] | undefined) => { - const response: WorkloadChartsResponse = { - data: { w1: { cpu } }, - dockerData: {}, - guestTypes: {}, - timestamp: 1, - stats: { oldestDataTimestamp: 0 }, - }; - persistWorkloadsSummaryCache('1h', '', 'acme', response); - const read = readWorkloadsSummaryCache('1h', '', 'acme'); - return read?.data.w1?.cpu ?? null; - }; - - it('returns [] when the points array is empty', async () => { - expect(await persistAndReadCpu([])).toEqual([]); - }); - - it('returns [] when the points array is undefined (!points arm)', async () => { - expect(await persistAndReadCpu(undefined)).toEqual([]); - }); - - it('returns a short array unchanged (length <= max arm)', async () => { - const cpu = await persistAndReadCpu([metric(10), metric(20)]); - expect(cpu).toEqual([metric(10), metric(20)]); - }); - - it('downsamples 361 points without appending/slicing (step loop, no last-append, no final trim)', async () => { - // step=ceil(361/360)=2 -> 181 samples; last sampled index == sliced last -> no append; - // 181 <= 360 -> returned as-is. - const cpu = await persistAndReadCpu(points(361)); - expect(cpu?.length).toBe(181); - expect(cpu?.[0]).toEqual(metric(0)); - expect(cpu?.[180]).toEqual(metric(360)); - }); - - it('appends the final point when the step loop misses it (last-append arm, no final trim)', async () => { - // 362 points -> step 2 -> 181 sampled; last sampled (360) != sliced last (361) -> push; - // 182 <= 360 -> returned as-is. - const cpu = await persistAndReadCpu(points(362)); - expect(cpu?.length).toBe(182); - expect(cpu?.[0]).toEqual(metric(0)); - expect(cpu?.[181]).toEqual(metric(361)); - }); - - it('trims back to exactly max when downsample+append overflows (final slice arm)', async () => { - // 720 points -> step 2 -> 360 sampled + 1 appended = 361 -> slice(1) -> 360. - const cpu = await persistAndReadCpu(points(720)); - expect(cpu?.length).toBe(360); - expect(cpu?.[0]).toEqual(metric(2)); - expect(cpu?.[359]).toEqual(metric(719)); - }); - - it('trims a large array (1000 pts) down to max keeping the most recent tail', async () => { - const cpu = await persistAndReadCpu(points(1000)); - expect(cpu?.length).toBe(360); - expect(cpu?.[cpu.length - 1]).toEqual(metric(999)); - }); - }); - - describe('readInMemoryWorkloadsSummaryCache', () => { - it('returns null on a miss (?? null arm)', () => { - expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); - }); - - it('returns the stored response on a hit', () => { - const response = makeResponse(); - persistWorkloadsSummaryCache('1h', 'n1', 'acme', response); - expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBe(response); - }); - - it('returns null when the org scope differs', () => { - const response = makeResponse(); - persistWorkloadsSummaryCache('1h', 'n1', 'acme', response); - expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'other')).toBeNull(); - }); - - it('resolves the default-omitted orgScope through getOrgID()', () => { - const response = makeResponse(); - persistWorkloadsSummaryCache('1h', 'n1', undefined, response); - expect(readInMemoryWorkloadsSummaryCache('1h', 'n1')).toBe(response); - }); - }); - - describe('writeInMemoryWorkloadsSummaryCache eviction', () => { - it('evicts the oldest entry once the 20-entry cap is exceeded', () => { - const first = makeResponse([metric(1)]); - persistWorkloadsSummaryCache('1h', 'node-0', 'acme', first); - // Fill distinct scopes up to the cap. - for (let i = 1; i < 20; i += 1) { - persistWorkloadsSummaryCache('1h', `node-${i}`, 'acme', makeResponse()); - } - // All 20 still resident. - expect(readInMemoryWorkloadsSummaryCache('1h', 'node-0', 'acme')).toBe(first); - expect(readInMemoryWorkloadsSummaryCache('1h', 'node-19', 'acme')).not.toBeNull(); - - // 21st distinct scope triggers eviction of the oldest (node-0). - const twentyFirst = makeResponse(); - persistWorkloadsSummaryCache('1h', 'node-20', 'acme', twentyFirst); - expect(readInMemoryWorkloadsSummaryCache('1h', 'node-0', 'acme')).toBeNull(); - expect(readInMemoryWorkloadsSummaryCache('1h', 'node-20', 'acme')).toBe(twentyFirst); - }); - - it('overwrites in place without evicting when the scope key already exists', () => { - const a = makeResponse([metric(1)]); - const b = makeResponse([metric(2)]); - for (let i = 0; i < 20; i += 1) { - persistWorkloadsSummaryCache('1h', `node-${i}`, 'acme', a); - } - // Re-persist an existing key -> has() is true so no eviction; value updates. - persistWorkloadsSummaryCache('1h', 'node-0', 'acme', b); - expect(readInMemoryWorkloadsSummaryCache('1h', 'node-0', 'acme')).toBe(b); - expect(readInMemoryWorkloadsSummaryCache('1h', 'node-19', 'acme')).toBe(a); - }); - }); - - describe('persistWorkloadsSummaryCache error / edge arms', () => { - it('skips localStorage entirely when window is undefined (SSR guard) but still warms memory', () => { - vi.stubGlobal('window', undefined); - try { - persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse()); - expect(localStorage.length).toBe(0); - } finally { - vi.unstubAllGlobals(); - } - // Memory was written before the SSR guard. - expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).not.toBeNull(); - }); - - it('stores an empty data record when response.data is undefined (|| {} arm)', () => { - persistWorkloadsSummaryCache('1h', '', 'acme', { - dockerData: {}, - timestamp: 1, - stats: { oldestDataTimestamp: 0 }, - } as unknown as WorkloadChartsResponse); - expect(readWorkloadsSummaryCache('1h', '', 'acme')?.data).toEqual({}); - }); - - it('stores an empty dockerData record when response.dockerData is undefined (|| {} arm)', () => { - persistWorkloadsSummaryCache('1h', '', 'acme', { - data: {}, - timestamp: 1, - stats: { oldestDataTimestamp: 0 }, - } as unknown as WorkloadChartsResponse); - expect(readWorkloadsSummaryCache('1h', '', 'acme')?.dockerData).toEqual({}); - }); - - it('persists dockerData entries through toCachedChartData', () => { - persistWorkloadsSummaryCache('1h', '', 'acme', { - data: {}, - dockerData: { c1: { cpu: [metric(1), metric(2)] } }, - timestamp: 1, - stats: { oldestDataTimestamp: 0 }, - }); - expect(readWorkloadsSummaryCache('1h', '', 'acme')?.dockerData?.c1?.cpu).toEqual([ - metric(1), - metric(2), - ]); - }); - - it('drops the localStorage entry when the serialized payload exceeds the char cap', () => { - // A single workload id whose key name alone pushes serialization past - // WORKLOADS_SUMMARY_CACHE_MAX_CHARS (900_000) -> removeItem + early return. - const hugeId = 'x'.repeat(1_000_000); - persistWorkloadsSummaryCache('1h', '', 'acme', { - data: { [hugeId]: { cpu: [metric(1)] } }, - dockerData: {}, - timestamp: 1, - stats: { oldestDataTimestamp: 0 }, - }); - expect(localStorage.length).toBe(0); - // Memory is populated before the size check. - expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).not.toBeNull(); - }); - - it('swallows a localStorage.setItem failure (catch arm) without throwing', () => { - const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { - throw new Error('QuotaExceededError'); - }); - const response = makeResponse(); - expect(() => persistWorkloadsSummaryCache('1h', '', 'acme', response)).not.toThrow(); - // Not persisted to storage (setItem threw)... - expect(localStorage.length).toBe(0); - // ...but memory was warmed before the try block. - expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBe(response); - spy.mockRestore(); - }); - }); - - describe('readWorkloadsSummaryCache', () => { - afterEach(() => vi.restoreAllMocks()); - - it('returns null on the SSR path (window undefined) without touching storage', () => { - persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse()); - vi.stubGlobal('window', undefined); - try { - expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); - } finally { - vi.unstubAllGlobals(); - } - // Storage entry left intact (read returned before touching localStorage). - expect(localStorage.length).toBe(1); - }); - - it('returns null when no entry exists (!raw arm)', () => { - expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); - }); - - it('returns null when JSON.parse throws (catch arm); leaves the malformed entry in place', () => { - // The catch arm only swallows and returns null; it does NOT removeItem. - const key = storageKeyFor('1h', '', 'acme'); - localStorage.setItem(key, '{ not valid json'); - expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); - expect(localStorage.getItem(key)).toBe('{ not valid json'); - }); - - const writePayload = ( - range: TimeRange, - nodeScope: string, - orgScope: string | undefined, - payload: Record, - ): void => { - const normalizedNode = nodeScope?.trim() ?? ''; - localStorage.setItem(storageKeyFor(range, normalizedNode, orgScope), JSON.stringify(payload)); - }; - - it('rejects and removes an entry whose version mismatches', () => { - const key = storageKeyFor('1h', '', 'acme'); - writePayload('1h', '', 'acme', { - version: 999, - range: '1h', - nodeScope: '', - cachedAt: Date.now(), - data: {}, - dockerData: {}, - }); - expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); - expect(localStorage.getItem(key)).toBeNull(); - }); - - it('rejects and removes an entry whose range mismatches', () => { - const key = storageKeyFor('1h', '', 'acme'); - writePayload('1h', '', 'acme', { - version: VERSION, - range: '4h', - nodeScope: '', - cachedAt: Date.now(), - data: {}, - dockerData: {}, - }); - expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); - expect(localStorage.getItem(key)).toBeNull(); - }); - - it('rejects and removes an entry whose nodeScope mismatches', () => { - const key = storageKeyFor('1h', '', 'acme'); - writePayload('1h', '', 'acme', { - version: VERSION, - range: '1h', - nodeScope: 'different', - cachedAt: Date.now(), - data: {}, - dockerData: {}, - }); - expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); - expect(localStorage.getItem(key)).toBeNull(); - }); - - it('rejects and removes an entry whose cachedAt is not a number', () => { - const key = storageKeyFor('1h', '', 'acme'); - writePayload('1h', '', 'acme', { - version: VERSION, - range: '1h', - nodeScope: '', - cachedAt: 'not-a-number', - data: {}, - dockerData: {}, - }); - expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); - expect(localStorage.getItem(key)).toBeNull(); - }); - - it('rejects and removes a stale entry past the 5-minute TTL', () => { - const key = storageKeyFor('1h', '', 'acme'); - const stale = Date.now() - (5 * 60_000 + 1); - writePayload('1h', '', 'acme', { - version: VERSION, - range: '1h', - nodeScope: '', - cachedAt: stale, - data: {}, - dockerData: {}, - }); - expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); - expect(localStorage.getItem(key)).toBeNull(); - }); - - it('accepts a fresh entry exactly at the TTL boundary (age == max age)', () => { - const now = 10_000_000; - vi.spyOn(Date, 'now').mockReturnValue(now); - writePayload('1h', '', 'acme', { - version: VERSION, - range: '1h', - nodeScope: '', - cachedAt: now - 5 * 60_000, - data: { w1: { cpu: [metric(7)] } }, - dockerData: {}, - }); - const read = readWorkloadsSummaryCache('1h', '', 'acme'); - expect(read?.data.w1?.cpu).toEqual([metric(7)]); - }); - - it('reconstructs the response with reset guestTypes/stats and the cached timestamp', () => { - const now = 50_000; - vi.spyOn(Date, 'now').mockReturnValue(now); - writePayload('1h', '', 'acme', { - version: VERSION, - range: '1h', - nodeScope: '', - cachedAt: 12345, - data: {}, - dockerData: {}, - }); - const read = readWorkloadsSummaryCache('1h', '', 'acme'); - expect(read).toEqual({ - data: {}, - dockerData: {}, - guestTypes: {}, - timestamp: 12345, - stats: { oldestDataTimestamp: 0 }, - }); - }); - - it('warms the in-memory cache as a side effect of a successful read', () => { - const now = 99_000; - vi.spyOn(Date, 'now').mockReturnValue(now); - writePayload('1h', '', 'acme', { - version: VERSION, - range: '1h', - nodeScope: '', - cachedAt: now, - data: {}, - dockerData: {}, - }); - expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); - const read = readWorkloadsSummaryCache('1h', '', 'acme'); - expect(read).not.toBeNull(); - expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBe(read); - }); - }); - - describe('hasFreshWorkloadsSummaryCache', () => { - afterEach(() => vi.restoreAllMocks()); - - it('returns false when neither memory nor storage has an entry (both || arms false)', () => { - expect(hasFreshWorkloadsSummaryCache('1h', '', 'acme')).toBe(false); - }); - - it('short-circuits true on a memory hit (|| left arm)', () => { - const response = makeResponse(); - persistWorkloadsSummaryCache('1h', '', 'acme', response); - // Drop storage so only memory holds it. - localStorage.clear(); - expect(hasFreshWorkloadsSummaryCache('1h', '', 'acme')).toBe(true); - }); - - it('falls through to storage and returns true on a storage hit (|| right arm)', () => { - const now = 1_000; - vi.spyOn(Date, 'now').mockReturnValue(now); - persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse()); - // Reset memory (keeps localStorage) so only storage holds it. - __resetWorkloadsSummaryCacheForTests(); - expect(hasFreshWorkloadsSummaryCache('1h', '', 'acme')).toBe(true); - }); - }); - - describe('fetchWorkloadsSummaryAndCache dedupe / signal arms', () => { - it('deduplicates concurrent same-key fetches without a signal (inFlight return)', async () => { - const response = makeResponse(); - mockGetWorkloadCharts.mockReset(); - mockGetWorkloadCharts.mockResolvedValue(response); - - const first = fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); - const second = fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); - - await expect(Promise.all([first, second])).resolves.toEqual([response, response]); - expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(1); - }); - - it('clears inFlight on settlement so a later call fetches again (finally arm)', async () => { - mockGetWorkloadCharts.mockReset(); - mockGetWorkloadCharts.mockResolvedValue(makeResponse()); - - await fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); - await fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); - - expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(2); - }); - - it('bypasses dedupe when an AbortSignal is supplied (both !signal guards false)', async () => { - mockGetWorkloadCharts.mockReset(); - mockGetWorkloadCharts.mockResolvedValue(makeResponse()); - - const controller = new AbortController(); - const p1 = fetchWorkloadsSummaryAndCache('1h', { signal: controller.signal }); - const p2 = fetchWorkloadsSummaryAndCache('1h', { signal: controller.signal }); - - await Promise.all([p1, p2]); - expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(2); - }); - - it('persists the fetched response before resolving', async () => { - const response = makeResponse(); - mockGetWorkloadCharts.mockReset(); - mockGetWorkloadCharts.mockResolvedValue(response); - - const result = await fetchWorkloadsSummaryAndCache('1h', { - nodeId: 'n1', - orgScope: 'acme', - }); - expect(result).toBe(response); - expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBe(response); - }); - }); - - describe('org_switched event handler', () => { - it('clears the in-memory cache when org_switched is emitted', () => { - const response = makeResponse(); - persistWorkloadsSummaryCache('1h', 'n1', 'acme', response); - expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBe(response); - - eventBus.emit('org_switched', 'new-org'); - - expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBeNull(); - }); - - it('clears in-flight fetch tracking so the next request is not deduped', async () => { - mockGetWorkloadCharts.mockReset(); - mockGetWorkloadCharts.mockResolvedValue(makeResponse()); - - // Kick off a never-resolving first fetch so inFlight is populated. - let resolveFirst!: (v: WorkloadChartsResponse) => void; - mockGetWorkloadCharts.mockReturnValueOnce( - new Promise((resolve) => { - resolveFirst = resolve; - }), - ); - void fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); - - // Emit org_switched -> inFlight map is cleared. - eventBus.emit('org_switched', 'new-org'); - - // A new concurrent call (same key) must NOT be deduped -> 2nd API call. - const p2 = fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); - expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(2); - - // Allow both promises to settle. - mockGetWorkloadCharts.mockResolvedValueOnce(makeResponse()); - resolveFirst(makeResponse()); - await p2; - }); - }); - - describe('__resetWorkloadsSummaryCacheForTests', () => { - it('clears the in-memory cache', () => { - persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse()); - expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).not.toBeNull(); - __resetWorkloadsSummaryCacheForTests(); - expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); - }); - }); -}); diff --git a/frontend-modern/src/utils/__tests__/workloadsSummaryCache.test.ts b/frontend-modern/src/utils/__tests__/workloadsSummaryCache.test.ts deleted file mode 100644 index 6724aacaaa..0000000000 --- a/frontend-modern/src/utils/__tests__/workloadsSummaryCache.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { WorkloadChartsResponse } from '@/api/charts'; -import { - __resetWorkloadsSummaryCacheForTests, - fetchWorkloadsSummaryAndCache, - hasFreshWorkloadsSummaryCache, - readInMemoryWorkloadsSummaryCache, - readWorkloadsSummaryCache, -} from '@/utils/workloadsSummaryCache'; - -const mockGetWorkloadCharts = vi.fn(); - -vi.mock('@/api/charts', async () => { - const actual = await vi.importActual('@/api/charts'); - return { - ...actual, - ChartsAPI: { - ...actual.ChartsAPI, - getWorkloadCharts: (...args: unknown[]) => mockGetWorkloadCharts(...args), - }, - }; -}); - -vi.mock('@/utils/apiClient', () => ({ - getOrgID: () => 'default', -})); - -vi.mock('@/stores/events', () => ({ - eventBus: { - on: vi.fn(() => () => {}), - }, -})); - -const makeResponse = (): WorkloadChartsResponse => ({ - data: { - 'cluster-a:pve1:101': { - cpu: [ - { timestamp: 1, value: 10 }, - { timestamp: 2, value: 20 }, - ], - memory: [ - { timestamp: 1, value: 30 }, - { timestamp: 2, value: 40 }, - ], - disk: [ - { timestamp: 1, value: 50 }, - { timestamp: 2, value: 60 }, - ], - diskread: [ - { timestamp: 1, value: 70 }, - { timestamp: 2, value: 80 }, - ], - diskwrite: [ - { timestamp: 1, value: 90 }, - { timestamp: 2, value: 100 }, - ], - netin: [ - { timestamp: 1, value: 110 }, - { timestamp: 2, value: 120 }, - ], - netout: [ - { timestamp: 1, value: 130 }, - { timestamp: 2, value: 140 }, - ], - }, - }, - dockerData: {}, - guestTypes: {}, - timestamp: Date.now(), - stats: { - oldestDataTimestamp: 1, - }, -}); - -describe('workloadsSummaryCache', () => { - beforeEach(() => { - mockGetWorkloadCharts.mockReset(); - __resetWorkloadsSummaryCacheForTests(); - localStorage.clear(); - }); - - it('fetches workload charts once and warms memory plus local cache', async () => { - const response = makeResponse(); - mockGetWorkloadCharts.mockResolvedValueOnce(response); - - await fetchWorkloadsSummaryAndCache('1h', { - maxPoints: 180, - }); - - expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, { - nodeId: undefined, - maxPoints: 180, - }); - expect(readInMemoryWorkloadsSummaryCache('1h')).toBe(response); - expect(readWorkloadsSummaryCache('1h')).toEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'cluster-a:pve1:101': expect.any(Object), - }), - }), - ); - expect(hasFreshWorkloadsSummaryCache('1h')).toBe(true); - }); - - it('deduplicates concurrent app-shell prewarm requests', async () => { - const response = makeResponse(); - mockGetWorkloadCharts.mockResolvedValueOnce(response); - - const first = fetchWorkloadsSummaryAndCache('1h'); - const second = fetchWorkloadsSummaryAndCache('1h'); - - await expect(Promise.all([first, second])).resolves.toEqual([response, response]); - expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(1); - }); -}); diff --git a/frontend-modern/src/utils/infrastructureSummaryCache.ts b/frontend-modern/src/utils/infrastructureSummaryCache.ts deleted file mode 100644 index 31c81232af..0000000000 --- a/frontend-modern/src/utils/infrastructureSummaryCache.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { - ChartsAPI, - type ChartData, - type ChartsResponse, - type InfrastructureSummaryMetric, - type InfrastructureChartsResponse, - type TimeRange, -} from '@/api/charts'; -import { getOrgID } from '@/utils/apiClient'; -import { normalizeOrgScope } from '@/utils/orgScope'; -import { eventBus } from '@/stores/events'; - -export const INFRA_SUMMARY_CACHE_PREFIX = 'pulse.infrastructureSummaryCharts.'; -export const INFRA_SUMMARY_CACHE_MAX_AGE_MS = 5 * 60_000; -const INFRA_SUMMARY_CACHE_VERSION = 5; -const INFRA_SUMMARY_CACHE_MAX_CHARS = 900_000; -const INFRA_SUMMARY_CACHE_MAX_POINTS_PER_SERIES = 360; -const DEFAULT_INFRA_SUMMARY_METRICS = [ - 'cpu', - 'memory', - 'disk', - 'diskread', - 'diskwrite', - 'netin', - 'netout', -] as const; -type NormalizedInfrastructureSummaryMetrics = readonly InfrastructureSummaryMetric[]; - -const INFRA_SUMMARY_PERF_LOG_PREFIX = '[InfraSummaryPerf]'; - -// Opt-in perf logging to keep default test/dev output clean. -const infraSummaryPerfEnabled = - import.meta.env.DEV && import.meta.env.VITE_INFRA_SUMMARY_PERF === '1'; - -function infraSummaryPerfNow(): number { - if (typeof performance !== 'undefined' && typeof performance.now === 'function') { - return performance.now(); - } - return Date.now(); -} - -function infraSummaryPerfLog(message: string, data?: Record): void { - if (!infraSummaryPerfEnabled) return; - if (data) { - console.debug(`${INFRA_SUMMARY_PERF_LOG_PREFIX} ${message}`, data); - return; - } - console.debug(`${INFRA_SUMMARY_PERF_LOG_PREFIX} ${message}`); -} - -function countChartMapPoints(map: Map): number { - let total = 0; - for (const data of map.values()) { - total += data.cpu?.length ?? 0; - total += data.memory?.length ?? 0; - total += data.disk?.length ?? 0; - total += data.diskread?.length ?? 0; - total += data.diskwrite?.length ?? 0; - total += data.netin?.length ?? 0; - total += data.netout?.length ?? 0; - } - return total; -} - -function pickRicherSeries(incoming?: T[], existing?: T[]): T[] | undefined { - const incomingLen = Array.isArray(incoming) ? incoming.length : 0; - const existingLen = Array.isArray(existing) ? existing.length : 0; - if (incomingLen === 0 && existingLen === 0) return undefined; - if (incomingLen >= existingLen) return incoming; - return existing; -} - -function mergeChartData(existing: ChartData | undefined, incoming: ChartData): ChartData { - if (!existing) return incoming; - - return { - cpu: pickRicherSeries(incoming.cpu, existing.cpu), - memory: pickRicherSeries(incoming.memory, existing.memory), - disk: pickRicherSeries(incoming.disk, existing.disk), - diskread: pickRicherSeries(incoming.diskread, existing.diskread), - diskwrite: pickRicherSeries(incoming.diskwrite, existing.diskwrite), - netin: pickRicherSeries(incoming.netin, existing.netin), - netout: pickRicherSeries(incoming.netout, existing.netout), - }; -} - -type CachedChartData = Pick< - ChartData, - 'cpu' | 'memory' | 'disk' | 'diskread' | 'diskwrite' | 'netin' | 'netout' ->; - -interface CachedInfrastructureSummary { - version: number; - range: TimeRange; - cachedAt: number; - metrics: InfrastructureSummaryMetric[]; - oldestDataTimestamp: number | null; - charts: Record; -} - -export interface InfrastructureSummaryCacheHit { - map: Map; - oldestDataTimestamp: number | null; - cachedAt: number; -} - -export interface InfrastructureSummaryFetchOptions { - caller?: string; - metrics?: readonly InfrastructureSummaryMetric[] | null; - signal?: AbortSignal; -} - -export interface InfrastructureSummaryFetchResult { - map: Map; - oldestDataTimestamp: number | null; -} - -const toCachedChartData = (data: ChartData): CachedChartData => ({ - cpu: trimPoints(data.cpu ?? [], INFRA_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - memory: trimPoints(data.memory ?? [], INFRA_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - disk: trimPoints(data.disk ?? [], INFRA_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - diskread: trimPoints(data.diskread ?? [], INFRA_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - diskwrite: trimPoints(data.diskwrite ?? [], INFRA_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - netin: trimPoints(data.netin ?? [], INFRA_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - netout: trimPoints(data.netout ?? [], INFRA_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), -}); - -function normalizeInfrastructureSummaryMetrics( - metrics?: readonly InfrastructureSummaryMetric[] | null, -): NormalizedInfrastructureSummaryMetrics { - if (!Array.isArray(metrics) || metrics.length === 0) { - return DEFAULT_INFRA_SUMMARY_METRICS; - } - return Array.from(new Set(metrics)) as NormalizedInfrastructureSummaryMetrics; -} - -function infrastructureSummaryMetricsKey( - metrics?: readonly InfrastructureSummaryMetric[] | null, -): string { - return normalizeInfrastructureSummaryMetrics(metrics).join(','); -} - -const inFlightKeyFor = ( - range: TimeRange, - orgScope: string, - metrics?: readonly InfrastructureSummaryMetric[] | null, -) => `${encodeURIComponent(orgScope)}::${range}::${infrastructureSummaryMetricsKey(metrics)}`; - -const cacheKeyForRange = ( - range: TimeRange, - orgScope: string = normalizeOrgScope(getOrgID()), - metrics?: readonly InfrastructureSummaryMetric[] | null, -) => - `${INFRA_SUMMARY_CACHE_PREFIX}${encodeURIComponent(orgScope)}::${range}::${infrastructureSummaryMetricsKey(metrics)}`; - -function trimPoints(points: T[], max: number): T[] { - if (points.length <= max) return points; - if (max <= 1) return points.slice(points.length - 1); - - // Simple stride sampling. We bias toward keeping the newest points. - const start = Math.max(0, points.length - max * 2); - const sliced = points.slice(start); - if (sliced.length <= max) return sliced; - - const step = Math.ceil(sliced.length / max); - const result: T[] = []; - for (let i = 0; i < sliced.length; i += step) { - result.push(sliced[i]); - } - // Ensure the last point is present. - if (result[result.length - 1] !== sliced[sliced.length - 1]) { - result.push(sliced[sliced.length - 1]); - } - return result.length > max ? result.slice(result.length - max) : result; -} - -export function extractInfrastructureSummaryChartMap( - response: ChartsResponse, -): Map { - const map = new Map(); - - if (response.nodeData) { - for (const [id, data] of Object.entries(response.nodeData)) { - map.set(id, mergeChartData(map.get(id), data)); - } - } - if (response.agentData) { - for (const [id, data] of Object.entries(response.agentData)) { - map.set(id, mergeChartData(map.get(id), data)); - } - } - if (response.dockerHostData) { - for (const [id, data] of Object.entries(response.dockerHostData)) { - map.set(id, mergeChartData(map.get(id), data)); - } - } - - return map; -} - -export function extractInfrastructureSummaryChartMapFromInfrastructureResponse( - response: InfrastructureChartsResponse, -): Map { - // Reuse the same extraction shape by mapping fields to the ChartsResponse subset. - return extractInfrastructureSummaryChartMap({ - data: {}, - nodeData: response.nodeData ?? {}, - storageData: {}, - dockerHostData: response.dockerHostData, - agentData: response.agentData, - dockerData: {}, - guestTypes: {}, - timestamp: response.timestamp, - stats: response.stats, - }); -} - -export function persistInfrastructureSummaryCache( - range: TimeRange, - map: Map, - oldestDataTimestamp: number | null, - orgScope?: string, - metrics?: readonly InfrastructureSummaryMetric[] | null, -): void { - if (typeof window === 'undefined') return; - - try { - const scopedOrg = normalizeOrgScope(orgScope ?? getOrgID()); - const normalizedMetrics = normalizeInfrastructureSummaryMetrics(metrics); - const charts: Record = {}; - for (const [key, value] of map.entries()) { - charts[key] = toCachedChartData(value); - } - const payload: CachedInfrastructureSummary = { - version: INFRA_SUMMARY_CACHE_VERSION, - range, - cachedAt: Date.now(), - metrics: [...normalizedMetrics], - oldestDataTimestamp, - charts, - }; - - const serialized = JSON.stringify(payload); - if (serialized.length > INFRA_SUMMARY_CACHE_MAX_CHARS) { - // Avoid blowing past localStorage limits (and evicting unrelated app state). - window.localStorage.removeItem(cacheKeyForRange(range, scopedOrg, normalizedMetrics)); - return; - } - - window.localStorage.setItem(cacheKeyForRange(range, scopedOrg, normalizedMetrics), serialized); - } catch { - // Ignore storage write failures. - } -} - -export function readInfrastructureSummaryCache( - range: TimeRange, - maxAgeMs: number = INFRA_SUMMARY_CACHE_MAX_AGE_MS, - orgScope?: string, - metrics?: readonly InfrastructureSummaryMetric[] | null, -): InfrastructureSummaryCacheHit | null { - if (typeof window === 'undefined') return null; - - try { - const scopedOrg = normalizeOrgScope(orgScope ?? getOrgID()); - const normalizedMetrics = normalizeInfrastructureSummaryMetrics(metrics); - const cacheKey = cacheKeyForRange(range, scopedOrg, normalizedMetrics); - const raw = window.localStorage.getItem(cacheKey); - if (!raw) return null; - - const parsed = JSON.parse(raw) as CachedInfrastructureSummary; - if ( - parsed?.version !== INFRA_SUMMARY_CACHE_VERSION || - parsed.range !== range || - infrastructureSummaryMetricsKey(parsed.metrics) !== - infrastructureSummaryMetricsKey(normalizedMetrics) || - typeof parsed.cachedAt !== 'number' - ) { - window.localStorage.removeItem(cacheKeyForRange(range, scopedOrg, normalizedMetrics)); - return null; - } - - if (Date.now() - parsed.cachedAt > maxAgeMs) { - window.localStorage.removeItem(cacheKey); - return null; - } - - const chartEntries = - parsed.charts && typeof parsed.charts === 'object' ? Object.entries(parsed.charts) : []; - const map = new Map( - chartEntries.map(([key, value]) => [ - key, - { - cpu: Array.isArray(value?.cpu) ? value.cpu : [], - memory: Array.isArray(value?.memory) ? value.memory : [], - disk: Array.isArray(value?.disk) ? value.disk : [], - diskread: Array.isArray(value?.diskread) ? value.diskread : [], - diskwrite: Array.isArray(value?.diskwrite) ? value.diskwrite : [], - netin: Array.isArray(value?.netin) ? value.netin : [], - netout: Array.isArray(value?.netout) ? value.netout : [], - }, - ]), - ); - - return { - map, - cachedAt: parsed.cachedAt, - oldestDataTimestamp: - typeof parsed.oldestDataTimestamp === 'number' && - Number.isFinite(parsed.oldestDataTimestamp) - ? parsed.oldestDataTimestamp - : null, - }; - } catch { - return null; - } -} - -const inFlightFetches = new Map>(); -let infraSummaryFetchSeq = 0; - -/** - * Fetch infrastructure summary charts for a range and persist the resulting - * cache payload. Concurrent requests for the same range are deduplicated. - */ -export function fetchInfrastructureSummaryAndCache( - range: TimeRange, - options?: InfrastructureSummaryFetchOptions, -): Promise { - const caller = options?.caller || 'unknown'; - const orgScope = normalizeOrgScope(getOrgID()); - const normalizedMetrics = normalizeInfrastructureSummaryMetrics(options?.metrics); - const inFlightKey = inFlightKeyFor(range, orgScope, normalizedMetrics); - - const existing = options?.signal ? undefined : inFlightFetches.get(inFlightKey); - if (existing) { - infraSummaryPerfLog('fetch deduped', { caller, range, orgScope }); - return existing; - } - - const requestId = ++infraSummaryFetchSeq; - const startedAt = infraSummaryPerfNow(); - infraSummaryPerfLog('fetch start', { caller, range, orgScope, requestId }); - - const request = ChartsAPI.getInfrastructureSummaryCharts(range, options?.signal, { - metrics: [...normalizedMetrics], - }) - .then((response) => { - const map = extractInfrastructureSummaryChartMapFromInfrastructureResponse(response); - const oldestDataTimestamp = - typeof response.stats?.oldestDataTimestamp === 'number' && - Number.isFinite(response.stats.oldestDataTimestamp) - ? response.stats.oldestDataTimestamp - : null; - persistInfrastructureSummaryCache( - range, - map, - oldestDataTimestamp, - orgScope, - normalizedMetrics, - ); - - infraSummaryPerfLog('fetch done', { - caller, - range, - orgScope, - metrics: normalizedMetrics, - requestId, - ms: Math.round(infraSummaryPerfNow() - startedAt), - series: map.size, - points: countChartMapPoints(map), - primarySourceHint: response.stats?.primarySourceHint, - metricsStoreEnabled: response.stats?.metricsStoreEnabled, - inMemoryThresholdSecs: response.stats?.inMemoryThresholdSecs, - pointCounts: response.stats?.pointCounts, - }); - - return { map, oldestDataTimestamp }; - }) - .catch((error) => { - infraSummaryPerfLog('fetch error', { - caller, - range, - orgScope, - metrics: normalizedMetrics, - requestId, - ms: Math.round(infraSummaryPerfNow() - startedAt), - error: error instanceof Error ? error.message : String(error), - }); - throw error; - }) - .finally(() => { - if (!options?.signal) { - inFlightFetches.delete(inFlightKey); - } - }); - - if (!options?.signal) { - inFlightFetches.set(inFlightKey, request); - } - return request; -} - -/** - * Test-only helper to clear in-flight request state between unit tests. - */ -export function __resetInfrastructureSummaryFetchesForTests(): void { - inFlightFetches.clear(); -} - -// Invalidate infrastructure summary cache on org switch. -const unsubscribeOrgSwitchCacheInvalidation = eventBus.on('org_switched', () => { - if (typeof window === 'undefined') return; - try { - const keysToRemove: string[] = []; - for (let i = 0; i < window.localStorage.length; i++) { - const key = window.localStorage.key(i); - if (key && key.startsWith(INFRA_SUMMARY_CACHE_PREFIX)) { - keysToRemove.push(key); - } - } - keysToRemove.forEach((key) => window.localStorage.removeItem(key)); - } catch { - /* ignore storage errors */ - } -}); - -if (import.meta.hot) { - import.meta.hot.dispose(() => { - unsubscribeOrgSwitchCacheInvalidation(); - }); -} diff --git a/frontend-modern/src/utils/workloadsSummaryCache.ts b/frontend-modern/src/utils/workloadsSummaryCache.ts deleted file mode 100644 index cd764d8c96..0000000000 --- a/frontend-modern/src/utils/workloadsSummaryCache.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { - ChartsAPI, - type ChartData, - type TimeRange, - type WorkloadChartsResponse, -} from '@/api/charts'; -import { getOrgID } from '@/utils/apiClient'; -import { normalizeOrgScope } from '@/utils/orgScope'; -import { eventBus } from '@/stores/events'; - -export const WORKLOADS_SUMMARY_CACHE_VERSION = 7; - -const WORKLOADS_SUMMARY_CACHE_PREFIX = 'pulse.workloadsSummaryCharts.'; -const WORKLOADS_SUMMARY_CACHE_MAX_AGE_MS = 5 * 60_000; -const WORKLOADS_SUMMARY_CACHE_MAX_POINTS_PER_SERIES = 360; -const WORKLOADS_SUMMARY_CACHE_MAX_CHARS = 900_000; -const MAX_IN_MEMORY_WORKLOAD_ENTRIES = 20; - -type CachedChartData = Pick< - ChartData, - 'cpu' | 'memory' | 'memoryused' | 'disk' | 'diskread' | 'diskwrite' | 'netin' | 'netout' ->; - -type CachedWorkloadsSummary = { - version: number; - range: TimeRange; - nodeScope: string; - cachedAt: number; - data: Record; - dockerData: Record; -}; - -type FetchWorkloadsSummaryAndCacheOptions = { - caller?: string; - maxPoints?: number | null; - nodeId?: string | null; - orgScope?: string | null; - signal?: AbortSignal; -}; - -const inMemoryWorkloadCache = new Map(); -const inFlightWorkloadFetches = new Map>(); - -const normalizeNodeScope = (nodeScope: string | null | undefined): string => - nodeScope?.trim() ?? ''; - -const normalizeMaxPoints = (maxPoints: number | null | undefined): number | undefined => { - if (typeof maxPoints !== 'number' || !Number.isFinite(maxPoints) || maxPoints <= 0) { - return undefined; - } - return Math.round(maxPoints); -}; - -const resolveOrgScope = (orgScope?: string | null): string => - normalizeOrgScope(orgScope ?? getOrgID()); - -const storageCacheKeyFor = (range: TimeRange, nodeScope: string, orgScope: string): string => - `${WORKLOADS_SUMMARY_CACHE_PREFIX}${encodeURIComponent(orgScope)}::${range}::${encodeURIComponent(nodeScope || '__all__')}`; - -export function workloadsSummaryCacheScopeKey( - range: TimeRange, - nodeScope: string | null | undefined = '', - orgScope?: string | null, -): string { - return `${WORKLOADS_SUMMARY_CACHE_VERSION}::${resolveOrgScope(orgScope)}::${range}::${normalizeNodeScope(nodeScope)}`; -} - -const trimPoints = (points: T[] | undefined, max: number): T[] => { - if (!points || points.length === 0) return []; - if (points.length <= max) return points; - if (max <= 1) return points.slice(points.length - 1); - - const start = Math.max(0, points.length - max * 2); - const sliced = points.slice(start); - if (sliced.length <= max) return sliced; - - const step = Math.ceil(sliced.length / max); - const result: T[] = []; - for (let i = 0; i < sliced.length; i += step) { - result.push(sliced[i]); - } - if (result[result.length - 1] !== sliced[sliced.length - 1]) { - result.push(sliced[sliced.length - 1]); - } - return result.length > max ? result.slice(result.length - max) : result; -}; - -const toCachedChartData = (data: ChartData): CachedChartData => ({ - cpu: trimPoints(data.cpu, WORKLOADS_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - memory: trimPoints(data.memory, WORKLOADS_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - memoryused: trimPoints(data.memoryused, WORKLOADS_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - disk: trimPoints(data.disk, WORKLOADS_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - diskread: trimPoints(data.diskread, WORKLOADS_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - diskwrite: trimPoints(data.diskwrite, WORKLOADS_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - netin: trimPoints(data.netin, WORKLOADS_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), - netout: trimPoints(data.netout, WORKLOADS_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), -}); - -const writeInMemoryWorkloadsSummaryCache = ( - range: TimeRange, - nodeScope: string, - orgScope: string, - response: WorkloadChartsResponse, -): void => { - const scopeKey = workloadsSummaryCacheScopeKey(range, nodeScope, orgScope); - if ( - !inMemoryWorkloadCache.has(scopeKey) && - inMemoryWorkloadCache.size >= MAX_IN_MEMORY_WORKLOAD_ENTRIES - ) { - const oldest = inMemoryWorkloadCache.keys().next().value; - if (oldest !== undefined) inMemoryWorkloadCache.delete(oldest); - } - inMemoryWorkloadCache.set(scopeKey, response); -}; - -export function readInMemoryWorkloadsSummaryCache( - range: TimeRange, - nodeScope: string | null | undefined = '', - orgScope?: string | null, -): WorkloadChartsResponse | null { - return ( - inMemoryWorkloadCache.get(workloadsSummaryCacheScopeKey(range, nodeScope, orgScope)) ?? null - ); -} - -export function persistWorkloadsSummaryCache( - range: TimeRange, - nodeScope: string | null | undefined, - orgScope: string | null | undefined, - response: WorkloadChartsResponse, -): void { - const normalizedNodeScope = normalizeNodeScope(nodeScope); - const normalizedOrgScope = resolveOrgScope(orgScope); - - writeInMemoryWorkloadsSummaryCache(range, normalizedNodeScope, normalizedOrgScope, response); - - if (typeof window === 'undefined') return; - try { - const data: Record = {}; - const dockerData: Record = {}; - for (const [id, chartData] of Object.entries(response.data || {})) { - data[id] = toCachedChartData(chartData); - } - for (const [id, chartData] of Object.entries(response.dockerData || {})) { - dockerData[id] = toCachedChartData(chartData); - } - - const payload: CachedWorkloadsSummary = { - version: WORKLOADS_SUMMARY_CACHE_VERSION, - range, - nodeScope: normalizedNodeScope, - cachedAt: Date.now(), - data, - dockerData, - }; - const serialized = JSON.stringify(payload); - const cacheKey = storageCacheKeyFor(range, normalizedNodeScope, normalizedOrgScope); - if (serialized.length > WORKLOADS_SUMMARY_CACHE_MAX_CHARS) { - window.localStorage.removeItem(cacheKey); - return; - } - window.localStorage.setItem(cacheKey, serialized); - } catch { - // Ignore cache write failures. - } -} - -export function readWorkloadsSummaryCache( - range: TimeRange, - nodeScope: string | null | undefined = '', - orgScope?: string | null, -): WorkloadChartsResponse | null { - if (typeof window === 'undefined') return null; - - const normalizedNodeScope = normalizeNodeScope(nodeScope); - const normalizedOrgScope = resolveOrgScope(orgScope); - const cacheKey = storageCacheKeyFor(range, normalizedNodeScope, normalizedOrgScope); - - try { - const raw = window.localStorage.getItem(cacheKey); - if (!raw) return null; - - const parsed = JSON.parse(raw) as CachedWorkloadsSummary; - if ( - parsed?.version !== WORKLOADS_SUMMARY_CACHE_VERSION || - parsed.range !== range || - parsed.nodeScope !== normalizedNodeScope || - typeof parsed.cachedAt !== 'number' - ) { - window.localStorage.removeItem(cacheKey); - return null; - } - if (Date.now() - parsed.cachedAt > WORKLOADS_SUMMARY_CACHE_MAX_AGE_MS) { - window.localStorage.removeItem(cacheKey); - return null; - } - - const response = { - data: parsed.data || {}, - dockerData: parsed.dockerData || {}, - guestTypes: {}, - timestamp: parsed.cachedAt, - stats: { - oldestDataTimestamp: 0, - }, - }; - writeInMemoryWorkloadsSummaryCache(range, normalizedNodeScope, normalizedOrgScope, response); - return response; - } catch { - return null; - } -} - -export function hasFreshWorkloadsSummaryCache( - range: TimeRange, - nodeScope: string | null | undefined = '', - orgScope?: string | null, -): boolean { - return ( - readInMemoryWorkloadsSummaryCache(range, nodeScope, orgScope) !== null || - readWorkloadsSummaryCache(range, nodeScope, orgScope) !== null - ); -} - -export function fetchWorkloadsSummaryAndCache( - range: TimeRange, - options: FetchWorkloadsSummaryAndCacheOptions = {}, -): Promise { - const nodeScope = normalizeNodeScope(options.nodeId); - const orgScope = resolveOrgScope(options.orgScope); - const maxPoints = normalizeMaxPoints(options.maxPoints); - const requestKey = `${workloadsSummaryCacheScopeKey(range, nodeScope, orgScope)}::${maxPoints ?? 'default'}`; - - if (!options.signal) { - const inFlight = inFlightWorkloadFetches.get(requestKey); - if (inFlight) return inFlight; - } - - const promise = ChartsAPI.getWorkloadCharts(range, options.signal, { - nodeId: nodeScope || undefined, - maxPoints, - }).then((response) => { - persistWorkloadsSummaryCache(range, nodeScope, orgScope, response); - return response; - }); - - if (!options.signal) { - inFlightWorkloadFetches.set(requestKey, promise); - void promise.finally(() => { - inFlightWorkloadFetches.delete(requestKey); - }); - } - - return promise; -} - -export function __resetWorkloadsSummaryCacheForTests(): void { - inMemoryWorkloadCache.clear(); - inFlightWorkloadFetches.clear(); -} - -const unsubscribeWorkloadOrgSwitch = eventBus.on('org_switched', () => { - inMemoryWorkloadCache.clear(); - inFlightWorkloadFetches.clear(); -}); - -if (import.meta.hot) { - import.meta.hot.dispose(() => { - unsubscribeWorkloadOrgSwitch(); - }); -}