From cceca653dc0e44b09d571f170cc8eb870dbf0af3 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 10 Apr 2026 18:30:39 +0100 Subject: [PATCH] Compact dashboard demo hot path --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/ai-runtime.md | 6 + .../v6/internal/subsystems/api-contracts.md | 10 + .../subsystems/performance-and-scalability.md | 8 +- .../internal/subsystems/storage-recovery.md | 7 + .../internal/subsystems/unified-resources.md | 24 +- .../src/api/__tests__/resources.test.ts | 48 ++ frontend-modern/src/api/resources.ts | 61 +++ .../src/components/AI/Chat/index.tsx | 14 +- .../monitoredSystemModelGuardrails.test.ts | 3 + .../__tests__/useDashboardTrends.test.ts | 237 +++++++--- .../src/hooks/useDashboardOverview.ts | 418 ++++++++++++++++-- .../src/hooks/useDashboardTrends.ts | 69 +-- frontend-modern/src/pages/Dashboard.tsx | 22 +- .../pages/__tests__/DashboardPage.test.tsx | 40 +- .../src/stores/__tests__/aiChat.test.ts | 10 + internal/api/resources.go | 390 ++++++++++++++++ internal/api/resources_test.go | 138 ++++++ internal/api/route_inventory_test.go | 1 + internal/api/router_routes_monitoring.go | 1 + internal/api/security_regression_test.go | 1 + 21 files changed, 1334 insertions(+), 179 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 4db3e9aa6..cc6c8b307 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -203,6 +203,11 @@ an add-only capacity posture. 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. + The same rule now applies to compact dashboard summary payloads. Shared + `internal/api/resources.go` summary routes may collapse resource counts, + problem rows, and top-resource rankings for dashboard hot paths, but + lifecycle surfaces must not treat `/api/resources/dashboard-summary` as + install inventory authority, enrollment proof, or 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/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 5f1fb4546..48a9dc1bd 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -60,6 +60,12 @@ runtime cost control, and shared AI transport surfaces. 6. Add or change AI provider, control-level, chat/session, or explore-state presentation through `frontend-modern/src/components/AI/Chat/`, `frontend-modern/src/utils/aiProviderPresentation.ts`, `frontend-modern/src/utils/aiProviderHealthPresentation.ts`, `frontend-modern/src/utils/aiControlLevelPresentation.ts`, `frontend-modern/src/utils/aiChatPresentation.ts`, `frontend-modern/src/utils/aiSessionDiffPresentation.ts`, and `frontend-modern/src/utils/aiExplorePresentation.ts` 7. Keep AI chat presentation helpers aligned through `frontend-modern/src/components/AI/Chat/` and the shared `frontend-modern/src/utils/textPresentation.ts` 8. Keep assistant drawer context, session, and org-switch reset state aligned through the shared `frontend-modern/src/stores/aiChat.ts` boundary instead of letting `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, or feature callers fork their own assistant shell state + That shared drawer ownership also covers passive resource reads while the + shell is mounted but closed. `frontend-modern/src/components/AI/Chat/` + may consume the live websocket snapshot or the existing unified-resource + cache for assistant context and suggestions, but it must not reopen + `useResources()` or trigger a second unfiltered `all-resources` REST fetch + just because the drawer component is present in the app shell. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index cd8fcef1c..1676916ac 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -252,6 +252,16 @@ when the disabled candidate no longer counts toward monitored-system capacity. 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. +36. Keep the compact dashboard overview route canonical on that same shared API + surface. `internal/api/resources.go`, + `internal/api/router_routes_monitoring.go`, + `frontend-modern/src/api/resources.ts`, + `frontend-modern/src/hooks/useDashboardOverview.ts`, and frontend dashboard + consumers must route KPI cards, problem-resource rows, governed resource + labels, top-infrastructure identity, and canonical metrics-target join keys + through `/api/resources/dashboard-summary` instead of reconstructing that + shell from the paginated `/api/resources` list payload or guessing how + dashboard trend identities map onto infrastructure chart series. ## 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 580b4c75d..ebe4c02e4 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", "cloud-paid", "frontend-primitives", "storage-recovery", "unified-resources"] + "dependency_subsystem_ids": ["ai-runtime", "api-contracts", "cloud-paid", "frontend-primitives", "storage-recovery", "unified-resources"] } ``` @@ -198,9 +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`, 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. +30. Keep dashboard summary-chart fetches scope-owned rather than page-churn-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 the compact dashboard overview changes, 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 top-resource or card reshuffle 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 overview hot path compact and route-owned. `frontend-modern/src/pages/Dashboard.tsx`, `frontend-modern/src/api/resources.ts`, and `frontend-modern/src/hooks/useDashboardOverview.ts` must hydrate KPI cards, problem-resource rows, and top-infrastructure identities through the compact dashboard-summary API contract owned by the adjacent `api-contracts` and `unified-resources` surfaces, rather than booting the full unfiltered paginated unified-resource list just to derive summary cards. +32. Keep infrastructure summary consumers on the compact dashboard overview rather than reopening the all-resources hook. `frontend-modern/src/hooks/useDashboardTrends.ts`, `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, and adjacent dashboard summary consumers may derive chart identity and storage presence from the overview payload they were already given, but they must not call `useResources()` or mount a second unfiltered unified-resource fetch path inside the dashboard hot path. That rule also applies to globally mounted helpers such as `frontend-modern/src/components/AI/Chat/index.tsx`: closed assistant surfaces must read the live websocket snapshot or existing unified-resource cache rather than forcing the dashboard to pay for `all-resources` just because the shell component is mounted. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index b568ad8c1..d16d1cb92 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -181,6 +181,13 @@ querying, and the operator-facing storage health presentation layer. 32. Keep infrastructure summary chart bucketing presentation-only on the adjacent shared API boundary. When `internal/api/router.go` normalizes mixed-cadence infrastructure history into equal-time summary buckets for operator-facing summary cards, storage and recovery may consume the resulting visual context only; they must not reinterpret those normalized chart samples as recovery freshness windows, backup cadence, or restore evidence. 33. Keep workload chart downsampling presentation-only on that same adjacent shared API boundary. When `internal/api/router.go` caps mixed-cadence workload history into equal-time buckets for operator-facing workload cards, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples as recovery freshness windows, backup cadence, or restore evidence. 34. Keep storage and recovery websocket reads on the neutral app-runtime boundary. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx`, `frontend-modern/src/components/Storage/useStoragePageResources.ts`, and storage/recovery-adjacent dashboard composition may consume live websocket state only through `frontend-modern/src/contexts/appRuntime.ts`, not by importing `frontend-modern/src/App.tsx` or rebuilding shell-local providers. + That same dashboard composition boundary also owns compact summary reads. + When `frontend-modern/src/pages/Dashboard.tsx` renders storage and + recovery-adjacent cards from `/api/resources/dashboard-summary` and + `/api/charts/storage-summary`, those cards may reuse the compact payloads + they were already given, but they must not reopen paginated + `useUnifiedResources()` transport or reintroduce per-pool + `/api/metrics-store/history` fan-out under the dashboard hot path. 35. 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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index c9a6fec40..dc2b7c131 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -152,18 +152,20 @@ 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. +10. Keep the dashboard overview shell on the compact governed summary route + rather than the unfiltered list transport. `frontend-modern/src/pages/Dashboard.tsx` + and `frontend-modern/src/hooks/useDashboardOverview.ts` may consume the + canonical `/api/resources/dashboard-summary` payload for KPI cards, + problem-resource rows, and top-resource identity, but they must not + recreate those summaries by mounting `useUnifiedResources()` just to count + or rank resources on the dashboard shell. +11. Keep summary consumers on the payload they were already given. + `frontend-modern/src/hooks/useDashboardTrends.ts` and `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. + may derive chart identity, storage presence, and infrastructure rollups + from the compact dashboard overview or resource snapshot they already own, + but they must not reopen `useResources()` or start a second unfiltered + unified-resource fetch path under the dashboard summary surface. ## Forbidden Paths diff --git a/frontend-modern/src/api/__tests__/resources.test.ts b/frontend-modern/src/api/__tests__/resources.test.ts index 2368dfc75..1c8955f34 100644 --- a/frontend-modern/src/api/__tests__/resources.test.ts +++ b/frontend-modern/src/api/__tests__/resources.test.ts @@ -11,6 +11,54 @@ describe('ResourceAPI', () => { vi.clearAllMocks(); }); + it('fetches the compact dashboard summary payload from the dashboard-summary endpoint', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + health: { totalResources: 4, byStatus: { online: 3, degraded: 1 } }, + infrastructure: { + total: 2, + byStatus: { online: 2 }, + byType: { agent: 1, 'docker-host': 1 }, + topCPU: [ + { + id: 'infra-a', + name: 'Infra A', + percent: 91, + metricsTarget: { resourceType: 'agent', resourceId: 'host-a' }, + }, + ], + topMemory: [ + { + id: 'infra-a', + name: 'Infra A', + percent: 82, + metricsTarget: { resourceType: 'agent', resourceId: 'host-a' }, + }, + ], + }, + workloads: { total: 1, running: 1, stopped: 0, byType: { vm: 1 } }, + storage: { + total: 1, + totalCapacity: 1_000, + totalUsed: 850, + warningCount: 1, + criticalCount: 0, + }, + problemResources: [], + } as any); + + const result = await ResourceAPI.getDashboardSummary(); + + expect(apiFetchJSON).toHaveBeenCalledWith('/api/resources/dashboard-summary', { + cache: 'no-store', + }); + expect(result.health.totalResources).toBe(4); + expect(result.infrastructure.topCPU[0]?.id).toBe('infra-a'); + expect(result.infrastructure.topCPU[0]?.metricsTarget).toEqual({ + resourceType: 'agent', + resourceId: 'host-a', + }); + }); + it('fetches the resource history bundle from the facet endpoint', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ resourceId: 'vm:42', diff --git a/frontend-modern/src/api/resources.ts b/frontend-modern/src/api/resources.ts index b654516ba..493c1cff3 100644 --- a/frontend-modern/src/api/resources.ts +++ b/frontend-modern/src/api/resources.ts @@ -5,6 +5,8 @@ import type { ResourceChangeSourceAdapter, ResourceChangeSourceType, ResourceFacetCounts, + ResourceMetricsTarget, + ResourcePolicy, } from '@/types/resource'; export interface ResourceTimelineQueryOptions { @@ -26,6 +28,61 @@ export interface ResourceFacetBundle { counts: ResourceFacetCounts; } +export interface DashboardOverviewSummaryTopResource { + id: string; + name: string; + percent: number; + metricsTarget?: ResourceMetricsTarget; +} + +export interface DashboardOverviewSummaryProblemResource { + id: string; + type: string; + name: string; + status: string; + lastSeen?: string; + sources?: string[]; + aiSafeSummary?: string; + policy?: ResourcePolicy; + canonicalIdentity?: { + displayName?: string; + hostname?: string; + platformId?: string; + primaryId?: string; + aliases?: string[]; + }; + problems: string[]; + worstValue: number; +} + +export interface DashboardOverviewSummaryResponse { + health: { + totalResources: number; + byStatus: Record; + }; + infrastructure: { + total: number; + byStatus: Record; + byType: Record; + topCPU: DashboardOverviewSummaryTopResource[]; + topMemory: DashboardOverviewSummaryTopResource[]; + }; + workloads: { + total: number; + running: number; + stopped: number; + byType: Record; + }; + storage: { + total: number; + totalCapacity: number; + totalUsed: number; + warningCount: number; + criticalCount: number; + }; + problemResources: DashboardOverviewSummaryProblemResource[]; +} + const normalizeResourceId = (resourceId: string): string => resourceId.trim(); const buildFacetPath = (resourceId: string, suffix: string): string => @@ -61,6 +118,10 @@ const fetchFacet = async (url: string): Promise => }); export class ResourceAPI { + static async getDashboardSummary(): Promise { + return fetchFacet('/api/resources/dashboard-summary'); + } + static async getTimeline( resourceId: string, options?: ResourceTimelineQueryOptions, diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index b50519b9b..b9c2d9d46 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -29,7 +29,7 @@ import { normalizeAIControlLevel, type AIControlLevel, } from '@/utils/aiControlLevelPresentation'; -import { useResources } from '@/hooks/useResources'; +import { getCachedUnifiedResources } from '@/hooks/useUnifiedResources'; import type { Resource } from '@/types/resource'; import { isAppContainerDiscoveryResourceType } from '@/utils/discoveryTarget'; import { @@ -37,6 +37,7 @@ import { isAgentFacetInfrastructureResource, } from '@/utils/agentResources'; import { normalizeChatMentionKeyPart } from '@/utils/chatIdentifiers'; +import { getGlobalWebSocketStore } from '@/stores/websocket-global'; import { getPreferredResourceDisplayName, getPreferredResourceHostname, @@ -77,7 +78,16 @@ export const AIChat: Component = (props) => { const [discoveryEnabled, setDiscoveryEnabled] = createSignal(null); // null = loading const [discoveryHintDismissed, setDiscoveryHintDismissed] = createSignal(false); const [autonomousBannerDismissed, setAutonomousBannerDismissed] = createSignal(false); - const { byType, resources: allResources } = useResources(); + const wsStore = getGlobalWebSocketStore(); + const allResources = createMemo(() => { + const liveResources = wsStore.state.resources ?? []; + if (Array.isArray(liveResources) && liveResources.length > 0) { + return liveResources; + } + return getCachedUnifiedResources({ cacheKey: 'all-resources' }); + }); + const byType = (type: Resource['type']) => + allResources().filter((resource) => resource.type === type); const isCluster = createMemo(() => byType('agent').length > 1); // @ mention autocomplete state diff --git a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts index 2db94aae1..5a43453ad 100644 --- a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts @@ -1058,6 +1058,9 @@ describe('monitored-system model guardrails', () => { it('keeps AI chat mention resources aware of agent facets beyond host type', () => { expect(aiChatSource).toContain('isAgentFacetInfrastructureResource'); + expect(aiChatSource).toContain('getGlobalWebSocketStore'); + expect(aiChatSource).toContain('getCachedUnifiedResources'); + expect(aiChatSource).not.toContain('useResources()'); expect(aiChatSource).toContain('const agentResources = allResources().filter((resource) =>'); expect(aiChatSource).toContain('getActionableAgentIdFromResource(resource) || resource.id;'); expect(aiChatSource).not.toContain("resource.type === 'truenas'"); diff --git a/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts b/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts index 70d72aa8e..67d0e7604 100644 --- a/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts +++ b/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts @@ -1,6 +1,6 @@ import { createRoot, createSignal } from 'solid-js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Resource } from '@/types/resource'; +import type { DashboardOverviewSummary } from '@/hooks/useDashboardOverview'; vi.mock('@/utils/apiClient', () => ({ getOrgID: () => 'test-org', @@ -22,27 +22,6 @@ vi.mock('@/utils/storageSummaryTrendCache', () => ({ readStorageSummaryTrendCache: vi.fn(), })); -vi.mock('@/components/Infrastructure/infrastructureSummaryModel', () => ({ - buildInfrastructureEmptyHistoryLabel: vi.fn(() => null), - buildInfrastructureSummarySeries: vi.fn((resources: Resource[]) => - resources - .filter((resource) => - ['agent', 'docker-host', 'k8s-cluster', 'k8s-node'].includes(resource.type), - ) - .map((resource) => ({ - id: resource.id, - cpu: [ - { timestamp: 1_700_000_000_000, value: 20 }, - { timestamp: 1_700_000_060_000, value: 30 }, - ], - memory: [ - { timestamp: 1_700_000_000_000, value: 40 }, - { timestamp: 1_700_000_060_000, value: 50 }, - ], - })), - ), -})); - import useDashboardTrendsSource from '@/hooks/useDashboardTrends.ts?raw'; import { buildStorageCapacityTrendPoints, @@ -60,19 +39,41 @@ import { readStorageSummaryTrendCache, } from '@/utils/storageSummaryTrendCache'; -function createResource(partial: Partial & Pick): Resource { +function createOverviewSummary( + overrides: Partial = {}, +): DashboardOverviewSummary { return { - ...partial, - id: partial.id, - type: partial.type, - name: partial.name ?? partial.id, - displayName: partial.displayName ?? partial.name ?? partial.id, - platformId: partial.platformId ?? 'platform-1', - platformType: partial.platformType ?? 'proxmox', - sourceType: partial.sourceType ?? 'proxmox', - status: partial.status ?? 'online', - lastSeen: partial.lastSeen ?? 1_700_000_000_000, - } as Resource; + health: { + totalResources: 0, + byStatus: {}, + ...overrides.health, + }, + infrastructure: { + total: 0, + byStatus: {}, + byType: {}, + topCPU: [], + topMemory: [], + ...overrides.infrastructure, + }, + workloads: { + total: 0, + running: 0, + stopped: 0, + byType: {}, + ...overrides.workloads, + }, + storage: { + total: 0, + totalCapacity: 0, + totalUsed: 0, + warningCount: 0, + criticalCount: 0, + ...overrides.storage, + }, + problemResources: [], + ...overrides, + }; } function createPoints(values: number[]): TrendPoint[] { @@ -195,7 +196,22 @@ describe('useDashboardTrends fetch scoping', () => { vi.clearAllMocks(); vi.mocked(readInfrastructureSummaryCache).mockReturnValue(null); vi.mocked(fetchInfrastructureSummaryAndCache).mockResolvedValue({ - map: new Map(), + map: new Map([ + [ + 'infra-a', + { + cpu: createPoints([20, 30]), + memory: createPoints([40, 50]), + }, + ], + [ + 'infra-b', + { + cpu: createPoints([45, 55]), + memory: createPoints([65, 75]), + }, + ], + ]), oldestDataTimestamp: null, }); vi.mocked(readStorageSummaryTrendCache).mockReturnValue(null); @@ -206,22 +222,52 @@ describe('useDashboardTrends fetch scoping', () => { }); }); - it('does not refetch dashboard summary charts as paginated resources expand within the same scope', async () => { - const infrastructureA = createResource({ id: 'infra-a', type: 'agent' }); - const infrastructureB = createResource({ id: 'infra-b', type: 'agent' }); - const storageA = createResource({ id: 'storage-a', type: 'storage' }); - const storageB = createResource({ id: 'storage-b', type: 'storage' }); + it('does not refetch dashboard summary charts as the compact overview expands within the same scope', async () => { + const initialOverview = createOverviewSummary({ + health: { totalResources: 2, byStatus: { online: 2 } }, + infrastructure: { + total: 1, + byStatus: { online: 1 }, + byType: { agent: 1 }, + topCPU: [{ id: 'infra-a', name: 'Infra A', percent: 55 }], + topMemory: [{ id: 'infra-a', name: 'Infra A', percent: 75 }], + }, + storage: { + total: 1, + totalCapacity: 1000, + totalUsed: 500, + warningCount: 0, + criticalCount: 0, + }, + }); + const expandedOverview = createOverviewSummary({ + ...initialOverview, + health: { totalResources: 4, byStatus: { online: 4 } }, + infrastructure: { + total: 2, + byStatus: { online: 2 }, + byType: { agent: 2 }, + topCPU: [ + { id: 'infra-a', name: 'Infra A', percent: 55 }, + { id: 'infra-b', name: 'Infra B', percent: 45 }, + ], + topMemory: [ + { id: 'infra-a', name: 'Infra A', percent: 75 }, + { id: 'infra-b', name: 'Infra B', percent: 65 }, + ], + }, + }); let dispose!: () => void; let trends!: ReturnType; - let setResources!: (value: Resource[]) => void; + let setOverview!: (value: DashboardOverviewSummary) => void; createRoot((d) => { dispose = d; - const [resources, setResourcesSignal] = createSignal([infrastructureA, storageA]); - setResources = setResourcesSignal; + const [overview, setOverviewSignal] = createSignal(initialOverview); + setOverview = setOverviewSignal; const [range] = createSignal<'1h'>('1h'); - trends = useDashboardTrends(resources, range); + trends = useDashboardTrends(overview, range); }); await vi.waitFor(() => { @@ -233,7 +279,7 @@ describe('useDashboardTrends fetch scoping', () => { }); }); - setResources([infrastructureA, infrastructureB, storageA, storageB]); + setOverview(expandedOverview); await Promise.resolve(); await Promise.resolve(); @@ -245,19 +291,82 @@ describe('useDashboardTrends fetch scoping', () => { dispose(); }); + it('joins dashboard sparkline rankings through canonical metrics targets instead of unified resource ids', async () => { + const overview = createOverviewSummary({ + health: { totalResources: 2, byStatus: { online: 2 } }, + infrastructure: { + total: 1, + byStatus: { online: 1 }, + byType: { agent: 1 }, + topCPU: [ + { + id: 'agent-a', + name: 'Infra A', + percent: 55, + metricsTarget: { resourceType: 'agent', resourceId: 'infra-a' }, + }, + ], + topMemory: [ + { + id: 'agent-a', + name: 'Infra A', + percent: 75, + metricsTarget: { resourceType: 'agent', resourceId: 'infra-a' }, + }, + ], + }, + }); + + let dispose!: () => void; + let trends!: ReturnType; + + createRoot((d) => { + dispose = d; + const [summary] = createSignal(overview); + const [range] = createSignal<'1h'>('1h'); + trends = useDashboardTrends(summary, range); + }); + + await vi.waitFor(() => { + expect(vi.mocked(fetchInfrastructureSummaryAndCache)).toHaveBeenCalledTimes(1); + }); + + expect(Array.from(trends().infrastructure.cpu.keys())).toEqual(['agent-a']); + expect(trends().infrastructure.cpu.get('agent-a')?.points).toEqual(createPoints([20, 30])); + expect(Array.from(trends().infrastructure.memory.keys())).toEqual(['agent-a']); + expect(trends().infrastructure.memory.get('agent-a')?.points).toEqual(createPoints([40, 50])); + + dispose(); + }); + it('refetches infrastructure charts when the dashboard trend range changes without refetching storage charts', async () => { - const infrastructureA = createResource({ id: 'infra-a', type: 'agent' }); - const storageA = createResource({ id: 'storage-a', type: 'storage' }); + const overview = createOverviewSummary({ + health: { totalResources: 2, byStatus: { online: 2 } }, + infrastructure: { + total: 1, + byStatus: { online: 1 }, + byType: { agent: 1 }, + topCPU: [{ id: 'infra-a', name: 'Infra A', percent: 55 }], + topMemory: [{ id: 'infra-a', name: 'Infra A', percent: 75 }], + }, + storage: { + total: 1, + totalCapacity: 1000, + totalUsed: 500, + warningCount: 0, + criticalCount: 0, + }, + }); let dispose!: () => void; let setRange!: (value: '1h' | '12h') => void; createRoot((d) => { dispose = d; - const [resources] = createSignal([infrastructureA, storageA]); + const [summary] = createSignal(overview); const [range, setRangeSignal] = createSignal<'1h' | '12h'>('1h'); setRange = setRangeSignal; - useDashboardTrends(resources, range); + useDashboardTrends(summary, range); }); await vi.waitFor(() => { @@ -278,15 +387,29 @@ describe('useDashboardTrends fetch scoping', () => { caller: 'useDashboardTrends', metrics: ['cpu', 'memory'], }); - expect(vi.mocked(fetchStorageSummaryTrendAndCache)).toHaveBeenCalledTimes(1); dispose(); }); it('reuses a fresh infrastructure summary cache instead of immediately refetching the same scope', async () => { - const infrastructureA = createResource({ id: 'infra-a', type: 'agent' }); - const storageA = createResource({ id: 'storage-a', type: 'storage' }); + const overview = createOverviewSummary({ + health: { totalResources: 2, byStatus: { online: 2 } }, + infrastructure: { + total: 1, + byStatus: { online: 1 }, + byType: { agent: 1 }, + topCPU: [{ id: 'infra-a', name: 'Infra A', percent: 55 }], + topMemory: [{ id: 'infra-a', name: 'Infra A', percent: 75 }], + }, + storage: { + total: 1, + totalCapacity: 1000, + totalUsed: 500, + warningCount: 0, + criticalCount: 0, + }, + }); vi.mocked(readInfrastructureSummaryCache).mockReturnValue({ map: new Map(), @@ -298,9 +421,9 @@ describe('useDashboardTrends fetch scoping', () => { createRoot((d) => { dispose = d; - const [resources] = createSignal([infrastructureA, storageA]); + const [summary] = createSignal(overview); const [range] = createSignal<'1h'>('1h'); - useDashboardTrends(resources, range); + useDashboardTrends(summary, range); }); await Promise.resolve(); @@ -318,10 +441,12 @@ 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 DASHBOARD_INFRASTRUCTURE_METRICS'); + expect(useDashboardTrendsSource).toContain('metrics: DASHBOARD_INFRASTRUCTURE_METRICS'); expect(useDashboardTrendsSource).toContain('const infrastructureScopeKey'); expect(useDashboardTrendsSource).toContain('const hasInfrastructureResources'); + expect(useDashboardTrendsSource).toContain('buildMetricTrendMap'); + expect(useDashboardTrendsSource).not.toContain('buildInfrastructureSummarySeries'); expect(useDashboardTrendsSource).not.toContain('request.cpu.map(async'); expect(useDashboardTrendsSource).not.toContain('request.memory.map(async'); }); diff --git a/frontend-modern/src/hooks/useDashboardOverview.ts b/frontend-modern/src/hooks/useDashboardOverview.ts index eaa1bb0b1..de655a88d 100644 --- a/frontend-modern/src/hooks/useDashboardOverview.ts +++ b/frontend-modern/src/hooks/useDashboardOverview.ts @@ -1,10 +1,26 @@ -import { createMemo, type Accessor } from 'solid-js'; +import { batch, createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js'; +import { + ResourceAPI, + type DashboardOverviewSummaryProblemResource, + type DashboardOverviewSummaryResponse, +} from '@/api/resources'; import type { Alert } from '@/types/api'; -import type { Resource, ResourceMetric, ResourceStatus } from '@/types/resource'; +import type { + Resource, + ResourceMetric, + ResourceMetricsTarget, + ResourceStatus, + ResourceType, +} from '@/types/resource'; import { isInfrastructure, isStorage, isWorkload } from '@/types/resource'; +import { getOrgID } from '@/utils/apiClient'; import { METRIC_THRESHOLDS, getMetricSeverity } from '@/utils/metricThresholds'; -import { OFFLINE_HEALTH_STATUSES, DEGRADED_HEALTH_STATUSES } from '@/utils/status'; +import { normalizeOrgScope } from '@/utils/orgScope'; import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity'; +import { resolvePlatformTypeFromSources, resolveSourceTypeFromSources } from '@/utils/sourcePlatforms'; +import { OFFLINE_HEALTH_STATUSES, DEGRADED_HEALTH_STATUSES } from '@/utils/status'; +import { eventBus } from '@/stores/events'; +import { getGlobalWebSocketStore } from '@/stores/websocket-global'; export interface ProblemResource { resource: Resource; @@ -12,19 +28,17 @@ export interface ProblemResource { worstValue: number; // 0–100 for metrics, 200 for offline, 150 for degraded } -export interface DashboardOverview { +export interface DashboardOverviewSummary { health: { totalResources: number; byStatus: Record; - criticalAlerts: number; - warningAlerts: number; }; infrastructure: { total: number; byStatus: Record; byType: Record; - topCPU: Array<{ id: string; name: string; percent: number }>; - topMemory: Array<{ id: string; name: string; percent: number }>; + topCPU: Array<{ id: string; name: string; percent: number; metricsTarget?: ResourceMetricsTarget }>; + topMemory: Array<{ id: string; name: string; percent: number; metricsTarget?: ResourceMetricsTarget }>; }; workloads: { total: number; @@ -39,12 +53,19 @@ export interface DashboardOverview { warningCount: number; criticalCount: number; }; + problemResources: ProblemResource[]; +} + +export interface DashboardOverview extends DashboardOverviewSummary { alerts: { activeCritical: number; activeWarning: number; total: number; }; - problemResources: ProblemResource[]; + health: DashboardOverviewSummary['health'] & { + criticalAlerts: number; + warningAlerts: number; + }; } const RESOURCE_STATUSES: ResourceStatus[] = [ @@ -57,6 +78,20 @@ const RESOURCE_STATUSES: ResourceStatus[] = [ 'unknown', ]; +const DASHBOARD_OVERVIEW_CACHE_MAX_AGE_MS = 15_000; +const DASHBOARD_OVERVIEW_WS_DEBOUNCE_MS = 800; +const DASHBOARD_OVERVIEW_WS_MIN_REFETCH_INTERVAL_MS = 2_500; + +type DashboardOverviewCacheEntry = { + summary: DashboardOverviewSummary; + hasSnapshot: boolean; + cachedAt: number; + lastFetchAt: number; + sharedFetch: Promise | null; +}; + +const dashboardOverviewCaches = new Map(); + function createStatusCounter(): Record { return RESOURCE_STATUSES.reduce>((acc, status) => { acc[status] = 0; @@ -64,6 +99,36 @@ function createStatusCounter(): Record { }, {}); } +function createEmptyDashboardOverviewSummary(): DashboardOverviewSummary { + return { + health: { + totalResources: 0, + byStatus: createStatusCounter(), + }, + infrastructure: { + total: 0, + byStatus: createStatusCounter(), + byType: {}, + topCPU: [], + topMemory: [], + }, + workloads: { + total: 0, + running: 0, + stopped: 0, + byType: {}, + }, + storage: { + total: 0, + totalCapacity: 0, + totalUsed: 0, + warningCount: 0, + criticalCount: 0, + }, + problemResources: [], + }; +} + function incrementCount(counter: Record, key: string): void { counter[key] = (counter[key] ?? 0) + 1; } @@ -120,7 +185,7 @@ function buildProblemResources(resources: Resource[]): ProblemResource[] { const status = resource.status; if (OFFLINE_HEALTH_STATUSES.has(status)) { problems.push('Offline'); - worstValue = 200; // Sort offline first + worstValue = 200; } else if (DEGRADED_HEALTH_STATUSES.has(status)) { problems.push('Degraded'); worstValue = Math.max(worstValue, 150); @@ -153,10 +218,176 @@ function buildProblemResources(resources: Resource[]): ProblemResource[] { return results.slice(0, 8); } -export function computeDashboardOverview( - resources: Resource[], - activeAlerts: Alert[], +function countAlerts(alerts: Alert[]) { + return alerts.reduce( + (acc, alert) => { + if (alert.level === 'critical') acc.critical += 1; + if (alert.level === 'warning') acc.warning += 1; + return acc; + }, + { critical: 0, warning: 0 }, + ); +} + +function mergeDashboardAlertCounts( + summary: DashboardOverviewSummary, + alerts: Alert[], ): DashboardOverview { + const alertCounts = countAlerts(alerts); + return { + ...summary, + health: { + ...summary.health, + criticalAlerts: alertCounts.critical, + warningAlerts: alertCounts.warning, + }, + alerts: { + activeCritical: alertCounts.critical, + activeWarning: alertCounts.warning, + total: alerts.length, + }, + }; +} + +function hasFreshDashboardOverviewCache(entry: DashboardOverviewCacheEntry) { + return entry.hasSnapshot && Date.now() - entry.cachedAt <= DASHBOARD_OVERVIEW_CACHE_MAX_AGE_MS; +} + +function setDashboardOverviewCache( + entry: DashboardOverviewCacheEntry, + summary: DashboardOverviewSummary, + at = Date.now(), +) { + entry.summary = summary; + entry.hasSnapshot = true; + entry.cachedAt = at; +} + +function getDashboardOverviewCacheEntry(cacheKey: string): DashboardOverviewCacheEntry { + const existing = dashboardOverviewCaches.get(cacheKey); + if (existing) { + return existing; + } + + const created: DashboardOverviewCacheEntry = { + summary: createEmptyDashboardOverviewSummary(), + hasSnapshot: false, + cachedAt: 0, + lastFetchAt: 0, + sharedFetch: null, + }; + dashboardOverviewCaches.set(cacheKey, created); + return created; +} + +function toSummaryProblemResource( + problem: DashboardOverviewSummaryProblemResource, +): ProblemResource { + const sources = (problem.sources ?? []).filter( + (source): source is string => typeof source === 'string' && source.trim().length > 0, + ); + const lastSeen = problem.lastSeen ? Date.parse(problem.lastSeen) : NaN; + const canonical = problem.canonicalIdentity; + const displayName = canonical?.displayName?.trim() || problem.name?.trim() || problem.id; + + return { + resource: { + id: problem.id, + type: problem.type as ResourceType, + name: displayName, + displayName, + platformId: canonical?.platformId?.trim() || problem.id, + platformType: resolvePlatformTypeFromSources(sources) || 'agent', + sourceType: resolveSourceTypeFromSources(sources), + status: (problem.status?.trim().toLowerCase() || 'unknown') as ResourceStatus, + lastSeen: Number.isFinite(lastSeen) ? lastSeen : 0, + canonicalIdentity: canonical, + policy: problem.policy, + aiSafeSummary: problem.aiSafeSummary, + }, + problems: Array.isArray(problem.problems) ? problem.problems : [], + worstValue: Number.isFinite(problem.worstValue) ? problem.worstValue : 0, + }; +} + +function normalizeSummaryResponse( + response: DashboardOverviewSummaryResponse, +): DashboardOverviewSummary { + const empty = createEmptyDashboardOverviewSummary(); + const healthByStatus = createStatusCounter(); + const infrastructureByStatus = createStatusCounter(); + + for (const [status, count] of Object.entries(response.health?.byStatus ?? {})) { + healthByStatus[status] = Number.isFinite(count) ? count : 0; + } + for (const [status, count] of Object.entries(response.infrastructure?.byStatus ?? {})) { + infrastructureByStatus[status] = Number.isFinite(count) ? count : 0; + } + + return { + health: { + totalResources: response.health?.totalResources ?? 0, + byStatus: healthByStatus, + }, + infrastructure: { + total: response.infrastructure?.total ?? 0, + byStatus: infrastructureByStatus, + byType: { ...(response.infrastructure?.byType ?? {}) }, + topCPU: Array.isArray(response.infrastructure?.topCPU) ? response.infrastructure.topCPU : [], + topMemory: Array.isArray(response.infrastructure?.topMemory) + ? response.infrastructure.topMemory + : [], + }, + workloads: { + total: response.workloads?.total ?? 0, + running: response.workloads?.running ?? 0, + stopped: response.workloads?.stopped ?? 0, + byType: { ...(response.workloads?.byType ?? {}) }, + }, + storage: { + total: response.storage?.total ?? 0, + totalCapacity: response.storage?.totalCapacity ?? 0, + totalUsed: response.storage?.totalUsed ?? 0, + warningCount: response.storage?.warningCount ?? 0, + criticalCount: response.storage?.criticalCount ?? 0, + }, + problemResources: Array.isArray(response.problemResources) + ? response.problemResources.map(toSummaryProblemResource) + : empty.problemResources, + }; +} + +async function fetchDashboardOverviewSummary(entry: DashboardOverviewCacheEntry, force = false) { + if (!force && hasFreshDashboardOverviewCache(entry)) { + return entry.summary; + } + if (entry.sharedFetch) { + return entry.sharedFetch; + } + + const request = (async () => { + const response = await ResourceAPI.getDashboardSummary(); + const summary = normalizeSummaryResponse(response); + const now = Date.now(); + setDashboardOverviewCache(entry, summary, now); + entry.lastFetchAt = now; + return summary; + })(); + + entry.sharedFetch = request; + try { + return await request; + } finally { + if (entry.sharedFetch === request) { + entry.sharedFetch = null; + } + } +} + +const shouldThrottleWsRefetch = (entry: DashboardOverviewCacheEntry) => + Date.now() - entry.lastFetchAt < DASHBOARD_OVERVIEW_WS_MIN_REFETCH_INTERVAL_MS; + +export function computeDashboardOverviewSummary(resources: Resource[]): DashboardOverviewSummary { const healthByStatus = createStatusCounter(); const infrastructureByStatus = createStatusCounter(); const infrastructureByType: Record = {}; @@ -212,21 +443,10 @@ export function computeDashboardOverview( } }); - const alertCounts = activeAlerts.reduce( - (acc, alert) => { - if (alert.level === 'critical') acc.critical += 1; - if (alert.level === 'warning') acc.warning += 1; - return acc; - }, - { critical: 0, warning: 0 }, - ); - return { health: { totalResources: resources.length, byStatus: healthByStatus, - criticalAlerts: alertCounts.critical, - warningAlerts: alertCounts.warning, }, infrastructure: { total: infrastructureResources.length, @@ -248,18 +468,148 @@ export function computeDashboardOverview( warningCount: storageWarningCount, criticalCount: storageCriticalCount, }, - alerts: { - activeCritical: alertCounts.critical, - activeWarning: alertCounts.warning, - total: activeAlerts.length, - }, problemResources: buildProblemResources(resources), }; } -export function useDashboardOverview( - resources: Accessor, - alerts: Accessor, -): Accessor { - return createMemo(() => computeDashboardOverview(resources(), alerts())); +export function computeDashboardOverview( + resources: Resource[], + activeAlerts: Alert[], +): DashboardOverview { + return mergeDashboardAlertCounts(computeDashboardOverviewSummary(resources), activeAlerts); +} + +export function useDashboardOverview(alerts: Accessor) { + const [orgScope, setOrgScope] = createSignal(normalizeOrgScope(getOrgID())); + const resolveScopedCacheKey = () => `dashboard-overview:${orgScope()}`; + let cacheEntry = getDashboardOverviewCacheEntry(resolveScopedCacheKey()); + const [summary, setSummary] = createSignal(cacheEntry.summary); + const [loading, setLoading] = createSignal(!cacheEntry.hasSnapshot); + const [error, setError] = createSignal(undefined); + const wsStore = getGlobalWebSocketStore(); + let refreshHandle: ReturnType | undefined; + let wsInitialized = false; + let lastWsUpdateToken = ''; + let scopeVersion = 0; + + const runRefetch = async (options?: { force?: boolean; source?: 'initial' | 'ws' | 'manual' }) => { + const force = options?.force === true; + const source = options?.source ?? 'manual'; + + if (!force && source === 'ws' && shouldThrottleWsRefetch(cacheEntry)) { + return summary(); + } + + const shouldShowLoading = force || !cacheEntry.hasSnapshot; + if (shouldShowLoading) { + setLoading(true); + } + + const requestVersion = scopeVersion; + const entryForRequest = cacheEntry; + try { + const fetched = await fetchDashboardOverviewSummary(entryForRequest, force); + if (requestVersion !== scopeVersion || entryForRequest !== cacheEntry) { + return summary(); + } + batch(() => { + setSummary(fetched); + setError(undefined); + }); + return fetched; + } catch (err) { + setError(err); + throw err; + } finally { + if (shouldShowLoading) { + setLoading(false); + } + } + }; + + const refetch = async () => runRefetch({ force: true, source: 'manual' }); + + if (!hasFreshDashboardOverviewCache(cacheEntry)) { + void runRefetch({ source: 'initial' }).catch(() => undefined); + } + + const scheduleRefetch = () => { + if (refreshHandle !== undefined) { + clearTimeout(refreshHandle); + } + + const elapsedSinceFetch = Date.now() - cacheEntry.lastFetchAt; + const minIntervalDelay = Math.max( + 0, + DASHBOARD_OVERVIEW_WS_MIN_REFETCH_INTERVAL_MS - elapsedSinceFetch, + ); + const delay = Math.max(DASHBOARD_OVERVIEW_WS_DEBOUNCE_MS, minIntervalDelay); + + refreshHandle = setTimeout(() => { + refreshHandle = undefined; + void runRefetch({ source: 'ws' }).catch(() => undefined); + }, delay); + }; + + createEffect(() => { + orgScope(); + + if (!wsStore.connected() || !wsStore.initialDataReceived()) { + wsInitialized = false; + lastWsUpdateToken = ''; + return; + } + + const lastUpdateToken = String(wsStore.state.lastUpdate ?? ''); + if (!wsInitialized) { + wsInitialized = true; + lastWsUpdateToken = lastUpdateToken; + return; + } + if (lastUpdateToken === lastWsUpdateToken) { + return; + } + + lastWsUpdateToken = lastUpdateToken; + scheduleRefetch(); + }); + + const unsubscribeOrgSwitch = eventBus.on('org_switched', (nextOrgID?: string) => { + const nextOrgScope = normalizeOrgScope(nextOrgID); + if (nextOrgScope === orgScope()) { + return; + } + + scopeVersion += 1; + setOrgScope(nextOrgScope); + cacheEntry = getDashboardOverviewCacheEntry(resolveScopedCacheKey()); + wsInitialized = false; + lastWsUpdateToken = ''; + + batch(() => { + setSummary(cacheEntry.summary); + setLoading(!cacheEntry.hasSnapshot); + setError(undefined); + }); + + if (!hasFreshDashboardOverviewCache(cacheEntry)) { + void runRefetch({ force: true, source: 'initial' }).catch(() => undefined); + } + }); + + onCleanup(() => { + unsubscribeOrgSwitch(); + if (refreshHandle !== undefined) { + clearTimeout(refreshHandle); + } + }); + + const overview = createMemo(() => mergeDashboardAlertCounts(summary(), alerts())); + + return { + overview, + loading, + error, + refetch, + }; } diff --git a/frontend-modern/src/hooks/useDashboardTrends.ts b/frontend-modern/src/hooks/useDashboardTrends.ts index 279dfcf22..1cacdeb4f 100644 --- a/frontend-modern/src/hooks/useDashboardTrends.ts +++ b/frontend-modern/src/hooks/useDashboardTrends.ts @@ -6,12 +6,9 @@ import { type StorageSummaryTrendResponse, type TimeRange, } from '@/api/charts'; -import { - buildInfrastructureEmptyHistoryLabel, - buildInfrastructureSummarySeries, -} from '@/components/Infrastructure/infrastructureSummaryModel'; +import { buildInfrastructureEmptyHistoryLabel } from '@/components/Infrastructure/infrastructureSummaryModel'; +import type { DashboardOverviewSummary } from '@/hooks/useDashboardOverview'; import { eventBus } from '@/stores/events'; -import { isAgentFacetInfrastructureResource } from '@/utils/agentResources'; import { getOrgID } from '@/utils/apiClient'; import { fetchInfrastructureSummaryAndCache, @@ -22,7 +19,6 @@ import { fetchStorageSummaryTrendAndCache, readStorageSummaryTrendCache, } from '@/utils/storageSummaryTrendCache'; -import { isInfrastructure, isStorage, type Resource } from '@/types/resource'; export type TrendPoint = { timestamp: number; @@ -113,38 +109,43 @@ function toInfrastructureSummaryRange(range: HistoryTimeRange): TimeRange { } } +type DashboardTrendResourceRef = DashboardOverviewSummary['infrastructure']['topCPU'][number]; + +function buildMetricTrendMap( + resources: DashboardTrendResourceRef[], + map: Map, + metric: 'cpu' | 'memory', +): Map { + const result = new Map(); + + for (const resource of resources.slice(0, 5)) { + const historyKey = resource.metricsTarget?.resourceId || resource.id; + const history = map.get(historyKey); + if (!history) { + continue; + } + + const points = metric === 'cpu' ? history.cpu ?? [] : history.memory ?? []; + result.set(resource.id, extractTrendData(points)); + } + + return result; +} + function buildInfrastructureTrendSnapshot( - resources: Resource[], + overview: DashboardOverviewSummary, map: Map, oldestDataTimestamp: number | null, ): DashboardTrends['infrastructure'] { - const infrastructureResources = resources.filter((resource) => isInfrastructure(resource)); - if (infrastructureResources.length === 0) { + if (overview.infrastructure.total === 0) { return createEmptyInfrastructureTrends(); } - const agentFacetResources = resources.filter((resource) => - isAgentFacetInfrastructureResource(resource), + const cpu = buildMetricTrendMap(overview.infrastructure.topCPU, map, 'cpu'); + const memory = buildMetricTrendMap(overview.infrastructure.topMemory, map, 'memory'); + const hasRenderableHistory = [...cpu.values(), ...memory.values()].some( + (trend) => trend.points.length >= 2, ); - const summarySeries = buildInfrastructureSummarySeries( - infrastructureResources, - map, - agentFacetResources, - ); - - const cpu = new Map(); - const memory = new Map(); - let hasRenderableHistory = false; - - for (const series of summarySeries) { - const cpuTrend = extractTrendData(series.cpu); - const memoryTrend = extractTrendData(series.memory); - if (cpuTrend.points.length >= 2 || memoryTrend.points.length >= 2) { - hasRenderableHistory = true; - } - cpu.set(series.id, cpuTrend); - memory.set(series.id, memoryTrend); - } return { cpu, @@ -264,7 +265,7 @@ function buildStorageTrendSnapshot( } export function useDashboardTrends( - resources: Accessor, + overview: Accessor, infrastructureRange?: Accessor, ): Accessor { const [infrastructureError, setInfrastructureError] = createSignal(null); @@ -289,9 +290,9 @@ export function useDashboardTrends( }); const hasInfrastructureResources = createMemo(() => - resources().some((resource) => isInfrastructure(resource)), + overview().infrastructure.total > 0, ); - const hasStorageResources = createMemo(() => resources().some((resource) => isStorage(resource))); + const hasStorageResources = createMemo(() => overview().storage.total > 0); const infrastructureScopeKey = createMemo(() => { const version = orgVersion(); @@ -413,7 +414,7 @@ export function useDashboardTrends( }); const infrastructureSnapshot = createMemo(() => - buildInfrastructureTrendSnapshot(resources(), infrastructureCharts(), oldestDataTimestamp()), + buildInfrastructureTrendSnapshot(overview(), infrastructureCharts(), oldestDataTimestamp()), ); const storageSnapshot = createMemo(() => buildStorageTrendSnapshot(storageSummary())); diff --git a/frontend-modern/src/pages/Dashboard.tsx b/frontend-modern/src/pages/Dashboard.tsx index 7b1e93be0..a38ce1048 100644 --- a/frontend-modern/src/pages/Dashboard.tsx +++ b/frontend-modern/src/pages/Dashboard.tsx @@ -11,7 +11,6 @@ import { import { useNavigate } from '@solidjs/router'; import { useWebSocket } from '@/contexts/appRuntime'; import { buildInfrastructureWorkspacePath } from '@/components/Settings/infrastructureWorkspaceModel'; -import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { useDashboardOverview } from '@/hooks/useDashboardOverview'; import { useDashboardTrends } from '@/hooks/useDashboardTrends'; import { useDashboardLayout } from '@/hooks/useDashboardLayout'; @@ -39,23 +38,16 @@ export default function Dashboard() { const navigate = useNavigate(); const { connected, reconnecting, reconnect, activeAlerts } = useWebSocket(); - // REST-backed resources: instant first paint, no WebSocket wait. - const dashboardResources = useUnifiedResources({ - query: '', - cacheKey: 'all-resources', - initialHydration: 'prefer-ws', - }); - const resources = createMemo(() => dashboardResources.resources?.() ?? []); - const alertsList = createMemo(() => Object.values(activeAlerts as Record).filter( (a): a is Alert => a !== undefined, ), ); - const overview = useDashboardOverview(resources, alertsList); + const overviewState = useDashboardOverview(alertsList); + const overview = overviewState.overview; const [trendRange, setTrendRange] = createSignal('1h'); - const trends = useDashboardTrends(resources, trendRange); + const trends = useDashboardTrends(overview, trendRange); const layout = useDashboardLayout(); const actions = useDashboardActions(alertsList); const recoverySummary = useDashboardRecovery(); @@ -64,14 +56,14 @@ export default function Dashboard() { const [loadingTimedOut, setLoadingTimedOut] = createSignal(false); let loadingTimeout: number | undefined; - const isLoading = createMemo(() => dashboardResources.loading()); + const isLoading = createMemo(() => overviewState.loading()); // Track whether we've completed the initial load successfully so that subsequent // background refetches don't tear down the content tree (which causes // flickering and scroll-position resets). Only set on successful load (not errors). const [initialLoadComplete, setInitialLoadComplete] = createSignal(false); createEffect(() => { - if (!isLoading() && !initialLoadComplete() && !dashboardResources.error()) { + if (!isLoading() && !initialLoadComplete() && !overviewState.error()) { setInitialLoadComplete(true); } }); @@ -96,12 +88,12 @@ export default function Dashboard() { const hasConnectionError = createMemo(() => { if (loadingTimedOut()) return true; - if (dashboardResources.error()) return true; + if (overviewState.error()) return true; return !isLoading() && !connected() && !reconnecting(); }); // True when we have renderable cached data (even if connection is now lost) - const hasCachedData = createMemo(() => (resources()?.length ?? 0) > 0); + const hasCachedData = createMemo(() => overview().health.totalResources > 0); const dashboardDisconnectedBannerState = createMemo(() => getDashboardDisconnectedBannerState(reconnecting()), ); diff --git a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx index e336d3026..2d3ba9d1d 100644 --- a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx +++ b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx @@ -5,9 +5,8 @@ import type { DashboardRecoverySummary } from '@/hooks/useDashboardRecovery'; import DashboardPage from '@/pages/Dashboard'; import dashboardPageSource from '@/pages/Dashboard.tsx?raw'; -let unifiedLoading = false; -let unifiedResources: any[] = []; -let unifiedError: unknown = undefined; +let overviewLoading = false; +let overviewError: unknown = undefined; let wsConnected = true; let wsReconnecting = false; const reconnectSpy = vi.fn(); @@ -73,18 +72,13 @@ vi.mock('@solidjs/router', async () => { }; }); -vi.mock('@/hooks/useUnifiedResources', () => ({ - useUnifiedResources: () => ({ - resources: () => unifiedResources, - loading: () => unifiedLoading, - error: () => unifiedError, - refetch: vi.fn(), - mutate: vi.fn(), - }), -})); - vi.mock('@/hooks/useDashboardOverview', () => ({ - useDashboardOverview: () => () => overviewMock, + useDashboardOverview: () => ({ + overview: () => overviewMock, + loading: () => overviewLoading, + error: () => overviewError, + refetch: vi.fn(), + }), })); vi.mock('@/hooks/useDashboardTrends', () => ({ @@ -118,13 +112,13 @@ vi.mock('@/hooks/useDashboardRecovery', () => ({ describe('Dashboard page module contract', () => { beforeEach(() => { - unifiedLoading = false; - unifiedResources = []; - unifiedError = undefined; + overviewLoading = false; + overviewError = undefined; wsConnected = true; wsReconnecting = false; reconnectSpy.mockReset(); navigateSpy.mockReset(); + overviewMock.health.totalResources = 0; overviewMock.storage.total = 0; overviewMock.storage.totalCapacity = 0; overviewMock.storage.totalUsed = 0; @@ -155,17 +149,17 @@ describe('Dashboard page module contract', () => { "from '@/components/Recovery/DashboardRecoveryStatusPanel'", ); expect(dashboardPageSource).toContain("from '@/components/Storage/DashboardStoragePanel'"); - expect(dashboardPageSource).toContain("cacheKey: 'all-resources'"); - expect(dashboardPageSource).toContain("initialHydration: 'prefer-ws'"); + expect(dashboardPageSource).toContain('const overviewState = useDashboardOverview(alertsList);'); + expect(dashboardPageSource).not.toContain("cacheKey: 'all-resources'"); }); it('routes dashboard trend hydration through the shared dashboard resources snapshot', () => { - expect(dashboardPageSource).toContain('const trends = useDashboardTrends(resources, trendRange);'); - expect(dashboardPageSource).not.toContain('useDashboardTrends(overview, resources, trendRange)'); + expect(dashboardPageSource).toContain('const trends = useDashboardTrends(overview, trendRange);'); + expect(dashboardPageSource).not.toContain('const dashboardResources = useUnifiedResources'); }); it('renders loading skeleton blocks when resources are loading', () => { - unifiedLoading = true; + overviewLoading = true; render(() => ); @@ -189,7 +183,7 @@ describe('Dashboard page module contract', () => { }); it('renders the governed storage and recovery dashboard panels', () => { - unifiedResources = [{ id: 'resource-1' }]; + overviewMock.health.totalResources = 4; overviewMock.storage.total = 4; overviewMock.storage.totalCapacity = 4000; overviewMock.storage.totalUsed = 2000; diff --git a/frontend-modern/src/stores/__tests__/aiChat.test.ts b/frontend-modern/src/stores/__tests__/aiChat.test.ts index a07ed51ec..e549143c6 100644 --- a/frontend-modern/src/stores/__tests__/aiChat.test.ts +++ b/frontend-modern/src/stores/__tests__/aiChat.test.ts @@ -1,7 +1,11 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { describe, expect, it, beforeEach } from 'vitest'; import { aiChatStore } from '@/stores/aiChat'; import { eventBus } from '@/stores/events'; +const aiChatSource = readFileSync(resolve(process.cwd(), 'src/components/AI/Chat/index.tsx'), 'utf8'); + describe('aiChatStore', () => { beforeEach(() => { aiChatStore.close(); @@ -134,4 +138,10 @@ describe('aiChatStore', () => { expect(aiChatStore.sessionId).not.toBe(previousSessionId); expect(localStorage.getItem('pulse:ai_chat_session_id')).toBe(aiChatStore.sessionId); }); + + it('keeps closed assistant resource reads on the websocket/cache path', () => { + expect(aiChatSource).toContain('getGlobalWebSocketStore'); + expect(aiChatSource).toContain('getCachedUnifiedResources'); + expect(aiChatSource).not.toContain('useResources()'); + }); }); diff --git a/internal/api/resources.go b/internal/api/resources.go index 897c280fb..a834b116f 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -185,6 +185,30 @@ func (h *ResourceHandlers) HandleStorageSummary(w http.ResponseWriter, r *http.R json.NewEncoder(w).Encode(response) } +// HandleDashboardSummary handles GET /api/resources/dashboard-summary. +func (h *ResourceHandlers) HandleDashboardSummary(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + orgID := GetOrgID(r.Context()) + registry, err := h.buildRegistry(orgID) + if err != nil { + http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) + return + } + + resources := unified.RefreshCanonicalMetadataSlice(registry.List()) + attachMetricsTargets(resources, registry) + applyResourceContractTypes(resources) + + response := buildDashboardOverviewSummaryResponse(resources) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + // HandleStorageIncidents handles GET /api/resources/storage-incidents. func (h *ResourceHandlers) HandleStorageIncidents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -1114,6 +1138,92 @@ type StorageSummaryResponse struct { TopIncidents []StorageSummaryIncident `json:"topIncidents"` } +type DashboardOverviewSummaryResponse struct { + Health DashboardOverviewHealthSummary `json:"health"` + Infrastructure DashboardOverviewInfrastructureSummary `json:"infrastructure"` + Workloads DashboardOverviewWorkloadsSummary `json:"workloads"` + Storage DashboardOverviewStorageSummary `json:"storage"` + ProblemResources []DashboardOverviewProblemResourceRow `json:"problemResources"` +} + +type DashboardOverviewHealthSummary struct { + TotalResources int `json:"totalResources"` + ByStatus map[string]int `json:"byStatus"` +} + +type DashboardOverviewInfrastructureSummary struct { + Total int `json:"total"` + ByStatus map[string]int `json:"byStatus"` + ByType map[string]int `json:"byType"` + TopCPU []DashboardOverviewTopResource `json:"topCPU"` + TopMemory []DashboardOverviewTopResource `json:"topMemory"` +} + +type DashboardOverviewTopResource struct { + ID string `json:"id"` + Name string `json:"name"` + Percent float64 `json:"percent"` + MetricsTarget *unified.MetricsTarget `json:"metricsTarget,omitempty"` +} + +type DashboardOverviewWorkloadsSummary struct { + Total int `json:"total"` + Running int `json:"running"` + Stopped int `json:"stopped"` + ByType map[string]int `json:"byType"` +} + +type DashboardOverviewStorageSummary struct { + Total int `json:"total"` + TotalCapacity int64 `json:"totalCapacity"` + TotalUsed int64 `json:"totalUsed"` + WarningCount int `json:"warningCount"` + CriticalCount int `json:"criticalCount"` +} + +type DashboardOverviewProblemResourceRow struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Status string `json:"status"` + LastSeen string `json:"lastSeen,omitempty"` + Sources []unified.DataSource `json:"sources,omitempty"` + AISafeSummary string `json:"aiSafeSummary,omitempty"` + Policy *unified.ResourcePolicy `json:"policy,omitempty"` + CanonicalIdentity *unified.CanonicalIdentity `json:"canonicalIdentity,omitempty"` + Problems []string `json:"problems"` + WorstValue float64 `json:"worstValue"` +} + +func EmptyDashboardOverviewSummaryResponse() DashboardOverviewSummaryResponse { + return DashboardOverviewSummaryResponse{}.NormalizeCollections() +} + +func (r DashboardOverviewSummaryResponse) NormalizeCollections() DashboardOverviewSummaryResponse { + if r.Health.ByStatus == nil { + r.Health.ByStatus = map[string]int{} + } + if r.Infrastructure.ByStatus == nil { + r.Infrastructure.ByStatus = map[string]int{} + } + if r.Infrastructure.ByType == nil { + r.Infrastructure.ByType = map[string]int{} + } + if r.Infrastructure.TopCPU == nil { + r.Infrastructure.TopCPU = []DashboardOverviewTopResource{} + } + if r.Infrastructure.TopMemory == nil { + r.Infrastructure.TopMemory = []DashboardOverviewTopResource{} + } + if r.Workloads.ByType == nil { + r.Workloads.ByType = map[string]int{} + } + if r.ProblemResources == nil { + r.ProblemResources = []DashboardOverviewProblemResourceRow{} + } + return r +} + func EmptyStorageSummaryResponse() StorageSummaryResponse { return StorageSummaryResponse{}.NormalizeCollections() } @@ -1205,6 +1315,286 @@ type registryCacheEntry struct { lastUpdate time.Time } +func buildDashboardOverviewSummaryResponse(resources []unified.Resource) DashboardOverviewSummaryResponse { + response := EmptyDashboardOverviewSummaryResponse() + if len(resources) == 0 { + return response + } + + infrastructureResources := make([]unified.Resource, 0, len(resources)) + problemResources := make([]DashboardOverviewProblemResourceRow, 0, len(resources)) + + for _, resource := range resources { + resourceType := strings.TrimSpace(string(resource.Type)) + status := strings.TrimSpace(string(resource.Status)) + if status == "" { + status = "unknown" + } + + response.Health.TotalResources++ + response.Health.ByStatus[status]++ + + if isDashboardInfrastructureResourceType(resource.Type) { + infrastructureResources = append(infrastructureResources, resource) + response.Infrastructure.Total++ + response.Infrastructure.ByStatus[status]++ + response.Infrastructure.ByType[resourceType]++ + } + + if isDashboardWorkloadResourceType(resource.Type) { + response.Workloads.Total++ + response.Workloads.ByType[resourceType]++ + switch status { + case "online", "running": + response.Workloads.Running++ + case "offline", "stopped": + response.Workloads.Stopped++ + } + } + + if isDashboardStorageResourceType(resource.Type) { + response.Storage.Total++ + if total := dashboardMetricTotal(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Disk }); total > 0 { + response.Storage.TotalCapacity += total + } + if used := dashboardMetricUsed(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Disk }); used > 0 { + response.Storage.TotalUsed += used + } + diskPercent := dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Disk })) + switch { + case diskPercent > 90: + response.Storage.CriticalCount++ + case diskPercent > 80: + response.Storage.WarningCount++ + } + } + + if row, ok := buildDashboardProblemResourceRow(resource); ok { + problemResources = append(problemResources, row) + } + } + + response.Infrastructure.TopCPU = buildDashboardTopInfrastructureResources( + infrastructureResources, + func(resource unified.Resource) float64 { + return dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.CPU })) + }, + ) + response.Infrastructure.TopMemory = buildDashboardTopInfrastructureResources( + infrastructureResources, + func(resource unified.Resource) float64 { + return dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Memory })) + }, + ) + + sort.Slice(problemResources, func(i, j int) bool { + if problemResources[i].WorstValue == problemResources[j].WorstValue { + return strings.ToLower(problemResources[i].Name) < strings.ToLower(problemResources[j].Name) + } + return problemResources[i].WorstValue > problemResources[j].WorstValue + }) + if len(problemResources) > 8 { + problemResources = problemResources[:8] + } + response.ProblemResources = problemResources + + return response.NormalizeCollections() +} + +func buildDashboardTopInfrastructureResources( + resources []unified.Resource, + metric func(unified.Resource) float64, +) []DashboardOverviewTopResource { + rows := make([]DashboardOverviewTopResource, 0, len(resources)) + for _, resource := range resources { + percent := metric(resource) + if percent <= 0 { + continue + } + rows = append(rows, DashboardOverviewTopResource{ + ID: resource.ID, + Name: dashboardResourceLabel(resource), + Percent: percent, + MetricsTarget: cloneDashboardMetricsTarget(resource.MetricsTarget), + }) + } + + sort.Slice(rows, func(i, j int) bool { + if rows[i].Percent == rows[j].Percent { + return strings.ToLower(rows[i].Name) < strings.ToLower(rows[j].Name) + } + return rows[i].Percent > rows[j].Percent + }) + if len(rows) > 5 { + rows = rows[:5] + } + return rows +} + +func cloneDashboardMetricsTarget(target *unified.MetricsTarget) *unified.MetricsTarget { + if target == nil { + return nil + } + cloned := *target + return &cloned +} + +func buildDashboardProblemResourceRow(resource unified.Resource) (DashboardOverviewProblemResourceRow, bool) { + problems := make([]string, 0, 4) + worstValue := 0.0 + status := strings.TrimSpace(strings.ToLower(string(resource.Status))) + + switch status { + case "offline", "error", "failed", "down", "unreachable", "disconnected", "timeout", "stopped", "inactive": + problems = append(problems, "Offline") + worstValue = 200 + case "degraded", "warning", "maintenance", "syncing", "initializing", "starting", "pending", "partial", "unknown", "recovering", "pausing", "restarting": + problems = append(problems, "Degraded") + worstValue = maxDashboardProblemValue(worstValue, 150) + } + + cpuPercent := dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.CPU })) + if cpuPercent >= 90 { + problems = append(problems, "CPU "+strconv.Itoa(int(cpuPercent+0.5))+"%") + worstValue = maxDashboardProblemValue(worstValue, cpuPercent) + } + + memoryPercent := dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Memory })) + if memoryPercent >= 85 { + problems = append(problems, "Memory "+strconv.Itoa(int(memoryPercent+0.5))+"%") + worstValue = maxDashboardProblemValue(worstValue, memoryPercent) + } + + diskPercent := dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Disk })) + if diskPercent >= 90 { + problems = append(problems, "Disk "+strconv.Itoa(int(diskPercent+0.5))+"%") + worstValue = maxDashboardProblemValue(worstValue, diskPercent) + } + + if len(problems) == 0 { + return DashboardOverviewProblemResourceRow{}, false + } + + row := DashboardOverviewProblemResourceRow{ + ID: resource.ID, + Type: string(resource.Type), + Name: dashboardResourceLabel(resource), + Status: status, + Sources: append([]unified.DataSource(nil), resource.Sources...), + AISafeSummary: strings.TrimSpace(resource.AISafeSummary), + CanonicalIdentity: cloneDashboardCanonicalIdentity(resource.Canonical), + Policy: unified.CloneResourcePolicy(resource.Policy), + Problems: problems, + WorstValue: worstValue, + } + if !resource.LastSeen.IsZero() { + row.LastSeen = resource.LastSeen.UTC().Format(time.RFC3339Nano) + } + + return row, true +} + +func dashboardResourceLabel(resource unified.Resource) string { + return unified.ResourcePolicyLabel( + unified.ResourceDisplayName(resource), + resource.AISafeSummary, + resource.Policy, + ) +} + +func cloneDashboardCanonicalIdentity(identity *unified.CanonicalIdentity) *unified.CanonicalIdentity { + if identity == nil { + return nil + } + cloned := *identity + if len(identity.Aliases) > 0 { + cloned.Aliases = append([]string(nil), identity.Aliases...) + } + return &cloned +} + +func isDashboardInfrastructureResourceType(resourceType unified.ResourceType) bool { + switch strings.TrimSpace(string(resourceType)) { + case "agent", "docker-host", "k8s-cluster", "k8s-node": + return true + default: + return false + } +} + +func isDashboardWorkloadResourceType(resourceType unified.ResourceType) bool { + switch strings.TrimSpace(string(resourceType)) { + case "vm", "system-container", "app-container", "oci-container", "pod", "jail": + return true + default: + return false + } +} + +func isDashboardStorageResourceType(resourceType unified.ResourceType) bool { + switch strings.TrimSpace(string(resourceType)) { + case "storage", "datastore", "pool", "dataset", "physical_disk", "ceph": + return true + default: + return false + } +} + +func nilSafeMetric( + metrics *unified.ResourceMetrics, + pick func(*unified.ResourceMetrics) *unified.MetricValue, +) *unified.MetricValue { + if metrics == nil { + return nil + } + return pick(metrics) +} + +func dashboardMetricPercent(metric *unified.MetricValue) float64 { + if metric == nil { + return 0 + } + if metric.Percent > 0 { + return metric.Percent + } + if metric.Value > 0 { + return metric.Value + } + if metric.Total != nil && metric.Used != nil && *metric.Total > 0 { + return (float64(*metric.Used) / float64(*metric.Total)) * 100 + } + return 0 +} + +func dashboardMetricTotal( + metrics *unified.ResourceMetrics, + pick func(*unified.ResourceMetrics) *unified.MetricValue, +) int64 { + metric := nilSafeMetric(metrics, pick) + if metric == nil || metric.Total == nil { + return 0 + } + return *metric.Total +} + +func dashboardMetricUsed( + metrics *unified.ResourceMetrics, + pick func(*unified.ResourceMetrics) *unified.MetricValue, +) int64 { + metric := nilSafeMetric(metrics, pick) + if metric == nil || metric.Used == nil { + return 0 + } + return *metric.Used +} + +func maxDashboardProblemValue(left, right float64) float64 { + if right > left { + return right + } + return left +} + func buildStorageSummaryResponse(resources []unified.Resource) StorageSummaryResponse { response := EmptyStorageSummaryResponse() response.GeneratedAt = time.Now().UTC() diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index e1366acf3..a42374e73 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -2884,6 +2884,144 @@ func TestResourceStorageSummaryRollsUpIncidents(t *testing.T) { } } +func TestResourceDashboardSummaryUsesCompactGovernedPayload(t *testing.T) { + now := time.Now().UTC() + criticalDiskTotal := int64(1_000) + criticalDiskUsed := int64(850) + restrictedResource := unified.Resource{ + ID: "agent:restricted-1", + Type: unified.ResourceTypeAgent, + Name: "restricted-host", + Status: unified.StatusOnline, + LastSeen: now, + UpdatedAt: now, + Tags: []string{"restricted"}, + Sources: []unified.DataSource{unified.SourceAgent}, + MetricsTarget: &unified.MetricsTarget{ + ResourceType: "agent", + ResourceID: "metrics-restricted-1", + }, + Metrics: &unified.ResourceMetrics{ + CPU: &unified.MetricValue{Percent: 95}, + Memory: &unified.MetricValue{Percent: 88}, + }, + } + expectedRestricted := unified.RefreshCanonicalMetadataSlice([]unified.Resource{restrictedResource})[0] + expectedRestrictedLabel := unified.ResourcePolicyLabel( + unified.ResourceDisplayName(expectedRestricted), + expectedRestricted.AISafeSummary, + expectedRestricted.Policy, + ) + + cfg := &config.Config{DataPath: t.TempDir()} + h := NewResourceHandlers(cfg) + h.SetStateProvider(resourceUnifiedSeedProvider{ + snapshot: models.StateSnapshot{LastUpdate: now}, + resources: []unified.Resource{ + restrictedResource, + { + ID: "docker-host:preview-1", + Type: unified.ResourceTypeAgent, + Name: "preview-docker-host", + Status: unified.StatusOffline, + LastSeen: now, + UpdatedAt: now, + Sources: []unified.DataSource{unified.SourceDocker}, + Docker: &unified.DockerData{Hostname: "preview-docker-host"}, + Metrics: &unified.ResourceMetrics{ + CPU: &unified.MetricValue{Percent: 70}, + Memory: &unified.MetricValue{Percent: 40}, + }, + }, + { + ID: "vm:101", + Type: unified.ResourceTypeVM, + Name: "vm-101", + Status: unified.ResourceStatus("running"), + LastSeen: now, + UpdatedAt: now, + Sources: []unified.DataSource{unified.SourceProxmox}, + }, + { + ID: "dataset:critical-1", + Type: "dataset", + Name: "tank/apps", + Status: unified.ResourceStatus("degraded"), + LastSeen: now, + UpdatedAt: now, + Sources: []unified.DataSource{unified.SourceTrueNAS}, + Metrics: &unified.ResourceMetrics{ + Disk: &unified.MetricValue{Used: &criticalDiskUsed, Total: &criticalDiskTotal, Percent: 85}, + }, + }, + }, + }) + registry, err := h.buildRegistry("") + if err != nil { + t.Fatalf("buildRegistry: %v", err) + } + expectedTopCPUMetricsTarget := registry.MetricsTarget("agent:restricted-1") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/resources/dashboard-summary", nil) + h.HandleDashboardSummary(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp DashboardOverviewSummaryResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.Health.TotalResources != 4 { + t.Fatalf("totalResources = %d, want 4", resp.Health.TotalResources) + } + if resp.Infrastructure.Total != 2 { + t.Fatalf("infrastructure.total = %d, want 2", resp.Infrastructure.Total) + } + if resp.Infrastructure.ByType["agent"] != 1 || resp.Infrastructure.ByType["docker-host"] != 1 { + t.Fatalf("unexpected infrastructure.byType = %+v", resp.Infrastructure.ByType) + } + if len(resp.Infrastructure.TopCPU) == 0 || resp.Infrastructure.TopCPU[0].Name != expectedRestrictedLabel { + t.Fatalf("topCPU = %+v, want governed summary label first", resp.Infrastructure.TopCPU) + } + if expectedTopCPUMetricsTarget == nil { + t.Fatal("expected restricted top CPU resource to expose a metrics target") + } + if resp.Infrastructure.TopCPU[0].MetricsTarget == nil || *resp.Infrastructure.TopCPU[0].MetricsTarget != *expectedTopCPUMetricsTarget { + t.Fatalf("topCPU[0].metricsTarget = %+v, want %+v", resp.Infrastructure.TopCPU[0].MetricsTarget, expectedTopCPUMetricsTarget) + } + if resp.Infrastructure.TopCPU[0].Name == "restricted-host" { + t.Fatalf("expected governed label, got raw restricted hostname") + } + if resp.Workloads.Total != 1 || resp.Workloads.Running != 1 || resp.Workloads.Stopped != 0 { + t.Fatalf("unexpected workloads summary = %+v", resp.Workloads) + } + if resp.Storage.Total != 1 || resp.Storage.TotalCapacity != 1_000 || resp.Storage.TotalUsed != 850 { + t.Fatalf("unexpected storage summary = %+v", resp.Storage) + } + if resp.Storage.WarningCount != 1 || resp.Storage.CriticalCount != 0 { + t.Fatalf("unexpected storage warning counts = %+v", resp.Storage) + } + if len(resp.ProblemResources) != 3 { + t.Fatalf("problemResources len = %d, want 3", len(resp.ProblemResources)) + } + if resp.ProblemResources[0].ID != "docker-host:preview-1" || len(resp.ProblemResources[0].Problems) == 0 || resp.ProblemResources[0].Problems[0] != "Offline" { + t.Fatalf("expected offline docker host first, got %+v", resp.ProblemResources[0]) + } + if resp.ProblemResources[1].ID != "dataset:critical-1" || len(resp.ProblemResources[1].Problems) == 0 || resp.ProblemResources[1].Problems[0] != "Degraded" { + t.Fatalf("expected degraded storage row second, got %+v", resp.ProblemResources[1]) + } + if resp.ProblemResources[2].ID != "agent:restricted-1" || resp.ProblemResources[2].Name != expectedRestrictedLabel { + t.Fatalf("expected governed restricted host third, got %+v", resp.ProblemResources[2]) + } + if resp.ProblemResources[2].CanonicalIdentity == nil || resp.ProblemResources[2].Policy == nil { + t.Fatalf("expected governed problem resource to include canonical identity and policy, got %+v", resp.ProblemResources[2]) + } +} + func TestResourceListIncludesTrueNASPhysicalDiskTemperature(t *testing.T) { previous := truenas.IsFeatureEnabled() truenas.SetFeatureEnabled(true) diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index 2f9fdeb90..8c3aba634 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -396,6 +396,7 @@ var allRouteAllowlist = []string{ "/api/resources", "/api/resources/storage-incidents", "/api/resources/storage-summary", + "/api/resources/dashboard-summary", "/api/resources/k8s/namespaces", "/api/resources/stats", "/api/resources/", diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index 6b697f067..c65c8a424 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -32,6 +32,7 @@ func (r *Router) registerMonitoringResourceRoutes( r.mux.HandleFunc("/api/resources", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleListResources))) r.mux.HandleFunc("/api/resources/storage-incidents", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleStorageIncidents))) r.mux.HandleFunc("/api/resources/storage-summary", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleStorageSummary))) + r.mux.HandleFunc("/api/resources/dashboard-summary", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleDashboardSummary))) r.mux.HandleFunc("/api/resources/stats", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleStats))) r.mux.HandleFunc("/api/resources/k8s/namespaces", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleK8sNamespaces))) r.mux.HandleFunc("/api/resources/{id}/facets", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceFacets))) diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 1964e8600..dbbd58118 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -2579,6 +2579,7 @@ func TestMonitoringReadEndpointsRequireMonitoringReadScope(t *testing.T) { "/api/charts", "/api/charts/workloads", "/api/charts/storage-summary", + "/api/resources/dashboard-summary", "/api/metrics-store/stats", "/api/metrics-store/history", "/api/guests/metadata",