Compact dashboard demo hot path

This commit is contained in:
rcourtman
2026-04-10 18:30:39 +01:00
parent a31d237952
commit cceca653dc
21 changed files with 1334 additions and 179 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
+61
View File
@@ -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<string, number>;
};
infrastructure: {
total: number;
byStatus: Record<string, number>;
byType: Record<string, number>;
topCPU: DashboardOverviewSummaryTopResource[];
topMemory: DashboardOverviewSummaryTopResource[];
};
workloads: {
total: number;
running: number;
stopped: number;
byType: Record<string, number>;
};
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 <T>(url: string): Promise<T> =>
});
export class ResourceAPI {
static async getDashboardSummary(): Promise<DashboardOverviewSummaryResponse> {
return fetchFacet<DashboardOverviewSummaryResponse>('/api/resources/dashboard-summary');
}
static async getTimeline(
resourceId: string,
options?: ResourceTimelineQueryOptions,
@@ -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<AIChatProps> = (props) => {
const [discoveryEnabled, setDiscoveryEnabled] = createSignal<boolean | null>(null); // null = loading
const [discoveryHintDismissed, setDiscoveryHintDismissed] = createSignal(false);
const [autonomousBannerDismissed, setAutonomousBannerDismissed] = createSignal(false);
const { byType, resources: allResources } = useResources();
const wsStore = getGlobalWebSocketStore();
const allResources = createMemo<Resource[]>(() => {
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
@@ -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'");
@@ -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<Resource> & Pick<Resource, 'id' | 'type'>): Resource {
function createOverviewSummary(
overrides: Partial<DashboardOverviewSummary> = {},
): 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<typeof useDashboardTrends>;
let setResources!: (value: Resource[]) => void;
let setOverview!: (value: DashboardOverviewSummary) => void;
createRoot((d) => {
dispose = d;
const [resources, setResourcesSignal] = createSignal<Resource[]>([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<typeof useDashboardTrends>;
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<Resource[]>([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<Resource[]>([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');
});
+384 -34
View File
@@ -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; // 0100 for metrics, 200 for offline, 150 for degraded
}
export interface DashboardOverview {
export interface DashboardOverviewSummary {
health: {
totalResources: number;
byStatus: Record<string, number>;
criticalAlerts: number;
warningAlerts: number;
};
infrastructure: {
total: number;
byStatus: Record<string, number>;
byType: Record<string, number>;
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<DashboardOverviewSummary> | null;
};
const dashboardOverviewCaches = new Map<string, DashboardOverviewCacheEntry>();
function createStatusCounter(): Record<string, number> {
return RESOURCE_STATUSES.reduce<Record<string, number>>((acc, status) => {
acc[status] = 0;
@@ -64,6 +99,36 @@ function createStatusCounter(): Record<string, number> {
}, {});
}
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<string, number>, 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<string, number> = {};
@@ -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<Resource[]>,
alerts: Accessor<Alert[]>,
): Accessor<DashboardOverview> {
return createMemo(() => computeDashboardOverview(resources(), alerts()));
export function computeDashboardOverview(
resources: Resource[],
activeAlerts: Alert[],
): DashboardOverview {
return mergeDashboardAlertCounts(computeDashboardOverviewSummary(resources), activeAlerts);
}
export function useDashboardOverview(alerts: Accessor<Alert[]>) {
const [orgScope, setOrgScope] = createSignal(normalizeOrgScope(getOrgID()));
const resolveScopedCacheKey = () => `dashboard-overview:${orgScope()}`;
let cacheEntry = getDashboardOverviewCacheEntry(resolveScopedCacheKey());
const [summary, setSummary] = createSignal<DashboardOverviewSummary>(cacheEntry.summary);
const [loading, setLoading] = createSignal(!cacheEntry.hasSnapshot);
const [error, setError] = createSignal<unknown>(undefined);
const wsStore = getGlobalWebSocketStore();
let refreshHandle: ReturnType<typeof setTimeout> | 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,
};
}
+35 -34
View File
@@ -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<string, ChartData>,
metric: 'cpu' | 'memory',
): Map<string, TrendData> {
const result = new Map<string, TrendData>();
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<string, ChartData>,
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<string, TrendData>();
const memory = new Map<string, TrendData>();
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<Resource[]>,
overview: Accessor<DashboardOverviewSummary>,
infrastructureRange?: Accessor<HistoryTimeRange>,
): Accessor<DashboardTrends> {
const [infrastructureError, setInfrastructureError] = createSignal<string | null>(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()));
+7 -15
View File
@@ -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<Alert[]>(() =>
Object.values(activeAlerts as Record<string, Alert | undefined>).filter(
(a): a is Alert => a !== undefined,
),
);
const overview = useDashboardOverview(resources, alertsList);
const overviewState = useDashboardOverview(alertsList);
const overview = overviewState.overview;
const [trendRange, setTrendRange] = createSignal<HistoryTimeRange>('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()),
);
@@ -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(() => <DashboardPage />);
@@ -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;
@@ -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()');
});
});
+390
View File
@@ -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()
+138
View File
@@ -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)
+1
View File
@@ -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/",
+1
View File
@@ -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)))
+1
View File
@@ -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",