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.
|
||||
- **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
|
||||
|
||||
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,
|
||||
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,
|
||||
|
||||
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 { 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":
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
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/activity",
|
||||
"/api/command-center/productivity",
|
||||
"/api/command-center/team",
|
||||
"/api/command-center/github",
|
||||
"/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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user