Remove the summary chart routes no client calls

GET /api/charts/workloads-summary has had no first-party caller since
the v6 import: ChartsAPI.getWorkloadsSummaryCharts arrived with it and
nothing ever called it. GET /api/charts/storage-summary lost its last
reader when storageSummaryTrendCache.ts was deleted on 2026-05-26.
Both still cost something. The workloads route carried a payload cache,
an SLO test in the release latency gate and a benchmark, and mock mode
rebuilt the 24-hour storage aggregate on every sampler tick for a cache
nothing read.

No stable release ever requested either route from the browser: v6.0.0
and later carry no caller, and the storage trend reader shipped only in
v6.0.0-rc.1 to rc.5. Neither route is in docs/API.md (a list of common
endpoints, not a complete one), and pulse-pro, pulse-mobile and
pulse-dev-infra never call them. A monitoring:read token could reach
them, so a private script may notice, but nothing known depends on
them. pulse-enterprise names the SLO test only as fixture data, and its
latency gate passes a test that is absent from the candidate as
removed. So the routes go with their clients:
handlers, response types, the monitor trend helpers, the mock aggregate
and its prewarm, the SLO budget, the benchmark, and the ChartsAPI
methods and types. Requests to either path now get 404.
buildMockWorkloadMetricHistorySeries goes too, because the
workloads-summary handler was its only production caller.

A TypeScript reference graph from src/index.tsx finds no production
reference to the removed methods or types. The production build loses
only the two static methods from the ChartsAPI chunk (326 B raw, 49 B
gzip); every other file differs only by chunk hashes and SRI values.
deadcode ./cmd/pulse and golangci unused report nothing newly
unreachable. On private mock stacks the Proxmox overview in Trends, a
guest History drawer and the storage page make the same chart requests
before and after at 1440 and 390 px, and neither build requested the
removed routes.

Contract prose that still described either route as live now records
the retirement, and the workload chart guard pins the per-type metric
sets the surviving /api/charts/workloads route requests.
This commit is contained in:
rcourtman
2026-10-07 23:14:49 +01:00
parent 36d3f8c557
commit 4a0a05858d
34 changed files with 157 additions and 3151 deletions
@@ -3048,13 +3048,10 @@ agent inventory, registration state, or command-channel readiness.
rehydration. Lifecycle surfaces must not reinterpret an attached check,
its compatibility `availability` summary, or its evidence freshness as
agent enrollment, heartbeat, command reachability, or fleet liveness.
The same presentation-only boundary now covers compact storage summary
chart reads as well. Shared `/api/charts/storage-summary` transport may
request only the canonical `used` and `avail` storage series needed for the
dashboard capacity sparkline, and lifecycle surfaces must not reinterpret
the omitted `usage` or `total` series as missing lifecycle telemetry or
enrollment-state evidence.
Dashboard storage trend consumers on that shared router boundary must now reuse the single `/api/storage-charts` summary response instead of fanning out per-pool `/api/metrics-store/history` reads, and lifecycle surfaces still must treat that batched storage summary transport as presentation context only rather than install, enrollment, or freshness truth.
The retired aggregate chart routes `/api/charts/storage-summary` and
`/api/charts/workloads-summary` must not return as compatibility reads for
lifecycle surfaces either.
Storage summary consumers on that shared router boundary (the storage page, through `useStorageSummaryCharts`) must reuse the single `/api/storage-charts` summary response instead of fanning out per-pool `/api/metrics-store/history` reads, and lifecycle surfaces still must treat that batched storage summary transport as presentation context only rather than install, enrollment, or freshness truth.
15. Keep install-script serving fallback-free, and keep the lifecycle
agent-download fallback pinned to published release lineage. The served
install-script endpoints (/install.sh, /install.ps1) have no GitHub fallback:
@@ -4664,11 +4661,11 @@ grant from public runtime-capabilities or presentation-policy payloads.
Shared workload-chart reads that lifecycle surfaces reuse must stay
presentation-only on that same boundary:
`internal/api/chartapi/service.go` may batch those reads in parallel, but it
must request only the canonical rendered metric set for workload cards instead
must request only the metric set it serializes for each workload type instead
of widening the hot path back to fetch-all metrics on behalf of install or
reporting callers.
The same presentation-only rule applies when shared infrastructure-summary or
workloads-summary chart routes serve a short cached response for repeated
The same presentation-only rule applies when the shared infrastructure-summary
or per-workload chart routes serve a short cached response for repeated
org/range/scope requests: lifecycle-adjacent surfaces may render those charts
as operator context, but agent registration, heartbeat, installer status,
profile assignment, reporting freshness, and fleet-control readiness must not
@@ -1286,12 +1286,16 @@ for a release, a diagnostic build, and a marker-declared source build.
Summary-chart response caching is a shared API boundary:
`internal/api/chartapi/service.go` may serve a short cached JSON payload for repeated
infrastructure-summary and workloads-summary requests with the same
infrastructure-summary and per-workload chart requests with the same
organization, range, metric set, and workload scope, but that cache is
transport-only. It may amortize polling and remount cost, but it must not
change normalized response shape, bypass monitor or read-state availability
checks, merge tenants, or become the source of truth for telemetry freshness.
Infrastructure-summary, workloads-summary, and per-workload chart payloads
change normalized response shape, answer for a tenant whose monitor is
unavailable, merge tenants, or become the source of truth for telemetry
freshness. The infrastructure route resolves read-state before it reads its
cache; the per-workload route checks read-state only when it recomputes, so a
cached workload payload can outlive a read-state outage by at most its
3-second TTL.
Infrastructure-summary and per-workload chart payloads
must share one bounded retention budget across query variants: no more than 64
entries or 16 MiB of encoded payloads. Inserts must purge expired variants and
evict the oldest retained payloads as either bound is reached; a response larger
@@ -3597,7 +3601,7 @@ a new API state machine, queue contract, or verification-accounting field.
23. Keep hosted AI settings bootstrap on the shared API contract as a retired path: `internal/api/ai_hosted_runtime.go`, `internal/api/ai_handlers.go`, `internal/api/ai_handler.go`, and `internal/api/contract_test.go` must treat a missing `ai.enc` in hosted mode as an unconfigured BYOK/local-provider state, not as a machine-owned `quickstart:pulse-hosted` bootstrap condition. Hosted tenant reads may inherit billing state for commercial authorization, but they must not create quickstart-backed AI config or call the quickstart bootstrap upstream route.
24. Keep post-boot AI enablement contract-backed on the shared AI/mobile approval surface: `internal/api/ai_handler.go`, `internal/api/ai_handlers.go`, `internal/api/router.go`, `internal/api/router_routes_ai_relay.go`, and `internal/api/contract_test.go` must turn the governed approvals-list API into the canonical empty-list payload as soon as settings-driven AI enablement succeeds, rather than leaving that surface on `503 Approval store not initialized` until some separate startup-only side effect happens. The same post-boot lifecycle owns the investigation surface: a successful in-process chat start or restart must reapply the same live dependency wiring as cold startup, including the Enterprise investigation orchestrator and Patrol circuit breaker, so enabling Intelligence after boot cannot leave finding investigations on `503 Investigation orchestrator not initialized` until the server restarts.
25. Keep infrastructure summary chart transport contract-backed on the shared API surface: `internal/api/chartapi/service.go`, `internal/api/contract_test.go`, and frontend infrastructure summary consumers must normalize long-range mixed-cadence history into equal-time summary buckets before shipping the infrastructure charts API payload, so 7-day and 30-day summary cards do not expose compressed right-edge tails just because recent samples arrive at a finer storage resolution.
26. Keep long-range workload chart transport time-proportional on the shared API surface: `internal/api/chartapi/service.go`, `internal/api/contract_test.go`, and workload chart consumers must cap mixed-cadence workload history by equal-time buckets rather than raw point index for the per-workload and aggregate workload chart APIs, so 7-day and 30-day workload cards do not bunch recent samples at the right edge just because recent telemetry is stored more densely.
26. Keep long-range workload chart transport time-proportional on the shared API surface: `internal/api/chartapi/service.go`, `internal/api/contract_test.go`, and workload chart consumers must cap mixed-cadence workload history by equal-time buckets rather than raw point index for the per-workload chart API, so 7-day and 30-day workload cards do not bunch recent samples at the right edge just because recent telemetry is stored more densely.
27. Keep chart timestamp precision canonical on that same shared API surface: when `internal/api/chartapi/service.go` serializes monitoring history into infrastructure or workload chart payloads, it must preserve canonical millisecond timestamps from the shared monitoring timeline instead of rounding through whole-second conversion, so seeded mock history and live appends collapse onto one operator-visible timeline instead of appearing as duplicated tail samples.
28. Keep Patrol remediation payload naming backward-compatible without leaking
legacy automation-first wording into product copy. `frontend-modern/src/api/patrol.ts`,
@@ -3608,7 +3612,7 @@ a new API state machine, queue contract, or verification-accounting field.
contract must describe the operator-visible capability as remediation or
safe remediation workflows.
29. Keep storage chart identity canonical on that same shared API surface: the shared storage charts endpoint must key pool and physical-disk series by the resolved unified-resource `MetricsTarget.ResourceID`, not by canonical resource IDs or page-local aliases, so storage rows, focused summary cards, sticky summary shells, and detail charts all address the same history series in live and mock mode.
30. Keep synthetic summary-chart fallback identity canonical on that same shared API surface: when `internal/api/chartapi/service.go` has to synthesize mock summary history for infrastructure, workloads, or storage cards, it must derive the fallback from canonical `resourceType`, `resourceID`, and `metricType` ownership instead of raw min/max seed-prefix helpers, so range changes and runtime mock updates stay on one governed timeline.
30. Keep synthetic chart-history fallback identity canonical on that same shared API surface: when `internal/api/chartapi/service.go` synthesizes mock history (through `BuildSyntheticMetricHistorySeries`, which the metrics-store history route uses), it must derive the fallback from canonical `resourceType`, `resourceID`, and `metricType` ownership instead of raw min/max seed-prefix helpers, so range changes and runtime mock updates stay on one governed timeline.
Mock storage follows the same rule at `/api/metrics-store/history`: when a
storage or Ceph resource has current capacity in canonical live state, the
handler must return its in-memory or deterministic `usage` series for both
@@ -3616,16 +3620,7 @@ a new API state machine, queue contract, or verification-accounting field.
demo store. This includes provider-derived targets with no legacy history
row and legacy rows whose persistent series is still sparse while fleet
backfill is running.
The same compact chart boundary also owns aggregate-only storage summary
transport. `/api/charts/storage-summary` may batch only the canonical
`used` and `avail` storage series required for the aggregate capacity
sparkline, and it must not regress into the full per-pool storage payload
or a fetch-all-metrics backend path just because the storage page carries a
broader chart surface.
When mock mode is active, that same endpoint must come from the
monitor-owned aggregate summary cache rather than rehydrating each pool
chart on request.
31. Keep workload-chart response identity canonical on that same shared API surface: `internal/api/chartapi/service.go`, `internal/api/contract_test.go`, and workload summary consumers must emit provider-backed VM and system-container series under the same canonical workload IDs that workloads page rows use, while resolving history through the unified `MetricsTarget.ResourceID`, so hover and focus selection do not fall off for provider-backed rows.
31. Keep workload-chart response identity canonical on that same shared API surface: `internal/api/chartapi/service.go`, `internal/api/contract_test.go`, and workload chart consumers must emit provider-backed VM and system-container series under the same canonical workload IDs that workloads page rows use, while resolving history through the unified `MetricsTarget.ResourceID`, so hover and focus selection do not fall off for provider-backed rows.
Kubernetes pod workload rows follow that same contract through their
metrics target. `/api/resources` may expose pod history only through the
unified `MetricsTarget.ResourceID`, but that target must be the canonical
@@ -3690,8 +3685,9 @@ a new API state machine, queue contract, or verification-accounting field.
hidden. Upgrade prompts, trial nudges, monitored-system migration guidance,
usage counts, billing identity, and plan metadata must therefore not depend
on hidden commercial routes surviving the public demo boundary.
37. Keep the storage summary route in `internal/api/chartapi/service.go` as the
canonical storage summary contract across dashboard and storage consumers.
37. Keep the storage charts route (`HandleStorageCharts` in
`internal/api/chartapi/service.go`) as the canonical storage summary
contract for the storage page's summary consumers.
`internal/api/chartapi/service.go`,
`internal/api/chartapi/service_test.go`, and shared frontend consumers must
expose pooled storage history through one response keyed by canonical
@@ -3723,6 +3719,14 @@ a new API state machine, queue contract, or verification-accounting field.
rows, governed resource labels, top-infrastructure identity, or metrics-
target join keys. New summary payloads must be owned by their product
route and pinned in the API contract there.
The aggregate `/api/charts/workloads-summary` and
`/api/charts/storage-summary` chart routes are retired the same way. No
first-party client ever called the workloads route, the storage route lost
its last reader when `storageSummaryTrendCache.ts` was deleted, and
neither appeared in `docs/API.md`. Their handlers, response types,
`ChartsAPI` methods, mock prewarm and SLO budget are gone, and they must
not return as compatibility reads; a new aggregate chart starts from a
live consumer and its own contract entry.
40. Keep mock and demo chart reads on the same canonical unified snapshot as
the rest of the API surface. `internal/api/chartapi/service.go`,
`internal/api/chartapi/service_test.go`, and chart consumers must route
@@ -6322,12 +6326,12 @@ the first store-backed read-state when provider-owned inventories such as
TrueNAS or VMware have not yet completed an initial baseline and been rebuilt
into the canonical monitor store.
That same workload-chart boundary now also owns the rendered-metric budget on
the shared monitoring routes. `/api/charts/workloads` and
`/api/charts/workloads-summary` may batch provider-backed reads in parallel,
but they must request only the canonical workload metrics they actually
serialize (`cpu`, `memory`, `disk`, `netin`, `netout`), with Kubernetes pods
staying on that same five-metric set, instead of widening back to disk
read/write or fetch-all backend batches that the browser never renders.
the shared monitoring routes. `/api/charts/workloads` may batch
provider-backed reads in parallel, but each workload type must request only
the metric set the route serializes for it: the guest sparkline set for VMs
and system containers, the infrastructure set for app containers, and the
five-metric set (`cpu`, `memory`, `disk`, `netin`, `netout`) for Kubernetes
pods, instead of fetch-all backend batches that the browser never renders.
The shared metrics-history contract now also owns physical-disk live I/O
windows. `/api/metrics-store/history` must accept `resourceType=disk`, keep
`30m` as a valid compact live range, and resolve `disk`, `diskread`,
@@ -8603,17 +8607,12 @@ That same generated payload may not shorten the earlier auto-register failure
branch back to plain "Pulse Settings" wording either; both the immediate
failure guidance and the final manual footer must preserve the same Settings →
Nodes completion destination.
`/api/charts/workloads-summary` now also has a canonical hot-path invariant:
aggregate workload charts must preserve stable guest counts while batching
store-backed metric reads across workload types, with no payload shape change.
That endpoint now also carries an explicit API p95 budget under the same
store-backed mixed-workload fixture used to verify the batched hot path.
That same summary-chart contract now also owns synthetic mock fallback
identity. When `internal/api/chartapi/service.go` needs to synthesize summary
history for workloads, infrastructure, or storage cards, it must key those
series by canonical `resourceType`, `resourceID`, and `metricType` instead of
ad hoc seed-prefix bounds, so all time ranges and runtime mock samples stay on
one governed timeline.
The chart contract also owns synthetic mock fallback identity. When
`internal/api/chartapi/service.go` synthesizes mock history for
`/api/metrics-store/history`, it must key those series by canonical
`resourceType`, `resourceID`, and `metricType` instead of ad hoc seed-prefix
bounds, so all time ranges and runtime mock samples stay on one governed
timeline.
Frontend AI API clients now also normalize `402 Payment Required` responses for
optional paywalled collections into explicit empty states, so Pulse Pro gating
does not become a transport error path during page bootstrap.
@@ -5148,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; the infrastructure and workloads summary chart routes already own
that chart contract.
sparklines; the infrastructure summary and per-workload chart routes already
own that chart contract.
The shared trial banner is retired for self-hosted v6 GA. Future commercial
notification work must start from the explicit Plans, hosted, activation,
recovery, or support surfaces rather than reviving a global authenticated-shell
@@ -3731,19 +3731,18 @@ sampler generation instead of regenerating or re-downsampling the same seeded
timeline on every endpoint hit. When seeded mock history is rebuilt or a live
mock tick advances, monitoring must invalidate that cache so preview charts
stay current without paying repeated per-request synthesis cost.
That same sampler-owned cache contract also covers compact summary reads after
the dashboard overview retirement. When live mock ticks advance, monitoring
must repopulate the canonical 24-hour aggregate `/api/charts/storage-summary`
cache inside the sampler path instead of leaving the first operator request
after each tick to rebuild per-pool mock storage charts on demand.
The same mock sampler path must also prewarm the default Workloads guest-chart
The mock sampler path must prewarm the default Workloads guest-chart
cache through `GetGuestMetricsForChartBatch`, using canonical `ReadState`
workload identities for VMs, system containers, Kubernetes pods, and app
containers so `/api/charts/workloads` and `/api/charts/workloads-summary` do
not rebuild every guest sparkline on the first post-tick request.
containers so `/api/charts/workloads` does not rebuild every guest sparkline
on the first post-tick request. The retired aggregate
`/api/charts/storage-summary` trend is no longer prewarmed; its route,
`GetStorageSummaryCapacityTrend` and the mock aggregate cache went with it,
and sampler ticks must not regain an aggregate storage synthesis step
without a live consumer.
That same metrics-hot-path ownership also includes metric-type selection for
compact summary reads. When infrastructure or storage summary routes request
only a subset of canonical chart series,
compact chart reads. When chart routes such as `/api/charts/infrastructure` or
`/api/charts/workloads` request only a subset of canonical chart series,
`internal/monitoring/monitor_metrics.go` must preserve that narrowed metric
set through the batch store fallback path instead of querying every metric type
for each resource and discarding most of the payload afterward.
@@ -2288,21 +2288,22 @@ queries. Compact route consumers that request only CPU/memory or only storage
`pkg/metrics/store.go` instead of fetching every series for every resource and
discarding the extra payload in higher layers.
That same protected workload-chart hot path now also owns rendered-metric
budgeting. `internal/api/router.go` may parallelize VM, container, pod, and
docker-container workload batch reads, but `/api/charts/workloads` and
`/api/charts/workloads-summary` must request only the canonical five workload
metrics they actually render instead of widening back to disk read/write or
fetch-all history queries.
budgeting. `internal/api/chartapi/service.go` may parallelize VM, container,
pod, and docker-container workload batch reads, but `/api/charts/workloads`
must request only the metric set it serializes for each workload type (the
guest sparkline set for VMs and system containers, the infrastructure set for
app containers, the five-metric set for Kubernetes pods) instead of widening
back to fetch-all history queries.
That same chart-batch hot path now also owns long-range in-memory coverage.
`internal/monitoring/monitor_metrics.go` may skip SQLite for guest and node
chart batches when `metrics_history` can prove the requested window is
already covered in memory, and performance work must preserve that
coverage-gated fast path rather than treating every long-duration request as
store-backed by default.
That same hot path now also covers mock-mode cache warmth. The canonical
24-hour `/api/charts/storage-summary` dashboard transport must stay prewarmed
across live mock sampler ticks so the first dashboard request after a refresh
does not pay the full aggregate-storage synthesis cost on the operator path.
That same hot path now also covers mock-mode cache warmth: live mock sampler
ticks prewarm only the Workloads guest-chart cache. The retired aggregate
`/api/charts/storage-summary` trend is no longer synthesized or prewarmed, so
no sampler tick pays for an aggregate nothing reads.
That same chart-client hot path also owns canonical Kubernetes target typing.
`frontend-modern/src/api/charts.ts` may normalize Kubernetes history requests
onto the shared backend `resourceType=k8s` transport, but it must preserve the
@@ -2464,12 +2465,10 @@ 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
`GetStorageMetricsForChartBatch(...)` for the surfaces that still own storage
summary presentation, but no deleted dashboard trend hook may reopen the full
storage-page `/api/storage-charts` payload or an N+1 per-pool
`/api/metrics-store/history` fan-out.
dashboard overview retirement. The compact `/api/charts/storage-summary` route
is retired after its last reader was deleted, and no deleted dashboard trend hook may
reopen it, the full storage-page `/api/storage-charts` payload, or an N+1
per-pool `/api/metrics-store/history` fan-out.
That same Workloads shell boundary also owns empty-state action routing in
`frontend-modern/src/components/Workloads/WorkloadsStateCards.tsx`. When the
Workloads route has no connected infrastructure sources, the CTA must hand
@@ -3013,19 +3012,15 @@ The unified-resource projection also uses that same helper for Kubernetes
`clusterId`, so the shared store, dashboard grouping, and detail-navigation
surfaces all see the same cluster-context prefix before any surface-specific
fallback applies.
The aggregate `/api/charts/workloads-summary` endpoint now also has its own
explicit API p95 budget constant, aligned with the per-workload charts budget,
and `internal/api/slo_bench_test.go` must fail if that aggregate budget or its
store-backed mixed-workload benchmark coverage drifts.
The infrastructure and workload-summary chart endpoints may keep a short
backend response cache for identical org/range/metric-scope summary requests,
but only as presentation hot-path protection for repeated summary polling and
remounts. The cached payloads must still be built from the canonical
The infrastructure summary and per-workload chart endpoints may keep a short
backend response cache for identical org/range/metric-scope or
org/range/node/point-count requests, but only as presentation hot-path
protection for repeated polling and remounts. The cached payloads must still be built from the canonical
store-backed/read-state sources, must remain isolated by organization and
explicit chart scope, and must not become telemetry freshness, lifecycle,
recovery, or persistence authority.
Those summary caches and the per-workload chart cache share one process-local
retention budget of 64 payloads and 16 MiB. Every insert sweeps expired query
The infrastructure summary cache and the per-workload chart cache share one
process-local retention budget of 64 payloads and 16 MiB. Every insert sweeps expired query
variants and evicts oldest payloads at either bound, so raw range, node,
metric-order, or point-count variants cannot turn a short TTL into unbounded
steady-state memory. Responses larger than the byte budget remain uncached
@@ -2290,21 +2290,15 @@ recovery scope, or a storage/recovery-owned secret source.
signed command grant, or command result as restore authorization or
recovery evidence; that authority remains action-governance and
agent-lifecycle owned.
32. Keep workload chart downsampling and short response caching presentation-only on that same adjacent shared API boundary. When `internal/api/chartapi/service.go` caps mixed-cadence workload history into equal-time buckets or serves a cached workload-summary payload for repeated operator-facing workload-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
The same adjacent chart boundary now covers compact storage capacity
transport. `internal/api/chartapi/service.go` may batch only the canonical
`used` and `avail` storage series for `/api/charts/storage-summary`, but
storage and recovery must not treat the omitted `usage` or `total` series
as lost recovery truth or widen that compact route back into the full
storage-page payload.
32. Keep workload chart downsampling and short response caching presentation-only on that same adjacent shared API boundary. When `internal/api/chartapi/service.go` caps mixed-cadence workload history into equal-time buckets or serves a cached workload chart payload for repeated operator-facing workload requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
The compact `/api/charts/storage-summary` capacity route is retired, and
storage and recovery must not reopen it as a second storage-history
transport beside `/api/storage-charts`.
That same adjacent API boundary also owns summary-request minimization:
storage/recovery-adjacent consumers may rely on filtered infrastructure or
guest summary payloads, but they must not widen a scoped chart request back
into full guest metric fan-out just because adjacent pages carry richer
detail charts elsewhere.
In mock mode, that same compact route must stay aggregate-only and
sampler-prewarmed; storage and recovery must not trigger per-pool chart
reconstruction on the first dashboard request after each mock refresh.
33. Keep shared `frontend-modern/src/App.tsx` public-route ownership explicit by
surface. Storage/recovery preview entrypoints such as
`/preview/setup-complete` may remain public app-shell routes, but unrelated
@@ -2801,12 +2795,11 @@ canonical pre-handler authorization proof covering both transfer routes.
`/api/storage-charts` summary payload, but it must not fan out row-local
`/api/metrics-store/history` calls, invent a second storage-history cache,
or drift onto storage-page-only metric identifiers.
Dashboard storage trends belong to that same owned summary contract: the
dashboard may derive a 24-hour storage capacity delta from
`/api/charts/storage-summary`, but it must not rebuild storage summary
behavior by fanning out per-pool `/api/metrics-store/history` reads, by
pulling the full storage-page `/api/storage-charts` payload, or by
inventing a dashboard-only storage history transport.
No dashboard storage trend survives the overview retirement, and its
`/api/charts/storage-summary` route is gone with it. A future aggregate
storage trend must not rebuild that behavior by fanning out per-pool
`/api/metrics-store/history` reads or by inventing a dashboard-only storage
history transport.
15a. Keep shared diagnostics cache scope honest when storage/recovery-adjacent
surfaces reuse `internal/api/diagnostics.go`. The shared diagnostics
payload must not include local commercial funnel summaries or
@@ -2641,10 +2641,11 @@ application resource-provider or WebSocket lifecycle.
18. Keep storage summary target selection on canonical unified-resource truth.
Storage-summary consumers may detect storage presence from canonical
`isStorage(...)` resources and their shared metrics-target IDs, but once
storage exists they must reuse the owned compact
`/api/charts/storage-summary` contract instead of rebuilding page-local
per-resource storage history fetches, storage-type aliases, or full
storage-page `/api/storage-charts` fetches.
storage exists they must reuse the shared `/api/storage-charts` summary
response through `frontend-modern/src/utils/storageSummaryCache.ts`
instead of rebuilding page-local per-resource storage history fetches or
storage-type aliases. The retired `/api/charts/storage-summary` route must
not return as a second storage summary transport.
19. Keep infrastructure framing presentation-only on active product surfaces.
Platform/runtime pages, shared infrastructure tables, and Settings
infrastructure panels may render page or section headers, but canonical
@@ -4117,7 +4118,7 @@ That same registry/view boundary now also applies to provider-backed storage.
`internal/unifiedresources/registry.go` must attach the resolved
`MetricsTarget` onto cached view clones before `ReadState` exposes
`StoragePoolView` or `PhysicalDiskView`, so `/api/resources`, storage summary
selection, `/api/storage-charts`, and `/api/charts/storage-summary` all see
selection, and `/api/storage-charts` all see
the same canonical history
identity instead of splitting between view-cache resource IDs and API
serialization-time metric IDs.
@@ -0,0 +1,43 @@
{
"version": 1,
"base_sha": "36d3f8c5571daa1e041abf41c4f7c394ca14111f",
"verified_at": "2026-10-07T17:01:28Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/charts.ts"
],
"content_sha256": {
"frontend-modern/src/api/charts.ts": "58f127b93cf60ea648a6b4768e89acc13c16b764ed3f2da6dde5169e7e163fed"
},
"routes": [
"/proxmox/overview (Trends)",
"/proxmox/overview (guest drawer History tab)",
"/proxmox/storage",
"GET /api/charts/workloads-summary, GET /api/charts/storage-summary (direct API probe)"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Two embedded production builds on private DEMO_MODE mock stacks with fresh data dirs and telemetry off: 'before' at 732bb6856a (port 7761) and 'after' with this change (port 7762). charts.ts and its two test files are byte-identical between 732bb6856a and this commit's parent 36d3f8c557, so the after build ran the chart client content committed here; the rest of its frontend is 732bb6856a's, not 36d3f8c557's.",
"Proxmox overview in Trends mode: 604 guest-table sparklines over 161 rows at 1440 px and 164 over 45 rows at 390 px, identical before and after; chart reads were /api/charts/infrastructure (metrics,range) and /api/charts/workloads (maxPoints,range), all 200.",
"Guest drawer History tab (artifact-cache-310): Utilization, Network I/O and Disk I/O charts rendered from /api/metrics-store/history 200, identical before and after at both widths.",
"Storage page: 73 rows and the pool summary charts from /api/storage-charts?range 200, identical before and after at both widths.",
"Neither build requested /api/charts/workloads-summary or /api/charts/storage-summary on any route. Direct probes with demo credentials: both routes 200 on before, 404 on after; /api/charts/workloads, /api/charts/infrastructure and /api/storage-charts 200 on both.",
"No console errors or page errors and no horizontal document overflow at either width on either build.",
"Dist proof: vite build before/after with identical file sets after hash normalisation; only assets/useUnifiedResources-*.js (the ChartsAPI chunk) differs in code, and after normalising hashes and 1-3 character identifiers its only residual is the removed getWorkloadsSummaryCharts and getStorageSummaryTrend static methods (326 B raw, 49 B zlib). index-*.js and index.html differ only in chunk-hash and SRI integrity values."
],
"interactions": [
"Demo sign-in, then with workloadsMetricDisplayMode=sparklines navigate to /proxmox/overview and wait for the /api/charts/workloads read.",
"Click the first guest row to open its inline drawer, click the History tab, and wait for the /api/metrics-store/history read.",
"Navigate to /proxmox/storage and wait for the /api/storage-charts read.",
"Record every /api/charts*, /api/storage-charts and /api/metrics-store request with its status; same script in a 1440x900 desktop context and a 390x844 phone context against both builds; screenshots of overview, drawer and storage inspected."
]
}
@@ -1,7 +1,6 @@
/**
* Branch-coverage tests for the currently-uncovered ChartsAPI methods:
* - ChartsAPI.getInfrastructureSummaryCharts range/node/signal request shaping
* - ChartsAPI.getStorageSummaryTrend
* Branch-coverage tests for ChartsAPI.getInfrastructureSummaryCharts
* range/node/signal request shaping.
*
* These tests assert request shaping (final path + query string + signal) and
* response handling. They mock the transport with the same harness used by
@@ -11,7 +10,7 @@
* getMetricsHistory).
*
* Branches exercised here:
* - range default ('1h' for infra, '24h' for storage-trend) vs explicit value
* - range default ('1h') vs explicit value
* - signal present vs undefined
* - options.nodeId truthy (string) -> `node=` param appended
* - options.nodeId falsy variants -> `node=` param omitted:
@@ -20,9 +19,7 @@
* * options undefined entirely
* - URL-encoding of special chars inside nodeId (URLSearchParams.toString)
* - combined range + node + signal in a single request
* - getStorageSummaryTrend forwards the raw TimeRange token without calling
* timeRangeToMinutes() (unlike getStorageSummaryCharts)
* - each function returns the parsed payload from apiFetchJSON verbatim
* - the parsed payload from apiFetchJSON is returned verbatim
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -30,12 +27,7 @@ vi.mock('@/utils/apiClient', () => ({
apiFetchJSON: vi.fn(),
}));
import {
ChartsAPI,
type InfrastructureChartsResponse,
type StorageSummaryTrendResponse,
type TimeRange,
} from '@/api/charts';
import { ChartsAPI, type InfrastructureChartsResponse, type TimeRange } from '@/api/charts';
import { apiFetchJSON } from '@/utils/apiClient';
const ALL_TIME_RANGES: TimeRange[] = ['5m', '15m', '30m', '1h', '4h', '12h', '24h', '7d', '30d'];
@@ -178,90 +170,3 @@ describe('ChartsAPI.getInfrastructureSummaryCharts — branch coverage', () => {
expect(result).toBe(payload);
});
});
describe('ChartsAPI.getStorageSummaryTrend — branch coverage', () => {
const apiFetchJSONMock = vi.mocked(apiFetchJSON);
beforeEach(() => {
apiFetchJSONMock.mockReset();
});
it('routes to /charts/storage-summary with default range=24h and signal undefined when called with no args', async () => {
apiFetchJSONMock.mockResolvedValueOnce({} as never);
await ChartsAPI.getStorageSummaryTrend();
expect(apiFetchJSONMock).toHaveBeenCalledTimes(1);
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/storage-summary?range=24h', {
signal: undefined,
});
});
it('passes the range token through WITHOUT minutes conversion (key difference from getStorageSummaryCharts)', async () => {
apiFetchJSONMock.mockResolvedValueOnce({} as never);
await ChartsAPI.getStorageSummaryTrend('5m');
// NOTE: getStorageSummaryCharts('5m') would build range=5 (minutes via
// timeRangeToMinutes). getStorageSummaryTrend keeps the raw token '5m'.
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/storage-summary?range=5m', {
signal: undefined,
});
});
it.each(ALL_TIME_RANGES)(
'forwards TimeRange="%s" verbatim into range param (no minutes conversion)',
async (range) => {
apiFetchJSONMock.mockResolvedValueOnce({} as never);
await ChartsAPI.getStorageSummaryTrend(range);
expect(apiFetchJSONMock).toHaveBeenCalledWith(`/api/charts/storage-summary?range=${range}`, {
signal: undefined,
});
},
);
it('forwards an AbortSignal through to apiFetchJSON', async () => {
apiFetchJSONMock.mockResolvedValueOnce({} as never);
const controller = new AbortController();
await ChartsAPI.getStorageSummaryTrend('24h', controller.signal);
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/storage-summary?range=24h', {
signal: controller.signal,
});
});
it('passes signal=undefined to apiFetchJSON when no AbortSignal is supplied', async () => {
apiFetchJSONMock.mockResolvedValueOnce({} as never);
await ChartsAPI.getStorageSummaryTrend('12h');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/storage-summary?range=12h', {
signal: undefined,
});
});
it('returns the parsed StorageSummaryTrendResponse payload verbatim from apiFetchJSON', async () => {
const payload: StorageSummaryTrendResponse = {
capacity: [
{ timestamp: 1700000000000, value: 80 },
{ timestamp: 1700000060000, value: 81 },
],
timestamp: 1700000060000,
stats: {
oldestDataTimestamp: 1699900000000,
range: '24h',
rangeSeconds: 86400,
metricsStoreEnabled: true,
primarySourceHint: 'store',
},
};
apiFetchJSONMock.mockResolvedValueOnce(payload as never);
const result = await ChartsAPI.getStorageSummaryTrend();
expect(result).toBe(payload);
});
});
@@ -39,17 +39,6 @@ describe('ChartsAPI', () => {
});
});
it('adds node query for host-scoped workloads summary', async () => {
apiFetchJSONMock.mockResolvedValueOnce({} as any);
await ChartsAPI.getWorkloadsSummaryCharts('1h', undefined, { nodeId: 'cluster-a-node-1' });
expect(apiFetchJSONMock).toHaveBeenCalledWith(
'/api/charts/workloads-summary?range=1h&node=cluster-a-node-1',
{ signal: undefined },
);
});
it('builds infrastructure summary chart requests with explicit metric filters', async () => {
apiFetchJSONMock.mockResolvedValueOnce({} as any);
+1 -81
View File
@@ -82,61 +82,6 @@ export interface WorkloadChartsResponse {
stats: ChartStats;
}
export interface WorkloadsSummaryMetricData {
p50: MetricPoint[];
p95: MetricPoint[];
}
export interface WorkloadsGuestCounts {
total: number;
running: number;
stopped: number;
}
export interface WorkloadsSummaryContributor {
id: string;
name: string;
value: number;
}
export interface WorkloadsSummaryContributors {
cpu: WorkloadsSummaryContributor[];
memory: WorkloadsSummaryContributor[];
disk: WorkloadsSummaryContributor[];
network: WorkloadsSummaryContributor[];
}
export interface WorkloadsSummaryBlastRadius {
scope: string;
top3Share: number;
activeWorkloads: number;
}
export interface WorkloadsSummaryBlastRadiusGroup {
cpu: WorkloadsSummaryBlastRadius;
memory: WorkloadsSummaryBlastRadius;
disk: WorkloadsSummaryBlastRadius;
network: WorkloadsSummaryBlastRadius;
}
export interface WorkloadsSummaryChartsResponse {
cpu: WorkloadsSummaryMetricData;
memory: WorkloadsSummaryMetricData;
disk: WorkloadsSummaryMetricData;
network: WorkloadsSummaryMetricData;
guestCounts: WorkloadsGuestCounts;
topContributors: WorkloadsSummaryContributors;
blastRadius: WorkloadsSummaryBlastRadiusGroup;
timestamp: number;
stats: ChartStats;
}
export interface StorageSummaryTrendResponse {
capacity: MetricPoint[];
timestamp: number;
stats: ChartStats;
}
// Persistent metrics history types (SQLite-backed, longer retention)
export type HistoryTimeRange = '30m' | '1h' | '6h' | '12h' | '24h' | '7d' | '14d' | '30d' | '90d';
type MetricsHistoryAPIResourceType =
@@ -400,23 +345,7 @@ export class ChartsAPI {
}
/**
* Fetch workloads aggregate chart data for Workloads top-card sparklines.
* Returns compact p50/p95 series, not per-workload lines.
*/
static async getWorkloadsSummaryCharts(
range: TimeRange = '1h',
signal?: AbortSignal,
options?: { nodeId?: string | null },
): Promise<WorkloadsSummaryChartsResponse> {
const url = this.buildChartsUrl('/charts/workloads-summary', {
range,
nodeId: options?.nodeId,
});
return apiFetchJSON(url, { signal });
}
/**
* Fetch workload-only chart data used by WorkloadsSummary sparklines.
* Fetch workload-only chart data for the workload table's Trends sparklines.
* Excludes infrastructure/storage series to keep payloads bounded at scale.
*/
static async getWorkloadCharts(
@@ -488,15 +417,6 @@ export class ChartsAPI {
const url = `${this.baseUrl}/storage-charts?${params.toString()}`;
return apiFetchJSON(url, { signal });
}
static async getStorageSummaryTrend(
range_: TimeRange = '24h',
signal?: AbortSignal,
): Promise<StorageSummaryTrendResponse> {
const params = new URLSearchParams({ range: range_ });
const url = `${this.baseUrl}/charts/storage-summary?${params.toString()}`;
return apiFetchJSON(url, { signal });
}
}
// ---------------------------------------------------------------------------
-72
View File
@@ -374,30 +374,6 @@ func TestNormalizeInfrastructureSummaryMetricPointSeriesAveragesPlateauMetrics(t
}
}
func TestTargetWorkloadsSummarySeriesPoints(t *testing.T) {
t.Parallel()
tests := []struct {
name string
duration time.Duration
want int
}{
{name: "zero_defaults_to_minimum", duration: 0, want: workloadsSummaryMinSeriesPoints},
{name: "one_hour_keeps_minute_resolution", duration: time.Hour, want: 60},
{name: "four_hours_caps_at_maximum", duration: 4 * time.Hour, want: workloadsSummaryMaxSeriesPoints},
{name: "seven_days_caps_at_maximum", duration: 7 * 24 * time.Hour, want: workloadsSummaryMaxSeriesPoints},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
if got := targetWorkloadsSummarySeriesPoints(tt.duration); got != tt.want {
t.Fatalf("targetWorkloadsSummarySeriesPoints(%v) = %d, want %d", tt.duration, got, tt.want)
}
})
}
}
func TestCapMetricPointSeriesSmoothsMixedCadenceTail(t *testing.T) {
t.Parallel()
@@ -440,54 +416,6 @@ func TestCapMetricPointSeriesSmoothsMixedCadenceTail(t *testing.T) {
}
}
func TestNormalizeWorkloadsSummaryMetricPointSeriesSmoothsMixedCadenceTail(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)
duration := 7 * 24 * time.Hour
windowStart := now.Add(-duration)
var points []MetricPoint
for ts := windowStart; ts.Before(now.Add(-24 * time.Hour)); ts = ts.Add(65 * time.Minute) {
points = append(points, MetricPoint{Timestamp: ts.UnixMilli(), Value: 20})
}
for ts := now.Add(-24 * time.Hour); ts.Before(now.Add(-2 * time.Hour)); ts = ts.Add(2 * time.Minute) {
points = append(points, MetricPoint{Timestamp: ts.UnixMilli(), Value: 40})
}
for ts := now.Add(-2 * time.Hour); ts.Before(now); ts = ts.Add(time.Minute) {
points = append(points, MetricPoint{Timestamp: ts.UnixMilli(), Value: 60})
}
points = append(points, MetricPoint{Timestamp: now.UnixMilli(), Value: 75})
normalized := normalizeWorkloadsSummaryMetricPointSeries(WorkloadsSummaryMetricData{
P50: append([]MetricPoint(nil), points...),
P95: append([]MetricPoint(nil), points...),
}, duration)
if len(normalized.P50) > workloadsSummaryMaxSeriesPoints {
t.Fatalf("expected p50 point count <= %d, got %d", workloadsSummaryMaxSeriesPoints, len(normalized.P50))
}
if len(normalized.P95) > workloadsSummaryMaxSeriesPoints {
t.Fatalf("expected p95 point count <= %d, got %d", workloadsSummaryMaxSeriesPoints, len(normalized.P95))
}
if normalized.P50[len(normalized.P50)-1].Timestamp != now.UnixMilli() {
t.Fatalf("expected latest p50 timestamp %d, got %d", now.UnixMilli(), normalized.P50[len(normalized.P50)-1].Timestamp)
}
if normalized.P95[len(normalized.P95)-1].Timestamp != now.UnixMilli() {
t.Fatalf("expected latest p95 timestamp %d, got %d", now.UnixMilli(), normalized.P95[len(normalized.P95)-1].Timestamp)
}
recentWindowStart := now.Add(-24 * time.Hour).UnixMilli()
recentCount := 0
for _, point := range normalized.P50 {
if point.Timestamp >= recentWindowStart {
recentCount++
}
}
if recentCount > 20 {
t.Fatalf("expected day-proportional workload summary buckets, got %d recent p50 points", recentCount)
}
}
func TestDockerMetricKeyFormat(t *testing.T) {
t.Parallel()
+8 -7
View File
@@ -69,13 +69,14 @@ func TestChartRoutesShareOnePayloadRetentionBudget(t *testing.T) {
service.chartPayloads = newBoundedChartPayloadCache(2, 10)
service.cacheInfrastructureChartsPayload("infra", []byte("aaaa"), now)
service.cacheWorkloadsSummaryChartsPayload("summary", []byte("bbbb"), now)
service.chartPayloads.put(
workloadChartsCachePrefix+"workloads",
[]byte("cccc"),
now.Add(workloadChartsCacheTTL),
now,
)
for _, key := range []string{"workloads-a", "workloads-b"} {
service.chartPayloads.put(
workloadChartsCachePrefix+key,
[]byte("bbbb"),
now.Add(workloadChartsCacheTTL),
now,
)
}
if got := len(service.chartPayloads.entries); got != 2 {
t.Fatalf("retained entries across chart routes = %d, want shared cap 2", got)
File diff suppressed because it is too large Load Diff
@@ -429,147 +429,6 @@ func TestHandleInfrastructureCharts_UsesCanonicalMockUnifiedReadStateForVMwareHo
}
}
func TestHandleWorkloadsSummaryCharts_AggregatesAndCounts(t *testing.T) {
monitor, state, _ := newTestMonitor(t)
state.Nodes = []models.Node{{
ID: "node-pve-1",
Name: "pve-1",
Instance: "pve",
}}
state.VMs = []models.VM{{
ID: "vm-101",
Name: "vm-101",
Node: "pve-1",
Instance: "pve",
Status: "running",
CPU: 0.25,
Memory: models.Memory{Usage: 40.0},
Disk: models.Disk{Usage: 55.0},
NetworkIn: 1200,
NetworkOut: 800,
}}
state.Containers = []models.Container{{
ID: "ct-201",
Name: "ct-201",
Node: "pve-1",
Instance: "pve",
Status: "stopped",
CPU: 0.10,
Memory: models.Memory{Usage: 30.0},
Disk: models.Disk{Usage: 45.0},
NetworkIn: 400,
NetworkOut: 600,
}}
state.DockerHosts = []models.DockerHost{{
ID: "docker-host-1",
Runtime: "docker",
Containers: []models.DockerContainer{{
ID: "docker-1",
Name: "docker-1",
State: "running",
CPUPercent: 35.0,
MemoryPercent: 60.0,
WritableLayerBytes: 10,
RootFilesystemBytes: 100,
}},
}}
service := NewService(fixedMonitorResolver{monitor: monitor})
req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads-summary?range=5m", nil)
rec := httptest.NewRecorder()
service.HandleWorkloadsSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("expected application/json, got %q", ct)
}
body := rec.Body.Bytes()
var decoded WorkloadsSummaryChartsResponse
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("unmarshal WorkloadsSummaryChartsResponse: %v", err)
}
if decoded.Stats.Range != "5m" {
t.Fatalf("expected stats.range=5m, got %q", decoded.Stats.Range)
}
if decoded.Stats.RangeSeconds != 300 {
t.Fatalf("expected stats.rangeSeconds=300, got %d", decoded.Stats.RangeSeconds)
}
if decoded.GuestCounts.Total != 3 {
t.Fatalf("expected guestCounts.total=3, got %d", decoded.GuestCounts.Total)
}
if decoded.GuestCounts.Running != 2 {
t.Fatalf("expected guestCounts.running=2, got %d", decoded.GuestCounts.Running)
}
if decoded.GuestCounts.Stopped != 1 {
t.Fatalf("expected guestCounts.stopped=1, got %d", decoded.GuestCounts.Stopped)
}
for metricName, metric := range map[string]WorkloadsSummaryMetricData{
"cpu": decoded.CPU,
"memory": decoded.Memory,
"disk": decoded.Disk,
"network": decoded.Network,
} {
if len(metric.P50) == 0 {
t.Fatalf("expected %s p50 points to be present", metricName)
}
if len(metric.P95) == 0 {
t.Fatalf("expected %s p95 points to be present", metricName)
}
}
if decoded.Stats.PointCounts.Total <= 0 {
t.Fatalf("expected stats.pointCounts.total to be > 0, got %d", decoded.Stats.PointCounts.Total)
}
if decoded.Stats.PointCounts.Guests <= 0 {
t.Fatalf("expected stats.pointCounts.guests to be > 0, got %d", decoded.Stats.PointCounts.Guests)
}
if decoded.BlastRadius.CPU.Scope != "concentrated" {
t.Fatalf("expected cpu blast radius concentrated, got %q", decoded.BlastRadius.CPU.Scope)
}
if decoded.BlastRadius.Network.Scope != "concentrated" {
t.Fatalf("expected network blast radius concentrated, got %q", decoded.BlastRadius.Network.Scope)
}
// Node-scoped request should only include workloads that match the selected node.
nodeReq := httptest.NewRequest(
http.MethodGet,
"/api/charts/workloads-summary?range=5m&node=node-pve-1",
nil,
)
nodeRec := httptest.NewRecorder()
service.HandleWorkloadsSummaryCharts(nodeRec, nodeReq)
if nodeRec.Code != http.StatusOK {
t.Fatalf("node-scoped expected status %d, got %d", http.StatusOK, nodeRec.Code)
}
var nodeScoped WorkloadsSummaryChartsResponse
if err := json.Unmarshal(nodeRec.Body.Bytes(), &nodeScoped); err != nil {
t.Fatalf("unmarshal node-scoped WorkloadsSummaryChartsResponse: %v", err)
}
if nodeScoped.GuestCounts.Total != 2 {
t.Fatalf("expected node-scoped guestCounts.total=2, got %d", nodeScoped.GuestCounts.Total)
}
if nodeScoped.GuestCounts.Running != 1 {
t.Fatalf("expected node-scoped guestCounts.running=1, got %d", nodeScoped.GuestCounts.Running)
}
if nodeScoped.GuestCounts.Stopped != 1 {
t.Fatalf("expected node-scoped guestCounts.stopped=1, got %d", nodeScoped.GuestCounts.Stopped)
}
if nodeScoped.BlastRadius.CPU.Scope != "concentrated" {
t.Fatalf("expected node-scoped cpu blast radius concentrated, got %q", nodeScoped.BlastRadius.CPU.Scope)
}
}
func TestHandleWorkloadCharts_WorkloadOnlyPayloadAndNodeFilter(t *testing.T) {
monitor, state, _ := newTestMonitor(t)
state.Nodes = []models.Node{{
@@ -805,84 +664,6 @@ func TestHandleWorkloadCharts_UsesCanonicalWorkloadIDsForVMwareVMs(t *testing.T)
}
}
func TestHandleWorkloadsSummaryCharts_UsesCanonicalWorkloadIDsForVMwareVMs(t *testing.T) {
monitor, state, history := newTestMonitor(t)
now := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Minute)
metricID := "vc-1:vm:vm-201"
resourceID := "vm-vmware-1"
history.AddGuestMetric(metricID, "cpu", 51, now.Add(-10*time.Minute))
history.AddGuestMetric(metricID, "memory", 64, now.Add(-5*time.Minute))
history.AddGuestMetric(metricID, "disk", 43, now.Add(-3*time.Minute))
history.AddGuestMetric(metricID, "netin", 1200, now.Add(-2*time.Minute))
history.AddGuestMetric(metricID, "netout", 800, now.Add(-2*time.Minute))
adapter := unifiedresources.NewMonitorAdapter(nil)
adapter.PopulateSnapshotAndSupplemental(state.GetSnapshot(), map[unifiedresources.DataSource][]unifiedresources.IngestRecord{
unifiedresources.SourceVMware: {
{
SourceID: metricID,
Resource: unifiedresources.Resource{
ID: resourceID,
Type: unifiedresources.ResourceTypeVM,
Name: "warehouse-api-01",
Status: unifiedresources.StatusOnline,
LastSeen: now,
MetricsTarget: &unifiedresources.MetricsTarget{
ResourceType: "vm",
ResourceID: metricID,
},
VMware: &unifiedresources.VMwareData{
ConnectionID: "vc-1",
EntityType: "vm",
ManagedObjectID: "vm-201",
},
},
},
},
})
setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(adapter))
readState := monitor.GetUnifiedReadStateOrSnapshot()
if readState == nil || len(readState.VMs()) != 1 || readState.VMs()[0] == nil {
t.Fatalf("expected one VMware VM in unified read state, got %+v", readState)
}
resourceID, _, ok := VMChartRequest(readState.VMs()[0])
if !ok {
t.Fatal("expected canonical VMware vm chart request")
}
service := NewService(fixedMonitorResolver{monitor: monitor})
req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads-summary?range=1h", nil)
rec := httptest.NewRecorder()
service.HandleWorkloadsSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String())
}
var decoded WorkloadsSummaryChartsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("unmarshal WorkloadsSummaryChartsResponse: %v", err)
}
if decoded.GuestCounts.Total != 1 || decoded.GuestCounts.Running != 1 {
t.Fatalf("expected guestCounts total/running = 1/1, got %+v", decoded.GuestCounts)
}
if len(decoded.TopContributors.CPU) == 0 {
t.Fatal("expected at least one cpu top contributor")
}
if decoded.TopContributors.CPU[0].ID != resourceID {
t.Fatalf("expected cpu top contributor id %q, got %+v", resourceID, decoded.TopContributors.CPU[0])
}
if decoded.TopContributors.CPU[0].ID == metricID {
t.Fatalf("expected workloads summary contributor id to avoid raw metrics target %q", metricID)
}
if got := decoded.TopContributors.CPU[0].Name; got != "warehouse-api-01" {
t.Fatalf("expected cpu top contributor name warehouse-api-01, got %q", got)
}
}
func TestHandleWorkloadCharts_IncludesKubernetesPods(t *testing.T) {
setMockModeForTest(t, false)
@@ -950,64 +731,6 @@ func TestHandleWorkloadCharts_IncludesKubernetesPods(t *testing.T) {
}
}
func TestHandleWorkloadsSummaryCharts_IncludesKubernetesPods(t *testing.T) {
setMockModeForTest(t, false)
monitor, state, _ := newTestMonitor(t)
state.KubernetesClusters = []models.KubernetesCluster{{
ID: "cluster-alpha",
Name: "cluster-alpha",
Status: "online",
Pods: []models.KubernetesPod{
{
UID: "pod-001",
Name: "api-0",
Namespace: "default",
Phase: "Running",
Containers: []models.KubernetesPodContainer{
{Name: "api", Ready: true},
},
},
{
UID: "pod-002",
Name: "batch-0",
Namespace: "ops",
Phase: "Failed",
Containers: []models.KubernetesPodContainer{
{Name: "worker", Ready: false},
},
},
},
}}
service := NewService(fixedMonitorResolver{monitor: monitor})
req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads-summary?range=5m", nil)
rec := httptest.NewRecorder()
service.HandleWorkloadsSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var decoded WorkloadsSummaryChartsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("unmarshal WorkloadsSummaryChartsResponse: %v", err)
}
if decoded.GuestCounts.Total != 2 {
t.Fatalf("expected guestCounts.total=2, got %d", decoded.GuestCounts.Total)
}
if decoded.GuestCounts.Running != 1 {
t.Fatalf("expected guestCounts.running=1, got %d", decoded.GuestCounts.Running)
}
if decoded.GuestCounts.Stopped != 1 {
t.Fatalf("expected guestCounts.stopped=1, got %d", decoded.GuestCounts.Stopped)
}
if len(decoded.CPU.P50) == 0 {
t.Fatal("expected cpu summary points for kubernetes workloads")
}
}
func TestHandleWorkloadCharts_UnknownNodeFilterFallsBackToGlobalScope(t *testing.T) {
monitor, state, _ := newTestMonitor(t)
state.Nodes = []models.Node{{
@@ -1049,43 +772,6 @@ func TestHandleWorkloadCharts_UnknownNodeFilterFallsBackToGlobalScope(t *testing
}
}
func TestHandleWorkloadsSummaryCharts_UnknownNodeFilterFallsBackToGlobalScope(t *testing.T) {
monitor, state, _ := newTestMonitor(t)
state.Nodes = []models.Node{{
ID: "node-pve-1",
Name: "pve-1",
Instance: "pve",
}}
state.VMs = []models.VM{{
ID: "vm-101",
Name: "vm-101",
Node: "pve-1",
Instance: "pve",
CPU: 0.3,
Memory: models.Memory{Usage: 42},
Disk: models.Disk{Usage: 55},
Status: "running",
}}
service := NewService(fixedMonitorResolver{monitor: monitor})
req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads-summary?range=5m&node=missing-node-id", nil)
rec := httptest.NewRecorder()
service.HandleWorkloadsSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var decoded WorkloadsSummaryChartsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("unmarshal WorkloadsSummaryChartsResponse: %v", err)
}
if decoded.GuestCounts.Total == 0 {
t.Fatalf("expected fallback to global scope when summary node filter is stale")
}
}
func TestHandleStorageCharts_Success(t *testing.T) {
monitor, state, metricsHistory := newTestMonitor(t)
state.Storage = []models.Storage{{ID: "store-1", Name: "Store One"}}
@@ -1237,44 +923,3 @@ func TestHandleStorageCharts_IncludesSupplementalStorageAndResolvesUnifiedNodeFi
t.Fatalf("expected TrueNAS pool to be filtered out by VMware host selection, got %v", decoded.Pools)
}
}
func TestHandleStorageSummaryCharts_AggregatesCapacityAcrossPools(t *testing.T) {
monitor, state, metricsHistory := newTestMonitor(t)
now := time.Now()
state.Storage = []models.Storage{
{ID: "store-1", Name: "Store One"},
{ID: "store-2", Name: "Store Two"},
}
metricsHistory.AddStorageMetric("store-1", "used", 400, now)
metricsHistory.AddStorageMetric("store-1", "avail", 600, now)
metricsHistory.AddStorageMetric("store-2", "used", 100, now)
metricsHistory.AddStorageMetric("store-2", "avail", 900, now)
syncTestResourceStore(t, monitor, state)
service := NewService(fixedMonitorResolver{monitor: monitor})
req := httptest.NewRequest(http.MethodGet, "/api/charts/storage-summary?range=1h", nil)
rec := httptest.NewRecorder()
service.HandleStorageSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("expected application/json, got %q", ct)
}
var decoded StorageSummaryTrendResponse
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("unmarshal StorageSummaryTrendResponse: %v", err)
}
if len(decoded.Capacity) != 1 {
t.Fatalf("expected 1 aggregate capacity point, got %d (%+v)", len(decoded.Capacity), decoded.Capacity)
}
if decoded.Capacity[0].Value != 25 {
t.Fatalf("expected aggregate capacity of 25%%, got %+v", decoded.Capacity[0])
}
if decoded.Stats.PointCounts.Total != 1 {
t.Fatalf("expected summary point count 1, got %+v", decoded.Stats.PointCounts)
}
}
-119
View File
@@ -100,125 +100,6 @@ func (r WorkloadChartsResponse) NormalizeCollections() WorkloadChartsResponse {
return r
}
// WorkloadsSummaryMetricData captures aggregate workload trend lines for a
// single metric (median and p95 across workloads).
type WorkloadsSummaryMetricData struct {
P50 []MetricPoint `json:"p50"`
P95 []MetricPoint `json:"p95"`
}
func (m WorkloadsSummaryMetricData) NormalizeCollections() WorkloadsSummaryMetricData {
if m.P50 == nil {
m.P50 = []MetricPoint{}
}
if m.P95 == nil {
m.P95 = []MetricPoint{}
}
return m
}
// WorkloadsGuestCounts captures workload counts used by the workloads summary
// cards for quick context.
type WorkloadsGuestCounts struct {
Total int `json:"total"`
Running int `json:"running"`
Stopped int `json:"stopped"`
}
// WorkloadsSummaryContributor identifies a high-impact workload for a metric.
type WorkloadsSummaryContributor struct {
ID string `json:"id"`
Name string `json:"name"`
Value float64 `json:"value"`
}
// WorkloadsSummaryContributors groups top contributors by metric.
type WorkloadsSummaryContributors struct {
CPU []WorkloadsSummaryContributor `json:"cpu"`
Memory []WorkloadsSummaryContributor `json:"memory"`
Disk []WorkloadsSummaryContributor `json:"disk"`
Network []WorkloadsSummaryContributor `json:"network"`
}
func (c WorkloadsSummaryContributors) NormalizeCollections() WorkloadsSummaryContributors {
if c.CPU == nil {
c.CPU = []WorkloadsSummaryContributor{}
}
if c.Memory == nil {
c.Memory = []WorkloadsSummaryContributor{}
}
if c.Disk == nil {
c.Disk = []WorkloadsSummaryContributor{}
}
if c.Network == nil {
c.Network = []WorkloadsSummaryContributor{}
}
return c
}
// WorkloadsSummaryBlastRadius describes how concentrated each metric pressure is.
type WorkloadsSummaryBlastRadius struct {
Scope string `json:"scope"` // idle, concentrated, mixed, distributed
Top3Share float64 `json:"top3Share"`
ActiveWorkloads int `json:"activeWorkloads"`
}
// WorkloadsSummaryBlastRadiusGroup groups blast-radius insights by metric.
type WorkloadsSummaryBlastRadiusGroup struct {
CPU WorkloadsSummaryBlastRadius `json:"cpu"`
Memory WorkloadsSummaryBlastRadius `json:"memory"`
Disk WorkloadsSummaryBlastRadius `json:"disk"`
Network WorkloadsSummaryBlastRadius `json:"network"`
}
// WorkloadsSummaryChartsResponse is a compact response for workloads top-card
// sparklines. It avoids returning per-workload time series.
type WorkloadsSummaryChartsResponse struct {
CPU WorkloadsSummaryMetricData `json:"cpu"`
Memory WorkloadsSummaryMetricData `json:"memory"`
Disk WorkloadsSummaryMetricData `json:"disk"`
Network WorkloadsSummaryMetricData `json:"network"`
GuestCounts WorkloadsGuestCounts `json:"guestCounts"`
TopContributors WorkloadsSummaryContributors `json:"topContributors"`
BlastRadius WorkloadsSummaryBlastRadiusGroup `json:"blastRadius"`
Timestamp int64 `json:"timestamp"`
Stats ChartStats `json:"stats"`
}
func EmptyWorkloadsSummaryChartsResponse() WorkloadsSummaryChartsResponse {
return WorkloadsSummaryChartsResponse{}.NormalizeCollections()
}
func (r WorkloadsSummaryChartsResponse) NormalizeCollections() WorkloadsSummaryChartsResponse {
r.CPU = r.CPU.NormalizeCollections()
r.Memory = r.Memory.NormalizeCollections()
r.Disk = r.Disk.NormalizeCollections()
r.Network = r.Network.NormalizeCollections()
r.TopContributors = r.TopContributors.NormalizeCollections()
r.Stats = r.Stats.NormalizeCollections()
return r
}
// StorageSummaryTrendResponse is a compact response for the dashboard storage
// card. It intentionally avoids returning per-pool and per-disk series.
type StorageSummaryTrendResponse struct {
Capacity []MetricPoint `json:"capacity"`
Timestamp int64 `json:"timestamp"`
Stats ChartStats `json:"stats"`
}
func EmptyStorageSummaryTrendResponse() StorageSummaryTrendResponse {
return StorageSummaryTrendResponse{}.NormalizeCollections()
}
func (r StorageSummaryTrendResponse) NormalizeCollections() StorageSummaryTrendResponse {
if r.Capacity == nil {
r.Capacity = []MetricPoint{}
}
r.Stats = r.Stats.NormalizeCollections()
return r
}
// ChartStats represents chart statistics
type ChartStats struct {
OldestDataTimestamp int64 `json:"oldestDataTimestamp"`
-8
View File
@@ -38,14 +38,6 @@ func (r *Router) handleInfrastructureCharts(w http.ResponseWriter, req *http.Req
r.ensureChartService().HandleInfrastructureCharts(w, req)
}
func (r *Router) handleWorkloadsSummaryCharts(w http.ResponseWriter, req *http.Request) {
r.ensureChartService().HandleWorkloadsSummaryCharts(w, req)
}
func (r *Router) handleStorageCharts(w http.ResponseWriter, req *http.Request) {
r.ensureChartService().HandleStorageCharts(w, req)
}
func (r *Router) handleStorageSummaryCharts(w http.ResponseWriter, req *http.Request) {
r.ensureChartService().HandleStorageSummaryCharts(w, req)
}
+3 -179
View File
@@ -2881,46 +2881,6 @@ func TestContract_StorageChartsUseCanonicalMetricsTargetIDs(t *testing.T) {
}
}
func TestContract_StorageSummaryChartsStayAggregateOnly(t *testing.T) {
monitor, state, metricsHistory := newTestMonitor(t)
now := time.Now().UTC().Truncate(time.Second)
state.Storage = []models.Storage{
{ID: "store-1", Name: "Store One"},
{ID: "store-2", Name: "Store Two"},
}
metricsHistory.AddStorageMetric("store-1", "used", 400, now)
metricsHistory.AddStorageMetric("store-1", "avail", 600, now)
metricsHistory.AddStorageMetric("store-1", "total", 1000, now)
metricsHistory.AddStorageMetric("store-2", "used", 100, now)
metricsHistory.AddStorageMetric("store-2", "avail", 900, now)
metricsHistory.AddStorageMetric("store-2", "total", 1000, now)
syncTestResourceStore(t, monitor, state)
router := &Router{monitor: monitor}
req := httptest.NewRequest(http.MethodGet, "/api/charts/storage-summary?range=24h", nil)
rec := httptest.NewRecorder()
router.handleStorageSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var decoded StorageSummaryTrendResponse
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("unmarshal storage summary response: %v", err)
}
if len(decoded.Capacity) != 1 {
t.Fatalf("expected one aggregate capacity point, got %+v", decoded.Capacity)
}
if decoded.Capacity[0].Value != 25 {
t.Fatalf("expected aggregate capacity of 25%%, got %+v", decoded.Capacity[0])
}
if decoded.Stats.PointCounts.Total != 1 || decoded.Stats.PointCounts.Storage != 1 {
t.Fatalf("expected aggregate-only point counts, got %+v", decoded.Stats.PointCounts)
}
}
func TestContract_TrueNASConnectionsDisabledMessageIsExplicit(t *testing.T) {
setTrueNASFeatureForTest(t, false)
handler, _, _ := newTrueNASHandlersForTest(t, nil)
@@ -3535,136 +3495,6 @@ func TestContract_WorkloadChartsUseCanonicalWorkloadIDsForProviderBackedVMs(t *t
if workloadDecoded.GuestTypes[resourceID] != "vm" {
t.Fatalf("expected vm guest type for %q, got %q", resourceID, workloadDecoded.GuestTypes[resourceID])
}
summaryReq := httptest.NewRequest(http.MethodGet, "/api/charts/workloads-summary?range=1h", nil)
summaryRec := httptest.NewRecorder()
router.handleWorkloadsSummaryCharts(summaryRec, summaryReq)
if summaryRec.Code != http.StatusOK {
t.Fatalf("expected workloads summary 200, got %d: %s", summaryRec.Code, summaryRec.Body.String())
}
var summaryDecoded WorkloadsSummaryChartsResponse
if err := json.Unmarshal(summaryRec.Body.Bytes(), &summaryDecoded); err != nil {
t.Fatalf("unmarshal workloads summary response: %v", err)
}
if summaryDecoded.GuestCounts.Total != 1 || summaryDecoded.GuestCounts.Running != 1 {
t.Fatalf("expected stable provider-backed guest counts, got %+v", summaryDecoded.GuestCounts)
}
if len(summaryDecoded.TopContributors.CPU) == 0 {
t.Fatal("expected provider-backed cpu top contributor")
}
if summaryDecoded.TopContributors.CPU[0].ID != resourceID {
t.Fatalf("expected workloads summary contributor id %q, got %+v", resourceID, summaryDecoded.TopContributors.CPU[0])
}
if summaryDecoded.TopContributors.CPU[0].ID == metricID {
t.Fatalf("expected workloads summary contributor id to avoid raw metrics target %q", metricID)
}
}
func TestContract_WorkloadsSummaryChartsNormalizeLongRangeMixedCadence(t *testing.T) {
store := newTestMetricsStore(t)
monitor, state, _ := newTestMonitor(t)
setTestUnexportedField(t, monitor, "metricsStore", store)
state.Nodes = []models.Node{{
ID: "node-contract-1",
Name: "node-contract-1",
Instance: "pve1",
Status: "online",
}}
state.VMs = []models.VM{{
ID: "vm-contract-1",
VMID: 101,
Name: "vm-contract-1",
Node: "node-contract-1",
Instance: "pve1",
Status: "running",
CPU: 0.75,
Memory: models.Memory{Usage: 42.0},
Disk: models.Disk{Usage: 55.0},
NetworkIn: 128,
NetworkOut: 256,
}}
syncTestResourceStore(t, monitor, state)
readState := monitor.GetUnifiedReadStateOrSnapshot()
vms := readState.VMs()
if len(vms) != 1 {
t.Fatalf("expected 1 vm view, got %d", len(vms))
}
sourceID := strings.TrimSpace(vms[0].SourceID())
if sourceID == "" {
t.Fatal("expected vm source ID")
}
now := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Minute)
samples := mixedCadenceLongRangeMetricSamples(now)
seed := make([]metrics.WriteMetric, 0, len(samples)*5)
appendMetric := func(ts time.Time, percentValue, rateValue float64) {
for _, metricType := range []string{"cpu", "memory", "disk"} {
seed = append(seed, metrics.WriteMetric{
ResourceType: "vm",
ResourceID: sourceID,
MetricType: metricType,
Value: percentValue,
Timestamp: ts,
Tier: metrics.TierMinute,
})
}
for _, metricType := range []string{"netin", "netout"} {
seed = append(seed, metrics.WriteMetric{
ResourceType: "vm",
ResourceID: sourceID,
MetricType: metricType,
Value: rateValue,
Timestamp: ts,
Tier: metrics.TierMinute,
})
}
}
for _, sample := range samples {
appendMetric(sample.timestamp, sample.percentValue, sample.rateValue)
}
store.WriteBatchSync(seed)
router := &Router{monitor: monitor}
req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads-summary?range=7d", nil)
rec := httptest.NewRecorder()
router.handleWorkloadsSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var decoded WorkloadsSummaryChartsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("unmarshal workloads summary charts response: %v", err)
}
if len(decoded.CPU.P50) == 0 {
t.Fatal("expected normalized workload summary p50 series")
}
if len(decoded.CPU.P50) > chartapi.WorkloadsSummaryMaxSeriesPoints {
t.Fatalf("expected workload summary p50 <= %d points, got %d", chartapi.WorkloadsSummaryMaxSeriesPoints, len(decoded.CPU.P50))
}
if len(decoded.CPU.P95) > chartapi.WorkloadsSummaryMaxSeriesPoints {
t.Fatalf("expected workload summary p95 <= %d points, got %d", chartapi.WorkloadsSummaryMaxSeriesPoints, len(decoded.CPU.P95))
}
if decoded.CPU.P50[len(decoded.CPU.P50)-1].Timestamp != now.UnixMilli() {
t.Fatalf("expected latest workload summary timestamp %d, got %d", now.UnixMilli(), decoded.CPU.P50[len(decoded.CPU.P50)-1].Timestamp)
}
recentWindowStart := now.Add(-24 * time.Hour).UnixMilli()
recentCount := 0
for _, point := range decoded.CPU.P50 {
if point.Timestamp >= recentWindowStart {
recentCount++
}
}
if recentCount > 20 {
t.Fatalf("expected day-proportional summary buckets, got %d recent p50 points", recentCount)
}
}
func TestContract_WorkloadChartMetricBudgetGuardrailsRemainCanonical(t *testing.T) {
@@ -3684,22 +3514,16 @@ func TestContract_WorkloadChartMetricBudgetGuardrailsRemainCanonical(t *testing.
`var workloadChartsBatchWG sync.WaitGroup`,
`workloadChartsBatchWG.Add(4)`,
`podBatchMetrics = monitor.GetGuestMetricsForChartBatch("k8s", podRequests, duration, workloadSummaryMetricOrder...)`,
`var workloadsSummaryBatchWG sync.WaitGroup`,
`workloadsSummaryBatchWG.Add(4)`,
`vmBatchMetrics = monitor.GetGuestMetricsForChartBatch("vm", vmRequests, duration, workloadSummaryMetricOrder...)`,
`containerBatchMetrics = monitor.GetGuestMetricsForChartBatch("container", containerRequests, duration, workloadSummaryMetricOrder...)`,
`dockerContainerBatchMetrics = monitor.GetGuestMetricsForChartBatch("dockerContainer", dockerContainerRequests, duration, workloadSummaryMetricOrder...)`,
`vmBatchMetrics = monitor.GetGuestMetricsForChartBatch("vm", vmRequests, duration, guestSparklineMetricOrder...)`,
`containerBatchMetrics = monitor.GetGuestMetricsForChartBatch("container", containerRequests, duration, guestSparklineMetricOrder...)`,
`dockerContainerBatchMetrics = monitor.GetGuestMetricsForChartBatch("dockerContainer", dockerContainerRequests, duration, infrastructureSummaryMetricOrder...)`,
`summaryChartsCacheTTL = 5 * time.Second`,
`infrastructureChartsCacheKey(req *http.Request, timeRange string, requestedMetricNames []string) string`,
`cachedInfrastructureChartsPayload`,
`cacheInfrastructureChartsPayload`,
`workloadsSummaryChartsCacheKey`,
`cachedWorkloadsSummaryChartsPayload`,
`cacheWorkloadsSummaryChartsPayload`,
`chartPayloadCacheMaxEntries = 64`,
`chartPayloadCacheMaxBytes = 16 << 20`,
`chartPayloads boundedChartPayloadCache`,
`type workloadSummaryMetricBucket struct`,
}
for _, snippet := range requiredSnippets {
if !strings.Contains(source, snippet) {
@@ -2,20 +2,18 @@ package api
import (
"context"
"math"
"reflect"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/api/chartapi"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
)
// This file exercises four previously-uncovered pure builders/mutators in the
// This file exercises three previously-uncovered pure builders/mutators in the
// api package. Every test function is prefixed with TestBranchcov0724pm so the
// run can be scoped with -run "^TestBranchcov0724pm".
//
@@ -23,7 +21,6 @@ import (
// - restoreAgentExecMetadata (agent_exec_token_binding.go)
// - buildAlertConnectionSnapshotsWithRuntimeSources (connections_alerts.go)
// - mockProtectionPostures (recovery_handlers.go)
// - chartapi.BuildMockWorkloadMetricHistorySeries (router.go)
// ---------------------------------------------------------------------------
// restoreAgentExecMetadata
@@ -393,77 +390,3 @@ func TestBranchcov0724pmMockProtectionPostures(t *testing.T) {
}
})
}
// ---------------------------------------------------------------------------
// chartapi.BuildMockWorkloadMetricHistorySeries
// ---------------------------------------------------------------------------
func TestBranchcov0724pmBuildMockWorkloadMetricHistorySeries(t *testing.T) {
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
t.Run("unsupported_metric_type_returns_nil", func(t *testing.T) {
for _, metricType := range []string{"smart_temp", "bogus", ""} {
got := chartapi.BuildMockWorkloadMetricHistorySeries(now, time.Hour, 100, "vm", "res-1", metricType, 50.0)
if got != nil {
t.Fatalf("metricType %q: expected nil, got %d points", metricType, len(got))
}
}
})
t.Run("supported_metric_types_produce_well_formed_series", func(t *testing.T) {
supported := []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout"}
duration := 2 * time.Hour
maxPoints := 0
expectedLen := chartapi.TargetMockSeriesPoints(duration, maxPoints)
for _, metricType := range supported {
t.Run(metricType, func(t *testing.T) {
got := chartapi.BuildMockWorkloadMetricHistorySeries(now, duration, maxPoints, "vm", "res-1", metricType, 50.0)
if len(got) != expectedLen {
t.Fatalf("expected %d points, got %d", expectedLen, len(got))
}
wantStart := now.Add(-duration)
if !got[0].Timestamp.Equal(wantStart) {
t.Fatalf("first timestamp = %v, want %v", got[0].Timestamp, wantStart)
}
if got[len(got)-1].Timestamp.After(now) {
t.Fatalf("last timestamp %v exceeds now %v", got[len(got)-1].Timestamp, now)
}
for i := 0; i < len(got); i++ {
if math.IsNaN(got[i].Value) || math.IsInf(got[i].Value, 0) {
t.Fatalf("value at index %d is not finite: %v", i, got[i].Value)
}
if i > 0 && !got[i].Timestamp.After(got[i-1].Timestamp) {
t.Fatalf("timestamps not strictly ascending at index %d: %v <= %v",
i, got[i].Timestamp, got[i-1].Timestamp)
}
}
})
}
})
t.Run("max_points_caps_series_length", func(t *testing.T) {
duration := 24 * time.Hour
maxPoints := 50
got := chartapi.BuildMockWorkloadMetricHistorySeries(now, duration, maxPoints, "vm", "res-1", "cpu", 50.0)
if len(got) != maxPoints {
t.Fatalf("expected %d points (capped by maxPoints), got %d", maxPoints, len(got))
}
})
t.Run("deterministic_for_same_inputs", func(t *testing.T) {
first := chartapi.BuildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-1", "cpu", 50.0)
second := chartapi.BuildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-1", "cpu", 50.0)
if !reflect.DeepEqual(first, second) {
t.Fatalf("series not deterministic for identical inputs")
}
})
t.Run("different_resource_ids_produce_different_series", func(t *testing.T) {
a := chartapi.BuildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-1", "cpu", 50.0)
b := chartapi.BuildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-2", "cpu", 50.0)
if reflect.DeepEqual(a, b) {
t.Fatalf("expected different series for different resource IDs")
}
})
}
-2
View File
@@ -405,8 +405,6 @@ var allRouteAllowlist = []string{
"/api/charts",
"/api/charts/workloads",
"/api/charts/infrastructure",
"/api/charts/storage-summary",
"/api/charts/workloads-summary",
"/api/metrics-store/stats",
"/api/metrics-store/history",
"/api/availability-history",
-159
View File
@@ -456,165 +456,6 @@ func BenchmarkHandleWorkloadCharts_StoreBacked(b *testing.B) {
})
}
// BenchmarkHandleWorkloadsSummaryCharts_StoreBacked measures the workloads
// summary endpoint under a store-backed 4h range. This path should also avoid
// per-workload N+1 queries while computing aggregate p50/p95 sparklines.
func BenchmarkHandleWorkloadsSummaryCharts_StoreBacked(b *testing.B) {
suppressBenchLogs(b)
store := newBenchMetricsStore(b)
monitor := &monitoring.Monitor{}
state := models.NewState()
setBenchUnexportedField(b, monitor, "state", state)
setBenchUnexportedField(b, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour))
setBenchUnexportedField(b, monitor, "metricsStore", store)
const (
vmCount = 30
containerCount = 20
dockerHostCount = 10
containersPerHost = 2
pointsPerMetric = 240
)
base := time.Now().Add(-4 * time.Hour).UTC().Truncate(time.Second)
nodes := make([]models.Node, 5)
for i := range nodes {
nodes[i] = models.Node{
ID: fmt.Sprintf("node-summary-bench-%d", i),
Name: fmt.Sprintf("node-%d", i),
Instance: "pve1",
Status: "online",
}
}
state.UpdateNodesForInstance("pve1", nodes)
vms := make([]models.VM, vmCount)
vmIDs := make([]string, vmCount)
for i := range vms {
vmIDs[i] = fmt.Sprintf("vm-summary-bench-%d", i)
vms[i] = models.VM{
ID: vmIDs[i],
VMID: 100 + i,
Name: fmt.Sprintf("vm-%d", i),
Node: nodes[i%len(nodes)].Name,
Instance: "pve1",
Status: "running",
Type: "qemu",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 4 << 30, Used: 2 << 30},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 50 << 30, Used: 25 << 30},
}
}
state.UpdateVMsForInstance("pve1", vms)
containers := make([]models.Container, containerCount)
containerIDs := make([]string, containerCount)
for i := range containers {
containerIDs[i] = fmt.Sprintf("ct-summary-bench-%d", i)
containers[i] = models.Container{
ID: containerIDs[i],
VMID: 200 + i,
Name: fmt.Sprintf("ct-%d", i),
Node: nodes[i%len(nodes)].Name,
Instance: "pve1",
Status: "running",
Type: "lxc",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 2 << 30, Used: 1 << 30},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 20 << 30, Used: 10 << 30},
}
}
state.UpdateContainersForInstance("pve1", containers)
dockerHosts := make([]models.DockerHost, dockerHostCount)
dockerContainerIDs := make([]string, 0, dockerHostCount*containersPerHost)
for i := range dockerHosts {
hostID := fmt.Sprintf("docker-host-summary-bench-%d", i)
hostContainers := make([]models.DockerContainer, containersPerHost)
for j := range hostContainers {
containerID := fmt.Sprintf("docker-container-summary-bench-%d-%d", i, j)
dockerContainerIDs = append(dockerContainerIDs, containerID)
hostContainers[j] = models.DockerContainer{
ID: containerID,
Name: fmt.Sprintf("docker-%d-%d", i, j),
State: "running",
Status: "running",
CPUPercent: float64((i+j)%80 + 10),
MemoryPercent: float64((i+j)%60 + 20),
NetInRate: float64((i+j)%50 + 5),
NetOutRate: float64((i+j)%50 + 7),
}
}
dockerHosts[i] = models.DockerHost{
ID: hostID,
AgentID: hostID,
Hostname: nodes[i%len(nodes)].Name,
Runtime: "docker",
Status: "online",
CPUUsage: float64(i%80 + 10),
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 32 << 30, Used: 16 << 30},
Disks: []models.Disk{{Usage: float64(i%40 + 30), Total: 200 << 30, Used: 100 << 30}},
Containers: hostContainers,
}
}
state.DockerHosts = dockerHosts
syncBenchResourceStore(b, monitor, state)
seedSeries := func(resourceType string, ids []string, metricTypes []string) {
batch := make([]metrics.WriteMetric, 0, len(ids)*len(metricTypes)*pointsPerMetric)
for r, id := range ids {
for _, mt := range metricTypes {
for p := 0; p < pointsPerMetric; p++ {
batch = append(batch, metrics.WriteMetric{
ResourceType: resourceType,
ResourceID: id,
MetricType: mt,
Value: float64((r + p) % 100),
Timestamp: base.Add(time.Duration(p) * time.Minute),
Tier: metrics.TierMinute,
})
}
}
}
store.WriteBatchSync(batch)
}
seedSeries("vm", vmIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
seedSeries("container", containerIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
seedSeries("dockerContainer", dockerContainerIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
router := &Router{monitor: monitor}
url := "/api/charts/workloads-summary?range=4h"
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
router.handleWorkloadsSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("sanity check failed: expected 200, got %d; body: %s", rec.Code, rec.Body.String())
}
var check WorkloadsSummaryChartsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &check); err != nil {
b.Fatalf("sanity check: unmarshal failed: %v", err)
}
if check.GuestCounts.Total != vmCount+containerCount+len(dockerContainerIDs) {
b.Fatalf("expected %d total guests, got %d", vmCount+containerCount+len(dockerContainerIDs), check.GuestCounts.Total)
}
// Seed once per run, as BenchmarkHandleWorkloadCharts_StoreBacked does.
b.Run("range=4h", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
router.handleWorkloadsSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
})
}
// BenchmarkHandleMetricsStoreStats measures latency of the /api/metrics-store/stats
// endpoint, which performs lightweight SQLite stat queries.
func BenchmarkHandleMetricsStoreStats(b *testing.B) {
-2
View File
@@ -21,8 +21,6 @@ func (r *Router) registerMonitoringResourceRoutes(
r.mux.HandleFunc("/api/charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleCharts)))
r.mux.HandleFunc("/api/charts/workloads", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleWorkloadCharts)))
r.mux.HandleFunc("/api/charts/infrastructure", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleInfrastructureCharts)))
r.mux.HandleFunc("/api/charts/storage-summary", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorageSummaryCharts)))
r.mux.HandleFunc("/api/charts/workloads-summary", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleWorkloadsSummaryCharts)))
r.mux.HandleFunc("/api/metrics-store/stats", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleMetricsStoreStats)))
r.mux.HandleFunc("/api/metrics-store/history", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleMetricsHistory)))
r.mux.HandleFunc("/api/availability-history", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleAvailabilityHistory)))
-1
View File
@@ -3332,7 +3332,6 @@ func TestMonitoringReadEndpointsRequireMonitoringReadScope(t *testing.T) {
"/api/storage-charts",
"/api/charts",
"/api/charts/workloads",
"/api/charts/storage-summary",
"/api/metrics-store/stats",
"/api/metrics-store/history",
"/api/availability-history",
-7
View File
@@ -47,11 +47,4 @@ const (
// with a store-backed 4h window across VMs, system containers, and docker
// containers. This is the workloads summary sparkline hot path.
SLOWorkloadChartsP95 = 90 * time.Millisecond
// SLOWorkloadsSummaryChartsP95 is the p95 target for
// GET /api/charts/workloads-summary with a store-backed 4h window across
// VMs, system containers, Kubernetes pods, and docker containers. This
// aggregate endpoint is latency-sensitive because it powers the top-card
// workload sparklines and blast-radius summaries without per-workload output.
SLOWorkloadsSummaryChartsP95 = 90 * time.Millisecond
)
+1 -204
View File
@@ -41,8 +41,7 @@ const (
// charts. A serial local run on April 11, 2026 measured ~82.2ms p95, while
// the governed RC rehearsal on the same day hit ~514.0ms p95. Keep the local
// SLO strict and align the GitHub Actions ceiling to the current envelope.
sloWorkloadChartsGitHubActionsP95 = 550 * time.Millisecond
sloWorkloadsSummaryChartsGitHubActionsP95 = sloWorkloadChartsGitHubActionsP95
sloWorkloadChartsGitHubActionsP95 = 550 * time.Millisecond
)
// suppressTestLogs is kept as a call-site marker for noisy integration-style
@@ -454,20 +453,6 @@ func TestSLO_InfrastructureCharts(t *testing.T) {
if sanityResp.Stats.PrimarySourceHint != "store_or_memory_fallback" {
t.Fatalf("sanity: expected store-backed source hint, got %q", sanityResp.Stats.PrimarySourceHint)
}
if SLOWorkloadsSummaryChartsP95 != SLOWorkloadChartsP95 {
t.Fatalf(
"sanity: workloads-summary SLO=%v, want alignment with workload charts SLO=%v",
SLOWorkloadsSummaryChartsP95,
SLOWorkloadChartsP95,
)
}
if sloWorkloadsSummaryChartsGitHubActionsP95 != sloWorkloadChartsGitHubActionsP95 {
t.Fatalf(
"sanity: workloads-summary GitHub Actions SLO=%v, want alignment with workload charts GitHub Actions SLO=%v",
sloWorkloadsSummaryChartsGitHubActionsP95,
sloWorkloadChartsGitHubActionsP95,
)
}
latencies := measureEndpointLatencies(t, func() {
req := httptest.NewRequest(http.MethodGet, url, nil)
@@ -647,194 +632,6 @@ func TestSLO_WorkloadCharts(t *testing.T) {
assertLatencySLO(t, "charts/workloads", latencies, target)
}
// TestSLO_WorkloadsSummaryCharts validates the aggregate workload summary
// endpoint that powers top-card sparklines and blast-radius summaries. The
// workload forces the store-backed batch path across VMs, system containers,
// Kubernetes pods, and docker containers.
func TestSLO_WorkloadsSummaryCharts(t *testing.T) {
skipUnderRace(t)
suppressTestLogs(t)
store := newTestMetricsStore(t)
const (
vmCount = 30
containerCount = 20
podCount = 10
dockerHostCount = 10
containersPerHost = 2
pointsPerMetric = 240
)
base := time.Now().Add(-4 * time.Hour).UTC().Truncate(time.Second)
seedBatchMetrics := func(resourceType string, ids []string, metricTypes []string) {
batch := make([]metrics.WriteMetric, 0, len(ids)*len(metricTypes)*pointsPerMetric)
for idx, id := range ids {
for _, mt := range metricTypes {
for p := 0; p < pointsPerMetric; p++ {
batch = append(batch, metrics.WriteMetric{
ResourceType: resourceType,
ResourceID: id,
MetricType: mt,
Value: float64((idx + p) % 100),
Timestamp: base.Add(time.Duration(p) * time.Minute),
Tier: metrics.TierMinute,
})
}
}
}
store.WriteBatchSync(batch)
}
monitor := &monitoring.Monitor{}
state := models.NewState()
setTestUnexportedField(t, monitor, "state", state)
setTestUnexportedField(t, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour))
setTestUnexportedField(t, monitor, "metricsStore", store)
nodes := make([]models.Node, 5)
for i := range nodes {
nodes[i] = models.Node{
ID: fmt.Sprintf("node-summary-slo-%d", i),
Name: fmt.Sprintf("node-%d", i),
Instance: "pve1",
Status: "online",
}
}
state.UpdateNodesForInstance("pve1", nodes)
vms := make([]models.VM, vmCount)
vmIDs := make([]string, vmCount)
for i := range vms {
vmIDs[i] = fmt.Sprintf("vm-summary-slo-%d", i)
vms[i] = models.VM{
ID: vmIDs[i],
VMID: 100 + i,
Name: fmt.Sprintf("vm-%d", i),
Node: nodes[i%len(nodes)].Name,
Instance: "pve1",
Status: "running",
Type: "qemu",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 4 << 30, Used: 2 << 30},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 50 << 30, Used: 25 << 30},
}
}
state.UpdateVMsForInstance("pve1", vms)
containers := make([]models.Container, containerCount)
containerIDs := make([]string, containerCount)
for i := range containers {
containerIDs[i] = fmt.Sprintf("ct-summary-slo-%d", i)
containers[i] = models.Container{
ID: containerIDs[i],
VMID: 200 + i,
Name: fmt.Sprintf("ct-%d", i),
Node: nodes[i%len(nodes)].Name,
Instance: "pve1",
Status: "running",
Type: "lxc",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 2 << 30, Used: 1 << 30},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 20 << 30, Used: 10 << 30},
}
}
state.UpdateContainersForInstance("pve1", containers)
clusters := []models.KubernetesCluster{{
ID: "k8s-summary-slo",
Name: "k8s-summary-slo",
Pods: make([]models.KubernetesPod, podCount),
}}
podIDs := make([]string, podCount)
for i := 0; i < podCount; i++ {
podIDs[i] = fmt.Sprintf("pod:%s:%s", "default", fmt.Sprintf("pod-%d", i))
clusters[0].Pods[i] = models.KubernetesPod{
UID: fmt.Sprintf("pod-summary-slo-%d", i),
Name: fmt.Sprintf("pod-%d", i),
Namespace: "default",
NodeName: nodes[i%len(nodes)].Name,
Phase: "Running",
UsageCPUPercent: float64((i % 80) + 10),
UsageMemoryPercent: float64((i % 60) + 20),
DiskUsagePercent: float64((i % 40) + 30),
NetInRate: float64((i % 50) + 5),
NetOutRate: float64((i % 50) + 7),
}
}
state.KubernetesClusters = clusters
dockerHosts := make([]models.DockerHost, dockerHostCount)
dockerContainerIDs := make([]string, 0, dockerHostCount*containersPerHost)
for i := range dockerHosts {
hostID := fmt.Sprintf("docker-host-summary-slo-%d", i)
hostContainers := make([]models.DockerContainer, containersPerHost)
for j := range hostContainers {
containerID := fmt.Sprintf("docker-container-summary-slo-%d-%d", i, j)
dockerContainerIDs = append(dockerContainerIDs, containerID)
hostContainers[j] = models.DockerContainer{
ID: containerID,
Name: fmt.Sprintf("docker-%d-%d", i, j),
State: "running",
Status: "running",
CPUPercent: float64((i+j)%80 + 10),
MemoryPercent: float64((i+j)%60 + 20),
NetInRate: float64((i+j)%50 + 5),
NetOutRate: float64((i+j)%50 + 7),
}
}
dockerHosts[i] = models.DockerHost{
ID: hostID,
AgentID: hostID,
Hostname: nodes[i%len(nodes)].Name,
Runtime: "docker",
Status: "online",
CPUUsage: float64(i%80 + 10),
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 32 << 30, Used: 16 << 30},
Disks: []models.Disk{{Usage: float64(i%40 + 30), Total: 200 << 30, Used: 100 << 30}},
Containers: hostContainers,
}
}
state.DockerHosts = dockerHosts
syncTestResourceStore(t, monitor, state)
seedBatchMetrics("vm", vmIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
seedBatchMetrics("container", containerIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
seedBatchMetrics("k8s", podIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
seedBatchMetrics("dockerContainer", dockerContainerIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
router := &Router{monitor: monitor}
url := "/api/charts/workloads-summary?range=4h"
sanityReq := httptest.NewRequest(http.MethodGet, url, nil)
sanityRec := httptest.NewRecorder()
router.handleWorkloadsSummaryCharts(sanityRec, sanityReq)
if sanityRec.Code != http.StatusOK {
t.Fatalf("sanity check failed: status %d body=%s", sanityRec.Code, sanityRec.Body.String())
}
var sanityResp WorkloadsSummaryChartsResponse
if err := json.Unmarshal(sanityRec.Body.Bytes(), &sanityResp); err != nil {
t.Fatalf("sanity unmarshal: %v", err)
}
if sanityResp.GuestCounts.Total != vmCount+containerCount+podCount+len(dockerContainerIDs) {
t.Fatalf("sanity: expected %d guests, got %d", vmCount+containerCount+podCount+len(dockerContainerIDs), sanityResp.GuestCounts.Total)
}
if sanityResp.Stats.PrimarySourceHint != "store_or_memory_fallback" {
t.Fatalf("sanity: expected store-backed source hint, got %q", sanityResp.Stats.PrimarySourceHint)
}
latencies := measureEndpointLatencies(t, func() {
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
router.handleWorkloadsSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status %d", rec.Code)
}
})
target := effectiveAPISLOTarget(SLOWorkloadsSummaryChartsP95, sloWorkloadsSummaryChartsGitHubActionsP95)
assertLatencySLO(t, "charts/workloads-summary", latencies, target)
}
// --- Test helpers ---
// skipUnderRace skips the test when the race detector is enabled, since the
-14
View File
@@ -47,14 +47,6 @@ type VersionResponse struct {
type ChartResponse = chartapi.ChartResponse
type InfrastructureChartsResponse = chartapi.InfrastructureChartsResponse
type WorkloadChartsResponse = chartapi.WorkloadChartsResponse
type WorkloadsSummaryMetricData = chartapi.WorkloadsSummaryMetricData
type WorkloadsGuestCounts = chartapi.WorkloadsGuestCounts
type WorkloadsSummaryContributor = chartapi.WorkloadsSummaryContributor
type WorkloadsSummaryContributors = chartapi.WorkloadsSummaryContributors
type WorkloadsSummaryBlastRadius = chartapi.WorkloadsSummaryBlastRadius
type WorkloadsSummaryBlastRadiusGroup = chartapi.WorkloadsSummaryBlastRadiusGroup
type WorkloadsSummaryChartsResponse = chartapi.WorkloadsSummaryChartsResponse
type StorageSummaryTrendResponse = chartapi.StorageSummaryTrendResponse
type ChartStats = chartapi.ChartStats
type ChartPointCounts = chartapi.ChartPointCounts
type VMChartData = chartapi.VMChartData
@@ -72,12 +64,6 @@ func EmptyInfrastructureChartsResponse() InfrastructureChartsResponse {
func EmptyWorkloadChartsResponse() WorkloadChartsResponse {
return chartapi.EmptyWorkloadChartsResponse()
}
func EmptyWorkloadsSummaryChartsResponse() WorkloadsSummaryChartsResponse {
return chartapi.EmptyWorkloadsSummaryChartsResponse()
}
func EmptyStorageSummaryTrendResponse() StorageSummaryTrendResponse {
return chartapi.EmptyStorageSummaryTrendResponse()
}
func EmptyStorageChartsResponse() StorageChartsResponse { return chartapi.EmptyStorageChartsResponse() }
// AgentVersionResponse represents Docker / Podman module version information.
+3 -36
View File
@@ -48,42 +48,8 @@ func TestChartResponsesUseCanonicalEmptyCollections(t *testing.T) {
}
}
func TestWorkloadsSummaryAndStorageChartsNormalizeNestedCollections(t *testing.T) {
payload, err := json.Marshal(EmptyWorkloadsSummaryChartsResponse())
if err != nil {
t.Fatalf("marshal empty workloads summary: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode empty workloads summary: %v", err)
}
for _, key := range []string{"cpu", "memory", "disk", "network"} {
metric, ok := decoded[key].(map[string]any)
if !ok {
t.Fatalf("expected %s metric object, got %T", key, decoded[key])
}
for _, subkey := range []string{"p50", "p95"} {
values, ok := metric[subkey].([]any)
if !ok || len(values) != 0 {
t.Fatalf("expected %s.%s to be an empty array, got %T (%v)", key, subkey, metric[subkey], metric[subkey])
}
}
}
top, ok := decoded["topContributors"].(map[string]any)
if !ok {
t.Fatalf("expected topContributors object, got %T", decoded["topContributors"])
}
for _, key := range []string{"cpu", "memory", "disk", "network"} {
values, ok := top[key].([]any)
if !ok || len(values) != 0 {
t.Fatalf("expected topContributors.%s to be an empty array, got %T (%v)", key, top[key], top[key])
}
}
payload, err = json.Marshal(StorageChartsResponse{
func TestStorageChartsNormalizeNestedCollections(t *testing.T) {
payload, err := json.Marshal(StorageChartsResponse{
Pools: map[string]StoragePoolChartData{
"pool1": {Name: "pool1"},
},
@@ -95,6 +61,7 @@ func TestWorkloadsSummaryAndStorageChartsNormalizeNestedCollections(t *testing.T
t.Fatalf("marshal normalized storage charts: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode normalized storage charts: %v", err)
}
@@ -2160,7 +2160,6 @@ func TestMockNativePollersDeferToCanonicalMockSampler(t *testing.T) {
"if mock.IsMockEnabled() {",
"return nil",
"func (m *Monitor) prewarmMockDashboardChartCaches() {",
"_, _ = m.mockStorageSummaryCapacityTrendCached(24 * time.Hour)",
},
},
{
@@ -2169,7 +2168,6 @@ func TestMockNativePollersDeferToCanonicalMockSampler(t *testing.T) {
"func mockCanonicalMetricSeries(resourceType, resourceID, metricType string, timestamps []time.Time) []MetricPoint {",
"values := canonicalMetricSeries(resourceType, resourceID, metricType, timestamps)",
"return lttb(points, chartDownsampleTarget)",
"func (m *Monitor) mockStorageSummaryCapacityTrend(duration time.Duration) []MetricPoint {",
`usageValues := canonicalMetricSeries("storage", storageID, "usage", timestamps)`,
},
},
-60
View File
@@ -224,63 +224,3 @@ func (m *Monitor) mockStorageMetricsForChart(storageID string, duration time.Dur
"total": lttb(totalPoints, chartDownsampleTarget),
}
}
func (m *Monitor) mockStorageSummaryCapacityTrend(duration time.Duration) []MetricPoint {
if m == nil {
return nil
}
readState := m.GetUnifiedReadStateOrSnapshot()
if readState == nil {
return nil
}
timestamps := mockChartTimestamps(duration)
if len(timestamps) == 0 {
return nil
}
usedTotals := make([]float64, len(timestamps))
capacityTotals := make([]float64, len(timestamps))
for _, pool := range readState.StoragePools() {
if pool == nil {
continue
}
storageID := strings.TrimSpace(pool.SourceID())
if storageID == "" {
continue
}
total := m.resolveMockStorageTotal(storageID, nil)
if total <= 0 {
continue
}
usageValues := canonicalMetricSeries("storage", storageID, "usage", timestamps)
if len(usageValues) != len(timestamps) {
continue
}
for i, usage := range usageValues {
usedTotals[i] += total * (clampFloat(usage, 0, 100) / 100.0)
capacityTotals[i] += total
}
}
points := make([]MetricPoint, 0, len(timestamps))
for i, ts := range timestamps {
total := capacityTotals[i]
if math.IsNaN(total) || math.IsInf(total, 0) || total <= 0 {
continue
}
points = append(points, MetricPoint{
Timestamp: ts,
Value: (usedTotals[i] / total) * 100,
})
}
return lttb(points, chartDownsampleTarget)
}
@@ -1048,15 +1048,6 @@ func TestStartAndStopMockMetricsSampler_ClearStaleMockChartCaches(t *testing.T)
}]; ok {
t.Fatalf("expected mock sampler start to clear stale guest chart caches, got %+v", monitor.mockChartMapCache)
}
summaryKey := mockChartMetricMapCacheKey{
kind: "storage-summary",
resourceType: "storage",
resourceID: "__aggregate__",
duration: 24 * time.Hour,
}
if _, ok := monitor.mockChartMapCache[summaryKey]; !ok {
t.Fatalf("expected mock sampler start to prewarm storage summary cache, got %+v", monitor.mockChartMapCache)
}
monitor.mockChartMapCache[mockChartMetricMapCacheKey{
kind: "storage",
-219
View File
@@ -1,7 +1,6 @@
package monitoring
import (
"math"
"sort"
"strings"
"time"
@@ -56,7 +55,6 @@ const (
)
var storageChartMetricTypes = []string{"usage", "used", "avail", "total"}
var storageSummaryChartMetricTypes = []string{"used", "avail"}
type mockChartMetricMapCacheKey struct {
kind string
@@ -787,107 +785,6 @@ func (m *Monitor) GetStorageMetricsForChartBatch(
return result
}
// GetStorageCapacityMetricsForSummaryBatch returns only the canonical capacity
// metrics required for the compact dashboard storage summary card.
func (m *Monitor) GetStorageCapacityMetricsForSummaryBatch(
storageIDs []string,
duration time.Duration,
) map[string]map[string][]MetricPoint {
if m == nil || len(storageIDs) == 0 {
return nil
}
result := make(map[string]map[string][]MetricPoint, len(storageIDs))
if mock.IsMockEnabled() {
for _, sid := range storageIDs {
inMemory := map[string][]MetricPoint{}
if m.metricsHistory != nil {
inMemory = filterMetricPointMap(
m.metricsHistory.GetAllStorageMetrics(sid, duration),
storageSummaryChartMetricTypes,
)
}
fullMetrics := m.mockStorageMetricsForChartCached(sid, duration, inMemory)
result[sid] = filterMetricPointMap(fullMetrics, storageSummaryChartMetricTypes)
}
return result
}
var needStore []string
for _, sid := range storageIDs {
inMemory := map[string][]MetricPoint{}
if m.metricsHistory != nil {
inMemory = filterMetricPointMap(
m.metricsHistory.GetAllStorageMetrics(sid, duration),
storageSummaryChartMetricTypes,
)
}
if m.metricsStore == nil {
result[sid] = inMemory
continue
}
if hasSufficientChartMapCoverageForMetrics(inMemory, duration, storageSummaryChartMetricTypes) {
result[sid] = inMemory
continue
}
needStore = append(needStore, sid)
result[sid] = inMemory
}
if len(needStore) == 0 {
return result
}
batchResult := m.queryStoreBatchMetricMapWithGapFill(
"storage",
needStore,
duration,
storageSummaryChartMetricTypes,
)
for _, sid := range needStore {
storeData, ok := batchResult[sid]
if !ok {
continue
}
result[sid] = mergeMetricHistory(result[sid], storeData, duration)
}
return result
}
func (m *Monitor) GetStorageSummaryCapacityTrend(duration time.Duration) ([]MetricPoint, int64) {
if m == nil {
return nil, 0
}
if mock.IsMockEnabled() {
return m.mockStorageSummaryCapacityTrendCached(duration)
}
readState := m.GetUnifiedReadStateOrSnapshot()
if readState == nil {
return nil, 0
}
storageIDs := make([]string, 0, len(readState.StoragePools()))
for _, pool := range readState.StoragePools() {
if pool == nil {
continue
}
storageID := strings.TrimSpace(pool.SourceID())
if storageID == "" {
continue
}
storageIDs = append(storageIDs, storageID)
}
return buildStorageSummaryCapacityTrend(
m.GetStorageCapacityMetricsForSummaryBatch(storageIDs, duration),
)
}
func (m *Monitor) nativeGuestMetricHistory(resourceType string, duration time.Duration) map[string]map[string][]MetricPoint {
providers := m.supplementalProviderSnapshot()
if len(providers) == 0 {
@@ -1311,7 +1208,6 @@ func (m *Monitor) prewarmMockDashboardChartCaches() {
return
}
_, _ = m.mockStorageSummaryCapacityTrendCached(24 * time.Hour)
m.prewarmMockWorkloadChartCaches(mockDashboardWorkloadPrewarmDuration)
}
@@ -1444,24 +1340,6 @@ func (m *Monitor) mockStorageMetricsForChartCached(
return m.writeMockChartMetricMapCache(cacheKey, computed)
}
func (m *Monitor) mockStorageSummaryCapacityTrendCached(duration time.Duration) ([]MetricPoint, int64) {
cacheKey := mockChartMetricMapCacheKey{
kind: "storage-summary",
resourceType: "storage",
resourceID: "__aggregate__",
duration: duration,
}
if cached, ok := m.readMockChartMetricMapCache(cacheKey); ok {
return cached["capacity"], oldestMetricSeriesTimestamp(cached["capacity"])
}
computed := m.mockStorageSummaryCapacityTrend(duration)
cached := m.writeMockChartMetricMapCache(cacheKey, map[string][]MetricPoint{
"capacity": computed,
})
return cached["capacity"], oldestMetricSeriesTimestamp(cached["capacity"])
}
func mergeGuestMetricHistory(base, candidate map[string][]MetricPoint, duration time.Duration) map[string][]MetricPoint {
return mergeMetricHistory(base, candidate, duration)
}
@@ -1489,103 +1367,6 @@ func mergeMetricHistory(base, candidate map[string][]MetricPoint, duration time.
return base
}
func filterMetricPointMap(metricMap map[string][]MetricPoint, metricTypes []string) map[string][]MetricPoint {
if len(metricTypes) == 0 {
return cloneMetricPointMap(metricMap)
}
filtered := make(map[string][]MetricPoint, len(metricTypes))
for _, metricType := range metricTypes {
if len(metricMap[metricType]) == 0 {
continue
}
filtered[metricType] = cloneMetricSeries(metricMap[metricType])
}
return filtered
}
func buildStorageSummaryCapacityTrend(
poolMetrics map[string]map[string][]MetricPoint,
) ([]MetricPoint, int64) {
type aggregateBucket struct {
used float64
avail float64
hasUsed bool
hasAvail bool
}
buckets := make(map[int64]*aggregateBucket)
var oldestTimestamp int64
for _, metrics := range poolMetrics {
for _, point := range metrics["used"] {
timestamp := point.Timestamp.UnixMilli()
bucket := buckets[timestamp]
if bucket == nil {
bucket = &aggregateBucket{}
buckets[timestamp] = bucket
}
bucket.used += point.Value
bucket.hasUsed = true
if oldestTimestamp == 0 || timestamp < oldestTimestamp {
oldestTimestamp = timestamp
}
}
for _, point := range metrics["avail"] {
timestamp := point.Timestamp.UnixMilli()
bucket := buckets[timestamp]
if bucket == nil {
bucket = &aggregateBucket{}
buckets[timestamp] = bucket
}
bucket.avail += point.Value
bucket.hasAvail = true
if oldestTimestamp == 0 || timestamp < oldestTimestamp {
oldestTimestamp = timestamp
}
}
}
if len(buckets) == 0 {
return nil, oldestTimestamp
}
timestamps := make([]int64, 0, len(buckets))
for timestamp := range buckets {
timestamps = append(timestamps, timestamp)
}
sort.Slice(timestamps, func(i, j int) bool {
return timestamps[i] < timestamps[j]
})
out := make([]MetricPoint, 0, len(timestamps))
for _, timestamp := range timestamps {
bucket := buckets[timestamp]
if bucket == nil || !bucket.hasUsed || !bucket.hasAvail {
continue
}
total := bucket.used + bucket.avail
if math.IsNaN(total) || math.IsInf(total, 0) || total <= 0 {
continue
}
out = append(out, MetricPoint{
Timestamp: time.UnixMilli(timestamp),
Value: (bucket.used / total) * 100,
})
}
return out, oldestTimestamp
}
func oldestMetricSeriesTimestamp(points []MetricPoint) int64 {
var oldest int64
for _, point := range points {
timestamp := point.Timestamp.UnixMilli()
if oldest == 0 || timestamp < oldest {
oldest = timestamp
}
}
return oldest
}
func shouldPreferMetricSeries(current, candidate []MetricPoint, duration time.Duration) bool {
if len(candidate) == 0 {
return false
@@ -546,75 +546,6 @@ func TestGetGuestMetricsForChartBatch_FiltersStoreReadsToRequestedMetricTypes(t
}
}
func TestGetStorageCapacityMetricsForSummaryBatch_FiltersStoreReadsToCapacitySeries(t *testing.T) {
suppressMonitoringTestLogs(t)
monitor := newChartBatchSLOMonitor(t)
now := time.Now().UTC().Truncate(time.Second)
duration := 24 * time.Hour
monitor.metricsStore.WriteBatchSync([]metrics.WriteMetric{
{ResourceType: "storage", ResourceID: "storage-summary-1", MetricType: "used", Value: 400, Timestamp: now.Add(-2 * time.Hour), Tier: metrics.TierMinute},
{ResourceType: "storage", ResourceID: "storage-summary-1", MetricType: "avail", Value: 600, Timestamp: now.Add(-2 * time.Hour), Tier: metrics.TierMinute},
{ResourceType: "storage", ResourceID: "storage-summary-1", MetricType: "usage", Value: 40, Timestamp: now.Add(-2 * time.Hour), Tier: metrics.TierMinute},
{ResourceType: "storage", ResourceID: "storage-summary-1", MetricType: "total", Value: 1000, Timestamp: now.Add(-2 * time.Hour), Tier: metrics.TierMinute},
})
result := monitor.GetStorageCapacityMetricsForSummaryBatch([]string{"storage-summary-1"}, duration)
if got := len(result["storage-summary-1"]["used"]); got != 1 {
t.Fatalf("expected used series, got %+v", result["storage-summary-1"])
}
if got := len(result["storage-summary-1"]["avail"]); got != 1 {
t.Fatalf("expected avail series, got %+v", result["storage-summary-1"])
}
if _, ok := result["storage-summary-1"]["usage"]; ok {
t.Fatalf("expected compact summary batch to omit usage, got %+v", result["storage-summary-1"])
}
if _, ok := result["storage-summary-1"]["total"]; ok {
t.Fatalf("expected compact summary batch to omit total, got %+v", result["storage-summary-1"])
}
}
func TestGetStorageSummaryCapacityTrend_MockAvoidsPerPoolChartCacheFanout(t *testing.T) {
previousEnabled := mock.IsMockEnabled()
previousConfig := mock.GetConfig()
t.Cleanup(func() {
mustSetMockEnabled(t, false)
mock.SetMockConfig(previousConfig)
if previousEnabled {
mustSetMockEnabled(t, true)
mock.SetMockConfig(previousConfig)
}
})
mustSetMockEnabled(t, false)
mock.SetMockConfig(previousConfig)
mustSetMockEnabled(t, true)
monitor := newChartFallbackTestMonitor(t)
points, oldestTimestamp := monitor.GetStorageSummaryCapacityTrend(24 * time.Hour)
if len(points) == 0 {
t.Fatal("expected aggregate mock storage summary capacity trend")
}
if oldestTimestamp == 0 {
t.Fatal("expected aggregate mock storage summary oldest timestamp")
}
summaryKey := mockChartMetricMapCacheKey{
kind: "storage-summary",
resourceType: "storage",
resourceID: "__aggregate__",
duration: 24 * time.Hour,
}
if _, ok := monitor.mockChartMapCache[summaryKey]; !ok {
t.Fatalf("expected summary cache entry, got %+v", monitor.mockChartMapCache)
}
for key := range monitor.mockChartMapCache {
if key.kind == "storage" {
t.Fatalf("expected aggregate summary cache without per-pool storage fanout, found %+v", key)
}
}
}
func TestSLO_GetPhysicalDiskTemperatureCharts_WithNativeHistoryFallback(t *testing.T) {
skipMonitoringSLOUnderRace(t)
suppressMonitoringTestLogs(t)
@@ -3,7 +3,6 @@ package monitoring
import (
"math"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
@@ -15,7 +14,6 @@ import (
//
// - monitorExistingClusterIPOverride (monitor_pve_cluster.go)
// - monitorExistingClusterFingerprint (monitor_pve_cluster.go)
// - buildStorageSummaryCapacityTrend (monitor_metrics.go)
// - parseNouveauGPUTemps (temperature.go)
//
// Conventions match sibling in-package tests in this directory (see
@@ -25,9 +23,8 @@ import (
// The receiver of parseNouveauGPUTemps (*TemperatureCollector) is never
// dereferenced inside the function, so a zero-value collector is safe.
// floatEq compares two floats with an absolute tolerance. The percentages
// produced by buildStorageSummaryCapacityTrend carry rounding error on the
// order of 1e-15, so 1e-6 is comfortably strict.
// floatEq compares two floats with an absolute tolerance. 1e-6 is comfortably
// strict for the parsed temperatures compared below.
func floatEq(a, b float64) bool {
return math.Abs(a-b) <= 1e-6
}
@@ -147,250 +144,6 @@ func TestBranchCovMonitorExistingClusterFingerprint(t *testing.T) {
}
}
func TestBranchCovBuildStorageSummaryCapacityTrend(t *testing.T) {
// Fixed millisecond timestamps so assertions are deterministic and the
// "older point seen after newer" branch can be exercised deterministically.
const (
t1ms int64 = 1_000_000
t2ms int64 = 2_000_000
t3ms int64 = 3_000_000
)
t1 := time.UnixMilli(t1ms)
t2 := time.UnixMilli(t2ms)
t3 := time.UnixMilli(t3ms)
pt := func(ts time.Time, v float64) MetricPoint {
return MetricPoint{Timestamp: ts, Value: v}
}
cases := []struct {
name string
poolMetrics map[string]map[string][]MetricPoint
// wantNil marks cases that must return a nil slice (early-return path).
wantNil bool
// wantPoints is the expected output slice. For non-nil-empty results
// pass a non-nil zero-length slice; for nil results set wantNil=true.
wantPoints []MetricPoint
wantOldest int64
}{
// Branch: nil input -> len(buckets)==0 early return path AND
// oldestTimestamp never set (stays 0).
{"nil input returns nil slice and zero oldest",
nil, true, nil, 0},
// Branch: empty pool map -> same early-return path.
{"empty pool map returns nil slice and zero oldest",
map[string]map[string][]MetricPoint{}, true, nil, 0},
// Branch: pool present but inner metric map nil -> reading
// nil["used"] / nil["avail"] yields empty slices, so no buckets.
{"pool with nil inner metric map returns nil slice",
map[string]map[string][]MetricPoint{"p1": nil}, true, nil, 0},
// Branch: pool with neither "used" nor "avail" key -> buckets empty.
{"pool with unrelated metric keys returns nil slice",
map[string]map[string][]MetricPoint{
"p1": {"total": []MetricPoint{pt(t1, 100)}},
}, true, nil, 0},
// Happy path: single pool, used + avail at same timestamp ->
// bucket hasUsed && hasAvail -> emitted at used/(used+avail)*100.
{"single pool used and avail at same ts emits percentage",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t1, 25)},
"avail": []MetricPoint{pt(t1, 75)},
},
},
false, []MetricPoint{{Timestamp: t1, Value: 25.0}}, t1ms},
// Branch: aggregation across pools at the SAME timestamp. The
// second pool's used and avail both find an existing bucket
// (exercises bucket != nil arm in BOTH inner loops); values sum.
{"two pools at same ts aggregate by summing",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t1, 50)},
"avail": []MetricPoint{pt(t1, 50)},
},
"p2": {
"used": []MetricPoint{pt(t1, 100)},
"avail": []MetricPoint{pt(t1, 100)},
},
},
// total used=150, total=300 -> 150/300*100 = 50
false, []MetricPoint{{Timestamp: t1, Value: 50.0}}, t1ms},
// Branch: multiple timestamps returned sorted ascending; oldest is
// the minimum. Within one pool, used points create the buckets and
// the subsequent avail loop finds them existing.
{"multiple timestamps returned sorted ascending",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t3, 30), pt(t1, 10), pt(t2, 20)},
"avail": []MetricPoint{pt(t3, 70), pt(t1, 90), pt(t2, 80)},
},
},
false,
[]MetricPoint{
{Timestamp: t1, Value: 10.0},
{Timestamp: t2, Value: 20.0},
{Timestamp: t3, Value: 30.0},
},
t1ms},
// Branch: oldestTimestamp update arm `timestamp < oldestTimestamp`
// -- iteration sees the newer timestamp first, then the older one.
{"older point seen after newer still sets oldest to min",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t2, 20), pt(t1, 10)},
"avail": []MetricPoint{pt(t2, 80), pt(t1, 90)},
},
},
false,
[]MetricPoint{
{Timestamp: t1, Value: 10.0},
{Timestamp: t2, Value: 20.0},
},
t1ms},
// Branch: used-only bucket -> hasAvail=false -> skipped in output
// loop. Buckets non-empty so we go past the early return; result is
// a NON-nil empty slice and oldestTimestamp is still set.
{"used only bucket skipped but oldest timestamp set",
map[string]map[string][]MetricPoint{
"p1": {"used": []MetricPoint{pt(t1, 50)}},
},
false, []MetricPoint{}, t1ms},
// Branch: avail-only bucket -> hasUsed=false -> skipped.
{"avail only bucket skipped but oldest timestamp set",
map[string]map[string][]MetricPoint{
"p1": {"avail": []MetricPoint{pt(t1, 50)}},
},
false, []MetricPoint{}, t1ms},
// Branch: used and avail land at DIFFERENT timestamps -> each
// bucket is half-complete -> all skipped; oldest is min(t1,t2)=t1.
{"used and avail at different timestamps both skipped",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t1, 50)},
"avail": []MetricPoint{pt(t2, 50)},
},
},
false, []MetricPoint{}, t1ms},
// Branch: total == 0 (used=0, avail=0) -> `total <= 0` skip arm.
{"zero total skipped via total le zero",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t1, 0)},
"avail": []MetricPoint{pt(t1, 0)},
},
},
false, []MetricPoint{}, t1ms},
// Branch: total negative (avail negative) -> `total <= 0` skip arm.
{"negative total skipped via total le zero",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t1, 5)},
"avail": []MetricPoint{pt(t1, -10)},
},
},
false, []MetricPoint{}, t1ms},
// Branch: NaN used propagates to NaN total -> math.IsNaN skip arm.
{"nan used makes total nan and is skipped",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t1, math.NaN())},
"avail": []MetricPoint{pt(t1, 50)},
},
},
false, []MetricPoint{}, t1ms},
// Branch: +Inf used -> +Inf total -> math.IsInf(total, 0) skip arm.
{"positive inf used makes total inf and is skipped",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t1, math.Inf(1))},
"avail": []MetricPoint{pt(t1, 50)},
},
},
false, []MetricPoint{}, t1ms},
// Branch: -Inf used -> -Inf total -> IsInf skip arm.
{"negative inf used makes total neg inf and is skipped",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t1, math.Inf(-1))},
"avail": []MetricPoint{pt(t1, 50)},
},
},
false, []MetricPoint{}, t1ms},
// Branch: negative used with positive total -> NOT skipped; emits a
// NEGATIVE percentage. This documents real (suspect) behavior; see
// GLM_REPORT.md "suspected source bugs".
{"negative used yields negative percentage when total positive",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t1, -5)},
"avail": []MetricPoint{pt(t1, 10)},
},
},
// (-5 / 5) * 100 = -100
false, []MetricPoint{{Timestamp: t1, Value: -100.0}}, t1ms},
// Mixed: one full bucket (t1) emits, one half bucket (t2, used-only)
// is skipped but still contributes to oldestTimestamp when older.
{"mixed full and half bucket only emits full bucket",
map[string]map[string][]MetricPoint{
"p1": {
"used": []MetricPoint{pt(t1, 30), pt(t2, 99)},
"avail": []MetricPoint{pt(t1, 70)},
},
},
false, []MetricPoint{{Timestamp: t1, Value: 30.0}}, t1ms},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotPoints, gotOldest := buildStorageSummaryCapacityTrend(tc.poolMetrics)
if gotOldest != tc.wantOldest {
t.Errorf("oldest timestamp = %d, want %d", gotOldest, tc.wantOldest)
}
if tc.wantNil {
if gotPoints != nil {
t.Fatalf("expected nil points slice, got %v", gotPoints)
}
return
}
if gotPoints == nil {
t.Fatalf("expected non-nil points slice, got nil")
}
if len(gotPoints) != len(tc.wantPoints) {
t.Fatalf("points length = %d, want %d (got=%v)",
len(gotPoints), len(tc.wantPoints), gotPoints)
}
for i, want := range tc.wantPoints {
got := gotPoints[i]
if !got.Timestamp.Equal(want.Timestamp) {
t.Errorf("point[%d].Timestamp = %v, want %v", i, got.Timestamp, want.Timestamp)
}
if !floatEq(got.Value, want.Value) {
t.Errorf("point[%d].Value = %v, want %v", i, got.Value, want.Value)
}
}
})
}
}
func TestBranchCovParseNouveauGPUTemps(t *testing.T) {
// parseNouveauGPUTemps never dereferences its *TemperatureCollector
// receiver, so a zero-value instance is sufficient.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"time"
)
// Exercise the ingestion shape from the workloads-summary benchmark without
// its HTTP, reflection or monitor fixtures. A historical benchmark crashed in
// Exercise the ingestion shape from the retired workloads-summary benchmark
// without its HTTP, reflection or monitor fixtures. A historical benchmark crashed in
// SQLite during the second synchronous seed; this is a diagnostic invariant,
// not a reproducer or a claim that the unexplained crash has been repaired.
func TestStoreLargeSummarySeedSurvivesReopen(t *testing.T) {