Merge remote-tracking branch 'origin/main' into refactor/drop-patrol-context-summary

This commit is contained in:
rcourtman
2026-10-07 15:09:09 +01:00
29 changed files with 718 additions and 3261 deletions
@@ -5020,28 +5020,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
@@ -5051,31 +5037,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,
@@ -5187,8 +5148,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
@@ -7577,10 +7538,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
@@ -7922,6 +7881,23 @@ When the cell's value is a retained host-agent SMART temperature, it renders
muted with a dotted underline and screen-reader "last known", as does a
retained value in the guest card. Tooltip and Thermals rows say "(last known)",
and the Thermals row carries the collection reason as its title.
The shared drawer History reads the same decision. For a physical disk,
`resourceDetailDrawerMetricsHistoryModel.ts` takes the reading from
`physicalDisk.temperature`, beside the collection state that qualifies it,
because websocket rows carry no top-level `temperature` for a disk. When that
state is set and is not `available` (an empty or absent state predates the
contract and stays current), `getResourceMetricsHistoryCurrentMetrics` offers
no current `smart_temp`. A positive retained reading then reaches
`getResourceMetricsHistoryDeferredMetrics` as a deferred metric: with no
history points the legend says "last known", never "current", and the reason
renders above the chart.
TrueNAS disk rows follow it too: the drawer's Temperature row reads
"(last known)" with the reason as title and no heat tone, and the one-line
summary leaves a retained reading out because it has no room for the reason.
The Proxmox outdated sensor setup notice (`features/platformPage/sensorSetup.ts`)
counts a SATA or SAS disk as having a temperature only when the reading is
current, so a retained one cannot hide the notice for a disk Pulse cannot read
now.
The focused browser proofs are
`frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts`,
@@ -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:<cluster>:pod:<uid>` 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
@@ -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"
]
},
{
@@ -1121,6 +1121,13 @@ for the Physical Disks verdict. The heat reason follows any native TrueNAS
alert text, and a hot disk's status dot turns warning though its source state
stays `online`, because phones show only the dot.
`truenasPageModel.test.ts` and `TrueNASStorageTopologyTable.test.tsx` pin it.
The Temp column reads the same collection state through
`getPhysicalDiskTemperaturePresentation`. A disk temperature that is not
current, such as a host agent's last reading kept on a merged disk row after
the agent stopped reporting, renders as last known: muted, with its reason as
the title and screen-reader "last known" text. The Temp sort ranks it as no
reading, and the tree sort orders each sibling group, so a retained value
never leads its group hottest first.
The vSphere Datastores and Networks tables follow the same exception-first
Health column. `getVmwareDatastoreIssue` and `getVmwareNetworkIssue` return
nothing for a green row; otherwise the reasons are, in order, an impaired
@@ -2566,41 +2573,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
@@ -3397,12 +3387,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
@@ -3911,12 +3899,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,
@@ -4027,19 +4009,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`,
@@ -4479,20 +4448,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
@@ -4627,10 +4590,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
@@ -4774,18 +4738,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
@@ -5454,9 +5410,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.
@@ -5668,12 +5623,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.
@@ -0,0 +1,52 @@
{
"version": 1,
"base_sha": "41cea2ffab5ca2d39240d14138975bc4e902bb61",
"verified_at": "2026-10-07T11:28:58Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts",
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts",
"frontend-modern/src/features/platformPage/sensorSetup.ts",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "e6e8b46a156df4a1d1b391de19f041c0a1f74eb7e2ecdb78fdfa773a4c336262",
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts": "fa1c743eaec09f424c80aa5d3aee9082817e54353a853b542620eb45792f5887",
"frontend-modern/src/features/platformPage/sensorSetup.ts": "5b92f1fc07b513a11f0e8f2654b0717baa4b99d742eae76368844731a5f89eb9",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "dd7c5189a950adba606a7ea4d0d40233d158e965883135b6fe82b772a6dcf5cd"
},
"routes": [
"/truenas/storage?kind=disks",
"/proxmox/storage",
"/proxmox"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Vite dev server on this tree (origin/main 41cea2ffab plus the change) against a private PULSE_MOCK_MODE backend (8-node estate) built with a local-only go build -overlay (never committed). The overlay stamps TrueNAS disk sdb with physicalDisk.temperature 61 and collection.temperature unavailable 'host agent stopped reporting' (the shape a TrueNAS disk merged with a silent host agent row reaches), pins sdd to a current 58, gives pve3 SATA disk /dev/nvme2 the same retained state (51) and marks pve3's sensor payload legacySensorsFormat; /api/resources and the websocket rows carried those states",
"Before control in the same session (the four runtime files swapped to origin/main under the running Vite, then restored and compared byte for byte): sdb Temp cell plain 61.0°C sorting first in hottest-first, drawer Temperature row 61°C in the amber warning tone, TrueNAS summary 'SATA, Passed, 16.0 TB, 61°C', History with no smart_temp points showing '-', and no outdated sensor setup notice for pve3",
"After, TrueNAS storage disks view: sdb Temp cell '61.0°C' muted (rgb 71,85,105) with a dotted underline, title 'Last known reading, not current: host agent stopped reporting', data-temperature-reading=last-known and screen-reader ', last known'; current disks (sdd 58.0°C, sda 46.0°C) unchanged; no Attention reason for sdb",
"After, Temp sort: descending sdd 58, sdf 47, nvme0n1 46, sda 46, sde 42, nvme1n1 41, sdc 39, then sdb last; ascending sdc 39 ... sdd 58, then sdb last (a retained reading sorts as no reading)",
"After, sdb row drawer (Platform details > Show TrueNAS): Health Temperature row '61°C (last known)' in text-muted with the reason as its title; the collapsed TrueNAS summary reads 'SATA, Passed, 16.0 TB' with no temperature",
"After, sdb drawer History tab: with seeded points the legend shows the stored 53°C and the line 'Disk reading: Last known reading, not current: host agent stopped reporting.'; with the smart_temp series emptied by a Playwright route the legend reads 'Disk 61°C last known' (data-history-last-known=smart_temp, no 'current') with the same line; current disk sdd with no points reads 'Disk 58°C current'",
"After, /proxmox/storage: 'pve3 is using an older temperature monitoring setup that cannot read SATA/SAS disk temperatures. Re-run the node setup script to upgrade it.' notice shows at both widths (hidden on main); /proxmox Overview loads no physical disks so the notice cannot show there on main or after (pre-existing, chipped separately)",
"No page errors at 1440x900 or 390x844",
"Re-run after review edits (History model reads a disk's value only from physicalDisk, never a top-level fallback): every reading above was unchanged at both widths"
],
"interactions": [
"Opened /truenas/storage?kind=disks at 1440x900 and 390x844 and read every disk row's Temp cell (text, title, data attribute, computed colour and decoration)",
"Clicked the Temp column header twice at 1440x900 and read the disk row order for descending and ascending",
"Opened the sdb row drawer with 'Expand details for sdb', opened Platform details and clicked 'Show TrueNAS', read the summary and the Health Temperature row at both widths",
"Opened the drawer History tab with stored points, returned to Overview, then reopened History with the smart_temp series emptied, and read the Temperature legend and reason line at both widths; repeated for current disk sdd",
"Opened /proxmox and /proxmox/storage at both widths and read the outdated sensor setup notice",
"Ran the same Playwright pass against the origin/main runtime files as the before control, restored the change and confirmed the full git diff was byte-identical"
]
}
@@ -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"
]
}
@@ -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(
@@ -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');
});
@@ -513,6 +513,66 @@ describe('getResourceMetricsHistoryCurrentMetrics branch coverage', () => {
});
});
it('offers a retained physical disk temperature only as last known, with its reason', () => {
// useUnifiedResources copies physicalDisk.temperature onto the resource.
// A disk in standby keeps its last reading under an unavailable state, so
// with no history points the chart must not label it current.
const disk = (state?: 'available' | 'unavailable', temperature = 47) =>
baseResource({
type: 'physical_disk',
metricsTarget: { resourceType: 'disk', resourceId: 'SERIAL-1' },
temperature,
physicalDisk: {
devPath: '/dev/sdb',
temperature,
collection: state
? { temperature: { state, source: 'smartctl', reason: 'disk is in standby' } }
: undefined,
},
});
expect(getResourceMetricsHistoryCurrentMetrics(disk('unavailable'))).toMatchObject({
smart_temp: undefined,
temperature: undefined,
});
expect(getResourceMetricsHistoryDeferredMetrics(disk('unavailable')).smart_temp).toEqual({
lastKnownValue: 47,
message: 'Last known reading, not current: disk is in standby.',
});
// An unavailable state with no retained reading has nothing to offer.
expect(getResourceMetricsHistoryCurrentMetrics(disk('unavailable', 0)).smart_temp).toBe(
undefined,
);
expect(getResourceMetricsHistoryDeferredMetrics(disk('unavailable', 0)).smart_temp).toBe(
undefined,
);
for (const current of [disk('available'), disk()]) {
expect(getResourceMetricsHistoryCurrentMetrics(current).smart_temp).toBe(47);
expect(getResourceMetricsHistoryDeferredMetrics(current).smart_temp).toBeUndefined();
}
// Websocket rows carry the reading only on physicalDisk, beside its state.
const websocketRow = (state: 'available' | 'unavailable') => {
const row = disk(state);
delete row.temperature;
return row;
};
expect(getResourceMetricsHistoryCurrentMetrics(websocketRow('available')).smart_temp).toBe(47);
expect(
getResourceMetricsHistoryCurrentMetrics(websocketRow('unavailable')).smart_temp,
).toBeUndefined();
expect(
getResourceMetricsHistoryDeferredMetrics(websocketRow('unavailable')).smart_temp,
).toMatchObject({ lastKnownValue: 47 });
// A top-level value never stands in for the disk's own missing reading,
// which the disk's collection state could not qualify.
const unpaired = disk('available');
delete unpaired.physicalDisk!.temperature;
expect(getResourceMetricsHistoryCurrentMetrics(unpaired).smart_temp).toBeUndefined();
});
it('uses the source-aware host subset for API-only Proxmox node targets', () => {
const resource = baseResource({
type: 'agent',
@@ -361,6 +361,43 @@ describe('resourceDetailDrawerTrueNASModel', () => {
]);
});
it('shows a retained disk temperature as last known, with its reason and no heat tone', () => {
// A TrueNAS disk merged with a host agent's row keeps the agent's last
// reading after the agent stops reporting. It is not current, so it must
// not read as a disk running hot now, nor sit in the summary as a reading.
const disk = (state: 'available' | 'unavailable') =>
baseResource({
type: 'physical_disk',
displayName: 'sdc',
platformScopes: ['truenas'],
physicalDisk: {
devPath: '/dev/sdc',
diskType: 'sata',
sizeBytes: 24 * 1024 ** 4,
health: 'PASSED',
temperature: 61,
collection: {
temperature: { state, source: 'agent', reason: 'host agent stopped reporting' },
},
},
});
const temperatureRow = (resource: Resource) =>
buildTrueNASDetailSections(resource)
.find((section) => section.label === 'Health')
?.rows.find((row) => row.label === 'Temperature');
expect(temperatureRow(disk('unavailable'))).toMatchObject({
value: '61°C (last known)',
tone: 'muted',
title: 'Last known reading, not current: host agent stopped reporting',
});
expect(buildTrueNASDetailsSummary(disk('unavailable'))).toBe('SATA, Passed, 24.0 TB');
expect(temperatureRow(disk('available'))).toMatchObject({ value: '61°C', tone: 'warning' });
expect(temperatureRow(disk('available'))?.title).toBeUndefined();
expect(buildTrueNASDetailsSummary(disk('available'))).toBe('SATA, Passed, 24.0 TB, 61°C');
});
it('shows the vdev layout as the storage kind while topology stays the pool discriminator', () => {
// Regression guard for 599c8e634. `topology` is the cross-provider
// discriminator ('pool' | 'dataset' | ...); the ZFS layout belongs in
@@ -14,6 +14,10 @@ import {
import type { HostGPUSensor } from '@/types/api';
import type { Resource } from '@/types/resource';
import { getDiskPercent, getMemoryPercent } from '@/types/resource';
import {
getPhysicalDiskTemperaturePresentation,
isPhysicalDiskTemperatureCurrent,
} from '@/features/storageBackups/diskTemperaturePresentation';
import { asTrimmedString } from '@/utils/stringUtils';
import { getMemoryObservationPresentation } from '@/utils/memoryObservation';
import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation';
@@ -174,6 +178,18 @@ const diskReadReason = (resource: Resource): string | undefined =>
? resource.proxmox?.diskStatusReason || undefined
: undefined;
// A physical disk's reading and its collection state both live on
// physicalDisk, so read them only together: websocket rows carry no top-level
// temperature for a disk, which only the REST mapper copies across. The
// reading may be a last known one kept while the current observation could
// not collect it, such as a disk in standby or a host agent past its
// reporting lease, and is then never current.
const resourceTemperature = (resource: Resource): number | undefined =>
resource.physicalDisk ? resource.physicalDisk.temperature : resource.temperature;
const isResourceTemperatureCurrent = (resource: Resource): boolean =>
isPhysicalDiskTemperatureCurrent(resource.physicalDisk?.collection);
export const getResourceMetricsHistoryDeferredMetrics = (
resource: Resource,
): Record<string, GuestDrawerHistoryDeferredMetric> => {
@@ -197,6 +213,19 @@ export const getResourceMetricsHistoryDeferredMetrics = (
message: getWorkloadGuestDiskStatusMessage(reason),
};
}
if (!isResourceTemperatureCurrent(resource)) {
const temperature = resourceTemperature(resource);
const retained = getPhysicalDiskTemperaturePresentation({
temperature: temperature ?? 0,
collection: resource.physicalDisk?.collection,
});
if (retained?.title) {
metrics.smart_temp = {
lastKnownValue: temperature,
message: `${retained.title}.`,
};
}
}
return metrics;
};
@@ -204,7 +233,9 @@ export const getResourceMetricsHistoryCurrentMetrics = (
resource: Resource,
): Record<string, number | undefined> => {
const diskPercent = resource.disk ? finiteMetric(getDiskPercent(resource)) : undefined;
const temperature = finiteMetric(resource.temperature);
const temperature = isResourceTemperatureCurrent(resource)
? finiteMetric(resourceTemperature(resource))
: undefined;
const memory = memoryReading(resource);
return {
cpu: finiteMetric(resource.cpu?.current),
@@ -20,6 +20,7 @@ import {
type DetailSection,
type DetailValueTone,
} from '@/components/shared/detailSectionModel';
import { getPhysicalDiskTemperaturePresentation } from '@/features/storageBackups/diskTemperaturePresentation';
import { hasImpairedResourceSource } from '@/utils/resourceSourceHealth';
export type ResourceDetailDrawerTrueNASRowTone = DetailValueTone;
@@ -51,11 +52,6 @@ const formatPercent = (percent?: number): string | null => {
return `${percent.toFixed(percent >= 10 ? 1 : 2)}%`;
};
const formatTemperature = (celsius?: number): string | null => {
if (typeof celsius !== 'number' || !Number.isFinite(celsius) || celsius <= 0) return null;
return `${celsius.toFixed(0)}°C`;
};
const formatDurationSeconds = (seconds?: number): string | null => {
const value = asPositiveNumber(seconds);
if (!value) return null;
@@ -396,6 +392,35 @@ const formatDiskHours = (hours?: number): string | null => {
return `${formatDetailIntegerValue(value) ?? value.toFixed(0)}h`;
};
const diskTemperature = (disk: ResourcePhysicalDiskMeta) =>
getPhysicalDiskTemperaturePresentation({
temperature: disk.temperature ?? 0,
collection: disk.collection,
});
// A retained reading, such as a silent host agent's on a merged disk row, is
// not current: it takes no heat tone and its title says why.
const diskTemperatureRow = (
disk: ResourcePhysicalDiskMeta,
): ResourceDetailDrawerTrueNASRow | null => {
const reading = diskTemperature(disk);
if (!reading) return null;
if (!reading.current) {
return row('Temperature', `${reading.label} (last known)`, {
title: reading.title,
tone: 'muted',
});
}
return row('Temperature', reading.label, {
tone: (disk.temperature ?? 0) >= 55 ? 'warning' : 'default',
});
};
const currentDiskTemperatureLabel = (disk: ResourcePhysicalDiskMeta): string | null => {
const reading = diskTemperature(disk);
return reading?.current ? reading.label : null;
};
const buildTrueNASDiskSections = (
resource: Resource,
disk: ResourcePhysicalDiskMeta,
@@ -414,9 +439,7 @@ const buildTrueNASDiskSections = (
const healthRows = compactRows([
row('Health', normalizeDelimitedLabel(disk.health), { tone: diskStateTone(disk) }),
row('Temperature', formatTemperature(disk.temperature), {
tone: disk.temperature && disk.temperature >= 55 ? 'warning' : 'default',
}),
diskTemperatureRow(disk),
row(
'Wearout',
disk.wearout === undefined || disk.wearout < 0 ? null : formatPercent(disk.wearout),
@@ -795,7 +818,8 @@ export const buildTrueNASDetailsSummary = (resource: Resource): string | null =>
diskTypeLabel(disk.diskType),
normalizeDelimitedLabel(disk.health),
formatDetailBytesValue(disk.sizeBytes),
formatTemperature(disk.temperature),
// The summary has no room to say a retained reading is not current.
currentDiskTemperatureLabel(disk),
].filter((value): value is string => Boolean(value));
return summary.length > 0 ? summary.join(', ') : null;
}
@@ -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;",
);
@@ -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(() => (
<>
<SeriesProbe series="guests" />
<SeriesProbe series="nodes" />
</>
));
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,
@@ -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(
{
@@ -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<string, ChartData>();
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<InfrastructureSummaryFetchResult, string>({
const infrastructureHistory = createNonSuspendingQuery<Map<string, ChartData>, 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);
};
@@ -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<string, ChartData | undefined> | Map<string, ChartData> | 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<InfrastructureChartsResponse, 'nodeData' | 'agentData' | 'dockerHostData'>,
): Map<string, ChartData> => {
const map = new Map<string, ChartData>();
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);
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import type { PhysicalDiskCollectionStatus, Resource } from '@/types/resource';
import { collectOutdatedSensorSetupNodes } from './sensorSetup';
const node = (over: Partial<Resource> & { proxmox?: Resource['proxmox'] }): Resource =>
@@ -11,6 +11,7 @@ const disk = (over: {
nodeName?: string;
diskType?: string;
temperature?: number;
collection?: PhysicalDiskCollectionStatus;
}): Resource =>
({
id: over.id ?? 'disk-1',
@@ -20,6 +21,7 @@ const disk = (over: {
physicalDisk: {
diskType: over.diskType ?? 'sata',
temperature: over.temperature ?? 0,
collection: over.collection,
},
platformData: over.nodeName ? { proxmox: { nodeName: over.nodeName } } : {},
}) as unknown as Resource;
@@ -80,6 +82,28 @@ describe('collectOutdatedSensorSetupNodes', () => {
).toEqual([]);
});
it('treats a retained temperature that is not current as missing', () => {
// Normalization keeps a disk's last known temperature when the current
// observation did not collect one, such as after a linked host agent
// stopped reporting. The disk cannot be read now, so the notice stays.
const retained = (state: 'available' | 'unavailable' | 'missing') =>
disk({
nodeName: 'pve1',
diskType: 'sata',
temperature: 34,
collection: {
temperature: { state, source: 'agent', reason: 'host agent stopped reporting' },
},
});
expect(collectOutdatedSensorSetupNodes([legacyNode()], [retained('unavailable')])).toEqual([
{ id: 'node-1', name: 'pve1' },
]);
expect(collectOutdatedSensorSetupNodes([legacyNode()], [retained('missing')])).toEqual([
{ id: 'node-1', name: 'pve1' },
]);
expect(collectOutdatedSensorSetupNodes([legacyNode()], [retained('available')])).toEqual([]);
});
it('ignores legacy nodes with only NVMe disks missing temperatures', () => {
expect(
collectOutdatedSensorSetupNodes(
@@ -1,5 +1,6 @@
import type { Resource } from '@/types/resource';
import { matchesPhysicalDiskNode } from '@/components/Storage/diskResourceUtils';
import { isPhysicalDiskTemperatureCurrent } from '@/features/storageBackups/diskTemperaturePresentation';
// Disk transports whose temperatures only arrive via SMART (the pulse-sensors
// wrapper). NVMe temps come from kernel hwmon and work even on legacy setups.
@@ -18,7 +19,9 @@ const isSMARTOnlyDiskWithoutTemperature = (disk: Resource): boolean => {
if (!meta) return false;
const diskType = (meta.diskType || '').trim().toLowerCase();
if (!SMART_ONLY_DISK_TYPES.has(diskType)) return false;
return (meta.temperature ?? 0) <= 0;
// A last known temperature kept for a disk that could not be read now, such
// as after a linked host agent stopped reporting, is not one arriving.
return (meta.temperature ?? 0) <= 0 || !isPhysicalDiskTemperatureCurrent(meta.collection);
};
// Flags PVE nodes whose SSH temperature monitoring still runs the pre-rc.6
@@ -37,6 +37,10 @@ import {
createPlatformResourceLabelResolver,
getPlatformResourceDetailRowClass,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import {
PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS,
getPhysicalDiskTemperaturePresentation,
} from '@/features/storageBackups/diskTemperaturePresentation';
import { useAlertsActivation } from '@/stores/alertsActivation';
import type { Resource } from '@/types/resource';
import {
@@ -200,6 +204,40 @@ const DiskEnduranceCell: Component<{ row: TrueNASStorageTopologyRow }> = (props)
);
};
// A disk row may carry a last known temperature it did not collect now, such
// as a host agent's reading after the agent stopped reporting on a merged disk.
const diskTemperatureReading = (row: TrueNASStorageTopologyRow) => {
const disk = row.kind === 'disk' ? row.resource.physicalDisk : undefined;
return disk
? getPhysicalDiskTemperaturePresentation({
temperature: disk.temperature ?? 0,
collection: disk.collection,
})
: null;
};
// A retained reading reads as last known, never as the disk's temperature now.
const DiskTemperatureCell: Component<{ row: TrueNASStorageTopologyRow }> = (props) => {
const reading = () => diskTemperatureReading(props.row);
const value = () =>
props.row.kind === 'disk' ? props.row.resource.physicalDisk?.temperature : undefined;
return (
<Show
when={reading()?.current === false}
fallback={<PlatformTableTemperatureValue value={value()} emptyText="-" />}
>
<span
class={PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS}
title={reading()?.title}
data-temperature-reading="last-known"
>
<PlatformTableTemperatureValue value={value()} />
<span class="sr-only">, last known</span>
</span>
</Show>
);
};
const ISSUE_TONES: Record<TrueNASStorageIssue['status'], PlatformIssueTone> = {
attention: 'warning',
offline: 'danger',
@@ -253,9 +291,10 @@ const getTrueNASStorageSortValue = (
case 'endurance':
return row.kind === 'disk' ? (diskEndurance(row)?.value ?? null) : null;
case 'temp': {
if (row.kind !== 'disk') return null;
const temperature = row.resource.physicalDisk?.temperature;
return typeof temperature === 'number' && Number.isFinite(temperature) ? temperature : null;
// Hottest first ranks current readings only; a last known one sorts as
// no reading.
const reading = diskTemperatureReading(row);
return reading?.current ? (row.resource.physicalDisk?.temperature ?? null) : null;
}
case 'health':
return HEALTH_SORT_RANK[
@@ -575,14 +614,7 @@ export const TrueNASStorageTopologyTable: Component<{
kindFilter() === 'disks' ? 'table-cell' : 'hidden lg:table-cell'
} text-base-content`}
>
<PlatformTableTemperatureValue
value={
row.kind === 'disk'
? row.resource.physicalDisk?.temperature
: undefined
}
emptyText="-"
/>
<DiskTemperatureCell row={row} />
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden`}
@@ -262,6 +262,69 @@ describe('TrueNASStorageTopologyTable', () => {
).toEqual(['sda', 'nvme0n1']);
});
it('shows a retained disk temperature as last known and sorts it as no reading', async () => {
// A TrueNAS disk merged with a host agent's row keeps the agent's last
// reading after the agent stops reporting, under an unavailable state.
const disk = (id: string, temperature: number, state: 'available' | 'unavailable') =>
makeStorageResource({
id,
type: 'physical_disk',
name: id,
storage: undefined,
physicalDisk: {
devPath: `/dev/${id}`,
serial: `serial-${id}`,
diskType: 'sata',
temperature,
collection: {
temperature: { state, source: 'agent', reason: 'host agent stopped reporting' },
},
},
});
const resources = [
disk('sda', 64, 'unavailable'),
disk('sdb', 41, 'available'),
disk('sdc', 38, 'available'),
];
const { container } = render(() => (
<TrueNASStorageTopologyTable
resources={resources}
scope={resources}
emptyIcon={<span />}
emptyTitle="No storage"
emptyDescription="No storage"
showToolbar={false}
/>
));
const row = (id: string) => container.querySelector(`[data-truenas-storage-resource="${id}"]`);
const order = () =>
[...container.querySelectorAll('[data-truenas-storage-resource]')].map((element) =>
element.getAttribute('data-truenas-storage-resource'),
);
try {
const retained = row('sda')?.querySelector('[data-temperature-reading="last-known"]');
expect(retained).toHaveTextContent('64.0°C, last known');
expect(retained).toHaveAttribute(
'title',
'Last known reading, not current: host agent stopped reporting',
);
expect(retained).toHaveClass('text-muted');
// 64C is over the factory SATA trigger, but a retained reading is not heat.
expect(row('sda')?.querySelector('[data-truenas-storage-health]')).toBeNull();
expect(row('sdb')?.querySelector('[data-temperature-reading="last-known"]')).toBeNull();
expect(row('sdb')).toHaveTextContent('41.0°C');
// Hottest first ranks current readings only; the retained one stays last.
await fireEvent.click(screen.getByRole('columnheader', { name: /Temp/ }));
expect(order()).toEqual(['sdb', 'sdc', 'sda']);
await fireEvent.click(screen.getByRole('columnheader', { name: /Temp/ }));
expect(order()).toEqual(['sdc', 'sdb', 'sda']);
} finally {
window.localStorage.clear();
}
});
it('follows a disk temperature trigger the user raised in Alerts', async () => {
const getConfig = vi.spyOn(AlertsAPI, 'getConfig').mockResolvedValue({
enabled: true,
@@ -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())');
@@ -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<typeof import('@/api/charts')>('@/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> = {},
): 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<string, Record<string, unknown>> | 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> = {}): 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();
});
});
@@ -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<typeof import('@/api/charts')>('@/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> = {},
): 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<string, Record<string, unknown>> | undefined;
}
const buildPayload = (overrides: Partial<StoredPayload> = {}): 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 = <T>(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<string, never> }),
);
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();
});
});
@@ -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<typeof import('@/api/charts')>('@/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<typeof makeResponse>) => void) | undefined;
mockGetCharts.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFetch = resolve as (value: ReturnType<typeof makeResponse>) => 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<string, ChartData>();
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 =');
});
});
@@ -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<typeof import('@/api/charts')>('@/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<void> => {
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<string, unknown>,
): 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<WorkloadChartsResponse>((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();
});
});
});
@@ -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<typeof import('@/api/charts')>('@/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);
});
});
@@ -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<string, unknown>): 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<string, ChartData>): 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<T>(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<string, CachedChartData>;
}
export interface InfrastructureSummaryCacheHit {
map: Map<string, ChartData>;
oldestDataTimestamp: number | null;
cachedAt: number;
}
export interface InfrastructureSummaryFetchOptions {
caller?: string;
metrics?: readonly InfrastructureSummaryMetric[] | null;
signal?: AbortSignal;
}
export interface InfrastructureSummaryFetchResult {
map: Map<string, ChartData>;
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<T>(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<string, ChartData> {
const map = new Map<string, ChartData>();
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<string, ChartData> {
// 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<string, ChartData>,
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<string, CachedChartData> = {};
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<string, ChartData>(
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<string, Promise<InfrastructureSummaryFetchResult>>();
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<InfrastructureSummaryFetchResult> {
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();
});
}
@@ -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<string, CachedChartData>;
dockerData: Record<string, CachedChartData>;
};
type FetchWorkloadsSummaryAndCacheOptions = {
caller?: string;
maxPoints?: number | null;
nodeId?: string | null;
orgScope?: string | null;
signal?: AbortSignal;
};
const inMemoryWorkloadCache = new Map<string, WorkloadChartsResponse>();
const inFlightWorkloadFetches = new Map<string, Promise<WorkloadChartsResponse>>();
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 = <T>(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<string, CachedChartData> = {};
const dockerData: Record<string, CachedChartData> = {};
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<WorkloadChartsResponse> {
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();
});
}