From 770efc62a0abb670f237c09e2752e3ee18f0ccc4 Mon Sep 17 00:00:00 2001 From: Rick Date: Fri, 12 Jun 2026 10:28:55 -0500 Subject: [PATCH 1/2] feat: show per-agent status freshness --- apps/dashboard/src/lib/openai-usage.test.ts | 7 +++- apps/dashboard/src/lib/openai-usage.ts | 4 ++- apps/dashboard/src/lib/token-analytics.ts | 6 ++++ apps/dashboard/src/routes/index.tsx | 37 ++++++++++++++++++--- apps/dashboard/src/styles.css | 34 +++++++++++++++++++ 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/src/lib/openai-usage.test.ts b/apps/dashboard/src/lib/openai-usage.test.ts index 25fcbfa..0e78db8 100644 --- a/apps/dashboard/src/lib/openai-usage.test.ts +++ b/apps/dashboard/src/lib/openai-usage.test.ts @@ -491,7 +491,12 @@ describe('ingestExternalRollupsToD1', () => { expect(snapshot.headline.sourceLabel).toContain('Hermes data') expect(snapshot.headline.summary).toContain('Latest usage bucket: n/a.') expect(snapshot.projects.available).toEqual([ - expect.objectContaining({ projectName: 'Hermes Usage', projectSlug: 'hermes-usage' }), + expect.objectContaining({ + latestGeneratedAt: '2026-05-23T12:00:00.000Z', + latestRollupDay: null, + projectName: 'Hermes Usage', + projectSlug: 'hermes-usage', + }), ]) expect(filtered.headline.granularity).toBe('hour') expect(filtered.charts.requestsCostCache).toHaveLength(24) diff --git a/apps/dashboard/src/lib/openai-usage.ts b/apps/dashboard/src/lib/openai-usage.ts index d5a2727..a03ebaa 100644 --- a/apps/dashboard/src/lib/openai-usage.ts +++ b/apps/dashboard/src/lib/openai-usage.ts @@ -470,7 +470,9 @@ async function loadSnapshotFromD1( ): Promise { const workspaceIds = selections.map((selection) => selection.workspace.id) const rows = await loadDailyRollups(env.DB, workspaceIds, getDaysBack(env)) - const availableProjects = selections.map(({ workspace }) => ({ + const availableProjects = selections.map(({ latestCreatedAt, latestDay, workspace }) => ({ + latestGeneratedAt: new Date(latestCreatedAt).toISOString(), + latestRollupDay: latestDay, projectId: workspace.id, projectName: workspace.name, projectProvider: workspace.provider, diff --git a/apps/dashboard/src/lib/token-analytics.ts b/apps/dashboard/src/lib/token-analytics.ts index d91ce27..d3423f2 100644 --- a/apps/dashboard/src/lib/token-analytics.ts +++ b/apps/dashboard/src/lib/token-analytics.ts @@ -1,4 +1,6 @@ export type DashboardProjectOption = { + latestGeneratedAt?: string + latestRollupDay?: string | null projectId: string projectName: string projectProvider: string @@ -549,6 +551,8 @@ function resolveAvailableProjects(availableProjects: DashboardProjectOption[] | for (const row of dailyRows) { if (!projectMap.has(row.projectId)) { projectMap.set(row.projectId, { + latestGeneratedAt: row.latestGeneratedAt, + latestRollupDay: row.latestRollupDay, projectId: row.projectId, projectName: row.projectName, projectProvider: row.projectProvider, @@ -600,6 +604,8 @@ function summarizeProjects( cachedTokens: 0, cost: 0, inputTokens: 0, + latestGeneratedAt: project.latestGeneratedAt, + latestRollupDay: project.latestRollupDay, outputTokens: 0, projectId: project.projectId, projectName: project.projectName, diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx index e0abc19..0583863 100644 --- a/apps/dashboard/src/routes/index.tsx +++ b/apps/dashboard/src/routes/index.tsx @@ -47,6 +47,7 @@ import type { } from '#/lib/dashboard-timeframe' import { loadDashboardSnapshotForRequest } from '#/lib/openai-usage' import type { + DashboardProjectOption, DashboardProjectSummary, DashboardSnapshot, } from '#/lib/token-analytics' @@ -969,8 +970,11 @@ function ProjectFilterChip({ key={project.projectId} >
-
- {project.projectName} +
+
+ {project.projectName} +
+
{project.projectProvider} · {project.projectSlug} @@ -1029,9 +1033,12 @@ function ProjectBreakdownCard({ projects }: ProjectBreakdownCardProps) { {project.projectName} - - {project.projectProvider} · {project.projectSlug} - +
+ + + {project.projectProvider} · {project.projectSlug} + +
@@ -1058,6 +1065,22 @@ function ProjectBreakdownCard({ projects }: ProjectBreakdownCardProps) { ) } +function AgentStatusIndicator({ project }: AgentStatusIndicatorProps) { + const status = getAgentDataStatus(project.latestGeneratedAt || '', { + latestRollupDay: project.latestRollupDay || undefined, + }) + + return ( + + + {status.label} + + ) +} + function ChartCard({ action, children, @@ -1746,6 +1769,10 @@ type ProjectBreakdownCardProps = { projects: DashboardProjectSummary[] } +type AgentStatusIndicatorProps = { + project: Pick +} + type DashboardSearch = { endDay?: string preset?: TimeframePreset diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 8028015..aaa7820 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -262,6 +262,40 @@ box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.16); } +.dashboard-inline-status { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: #64748b; + font-size: 0.75rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; +} + +.dashboard-inline-status .dashboard-status-dot { + width: 0.5rem; + height: 0.5rem; + box-shadow: none; + animation: none; +} + +.dashboard-inline-status.dashboard-status-healthy { + color: #166534; +} + +.dashboard-inline-status.dashboard-status-delayed { + color: #b45309; +} + +.dashboard-inline-status.dashboard-status-stale { + color: #b91c1c; +} + +.dashboard-inline-status.dashboard-status-unknown { + color: #475569; +} + .dashboard-header-actions { display: flex; flex-wrap: wrap; From 649f423b9009bbd5eb01d804d5d5fd3cc62e043d Mon Sep 17 00:00:00 2001 From: Rick Date: Fri, 12 Jun 2026 12:47:53 -0500 Subject: [PATCH 2/2] fix: flag mixed agent health in dashboard status --- .../src/lib/dashboard-agent-status.test.ts | 82 ++++++++- .../src/lib/dashboard-agent-status.ts | 94 +++++++++++ apps/dashboard/src/routes/index.tsx | 157 +++++++++++------- 3 files changed, 272 insertions(+), 61 deletions(-) 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 0583863..af40399 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,15 +93,17 @@ 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 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' @@ -893,6 +895,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 || @@ -906,6 +910,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( @@ -931,8 +961,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} +
) }