diff --git a/.changeset/fn-6655-command-center-team-view.md b/.changeset/fn-6655-command-center-team-view.md new file mode 100644 index 0000000000..04532c608a --- /dev/null +++ b/.changeset/fn-6655-command-center-team-view.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add the Command Center Team tab and `/api/command-center/team` endpoint for project-scoped per-agent token, cost, files-changed, task-completion, and live-status analytics. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d5b053bd34..c0e78c80f5 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -668,6 +668,7 @@ Features: - **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. - **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. +- **Team** shows a per-agent analytics table plus tokens-by-agent and tasks-done-by-agent charts. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users. - **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. @@ -677,6 +678,7 @@ Features: 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. - GitHub issue analytics is local and additive: empty filed/fixed totals render the GitHub area's empty state; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center. +- Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner. - Signals is best-effort: if the Signals endpoint is absent or no signal source is connected, the Signals area falls back to its empty state and other Command Center metrics remain valid. ## Reliability View diff --git a/packages/core/src/__tests__/team-analytics.test.ts b/packages/core/src/__tests__/team-analytics.test.ts new file mode 100644 index 0000000000..666f344f2a --- /dev/null +++ b/packages/core/src/__tests__/team-analytics.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { aggregateTeamAnalytics } from "../team-analytics.js"; + +interface TaskSeed { + id: string; + agentId?: string | null; + column?: string; + columnMovedAt?: string | null; + updatedAt?: string; + modifiedFiles?: unknown; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; + cacheWriteTokens?: number; + totalTokens?: number | null; + tokenUsageLastUsedAt?: string | null; + modelProvider?: string | null; + modelId?: string | null; +} + +function insertAgent(db: Database, id: string, name: string, role = "executor", state = "idle"): void { + db.prepare( + `INSERT INTO agents (id, name, role, state, createdAt, updatedAt) + VALUES (?, ?, ?, ?, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')`, + ).run(id, name, role, state); +} + +function modifiedFilesValue(value: unknown): string | null { + if (value === undefined) return "[]"; + if (value === null) return null; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +function insertTask(db: Database, task: TaskSeed): void { + const updatedAt = task.updatedAt ?? "2026-03-01T00:00:00.000Z"; + db.prepare( + `INSERT INTO tasks + (id, description, "column", createdAt, updatedAt, columnMovedAt, assignedAgentId, + modifiedFiles, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, + tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId) + VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + task.id, + task.column ?? "todo", + updatedAt, + updatedAt, + task.columnMovedAt ?? null, + task.agentId ?? null, + modifiedFilesValue(task.modifiedFiles), + task.inputTokens ?? null, + task.outputTokens ?? null, + task.cachedTokens ?? null, + task.cacheWriteTokens ?? null, + task.totalTokens === undefined ? null : task.totalTokens, + task.tokenUsageLastUsedAt ?? null, + task.modelProvider ?? null, + task.modelId ?? null, + ); +} + +describe("team-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-team-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("aggregates multiple agents with token cost, files, completed tasks, and live state", () => { + insertAgent(db, "agent-a", "Alpha", "executor", "running"); + insertAgent(db, "agent-b", "Beta", "reviewer", "idle"); + insertTask(db, { + id: "a-tokens", + agentId: "agent-a", + inputTokens: 1_000_000, + outputTokens: 1_000_000, + totalTokens: 2_000_000, + tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z", + modelProvider: "openai", + modelId: "gpt-4o", + }); + insertTask(db, { + id: "a-done", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-03-03T00:00:00.000Z", + modifiedFiles: ["src/a.ts", "src/b.ts"], + updatedAt: "2026-03-03T00:00:00.000Z", + }); + insertTask(db, { + id: "a-progress", + agentId: "agent-a", + column: "in-progress", + modifiedFiles: ["docs/readme.md"], + updatedAt: "2026-03-04T00:00:00.000Z", + }); + insertTask(db, { + id: "b-review", + agentId: "agent-b", + column: "in-review", + inputTokens: 50, + outputTokens: 25, + totalTokens: 75, + tokenUsageLastUsedAt: "2026-03-05T00:00:00.000Z", + modelProvider: "openai", + modelId: "gpt-4o-mini", + modifiedFiles: ["src/c.ts"], + updatedAt: "2026-03-05T00:00:00.000Z", + }); + + const result = aggregateTeamAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + now: Date.parse("2026-03-10T00:00:00.000Z"), + }); + + expect(result.from).toBe("2026-03-01T00:00:00.000Z"); + expect(result.to).toBe("2026-03-31T00:00:00.000Z"); + expect(result.agents.map((agent) => agent.agentId)).toEqual(["agent-a", "agent-b"]); + + const byAgent = new Map(result.agents.map((agent) => [agent.agentId, agent])); + expect(byAgent.get("agent-a")).toMatchObject({ + agentName: "Alpha", + role: "executor", + state: "running", + filesChanged: 3, + tasksCompleted: 1, + tasksInProgress: 1, + tasksInReview: 0, + }); + expect(byAgent.get("agent-a")?.tokens.totalTokens).toBe(2_000_000); + expect(byAgent.get("agent-a")?.cost).toEqual({ usd: 12.5, unavailable: false, stale: false }); + expect(byAgent.get("agent-b")).toMatchObject({ + agentName: "Beta", + role: "reviewer", + state: "idle", + filesChanged: 1, + tasksCompleted: 0, + tasksInProgress: 0, + tasksInReview: 1, + }); + expect(result.totals.tokens.totalTokens).toBe(2_000_075); + expect(result.totals.filesChanged).toBe(4); + expect(result.totals.tasksCompleted).toBe(1); + expect(result.totals.tasksInProgress).toBe(1); + expect(result.totals.tasksInReview).toBe(1); + }); + + it("returns zeroed totals and an empty agent array for an empty database", () => { + const result = aggregateTeamAnalytics(db, {}); + + expect(result.totals).toEqual({ + tokens: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + nTasks: 0, + }, + cost: { usd: null, unavailable: false, stale: false }, + filesChanged: 0, + tasksCompleted: 0, + tasksInProgress: 0, + tasksInReview: 0, + }); + expect(result.agents).toEqual([]); + }); + + it("filters completed tasks by range while preserving current in-progress counts", () => { + insertAgent(db, "agent-a", "Alpha"); + insertTask(db, { + id: "done-before", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-02-28T23:59:59.999Z", + }); + insertTask(db, { + id: "done-in-range", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-03-01T00:00:00.000Z", + }); + insertTask(db, { id: "active", agentId: "agent-a", column: "in-progress" }); + + const result = aggregateTeamAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + }); + + expect(result.agents[0].tasksCompleted).toBe(1); + expect(result.agents[0].tasksInProgress).toBe(1); + }); + + it("keeps a safe row for a task whose agent row was deleted", () => { + insertTask(db, { + id: "orphan", + agentId: "deleted-agent", + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z", + modifiedFiles: ["src/orphan.ts"], + updatedAt: "2026-03-02T00:00:00.000Z", + }); + + const result = aggregateTeamAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + }); + + expect(result.agents).toHaveLength(1); + expect(result.agents[0]).toMatchObject({ + agentId: "deleted-agent", + agentName: null, + role: null, + state: null, + filesChanged: 1, + }); + expect(result.agents[0].tokens.totalTokens).toBe(15); + }); + + it("marks unpriced models unavailable instead of treating them as zero-cost", () => { + insertAgent(db, "agent-a", "Alpha"); + insertTask(db, { + id: "unknown-model", + agentId: "agent-a", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z", + modelProvider: "unknown-provider", + modelId: "unknown-model", + }); + + const result = aggregateTeamAnalytics(db, {}); + + expect(result.agents[0].cost).toEqual({ usd: null, unavailable: true, stale: false }); + expect(result.totals.cost).toEqual({ usd: null, unavailable: true, stale: false }); + }); + + it("uses inclusive upper and lower bounds for tokens, completions, and files", () => { + insertAgent(db, "agent-a", "Alpha"); + insertTask(db, { + id: "from-boundary", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-03-01T00:00:00.000Z", + tokenUsageLastUsedAt: "2026-03-01T00:00:00.000Z", + inputTokens: 10, + totalTokens: 10, + modifiedFiles: ["from.ts"], + updatedAt: "2026-03-01T00:00:00.000Z", + }); + insertTask(db, { + id: "to-boundary", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-03-31T00:00:00.000Z", + tokenUsageLastUsedAt: "2026-03-31T00:00:00.000Z", + inputTokens: 20, + totalTokens: 20, + modifiedFiles: ["to.ts"], + updatedAt: "2026-03-31T00:00:00.000Z", + }); + insertTask(db, { + id: "after-boundary", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-03-31T00:00:00.001Z", + tokenUsageLastUsedAt: "2026-03-31T00:00:00.001Z", + inputTokens: 30, + totalTokens: 30, + modifiedFiles: ["after.ts"], + updatedAt: "2026-03-31T00:00:00.001Z", + }); + + const result = aggregateTeamAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + }); + + expect(result.agents[0].tokens.totalTokens).toBe(30); + expect(result.agents[0].tasksCompleted).toBe(2); + expect(result.agents[0].filesChanged).toBe(2); + }); + + it("tolerates invalid modifiedFiles JSON", () => { + insertAgent(db, "agent-a", "Alpha"); + insertTask(db, { + id: "bad-files", + agentId: "agent-a", + modifiedFiles: "not-json", + updatedAt: "2026-03-02T00:00:00.000Z", + }); + + const result = aggregateTeamAnalytics(db, {}); + + expect(result.agents[0].filesChanged).toBe(0); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index efc8e00949..65e865d151 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -576,6 +576,13 @@ export type { LanguageCount, LocSummary, } from "./productivity-analytics.js"; +export { aggregateTeamAnalytics } from "./team-analytics.js"; +export type { + TeamAnalytics, + TeamAnalyticsQuery, + TeamAgentSummary, + TeamMetricTotals, +} from "./team-analytics.js"; export { aggregateGithubIssueAnalytics } from "./github-issue-analytics.js"; export type { GithubIssueAnalytics, diff --git a/packages/core/src/team-analytics.ts b/packages/core/src/team-analytics.ts new file mode 100644 index 0000000000..ca49585568 --- /dev/null +++ b/packages/core/src/team-analytics.ts @@ -0,0 +1,315 @@ +import type { Database } from "./db.js"; +import { costFor, type CostResult } from "./model-pricing.js"; +import type { TokenTotals } from "./token-analytics.js"; + +export interface TeamAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; + /** Epoch ms "now" used only for pricing-staleness. */ + now?: number; +} + +export interface TeamMetricTotals { + tokens: TokenTotals; + cost: CostResult; + filesChanged: number; + tasksCompleted: number; + tasksInProgress: number; + tasksInReview: number; +} + +export interface TeamAgentSummary extends TeamMetricTotals { + agentId: string; + agentName: string | null; + role: string | null; + state: string | null; +} + +export interface TeamAnalytics { + from: string | null; + to: string | null; + totals: TeamMetricTotals; + agents: TeamAgentSummary[]; +} + +interface AgentRow { + id: string; + name: string | null; + role: string | null; + state: string | null; +} + +interface TaskTokenRow { + agentId: string; + inputTokens: number | null; + outputTokens: number | null; + cachedTokens: number | null; + cacheWriteTokens: number | null; + totalTokens: number | null; + modelProvider: string | null; + modelId: string | null; +} + +interface CountByAgentRow { + agentId: string; + count: number; +} + +interface ModifiedFilesRow { + agentId: string; + modifiedFiles: string | null; +} + +function emptyTokenTotals(): TokenTotals { + return { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + nTasks: 0, + }; +} + +interface CostAccumulator { + usd: number; + anyPriced: boolean; + anyUnavailable: boolean; + anyStale: boolean; +} + +function emptyCostAccumulator(): CostAccumulator { + return { usd: 0, anyPriced: false, anyUnavailable: false, anyStale: false }; +} + +function finalizeCost(acc: CostAccumulator): CostResult { + return { + usd: acc.anyPriced ? acc.usd : null, + unavailable: acc.anyUnavailable, + stale: acc.anyStale, + }; +} + +function addTokenRow(totals: TokenTotals, row: TaskTokenRow): void { + totals.inputTokens += row.inputTokens ?? 0; + totals.outputTokens += row.outputTokens ?? 0; + totals.cachedTokens += row.cachedTokens ?? 0; + totals.cacheWriteTokens += row.cacheWriteTokens ?? 0; + totals.totalTokens += + row.totalTokens ?? + (row.inputTokens ?? 0) + + (row.outputTokens ?? 0) + + (row.cachedTokens ?? 0) + + (row.cacheWriteTokens ?? 0); + totals.nTasks += 1; +} + +function addRowCost(acc: CostAccumulator, row: TaskTokenRow, now?: number): void { + const result = costFor( + { + inputTokens: row.inputTokens ?? 0, + outputTokens: row.outputTokens ?? 0, + cachedTokens: row.cachedTokens ?? 0, + cacheWriteTokens: row.cacheWriteTokens ?? 0, + }, + { provider: row.modelProvider, model: row.modelId }, + now, + ); + if (result.stale) acc.anyStale = true; + if (result.unavailable || result.usd === null) { + acc.anyUnavailable = true; + } else { + acc.usd += result.usd; + acc.anyPriced = true; + } +} + +function emptyMetricTotals(): TeamMetricTotals { + return { + tokens: emptyTokenTotals(), + cost: { usd: null, unavailable: false, stale: false }, + filesChanged: 0, + tasksCompleted: 0, + tasksInProgress: 0, + tasksInReview: 0, + }; +} + +function countModifiedFiles(value: string | null): number { + if (!value) return 0; + let files: unknown; + try { + files = JSON.parse(value); + } catch { + return 0; + } + if (!Array.isArray(files)) return 0; + let count = 0; + for (const file of files) { + if (typeof file === "string" && file.length > 0) count += 1; + } + return count; +} + +function addRangeClauses(column: string, clauses: string[], params: string[], query: TeamAnalyticsQuery): void { + if (query.from !== undefined) { + clauses.push(`${column} >= ?`); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push(`${column} <= ?`); + params.push(query.to); + } +} + +function makeSummary(agentId: string, agent?: AgentRow): TeamAgentSummary { + return { + agentId, + agentName: agent?.name ?? null, + role: agent?.role ?? null, + state: agent?.state ?? null, + ...emptyMetricTotals(), + }; +} + +/** + * Aggregate store-derived per-agent Command Center metrics over a date range. + * + * FNXC:CommandCenter 2026-06-18-16:57: + * Team analytics derives per-agent tokens/cost, files changed, and tasks completed from the tasks+agents tables only; no new schema, no GitHub-issue data (that is FN-6653). Keep the aggregator pure/read-only and project-scoped by accepting the already-scoped Database handle from the HTTP layer. + */ +export function aggregateTeamAnalytics( + db: Database, + query: TeamAnalyticsQuery = {}, +): TeamAnalytics { + const summaries = new Map(); + const costAccumulators = new Map(); + const totalTokens = emptyTokenTotals(); + const totalCost = emptyCostAccumulator(); + + const agents = db + .prepare(`SELECT id, name, role, state FROM agents ORDER BY id`) + .all() as AgentRow[]; + for (const agent of agents) { + summaries.set(agent.id, makeSummary(agent.id, agent)); + costAccumulators.set(agent.id, emptyCostAccumulator()); + } + + const ensureSummary = (agentId: string): TeamAgentSummary => { + const existing = summaries.get(agentId); + if (existing) return existing; + const created = makeSummary(agentId); + summaries.set(agentId, created); + costAccumulators.set(agentId, emptyCostAccumulator()); + return created; + }; + + const tokenClauses = ["assignedAgentId IS NOT NULL", "tokenUsageLastUsedAt IS NOT NULL"]; + const tokenParams: string[] = []; + addRangeClauses("tokenUsageLastUsedAt", tokenClauses, tokenParams, query); + const tokenRows = db + .prepare( + `SELECT + assignedAgentId AS agentId, + tokenUsageInputTokens AS inputTokens, + tokenUsageOutputTokens AS outputTokens, + tokenUsageCachedTokens AS cachedTokens, + tokenUsageCacheWriteTokens AS cacheWriteTokens, + tokenUsageTotalTokens AS totalTokens, + modelProvider, + modelId + FROM tasks + WHERE ${tokenClauses.join(" AND ")}`, + ) + .all(...tokenParams) as TaskTokenRow[]; + + for (const row of tokenRows) { + const summary = ensureSummary(row.agentId); + const agentCost = costAccumulators.get(row.agentId) ?? emptyCostAccumulator(); + costAccumulators.set(row.agentId, agentCost); + addTokenRow(summary.tokens, row); + addTokenRow(totalTokens, row); + addRowCost(agentCost, row, query.now); + addRowCost(totalCost, row, query.now); + } + + const completedClauses = ["assignedAgentId IS NOT NULL", `"column" = 'done'`, "columnMovedAt IS NOT NULL"]; + const completedParams: string[] = []; + addRangeClauses("columnMovedAt", completedClauses, completedParams, query); + const completedRows = db + .prepare( + `SELECT assignedAgentId AS agentId, COUNT(*) AS count + FROM tasks + WHERE ${completedClauses.join(" AND ")} + GROUP BY assignedAgentId`, + ) + .all(...completedParams) as CountByAgentRow[]; + for (const row of completedRows) { + ensureSummary(row.agentId).tasksCompleted = row.count; + } + + const currentRows = db + .prepare( + `SELECT assignedAgentId AS agentId, "column" AS columnName, COUNT(*) AS count + FROM tasks + WHERE assignedAgentId IS NOT NULL AND "column" IN ('in-progress', 'in-review') + GROUP BY assignedAgentId, "column"`, + ) + .all() as Array; + for (const row of currentRows) { + const summary = ensureSummary(row.agentId); + if (row.columnName === "in-progress") summary.tasksInProgress = row.count; + if (row.columnName === "in-review") summary.tasksInReview = row.count; + } + + const filesClauses = ["assignedAgentId IS NOT NULL", "modifiedFiles IS NOT NULL", "modifiedFiles NOT IN ('', '[]')"]; + const filesParams: string[] = []; + addRangeClauses("updatedAt", filesClauses, filesParams, query); + const fileRows = db + .prepare( + `SELECT assignedAgentId AS agentId, modifiedFiles + FROM tasks + WHERE ${filesClauses.join(" AND ")}`, + ) + .all(...filesParams) as ModifiedFilesRow[]; + for (const row of fileRows) { + ensureSummary(row.agentId).filesChanged += countModifiedFiles(row.modifiedFiles); + } + + for (const [agentId, summary] of summaries) { + summary.cost = finalizeCost(costAccumulators.get(agentId) ?? emptyCostAccumulator()); + } + + let filesChanged = 0; + let tasksCompleted = 0; + let tasksInProgress = 0; + let tasksInReview = 0; + for (const summary of summaries.values()) { + filesChanged += summary.filesChanged; + tasksCompleted += summary.tasksCompleted; + tasksInProgress += summary.tasksInProgress; + tasksInReview += summary.tasksInReview; + } + + const sortedAgents = [...summaries.values()].sort((a, b) => { + const tokenCmp = b.tokens.totalTokens - a.tokens.totalTokens; + if (tokenCmp !== 0) return tokenCmp; + return a.agentId.localeCompare(b.agentId); + }); + + return { + from: query.from ?? null, + to: query.to ?? null, + totals: { + tokens: totalTokens, + cost: finalizeCost(totalCost), + filesChanged, + tasksCompleted, + tasksInProgress, + tasksInReview, + }, + agents: sortedAgents, + }; +} diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 0016651bf3..149bee7aec 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -8,6 +8,7 @@ import { TokensArea } from "./areas/TokensArea"; import { ToolsArea } from "./areas/ToolsArea"; import { ActivityArea } from "./areas/ActivityArea"; import { ProductivityArea } from "./areas/ProductivityArea"; +import { TeamArea } from "./areas/TeamArea"; import { EcosystemArea } from "./areas/EcosystemArea"; import { GithubArea } from "./areas/GithubArea"; import { SignalsArea } from "./areas/SignalsArea"; @@ -26,6 +27,7 @@ type SubViewId = | "tools" | "activity" | "productivity" + | "team" | "ecosystem" | "github" | "signals" @@ -36,6 +38,10 @@ interface SubView { label: string; } +/* +FNXC:CommandCenter 2026-06-18-16:57: +Team tab shows each agent's tokens/cost/files-changed/tasks-completed with live status and bar charts, reusing existing analytics primitives; GitHub-issue per-agent stats are FN-6653, not here. +*/ function useSubViews(): SubView[] { const { t } = useTranslation("app"); return [ @@ -44,6 +50,7 @@ function useSubViews(): SubView[] { { id: "tools", label: t("commandCenter.tabs.tools", "Tools") }, { id: "activity", label: t("commandCenter.tabs.activity", "Activity") }, { id: "productivity", label: t("commandCenter.tabs.productivity", "Productivity") }, + { id: "team", label: t("commandCenter.tabs.team", "Team") }, { id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") }, { id: "github", label: t("commandCenter.tabs.github", "GitHub") }, { id: "signals", label: t("commandCenter.tabs.signals", "Signals") }, @@ -426,6 +433,8 @@ export function CommandCenter() { return ; case "productivity": return ; + case "team": + return ; case "ecosystem": return ; case "github": diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx index 1699ffc4b3..b46e863289 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx @@ -89,6 +89,22 @@ function emptyGithubFixture() { return { filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] }; } +function emptyTeamFixture() { + return { + from: null, + to: null, + totals: { + tokens: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 }, + cost: { usd: null, unavailable: false, stale: false }, + filesChanged: 0, + tasksCompleted: 0, + tasksInProgress: 0, + tasksInReview: 0, + }, + agents: [], + }; +} + function populatedActivityFixture() { return { ...emptyActivityFixture(), @@ -118,6 +134,7 @@ function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) { if (path.startsWith("/command-center/tools")) return Promise.resolve(populated ? populatedToolsFixture() : emptyToolsFixture()); if (path.startsWith("/command-center/activity")) return Promise.resolve(populated ? populatedActivityFixture() : emptyActivityFixture()); if (path.startsWith("/command-center/github")) return Promise.resolve(emptyGithubFixture()); + if (path.startsWith("/command-center/team")) return Promise.resolve(emptyTeamFixture()); if (path.startsWith("/command-center/signals")) return Promise.resolve({ totalSignals: 0, open: 0, resolved: 0, mttr: { value: null, unavailable: true }, bySource: [], bySeverity: [] }); if (path === "/command-center/live") { return Promise.resolve({ @@ -198,6 +215,11 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => { expect(tokensPanel).toBe(screen.getByRole("tabpanel")); assertScrollOwnerContract(tokensPanel); + fireEvent.click(screen.getByTestId("command-center-tab-team")); + const teamPanel = screen.getByTestId("command-center-panel-team"); + expect(teamPanel).toBe(screen.getByRole("tabpanel")); + assertScrollOwnerContract(teamPanel); + fireEvent.click(screen.getByTestId("command-center-tab-github")); const githubPanel = screen.getByTestId("command-center-panel-github"); expect(githubPanel).toBe(screen.getByRole("tabpanel")); 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 e43a40ff74..096971a721 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -118,6 +118,47 @@ function githubFixture(filed = 0, fixed = 0) { }; } +function teamFixture(agents: unknown[] = [ + { + agentId: "agent-alpha", + agentName: "Alpha Agent", + role: "executor", + state: "running", + tokens: { inputTokens: 900, outputTokens: 450, cachedTokens: 150, cacheWriteTokens: 0, totalTokens: 1500, nTasks: 2 }, + cost: { usd: 4.25, unavailable: false, stale: false }, + filesChanged: 7, + tasksCompleted: 3, + tasksInProgress: 1, + tasksInReview: 0, + }, + { + agentId: "agent-beta", + agentName: "Beta Agent", + role: "reviewer", + state: "idle", + tokens: { inputTokens: 100, outputTokens: 50, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 150, nTasks: 1 }, + cost: { usd: null, unavailable: true, stale: false }, + filesChanged: 2, + tasksCompleted: 1, + tasksInProgress: 0, + tasksInReview: 1, + }, +]) { + return { + from: "2026-06-08", + to: null, + totals: { + tokens: { inputTokens: 1000, outputTokens: 500, cachedTokens: 150, cacheWriteTokens: 0, totalTokens: 1650, nTasks: 3 }, + cost: { usd: 4.25, unavailable: true, stale: false }, + filesChanged: 9, + tasksCompleted: 4, + tasksInProgress: 1, + tasksInReview: 1, + }, + agents, + }; +} + function signalsFixture(open = 2) { return { totalSignals: open, @@ -146,6 +187,7 @@ function mockOverviewApi({ tools = toolsFixture(), activity = activityFixture(), github = githubFixture(), + team = teamFixture([]), signals = signalsFixture(), live = liveFixture(), }: { @@ -153,6 +195,7 @@ function mockOverviewApi({ tools?: unknown; activity?: unknown; github?: unknown; + team?: unknown; signals?: unknown; live?: unknown; } = {}) { @@ -161,6 +204,7 @@ function mockOverviewApi({ if (path.startsWith("/command-center/tools")) return Promise.resolve(tools); if (path.startsWith("/command-center/activity")) return Promise.resolve(activity); if (path.startsWith("/command-center/github")) return Promise.resolve(github); + if (path.startsWith("/command-center/team")) return team instanceof Error ? Promise.reject(team) : Promise.resolve(team); if (path.startsWith("/command-center/signals")) { return signals instanceof Error ? Promise.reject(signals) : Promise.resolve(signals); } @@ -476,8 +520,8 @@ describe("CommandCenter shell", () => { render(); const tablist = screen.getByRole("tablist"); const tabs = within(tablist).getAllByRole("tab"); - // Overview, Tokens, Tools, Activity, Productivity, Ecosystem, GitHub, Signals, Mission Control. - expect(tabs.length).toBe(9); + // Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, Mission Control. + expect(tabs.length).toBe(10); // roving tabindex: exactly one tab is focusable. const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0"); expect(focusable.length).toBe(1); @@ -505,6 +549,106 @@ describe("CommandCenter shell", () => { expect(screen.getByTestId("cc-github-fixed").textContent).toContain("2"); }); + it("renders the Team tab with sortable per-agent stats and charts", async () => { + mockOverviewApi({ team: teamFixture() }); + render(); + + fireEvent.click(screen.getByTestId("command-center-tab-team")); + + await screen.findByTestId("cc-area-team"); + expect(screen.getByTestId("command-center-tab-team").getAttribute("aria-selected")).toBe("true"); + const alphaRow = screen.getByTestId("cc-team-row-agent-alpha"); + expect(alphaRow).toBeTruthy(); + expect(within(alphaRow).getByText("Alpha Agent")).toBeTruthy(); + expect(within(alphaRow).getByText("executor")).toBeTruthy(); + expect(screen.getByTestId("cc-team-table").textContent).toContain("1,500"); + expect(screen.getByTestId("cc-team-table").textContent).toContain("3"); + expect(screen.getByTestId("cc-team-tokens-chart")).toBeTruthy(); + expect(screen.getByRole("list", { name: "Tokens by agent" })).toBeTruthy(); + expect(screen.getByTestId("cc-team-completed-chart")).toBeTruthy(); + expect(screen.getByRole("list", { name: "Tasks done by agent" })).toBeTruthy(); + + fireEvent.click(screen.getByTestId("cc-team-sort-agent")); + const rows = within(screen.getByTestId("cc-team-table")).getAllByRole("row").slice(1); + expect(rows[0].getAttribute("data-testid")).toBe("cc-team-row-agent-alpha"); + }); + + it("renders the Team empty state for zero agents without an empty chart shell", async () => { + mockOverviewApi({ team: teamFixture([]) }); + render(); + + fireEvent.click(screen.getByTestId("command-center-tab-team")); + + await screen.findByTestId("cc-area-team-empty"); + expect(screen.queryByTestId("cc-area-team")).toBeNull(); + expect(screen.queryByTestId("cc-team-tokens-chart")).toBeNull(); + }); + + it("renders Team loading and error states through AreaShell", async () => { + let resolveTeam: (value: unknown) => void = () => undefined; + mockOverviewApi({ team: new Promise((resolve) => { resolveTeam = resolve; }) }); + const { unmount } = render(); + + fireEvent.click(screen.getByTestId("command-center-tab-team")); + expect(screen.getByTestId("cc-area-team-loading")).toBeTruthy(); + await act(async () => { + resolveTeam(teamFixture([])); + }); + await screen.findByTestId("cc-area-team-empty"); + unmount(); + + mockOverviewApi({ team: new Error("team failed") }); + render(); + fireEvent.click(screen.getByTestId("command-center-tab-team")); + await screen.findByTestId("cc-area-team-error"); + expect(screen.getByTestId("cc-area-team-error").textContent).toContain("team failed"); + }); + + it("keeps Team charts safe for zero-valued agents", async () => { + mockOverviewApi({ + team: teamFixture([ + { + agentId: "agent-zero", + agentName: "Zero Agent", + role: "executor", + state: "idle", + tokens: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 }, + cost: { usd: null, unavailable: false, stale: false }, + filesChanged: 0, + tasksCompleted: 0, + tasksInProgress: 0, + tasksInReview: 0, + }, + ]), + }); + render(); + + fireEvent.click(screen.getByTestId("command-center-tab-team")); + + await screen.findByTestId("cc-area-team"); + expect(screen.getByTestId("cc-team-tokens-chart").textContent).toContain("No non-zero values"); + expect(screen.getByTestId("cc-team-completed-chart").textContent).toContain("No non-zero values"); + expect(screen.getByTestId("cc-area-team").textContent).not.toContain("NaN"); + }); + + it("keeps existing Command Center tab test ids after adding Team", () => { + render(); + for (const id of [ + "overview", + "tokens", + "tools", + "activity", + "productivity", + "ecosystem", + "github", + "signals", + "mission-control", + "team", + ]) { + expect(screen.getByTestId(`command-center-tab-${id}`)).toBeTruthy(); + } + }); + it("supports arrow-key navigation between tabs (roving tabindex)", () => { render(); const overviewTab = screen.getByTestId("command-center-tab-overview"); diff --git a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx new file mode 100644 index 0000000000..1ef1153aa6 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx @@ -0,0 +1,265 @@ +/* +FNXC:CommandCenter 2026-06-18-16:57: +Team tab shows each agent's tokens/cost/files-changed/tasks-completed with live status and bar charts, reusing existing analytics primitives; GitHub-issue per-agent stats are FN-6653, not here. +*/ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { CostResult, TeamAgentSummary, TeamAnalytics } from "@fusion/core"; +import type { DateRange } from "../DateRangePicker"; +import { Bar, type BarDatum } from "../charts/Bar"; +import { Sparkline } from "../charts/Sparkline"; +import { AreaShell } from "./AreaShell"; +import { useAnalyticsArea } from "./useAnalyticsArea"; +import { formatCost, formatCount } from "./areaShared"; + +const TEAM_LIVE_REFRESH_MS = 15_000; +type SortKey = "agent" | "tokens" | "cost" | "filesChanged" | "tasksCompleted" | "tasksInProgress"; + +function costSortValue(cost: CostResult): number { + return cost.unavailable || cost.usd === null ? -1 : cost.usd; +} + +function agentLabel(agent: TeamAgentSummary, unknownLabel: string): string { + return agent.agentName ?? agent.agentId ?? unknownLabel; +} + +function stateDotClass(state: string | null): string { + switch (state) { + case "running": + return "status-dot status-dot--connecting"; + case "active": + case "idle": + return "status-dot status-dot--online"; + case "error": + case "failed": + return "status-dot status-dot--error"; + case "starting": + case "pending": + return "status-dot status-dot--pending"; + default: + return "status-dot status-dot--pending"; + } +} + +function sortAgents(agents: TeamAgentSummary[], key: SortKey, dir: 1 | -1, unknownLabel: string): TeamAgentSummary[] { + const sorted = [...agents]; + sorted.sort((a, b) => { + let cmp = 0; + if (key === "agent") { + cmp = agentLabel(a, unknownLabel).localeCompare(agentLabel(b, unknownLabel)); + } else if (key === "tokens") { + cmp = a.tokens.totalTokens - b.tokens.totalTokens; + } else if (key === "cost") { + cmp = costSortValue(a.cost) - costSortValue(b.cost); + } else if (key === "filesChanged") { + cmp = a.filesChanged - b.filesChanged; + } else if (key === "tasksCompleted") { + cmp = a.tasksCompleted - b.tasksCompleted; + } else { + cmp = a.tasksInProgress - b.tasksInProgress; + } + if (cmp === 0) { + cmp = a.agentId.localeCompare(b.agentId); + } + return cmp * dir; + }); + return sorted; +} + +function buildBarData( + agents: TeamAgentSummary[], + valueFor: (agent: TeamAgentSummary) => number, + unknownLabel: string, +): BarDatum[] { + return [...agents] + .sort((a, b) => valueFor(b) - valueFor(a) || a.agentId.localeCompare(b.agentId)) + .slice(0, 12) + .map((agent) => { + const value = valueFor(agent); + return { + label: agentLabel(agent, unknownLabel), + value, + valueLabel: formatCount(value), + }; + }); +} + +/** Render per-agent team analytics from the project-scoped `/command-center/team` endpoint. */ +export function TeamArea({ range }: { range: DateRange }) { + const { t } = useTranslation("app"); + const { data, isLoading, error } = useAnalyticsArea("/command-center/team", range, { + pollMs: TEAM_LIVE_REFRESH_MS, + }); + const agents = useMemo(() => data?.agents ?? [], [data?.agents]); + const unknownAgent = t("commandCenter.team.unknownAgent", "(unknown agent)"); + const unknownRole = t("commandCenter.team.unknownRole", "Unknown role"); + const noChartData = t("commandCenter.team.noChartData", "No non-zero values for this chart yet."); + + const [sortKey, setSortKey] = useState("tokens"); + const [sortDir, setSortDir] = useState<1 | -1>(-1); + const agentIdsSig = useMemo(() => agents.map((agent) => agent.agentId).join(" "), [agents]); + const firstSig = useRef(null); + useEffect(() => { + if (firstSig.current === null) { + firstSig.current = agentIdsSig; + return; + } + if (firstSig.current !== agentIdsSig) { + firstSig.current = agentIdsSig; + setSortKey("tokens"); + setSortDir(-1); + } + }, [agentIdsSig]); + + const sortedAgents = useMemo( + () => sortAgents(agents, sortKey, sortDir, unknownAgent), + [agents, sortDir, sortKey, unknownAgent], + ); + + const tokenBarData = useMemo( + () => buildBarData(agents, (agent) => agent.tokens.totalTokens, unknownAgent), + [agents, unknownAgent], + ); + const completedBarData = useMemo( + () => buildBarData(agents, (agent) => agent.tasksCompleted, unknownAgent), + [agents, unknownAgent], + ); + const hasTokenChart = tokenBarData.some((datum) => datum.value > 0); + const hasCompletedChart = completedBarData.some((datum) => datum.value > 0); + const sparklineValues = useMemo( + () => agents.flatMap((agent) => [agent.tokens.totalTokens, agent.filesChanged, agent.tasksCompleted]), + [agents], + ); + + function toggleSort(key: SortKey) { + if (key === sortKey) { + setSortDir((dir) => (dir === 1 ? -1 : 1)); + } else { + setSortKey(key); + setSortDir(key === "agent" ? 1 : -1); + } + } + + function caret(key: SortKey) { + if (key !== sortKey) return null; + return {sortDir === 1 ? "▲" : "▼"}; + } + + return ( + +
+

{t("commandCenter.team.totalsTitle", "Team totals")}

+
+
+
{t("commandCenter.team.totalTokens", "Total tokens")}
+
{formatCount(data?.totals.tokens.totalTokens ?? 0)}
+
+
+
{t("commandCenter.team.totalCost", "Estimated cost")}
+
+ {data ? formatCost(data.totals.cost.usd, data.totals.cost.unavailable) : "—"} +
+
+
+
{t("commandCenter.team.filesChanged", "Files changed")}
+
{formatCount(data?.totals.filesChanged ?? 0)}
+
+
+
{t("commandCenter.team.tasksCompleted", "Tasks done")}
+
{formatCount(data?.totals.tasksCompleted ?? 0)}
+
+
+
+ +
+
+

{t("commandCenter.team.tokensByAgent", "Tokens by agent")}

+ {hasTokenChart ? ( + + ) : ( +

{noChartData}

+ )} +
+
+

{t("commandCenter.team.completedByAgent", "Tasks done by agent")}

+ {hasCompletedChart ? ( + + ) : ( +

{noChartData}

+ )} +
+
+

{t("commandCenter.team.spread", "Team spread")}

+ +
+
+ +
+

{t("commandCenter.team.tableTitle", "Per-agent breakdown")}

+
+ + + + + + + + + + + + + {sortedAgents.map((agent) => ( + + + + + + + + + ))} + +
toggleSort("agent")} data-testid="cc-team-sort-agent"> + {t("commandCenter.team.agent", "Agent")} + {caret("agent")} + toggleSort("tokens")} data-testid="cc-team-sort-tokens"> + {t("commandCenter.team.tokens", "Tokens")} + {caret("tokens")} + toggleSort("cost")} data-testid="cc-team-sort-cost"> + {t("commandCenter.team.cost", "Cost")} + {caret("cost")} + toggleSort("filesChanged")} data-testid="cc-team-sort-files"> + {t("commandCenter.team.files", "Files changed")} + {caret("filesChanged")} + toggleSort("tasksCompleted")} data-testid="cc-team-sort-completed"> + {t("commandCenter.team.done", "Tasks done")} + {caret("tasksCompleted")} + toggleSort("tasksInProgress")} data-testid="cc-team-sort-progress"> + {t("commandCenter.team.inProgress", "In progress")} + {caret("tasksInProgress")} +
+ + + + {agentLabel(agent, unknownAgent)} + {agent.role ?? unknownRole} + + + {formatCount(agent.tokens.totalTokens)}{formatCost(agent.cost.usd, agent.cost.unavailable)}{formatCount(agent.filesChanged)}{formatCount(agent.tasksCompleted)}{formatCount(agent.tasksInProgress)}
+
+
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/areas/areas.css b/packages/dashboard/app/components/command-center/areas/areas.css index 94f80608af..f4039b4f91 100644 --- a/packages/dashboard/app/components/command-center/areas/areas.css +++ b/packages/dashboard/app/components/command-center/areas/areas.css @@ -188,3 +188,92 @@ The Tokens area needs a real hour/day/week control and live token-number motion. font-size: var(--font-size-xs, 0.75rem); color: var(--text-muted); } + +/* +FNXC:CommandCenterStyling 2026-06-18-16:57: +The Team view must use dashboard design tokens only, preserve .cc-tabpanel as the scroll owner on mobile, reuse the shared .status-dot convention for live state, and keep any decorative motion duration-token based with reduced-motion disabled. +*/ +.cc-team-chart-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.cc-team-chart-panel { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; + padding: var(--space-3); + border: var(--border-width) solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--surface-1); + animation: cc-team-panel-reveal var(--duration-fast) ease-out both; +} + +.cc-team-spark-panel { + grid-column: 1 / -1; +} + +.cc-muted-hint, +.cc-team-agent-role { + color: var(--text-muted); +} + +.cc-muted-hint { + margin: 0; + font-size: var(--font-size-sm); +} + +.cc-team-agent-cell { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +.cc-team-agent-name, +.cc-team-agent-role { + display: block; +} + +.cc-team-agent-name { + color: var(--text-primary); + font-weight: 600; +} + +.cc-team-agent-role { + font-size: var(--font-size-xs); + text-transform: capitalize; +} + +@keyframes cc-team-panel-reveal { + from { + opacity: 0; + transform: translateY(var(--space-1)); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .cc-team-chart-panel { + animation: none; + } +} + +@media (max-width: 768px) { + .cc-team-chart-grid { + grid-template-columns: 1fr; + } + + .cc-team-spark-panel { + grid-column: auto; + } + + .cc-team-chart-panel { + padding: var(--space-2); + } +} diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts index f62272a9b2..6acc583eb5 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts @@ -64,6 +64,7 @@ const ENDPOINTS = [ "/api/command-center/tools", "/api/command-center/activity", "/api/command-center/productivity", + "/api/command-center/team", "/api/command-center/github", "/api/command-center/live", ]; 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 de33cffde9..bad7185fa7 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -60,6 +60,29 @@ function seedAgentRun(db: Database, opts: { id: string; agentId: string; started ).run(opts.id, opts.agentId, opts.startedAt, opts.status); } +function seedTeamMetrics(db: Database, opts: { agentId: string; name: string; tokens: number; taskId: string }): void { + db.prepare( + `INSERT OR IGNORE INTO agents (id, name, role, state, createdAt, updatedAt) + VALUES (?, ?, 'executor', 'running', '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')`, + ).run(opts.agentId, opts.name); + db.prepare( + `INSERT INTO tasks + (id, description, "column", assignedAgentId, modelProvider, modelId, + tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageTotalTokens, + tokenUsageLastUsedAt, modifiedFiles, columnMovedAt, createdAt, updatedAt) + VALUES (?, 'desc', 'done', ?, 'anthropic', 'claude-sonnet-4-5', ?, ?, ?, + '2026-03-01T00:00:00.000Z', ?, '2026-03-02T00:00:00.000Z', + '2026-03-01T00:00:00.000Z', '2026-03-02T00:00:00.000Z')`, + ).run( + opts.taskId, + opts.agentId, + opts.tokens, + opts.tokens, + opts.tokens * 2, + JSON.stringify([`src/${opts.taskId}.ts`]), + ); +} + 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( @@ -211,6 +234,35 @@ describe("register-command-center-routes", () => { expect(res.body as Record).not.toHaveProperty("series"); }); + it("returns the team aggregator shape for a fixture DB", async () => { + seedTeamMetrics(dbA, { agentId: "agent-route-a", name: "Route Alpha", tokens: 321, taskId: "FN-A-team" }); + + const res = await request( + app, + "GET", + "/api/command-center/team?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&projectId=proj-a", + ); + + expect(res.status).toBe(200); + const body = res.body as { + totals: { tokens: { totalTokens: number }; filesChanged: number; tasksCompleted: number }; + agents: Array<{ agentId: string; agentName: string; tokens: { totalTokens: number }; filesChanged: number }>; + }; + expect(body).toHaveProperty("totals"); + expect(body).toHaveProperty("agents"); + expect(body.totals.tokens.totalTokens).toBe(642); + expect(body.totals.filesChanged).toBe(1); + expect(body.totals.tasksCompleted).toBe(1); + expect(body.agents).toContainEqual( + expect.objectContaining({ + agentId: "agent-route-a", + agentName: "Route Alpha", + tokens: expect.objectContaining({ totalTokens: 642 }), + filesChanged: 1, + }), + ); + }); + 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" }); @@ -285,6 +337,22 @@ describe("register-command-center-routes", () => { expect((b.body as { totals: { totalTokens: number } }).totals.totalTokens).toBe(1998); }); + it("team endpoint stays project scoped", async () => { + seedTeamMetrics(dbA, { agentId: "agent-a-only", name: "Project A Agent", tokens: 111, taskId: "FN-A-team" }); + seedTeamMetrics(dbB, { agentId: "agent-b-only", name: "Project B Agent", tokens: 999, taskId: "FN-B-team" }); + const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z"; + + const a = await request(app, "GET", `/api/command-center/team?${range}&projectId=proj-a`); + const b = await request(app, "GET", `/api/command-center/team?${range}&projectId=proj-b`); + + const aAgents = (a.body as { agents: Array<{ agentId: string; agentName: string; tokens: { totalTokens: number } }> }).agents; + const bAgents = (b.body as { agents: Array<{ agentId: string; agentName: string; tokens: { totalTokens: number } }> }).agents; + expect(aAgents.some((agent) => agent.agentId === "agent-a-only" && agent.tokens.totalTokens === 222)).toBe(true); + expect(aAgents.some((agent) => agent.agentId === "agent-b-only" || agent.agentName === "Project B Agent")).toBe(false); + expect(bAgents.some((agent) => agent.agentId === "agent-b-only" && agent.tokens.totalTokens === 1998)).toBe(true); + expect(bAgents.some((agent) => agent.agentId === "agent-a-only" || agent.agentName === "Project A Agent")).toBe(false); + }); + it("github endpoint defaults invalid ranges and stays project scoped", async () => { seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 2, fixed: 1 }); seedGithubIssueMetrics(dbB, { prefix: "FN-B", repo: "acme/beta", filed: 5, fixed: 4 }); @@ -483,6 +551,7 @@ describe("vite /api proxy negative-lookahead (proxy verification)", () => { it("proxies the real command-center endpoints to the backend", () => { expect(PROXY_RE.test("/api/command-center/tokens")).toBe(true); + expect(PROXY_RE.test("/api/command-center/team")).toBe(true); expect(PROXY_RE.test("/api/command-center/live")).toBe(true); expect(PROXY_RE.test("/api/command-center/github")).toBe(true); expect(PROXY_RE.test("/api/command-center/activity?from=x&to=y")).toBe(true); diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index b6d2faafda..5769fd64fd 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -3,6 +3,7 @@ import { aggregateToolAnalytics, aggregateActivityAnalytics, aggregateProductivityAnalytics, + aggregateTeamAnalytics, aggregateGithubIssueAnalytics, composeLiveSnapshot, type TokenGroupBy, @@ -240,6 +241,29 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { } }); + /** + * GET /api/command-center/team + * Per-agent store-derived tokens/cost, files changed, task counts, and live identity. + * + * FNXC:CommandCenter 2026-06-18-16:57: + * The Team endpoint must inherit Command Center auth and resolve getScopedStore(req) before aggregation so project-A callers cannot read project-B agent rows or task metrics. It intentionally omits GitHub issue stats; FN-6653 owns that overlay. + */ + router.get("/command-center/team", async (req, res) => { + try { + const store = await getScopedStore(req); + const range = resolveRange(req.query); + const result = aggregateTeamAnalytics(store.getDatabase(), { + from: range.from, + to: range.to, + now: Date.now(), + }); + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to aggregate team analytics"); + } + }); + /** * GET /api/command-center/github * GitHub issues filed by Fusion and imported GitHub issues fixed by Fusion.