diff --git a/frontend/src/features/dashboard/DashboardPage.test.tsx b/frontend/src/features/dashboard/DashboardPage.test.tsx index 06ca4b42..947f1044 100644 --- a/frontend/src/features/dashboard/DashboardPage.test.tsx +++ b/frontend/src/features/dashboard/DashboardPage.test.tsx @@ -10,19 +10,25 @@ import { Outlet, } from '@tanstack/react-router'; import { DashboardPage } from './DashboardPage'; -import type { StatsResponse } from '$/api/stats'; +import type { NodeTileSeries, StatsResponse } from '$/api/stats'; // --------------------------------------------------------------------------- // Mock the stats API module // --------------------------------------------------------------------------- const mockGetStats = vi.fn<() => Promise>(); +const mockGetEnvActivityTiles = vi.fn<(env: string, days?: number) => Promise>(); -vi.mock('$/api/stats', () => ({ - getStats: () => mockGetStats(), - // Other dashboard panels call these; tests don't assert on them, so - // resolve to empty so the queries don't reject and trigger extra logs. - getOsqueryVersionCounts: () => Promise.resolve([]), -})); +vi.mock('$/api/stats', async () => { + const actual = await vi.importActual('$/api/stats'); + return { + ...actual, + getStats: () => mockGetStats(), + // Other dashboard panels call these; tests don't assert on them, so + // resolve to empty so the queries don't reject and trigger extra logs. + getOsqueryVersionCounts: () => Promise.resolve([]), + getEnvActivityTiles: (env: string, days?: number) => mockGetEnvActivityTiles(env, days), + }; +}); vi.mock('$/api/client', () => ({ isAuthenticated: () => true, @@ -83,6 +89,23 @@ function makeStatsResponse(overrides: Partial = {}): StatsRespons }; } +function makeTileSeries(): NodeTileSeries { + const buckets = 24; + const start = new Date(Date.now() - (buckets - 1) * 60 * 60 * 1000).toISOString(); + const zeros = () => Array.from({ length: buckets }, () => 0); + return { + start, + bucket_seconds: 3600, + enroll: zeros(), + config: [...zeros().slice(0, buckets - 1), 3], + status: [...zeros().slice(0, buckets - 2), 1, 2], + result: [...zeros().slice(0, buckets - 3), 2, 1, 1], + query_read: [...zeros().slice(0, buckets - 1), 1], + query_write: [...zeros().slice(0, buckets - 1), 1], + total: [...zeros().slice(0, buckets - 3), 2, 2, 7], + }; +} + // --------------------------------------------------------------------------- // Test harness: wrap DashboardPage in a minimal router + QueryClient // --------------------------------------------------------------------------- @@ -141,6 +164,7 @@ function renderWithProviders(router: ReturnType) { describe('DashboardPage', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetEnvActivityTiles.mockResolvedValue(makeTileSeries()); }); afterEach(() => { @@ -216,6 +240,20 @@ describe('DashboardPage', () => { ); }); + it('shows endpoint health instead of recent enrollments', async () => { + mockGetStats.mockResolvedValue(makeStatsResponse()); + renderWithProviders(makeTestRouter()); + + await waitFor(() => + expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument(), + ); + + expect(screen.getByText('Endpoint health')).toBeInTheDocument(); + expect(await screen.findByText('Query read')).toBeInTheDocument(); + expect(screen.getByText('Query write')).toBeInTheDocument(); + expect(screen.queryByText('Recent enrollments')).not.toBeInTheDocument(); + }); + it('shows error state and retry button when the API call fails', async () => { mockGetStats.mockRejectedValue(new Error('network failure')); renderWithProviders(makeTestRouter()); diff --git a/frontend/src/features/dashboard/DashboardPage.tsx b/frontend/src/features/dashboard/DashboardPage.tsx index 099a2088..16f6d6ca 100644 --- a/frontend/src/features/dashboard/DashboardPage.tsx +++ b/frontend/src/features/dashboard/DashboardPage.tsx @@ -4,7 +4,6 @@ * Data sources: * GET /api/v1/stats (polled every 30s) * GET /api/v1/audit-logs?page_size=8 (polled every 5m) - * GET /api/v1/nodes/{firstEnv}?page_size=5&sort=firstseen&dir=desc (polled every 60s) * GET /api/v1/stats/activity/env-tiles/{env}?days=N (per env; node activity * line chart: status, result, * config, query read/write) @@ -16,8 +15,16 @@ import { useParams } from '@tanstack/react-router'; import { usePageTitle } from '$/lib/usePageTitle'; import { useQueries, useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; -import { getStats, getOsqueryVersionCounts, getEnvActivityTiles } from '$/api/stats'; -import type { PlatformCounts, NodeTileSeries, ActivityInterval } from '$/api/stats'; +import { + getStats, + getOsqueryVersionCounts, + getEnvActivityTiles, + TILE_CATEGORIES, + TILE_CATEGORY_LABELS, + tileCategoryTotal, + tileLastSeen, +} from '$/api/stats'; +import type { PlatformCounts, NodeTileSeries, ActivityInterval, TileCategory } from '$/api/stats'; import { listAuditLogs, LOG_TYPE, LOG_TYPE_LABELS } from '$/api/audit'; import { listNodes } from '$/api/nodes'; import { listQueries } from '$/api/queries'; @@ -28,7 +35,7 @@ import { EmptyState } from '$/components/data/EmptyState'; import { StatusPip } from '$/components/data/StatusPip'; import { cn } from '$/lib/cn'; import { formatRelative } from '$/lib/time'; -import { DEFAULT_INACTIVE_HOURS, isNodeActive } from '$/lib/node-status'; +import { DEFAULT_INACTIVE_HOURS } from '$/lib/node-status'; // --------------------------------------------------------------------------- // Node-activity series, derived from the Redis-backed env-tiles endpoint. @@ -628,29 +635,115 @@ function ActivityRow({ } // --------------------------------------------------------------------------- -// Recent enrollment row +// Endpoint health — selected-environment osquery endpoint activity. // --------------------------------------------------------------------------- -function EnrollRow({ - hostname, platform, environment, lastSeen, isActive, -}: { hostname: string; platform: string; environment: string; lastSeen: string; isActive: boolean }) { +const ENDPOINT_HEALTH_TONE: Record = { + config: 'var(--info)', + status: 'var(--success)', + result: 'var(--signal)', + query_read: 'var(--warning)', + query_write: 'var(--signal)', +}; + +function EndpointHealthPanel({ + tiles, + intervalLabel, + isLoading, + isFetching, + hasEnvironment, + onRefresh, +}: { + tiles?: NodeTileSeries; + intervalLabel: string; + isLoading: boolean; + isFetching: boolean; + hasEnvironment: boolean; + onRefresh: () => void; +}) { + const rows = TILE_CATEGORIES.map((category) => { + const total = tiles ? tileCategoryTotal(tiles, category) : 0; + const lastSeen = tiles ? tileLastSeen(tiles, category) : null; + return { category, total, lastSeen }; + }); + const anyActivity = rows.some((row) => row.total > 0); + return ( -
- -
-
- {hostname || 'unknown'} -
-
- {platform || '—'} · {environment} +
+
+
+ + Endpoint health + +
+ {intervalLabel} +
+ +
+
+ {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( +
+ + + +
+ )) + ) : !hasEnvironment ? ( +
+ No environment selected. +
+ ) : ( + <> +
+ Endpoint + Events + Last seen +
+ {rows.map((row) => { + const label = TILE_CATEGORY_LABELS[row.category]; + return ( +
+
+ + + {label} + +
+ + {row.total.toLocaleString()} + + {row.lastSeen ? ( + + ) : ( + + none + + )} +
+ ); + })} + {!anyActivity && ( +
+ No endpoint activity in this window. +
+ )} + + )}
-
); } @@ -1133,21 +1226,6 @@ export function DashboardPage() { const effectiveEnv = envMeta?.uuid ?? envUuids[0] ?? ''; const envName = envMeta?.name ?? envParam; - const { data: recentNodes, isLoading: nodesLoading, refetch: refetchRecentNodes } = useQuery({ - queryKey: ['dashboard-recent-nodes', effectiveEnv], - queryFn: () => - listNodes({ - env: effectiveEnv!, - sort: 'firstseen', - dir: 'desc', - pageSize: 5, - }), - enabled: !!effectiveEnv, - refetchInterval: 60_000, - refetchIntervalInBackground: false, - retry: 1, - }); - // ── Recently seen nodes — pulled from the selected env, lastseen desc ─ const { data: recentlySeenNodes, isLoading: recentlySeenLoading, refetch: refetchRecentlySeen } = useQuery({ queryKey: ['dashboard-recently-seen', effectiveEnv], @@ -1259,8 +1337,12 @@ export function DashboardPage() { total: slice(trimmedSeries.total), }; })(); - // Sparkline data from the total series. - const sparkData = chartSeries.total; + const activityWindowLabel = + activityInterval === '7d' + ? 'Last 7 days' + : activityInterval === '12h' + ? 'Last 12 hours' + : 'Last 24 hours'; const envTableRows: EnvTableEnv[] = (data?.environments ?? []).map((e) => ({ uuid: e.uuid, @@ -1366,7 +1448,7 @@ export function DashboardPage() { Node activity
- {activityInterval === '7d' ? 'Last 7 days' : activityInterval === '12h' ? 'Last 12 hours' : 'Last 24 hours'} · per environment + {activityWindowLabel} · per environment
@@ -1718,8 +1800,8 @@ export function DashboardPage() { )} - {/* ── Activity feed + Recent enrollments ──────────────────────────── */} -
+ {/* ── Activity feed + Endpoint health ────────────────────────────── */} +
{/* Activity feed — 2/3 width on md+ */}
@@ -1758,62 +1840,14 @@ export function DashboardPage() {
- {/* Recent enrollments — 1/3 */} -
-
- - Recent enrollments - -
- void refetchRecentNodes()} isPending={nodesLoading} /> - {effectiveEnv && ( - - View all → - - )} -
-
-
- {nodesLoading || (!effectiveEnv && isLoading) ? ( - Array.from({ length: 3 }).map((_, i) => ( -
- -
- - -
- -
- )) - ) : !effectiveEnv ? ( -
- No environment selected. -
- ) : !recentNodes?.items.length ? ( -
- No nodes enrolled yet. -
- ) : ( - recentNodes.items.map((node) => { - const isActive = isNodeActive(node.last_seen, inactiveHours); - return ( - - ); - }) - )} -
-
+ q.isLoading) || (!effectiveEnv && isLoading)} + isFetching={activityQueries.some((q) => q.isFetching)} + hasEnvironment={!!effectiveEnv} + onRefresh={() => void refetchActivityTiles()} + />
{/* ── Recently seen nodes ─────────────────────────────────────────── */}