From 4a0a05858d45a51471ee76e0a91e0f6e2961596d Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:14:49 +0100 Subject: [PATCH] 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. --- .../v6/internal/subsystems/agent-lifecycle.md | 17 +- .../v6/internal/subsystems/api-contracts.md | 69 +- .../subsystems/frontend-primitives.md | 4 +- .../v6/internal/subsystems/monitoring.md | 19 +- .../subsystems/performance-and-scalability.md | 45 +- .../internal/subsystems/storage-recovery.md | 25 +- .../internal/subsystems/unified-resources.md | 11 +- .../53c6d449af452430.json | 43 + .../__tests__/charts.branchcov0718.test.ts | 105 +- .../src/api/__tests__/chartsApi.test.ts | 11 - frontend-modern/src/api/charts.ts | 82 +- internal/api/chartapi/charts_test.go | 72 -- internal/api/chartapi/payload_cache_test.go | 15 +- internal/api/chartapi/service.go | 1004 +---------------- .../api/chartapi/service_handlers_test.go | 355 ------ internal/api/chartapi/types.go | 119 -- internal/api/charts_compat.go | 8 - internal/api/contract_test.go | 182 +-- .../api/pure_helpers_branchcov0724pm_test.go | 79 +- internal/api/route_inventory_test.go | 2 - internal/api/router_bench_test.go | 159 --- internal/api/router_routes_monitoring.go | 2 - internal/api/security_regression_test.go | 1 - internal/api/slo.go | 7 - internal/api/slo_bench_test.go | 205 +--- internal/api/types.go | 14 - internal/api/types_additional_test.go | 39 +- .../monitoring/canonical_guardrails_test.go | 2 - internal/monitoring/mock_chart_history.go | 60 - .../monitoring/mock_metrics_history_test.go | 9 - internal/monitoring/monitor_metrics.go | 219 ---- .../monitoring/monitor_metrics_slo_test.go | 69 -- ...g_cluster_trend_temp_branchcov0716_test.go | 251 +---- pkg/metrics/store_large_seed_test.go | 4 +- 34 files changed, 157 insertions(+), 3151 deletions(-) create mode 100644 frontend-modern/browser-verification/53c6d449af452430.json diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 07295760aa..95c2738054 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 64978f4e99..8442932879 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index ec775ca41c..b3c29e77b2 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 67a6f98337..2d41a11bcd 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index ef776a8f02..f8168c889c 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 27c4f95c7b..d1620e7442 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 7f476226af..1286b840ff 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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. diff --git a/frontend-modern/browser-verification/53c6d449af452430.json b/frontend-modern/browser-verification/53c6d449af452430.json new file mode 100644 index 0000000000..63c20dfa5e --- /dev/null +++ b/frontend-modern/browser-verification/53c6d449af452430.json @@ -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." + ] +} diff --git a/frontend-modern/src/api/__tests__/charts.branchcov0718.test.ts b/frontend-modern/src/api/__tests__/charts.branchcov0718.test.ts index fa73f09f1e..a646e26adf 100644 --- a/frontend-modern/src/api/__tests__/charts.branchcov0718.test.ts +++ b/frontend-modern/src/api/__tests__/charts.branchcov0718.test.ts @@ -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); - }); -}); diff --git a/frontend-modern/src/api/__tests__/chartsApi.test.ts b/frontend-modern/src/api/__tests__/chartsApi.test.ts index 16df3541c0..a7936442a8 100644 --- a/frontend-modern/src/api/__tests__/chartsApi.test.ts +++ b/frontend-modern/src/api/__tests__/chartsApi.test.ts @@ -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); diff --git a/frontend-modern/src/api/charts.ts b/frontend-modern/src/api/charts.ts index 86425a4062..5f0677e22e 100644 --- a/frontend-modern/src/api/charts.ts +++ b/frontend-modern/src/api/charts.ts @@ -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 { - 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 { - const params = new URLSearchParams({ range: range_ }); - const url = `${this.baseUrl}/charts/storage-summary?${params.toString()}`; - return apiFetchJSON(url, { signal }); - } } // --------------------------------------------------------------------------- diff --git a/internal/api/chartapi/charts_test.go b/internal/api/chartapi/charts_test.go index 6ec7b1fc17..67909cea7c 100644 --- a/internal/api/chartapi/charts_test.go +++ b/internal/api/chartapi/charts_test.go @@ -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() diff --git a/internal/api/chartapi/payload_cache_test.go b/internal/api/chartapi/payload_cache_test.go index 98548bad50..036d7fe905 100644 --- a/internal/api/chartapi/payload_cache_test.go +++ b/internal/api/chartapi/payload_cache_test.go @@ -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) diff --git a/internal/api/chartapi/service.go b/internal/api/chartapi/service.go index 82d65711fa..f0e5fbb39e 100644 --- a/internal/api/chartapi/service.go +++ b/internal/api/chartapi/service.go @@ -7,14 +7,12 @@ import ( "math" "net/http" "net/url" - "sort" "strconv" "strings" "sync" "time" "github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext" - "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" @@ -26,14 +24,13 @@ const workloadChartsCacheTTL = 3 * time.Second const summaryChartsCacheTTL = 5 * time.Second const ( - // All three chart routes share one retention budget. This stays modest for + // Both cached chart routes share one retention budget. This stays modest for // low-memory container deployments while still holding several ordinary // large-estate responses. chartPayloadCacheMaxEntries = 64 chartPayloadCacheMaxBytes = 16 << 20 infrastructureChartsCachePrefix = "infrastructure|" - workloadsSummaryCachePrefix = "workloads-summary|" workloadChartsCachePrefix = "workload-charts|" ) @@ -659,12 +656,9 @@ func CapMetricPointSeriesByIndex(points []MetricPoint, maxPoints int) []MetricPo const ( infrastructureSummaryMinSeriesPoints = 24 infrastructureSummaryMaxSeriesPoints = 96 - workloadsSummaryMinSeriesPoints = 24 - workloadsSummaryMaxSeriesPoints = 96 ) const InfrastructureSummaryMaxSeriesPoints = infrastructureSummaryMaxSeriesPoints -const WorkloadsSummaryMaxSeriesPoints = workloadsSummaryMaxSeriesPoints // capMetricPointSeries keeps mixed-cadence series visually proportional across // the selected time window. Index-based capping over-selects recent dense @@ -805,41 +799,6 @@ func (r *Service) cacheInfrastructureChartsPayload(key string, payload []byte, n ) } -func targetWorkloadsSummarySeriesPoints(duration time.Duration) int { - return targetBoundedSummarySeriesPoints( - duration, - workloadsSummaryMinSeriesPoints, - workloadsSummaryMaxSeriesPoints, - ) -} - -func workloadsSummaryChartsCacheKey(req *http.Request, timeRange, selectedNodeID string) string { - orgID := strings.TrimSpace(apicontext.OrgID(req.Context())) - if orgID == "" { - orgID = "default" - } - return orgID + "|" + strings.TrimSpace(timeRange) + "|" + strings.TrimSpace(selectedNodeID) -} - -func (r *Service) cachedWorkloadsSummaryChartsPayload(key string, now time.Time) ([]byte, bool) { - if r == nil || key == "" { - return nil, false - } - return r.chartPayloads.get(workloadsSummaryCachePrefix+key, now) -} - -func (r *Service) cacheWorkloadsSummaryChartsPayload(key string, payload []byte, now time.Time) { - if r == nil || key == "" || len(payload) == 0 { - return - } - r.chartPayloads.put( - workloadsSummaryCachePrefix+key, - payload, - now.Add(summaryChartsCacheTTL), - now, - ) -} - func aggregateInfrastructureSummaryBucketValue( metricType string, bucket infrastructureSummaryBucket, @@ -1335,51 +1294,6 @@ func BuildSyntheticMetricHistorySeries( return buildSyntheticMetricHistorySeries(now, duration, maxPoints, resourceType, resourceID, metricType, current) } -func buildMockWorkloadMetricHistorySeries( - now time.Time, - duration time.Duration, - maxPoints int, - resourceType string, - resourceID string, - metricType string, - current float64, -) []monitoring.MetricPoint { - switch metricType { - case "cpu", "memory", "disk": - case "diskread", "diskwrite", "netin", "netout": - default: - return nil - } - - numPoints := targetMockSeriesPoints(duration, maxPoints) - series := generateStyledMockSeries( - now.UnixMilli(), duration, numPoints, - current, resourceType, resourceID, metricType, - ) - - converted := make([]monitoring.MetricPoint, len(series)) - for i, point := range series { - converted[i] = monitoring.MetricPoint{ - Timestamp: time.UnixMilli(point.Timestamp), - Value: point.Value, - } - } - - return converted -} - -func BuildMockWorkloadMetricHistorySeries( - now time.Time, - duration time.Duration, - maxPoints int, - resourceType string, - resourceID string, - metricType string, - current float64, -) []monitoring.MetricPoint { - return buildMockWorkloadMetricHistorySeries(now, duration, maxPoints, resourceType, resourceID, metricType, current) -} - // handleWorkloadCharts serves workload-only chart data used by workloads // sparklines. It intentionally excludes infrastructure/storage chart payloads // to keep requests small and stable for large fleets. @@ -2121,51 +2035,6 @@ func (r *Service) HandleInfrastructureCharts(w http.ResponseWriter, req *http.Re } } -type workloadSummaryBuckets struct { - cpu workloadSummaryMetricBucket - memory workloadSummaryMetricBucket - disk workloadSummaryMetricBucket - network workloadSummaryMetricBucket -} - -type workloadSummaryMetricBucket struct { - sum float64 - max float64 - count int -} - -func (bucket *workloadSummaryMetricBucket) add(value float64) { - if bucket == nil { - return - } - if bucket.count == 0 || value > bucket.max { - bucket.max = value - } - bucket.sum += value - bucket.count++ -} - -func (bucket workloadSummaryMetricBucket) average() float64 { - if bucket.count == 0 { - return 0 - } - return bucket.sum / float64(bucket.count) -} - -type workloadsSummarySnapshot struct { - id string - name string - cpu float64 - memory float64 - disk float64 - network float64 -} - -func workloadSummaryBucketTimestamp(timestampMs int64) int64 { - const bucketSizeMs = int64(30_000) - return (timestampMs / bucketSizeMs) * bucketSizeMs -} - func clampWorkloadPercent(value float64) float64 { if value != value { return 0 @@ -2187,16 +2056,6 @@ func ProxmoxModelCPURatioPercent(value float64) float64 { return proxmoxModelCPURatioPercent(value) } -func clampNonNegativeWorkloadValue(value float64) float64 { - if value != value { - return 0 - } - if value < 0 { - return 0 - } - return value -} - func kubernetesPodMetricIDFromView(pod *unifiedresources.PodView) string { if pod == nil { return "" @@ -2219,807 +2078,6 @@ func kubernetesPodMetricIDFromView(pod *unifiedresources.PodView) string { return fmt.Sprintf("k8s:%s:pod:%s", clusterKey, podKey) } -func getOrCreateWorkloadBucket(buckets map[int64]*workloadSummaryBuckets, bucketTs int64) *workloadSummaryBuckets { - if bucket, ok := buckets[bucketTs]; ok { - return bucket - } - bucket := &workloadSummaryBuckets{} - buckets[bucketTs] = bucket - return bucket -} - -func appendWorkloadMetricPoints( - buckets map[int64]*workloadSummaryBuckets, - points []monitoring.MetricPoint, - target string, - oldestTimestamp *int64, -) int { - added := 0 - for _, point := range points { - ts := point.Timestamp.UnixMilli() - if ts <= 0 { - continue - } - if ts < *oldestTimestamp { - *oldestTimestamp = ts - } - bucketTs := workloadSummaryBucketTimestamp(ts) - bucket := getOrCreateWorkloadBucket(buckets, bucketTs) - value := clampNonNegativeWorkloadValue(point.Value) - switch target { - case "cpu", "memory", "disk": - value = clampWorkloadPercent(value) - } - switch target { - case "cpu": - bucket.cpu.add(value) - case "memory": - bucket.memory.add(value) - case "disk": - bucket.disk.add(value) - case "network": - bucket.network.add(value) - } - added++ - } - return added -} - -func mergeWorkloadNetworkPoints( - netIn []monitoring.MetricPoint, - netOut []monitoring.MetricPoint, -) []monitoring.MetricPoint { - totals := make(map[int64]float64) - for _, point := range netIn { - ts := point.Timestamp.UnixMilli() - if ts <= 0 { - continue - } - totals[ts] += clampNonNegativeWorkloadValue(point.Value) - } - for _, point := range netOut { - ts := point.Timestamp.UnixMilli() - if ts <= 0 { - continue - } - totals[ts] += clampNonNegativeWorkloadValue(point.Value) - } - if len(totals) == 0 { - return nil - } - keys := make([]int64, 0, len(totals)) - for ts := range totals { - keys = append(keys, ts) - } - sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) - points := make([]monitoring.MetricPoint, 0, len(keys)) - for _, ts := range keys { - points = append(points, monitoring.MetricPoint{ - Timestamp: time.UnixMilli(ts), - Value: totals[ts], - }) - } - return points -} - -func buildWorkloadsSummaryMetric( - buckets map[int64]*workloadSummaryBuckets, - selector func(*workloadSummaryBuckets) workloadSummaryMetricBucket, -) WorkloadsSummaryMetricData { - keys := make([]int64, 0, len(buckets)) - for ts := range buckets { - keys = append(keys, ts) - } - sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) - - data := WorkloadsSummaryMetricData{ - P50: make([]MetricPoint, 0, len(keys)), - P95: make([]MetricPoint, 0, len(keys)), - } - for _, ts := range keys { - bucket := selector(buckets[ts]) - if bucket.count == 0 { - continue - } - data.P50 = append(data.P50, MetricPoint{ - Timestamp: ts, - Value: bucket.average(), - }) - data.P95 = append(data.P95, MetricPoint{ - Timestamp: ts, - Value: bucket.max, - }) - } - return data -} - -func summaryMetricPointCount(metric WorkloadsSummaryMetricData) int { - return len(metric.P50) + len(metric.P95) -} - -func normalizeWorkloadsSummaryMetricPointSeries( - metric WorkloadsSummaryMetricData, - duration time.Duration, -) WorkloadsSummaryMetricData { - targetPoints := targetWorkloadsSummarySeriesPoints(duration) - metric.P50 = capMetricPointSeries(metric.P50, targetPoints) - metric.P95 = capMetricPointSeries(metric.P95, targetPoints) - return metric -} - -func latestSummaryMetricValue(points []monitoring.MetricPoint, fallback float64, clamp func(float64) float64) float64 { - if len(points) == 0 { - return clamp(fallback) - } - - latest := points[0] - for i := 1; i < len(points); i++ { - if points[i].Timestamp.After(latest.Timestamp) { - latest = points[i] - } - } - return clamp(latest.Value) -} - -func buildWorkloadsTopContributors( - snapshots []workloadsSummarySnapshot, - selector func(workloadsSummarySnapshot) float64, -) []WorkloadsSummaryContributor { - contributors := make([]WorkloadsSummaryContributor, 0, len(snapshots)) - for _, snapshot := range snapshots { - value := selector(snapshot) - if value <= 0 { - continue - } - contributors = append(contributors, WorkloadsSummaryContributor{ - ID: snapshot.id, - Name: snapshot.name, - Value: value, - }) - } - - sort.Slice(contributors, func(i, j int) bool { - if contributors[i].Value == contributors[j].Value { - if contributors[i].Name == contributors[j].Name { - return contributors[i].ID < contributors[j].ID - } - return contributors[i].Name < contributors[j].Name - } - return contributors[i].Value > contributors[j].Value - }) - - if len(contributors) > 3 { - contributors = contributors[:3] - } - return contributors -} - -func buildWorkloadsBlastRadius( - snapshots []workloadsSummarySnapshot, - selector func(workloadsSummarySnapshot) float64, -) WorkloadsSummaryBlastRadius { - values := make([]float64, 0, len(snapshots)) - for _, snapshot := range snapshots { - value := selector(snapshot) - if value <= 0 { - continue - } - values = append(values, value) - } - - if len(values) == 0 { - return WorkloadsSummaryBlastRadius{ - Scope: "idle", - Top3Share: 0, - ActiveWorkloads: 0, - } - } - - sort.Slice(values, func(i, j int) bool { return values[i] > values[j] }) - total := 0.0 - for _, value := range values { - total += value - } - - topCount := 3 - if len(values) < topCount { - topCount = len(values) - } - top3 := 0.0 - for i := 0; i < topCount; i++ { - top3 += values[i] - } - - share := 0.0 - if total > 0 { - share = (top3 / total) * 100 - } - - scope := "distributed" - switch { - case share >= 80: - scope = "concentrated" - case share >= 55: - scope = "mixed" - } - - return WorkloadsSummaryBlastRadius{ - Scope: scope, - Top3Share: share, - ActiveWorkloads: len(values), - } -} - -// handleWorkloadsSummaryCharts serves compact, aggregate workload sparklines -// for the Workloads top cards. It intentionally avoids returning per-workload -// time series to keep payloads bounded for large fleets. -func (r *Service) HandleWorkloadsSummaryCharts(w http.ResponseWriter, req *http.Request) { - log.Debug().Str("method", req.Method).Str("url", req.URL.String()).Msg("Workloads summary charts endpoint hit") - const inMemoryChartThreshold = 2 * time.Hour - - if req.Method != http.MethodGet && req.Method != http.MethodHead { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - query := req.URL.Query() - timeRange := query.Get("range") - if timeRange == "" { - timeRange = "1h" - } - selectedNodeID := strings.TrimSpace(query.Get("node")) - duration := parseChartsRangeDuration(timeRange) - - monitor := r.getTenantMonitor(req.Context()) - if monitor == nil { - http.Error(w, "Tenant monitor is not available", http.StatusInternalServerError) - return - } - nodes := monitor.NodesSnapshot() - readState := monitor.GetUnifiedReadStateOrSnapshot() - if readState == nil { - http.Error(w, "State unavailable", http.StatusInternalServerError) - return - } - - now := time.Now() - cacheKey := workloadsSummaryChartsCacheKey(req, timeRange, selectedNodeID) - if payload, ok := r.cachedWorkloadsSummaryChartsPayload(cacheKey, now); ok { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(payload) - return - } - - mockModeEnabled := mock.IsMockEnabled() - metricsStoreEnabled := monitor.GetMetricsStore() != nil - primarySourceHint := "memory" - if metricsStoreEnabled && duration > inMemoryChartThreshold { - primarySourceHint = "store_or_memory_fallback" - } - - currentTime := now.UnixMilli() - currentTimeTime := now - oldestTimestamp := currentTime - buckets := make(map[int64]*workloadSummaryBuckets) - guestPointCount := 0 - guestCounts := WorkloadsGuestCounts{} - snapshots := make([]workloadsSummarySnapshot, 0, len(readState.VMs())+len(readState.Containers())) - - var selectedNode *models.Node - if selectedNodeID != "" { - for idx := range nodes { - if nodes[idx].ID == selectedNodeID { - selectedNode = &nodes[idx] - break - } - } - if selectedNode == nil { - log.Debug(). - Str("selectedNodeID", selectedNodeID). - Msg("Workloads summary node filter not found in current state; falling back to global scope") - } - } - - matchesSelectedNode := func(instance, nodeName string) bool { - if selectedNodeID == "" { - return true - } - if selectedNode == nil { - return true - } - return strings.EqualFold(strings.TrimSpace(instance), strings.TrimSpace(selectedNode.Instance)) && - strings.EqualFold(strings.TrimSpace(nodeName), strings.TrimSpace(selectedNode.Name)) - } - - matchesSelectedDockerHostView := func(host *unifiedresources.DockerHostView) bool { - if selectedNodeID == "" { - return true - } - if selectedNode == nil { - return true - } - if host == nil { - return false - } - nodeName := strings.TrimSpace(selectedNode.Name) - if nodeName == "" { - return false - } - return strings.EqualFold(strings.TrimSpace(host.Hostname()), nodeName) || - strings.EqualFold(strings.TrimSpace(host.Name()), nodeName) - } - - matchesSelectedKubernetesPodView := func(pod *unifiedresources.PodView) bool { - if selectedNodeID == "" { - return true - } - if selectedNode == nil { - return true - } - if pod == nil { - return false - } - nodeName := strings.TrimSpace(selectedNode.Name) - if nodeName == "" { - return false - } - return strings.EqualFold(strings.TrimSpace(pod.NodeName()), nodeName) - } - - vmList := make([]*unifiedresources.VMView, 0) - vmResponseKeys := make([]string, 0) - vmRequests := make([]monitoring.GuestChartRequest, 0) - for _, vm := range readState.VMs() { - if vm == nil { - continue - } - if !matchesSelectedNode(vm.Instance(), vm.Node()) { - continue - } - responseKey, request, ok := vmChartRequest(vm) - if !ok { - continue - } - vmList = append(vmList, vm) - vmResponseKeys = append(vmResponseKeys, responseKey) - vmRequests = append(vmRequests, request) - } - containerList := make([]*unifiedresources.ContainerView, 0) - containerResponseKeys := make([]string, 0) - containerRequests := make([]monitoring.GuestChartRequest, 0) - for _, ct := range readState.Containers() { - if ct == nil { - continue - } - if !matchesSelectedNode(ct.Instance(), ct.Node()) { - continue - } - responseKey, request, ok := systemContainerChartRequest(ct) - if !ok { - continue - } - containerList = append(containerList, ct) - containerResponseKeys = append(containerResponseKeys, responseKey) - containerRequests = append(containerRequests, request) - } - podList := make([]*unifiedresources.PodView, 0) - podRequests := make([]monitoring.GuestChartRequest, 0) - for _, pod := range readState.Pods() { - if pod == nil { - continue - } - if !matchesSelectedKubernetesPodView(pod) { - continue - } - - metricKey := kubernetesPodMetricIDFromView(pod) - if metricKey == "" { - continue - } - podList = append(podList, pod) - podRequests = append(podRequests, monitoring.GuestChartRequest{InMemoryKey: metricKey, SQLResourceID: metricKey}) - } - dockerHostsByID := make(map[string]*unifiedresources.DockerHostView, len(readState.DockerHosts())) - for _, host := range readState.DockerHosts() { - if host == nil { - continue - } - dockerHostsByID[host.ID()] = host - } - - dockerContainerList := make([]*unifiedresources.DockerContainerView, 0) - dockerContainerRequests := make([]monitoring.GuestChartRequest, 0) - for _, container := range readState.DockerContainers() { - if container == nil { - continue - } - if selectedNodeID != "" && selectedNode != nil { - host := dockerHostsByID[container.ParentID()] - if host == nil || !matchesSelectedDockerHostView(host) { - continue - } - } - containerID := strings.TrimSpace(container.ContainerID()) - if containerID == "" { - continue - } - dockerContainerList = append(dockerContainerList, container) - dockerContainerRequests = append(dockerContainerRequests, monitoring.GuestChartRequest{ - InMemoryKey: fmt.Sprintf("docker:%s", containerID), - SQLResourceID: containerID, - }) - } - var ( - vmBatchMetrics map[string]map[string][]monitoring.MetricPoint - containerBatchMetrics map[string]map[string][]monitoring.MetricPoint - podBatchMetrics map[string]map[string][]monitoring.MetricPoint - dockerContainerBatchMetrics map[string]map[string][]monitoring.MetricPoint - ) - var workloadsSummaryBatchWG sync.WaitGroup - workloadsSummaryBatchWG.Add(4) - go func() { - defer workloadsSummaryBatchWG.Done() - vmBatchMetrics = monitor.GetGuestMetricsForChartBatch("vm", vmRequests, duration, workloadSummaryMetricOrder...) - }() - go func() { - defer workloadsSummaryBatchWG.Done() - containerBatchMetrics = monitor.GetGuestMetricsForChartBatch("container", containerRequests, duration, workloadSummaryMetricOrder...) - }() - go func() { - defer workloadsSummaryBatchWG.Done() - podBatchMetrics = monitor.GetGuestMetricsForChartBatch("k8s", podRequests, duration, workloadSummaryMetricOrder...) - }() - go func() { - defer workloadsSummaryBatchWG.Done() - dockerContainerBatchMetrics = monitor.GetGuestMetricsForChartBatch("dockerContainer", dockerContainerRequests, duration, workloadSummaryMetricOrder...) - }() - workloadsSummaryBatchWG.Wait() - - var guestSummaryPoints int - snapshots, guestSummaryPoints = appendGuestWorkloadSummaries(vmList, vmResponseKeys, vmRequests, vmBatchMetrics, currentTimeTime, &guestCounts, buckets, snapshots, &oldestTimestamp) - guestPointCount += guestSummaryPoints - - snapshots, guestSummaryPoints = appendGuestWorkloadSummaries(containerList, containerResponseKeys, containerRequests, containerBatchMetrics, currentTimeTime, &guestCounts, buckets, snapshots, &oldestTimestamp) - guestPointCount += guestSummaryPoints - - for _, pod := range podList { - metricKey := kubernetesPodMetricIDFromView(pod) - - guestCounts.Total++ - if strings.EqualFold(pod.PodPhase(), "running") { - guestCounts.Running++ - } else { - guestCounts.Stopped++ - } - - snapshot := workloadsSummarySnapshot{ - id: metricKey, - name: strings.TrimSpace(pod.Namespace()), - cpu: clampWorkloadPercent(pod.CPUPercent()), - memory: clampWorkloadPercent(pod.MemoryPercent()), - disk: clampWorkloadPercent(pod.DiskPercent()), - network: clampNonNegativeWorkloadValue(pod.NetInRate() + pod.NetOutRate()), - } - if name := strings.TrimSpace(pod.Name()); name != "" { - if snapshot.name == "" { - snapshot.name = name - } else { - snapshot.name = fmt.Sprintf("%s/%s", snapshot.name, name) - } - } - if snapshot.name == "" { - snapshot.name = metricKey - } - - metrics := podBatchMetrics[metricKey] - cpuPoints := metrics["cpu"] - if len(cpuPoints) == 0 { - cpuPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: pod.CPUPercent()}} - } - memoryPoints := metrics["memory"] - if len(memoryPoints) == 0 { - memoryPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: pod.MemoryPercent()}} - } - diskPoints := metrics["disk"] - if len(diskPoints) == 0 { - diskPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: pod.DiskPercent()}} - } - netInPoints := metrics["netin"] - if len(netInPoints) == 0 { - netInPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: pod.NetInRate()}} - } - netOutPoints := metrics["netout"] - if len(netOutPoints) == 0 { - netOutPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: pod.NetOutRate()}} - } - - if mockModeEnabled { - if len(cpuPoints) < mockWorkloadMinSeriesPoints { - cpuPoints = buildMockWorkloadMetricHistorySeries(currentTimeTime, duration, 0, "k8s", metricKey, "cpu", snapshot.cpu) - } - if len(memoryPoints) < mockWorkloadMinSeriesPoints { - memoryPoints = buildMockWorkloadMetricHistorySeries(currentTimeTime, duration, 0, "k8s", metricKey, "memory", snapshot.memory) - } - if len(diskPoints) < mockWorkloadMinSeriesPoints { - diskPoints = buildMockWorkloadMetricHistorySeries(currentTimeTime, duration, 0, "k8s", metricKey, "disk", snapshot.disk) - } - if len(netInPoints) < mockWorkloadMinSeriesPoints { - netInPoints = buildMockWorkloadMetricHistorySeries(currentTimeTime, duration, 0, "k8s", metricKey, "netin", pod.NetInRate()) - } - if len(netOutPoints) < mockWorkloadMinSeriesPoints { - netOutPoints = buildMockWorkloadMetricHistorySeries(currentTimeTime, duration, 0, "k8s", metricKey, "netout", pod.NetOutRate()) - } - } - - networkPoints := mergeWorkloadNetworkPoints(netInPoints, netOutPoints) - - snapshot.cpu = latestSummaryMetricValue(cpuPoints, snapshot.cpu, clampWorkloadPercent) - snapshot.memory = latestSummaryMetricValue(memoryPoints, snapshot.memory, clampWorkloadPercent) - snapshot.disk = latestSummaryMetricValue(diskPoints, snapshot.disk, clampWorkloadPercent) - snapshot.network = latestSummaryMetricValue(networkPoints, snapshot.network, clampNonNegativeWorkloadValue) - - guestPointCount += appendWorkloadMetricPoints(buckets, cpuPoints, "cpu", &oldestTimestamp) - guestPointCount += appendWorkloadMetricPoints(buckets, memoryPoints, "memory", &oldestTimestamp) - guestPointCount += appendWorkloadMetricPoints(buckets, diskPoints, "disk", &oldestTimestamp) - guestPointCount += appendWorkloadMetricPoints(buckets, networkPoints, "network", &oldestTimestamp) - snapshots = append(snapshots, snapshot) - } - - for _, container := range dockerContainerList { - containerID := strings.TrimSpace(container.ContainerID()) - guestCounts.Total++ - containerState := strings.TrimSpace(container.ContainerState()) - isRunning := workloadSummaryStatusIsRunning(containerState, container.Status()) - if !isRunning && containerState == "" { - isRunning = container.CPUPercent() > 0 || - container.MemoryPercent() > 0 || - container.NetInRate() > 0 || - container.NetOutRate() > 0 - } - if isRunning { - guestCounts.Running++ - } else { - guestCounts.Stopped++ - } - - snapshot := workloadsSummarySnapshot{ - id: containerID, - name: strings.TrimSpace(container.Name()), - cpu: clampWorkloadPercent(container.CPUPercent()), - memory: clampWorkloadPercent(container.MemoryPercent()), - disk: clampWorkloadPercent(container.DiskPercent()), - network: 0, - } - if snapshot.name == "" { - snapshot.name = containerID - } - - metrics := dockerContainerBatchMetrics[containerID] - cpuPoints := metrics["cpu"] - if len(cpuPoints) == 0 { - cpuPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: container.CPUPercent()}} - } - memoryPoints := metrics["memory"] - if len(memoryPoints) == 0 { - memoryPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: container.MemoryPercent()}} - } - diskPoints := metrics["disk"] - if len(diskPoints) == 0 { - diskPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: container.DiskPercent()}} - } - netInPoints := metrics["netin"] - netOutPoints := metrics["netout"] - - networkPoints := mergeWorkloadNetworkPoints(netInPoints, netOutPoints) - - snapshot.cpu = latestSummaryMetricValue(cpuPoints, snapshot.cpu, clampWorkloadPercent) - snapshot.memory = latestSummaryMetricValue(memoryPoints, snapshot.memory, clampWorkloadPercent) - snapshot.disk = latestSummaryMetricValue(diskPoints, snapshot.disk, clampWorkloadPercent) - snapshot.network = latestSummaryMetricValue(networkPoints, snapshot.network, clampNonNegativeWorkloadValue) - - guestPointCount += appendWorkloadMetricPoints(buckets, cpuPoints, "cpu", &oldestTimestamp) - guestPointCount += appendWorkloadMetricPoints(buckets, memoryPoints, "memory", &oldestTimestamp) - guestPointCount += appendWorkloadMetricPoints(buckets, diskPoints, "disk", &oldestTimestamp) - guestPointCount += appendWorkloadMetricPoints(buckets, networkPoints, "network", &oldestTimestamp) - snapshots = append(snapshots, snapshot) - } - - cpuMetric := buildWorkloadsSummaryMetric(buckets, func(bucket *workloadSummaryBuckets) workloadSummaryMetricBucket { - return bucket.cpu - }) - memoryMetric := buildWorkloadsSummaryMetric(buckets, func(bucket *workloadSummaryBuckets) workloadSummaryMetricBucket { - return bucket.memory - }) - diskMetric := buildWorkloadsSummaryMetric(buckets, func(bucket *workloadSummaryBuckets) workloadSummaryMetricBucket { - return bucket.disk - }) - networkMetric := buildWorkloadsSummaryMetric(buckets, func(bucket *workloadSummaryBuckets) workloadSummaryMetricBucket { - return bucket.network - }) - cpuMetric = normalizeWorkloadsSummaryMetricPointSeries(cpuMetric, duration) - memoryMetric = normalizeWorkloadsSummaryMetricPointSeries(memoryMetric, duration) - diskMetric = normalizeWorkloadsSummaryMetricPointSeries(diskMetric, duration) - networkMetric = normalizeWorkloadsSummaryMetricPointSeries(networkMetric, duration) - - summaryPointCount := summaryMetricPointCount(cpuMetric) + - summaryMetricPointCount(memoryMetric) + - summaryMetricPointCount(diskMetric) + - summaryMetricPointCount(networkMetric) - - topContributors := WorkloadsSummaryContributors{ - CPU: buildWorkloadsTopContributors(snapshots, func(snapshot workloadsSummarySnapshot) float64 { - return snapshot.cpu - }), - Memory: buildWorkloadsTopContributors(snapshots, func(snapshot workloadsSummarySnapshot) float64 { - return snapshot.memory - }), - Disk: buildWorkloadsTopContributors(snapshots, func(snapshot workloadsSummarySnapshot) float64 { - return snapshot.disk - }), - Network: buildWorkloadsTopContributors(snapshots, func(snapshot workloadsSummarySnapshot) float64 { - return snapshot.network - }), - } - - blastRadius := WorkloadsSummaryBlastRadiusGroup{ - CPU: buildWorkloadsBlastRadius(snapshots, func(snapshot workloadsSummarySnapshot) float64 { - return snapshot.cpu - }), - Memory: buildWorkloadsBlastRadius(snapshots, func(snapshot workloadsSummarySnapshot) float64 { - return snapshot.memory - }), - Disk: buildWorkloadsBlastRadius(snapshots, func(snapshot workloadsSummarySnapshot) float64 { - return snapshot.disk - }), - Network: buildWorkloadsBlastRadius(snapshots, func(snapshot workloadsSummarySnapshot) float64 { - return snapshot.network - }), - } - - response := EmptyWorkloadsSummaryChartsResponse() - response.CPU = cpuMetric - response.Memory = memoryMetric - response.Disk = diskMetric - response.Network = networkMetric - response.GuestCounts = guestCounts - response.TopContributors = topContributors - response.BlastRadius = blastRadius - response.Timestamp = currentTime - response.Stats = ChartStats{ - OldestDataTimestamp: oldestTimestamp, - Range: timeRange, - RangeSeconds: int64(duration / time.Second), - MetricsStoreEnabled: metricsStoreEnabled, - PrimarySourceHint: primarySourceHint, - InMemoryThresholdSecs: int64(inMemoryChartThreshold / time.Second), - PointCounts: ChartPointCounts{ - Total: summaryPointCount, - Guests: guestPointCount, - }, - } - - payload, err := json.Marshal(response.NormalizeCollections()) - if err != nil { - log.Error().Err(err).Msg("Failed to encode workloads summary chart data response") - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - r.cacheWorkloadsSummaryChartsPayload(cacheKey, payload, now) - - w.Header().Set("Content-Type", "application/json") - if _, err := w.Write(payload); err != nil { - log.Error().Err(err).Msg("Failed to encode workloads summary chart data response") - return - } -} - -// guestWorkloadSummaryView is the guest view subset the workloads summary -// loop consumes from VMs and LXC containers. -type guestWorkloadSummaryView interface { - Status() unifiedresources.ResourceStatus - Name() string - CPUPercent() float64 - MemoryPercent() float64 - DiskPercent() float64 - NetIn() float64 - NetOut() float64 -} - -// appendGuestWorkloadSummaries accumulates workload-summary snapshots and -// chart points for one proxmox guest family (VMs or LXC containers), -// returning the extended snapshot slice and the number of points added. -func appendGuestWorkloadSummaries[V guestWorkloadSummaryView]( - guests []V, - responseKeys []string, - requests []monitoring.GuestChartRequest, - batchMetrics map[string]map[string][]monitoring.MetricPoint, - currentTimeTime time.Time, - guestCounts *WorkloadsGuestCounts, - buckets map[int64]*workloadSummaryBuckets, - snapshots []workloadsSummarySnapshot, - oldestTimestamp *int64, -) ([]workloadsSummarySnapshot, int) { - added := 0 - for idx, g := range guests { - responseKey := responseKeys[idx] - metricID := requests[idx].SQLResourceID - guestCounts.Total++ - if workloadSummaryStatusIsRunning("", g.Status()) { - guestCounts.Running++ - } else { - guestCounts.Stopped++ - } - - snapshot := workloadsSummarySnapshot{ - id: responseKey, - name: strings.TrimSpace(g.Name()), - cpu: clampWorkloadPercent(g.CPUPercent()), - memory: clampWorkloadPercent(g.MemoryPercent()), - disk: clampWorkloadPercent(g.DiskPercent()), - network: clampNonNegativeWorkloadValue(g.NetIn() + g.NetOut()), - } - if snapshot.name == "" { - snapshot.name = responseKey - } - - metrics := batchMetrics[metricID] - cpuPoints := metrics["cpu"] - if len(cpuPoints) == 0 { - cpuPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: g.CPUPercent()}} - } - memoryPoints := metrics["memory"] - if len(memoryPoints) == 0 { - memoryPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: g.MemoryPercent()}} - } - diskPoints := metrics["disk"] - if len(diskPoints) == 0 { - diskPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: g.DiskPercent()}} - } - netInPoints := metrics["netin"] - netOutPoints := metrics["netout"] - if len(netInPoints) == 0 && len(netOutPoints) == 0 { - netInPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: g.NetIn()}} - netOutPoints = []monitoring.MetricPoint{{Timestamp: currentTimeTime, Value: g.NetOut()}} - } - - networkPoints := mergeWorkloadNetworkPoints(netInPoints, netOutPoints) - - snapshot.cpu = latestSummaryMetricValue(cpuPoints, snapshot.cpu, clampWorkloadPercent) - snapshot.memory = latestSummaryMetricValue(memoryPoints, snapshot.memory, clampWorkloadPercent) - snapshot.disk = latestSummaryMetricValue(diskPoints, snapshot.disk, clampWorkloadPercent) - snapshot.network = latestSummaryMetricValue(networkPoints, snapshot.network, clampNonNegativeWorkloadValue) - - added += appendWorkloadMetricPoints(buckets, cpuPoints, "cpu", oldestTimestamp) - added += appendWorkloadMetricPoints(buckets, memoryPoints, "memory", oldestTimestamp) - added += appendWorkloadMetricPoints(buckets, diskPoints, "disk", oldestTimestamp) - added += appendWorkloadMetricPoints(buckets, networkPoints, "network", oldestTimestamp) - snapshots = append(snapshots, snapshot) - } - return snapshots, added -} - -func workloadSummaryStatusIsRunning(runtimeState string, status unifiedresources.ResourceStatus) bool { - switch strings.ToLower(strings.TrimSpace(runtimeState)) { - case "running", "online", "ok": - return true - case "stopped", "offline", "paused", "created", "dead", "exited": - return false - } - - switch status { - case unifiedresources.StatusOnline: - return true - case unifiedresources.StatusWarning: - // Warning is an attention state on a running workload (degraded - // guest state, stale source data); power-off maps to StatusOffline. - return true - case unifiedresources.StatusOffline: - return false - } - - return false -} - // handleStorageCharts returns pool capacity and physical disk temperature // time-series for the storage summary sparklines. func (r *Service) HandleStorageCharts(w http.ResponseWriter, req *http.Request) { @@ -3153,66 +2211,6 @@ func (r *Service) HandleStorageCharts(w http.ResponseWriter, req *http.Request) } } -// handleStorageSummaryCharts serves a compact aggregate capacity trend for the -// dashboard storage card. It intentionally avoids returning per-pool and -// per-disk series so the dashboard does not overfetch the full storage page -// payload. -func (r *Service) HandleStorageSummaryCharts(w http.ResponseWriter, req *http.Request) { - const inMemoryChartThreshold = 2 * time.Hour - - if req.Method != http.MethodGet && req.Method != http.MethodHead { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - query := req.URL.Query() - timeRange := query.Get("range") - if timeRange == "" { - timeRange = "24h" - } - duration := parseChartsRangeDuration(timeRange) - - monitor := r.getTenantMonitor(req.Context()) - if monitor == nil { - http.Error(w, "Tenant monitor is not available", http.StatusInternalServerError) - return - } - - currentTime := time.Now().UnixMilli() - capacity, oldestTimestamp := monitor.GetStorageSummaryCapacityTrend(duration) - if oldestTimestamp == 0 { - oldestTimestamp = currentTime - } - - metricsStoreEnabled := monitor.GetMetricsStore() != nil - primarySourceHint := "memory" - if metricsStoreEnabled && duration > inMemoryChartThreshold { - primarySourceHint = "store_or_memory_fallback" - } - - resp := EmptyStorageSummaryTrendResponse() - resp.Capacity = monitorPointsToAPI(capacity) - resp.Timestamp = currentTime - resp.Stats = ChartStats{ - OldestDataTimestamp: oldestTimestamp, - Range: timeRange, - RangeSeconds: int64(duration / time.Second), - MetricsStoreEnabled: metricsStoreEnabled, - PrimarySourceHint: primarySourceHint, - InMemoryThresholdSecs: int64(inMemoryChartThreshold / time.Second), - PointCounts: ChartPointCounts{ - Total: len(resp.Capacity), - Storage: len(resp.Capacity), - }, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(resp.NormalizeCollections()); err != nil { - log.Error().Err(err).Msg("Failed to encode storage summary chart data") - http.Error(w, "Internal server error", http.StatusInternalServerError) - } -} - // monitorPointsToAPI converts monitoring MetricPoints (time.Time timestamps) // to API MetricPoints (Unix millisecond timestamps) for JSON serialization. func monitorPointsToAPI(points []monitoring.MetricPoint) []MetricPoint { diff --git a/internal/api/chartapi/service_handlers_test.go b/internal/api/chartapi/service_handlers_test.go index d9e10d2062..be011c96d5 100644 --- a/internal/api/chartapi/service_handlers_test.go +++ b/internal/api/chartapi/service_handlers_test.go @@ -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) - } -} diff --git a/internal/api/chartapi/types.go b/internal/api/chartapi/types.go index 42646e9426..4a965e94ee 100644 --- a/internal/api/chartapi/types.go +++ b/internal/api/chartapi/types.go @@ -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"` diff --git a/internal/api/charts_compat.go b/internal/api/charts_compat.go index 74e539f3a1..b810fbf837 100644 --- a/internal/api/charts_compat.go +++ b/internal/api/charts_compat.go @@ -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) -} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 953ed0fba2..fb2b7d318c 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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) { diff --git a/internal/api/pure_helpers_branchcov0724pm_test.go b/internal/api/pure_helpers_branchcov0724pm_test.go index 0dfb2ad006..653531812c 100644 --- a/internal/api/pure_helpers_branchcov0724pm_test.go +++ b/internal/api/pure_helpers_branchcov0724pm_test.go @@ -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") - } - }) -} diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index e49d88383e..c70759e8d6 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -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", diff --git a/internal/api/router_bench_test.go b/internal/api/router_bench_test.go index f954789f45..650025f9ed 100644 --- a/internal/api/router_bench_test.go +++ b/internal/api/router_bench_test.go @@ -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) { diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index 8599aac087..4bc1fcc6ee 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -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))) diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 6176eed3b1..f39665f336 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -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", diff --git a/internal/api/slo.go b/internal/api/slo.go index 7f2bba9a6e..0c58d250d3 100644 --- a/internal/api/slo.go +++ b/internal/api/slo.go @@ -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 ) diff --git a/internal/api/slo_bench_test.go b/internal/api/slo_bench_test.go index 06edc3c98d..ea799c6d89 100644 --- a/internal/api/slo_bench_test.go +++ b/internal/api/slo_bench_test.go @@ -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 diff --git a/internal/api/types.go b/internal/api/types.go index 0fd4420d3d..427e173ba0 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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. diff --git a/internal/api/types_additional_test.go b/internal/api/types_additional_test.go index 195c087f2f..63c1d1c602 100644 --- a/internal/api/types_additional_test.go +++ b/internal/api/types_additional_test.go @@ -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) } diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index e2110c7103..0df5ff6094 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -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)`, }, }, diff --git a/internal/monitoring/mock_chart_history.go b/internal/monitoring/mock_chart_history.go index 7fceca8c43..507cd858ba 100644 --- a/internal/monitoring/mock_chart_history.go +++ b/internal/monitoring/mock_chart_history.go @@ -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) -} diff --git a/internal/monitoring/mock_metrics_history_test.go b/internal/monitoring/mock_metrics_history_test.go index d9defb1050..fbd475d8b2 100644 --- a/internal/monitoring/mock_metrics_history_test.go +++ b/internal/monitoring/mock_metrics_history_test.go @@ -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", diff --git a/internal/monitoring/monitor_metrics.go b/internal/monitoring/monitor_metrics.go index 1949f205f8..84312425d6 100644 --- a/internal/monitoring/monitor_metrics.go +++ b/internal/monitoring/monitor_metrics.go @@ -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 diff --git a/internal/monitoring/monitor_metrics_slo_test.go b/internal/monitoring/monitor_metrics_slo_test.go index cbd20fb9d6..48b7603be9 100644 --- a/internal/monitoring/monitor_metrics_slo_test.go +++ b/internal/monitoring/monitor_metrics_slo_test.go @@ -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) diff --git a/internal/monitoring/monitoring_cluster_trend_temp_branchcov0716_test.go b/internal/monitoring/monitoring_cluster_trend_temp_branchcov0716_test.go index 234ad2f894..2c9bc37131 100644 --- a/internal/monitoring/monitoring_cluster_trend_temp_branchcov0716_test.go +++ b/internal/monitoring/monitoring_cluster_trend_temp_branchcov0716_test.go @@ -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. diff --git a/pkg/metrics/store_large_seed_test.go b/pkg/metrics/store_large_seed_test.go index e3e5854366..6c9d177c9e 100644 --- a/pkg/metrics/store_large_seed_test.go +++ b/pkg/metrics/store_large_seed_test.go @@ -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) {