From 8d97bc3995a30e4fe3e4ed8f3dbf3e71ae8dd013 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 10 Apr 2026 17:32:30 +0100 Subject: [PATCH] Tighten dashboard summary hot paths --- .../v6/internal/subsystems/agent-lifecycle.md | 6 + .../v6/internal/subsystems/api-contracts.md | 8 + .../v6/internal/subsystems/cloud-paid.md | 8 + .../subsystems/performance-and-scalability.md | 17 +- .../internal/subsystems/storage-recovery.md | 22 +- .../internal/subsystems/unified-resources.md | 20 +- .../src/__tests__/App.architecture.test.ts | 2 + .../src/api/__tests__/chartsApi.test.ts | 13 + frontend-modern/src/api/charts.ts | 36 +- .../src/components/GitHubStarBanner.tsx | 11 +- ...esourceTable.performance.contract.test.tsx | 4 + .../useInfrastructureSummaryState.test.tsx | 17 +- .../useInfrastructureSummaryState.ts | 11 +- .../__tests__/GitHubStarBanner.test.tsx | 40 ++- .../__tests__/useDashboardTrends.test.ts | 59 ++-- .../__tests__/useUnifiedResources.test.ts | 83 ++++- .../src/hooks/useDashboardTrends.ts | 38 ++- .../src/hooks/useUnifiedResources.ts | 53 ++- frontend-modern/src/pages/Dashboard.tsx | 6 +- .../pages/__tests__/DashboardPage.test.tsx | 1 + frontend-modern/src/useAppRuntimeState.ts | 2 + .../infrastructureSummaryCache.test.ts | 28 +- .../src/utils/infrastructureSummaryCache.ts | 68 +++- .../src/utils/storageSummaryTrendCache.ts | 59 ++++ internal/api/contract_test.go | 70 ++++ internal/api/route_inventory_test.go | 1 + internal/api/router.go | 310 ++++++++++++++++-- internal/api/router_misc_additional_test.go | 104 ++++++ internal/api/router_routes_monitoring.go | 1 + internal/api/security_regression_test.go | 1 + internal/api/types.go | 20 ++ 31 files changed, 979 insertions(+), 140 deletions(-) create mode 100644 frontend-modern/src/utils/storageSummaryTrendCache.ts diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f51fae9ee..4db3e9aa6 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -197,6 +197,12 @@ an add-only capacity posture. 11. Keep shared `internal/api/router.go` workload-chart downsampling presentation-only: when that router caps mixed-cadence workload history into equal-time buckets for operator-facing cards, lifecycle-adjacent setup and fleet surfaces must not reuse the shaped chart samples as heartbeat, enrollment, or last-seen authority. That same presentation-only boundary must preserve canonical millisecond timestamps when it serializes chart points, so lifecycle-adjacent first-host and fleet surfaces do not misread rounded chart samples as duplicate or restarted heartbeat evidence. The same rule now applies to storage summary interaction. Shared sticky-card or row-hover focus behavior on infrastructure, workloads, and storage may reuse the canonical chart transport, but lifecycle-adjacent install, enrollment, and fleet surfaces must not treat highlighted summary series or sticky-shell state as agent freshness or setup progress. + The same rule now applies to infrastructure-summary metric filters. Shared + dashboard and infrastructure consumers may narrow the canonical + `/api/charts/infrastructure` payload with a `metrics` query for + presentation hot paths, but lifecycle surfaces must not reinterpret + omitted disk or network series as missing lifecycle telemetry, missing + agent capabilities, or reduced fleet freshness truth. 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. 12. Keep lifecycle installer fallback pinned to published release lineage only. When `internal/api/unified_agent.go` has to proxy `/install.sh` or diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 94f03db09..cd8fcef1c 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -244,6 +244,14 @@ when the disabled candidate no longer counts toward monitored-system capacity. metrics-target IDs, preserve millisecond chart timestamps, and avoid reconstructing storage summary behavior from per-pool `/api/metrics-store/history` fan-out. +35. Keep infrastructure summary metric filtering canonical on that same shared + API surface. `frontend-modern/src/api/charts.ts`, + `internal/api/router_routes_monitoring.go`, `internal/api/router.go`, + `internal/api/types.go`, and `internal/api/contract_test.go` must route + optional infrastructure-summary `metrics` filters through one governed + transport contract, so dashboard-specific consumers can request only CPU + and memory without inventing a second summary endpoint or silently widening + back to disk/network payloads. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 68fd1b8d3..344fecce8 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -144,6 +144,14 @@ still grows monitored-system usage. `/settings/system/billing/usage?details=counting-rules` for explanation and `/settings/system/billing/plan?intent=max_monitored_systems` for upgrade intent. +24. Keep public-demo dashboard bootstrap route-owned on the adjacent + commercial/runtime boundary. `frontend-modern/src/useAppRuntimeState.ts` + may prewarm shared infrastructure summary caches for non-dashboard routes, + but public-demo dashboard arrival must not front-run a broader + infrastructure-summary fetch than the route actually renders. Commercial + posture on `v6-demo` therefore stays governed by the route-owned + presentation policy and summary scope rather than by app-shell-wide + bootstrap heuristics. ## Forbidden Paths 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 55c2732f8..580b4c75d 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -9,7 +9,7 @@ "contract_file": "docs/release-control/v6/internal/subsystems/performance-and-scalability.md", "status_file": "docs/release-control/v6/internal/status.json", "registry_file": "docs/release-control/v6/internal/subsystems/registry.json", - "dependency_subsystem_ids": ["api-contracts", "frontend-primitives", "unified-resources"] + "dependency_subsystem_ids": ["api-contracts", "cloud-paid", "frontend-primitives", "storage-recovery", "unified-resources"] } ``` @@ -198,7 +198,9 @@ regression protection. chart-transport hot paths, fold summary-card caching into commercial callback behavior, or reuse those public auth endpoints as a justification for relaxing the protected history payload budgets that belong elsewhere. -30. Keep dashboard summary-chart fetches scope-owned rather than pagination-owned: `frontend-modern/src/hooks/useDashboardTrends.ts` must hydrate infrastructure and storage summaries once per org/range scope from the canonical summary caches and recompute card presentation locally as additional resource pages arrive, rather than refetching the infrastructure-summary transport in `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` or the storage-summary transport in `frontend-modern/src/utils/storageSummaryCache.ts` for every resource-id expansion on the same dashboard load. +30. Keep dashboard summary-chart fetches scope-owned rather than pagination-owned: `frontend-modern/src/hooks/useDashboardTrends.ts` must hydrate infrastructure and storage summaries once per org/range scope from the canonical summary caches and recompute card presentation locally as additional resource pages arrive, rather than refetching the infrastructure-summary transport in `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, the dashboard storage-summary trend transport in `frontend-modern/src/utils/storageSummaryTrendCache.ts`, or the storage-page summary transport in `frontend-modern/src/utils/storageSummaryCache.ts` for every resource-id expansion on the same dashboard load. That dashboard infrastructure path must also request only the metrics it renders through the canonical infrastructure-summary route owned by `internal/api/router_routes_monitoring.go` and `internal/api/router.go`; the dashboard may not pay for disk or network summary series when it only renders CPU and memory. App-shell prewarm in `frontend-modern/src/useAppRuntimeState.ts` must not front-run that dashboard-specific route while the operator is already on the root dashboard route owned by `frontend-modern/src/App.tsx`. +31. Keep the dashboard all-resources hot path websocket-first when the canonical snapshot is already imminent: `frontend-modern/src/pages/Dashboard.tsx` may request `initialHydration: 'prefer-ws'` from `frontend-modern/src/hooks/useUnifiedResources.ts` for the unfiltered dashboard resource surface, but that delay must stay bounded to one short initial-hydration window, remain limited to supported websocket-owned snapshots, and fall back to the canonical paginated unified-resource transport in `frontend-modern/src/hooks/useUnifiedResources.ts` when the websocket snapshot does not arrive in time. +32. Keep infrastructure summary consumers on the passed dashboard snapshot rather than reopening the all-resources hook. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` may derive infrastructure and workload rollups from `props.resources`, but it must not call `useResources()` or mount a second unfiltered unified-resource fetch path inside the summary hot path. ## Forbidden Paths @@ -344,11 +346,12 @@ the shared `isAgentFacetInfrastructureResource(...)` helper instead of a local infrastructure selector contract. That same protected dashboard hot path now includes storage trend loading too. `frontend-modern/src/hooks/useDashboardTrends.ts` and -`internal/api/router.go` must keep dashboard storage trends on one -`/api/storage-charts` request backed by -`GetStorageMetricsForChartBatch(...)`, so the dashboard does not reopen an -N+1 per-pool `/api/metrics-store/history` fan-out just to compute the shared -24-hour storage capacity delta. +`internal/api/router.go` must keep dashboard storage trends on one compact +`/api/charts/storage-summary` request backed by +`GetStorageMetricsForChartBatch(...)`, so the dashboard does not pull the full +storage-page `/api/storage-charts` payload or reopen an N+1 per-pool +`/api/metrics-store/history` fan-out just to compute the shared 24-hour +storage capacity delta. That same dashboard shell boundary also owns empty-state action routing in `frontend-modern/src/components/Dashboard/DashboardStateCards.tsx`. When the dashboard has no connected infrastructure, the CTA must hand operators diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 3aa04d225..b568ad8c1 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -216,14 +216,15 @@ querying, and the operator-facing storage health presentation layer. runtime as populated mock inventory, but they must not expose `demo_fixtures`, billing identity, or alternate entitlement semantics as recovery-local transport or operator-facing storage metadata. -37. Keep dashboard storage summary fetches scope-owned on the shared storage - summary cache. `frontend-modern/src/components/Storage/StorageSummary.tsx`, - `frontend-modern/src/utils/storageSummaryCache.ts`, and +37. Keep dashboard storage summary fetches scope-owned on the shared summary + caches. `frontend-modern/src/components/Storage/StorageSummary.tsx`, + `frontend-modern/src/utils/storageSummaryCache.ts`, + `frontend-modern/src/utils/storageSummaryTrendCache.ts`, and `frontend-modern/src/pages/Dashboard.tsx` may reuse cached org/range - storage summaries for first paint, but they must not refetch - `/api/storage-charts` once per additional dashboard resource page or invent - a dashboard-only storage summary transport path outside the canonical cache - owner. + storage summaries for first paint, but they must not refetch the full + `/api/storage-charts` payload once per additional dashboard resource page + or invent a dashboard-only storage summary transport path outside the + canonical cache owners. ## Forbidden Paths @@ -312,9 +313,10 @@ querying, and the operator-facing storage health presentation layer. shared sticky summary primitive instead of a storage-local scroll wrapper. Dashboard storage trends belong to that same owned summary contract: the dashboard may derive a 24-hour storage capacity delta from - `/api/storage-charts`, but it must not rebuild storage summary behavior by - fanning out per-pool `/api/metrics-store/history` reads or by inventing a - dashboard-only storage history transport. + `/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. 16. Keep storage summary interaction scoped through the same canonical IDs. 17. Keep adjacent AI settings persistence vendor-neutral on the shared `internal/api/` boundary. When storage- or recovery-adjacent hosted flows diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index e4fd927c9..c9a6fec40 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -152,6 +152,18 @@ assembly branch. snapshot freshness must come from websocket `state.resources` instead of layering confirmatory dashboard/infrastructure REST refetch loops over already-owned resource updates. +10. Keep websocket-first initial hydration bounded and canonical. When + `frontend-modern/src/pages/Dashboard.tsx` opts the unfiltered dashboard + surface into `initialHydration: 'prefer-ws'`, the wait must stay short, + must apply only to supported websocket-owned snapshots, and must fall back + to the canonical paginated unified-resource transport in + `frontend-modern/src/hooks/useUnifiedResources.ts` instead of inventing a + dashboard-only resource bootstrap path. +11. Keep summary consumers on the resource snapshot they were already given. + `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` + may derive workload and infrastructure rollups from `props.resources`, but + it must not reopen `useResources()` or start a second unfiltered + unified-resource fetch path under the infrastructure summary surface. ## Forbidden Paths @@ -283,8 +295,9 @@ assembly branch. truth. `frontend-modern/src/hooks/useDashboardTrends.ts` may detect storage presence from canonical `isStorage(...)` resources and their shared metrics-target IDs, but once storage exists it must reuse the owned - `/api/storage-charts` summary contract instead of rebuilding page-local - per-resource storage history fetches or storage-type aliases. + 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. ## Current State @@ -410,7 +423,8 @@ 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, and `/api/storage-charts` all see the same canonical history +selection, `/api/storage-charts`, and `/api/charts/storage-summary` all see +the same canonical history identity instead of splitting between view-cache resource IDs and API serialization-time metric IDs. That same VMware contract now also includes the identity rule. VMware managed diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 563b8f3bb..13f4bd7f5 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -106,6 +106,8 @@ describe('App architecture', () => { expect(appRuntimeStateSource).toContain( "eventBus.on('websocket_reconnected', handleWebSocketReconnected);", ); + expect(appRuntimeStateSource).toContain("const ROOT_DASHBOARD_PATH = '/dashboard';"); + expect(appRuntimeStateSource).toContain('if (pathname === ROOT_DASHBOARD_PATH) return false;'); expect(appRuntimeStateSource).not.toContain("import { startMetricsCollector } from '@/stores/metricsCollector';"); expect(appRuntimeStateSource).not.toContain('startMetricsCollector();'); expect(appRuntimeStateSource).not.toContain('function AppLayout('); diff --git a/frontend-modern/src/api/__tests__/chartsApi.test.ts b/frontend-modern/src/api/__tests__/chartsApi.test.ts index 2f1609302..95dc3a365 100644 --- a/frontend-modern/src/api/__tests__/chartsApi.test.ts +++ b/frontend-modern/src/api/__tests__/chartsApi.test.ts @@ -50,6 +50,19 @@ describe('ChartsAPI', () => { ); }); + it('builds infrastructure summary chart requests with explicit metric filters', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as any); + + await ChartsAPI.getInfrastructureSummaryCharts('1h', undefined, { + metrics: ['cpu', 'memory'], + }); + + expect(apiFetchJSONMock).toHaveBeenCalledWith( + '/api/charts/infrastructure?range=1h&metrics=cpu%2Cmemory', + { signal: undefined }, + ); + }); + it('calls workload-only charts endpoint with node and maxPoints', async () => { apiFetchJSONMock.mockResolvedValueOnce({} as any); diff --git a/frontend-modern/src/api/charts.ts b/frontend-modern/src/api/charts.ts index 3bc8102eb..e6983aa12 100644 --- a/frontend-modern/src/api/charts.ts +++ b/frontend-modern/src/api/charts.ts @@ -70,6 +70,15 @@ export interface InfrastructureChartsResponse { stats: ChartStats; } +export type InfrastructureSummaryMetric = + | 'cpu' + | 'memory' + | 'disk' + | 'diskread' + | 'diskwrite' + | 'netin' + | 'netout'; + export interface WorkloadChartsResponse { data: Record; dockerData?: Record; @@ -127,6 +136,12 @@ export interface WorkloadsSummaryChartsResponse { 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' | '30d' | '90d'; type MetricsHistoryAPIResourceType = @@ -272,12 +287,19 @@ export class ChartsAPI { private static buildChartsUrl( path: string, - params: { range: TimeRange; nodeId?: string | null }, + params: { + range: TimeRange; + nodeId?: string | null; + metrics?: readonly InfrastructureSummaryMetric[] | null; + }, ): string { const searchParams = new URLSearchParams({ range: params.range }); if (params.nodeId) { searchParams.set('node', params.nodeId); } + if (Array.isArray(params.metrics) && params.metrics.length > 0) { + searchParams.set('metrics', Array.from(new Set(params.metrics)).join(',')); + } return `${this.baseUrl}${path}?${searchParams.toString()}`; } @@ -301,11 +323,12 @@ export class ChartsAPI { static async getInfrastructureSummaryCharts( range: TimeRange = '1h', signal?: AbortSignal, - options?: { nodeId?: string | null }, + options?: { nodeId?: string | null; metrics?: readonly InfrastructureSummaryMetric[] | null }, ): Promise { const url = this.buildChartsUrl('/charts/infrastructure', { range, nodeId: options?.nodeId, + metrics: options?.metrics, }); return apiFetchJSON(url, { signal }); } @@ -406,6 +429,15 @@ 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/frontend-modern/src/components/GitHubStarBanner.tsx b/frontend-modern/src/components/GitHubStarBanner.tsx index cd89be509..7c618e715 100644 --- a/frontend-modern/src/components/GitHubStarBanner.tsx +++ b/frontend-modern/src/components/GitHubStarBanner.tsx @@ -5,7 +5,7 @@ import { STORAGE_KEYS, } from '@/utils/localStorage'; import { Dialog } from '@/components/shared/Dialog'; -import { useResources } from '@/hooks/useResources'; +import { useWebSocket } from '@/contexts/appRuntime'; import GithubIcon from 'lucide-solid/icons/github'; import StarIcon from 'lucide-solid/icons/star'; import XIcon from 'lucide-solid/icons/x'; @@ -17,7 +17,7 @@ function getTodayDateString(): string { } export function GitHubStarBanner() { - const { resources } = useResources(); + const { initialDataReceived, state } = useWebSocket(); // Track if user has dismissed the modal (permanent) const [dismissed, setDismissed] = createLocalStorageBooleanSignal( @@ -47,8 +47,13 @@ export function GitHubStarBanner() { return; } + if (!initialDataReceived()) { + setShowModal(false); + return; + } + // Check if user has connected infrastructure - const hasInfrastructure = resources().length > 0; + const hasInfrastructure = (state.resources || []).length > 0; if (!hasInfrastructure) { setShowModal(false); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx index 3d89f534f..32152ffcb 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx @@ -248,6 +248,10 @@ describe('UnifiedResourceTable performance contract', () => { expect(infrastructureSummaryStateSource).toContain('buildInfrastructureDisplaySeries'); expect(infrastructureSummaryStateSource).toContain('buildInfrastructureMetricSeries'); expect(infrastructureSummaryStateSource).toContain('buildInfrastructureEmptyMessage'); + expect(infrastructureSummaryStateSource).not.toContain('useResources('); + expect(infrastructureSummaryStateSource).toContain( + 'props.resources.filter((resource) => isWorkload(resource))', + ); expect(infrastructureSummaryStateSource).not.toContain( 'const match = allSeries.find((series) => series.id === focused);', ); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/useInfrastructureSummaryState.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/useInfrastructureSummaryState.test.tsx index 07c5df7b8..ca75d3e6d 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/useInfrastructureSummaryState.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/useInfrastructureSummaryState.test.tsx @@ -16,13 +16,6 @@ const { mockReadInfrastructureSummaryCache: vi.fn(() => null), })); -vi.mock('@/hooks/useResources', () => ({ - useResources: () => ({ - workloads: () => [], - resources: () => [], - }), -})); - vi.mock('@/utils/infrastructureSummaryCache', () => ({ fetchInfrastructureSummaryAndCache: mockFetchInfrastructureSummaryAndCache, readInfrastructureSummaryCache: mockReadInfrastructureSummaryCache, @@ -119,4 +112,14 @@ describe('useInfrastructureSummaryState', () => { expect(result.hasInteractiveResourceId('host-1')).toBe(true); }); + it('derives infrastructure and workload scope from the passed resource snapshot', async () => { + const stateSource = await import('../useInfrastructureSummaryState.ts?raw'); + + expect(stateSource.default).not.toContain('useResources('); + expect(stateSource.default).toContain('props.resources.filter((resource) => isWorkload(resource))'); + expect(stateSource.default).toContain( + 'props.resources.filter((resource) => isAgentFacetInfrastructureResource(resource))', + ); + }); + }); diff --git a/frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts b/frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts index 3cc3e2f24..fc55a22cc 100644 --- a/frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts +++ b/frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts @@ -1,6 +1,6 @@ import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; import type { ChartData, TimeRange } from '@/api/charts'; -import { useResources } from '@/hooks/useResources'; +import { isWorkload } from '@/types/resource'; import { useSummaryContextualFocusState, type SummaryChartHoverSync, @@ -76,10 +76,11 @@ export function useInfrastructureSummaryState(props: InfrastructureSummaryProps) const selectedRange = createMemo(() => props.timeRange || '1h'); const hasCurrentRangeCharts = createMemo(() => chartRange() === selectedRange()); const isCurrentRangeLoaded = createMemo(() => loadedRange() === selectedRange()); - - const { workloads, resources } = useResources(); const agentResources = createMemo(() => - resources().filter((resource) => isAgentFacetInfrastructureResource(resource)), + props.resources.filter((resource) => isAgentFacetInfrastructureResource(resource)), + ); + const workloadResources = createMemo(() => + props.resources.filter((resource) => isWorkload(resource)), ); const [orgVersion, setOrgVersion] = createSignal(0); @@ -310,7 +311,7 @@ export function useInfrastructureSummaryState(props: InfrastructureSummaryProps) const shouldShowNetworkCard = createMemo(() => shouldShowInfrastructureNetworkCard(hasNetData(), props.resources), ); - const workloadStats = createMemo(() => buildInfrastructureWorkloadStats(workloads())); + const workloadStats = createMemo(() => buildInfrastructureWorkloadStats(workloadResources())); const resourceCounts = createMemo(() => buildInfrastructureResourceCounts(props.resources)); const emptyMessage = createMemo(() => buildInfrastructureEmptyMessage(fetchFailed(), emptyHistoryLabel()), diff --git a/frontend-modern/src/components/__tests__/GitHubStarBanner.test.tsx b/frontend-modern/src/components/__tests__/GitHubStarBanner.test.tsx index 2b8a1b64a..e96f37321 100644 --- a/frontend-modern/src/components/__tests__/GitHubStarBanner.test.tsx +++ b/frontend-modern/src/components/__tests__/GitHubStarBanner.test.tsx @@ -1,5 +1,6 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import gitHubStarBannerSource from '../GitHubStarBanner.tsx?raw'; /* ------------------------------------------------------------------ */ /* Mocks */ @@ -7,11 +8,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; type BannerResource = { id: string; name: string }; -const mockResources = vi.hoisted(() => vi.fn<() => BannerResource[]>(() => [])); +const wsState = vi.hoisted(() => ({ resources: [] as BannerResource[] })); +const mockInitialDataReceived = vi.hoisted(() => vi.fn<() => boolean>(() => true)); -vi.mock('@/hooks/useResources', () => ({ - useResources: () => ({ - resources: mockResources, +vi.mock('@/contexts/appRuntime', () => ({ + useWebSocket: () => ({ + state: wsState, + initialDataReceived: mockInitialDataReceived, }), })); @@ -32,13 +35,12 @@ async function renderBanner() { render(() => ); } -/** Set mockResources to return N fake resources. */ +/** Seed N fake websocket resources into runtime state. */ function setResourceCount(n: number) { - const resources = Array.from({ length: n }, (_, i) => ({ + wsState.resources = Array.from({ length: n }, (_, i) => ({ id: `res-${i}`, name: `Resource ${i}`, })); - mockResources.mockReturnValue(resources); } /* ------------------------------------------------------------------ */ @@ -48,7 +50,8 @@ function setResourceCount(n: number) { describe('GitHubStarBanner', () => { beforeEach(() => { vi.useFakeTimers(); - mockResources.mockReturnValue([]); + wsState.resources = []; + mockInitialDataReceived.mockReturnValue(true); localStorage.clear(); }); @@ -61,13 +64,19 @@ describe('GitHubStarBanner', () => { /* ---------- Visibility: not shown scenarios ---------- */ it('does not render when there are no resources', async () => { - mockResources.mockReturnValue([]); + wsState.resources = []; await renderBanner(); expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); }); + it('keeps the shell banner on websocket runtime state instead of reopening unified resources', () => { + expect(gitHubStarBannerSource).toContain('useWebSocket'); + expect(gitHubStarBannerSource).toContain('initialDataReceived'); + expect(gitHubStarBannerSource).not.toContain('useResources'); + }); + it('does not render on the first day infrastructure is seen (records first-seen date)', async () => { vi.setSystemTime(new Date('2026-03-01T12:00:00Z')); setResourceCount(3); @@ -81,6 +90,17 @@ describe('GitHubStarBanner', () => { expect(localStorage.getItem(FIRST_SEEN_KEY)).toBe('2026-03-01'); }); + it('does not render before websocket initial data is available', async () => { + mockInitialDataReceived.mockReturnValue(false); + setResourceCount(3); + + await renderBanner(); + + expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); + const stored = localStorage.getItem(FIRST_SEEN_KEY); + expect(stored === null || stored === '').toBe(true); + }); + it('does not render when first-seen date is today (same day)', async () => { vi.setSystemTime(new Date('2026-03-01T12:00:00Z')); localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); @@ -275,7 +295,7 @@ describe('GitHubStarBanner', () => { it('does not write first-seen date when there are no resources', async () => { vi.setSystemTime(new Date('2026-03-01T12:00:00Z')); - mockResources.mockReturnValue([]); + wsState.resources = []; await renderBanner(); diff --git a/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts b/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts index f06c25033..70d72aa8e 100644 --- a/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts +++ b/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts @@ -17,9 +17,9 @@ vi.mock('@/utils/infrastructureSummaryCache', () => ({ readInfrastructureSummaryCache: vi.fn(), })); -vi.mock('@/utils/storageSummaryCache', () => ({ - fetchStorageSummaryAndCache: vi.fn(), - readStorageSummaryCache: vi.fn(), +vi.mock('@/utils/storageSummaryTrendCache', () => ({ + fetchStorageSummaryTrendAndCache: vi.fn(), + readStorageSummaryTrendCache: vi.fn(), })); vi.mock('@/components/Infrastructure/infrastructureSummaryModel', () => ({ @@ -56,9 +56,9 @@ import { readInfrastructureSummaryCache, } from '@/utils/infrastructureSummaryCache'; import { - fetchStorageSummaryAndCache, - readStorageSummaryCache, -} from '@/utils/storageSummaryCache'; + fetchStorageSummaryTrendAndCache, + readStorageSummaryTrendCache, +} from '@/utils/storageSummaryTrendCache'; function createResource(partial: Partial & Pick): Resource { return { @@ -198,17 +198,10 @@ describe('useDashboardTrends fetch scoping', () => { map: new Map(), oldestDataTimestamp: null, }); - vi.mocked(readStorageSummaryCache).mockReturnValue(null); - vi.mocked(fetchStorageSummaryAndCache).mockResolvedValue({ - pools: { - 'pool-a': { - name: 'Pool A', - usage: [], - used: createPoints([400, 500]), - avail: createPoints([600, 500]), - }, - }, - disks: {}, + vi.mocked(readStorageSummaryTrendCache).mockReturnValue(null); + vi.mocked(fetchStorageSummaryTrendAndCache).mockResolvedValue({ + capacity: createPoints([40, 50]), + timestamp: 1_700_000_060_000, stats: {} as never, }); }); @@ -233,7 +226,11 @@ describe('useDashboardTrends fetch scoping', () => { await vi.waitFor(() => { expect(vi.mocked(fetchInfrastructureSummaryAndCache)).toHaveBeenCalledTimes(1); - expect(vi.mocked(fetchStorageSummaryAndCache)).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetchStorageSummaryTrendAndCache)).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetchInfrastructureSummaryAndCache)).toHaveBeenCalledWith('1h', { + caller: 'useDashboardTrends', + metrics: ['cpu', 'memory'], + }); }); setResources([infrastructureA, infrastructureB, storageA, storageB]); @@ -242,7 +239,7 @@ describe('useDashboardTrends fetch scoping', () => { await Promise.resolve(); expect(vi.mocked(fetchInfrastructureSummaryAndCache)).toHaveBeenCalledTimes(1); - expect(vi.mocked(fetchStorageSummaryAndCache)).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetchStorageSummaryTrendAndCache)).toHaveBeenCalledTimes(1); expect(Array.from(trends().infrastructure.cpu.keys())).toEqual(['infra-a', 'infra-b']); dispose(); @@ -265,7 +262,11 @@ describe('useDashboardTrends fetch scoping', () => { await vi.waitFor(() => { expect(vi.mocked(fetchInfrastructureSummaryAndCache)).toHaveBeenCalledTimes(1); - expect(vi.mocked(fetchStorageSummaryAndCache)).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetchStorageSummaryTrendAndCache)).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetchInfrastructureSummaryAndCache)).toHaveBeenCalledWith('1h', { + caller: 'useDashboardTrends', + metrics: ['cpu', 'memory'], + }); }); setRange('12h'); @@ -273,8 +274,12 @@ describe('useDashboardTrends fetch scoping', () => { await vi.waitFor(() => { expect(vi.mocked(fetchInfrastructureSummaryAndCache)).toHaveBeenCalledTimes(2); }); + expect(vi.mocked(fetchInfrastructureSummaryAndCache)).toHaveBeenLastCalledWith('12h', { + caller: 'useDashboardTrends', + metrics: ['cpu', 'memory'], + }); - expect(vi.mocked(fetchStorageSummaryAndCache)).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetchStorageSummaryTrendAndCache)).toHaveBeenCalledTimes(1); dispose(); }); @@ -302,7 +307,7 @@ describe('useDashboardTrends fetch scoping', () => { await Promise.resolve(); expect(vi.mocked(fetchInfrastructureSummaryAndCache)).not.toHaveBeenCalled(); - expect(vi.mocked(fetchStorageSummaryAndCache)).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetchStorageSummaryTrendAndCache)).toHaveBeenCalledTimes(1); dispose(); }); @@ -313,6 +318,8 @@ describe('useDashboardTrends infrastructure routing', () => { expect(useDashboardTrendsSource).toContain('readInfrastructureSummaryCache'); expect(useDashboardTrendsSource).toContain('fetchInfrastructureSummaryAndCache'); expect(useDashboardTrendsSource).toContain("caller: 'useDashboardTrends'"); + expect(useDashboardTrendsSource).toContain("const DASHBOARD_INFRASTRUCTURE_METRICS"); + expect(useDashboardTrendsSource).toContain("metrics: DASHBOARD_INFRASTRUCTURE_METRICS"); expect(useDashboardTrendsSource).toContain('const infrastructureScopeKey'); expect(useDashboardTrendsSource).toContain('const hasInfrastructureResources'); expect(useDashboardTrendsSource).not.toContain('request.cpu.map(async'); @@ -320,11 +327,11 @@ describe('useDashboardTrends infrastructure routing', () => { }); it('routes storage trends through the storage summary charts endpoint', () => { - expect(useDashboardTrendsSource).toContain('readStorageSummaryCache'); - expect(useDashboardTrendsSource).toContain('fetchStorageSummaryAndCache(STORAGE_RANGE'); + expect(useDashboardTrendsSource).toContain('readStorageSummaryTrendCache'); + expect(useDashboardTrendsSource).toContain('fetchStorageSummaryTrendAndCache(STORAGE_RANGE'); expect(useDashboardTrendsSource).toContain('const storageScopeKey'); - expect(useDashboardTrendsSource).toContain('buildStorageCapacityTrendPoints(storageSummary.pools)'); - expect(useDashboardTrendsSource).not.toContain('metrics-store/history'); + expect(useDashboardTrendsSource).toContain('extractTrendData(storageSummary.capacity)'); + expect(useDashboardTrendsSource).not.toContain('/api/storage-charts'); expect(useDashboardTrendsSource).not.toContain('request.storage.map(async'); }); }); diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index af83ce0c9..b54cb3dab 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -95,6 +95,8 @@ const waitForValue = async (readValue: () => T, expected: T) => { describe('useUnifiedResources', () => { let apiFetchMock: ReturnType; let setWsState: SetStoreFunction; + let setWsConnected: (value: boolean) => boolean; + let setWsInitialDataReceived: (value: boolean) => boolean; let useUnifiedResources: UseUnifiedResourcesModule['useUnifiedResources']; let useStorageRecoveryResources: UseUnifiedResourcesModule['useStorageRecoveryResources']; let resetUnifiedResourcesCacheForTests: UseUnifiedResourcesModule['__resetUnifiedResourcesCacheForTests']; @@ -109,8 +111,10 @@ describe('useUnifiedResources', () => { json: async () => ({ data: [v2Resource] }), }); - const [connected] = createSignal(true); - const [initialDataReceived] = createSignal(true); + const [connected, _setConnected] = createSignal(true); + const [initialDataReceived, _setInitialDataReceived] = createSignal(true); + setWsConnected = _setConnected; + setWsInitialDataReceived = _setInitialDataReceived; const [state, _setWsState] = createStore({ connectedInfrastructure: [], metrics: [], @@ -214,6 +218,80 @@ describe('useUnifiedResources', () => { dispose(); }); + it('prefers websocket initial hydration over an immediate REST fetch for dashboard snapshots', async () => { + setWsConnected(false); + setWsInitialDataReceived(false); + setWsState('resources', []); + + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources({ + query: '', + cacheKey: 'all-resources', + initialHydration: 'prefer-ws', + }); + }); + + await flushAsync(); + expect(apiFetchMock).not.toHaveBeenCalled(); + expect(result!.loading()).toBe(true); + + batch(() => { + setWsConnected(true); + setWsState( + 'resources', + reconcile( + [ + createWsResource({ id: 'node-1', type: 'agent' }), + createWsResource({ id: 'storage-1', type: 'storage', name: 'storage-1' }), + ], + { key: 'id' }, + ), + ); + setWsState('lastUpdate', 1738843202000); + setWsInitialDataReceived(true); + }); + + await waitForResourceCount(() => result!.resources().length, 2); + expect(apiFetchMock).not.toHaveBeenCalled(); + expect(result!.loading()).toBe(false); + + await vi.advanceTimersByTimeAsync(2_000); + expect(apiFetchMock).not.toHaveBeenCalled(); + + dispose(); + }); + + it('falls back to REST when websocket initial hydration does not arrive in time', async () => { + setWsConnected(false); + setWsInitialDataReceived(false); + setWsState('resources', []); + + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources({ + query: '', + cacheKey: 'all-resources', + initialHydration: 'prefer-ws', + }); + }); + + await flushAsync(); + expect(apiFetchMock).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1_250); + await flushAsync(); + + expect(apiFetchMock).toHaveBeenCalledTimes(1); + expect(result!.resources().length).toBeGreaterThanOrEqual(1); + + dispose(); + }); + it('projects kubernetes clusterId from the canonical context prefix', async () => { apiFetchMock.mockResolvedValue({ ok: true, @@ -1197,6 +1275,7 @@ describe('useUnifiedResources', () => { it('uses the shared org scope helper for cache and fetch state', () => { expect(useUnifiedResourcesSource).toContain('normalizeOrgScope(getOrgID())'); expect(useUnifiedResourcesSource).toContain('supportsCanonicalWsHydration'); + expect(useUnifiedResourcesSource).toContain("initialHydration === 'prefer-ws'"); expect(useUnifiedResourcesSource).toContain('wsStore.state.resources'); expect(useUnifiedResourcesSource).not.toContain('const DEFAULT_ORG_SCOPE = \'default\''); expect(useUnifiedResourcesSource).not.toContain('const normalizeOrgScope ='); diff --git a/frontend-modern/src/hooks/useDashboardTrends.ts b/frontend-modern/src/hooks/useDashboardTrends.ts index 7798c24ea..279dfcf22 100644 --- a/frontend-modern/src/hooks/useDashboardTrends.ts +++ b/frontend-modern/src/hooks/useDashboardTrends.ts @@ -2,7 +2,8 @@ import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from import { type ChartData, type HistoryTimeRange, - type StorageSummaryChartsResponse, + type InfrastructureSummaryMetric, + type StorageSummaryTrendResponse, type TimeRange, } from '@/api/charts'; import { @@ -18,9 +19,9 @@ import { } from '@/utils/infrastructureSummaryCache'; import { normalizeOrgScope } from '@/utils/orgScope'; import { - fetchStorageSummaryAndCache, - readStorageSummaryCache, -} from '@/utils/storageSummaryCache'; + fetchStorageSummaryTrendAndCache, + readStorageSummaryTrendCache, +} from '@/utils/storageSummaryTrendCache'; import { isInfrastructure, isStorage, type Resource } from '@/types/resource'; export type TrendPoint = { @@ -49,6 +50,7 @@ export interface DashboardTrends { const INFRASTRUCTURE_RANGE: HistoryTimeRange = '1h'; const STORAGE_RANGE: TimeRange = '24h'; +const DASHBOARD_INFRASTRUCTURE_METRICS: InfrastructureSummaryMetric[] = ['cpu', 'memory']; function createEmptyTrendData(): TrendData { return { @@ -154,7 +156,15 @@ function buildInfrastructureTrendSnapshot( } export function buildStorageCapacityTrendPoints( - pools: StorageSummaryChartsResponse['pools'], + pools: Record< + string, + { + name?: string; + usage?: TrendPoint[]; + used?: TrendPoint[]; + avail?: TrendPoint[]; + } + >, ): TrendPoint[] { const buckets = new Map< number, @@ -243,13 +253,13 @@ export function extractTrendData(points: Array<{ timestamp: number; value: numbe } function buildStorageTrendSnapshot( - storageSummary: StorageSummaryChartsResponse | null, + storageSummary: StorageSummaryTrendResponse | null, ): DashboardTrends['storage'] { if (!storageSummary) { return createEmptyStorageTrends(); } return { - capacity: extractTrendData(buildStorageCapacityTrendPoints(storageSummary.pools)), + capacity: extractTrendData(storageSummary.capacity), }; } @@ -263,7 +273,7 @@ export function useDashboardTrends( new Map(), ); const [oldestDataTimestamp, setOldestDataTimestamp] = createSignal(null); - const [storageSummary, setStorageSummary] = createSignal( + const [storageSummary, setStorageSummary] = createSignal( null, ); const [infrastructureLoading, setInfrastructureLoading] = createSignal(false); @@ -322,7 +332,12 @@ export function useDashboardTrends( return; } - const cached = readInfrastructureSummaryCache(summaryRange); + const cached = readInfrastructureSummaryCache( + summaryRange, + undefined, + undefined, + DASHBOARD_INFRASTRUCTURE_METRICS, + ); if (cached) { setInfrastructureCharts(cached.map); setOldestDataTimestamp(cached.oldestDataTimestamp); @@ -336,6 +351,7 @@ export function useDashboardTrends( fetchInfrastructureSummaryAndCache(summaryRange, { caller: 'useDashboardTrends', + metrics: DASHBOARD_INFRASTRUCTURE_METRICS, }) .then((result) => { if (requestId !== latestInfrastructureRequestId) return; @@ -368,7 +384,7 @@ export function useDashboardTrends( return; } - const cached = readStorageSummaryCache(STORAGE_RANGE); + const cached = readStorageSummaryTrendCache(STORAGE_RANGE); if (cached) { setStorageSummary(cached); } @@ -376,7 +392,7 @@ export function useDashboardTrends( const requestId = ++latestStorageRequestId; setStorageLoading(true); - fetchStorageSummaryAndCache(STORAGE_RANGE, { caller: 'useDashboardTrends' }) + fetchStorageSummaryTrendAndCache(STORAGE_RANGE, { caller: 'useDashboardTrends' }) .then((result) => { if (requestId !== latestStorageRequestId) return; setStorageSummary(result); diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index d9cd05f28..92962cef2 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -41,6 +41,7 @@ const UNIFIED_RESOURCES_MAX_PAGES = 20; const UNIFIED_RESOURCES_CACHE_MAX_AGE_MS = 15_000; const UNIFIED_RESOURCES_WS_DEBOUNCE_MS = 800; const UNIFIED_RESOURCES_WS_MIN_REFETCH_INTERVAL_MS = 2_500; +const UNIFIED_RESOURCES_WS_INITIAL_HYDRATION_WAIT_MS = 1_200; type APIMetricValue = { value?: number; @@ -916,13 +917,17 @@ export const getCachedUnifiedResources = (options?: { type UseUnifiedResourcesOptions = { query?: string; cacheKey?: string; + initialHydration?: 'immediate' | 'prefer-ws'; }; export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { const query = normalizeUnifiedResourcesQuery(options?.query ?? DEFAULT_UNIFIED_RESOURCES_QUERY); const cacheKey = (options?.cacheKey || query || 'all').trim(); + const initialHydration = options?.initialHydration ?? 'immediate'; const typeFilter = parseUnifiedResourcesTypeFilter(query); const supportsCanonicalWsHydration = query === '' || typeFilter !== null; + const prefersWsInitialHydration = + initialHydration === 'prefer-ws' && supportsCanonicalWsHydration; const [orgScope, setOrgScope] = createSignal(normalizeOrgScope(getOrgID())); const resolveScopedCacheKey = () => buildScopedUnifiedResourcesCacheKey(cacheKey, orgScope()); let cacheEntry = seedUnifiedResourcesCacheFromAllResources( @@ -939,6 +944,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { const [error, setError] = createSignal(undefined); const wsStore = getGlobalWebSocketStore(); let refreshHandle: ReturnType | undefined; + let initialHydrationHandle: ReturnType | undefined; let inFlightRefetch: Promise | null = null; let wsInitialized = false; let lastWsUpdateToken = ''; @@ -1018,11 +1024,43 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { return resources as unknown as Resource[]; }; + const clearInitialHydrationTimeout = () => { + if (initialHydrationHandle !== undefined) { + clearTimeout(initialHydrationHandle); + initialHydrationHandle = undefined; + } + }; + + const shouldPreferWsInitialHydration = () => + prefersWsInitialHydration && + !cacheEntry.hasSnapshot && + !wsStore.initialDataReceived() && + (!Array.isArray(wsStore.state.resources) || wsStore.state.resources.length === 0); + + const scheduleInitialHydrationFallback = () => { + if (initialHydrationHandle !== undefined) { + return; + } + initialHydrationHandle = setTimeout(() => { + initialHydrationHandle = undefined; + if (cacheEntry.hasSnapshot || wsStore.initialDataReceived()) { + return; + } + void runRefetch({ source: 'initial' }).catch((err) => { + logger.warn('[useUnifiedResources] Failed deferred initial refresh', err); + }); + }, UNIFIED_RESOURCES_WS_INITIAL_HYDRATION_WAIT_MS); + }; + // If cache is stale, refresh it in the background without blocking initial render. if (!hasFreshUnifiedResourcesCache(cacheEntry)) { - void runRefetch({ source: 'initial' }).catch((err) => { - logger.warn('[useUnifiedResources] Failed background refresh for stale cache', err); - }); + if (shouldPreferWsInitialHydration()) { + scheduleInitialHydrationFallback(); + } else { + void runRefetch({ source: 'initial' }).catch((err) => { + logger.warn('[useUnifiedResources] Failed background refresh for stale cache', err); + }); + } } const scheduleRefetch = () => { @@ -1069,6 +1107,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { const wsResources = Array.isArray(wsStore.state.resources) ? wsStore.state.resources : []; const projectedResources = filterCanonicalUnifiedResources(wsResources, query, typeFilter); const now = Date.now(); + clearInitialHydrationTimeout(); const allResourcesEntry = getUnifiedResourcesCacheEntry( buildScopedUnifiedResourcesCacheKey(ALL_RESOURCES_CACHE_KEY, currentOrgScope), ); @@ -1138,6 +1177,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { inFlightRefetch = null; wsInitialized = false; lastWsUpdateToken = ''; + clearInitialHydrationTimeout(); const scopedResources = cacheEntry.resources; batch(() => { @@ -1147,12 +1187,17 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { }); if (!hasFreshUnifiedResourcesCache(cacheEntry)) { - void runRefetch({ force: true, source: 'initial' }).catch(() => undefined); + if (shouldPreferWsInitialHydration()) { + scheduleInitialHydrationFallback(); + } else { + void runRefetch({ force: true, source: 'initial' }).catch(() => undefined); + } } }); onCleanup(() => { unsubscribeOrgSwitch(); + clearInitialHydrationTimeout(); if (refreshHandle !== undefined) { clearTimeout(refreshHandle); } diff --git a/frontend-modern/src/pages/Dashboard.tsx b/frontend-modern/src/pages/Dashboard.tsx index ea93ef11c..7b1e93be0 100644 --- a/frontend-modern/src/pages/Dashboard.tsx +++ b/frontend-modern/src/pages/Dashboard.tsx @@ -40,7 +40,11 @@ export default function Dashboard() { const { connected, reconnecting, reconnect, activeAlerts } = useWebSocket(); // REST-backed resources: instant first paint, no WebSocket wait. - const dashboardResources = useUnifiedResources({ query: '', cacheKey: 'all-resources' }); + const dashboardResources = useUnifiedResources({ + query: '', + cacheKey: 'all-resources', + initialHydration: 'prefer-ws', + }); const resources = createMemo(() => dashboardResources.resources?.() ?? []); const alertsList = createMemo(() => diff --git a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx index b8c019a09..e336d3026 100644 --- a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx +++ b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx @@ -156,6 +156,7 @@ describe('Dashboard page module contract', () => { ); expect(dashboardPageSource).toContain("from '@/components/Storage/DashboardStoragePanel'"); expect(dashboardPageSource).toContain("cacheKey: 'all-resources'"); + expect(dashboardPageSource).toContain("initialHydration: 'prefer-ws'"); }); it('routes dashboard trend hydration through the shared dashboard resources snapshot', () => { diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index 75472425a..41c8994c8 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -76,6 +76,7 @@ export type AppConnectionStatus = { }; const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath(); +const ROOT_DASHBOARD_PATH = '/dashboard'; export const useAppRuntimeState = () => { initKioskMode(); @@ -114,6 +115,7 @@ export const useAppRuntimeState = () => { const pathname = window.location.pathname; if (!pathname) return true; if (pathname === ROOT_INFRASTRUCTURE_PATH) return false; + if (pathname === ROOT_DASHBOARD_PATH) return false; return true; }; diff --git a/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.test.ts b/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.test.ts index 08dcc1348..1cb17d7b2 100644 --- a/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.test.ts +++ b/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.test.ts @@ -54,8 +54,11 @@ const makeMetricSeries = (count: number, start = Date.now() - count * 30_000) => value: i % 100, })); -const cacheKeyForRange = (range: TimeRange, orgScope = 'default') => - `pulse.infrastructureSummaryCharts.${encodeURIComponent(orgScope)}::${range}`; +const cacheKeyForRange = ( + range: TimeRange, + orgScope = 'default', + metrics = 'cpu,memory,disk,diskread,diskwrite,netin,netout', +) => `pulse.infrastructureSummaryCharts.${encodeURIComponent(orgScope)}::${range}::${metrics}`; describe('infrastructureSummaryCache fetch dedupe', () => { beforeEach(() => { @@ -78,7 +81,9 @@ describe('infrastructureSummaryCache fetch dedupe', () => { const second = fetchInfrastructureSummaryAndCache('1h'); expect(mockGetCharts).toHaveBeenCalledTimes(1); - expect(mockGetCharts).toHaveBeenCalledWith('1h'); + expect(mockGetCharts).toHaveBeenCalledWith('1h', undefined, { + metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'], + }); resolveFetch?.(makeResponse()); @@ -98,6 +103,23 @@ describe('infrastructureSummaryCache fetch dedupe', () => { expect(readInfrastructureSummaryCache('24h')).not.toBeNull(); }); + it('separates dashboard metric-filter fetches from the full infrastructure cache', async () => { + mockGetCharts.mockImplementation((_range: TimeRange) => Promise.resolve(makeResponse())); + + await fetchInfrastructureSummaryAndCache('1h', { metrics: ['cpu', 'memory'] }); + await fetchInfrastructureSummaryAndCache('1h'); + + expect(mockGetCharts).toHaveBeenCalledTimes(2); + expect(mockGetCharts).toHaveBeenNthCalledWith(1, '1h', undefined, { + metrics: ['cpu', 'memory'], + }); + expect(mockGetCharts).toHaveBeenNthCalledWith(2, '1h', undefined, { + metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'], + }); + expect(readInfrastructureSummaryCache('1h', undefined, undefined, ['cpu', 'memory'])).not.toBeNull(); + expect(readInfrastructureSummaryCache('1h')).not.toBeNull(); + }); + it('merges overlapping agentData and dockerHostData keys without dropping richer network series', () => { const now = Date.now(); const response = { diff --git a/frontend-modern/src/utils/infrastructureSummaryCache.ts b/frontend-modern/src/utils/infrastructureSummaryCache.ts index a6591ad16..c5ef33d7a 100644 --- a/frontend-modern/src/utils/infrastructureSummaryCache.ts +++ b/frontend-modern/src/utils/infrastructureSummaryCache.ts @@ -2,6 +2,7 @@ import { ChartsAPI, type ChartData, type ChartsResponse, + type InfrastructureSummaryMetric, type InfrastructureChartsResponse, type TimeRange, } from '@/api/charts'; @@ -11,9 +12,11 @@ import { eventBus } from '@/stores/events'; export const INFRA_SUMMARY_CACHE_PREFIX = 'pulse.infrastructureSummaryCharts.'; export const INFRA_SUMMARY_CACHE_MAX_AGE_MS = 5 * 60_000; -const INFRA_SUMMARY_CACHE_VERSION = 4; +const INFRA_SUMMARY_CACHE_VERSION = 5; const INFRA_SUMMARY_CACHE_MAX_CHARS = 900_000; const INFRA_SUMMARY_CACHE_MAX_POINTS_PER_SERIES = 360; +const DEFAULT_INFRA_SUMMARY_METRICS = ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'] as const; +type NormalizedInfrastructureSummaryMetrics = readonly InfrastructureSummaryMetric[]; const INFRA_SUMMARY_PERF_LOG_PREFIX = '[InfraSummaryPerf]'; @@ -82,6 +85,7 @@ interface CachedInfrastructureSummary { version: number; range: TimeRange; cachedAt: number; + metrics: InfrastructureSummaryMetric[]; oldestDataTimestamp: number | null; charts: Record; } @@ -94,6 +98,7 @@ export interface InfrastructureSummaryCacheHit { export interface InfrastructureSummaryFetchOptions { caller?: string; + metrics?: readonly InfrastructureSummaryMetric[] | null; } export interface InfrastructureSummaryFetchResult { @@ -111,11 +116,33 @@ const toCachedChartData = (data: ChartData): CachedChartData => ({ netout: trimPoints(data.netout ?? [], INFRA_SUMMARY_CACHE_MAX_POINTS_PER_SERIES), }); -const inFlightKeyFor = (range: TimeRange, orgScope: string) => - `${encodeURIComponent(orgScope)}::${range}`; +function normalizeInfrastructureSummaryMetrics( + metrics?: readonly InfrastructureSummaryMetric[] | null, +): NormalizedInfrastructureSummaryMetrics { + if (!Array.isArray(metrics) || metrics.length === 0) { + return DEFAULT_INFRA_SUMMARY_METRICS; + } + return Array.from(new Set(metrics)) as NormalizedInfrastructureSummaryMetrics; +} -const cacheKeyForRange = (range: TimeRange, orgScope: string = normalizeOrgScope(getOrgID())) => - `${INFRA_SUMMARY_CACHE_PREFIX}${encodeURIComponent(orgScope)}::${range}`; +function infrastructureSummaryMetricsKey( + metrics?: readonly InfrastructureSummaryMetric[] | null, +): string { + return normalizeInfrastructureSummaryMetrics(metrics).join(','); +} + +const inFlightKeyFor = ( + range: TimeRange, + orgScope: string, + metrics?: readonly InfrastructureSummaryMetric[] | null, +) => `${encodeURIComponent(orgScope)}::${range}::${infrastructureSummaryMetricsKey(metrics)}`; + +const cacheKeyForRange = ( + range: TimeRange, + orgScope: string = normalizeOrgScope(getOrgID()), + metrics?: readonly InfrastructureSummaryMetric[] | null, +) => + `${INFRA_SUMMARY_CACHE_PREFIX}${encodeURIComponent(orgScope)}::${range}::${infrastructureSummaryMetricsKey(metrics)}`; function trimPoints(points: T[], max: number): T[] { if (points.length <= max) return points; @@ -184,11 +211,13 @@ export function persistInfrastructureSummaryCache( map: Map, oldestDataTimestamp: number | null, orgScope?: string, + metrics?: readonly InfrastructureSummaryMetric[] | null, ): void { if (typeof window === 'undefined') return; try { const scopedOrg = normalizeOrgScope(orgScope ?? getOrgID()); + const normalizedMetrics = normalizeInfrastructureSummaryMetrics(metrics); const charts: Record = {}; for (const [key, value] of map.entries()) { charts[key] = toCachedChartData(value); @@ -197,6 +226,7 @@ export function persistInfrastructureSummaryCache( version: INFRA_SUMMARY_CACHE_VERSION, range, cachedAt: Date.now(), + metrics: [...normalizedMetrics], oldestDataTimestamp, charts, }; @@ -204,11 +234,11 @@ export function persistInfrastructureSummaryCache( const serialized = JSON.stringify(payload); if (serialized.length > INFRA_SUMMARY_CACHE_MAX_CHARS) { // Avoid blowing past localStorage limits (and evicting unrelated app state). - window.localStorage.removeItem(cacheKeyForRange(range, scopedOrg)); + window.localStorage.removeItem(cacheKeyForRange(range, scopedOrg, normalizedMetrics)); return; } - window.localStorage.setItem(cacheKeyForRange(range, scopedOrg), serialized); + window.localStorage.setItem(cacheKeyForRange(range, scopedOrg, normalizedMetrics), serialized); } catch { // Ignore storage write failures. } @@ -218,12 +248,14 @@ export function readInfrastructureSummaryCache( range: TimeRange, maxAgeMs: number = INFRA_SUMMARY_CACHE_MAX_AGE_MS, orgScope?: string, + metrics?: readonly InfrastructureSummaryMetric[] | null, ): InfrastructureSummaryCacheHit | null { if (typeof window === 'undefined') return null; try { const scopedOrg = normalizeOrgScope(orgScope ?? getOrgID()); - const cacheKey = cacheKeyForRange(range, scopedOrg); + const normalizedMetrics = normalizeInfrastructureSummaryMetrics(metrics); + const cacheKey = cacheKeyForRange(range, scopedOrg, normalizedMetrics); const raw = window.localStorage.getItem(cacheKey); if (!raw) return null; @@ -231,9 +263,11 @@ export function readInfrastructureSummaryCache( if ( parsed?.version !== INFRA_SUMMARY_CACHE_VERSION || parsed.range !== range || + infrastructureSummaryMetricsKey(parsed.metrics) !== + infrastructureSummaryMetricsKey(normalizedMetrics) || typeof parsed.cachedAt !== 'number' ) { - window.localStorage.removeItem(cacheKeyForRange(range, scopedOrg)); + window.localStorage.removeItem(cacheKeyForRange(range, scopedOrg, normalizedMetrics)); return null; } @@ -276,8 +310,9 @@ export function readInfrastructureSummaryCache( export function hasFreshInfrastructureSummaryCache( range: TimeRange, maxAgeMs: number = INFRA_SUMMARY_CACHE_MAX_AGE_MS, + metrics?: readonly InfrastructureSummaryMetric[] | null, ): boolean { - return readInfrastructureSummaryCache(range, maxAgeMs) !== null; + return readInfrastructureSummaryCache(range, maxAgeMs, undefined, metrics) !== null; } const inFlightFetches = new Map>(); @@ -293,7 +328,8 @@ export function fetchInfrastructureSummaryAndCache( ): Promise { const caller = options?.caller || 'unknown'; const orgScope = normalizeOrgScope(getOrgID()); - const inFlightKey = inFlightKeyFor(range, orgScope); + const normalizedMetrics = normalizeInfrastructureSummaryMetrics(options?.metrics); + const inFlightKey = inFlightKeyFor(range, orgScope, normalizedMetrics); const existing = inFlightFetches.get(inFlightKey); if (existing) { @@ -303,9 +339,11 @@ export function fetchInfrastructureSummaryAndCache( const requestId = ++infraSummaryFetchSeq; const startedAt = infraSummaryPerfNow(); - infraSummaryPerfLog('fetch start', { caller, range, orgScope, requestId }); + infraSummaryPerfLog('fetch start', { caller, range, orgScope, requestId }); - const request = ChartsAPI.getInfrastructureSummaryCharts(range) + const request = ChartsAPI.getInfrastructureSummaryCharts(range, undefined, { + metrics: [...normalizedMetrics], + }) .then((response) => { const map = extractInfrastructureSummaryChartMapFromInfrastructureResponse(response); const oldestDataTimestamp = @@ -313,12 +351,13 @@ export function fetchInfrastructureSummaryAndCache( Number.isFinite(response.stats.oldestDataTimestamp) ? response.stats.oldestDataTimestamp : null; - persistInfrastructureSummaryCache(range, map, oldestDataTimestamp, orgScope); + persistInfrastructureSummaryCache(range, map, oldestDataTimestamp, orgScope, normalizedMetrics); infraSummaryPerfLog('fetch done', { caller, range, orgScope, + metrics: normalizedMetrics, requestId, ms: Math.round(infraSummaryPerfNow() - startedAt), series: map.size, @@ -336,6 +375,7 @@ export function fetchInfrastructureSummaryAndCache( caller, range, orgScope, + metrics: normalizedMetrics, requestId, ms: Math.round(infraSummaryPerfNow() - startedAt), error: error instanceof Error ? error.message : String(error), diff --git a/frontend-modern/src/utils/storageSummaryTrendCache.ts b/frontend-modern/src/utils/storageSummaryTrendCache.ts new file mode 100644 index 000000000..1ed4f0ddb --- /dev/null +++ b/frontend-modern/src/utils/storageSummaryTrendCache.ts @@ -0,0 +1,59 @@ +import { ChartsAPI, type StorageSummaryTrendResponse, type TimeRange } from '@/api/charts'; +import { eventBus } from '@/stores/events'; +import { getOrgID } from '@/utils/apiClient'; +import { normalizeOrgScope } from '@/utils/orgScope'; + +const STORAGE_SUMMARY_TREND_CACHE_VERSION = 1; + +function cacheKeyFor( + range: TimeRange, + orgScope: string = normalizeOrgScope(getOrgID()), +): string { + return `${STORAGE_SUMMARY_TREND_CACHE_VERSION}::${orgScope}::${range}`; +} + +const inMemoryCache = new Map(); +const inFlightFetches = new Map>(); + +export function readStorageSummaryTrendCache(range: TimeRange): StorageSummaryTrendResponse | null { + return inMemoryCache.get(cacheKeyFor(range)) ?? null; +} + +export function fetchStorageSummaryTrendAndCache( + range: TimeRange, + _options?: { caller?: string }, +): Promise { + const inFlightKey = cacheKeyFor(range); + const existing = inFlightFetches.get(inFlightKey); + if (existing) { + return existing; + } + + const request = ChartsAPI.getStorageSummaryTrend(range) + .then((response) => { + inMemoryCache.set(inFlightKey, response); + return response; + }) + .finally(() => { + inFlightFetches.delete(inFlightKey); + }); + + inFlightFetches.set(inFlightKey, request); + return request; +} + +export function __resetStorageSummaryTrendCacheForTests(): void { + inMemoryCache.clear(); + inFlightFetches.clear(); +} + +const unsubscribeStorageOrgSwitch = eventBus.on('org_switched', () => { + inMemoryCache.clear(); + inFlightFetches.clear(); +}); + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + unsubscribeStorageOrgSwitch(); + }); +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 73735e963..86e548b57 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -914,6 +914,76 @@ func TestContract_InfrastructureChartsNormalizeLongRangeMixedCadence(t *testing. } } +func TestContract_InfrastructureChartsHonorExplicitMetricFilters(t *testing.T) { + monitor, state, _ := newTestMonitor(t) + state.Nodes = []models.Node{{ + ID: "node-contract-1", + Name: "node-contract-1", + Status: "online", + CPU: 0.1, + Memory: models.Memory{Usage: 12.0}, + Disk: models.Disk{Usage: 34.0}, + }} + state.DockerHosts = []models.DockerHost{{ + ID: "docker-host-contract-1", + Runtime: "docker", + CPUUsage: 23.0, + Memory: models.Memory{Usage: 45.0}, + Disks: []models.Disk{{Usage: 67.0}}, + Status: "online", + }} + state.Hosts = []models.Host{{ + ID: "agent-contract-1", + Hostname: "agent-contract-1", + CPUUsage: 11.0, + Memory: models.Memory{Usage: 22.0}, + Disks: []models.Disk{{Usage: 33.0}}, + Status: "online", + }} + syncTestResourceStore(t, monitor, state) + + router := &Router{monitor: monitor} + req := httptest.NewRequest( + http.MethodGet, + "/api/charts/infrastructure?range=5m&metrics=cpu,memory", + nil, + ) + rec := httptest.NewRecorder() + + router.handleInfrastructureCharts(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var decoded InfrastructureChartsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { + t.Fatalf("unmarshal infrastructure charts response: %v", err) + } + + if decoded.Stats.PointCounts.Nodes != 2 { + t.Fatalf("expected stats.pointCounts.nodes=2, got %d", decoded.Stats.PointCounts.Nodes) + } + if decoded.Stats.PointCounts.DockerHosts != 2 { + t.Fatalf( + "expected stats.pointCounts.dockerHosts=2, got %d", + decoded.Stats.PointCounts.DockerHosts, + ) + } + if decoded.Stats.PointCounts.Agents != 2 { + t.Fatalf("expected stats.pointCounts.agents=2, got %d", decoded.Stats.PointCounts.Agents) + } + if _, ok := decoded.NodeData["node-contract-1"]["disk"]; ok { + t.Fatal("expected node disk series to be filtered out of infrastructure summary payload") + } + if _, ok := decoded.DockerHostData["docker-host-contract-1"]["disk"]; ok { + t.Fatal("expected docker-host disk series to be filtered out of infrastructure summary payload") + } + if _, ok := decoded.AgentData["agent-contract-1"]["disk"]; ok { + t.Fatal("expected agent disk series to be filtered out of infrastructure summary payload") + } +} + func TestContract_WorkloadChartsCapLongRangeMixedCadenceByTime(t *testing.T) { store := newTestMetricsStore(t) monitor, state, _ := newTestMonitor(t) diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index 96eef8555..2f9fdeb90 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -382,6 +382,7 @@ 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", diff --git a/internal/api/router.go b/internal/api/router.go index 8e4cc3f10..494356054 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -5897,6 +5897,59 @@ var sparklineMetrics = map[string]bool{ "netout": true, } +var infrastructureSummaryMetricOrder = []string{ + "cpu", + "memory", + "disk", + "diskread", + "diskwrite", + "netin", + "netout", +} + +func parseInfrastructureSummaryRequestedMetrics( + query url.Values, +) ([]string, map[string]bool, error) { + rawValues, ok := query["metrics"] + if !ok || len(rawValues) == 0 { + requested := make(map[string]bool, len(infrastructureSummaryMetricOrder)) + for _, metricType := range infrastructureSummaryMetricOrder { + requested[metricType] = true + } + return append([]string(nil), infrastructureSummaryMetricOrder...), requested, nil + } + + requestedList := make([]string, 0, len(infrastructureSummaryMetricOrder)) + requestedSet := make(map[string]bool, len(infrastructureSummaryMetricOrder)) + invalid := make([]string, 0) + + for _, rawValue := range rawValues { + for _, part := range strings.Split(rawValue, ",") { + metricType := strings.TrimSpace(strings.ToLower(part)) + if metricType == "" { + continue + } + if !sparklineMetrics[metricType] { + invalid = append(invalid, metricType) + continue + } + if requestedSet[metricType] { + continue + } + requestedSet[metricType] = true + requestedList = append(requestedList, metricType) + } + } + + if len(invalid) > 0 { + return nil, nil, fmt.Errorf("invalid infrastructure metrics filter: %s", strings.Join(invalid, ", ")) + } + if len(requestedList) == 0 { + return nil, nil, fmt.Errorf("infrastructure metrics filter must include at least one valid metric") + } + return requestedList, requestedSet, nil +} + func convertMetricsForChart( metrics map[string][]monitoring.MetricPoint, oldestTimestamp *int64, @@ -6494,6 +6547,11 @@ func (r *Router) handleInfrastructureCharts(w http.ResponseWriter, req *http.Req if timeRange == "" { timeRange = "1h" } + requestedMetricNames, requestedMetrics, err := parseInfrastructureSummaryRequestedMetrics(query) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } // Convert time range to duration. duration := parseChartsRangeDuration(timeRange) @@ -6517,7 +6575,12 @@ func (r *Router) handleInfrastructureCharts(w http.ResponseWriter, req *http.Req oldestTimestamp := currentTime // Process Nodes - batch-load historical data (1-2 SQL calls instead of NĂ—5). - nodeMetricTypes := []string{"cpu", "memory", "disk", "netin", "netout"} + nodeMetricTypes := make([]string, 0, 5) + for _, metricType := range []string{"cpu", "memory", "disk", "netin", "netout"} { + if requestedMetrics[metricType] { + nodeMetricTypes = append(nodeMetricTypes, metricType) + } + } nodeData := make(map[string]NodeChartData) nodeList := readState.Nodes() nodeIDs := make([]string, 0, len(nodeList)) @@ -6529,7 +6592,10 @@ func (r *Router) handleInfrastructureCharts(w http.ResponseWriter, req *http.Req nodeIDs = append(nodeIDs, nid) } } - nodeBatchMetrics := monitor.GetNodeMetricsForChartBatch(nodeIDs, nodeMetricTypes, duration) + nodeBatchMetrics := map[string]map[string][]monitoring.MetricPoint{} + if len(nodeMetricTypes) > 0 { + nodeBatchMetrics = monitor.GetNodeMetricsForChartBatch(nodeIDs, nodeMetricTypes, duration) + } for _, node := range nodeList { if node == nil { continue @@ -6559,23 +6625,24 @@ func (r *Router) handleInfrastructureCharts(w http.ResponseWriter, req *http.Req } } for _, metricType := range nodeMetricTypes { - if len(nodeData[nid][metricType]) == 0 { - var value float64 - hasFallbackValue := true - switch metricType { - case "cpu": - value = node.CPUPercent() - case "memory": - value = node.MemoryPercent() - case "disk": - value = node.DiskPercent() - default: - hasFallbackValue = false - } - if hasFallbackValue { - nodeData[nid][metricType] = []MetricPoint{ - {Timestamp: currentTime, Value: value}, - } + if len(nodeData[nid][metricType]) > 0 { + continue + } + var value float64 + hasFallbackValue := true + switch metricType { + case "cpu": + value = node.CPUPercent() + case "memory": + value = node.MemoryPercent() + case "disk": + value = node.DiskPercent() + default: + hasFallbackValue = false + } + if hasFallbackValue { + nodeData[nid][metricType] = []MetricPoint{ + {Timestamp: currentTime, Value: value}, } } } @@ -6609,7 +6676,7 @@ func (r *Router) handleInfrastructureCharts(w http.ResponseWriter, req *http.Req dockerHostData[dhID] = make(VMChartData) if batchMetrics, ok := dhBatchMetrics[dhID]; ok { for metricType, points := range batchMetrics { - if !sparklineMetrics[metricType] { + if !requestedMetrics[metricType] { continue } dockerHostData[dhID][metricType] = make([]MetricPoint, len(points)) @@ -6625,14 +6692,27 @@ func (r *Router) handleInfrastructureCharts(w http.ResponseWriter, req *http.Req } } } - if len(dockerHostData[dhID]["cpu"]) == 0 { - dockerHostData[dhID]["cpu"] = []MetricPoint{{Timestamp: currentTime, Value: dh.CPUPercent()}} - dockerHostData[dhID]["memory"] = []MetricPoint{{Timestamp: currentTime, Value: dh.MemoryPercent()}} - var diskPercent float64 - if disks := dh.Disks(); len(disks) > 0 { - diskPercent = disks[0].Usage + for _, metricType := range requestedMetricNames { + if len(dockerHostData[dhID][metricType]) > 0 { + continue + } + var value float64 + hasFallbackValue := true + switch metricType { + case "cpu": + value = dh.CPUPercent() + case "memory": + value = dh.MemoryPercent() + case "disk": + if disks := dh.Disks(); len(disks) > 0 { + value = disks[0].Usage + } + default: + hasFallbackValue = false + } + if hasFallbackValue { + dockerHostData[dhID][metricType] = []MetricPoint{{Timestamp: currentTime, Value: value}} } - dockerHostData[dhID]["disk"] = []MetricPoint{{Timestamp: currentTime, Value: diskPercent}} } normalizeInfrastructureSummaryChartSeries(dockerHostData[dhID], duration, currentTime) } @@ -6657,7 +6737,7 @@ func (r *Router) handleInfrastructureCharts(w http.ResponseWriter, req *http.Req agentData[hID] = make(VMChartData) if batchMetrics, ok := agentBatchMetrics[request.SQLResourceID]; ok { for metricType, points := range batchMetrics { - if !sparklineMetrics[metricType] { + if !requestedMetrics[metricType] { continue } agentData[hID][metricType] = make([]MetricPoint, len(points)) @@ -6673,10 +6753,25 @@ func (r *Router) handleInfrastructureCharts(w http.ResponseWriter, req *http.Req } } } - if len(agentData[hID]["cpu"]) == 0 { - agentData[hID]["cpu"] = []MetricPoint{{Timestamp: currentTime, Value: h.CPUPercent()}} - agentData[hID]["memory"] = []MetricPoint{{Timestamp: currentTime, Value: h.MemoryPercent()}} - agentData[hID]["disk"] = []MetricPoint{{Timestamp: currentTime, Value: h.DiskPercent()}} + for _, metricType := range requestedMetricNames { + if len(agentData[hID][metricType]) > 0 { + continue + } + var value float64 + hasFallbackValue := true + switch metricType { + case "cpu": + value = h.CPUPercent() + case "memory": + value = h.MemoryPercent() + case "disk": + value = h.DiskPercent() + default: + hasFallbackValue = false + } + if hasFallbackValue { + agentData[hID][metricType] = []MetricPoint{{Timestamp: currentTime, Value: value}} + } } normalizeInfrastructureSummaryChartSeries(agentData[hID], duration, currentTime) } @@ -7833,6 +7928,85 @@ func (r *Router) 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 *Router) 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 + } + readState := monitor.GetUnifiedReadStateOrSnapshot() + if readState == nil { + http.Error(w, "State unavailable", http.StatusInternalServerError) + return + } + + 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) + } + + currentTime := time.Now().UnixMilli() + capacity, oldestTimestamp := buildStorageSummaryCapacityTrend( + monitor.GetStorageMetricsForChartBatch(storageIDs, 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 = 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(capacity), + Storage: len(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 { @@ -7846,6 +8020,78 @@ func monitorPointsToAPI(points []monitoring.MetricPoint) []MetricPoint { return out } +func buildStorageSummaryCapacityTrend( + poolMetrics map[string]map[string][]monitoring.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: timestamp, + Value: (bucket.used / total) * 100, + }) + } + + return out, oldestTimestamp +} + // handleMetricsStoreStats returns statistics about the persistent metrics store func (r *Router) handleMetricsStoreStats(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { diff --git a/internal/api/router_misc_additional_test.go b/internal/api/router_misc_additional_test.go index d961452e4..9f5129858 100644 --- a/internal/api/router_misc_additional_test.go +++ b/internal/api/router_misc_additional_test.go @@ -583,6 +583,69 @@ func TestHandleInfrastructureCharts_Lightweight(t *testing.T) { } } +func TestHandleInfrastructureCharts_MetricFilter(t *testing.T) { + monitor, state, _ := newTestMonitor(t) + state.Nodes = []models.Node{{ + ID: "node-1", + Name: "node-one", + Status: "online", + CPU: 0.1, + Memory: models.Memory{Usage: 12.0}, + Disk: models.Disk{Usage: 34.0}, + }} + state.DockerHosts = []models.DockerHost{{ + ID: "docker-host-1", + Runtime: "docker", + CPUUsage: 23.0, + Memory: models.Memory{Usage: 45.0}, + Disks: []models.Disk{{Usage: 67.0}}, + Status: "online", + }} + state.Hosts = []models.Host{{ + ID: "host-1", + Hostname: "host-one", + CPUUsage: 11.0, + Memory: models.Memory{Usage: 22.0}, + Disks: []models.Disk{{Usage: 33.0}}, + Status: "online", + }} + syncTestResourceStore(t, monitor, state) + router := &Router{monitor: monitor} + + req := httptest.NewRequest(http.MethodGet, "/api/charts/infrastructure?range=5m&metrics=cpu,memory", nil) + rec := httptest.NewRecorder() + + router.handleInfrastructureCharts(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var decoded InfrastructureChartsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { + t.Fatalf("unmarshal InfrastructureChartsResponse: %v", err) + } + + if decoded.Stats.PointCounts.Nodes != 2 { + t.Fatalf("expected stats.pointCounts.nodes=2, got %d", decoded.Stats.PointCounts.Nodes) + } + if decoded.Stats.PointCounts.DockerHosts != 2 { + t.Fatalf("expected stats.pointCounts.dockerHosts=2, got %d", decoded.Stats.PointCounts.DockerHosts) + } + if decoded.Stats.PointCounts.Agents != 2 { + t.Fatalf("expected stats.pointCounts.agents=2, got %d", decoded.Stats.PointCounts.Agents) + } + if _, ok := decoded.NodeData["node-1"]["disk"]; ok { + t.Fatalf("expected disk series to be filtered out of node payload") + } + if _, ok := decoded.DockerHostData["docker-host-1"]["disk"]; ok { + t.Fatalf("expected disk series to be filtered out of docker host payload") + } + if _, ok := decoded.AgentData["host-1"]["disk"]; ok { + t.Fatalf("expected disk series to be filtered out of agent payload") + } +} + func TestHandleWorkloadsSummaryCharts_AggregatesAndCounts(t *testing.T) { monitor, state, _ := newTestMonitor(t) state.Nodes = []models.Node{{ @@ -1391,6 +1454,47 @@ func TestHandleStorageCharts_IncludesSupplementalStorageAndResolvesUnifiedNodeFi } } +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) + + router := &Router{monitor: monitor} + req := httptest.NewRequest(http.MethodGet, "/api/charts/storage-summary?range=1h", nil) + rec := httptest.NewRecorder() + + router.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) + } +} + func TestEstablishSession(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodGet, "/", nil) diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index 7e3fb3954..6b697f067 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -19,6 +19,7 @@ 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))) diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index a52c64bc4..1964e8600 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -2578,6 +2578,7 @@ 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/guests/metadata", diff --git a/internal/api/types.go b/internal/api/types.go index dffde3165..78036b23e 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -240,6 +240,26 @@ func (r WorkloadsSummaryChartsResponse) NormalizeCollections() WorkloadsSummaryC 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"`