FN-6655: add Command Center team analytics

Adds a live Team area to Command Center with per-agent analytics and supporting API data.\n\n- Add read-only core aggregation for agent tokens, cost, changed files, and task status counts.\n- Expose the team metrics through the Command Center API and render a responsive Team dashboard with charts and tables.\n- Cover aggregation, route, desktop, and mobile Command Center behavior with tests.\n- Document the Team area and add a patch changeset for the published CLI bundle.\n\nFiles changed:\n .changeset/fn-6655-command-center-team-view.md     |   5 +\n docs/dashboard-guide.md                            |   2 +\n packages/core/src/__tests__/team-analytics.test.ts | 315 +++++++++++++++++++++\n packages/core/src/index.ts                         |   7 +\n packages/core/src/team-analytics.ts                | 315 +++++++++++++++++++++\n .../components/command-center/CommandCenter.tsx    |   9 +\n .../__tests__/CommandCenter.mobile-scroll.test.tsx |  22 ++\n .../__tests__/CommandCenter.test.tsx               | 148 +++++++++-\n .../components/command-center/areas/TeamArea.tsx   | 265 +++++++++++++++++\n .../app/components/command-center/areas/areas.css  |  89 ++++++\n .../register-command-center-routes.auth.test.ts    |   1 +\n .../register-command-center-routes.test.ts         |  69 +++++\n .../src/routes/register-command-center-routes.ts   |  24 ++\n 13 files changed, 1269 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-6655

Fusion-Task-Lineage: 366170f4-a250-49ce-8ca6-aea865ab0b84
This commit is contained in:
gsxdsm
2026-06-18 17:52:08 -07:00
parent 317b08b227
commit 36f1feef31
13 changed files with 1269 additions and 2 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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<string, TeamAgentSummary>();
const costAccumulators = new Map<string, CostAccumulator>();
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<CountByAgentRow & { columnName: string }>;
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,
};
}

View File

@@ -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 <ActivityArea range={range} />;
case "productivity":
return <ProductivityArea range={range} />;
case "team":
return <TeamArea range={range} />;
case "ecosystem":
return <EcosystemArea range={range} />;
case "github":

View File

@@ -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"));

View File

@@ -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(<CommandCenter />);
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(<CommandCenter />);
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(<CommandCenter />);
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(<CommandCenter />);
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(<CommandCenter />);
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(<CommandCenter />);
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(<CommandCenter />);
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(<CommandCenter />);
const overviewTab = screen.getByTestId("command-center-tab-overview");

View File

@@ -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<TeamAnalytics>("/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<SortKey>("tokens");
const [sortDir, setSortDir] = useState<1 | -1>(-1);
const agentIdsSig = useMemo(() => agents.map((agent) => agent.agentId).join(" "), [agents]);
const firstSig = useRef<string | null>(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 <span className="cc-sort-caret">{sortDir === 1 ? "▲" : "▼"}</span>;
}
return (
<AreaShell
testId="team"
isLoading={isLoading}
error={error}
isEmpty={!data || data.agents.length === 0}
emptyMessage={t("commandCenter.team.empty", "No agents have reported team analytics yet.")}
>
<div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.team.totalsTitle", "Team totals")}</h3>
<div className="cc-stat-grid">
<div className="card cc-stat-card" data-testid="cc-team-total-tokens">
<div className="cc-stat-label">{t("commandCenter.team.totalTokens", "Total tokens")}</div>
<div className="cc-stat-value">{formatCount(data?.totals.tokens.totalTokens ?? 0)}</div>
</div>
<div className="card cc-stat-card" data-testid="cc-team-total-cost">
<div className="cc-stat-label">{t("commandCenter.team.totalCost", "Estimated cost")}</div>
<div className="cc-stat-value">
{data ? formatCost(data.totals.cost.usd, data.totals.cost.unavailable) : "—"}
</div>
</div>
<div className="card cc-stat-card" data-testid="cc-team-total-files">
<div className="cc-stat-label">{t("commandCenter.team.filesChanged", "Files changed")}</div>
<div className="cc-stat-value">{formatCount(data?.totals.filesChanged ?? 0)}</div>
</div>
<div className="card cc-stat-card" data-testid="cc-team-total-completed">
<div className="cc-stat-label">{t("commandCenter.team.tasksCompleted", "Tasks done")}</div>
<div className="cc-stat-value">{formatCount(data?.totals.tasksCompleted ?? 0)}</div>
</div>
</div>
</div>
<div className="cc-area-section cc-team-chart-grid">
<div className="cc-team-chart-panel" data-testid="cc-team-tokens-chart">
<h3 className="cc-area-section-title">{t("commandCenter.team.tokensByAgent", "Tokens by agent")}</h3>
{hasTokenChart ? (
<Bar data={tokenBarData} ariaLabel={t("commandCenter.team.tokensByAgent", "Tokens by agent")} />
) : (
<p className="cc-muted-hint">{noChartData}</p>
)}
</div>
<div className="cc-team-chart-panel" data-testid="cc-team-completed-chart">
<h3 className="cc-area-section-title">{t("commandCenter.team.completedByAgent", "Tasks done by agent")}</h3>
{hasCompletedChart ? (
<Bar data={completedBarData} ariaLabel={t("commandCenter.team.completedByAgent", "Tasks done by agent")} />
) : (
<p className="cc-muted-hint">{noChartData}</p>
)}
</div>
<div className="cc-team-chart-panel cc-team-spark-panel" data-testid="cc-team-spread-chart">
<h3 className="cc-area-section-title">{t("commandCenter.team.spread", "Team spread")}</h3>
<Sparkline values={sparklineValues} ariaLabel={t("commandCenter.team.spread", "Team spread")} />
</div>
</div>
<div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.team.tableTitle", "Per-agent breakdown")}</h3>
<div className="cc-table-wrap">
<table className="cc-table" data-testid="cc-team-table">
<thead>
<tr>
<th className="cc-sortable" onClick={() => toggleSort("agent")} data-testid="cc-team-sort-agent">
{t("commandCenter.team.agent", "Agent")}
{caret("agent")}
</th>
<th className="cc-sortable" onClick={() => toggleSort("tokens")} data-testid="cc-team-sort-tokens">
{t("commandCenter.team.tokens", "Tokens")}
{caret("tokens")}
</th>
<th className="cc-sortable" onClick={() => toggleSort("cost")} data-testid="cc-team-sort-cost">
{t("commandCenter.team.cost", "Cost")}
{caret("cost")}
</th>
<th className="cc-sortable" onClick={() => toggleSort("filesChanged")} data-testid="cc-team-sort-files">
{t("commandCenter.team.files", "Files changed")}
{caret("filesChanged")}
</th>
<th className="cc-sortable" onClick={() => toggleSort("tasksCompleted")} data-testid="cc-team-sort-completed">
{t("commandCenter.team.done", "Tasks done")}
{caret("tasksCompleted")}
</th>
<th className="cc-sortable" onClick={() => toggleSort("tasksInProgress")} data-testid="cc-team-sort-progress">
{t("commandCenter.team.inProgress", "In progress")}
{caret("tasksInProgress")}
</th>
</tr>
</thead>
<tbody>
{sortedAgents.map((agent) => (
<tr key={agent.agentId} data-testid={`cc-team-row-${agent.agentId}`}>
<td>
<span className="cc-team-agent-cell">
<span
className={stateDotClass(agent.state)}
aria-label={t("commandCenter.team.state", "Agent state: {{state}}", {
state: agent.state ?? t("commandCenter.team.unknownState", "unknown"),
})}
/>
<span>
<span className="cc-team-agent-name">{agentLabel(agent, unknownAgent)}</span>
<span className="cc-team-agent-role">{agent.role ?? unknownRole}</span>
</span>
</span>
</td>
<td>{formatCount(agent.tokens.totalTokens)}</td>
<td>{formatCost(agent.cost.usd, agent.cost.unavailable)}</td>
<td>{formatCount(agent.filesChanged)}</td>
<td>{formatCount(agent.tasksCompleted)}</td>
<td>{formatCount(agent.tasksInProgress)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</AreaShell>
);
}

View File

@@ -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);
}
}

View File

@@ -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",
];

View File

@@ -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<string, unknown>).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);

View File

@@ -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.