diff --git a/apps/dashboard/src/lib/dashboard-agent-status.test.ts b/apps/dashboard/src/lib/dashboard-agent-status.test.ts index 4a053ab..5276adc 100644 --- a/apps/dashboard/src/lib/dashboard-agent-status.test.ts +++ b/apps/dashboard/src/lib/dashboard-agent-status.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_AGENT_UPDATE_INTERVAL_MS, getAgentDataStatus } from '#/lib/dashboard-agent-status' +import { + DEFAULT_AGENT_UPDATE_INTERVAL_MS, + getAgentDataStatus, + getAggregateAgentDataStatus, +} from '#/lib/dashboard-agent-status' describe('getAgentDataStatus', () => { const now = new Date('2026-05-24T12:00:00Z') @@ -68,3 +72,79 @@ describe('getAgentDataStatus', () => { }) }) }) + +describe('getAggregateAgentDataStatus', () => { + const now = new Date('2026-06-12T17:00:00Z') + + it('stays healthy when all selected agents are receiving updates', () => { + const status = getAggregateAgentDataStatus( + [ + { + latestGeneratedAt: '2026-06-12T16:55:00Z', + latestRollupDay: '2026-06-12', + projectName: 'Tevpro Hermes Usage', + }, + { + latestGeneratedAt: '2026-06-12T16:50:00Z', + latestRollupDay: '2026-06-12', + projectName: 'Tevpro Sales', + }, + ], + { now }, + ) + + expect(status).toEqual({ + detail: 'All 2 selected agents are receiving updates.', + label: 'Receiving data', + level: 'healthy', + }) + }) + + it('turns delayed when only some selected agents are current', () => { + const status = getAggregateAgentDataStatus( + [ + { + latestGeneratedAt: '2026-06-12T16:55:00Z', + latestRollupDay: '2026-06-12', + projectName: 'Tevpro Hermes Usage', + }, + { + latestGeneratedAt: '2026-06-12T15:00:00Z', + latestRollupDay: '2026-06-12', + projectName: 'Tevpro Sales', + }, + ], + { now }, + ) + + expect(status).toEqual({ + detail: '1 of 2 selected agents need attention: Tevpro Sales.', + label: 'Some agents delayed', + level: 'delayed', + }) + }) + + it('turns stale when every selected agent is stale', () => { + const status = getAggregateAgentDataStatus( + [ + { + latestGeneratedAt: '2026-06-10T12:00:00Z', + latestRollupDay: '2026-06-10', + projectName: 'Tevpro Hermes Usage', + }, + { + latestGeneratedAt: '2026-06-10T11:00:00Z', + latestRollupDay: '2026-06-10', + projectName: 'Tevpro Sales', + }, + ], + { now }, + ) + + expect(status).toEqual({ + detail: 'None of the 2 selected agents have recent data.', + label: 'No recent data', + level: 'stale', + }) + }) +}) diff --git a/apps/dashboard/src/lib/dashboard-agent-status.ts b/apps/dashboard/src/lib/dashboard-agent-status.ts index 8a01c33..aeac224 100644 --- a/apps/dashboard/src/lib/dashboard-agent-status.ts +++ b/apps/dashboard/src/lib/dashboard-agent-status.ts @@ -4,6 +4,13 @@ export type AgentDataStatus = { level: 'healthy' | 'delayed' | 'stale' | 'unknown' } +export type AgentStatusProject = { + latestGeneratedAt?: string + latestRollupDay?: string | null + projectId?: string + projectName?: string +} + export const DEFAULT_AGENT_UPDATE_INTERVAL_MS = 15 * 60 * 1000 const DELAYED_UPDATE_MULTIPLIER = 2 const MAX_ROLLUP_DAY_LAG_DAYS = 1 @@ -65,6 +72,81 @@ export function getAgentDataStatus( } } +export function getAggregateAgentDataStatus( + projects: AgentStatusProject[], + options?: { + expectedUpdateIntervalMs?: number + now?: Date + }, +): AgentDataStatus { + if (projects.length === 0) { + return getAgentDataStatus('', options) + } + + if (projects.length === 1) { + const [project] = projects + return getAgentDataStatus(project?.latestGeneratedAt || '', { + expectedUpdateIntervalMs: options?.expectedUpdateIntervalMs, + latestRollupDay: project?.latestRollupDay || undefined, + now: options?.now, + }) + } + + const projectStatuses = projects.map((project) => ({ + name: project.projectName || project.projectId || 'Unknown agent', + status: getAgentDataStatus(project.latestGeneratedAt || '', { + expectedUpdateIntervalMs: options?.expectedUpdateIntervalMs, + latestRollupDay: project.latestRollupDay || undefined, + now: options?.now, + }), + })) + const counts = projectStatuses.reduce( + (accumulator, project) => { + accumulator[project.status.level] += 1 + return accumulator + }, + { + delayed: 0, + healthy: 0, + stale: 0, + unknown: 0, + }, + ) + + if (counts.healthy === projectStatuses.length) { + return { + detail: `All ${projectStatuses.length} selected agents are receiving updates.`, + label: 'Receiving data', + level: 'healthy', + } + } + + if (counts.stale === projectStatuses.length) { + return { + detail: `None of the ${projectStatuses.length} selected agents have recent data.`, + label: 'No recent data', + level: 'stale', + } + } + + if (counts.unknown === projectStatuses.length) { + return { + detail: `Waiting for the first ingest from all ${projectStatuses.length} selected agents.`, + label: 'Waiting for data', + level: 'unknown', + } + } + + const attentionProjects = projectStatuses.filter((project) => project.status.level !== 'healthy') + const attentionNames = formatProjectNameList(attentionProjects.map((project) => project.name)) + + return { + detail: `${attentionProjects.length} of ${projectStatuses.length} selected agents need attention: ${attentionNames}.`, + label: 'Some agents delayed', + level: 'delayed', + } +} + function getUtcDayLag(day: string, now: Date) { const normalizedDay = /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null if (!normalizedDay) { @@ -102,3 +184,15 @@ function formatRelativeDuration(valueMs: number) { const remainingHours = hours % 24 return remainingHours === 0 ? `${days}d` : `${days}d ${remainingHours}h` } + +function formatProjectNameList(names: string[]) { + if (names.length === 0) { + return 'none' + } + + if (names.length <= 2) { + return names.join(', ') + } + + return `${names.slice(0, 2).join(', ')}, and ${names.length - 2} more` +} diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx index c5e699d..be29899 100644 --- a/apps/dashboard/src/routes/index.tsx +++ b/apps/dashboard/src/routes/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import { createFileRoute, useNavigate } from '@tanstack/react-router' @@ -37,7 +37,7 @@ import { aggregateSingleMetricChartPoints, aggregateTrafficChartPoints, } from '#/lib/chart-presentation' -import { getAgentDataStatus } from '#/lib/dashboard-agent-status' +import { getAgentDataStatus, getAggregateAgentDataStatus } from '#/lib/dashboard-agent-status' import { filterSnapshotByProjects } from '#/lib/dashboard-projects' import { DASHBOARD_TIME_ZONE } from '#/lib/dashboard-time-zone' import { filterSnapshotByTimeframe } from '#/lib/dashboard-timeframe' @@ -93,19 +93,21 @@ function Home() { }) const projectSnapshot = useMemo(() => filterSnapshotByProjects(snapshot, selectedProjectIds), [selectedProjectIds, snapshot]) const activeSnapshot = useMemo(() => filterSnapshotByTimeframe(projectSnapshot, timeframe), [projectSnapshot, timeframe]) + const selectedProjects = useMemo(() => { + if (selectedProjectIds.length === 0 || selectedProjectIds.length === availableProjectIds.length) { + return snapshot.projects.available + } + + const selectedSet = new Set(selectedProjectIds) + return snapshot.projects.available.filter((project) => selectedSet.has(project.projectId)) + }, [availableProjectIds.length, selectedProjectIds, snapshot.projects.available]) const newestFirstRollups: typeof activeSnapshot.table = useMemo( () => [...activeSnapshot.table].sort((left, right) => right.day.localeCompare(left.day)), [activeSnapshot.table], ) const agentDataStatus = useMemo( - () => - getAgentDataStatus(projectSnapshot.headline.generatedAt, { - latestRollupDay: projectSnapshot.filters.availableEndDay, - }), - [ - projectSnapshot.filters.availableEndDay, - projectSnapshot.headline.generatedAt, - ], + () => getAggregateAgentDataStatus(selectedProjects), + [selectedProjects], ) const bucketLabel = activeSnapshot.headline.granularity === 'hour' ? 'Hourly' : 'Daily' @@ -897,6 +899,8 @@ function ProjectFilterChip({ onChange, selectedProjectIds, }: ProjectFilterChipProps) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) const selectedSet = new Set(selectedProjectIds) const selectedLabel = selectedProjectIds.length === 0 || @@ -910,6 +914,32 @@ function ProjectFilterChip({ )?.projectName || 'Selected agent' : `${selectedProjectIds.length} selected agents` + useEffect(() => { + if (!isOpen) { + return + } + + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setIsOpen(false) + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('mousedown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen]) + const toggleProject = (projectId: string) => { if (selectedProjectIds.length === 0) { onChange( @@ -935,8 +965,13 @@ function ProjectFilterChip({ } return ( -
- +
+
-
-
-
-

Agents

-

- Select one or more agents to compare or roll up. -

+ + {isOpen ? ( +
+
+
+

Agents

+

+ Select one or more agents to compare or roll up. +

+
+
- -
-
- {availableProjects.map((project) => { - const checked = - selectedProjectIds.length === 0 || - selectedSet.has(project.projectId) - - return ( -
+ ) : null} + ) }