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:
5
.changeset/fn-6655-command-center-team-view.md
Normal file
5
.changeset/fn-6655-command-center-team-view.md
Normal 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.
|
||||||
@@ -668,6 +668,7 @@ Features:
|
|||||||
- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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:
|
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.
|
- 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.
|
- 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.
|
- 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
|
## Reliability View
|
||||||
|
|||||||
315
packages/core/src/__tests__/team-analytics.test.ts
Normal file
315
packages/core/src/__tests__/team-analytics.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -576,6 +576,13 @@ export type {
|
|||||||
LanguageCount,
|
LanguageCount,
|
||||||
LocSummary,
|
LocSummary,
|
||||||
} from "./productivity-analytics.js";
|
} 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 { aggregateGithubIssueAnalytics } from "./github-issue-analytics.js";
|
||||||
export type {
|
export type {
|
||||||
GithubIssueAnalytics,
|
GithubIssueAnalytics,
|
||||||
|
|||||||
315
packages/core/src/team-analytics.ts
Normal file
315
packages/core/src/team-analytics.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { TokensArea } from "./areas/TokensArea";
|
|||||||
import { ToolsArea } from "./areas/ToolsArea";
|
import { ToolsArea } from "./areas/ToolsArea";
|
||||||
import { ActivityArea } from "./areas/ActivityArea";
|
import { ActivityArea } from "./areas/ActivityArea";
|
||||||
import { ProductivityArea } from "./areas/ProductivityArea";
|
import { ProductivityArea } from "./areas/ProductivityArea";
|
||||||
|
import { TeamArea } from "./areas/TeamArea";
|
||||||
import { EcosystemArea } from "./areas/EcosystemArea";
|
import { EcosystemArea } from "./areas/EcosystemArea";
|
||||||
import { GithubArea } from "./areas/GithubArea";
|
import { GithubArea } from "./areas/GithubArea";
|
||||||
import { SignalsArea } from "./areas/SignalsArea";
|
import { SignalsArea } from "./areas/SignalsArea";
|
||||||
@@ -26,6 +27,7 @@ type SubViewId =
|
|||||||
| "tools"
|
| "tools"
|
||||||
| "activity"
|
| "activity"
|
||||||
| "productivity"
|
| "productivity"
|
||||||
|
| "team"
|
||||||
| "ecosystem"
|
| "ecosystem"
|
||||||
| "github"
|
| "github"
|
||||||
| "signals"
|
| "signals"
|
||||||
@@ -36,6 +38,10 @@ interface SubView {
|
|||||||
label: string;
|
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[] {
|
function useSubViews(): SubView[] {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
return [
|
return [
|
||||||
@@ -44,6 +50,7 @@ function useSubViews(): SubView[] {
|
|||||||
{ id: "tools", label: t("commandCenter.tabs.tools", "Tools") },
|
{ id: "tools", label: t("commandCenter.tabs.tools", "Tools") },
|
||||||
{ id: "activity", label: t("commandCenter.tabs.activity", "Activity") },
|
{ id: "activity", label: t("commandCenter.tabs.activity", "Activity") },
|
||||||
{ id: "productivity", label: t("commandCenter.tabs.productivity", "Productivity") },
|
{ 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: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") },
|
||||||
{ id: "github", label: t("commandCenter.tabs.github", "GitHub") },
|
{ id: "github", label: t("commandCenter.tabs.github", "GitHub") },
|
||||||
{ id: "signals", label: t("commandCenter.tabs.signals", "Signals") },
|
{ id: "signals", label: t("commandCenter.tabs.signals", "Signals") },
|
||||||
@@ -426,6 +433,8 @@ export function CommandCenter() {
|
|||||||
return <ActivityArea range={range} />;
|
return <ActivityArea range={range} />;
|
||||||
case "productivity":
|
case "productivity":
|
||||||
return <ProductivityArea range={range} />;
|
return <ProductivityArea range={range} />;
|
||||||
|
case "team":
|
||||||
|
return <TeamArea range={range} />;
|
||||||
case "ecosystem":
|
case "ecosystem":
|
||||||
return <EcosystemArea range={range} />;
|
return <EcosystemArea range={range} />;
|
||||||
case "github":
|
case "github":
|
||||||
|
|||||||
@@ -89,6 +89,22 @@ function emptyGithubFixture() {
|
|||||||
return { filed: 0, fixed: 0, net: 0, daily: [], byRepo: [] };
|
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() {
|
function populatedActivityFixture() {
|
||||||
return {
|
return {
|
||||||
...emptyActivityFixture(),
|
...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/tools")) return Promise.resolve(populated ? populatedToolsFixture() : emptyToolsFixture());
|
||||||
if (path.startsWith("/command-center/activity")) return Promise.resolve(populated ? populatedActivityFixture() : emptyActivityFixture());
|
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/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.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") {
|
if (path === "/command-center/live") {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -198,6 +215,11 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
|
|||||||
expect(tokensPanel).toBe(screen.getByRole("tabpanel"));
|
expect(tokensPanel).toBe(screen.getByRole("tabpanel"));
|
||||||
assertScrollOwnerContract(tokensPanel);
|
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"));
|
fireEvent.click(screen.getByTestId("command-center-tab-github"));
|
||||||
const githubPanel = screen.getByTestId("command-center-panel-github");
|
const githubPanel = screen.getByTestId("command-center-panel-github");
|
||||||
expect(githubPanel).toBe(screen.getByRole("tabpanel"));
|
expect(githubPanel).toBe(screen.getByRole("tabpanel"));
|
||||||
|
|||||||
@@ -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) {
|
function signalsFixture(open = 2) {
|
||||||
return {
|
return {
|
||||||
totalSignals: open,
|
totalSignals: open,
|
||||||
@@ -146,6 +187,7 @@ function mockOverviewApi({
|
|||||||
tools = toolsFixture(),
|
tools = toolsFixture(),
|
||||||
activity = activityFixture(),
|
activity = activityFixture(),
|
||||||
github = githubFixture(),
|
github = githubFixture(),
|
||||||
|
team = teamFixture([]),
|
||||||
signals = signalsFixture(),
|
signals = signalsFixture(),
|
||||||
live = liveFixture(),
|
live = liveFixture(),
|
||||||
}: {
|
}: {
|
||||||
@@ -153,6 +195,7 @@ function mockOverviewApi({
|
|||||||
tools?: unknown;
|
tools?: unknown;
|
||||||
activity?: unknown;
|
activity?: unknown;
|
||||||
github?: unknown;
|
github?: unknown;
|
||||||
|
team?: unknown;
|
||||||
signals?: unknown;
|
signals?: unknown;
|
||||||
live?: unknown;
|
live?: unknown;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
@@ -161,6 +204,7 @@ function mockOverviewApi({
|
|||||||
if (path.startsWith("/command-center/tools")) return Promise.resolve(tools);
|
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/activity")) return Promise.resolve(activity);
|
||||||
if (path.startsWith("/command-center/github")) return Promise.resolve(github);
|
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")) {
|
if (path.startsWith("/command-center/signals")) {
|
||||||
return signals instanceof Error ? Promise.reject(signals) : Promise.resolve(signals);
|
return signals instanceof Error ? Promise.reject(signals) : Promise.resolve(signals);
|
||||||
}
|
}
|
||||||
@@ -476,8 +520,8 @@ describe("CommandCenter shell", () => {
|
|||||||
render(<CommandCenter />);
|
render(<CommandCenter />);
|
||||||
const tablist = screen.getByRole("tablist");
|
const tablist = screen.getByRole("tablist");
|
||||||
const tabs = within(tablist).getAllByRole("tab");
|
const tabs = within(tablist).getAllByRole("tab");
|
||||||
// Overview, Tokens, Tools, Activity, Productivity, Ecosystem, GitHub, Signals, Mission Control.
|
// Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, Mission Control.
|
||||||
expect(tabs.length).toBe(9);
|
expect(tabs.length).toBe(10);
|
||||||
// roving tabindex: exactly one tab is focusable.
|
// roving tabindex: exactly one tab is focusable.
|
||||||
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
|
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
|
||||||
expect(focusable.length).toBe(1);
|
expect(focusable.length).toBe(1);
|
||||||
@@ -505,6 +549,106 @@ describe("CommandCenter shell", () => {
|
|||||||
expect(screen.getByTestId("cc-github-fixed").textContent).toContain("2");
|
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)", () => {
|
it("supports arrow-key navigation between tabs (roving tabindex)", () => {
|
||||||
render(<CommandCenter />);
|
render(<CommandCenter />);
|
||||||
const overviewTab = screen.getByTestId("command-center-tab-overview");
|
const overviewTab = screen.getByTestId("command-center-tab-overview");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
color: var(--text-muted);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const ENDPOINTS = [
|
|||||||
"/api/command-center/tools",
|
"/api/command-center/tools",
|
||||||
"/api/command-center/activity",
|
"/api/command-center/activity",
|
||||||
"/api/command-center/productivity",
|
"/api/command-center/productivity",
|
||||||
|
"/api/command-center/team",
|
||||||
"/api/command-center/github",
|
"/api/command-center/github",
|
||||||
"/api/command-center/live",
|
"/api/command-center/live",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -60,6 +60,29 @@ function seedAgentRun(db: Database, opts: { id: string; agentId: string; started
|
|||||||
).run(opts.id, opts.agentId, opts.startedAt, opts.status);
|
).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 {
|
function seedGithubIssueMetrics(db: Database, opts: { prefix: string; repo: string; filed: number; fixed: number }): void {
|
||||||
for (let i = 0; i < opts.filed; i += 1) {
|
for (let i = 0; i < opts.filed; i += 1) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -211,6 +234,35 @@ describe("register-command-center-routes", () => {
|
|||||||
expect(res.body as Record<string, unknown>).not.toHaveProperty("series");
|
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 () => {
|
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";
|
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" });
|
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);
|
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 () => {
|
it("github endpoint defaults invalid ranges and stays project scoped", async () => {
|
||||||
seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 2, fixed: 1 });
|
seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 2, fixed: 1 });
|
||||||
seedGithubIssueMetrics(dbB, { prefix: "FN-B", repo: "acme/beta", filed: 5, fixed: 4 });
|
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", () => {
|
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/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/live")).toBe(true);
|
||||||
expect(PROXY_RE.test("/api/command-center/github")).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);
|
expect(PROXY_RE.test("/api/command-center/activity?from=x&to=y")).toBe(true);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
aggregateToolAnalytics,
|
aggregateToolAnalytics,
|
||||||
aggregateActivityAnalytics,
|
aggregateActivityAnalytics,
|
||||||
aggregateProductivityAnalytics,
|
aggregateProductivityAnalytics,
|
||||||
|
aggregateTeamAnalytics,
|
||||||
aggregateGithubIssueAnalytics,
|
aggregateGithubIssueAnalytics,
|
||||||
composeLiveSnapshot,
|
composeLiveSnapshot,
|
||||||
type TokenGroupBy,
|
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
|
* GET /api/command-center/github
|
||||||
* GitHub issues filed by Fusion and imported GitHub issues fixed by Fusion.
|
* GitHub issues filed by Fusion and imported GitHub issues fixed by Fusion.
|
||||||
|
|||||||
Reference in New Issue
Block a user