mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Merge remote-tracking branch 'origin/main' into refactor/drop-patrol-context-summary
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
+60
@@ -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',
|
||||
|
||||
+37
@@ -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
|
||||
|
||||
+32
-1
@@ -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;
|
||||
}
|
||||
|
||||
+3
-2
@@ -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;",
|
||||
);
|
||||
|
||||
+45
-2
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user