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
This commit is contained in:
5
.changeset/fn-6654-agent-runs-sheets.md
Normal file
5
.changeset/fn-6654-agent-runs-sheets.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<string, DailyActivity>();
|
||||
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 */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
@@ -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) },
|
||||
{
|
||||
|
||||
@@ -66,11 +66,14 @@ function toolsFixture(toolCalls = 30) {
|
||||
};
|
||||
}
|
||||
|
||||
function activityFixture(overrides: Partial<Record<"sessions" | "messages" | "activeNodes" | "activeAgents" | "doneInRange" | "inProgress", number>> = {}) {
|
||||
function activityFixture(
|
||||
overrides: Partial<Record<"sessions" | "messages" | "activeNodes" | "activeAgents" | "agentRuns" | "doneInRange" | "inProgress", number>> = {},
|
||||
) {
|
||||
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<Record<"sessions" | "messages" | "ac
|
||||
messages,
|
||||
activeNodes,
|
||||
activeAgents,
|
||||
daily: messages > 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<Record<"sessions" | "messages" | "ac
|
||||
}
|
||||
|
||||
const emptyActivityFixture = () =>
|
||||
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(<CommandCenter />);
|
||||
|
||||
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(<CommandCenter />);
|
||||
@@ -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(<CommandCenter />);
|
||||
|
||||
await screen.findByTestId("command-center-stat-nodes");
|
||||
|
||||
@@ -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 (
|
||||
<AreaShell testId="activity" isLoading={isInitialLoading} error={error} isEmpty={isEmpty}>
|
||||
@@ -63,6 +70,26 @@ export function ActivityArea({ range }: { range: DateRange }) {
|
||||
<div className="cc-stat-label">{t("commandCenter.activity.activeAgents", "Active agents")}</div>
|
||||
<div className="cc-stat-value">{formatCount(data?.activeAgents ?? 0)}</div>
|
||||
</div>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div className="card cc-stat-card" data-testid="cc-activity-agent-runs">
|
||||
<div className="cc-stat-label">{t("commandCenter.activity.agentRuns", "Agent runs")}</div>
|
||||
<div className="cc-stat-value">{formatCount(agentRuns.total)}</div>
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-activity-agent-runs-active">
|
||||
<div className="cc-stat-label">{t("commandCenter.activity.agentRunsActive", "Active")}</div>
|
||||
<div className="cc-stat-value">{formatCount(agentRuns.active)}</div>
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-activity-agent-runs-completed">
|
||||
<div className="cc-stat-label">{t("commandCenter.activity.agentRunsCompleted", "Completed")}</div>
|
||||
<div className="cc-stat-value">{formatCount(agentRuns.completed)}</div>
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-activity-agent-runs-failed">
|
||||
<div className="cc-stat-label">{t("commandCenter.activity.agentRunsFailed", "Failed")}</div>
|
||||
<div className="cc-stat-value">{formatCount(agentRuns.failed)}</div>
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-activity-stickiness">
|
||||
<div className="cc-stat-label">{t("commandCenter.activity.stickiness", "Stickiness")}</div>
|
||||
<div className="cc-stat-value">{data ? `${Math.round(data.stickiness * 100)}%` : "—"}</div>
|
||||
@@ -95,6 +122,14 @@ export function ActivityArea({ range }: { range: DateRange }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section" data-testid="cc-activity-agent-runs-sparkline">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.activity.agentRunsPerDay", "Agent runs / day")}</h3>
|
||||
<Sparkline
|
||||
values={agentRunsSeries}
|
||||
ariaLabel={t("commandCenter.activity.agentRunsPerDay", "Agent runs / day")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section" data-testid="cc-activity-line-throughput">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.activity.throughputPerDay", "Throughput / day")}</h3>
|
||||
<LineChart
|
||||
|
||||
@@ -110,10 +110,11 @@ function activityFixture() {
|
||||
messages: 12,
|
||||
activeNodes: 3,
|
||||
activeAgents: 2,
|
||||
agentRuns: { total: 8, active: 1, completed: 6, failed: 1 },
|
||||
daily: [
|
||||
{ day: "2026-06-08", messages: 2, activeNodes: 1, activeAgents: 1 },
|
||||
{ day: "2026-06-09", messages: 4, activeNodes: 2, activeAgents: 1 },
|
||||
{ day: "2026-06-10", messages: 6, activeNodes: 3, activeAgents: 2 },
|
||||
{ day: "2026-06-08", messages: 2, activeNodes: 1, activeAgents: 1, agentRuns: 2 },
|
||||
{ day: "2026-06-09", messages: 4, activeNodes: 2, activeAgents: 1, agentRuns: 3 },
|
||||
{ day: "2026-06-10", messages: 6, activeNodes: 3, activeAgents: 2, agentRuns: 3 },
|
||||
],
|
||||
stickiness: 0.5,
|
||||
mttr: { value: null, unavailable: true, sampleCount: 0 },
|
||||
@@ -218,13 +219,54 @@ describe("ActivityArea", () => {
|
||||
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(<ActivityArea range={range7d} />);
|
||||
|
||||
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(<ActivityArea range={range7d} />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user