From 64092ca0aa8674f300e2d0c7c2f59520ab39687c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 18 Jun 2026 16:51:56 -0700 Subject: [PATCH] FN-6654: add Command Center agent-run sheets Surface agent heartbeat-run analytics in the Command Center activity views and exports. - Add agent-run summary and daily aggregation to activity analytics with zero-value fallback for older databases. - Render overview and Activity stat cards plus a daily agent-runs sparkline. - Include agent-run values in Activity CSV exports, docs, release notes, and regression coverage. Files changed: .changeset/fn-6654-agent-runs-sheets.md | 5 + docs/dashboard-guide.md | 5 +- .../core/src/__tests__/activity-analytics.test.ts | 66 +++++++++++++ packages/core/src/activity-analytics.ts | 102 +++++++++++++++++++-- .../components/command-center/CommandCenter.tsx | 7 +- .../__tests__/CommandCenter.test.tsx | 27 +++++- .../command-center/areas/ActivityArea.tsx | 37 +++++++- .../command-center/areas/__tests__/areas.test.tsx | 50 +++++++++- .../src/__tests__/command-center-csv.test.ts | 46 +++++++++- .../register-command-center-routes.test.ts | 14 +++ packages/dashboard/src/command-center-csv.ts | 12 ++- 11 files changed, 348 insertions(+), 23 deletions(-) Fusion-Task-Id: FN-6654 Fusion-Task-Lineage: d5c78ec1-8aef-4fdf-8a11-ee337da4cb00 --- .changeset/fn-6654-agent-runs-sheets.md | 5 + docs/dashboard-guide.md | 5 +- .../src/__tests__/activity-analytics.test.ts | 66 ++++++++++++ packages/core/src/activity-analytics.ts | 102 ++++++++++++++++-- .../command-center/CommandCenter.tsx | 7 +- .../__tests__/CommandCenter.test.tsx | 27 ++++- .../command-center/areas/ActivityArea.tsx | 37 ++++++- .../areas/__tests__/areas.test.tsx | 50 ++++++++- .../src/__tests__/command-center-csv.test.ts | 46 +++++++- .../register-command-center-routes.test.ts | 14 +++ packages/dashboard/src/command-center-csv.ts | 12 ++- 11 files changed, 348 insertions(+), 23 deletions(-) create mode 100644 .changeset/fn-6654-agent-runs-sheets.md diff --git a/.changeset/fn-6654-agent-runs-sheets.md b/.changeset/fn-6654-agent-runs-sheets.md new file mode 100644 index 0000000000..d03e7e97c9 --- /dev/null +++ b/.changeset/fn-6654-agent-runs-sheets.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add Command Center agent-run sheets that show total, active, completed, and failed heartbeat runs in the Activity area and Overview, plus agent-run daily activity and CSV export rows. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index e84d046197..d4da6fde47 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -663,15 +663,16 @@ Navigation: Features: - Global date-range picker in the header scopes the analytics tabs; **Mission Control** remains live rather than historical. -- **Overview** summarizes token usage/cost, autonomy, active nodes, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with tokens-by-model, tool-category, and daily activity trend charts that reuse the already-loaded tokens, tools, and activity analytics. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. +- **Overview** summarizes token usage/cost, autonomy, active nodes, agent runs, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with tokens-by-model, tool-category, and daily activity trend charts that reuse the already-loaded tokens, tools, and activity analytics. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. - **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. It also includes a live token-usage-over-time chart backed by per-task token timestamps; use the granularity control to switch the chart between hourly, daily, and weekly buckets. The token total and chart poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users. - **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. -- **Activity** tracks sessions, messages, active nodes, active agents, and stickiness, then renders live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`). These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users. +- **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area also renders live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`). These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users. - **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language. - **Ecosystem** shows active model breadth and per-model task activity; unavailable plugin-activation metrics render as unavailable rather than zero. - **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using task `updatedAt` as the documented completion-time approximation because Fusion does not persist a separate source-issue closed timestamp. The area shows filed/fixed/net stat cards, filed-vs-fixed daily sparklines, and a by-repository bar breakdown; it never calls GitHub, the `gh` CLI, or any external network source. - **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected. - **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. Motion-heavy accents respect reduced-motion preferences. +- CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`. Data states: - Overview shows a loading state while core analytics settle, then shows `No usage data yet. Run some agents to populate the Command Center.` only after the selected range has settled with no core usage data. diff --git a/packages/core/src/__tests__/activity-analytics.test.ts b/packages/core/src/__tests__/activity-analytics.test.ts index f893bb0c83..e2b915a0e3 100644 --- a/packages/core/src/__tests__/activity-analytics.test.ts +++ b/packages/core/src/__tests__/activity-analytics.test.ts @@ -84,11 +84,38 @@ function insertCliSession(db: Database, id: string, createdAt: string): void { ).run(id, createdAt, createdAt); } +let agentRunSeq = 0; +function insertAgentRun( + db: Database, + fields: { + agentId?: string; + startedAt: string; + endedAt?: string | null; + status: string; + }, +): string { + const id = `run-${agentRunSeq++}`; + const agentId = fields.agentId ?? "agent-1"; + db.prepare( + `INSERT OR IGNORE INTO agents (id, name, role, state, createdAt, updatedAt) + VALUES (?, ?, 'executor', 'idle', ?, ?)`, + ).run(agentId, agentId, fields.startedAt, fields.startedAt); + db.prepare( + `INSERT INTO agentRuns (id, agentId, data, startedAt, endedAt, status) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, agentId, JSON.stringify({ taskId: `task-${id}` }), fields.startedAt, fields.endedAt ?? null, fields.status); + return id; +} + describe("activity-analytics", () => { let tmpDir: string; let db: Database; beforeEach(() => { + incidentSeq = 0; + deploySeq = 0; + moveSeq = 0; + agentRunSeq = 0; tmpDir = mkdtempSync(join(tmpdir(), "kb-activity-analytics-")); db = new Database(join(tmpDir, ".fusion")); db.init(); @@ -127,6 +154,44 @@ describe("activity-analytics", () => { expect(result.daily[1]).toMatchObject({ day: "2026-03-02", activeNodes: 1, activeAgents: 1, messages: 1 }); }); + it("counts agent runs by status over startedAt range and includes unknown statuses only in total", () => { + insertAgentRun(db, { agentId: "agent-a", startedAt: "2026-03-01T00:00:00.000Z", status: "active" }); + insertAgentRun(db, { agentId: "agent-b", startedAt: "2026-03-02T00:00:00.000Z", endedAt: "2026-03-02T00:10:00.000Z", status: "completed" }); + insertAgentRun(db, { agentId: "agent-c", startedAt: "2026-03-03T00:00:00.000Z", endedAt: "2026-03-03T00:05:00.000Z", status: "failed" }); + insertAgentRun(db, { agentId: "agent-d", startedAt: "2026-03-04T00:00:00.000Z", endedAt: "2026-03-04T00:01:00.000Z", status: "cancelled" }); + insertAgentRun(db, { agentId: "agent-old", startedAt: "2026-02-28T23:59:59.000Z", status: "completed" }); + insertAgentRun(db, { agentId: "agent-new", startedAt: "2026-04-01T00:00:00.000Z", status: "failed" }); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" }); + + expect(result.agentRuns).toEqual({ total: 4, active: 1, completed: 1, failed: 1 }); + }); + + it("aligns per-day agent run counts with usage days and run-only days", () => { + emitUsageEvent(db, { kind: "user_message", agentId: "a", nodeId: "n1", ts: "2026-03-01T08:00:00.000Z" }); + emitUsageEvent(db, { kind: "user_message", agentId: "b", nodeId: "n2", ts: "2026-03-03T08:00:00.000Z" }); + insertAgentRun(db, { startedAt: "2026-03-02T00:00:00.000Z", status: "completed" }); + insertAgentRun(db, { startedAt: "2026-03-03T00:00:00.000Z", status: "failed" }); + insertAgentRun(db, { startedAt: "2026-03-03T02:00:00.000Z", status: "active" }); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + + expect(result.daily).toEqual([ + { day: "2026-03-01", activeNodes: 1, activeAgents: 1, messages: 1, agentRuns: 0 }, + { day: "2026-03-02", activeNodes: 0, activeAgents: 0, messages: 0, agentRuns: 1 }, + { day: "2026-03-03", activeNodes: 1, activeAgents: 1, messages: 1, agentRuns: 2 }, + ]); + }); + + it("returns zero agent-run metrics when the agentRuns table is absent", () => { + db.prepare("DROP TABLE agentRuns").run(); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + + expect(result.agentRuns).toEqual({ total: 0, active: 0, completed: 0, failed: 0 }); + expect(result.daily).toEqual([]); + }); + it("computes stickiness = DAU/MAU", () => { // Day 1: agents a,b active. Day 2: agent a active. MAU = {a,b} = 2. // DAU = mean(2, 1) = 1.5. stickiness = 1.5 / 2 = 0.75. @@ -148,6 +213,7 @@ describe("activity-analytics", () => { expect(result.messages).toBe(0); expect(result.activeNodes).toBe(0); expect(result.activeAgents).toBe(0); + expect(result.agentRuns).toEqual({ total: 0, active: 0, completed: 0, failed: 0 }); expect(result.daily).toEqual([]); expect(result.stickiness).toBe(0); }); diff --git a/packages/core/src/activity-analytics.ts b/packages/core/src/activity-analytics.ts index ce8693ed34..d17b69b1ad 100644 --- a/packages/core/src/activity-analytics.ts +++ b/packages/core/src/activity-analytics.ts @@ -24,13 +24,23 @@ export interface ActivityAnalyticsQuery { to?: string; } -/** Distinct active nodes/agents and message count for a single UTC day. */ +/** Distinct active nodes/agents, messages, and agent-run count for a single UTC day. */ export interface DailyActivity { /** UTC date, `YYYY-MM-DD`. */ day: string; activeNodes: number; activeAgents: number; messages: number; + /** Agent heartbeat runs started on this UTC day. */ + agentRuns: number; +} + +/** Agent heartbeat-run counts over an activity range, grouped by canonical status. */ +export interface AgentRunSummary { + total: number; + active: number; + completed: number; + failed: number; } /** @@ -77,6 +87,8 @@ export interface ActivityAnalytics { activeNodes: number; /** Distinct agents with any usage_event in range. */ activeAgents: number; + /** Agent heartbeat runs started in range, grouped by status. */ + agentRuns: AgentRunSummary; /** Per-day breakdown, ascending by day. */ daily: DailyActivity[]; /** @@ -107,6 +119,16 @@ interface DayAggRow { messages: number; } +interface AgentRunStatusRow { + status: string; + count: number; +} + +interface AgentRunDayRow { + day: string; + count: number; +} + function rangeClauses( column: string, query: ActivityAnalyticsQuery, @@ -189,12 +211,36 @@ export function aggregateActivityAnalytics( ORDER BY day ASC`, ) .all(...eventRange.params) as DayAggRow[]; - const daily: DailyActivity[] = dailyRows.map((r) => ({ - day: r.day, - activeNodes: r.activeNodes, - activeAgents: r.activeAgents, - messages: r.messages ?? 0, - })); + /** + * FNXC:CommandCenter 2026-06-18-00:00: + * Command Center activity analytics must surface agent heartbeat-run volume as stat cards by status and as a per-day trend without requiring a schema migration or new endpoint. Count by agentRuns.startedAt in the selected range, and degrade to zeros when older databases do not have the table. + */ + const agentRunMetrics = aggregateAgentRunMetrics(db, query); + const dailyByDay = new Map(); + for (const r of dailyRows) { + dailyByDay.set(r.day, { + day: r.day, + activeNodes: r.activeNodes, + activeAgents: r.activeAgents, + messages: r.messages ?? 0, + agentRuns: 0, + }); + } + for (const r of agentRunMetrics.daily) { + const existing = dailyByDay.get(r.day); + if (existing) { + existing.agentRuns = r.count; + } else { + dailyByDay.set(r.day, { + day: r.day, + activeNodes: 0, + activeAgents: 0, + messages: 0, + agentRuns: r.count, + }); + } + } + const daily: DailyActivity[] = [...dailyByDay.values()].sort((a, b) => a.day.localeCompare(b.day)); // Stickiness = DAU/MAU. DAU = mean distinct-active-agents-per-day; MAU = // distinct active agents over the range. @@ -215,6 +261,7 @@ export function aggregateActivityAnalytics( messages, activeNodes, activeAgents, + agentRuns: agentRunMetrics.summary, daily, stickiness, mttr: monitor.mttr, @@ -227,6 +274,47 @@ export function aggregateActivityAnalytics( }; } +function zeroAgentRunSummary(): AgentRunSummary { + return { total: 0, active: 0, completed: 0, failed: 0 }; +} + +function aggregateAgentRunMetrics( + db: Database, + query: ActivityAnalyticsQuery, +): { summary: AgentRunSummary; daily: AgentRunDayRow[] } { + if (!tableExists(db, "agentRuns")) { + return { summary: zeroAgentRunSummary(), daily: [] }; + } + + const range = rangeClauses("startedAt", query); + const statusRows = db + .prepare( + `SELECT status, COUNT(*) AS count + FROM agentRuns ${range.where} + GROUP BY status`, + ) + .all(...range.params) as AgentRunStatusRow[]; + + const summary = zeroAgentRunSummary(); + for (const row of statusRows) { + summary.total += row.count; + if (row.status === "active") summary.active = row.count; + if (row.status === "completed") summary.completed = row.count; + if (row.status === "failed") summary.failed = row.count; + } + + const daily = db + .prepare( + `SELECT substr(startedAt, 1, 10) AS day, COUNT(*) AS count + FROM agentRuns ${range.where} + GROUP BY day + ORDER BY day ASC`, + ) + .all(...range.params) as AgentRunDayRow[]; + + return { summary, daily }; +} + /* ------------------------------------------------------------------------- */ /* U7 — SDLC funnel + throughput */ /* ------------------------------------------------------------------------- */ diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index ee63fb4934..0016651bf3 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -60,7 +60,7 @@ interface OverviewStatCard { /* FNXC:CommandCenter 2026-06-17-00:00: -Overview is the Command Center landing surface, so it must reflect real analytics instead of shell placeholders. Show loading while core analytics have not settled, show the empty state only after settled zero data, and treat Signals as best-effort because that endpoint can be absent without invalidating tokens/tools/activity metrics. +Overview is the Command Center landing surface, so it must reflect real analytics instead of shell placeholders. Show loading while core analytics have not settled, show the empty state only after settled zero data, include the agent-runs card as a first-class activity signal, and treat Signals as best-effort because that endpoint can be absent without invalidating tokens/tools/activity metrics. */ const OVERVIEW_TOKEN_REFRESH_MS = 15_000; @@ -136,6 +136,7 @@ function OverviewTab({ range }: { range: DateRange }) { const toolCalls = tools.data?.toolCalls ?? 0; const activeNodes = activity.data?.activeNodes ?? 0; const activeAgents = activity.data?.activeAgents ?? 0; + const agentRunsTotal = activity.data?.agentRuns?.total ?? 0; const tasksDone = activity.data?.funnel?.doneInRange ?? 0; /* FNXC:CommandCenter 2026-06-18-00:00: @@ -167,7 +168,7 @@ function OverviewTab({ range }: { range: DateRange }) { [tools.data?.byCategory], ); const dailyActivityValues = useMemo( - () => (activity.data?.daily ?? []).map((day) => day.messages + day.activeAgents), + () => (activity.data?.daily ?? []).map((day) => day.messages + day.activeAgents + (day.agentRuns ?? 0)), [activity.data?.daily], ); const activityTrendValues = @@ -180,6 +181,7 @@ function OverviewTab({ range }: { range: DateRange }) { (activity.data?.messages ?? 0) > 0 || activeNodes > 0 || activeAgents > 0 || + agentRunsTotal > 0 || tasksDone > 0; const hasData = tokenTotal > 0 || toolCalls > 0 || hasActivityData; const hasAllCoreData = tokens.data !== null && tools.data !== null && activity.data !== null; @@ -204,6 +206,7 @@ function OverviewTab({ range }: { range: DateRange }) { }, { id: "autonomy", label: t("commandCenter.overview.autonomy", "Autonomy ratio"), value: autonomyLabel }, { id: "nodes", label: t("commandCenter.overview.activeNodes", "Active nodes"), value: formatCount(activeNodes) }, + { id: "agentRuns", label: t("commandCenter.overview.agentRuns", "Agent runs"), value: formatCount(agentRunsTotal) }, { id: "tasksDone", label: t("commandCenter.overview.tasksDone", "Tasks done"), value: formatCount(tasksDone) }, { id: "models", label: t("commandCenter.overview.uniqueModels", "Unique models"), value: formatCount(uniqueModels) }, { diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index 7676ea576b..e43a40ff74 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -66,11 +66,14 @@ function toolsFixture(toolCalls = 30) { }; } -function activityFixture(overrides: Partial> = {}) { +function activityFixture( + overrides: Partial> = {}, +) { const sessions = overrides.sessions ?? 4; const messages = overrides.messages ?? 18; const activeNodes = overrides.activeNodes ?? 3; const activeAgents = overrides.activeAgents ?? 2; + const agentRuns = overrides.agentRuns ?? 8; const doneInRange = overrides.doneInRange ?? 7; const inProgress = overrides.inProgress ?? 3; return { @@ -80,7 +83,8 @@ function activityFixture(overrides: Partial 0 ? [{ day: "2026-06-08", activeNodes, activeAgents, messages }] : [], + agentRuns: { total: agentRuns, active: agentRuns > 0 ? 1 : 0, completed: Math.max(0, agentRuns - 2), failed: agentRuns > 1 ? 1 : 0 }, + daily: messages > 0 || agentRuns > 0 ? [{ day: "2026-06-08", activeNodes, activeAgents, messages, agentRuns }] : [], stickiness: activeAgents > 0 ? 0.5 : 0, mttr: { value: null, unavailable: true }, monitor: { mttr: { value: null, unavailable: true }, incidents: 0, deployments: 0 }, @@ -100,7 +104,7 @@ function activityFixture(overrides: Partial - activityFixture({ sessions: 0, messages: 0, activeNodes: 0, activeAgents: 0, doneInRange: 0 }); + activityFixture({ sessions: 0, messages: 0, activeNodes: 0, activeAgents: 0, agentRuns: 0, doneInRange: 0 }); function githubFixture(filed = 0, fixed = 0) { return { @@ -208,6 +212,20 @@ describe("CommandCenter shell", () => { expect(screen.queryByTestId("command-center-overview-charts")).toBeNull(); }); + it("renders the Overview agent-runs card when run data is the only activity", async () => { + mockOverviewApi({ + tokens: tokenFixture(0), + tools: toolsFixture(0), + activity: activityFixture({ sessions: 0, messages: 0, activeNodes: 0, activeAgents: 0, agentRuns: 5, doneInRange: 0 }), + signals: signalsFixture(0), + live: liveFixture([{ column: "in-progress", count: 0 }]), + }); + render(); + + await waitFor(() => expect(screen.queryByTestId("command-center-empty")).toBeNull()); + expect(statValue("command-center-stat-agentRuns")).toBe("5"); + }); + it("renders live Overview headline values when analytics data exists", async () => { mockOverviewApi(); render(); @@ -219,6 +237,7 @@ describe("CommandCenter shell", () => { expect(screen.getByTestId("command-center-stat-tokens").textContent).toContain("$12.50"); expect(statValue("command-center-stat-autonomy")).toBe("10.0:1"); expect(statValue("command-center-stat-nodes")).toBe("3"); + expect(statValue("command-center-stat-agentRuns")).toBe("8"); expect(statValue("command-center-stat-tasksDone")).toBe("7"); expect(statValue("command-center-stat-models")).toBe("2"); expect(statValue("command-center-stat-signals")).toBe("2"); @@ -343,7 +362,7 @@ describe("CommandCenter shell", () => { }); it("renders cards for partially populated analytics instead of the empty state", async () => { - mockOverviewApi({ tokens: tokenFixture(0), tools: toolsFixture(0), activity: activityFixture({ sessions: 0, messages: 0, activeNodes: 1, activeAgents: 0, doneInRange: 0 }), signals: signalsFixture(0) }); + mockOverviewApi({ tokens: tokenFixture(0), tools: toolsFixture(0), activity: activityFixture({ sessions: 0, messages: 0, activeNodes: 1, activeAgents: 0, agentRuns: 0, doneInRange: 0 }), signals: signalsFixture(0) }); render(); await screen.findByTestId("command-center-stat-nodes"); diff --git a/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx b/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx index b3694e0b2d..e2b6931e66 100644 --- a/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import type { ActivityAnalytics } from "@fusion/core"; import type { DateRange } from "../DateRangePicker"; import { LineChart } from "../charts/LineChart"; +import { Sparkline } from "../charts/Sparkline"; import { AreaShell } from "./AreaShell"; import { useAnalyticsArea } from "./useAnalyticsArea"; import { formatCount, isInvalidRange } from "./areaShared"; @@ -21,6 +22,7 @@ export function ActivityArea({ range }: { range: DateRange }) { const messagesSeries = useMemo(() => daily.map((d) => d.messages), [daily]); const agentsSeries = useMemo(() => daily.map((d) => d.activeAgents), [daily]); const nodesSeries = useMemo(() => daily.map((d) => d.activeNodes), [daily]); + const agentRunsSeries = useMemo(() => daily.map((d) => d.agentRuns), [daily]); const throughputSeries = useMemo( () => daily.map((d) => d.messages + d.activeAgents + d.activeNodes), [daily], @@ -38,9 +40,14 @@ export function ActivityArea({ range }: { range: DateRange }) { return () => window.clearInterval(interval); }, [invalidRange, reload]); + const agentRuns = data?.agentRuns ?? { total: 0, active: 0, completed: 0, failed: 0 }; const isEmpty = !data || - (data.sessions === 0 && data.messages === 0 && data.activeNodes === 0 && data.activeAgents === 0); + (data.sessions === 0 && + data.messages === 0 && + data.activeNodes === 0 && + data.activeAgents === 0 && + agentRuns.total === 0); return ( @@ -63,6 +70,26 @@ export function ActivityArea({ range }: { range: DateRange }) {
{t("commandCenter.activity.activeAgents", "Active agents")}
{formatCount(data?.activeAgents ?? 0)}
+ {/* + FNXC:CommandCenter 2026-06-18-00:00: + Activity Summary needs agent-run sheets for total, active, completed, and failed heartbeat runs so operators can read run volume without leaving the existing Command Center Activity surface. + */} +
+
{t("commandCenter.activity.agentRuns", "Agent runs")}
+
{formatCount(agentRuns.total)}
+
+
+
{t("commandCenter.activity.agentRunsActive", "Active")}
+
{formatCount(agentRuns.active)}
+
+
+
{t("commandCenter.activity.agentRunsCompleted", "Completed")}
+
{formatCount(agentRuns.completed)}
+
+
+
{t("commandCenter.activity.agentRunsFailed", "Failed")}
+
{formatCount(agentRuns.failed)}
+
{t("commandCenter.activity.stickiness", "Stickiness")}
{data ? `${Math.round(data.stickiness * 100)}%` : "—"}
@@ -95,6 +122,14 @@ export function ActivityArea({ range }: { range: DateRange }) { />
+
+

{t("commandCenter.activity.agentRunsPerDay", "Agent runs / day")}

+ +
+

{t("commandCenter.activity.throughputPerDay", "Throughput / day")}

{ expect(screen.getByTestId("cc-activity-messages").textContent).toContain("12"); expect(screen.getByTestId("cc-activity-nodes").textContent).toContain("3"); expect(screen.getByTestId("cc-activity-agents").textContent).toContain("2"); + expect(screen.getByTestId("cc-activity-agent-runs").textContent).toContain("8"); + expect(screen.getByTestId("cc-activity-agent-runs-active").textContent).toContain("1"); + expect(screen.getByTestId("cc-activity-agent-runs-completed").textContent).toContain("6"); + expect(screen.getByTestId("cc-activity-agent-runs-failed").textContent).toContain("1"); expect(screen.getByTestId("cc-activity-stickiness").textContent).toContain("50%"); expect(screen.getByTestId("cc-activity-line-messages")).toBeTruthy(); expect(screen.getByTestId("cc-activity-line-agents")).toBeTruthy(); expect(screen.getByTestId("cc-activity-line-nodes")).toBeTruthy(); + expect(screen.getByTestId("cc-activity-agent-runs-sparkline")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Agent runs / day" })).toBeTruthy(); expect(screen.getByTestId("cc-activity-line-throughput")).toBeTruthy(); }); + it("renders zero agent-run cards when counts are zero and other activity exists", async () => { + apiMock.mockResolvedValue({ + ...activityFixture(), + agentRuns: { total: 0, active: 0, completed: 0, failed: 0 }, + daily: [{ day: "2026-06-08", messages: 1, activeNodes: 1, activeAgents: 1, agentRuns: 0 }], + }); + render(); + + await screen.findByTestId("cc-area-activity"); + expect(screen.queryByTestId("cc-area-activity-empty")).toBeNull(); + expect(screen.getByTestId("cc-activity-agent-runs").textContent).toContain("0"); + expect(screen.getByTestId("cc-activity-agent-runs-active").textContent).toContain("0"); + expect(screen.getByTestId("cc-activity-agent-runs-completed").textContent).toContain("0"); + expect(screen.getByTestId("cc-activity-agent-runs-failed").textContent).toContain("0"); + }); + + it("renders agent-run cards instead of the empty state when only run data exists", async () => { + apiMock.mockResolvedValue({ + ...activityFixture(), + sessions: 0, + messages: 0, + activeNodes: 0, + activeAgents: 0, + agentRuns: { total: 2, active: 1, completed: 1, failed: 0 }, + daily: [{ day: "2026-06-08", messages: 0, activeNodes: 0, activeAgents: 0, agentRuns: 2 }], + stickiness: 0, + }); + render(); + + await screen.findByTestId("cc-area-activity"); + expect(screen.queryByTestId("cc-area-activity-empty")).toBeNull(); + expect(screen.getByTestId("cc-activity-agent-runs").textContent).toContain("2"); + expect(screen.getByTestId("cc-activity-agent-runs-sparkline")).toBeTruthy(); + }); + it("renders the empty state for zero activity without empty chart shells", async () => { apiMock.mockResolvedValue({ ...activityFixture(), @@ -232,6 +274,7 @@ describe("ActivityArea", () => { messages: 0, activeNodes: 0, activeAgents: 0, + agentRuns: { total: 0, active: 0, completed: 0, failed: 0 }, daily: [], stickiness: 0, }); @@ -241,6 +284,7 @@ describe("ActivityArea", () => { expect(screen.queryByTestId("cc-activity-line-messages")).toBeNull(); expect(screen.queryByTestId("cc-activity-line-agents")).toBeNull(); expect(screen.queryByTestId("cc-activity-line-nodes")).toBeNull(); + expect(screen.queryByTestId("cc-activity-agent-runs-sparkline")).toBeNull(); expect(screen.queryByTestId("cc-activity-line-throughput")).toBeNull(); }); diff --git a/packages/dashboard/src/__tests__/command-center-csv.test.ts b/packages/dashboard/src/__tests__/command-center-csv.test.ts index 797dee61fc..bec7d9ed39 100644 --- a/packages/dashboard/src/__tests__/command-center-csv.test.ts +++ b/packages/dashboard/src/__tests__/command-center-csv.test.ts @@ -4,9 +4,10 @@ import { describe, expect, it } from "vitest"; import { serializeCsv, tokenAnalyticsToTable, + activityAnalyticsToTable, type CsvTable, } from "../command-center-csv.js"; -import type { TokenAnalytics } from "@fusion/core"; +import type { ActivityAnalytics, TokenAnalytics } from "@fusion/core"; describe("serializeCsv (RFC-4180)", () => { it("emits a header row and CRLF-terminated records", () => { @@ -47,6 +48,49 @@ describe("serializeCsv (RFC-4180)", () => { }); }); +describe("activityAnalyticsToTable", () => { + function result(): ActivityAnalytics { + return { + from: "2026-06-01", + to: "2026-06-02", + sessions: 2, + messages: 5, + activeNodes: 1, + activeAgents: 2, + agentRuns: { total: 9, active: 3, completed: 4, failed: 2 }, + daily: [{ day: "2026-06-01", messages: 5, activeNodes: 1, activeAgents: 2, agentRuns: 9 }], + stickiness: 0.5, + mttr: { value: null, unavailable: true, sampleCount: 0 }, + monitor: { + mttr: { value: null, unavailable: true, sampleCount: 0 }, + incidentsOpened: 0, + incidentsResolved: 0, + openIncidents: 0, + deployments: 0, + }, + funnel: { + stages: [], + enteredInRange: 0, + doneInRange: 0, + completionRate: null, + throughputPerDay: 0, + rangeDays: 2, + }, + }; + } + + it("includes agent run daily and summary rows", () => { + const table = activityAnalyticsToTable(result()); + + expect(table.header).toEqual(["day", "messages", "activeNodes", "activeAgents", "agentRuns"]); + expect(table.rows).toContainEqual(["2026-06-01", 5, 1, 2, 9]); + expect(table.rows).toContainEqual(["(agentRuns.total)", 9, "", "", ""]); + expect(table.rows).toContainEqual(["(agentRuns.active)", 3, "", "", ""]); + expect(table.rows).toContainEqual(["(agentRuns.completed)", 4, "", "", ""]); + expect(table.rows).toContainEqual(["(agentRuns.failed)", 2, "", "", ""]); + }); +}); + describe("tokenAnalyticsToTable", () => { function emptyResult(): TokenAnalytics { return { diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts index 1711b293be..de33cffde9 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -49,6 +49,17 @@ function seedDb(db: Database, opts: { taskId: string; model: string; tokens: num }); } +function seedAgentRun(db: Database, opts: { id: string; agentId: string; startedAt: string; status: string }): void { + db.prepare( + `INSERT OR IGNORE INTO agents (id, name, role, state, createdAt, updatedAt) + VALUES (?, ?, 'executor', 'idle', ?, ?)`, + ).run(opts.agentId, opts.agentId, opts.startedAt, opts.startedAt); + db.prepare( + `INSERT INTO agentRuns (id, agentId, data, startedAt, endedAt, status) + VALUES (?, ?, '{}', ?, NULL, ?)`, + ).run(opts.id, opts.agentId, opts.startedAt, opts.status); +} + function seedGithubIssueMetrics(db: Database, opts: { prefix: string; repo: string; filed: number; fixed: number }): void { for (let i = 0; i < opts.filed; i += 1) { db.prepare( @@ -202,6 +213,7 @@ describe("register-command-center-routes", () => { it("returns the tools / activity / productivity aggregator shapes", async () => { const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z"; + seedAgentRun(dbA, { id: "run-a1", agentId: "agent-route", startedAt: "2026-03-02T00:00:00.000Z", status: "active" }); const tools = await request(app, "GET", `/api/command-center/tools?${range}&projectId=proj-a`); expect(tools.status).toBe(200); expect(tools.body).toHaveProperty("autonomyRatio"); @@ -211,6 +223,8 @@ describe("register-command-center-routes", () => { expect(activity.status).toBe(200); expect(activity.body).toHaveProperty("stickiness"); expect(activity.body).toHaveProperty("mttr"); + expect(activity.body).toHaveProperty("agentRuns"); + expect((activity.body as { agentRuns: { total: number; active: number } }).agentRuns).toMatchObject({ total: 1, active: 1 }); const prod = await request(app, "GET", `/api/command-center/productivity?${range}&projectId=proj-a`); expect(prod.status).toBe(200); diff --git a/packages/dashboard/src/command-center-csv.ts b/packages/dashboard/src/command-center-csv.ts index d72578639b..2dd838a6bc 100644 --- a/packages/dashboard/src/command-center-csv.ts +++ b/packages/dashboard/src/command-center-csv.ts @@ -136,21 +136,27 @@ export function toolAnalyticsToTable(result: ToolAnalytics): CsvTable { /** Activity analytics → CSV. One row per day plus summary rows. */ export function activityAnalyticsToTable(result: ActivityAnalytics): CsvTable { - const header = ["day", "messages", "activeNodes", "activeAgents"]; + const header = ["day", "messages", "activeNodes", "activeAgents", "agentRuns"]; const rows: CsvCell[][] = result.daily.map((d) => [ d.day, d.messages, d.activeNodes, d.activeAgents, + d.agentRuns, ]); rows.push([ "(total)", result.messages, result.activeNodes, result.activeAgents, + result.agentRuns.total, ]); - rows.push(["(sessions)", result.sessions, "", ""]); - rows.push(["(stickiness)", result.stickiness, "", ""]); + rows.push(["(sessions)", result.sessions, "", "", ""]); + rows.push(["(stickiness)", result.stickiness, "", "", ""]); + rows.push(["(agentRuns.total)", result.agentRuns.total, "", "", ""]); + rows.push(["(agentRuns.active)", result.agentRuns.active, "", "", ""]); + rows.push(["(agentRuns.completed)", result.agentRuns.completed, "", "", ""]); + rows.push(["(agentRuns.failed)", result.agentRuns.failed, "", "", ""]); return { header, rows }; }