Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion apps/dashboard/src/lib/dashboard-agent-status.test.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand Down Expand Up @@ -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',
})
})
})
94 changes: 94 additions & 0 deletions apps/dashboard/src/lib/dashboard-agent-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
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
Expand Down Expand Up @@ -65,6 +72,81 @@
}
}

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 || '', {

Check failure on line 88 in apps/dashboard/src/lib/dashboard-agent-status.ts

View workflow job for this annotation

GitHub Actions / dashboard-validate

Unnecessary optional chain on a non-nullish value
expectedUpdateIntervalMs: options?.expectedUpdateIntervalMs,
latestRollupDay: project?.latestRollupDay || undefined,

Check failure on line 90 in apps/dashboard/src/lib/dashboard-agent-status.ts

View workflow job for this annotation

GitHub Actions / dashboard-validate

Unnecessary optional chain on a non-nullish value
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) {
Expand Down Expand Up @@ -102,3 +184,15 @@
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`
}
Loading
Loading