mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Compact dashboard demo hot path
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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<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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
|
||||
@@ -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()');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user