diff --git a/.changeset/fn-6684-command-center-charts.md b/.changeset/fn-6684-command-center-charts.md new file mode 100644 index 0000000000..2c8beecf45 --- /dev/null +++ b/.changeset/fn-6684-command-center-charts.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add Command Center pie and line charts to Team, Ecosystem, GitHub, Signals, and System surfaces using existing analytics data. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 07906982cd..a6293e2815 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -668,12 +668,12 @@ Features: - **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. The area keeps the existing category bar and adds a recharts category-share pie from `ToolAnalytics.byCategory`. There is intentionally no tools line chart yet because `ToolAnalytics` does not expose a per-day tool trend; the dashboard does not fabricate one or call a new endpoint. - **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 keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. 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. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called. -- **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 the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, filed-vs-fixed daily sparklines, and a by-repository bar breakdown. -- **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected. -- **System** is the canonical system-telemetry destination. It reuses `GET /api/system-stats` with no new endpoint, renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory trend sparklines, and charts tasks by column plus agents by state with the shared Command Center chart primitives. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed. -- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. Motion-heavy accents respect reduced-motion preferences. +- **Team** shows a per-agent analytics table plus tokens-by-agent and tasks-done-by-agent charts, and adds a real token-share pie from the same per-agent token totals. 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. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. 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. It reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown. +- **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 the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, and a by-repository bar breakdown. +- **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. +- **System** is the canonical system-telemetry destination. It reuses `GET /api/system-stats` with no new endpoint, renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed. +- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences. - CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`. Rendering invariants: @@ -683,11 +683,11 @@ Rendering invariants: - Command Center stat cards, overview chart cards, live strips, table wrappers, Team chart panels, token-series plots, system control cards, and gauge/chart cards share the same tokenized rhythm: `--space-3` gaps/padding for card-like surfaces, `--border-width` borders, `--radius-md` radii, and `--surface-1` backgrounds. Area-specific accents may use `color-mix(...)`, but layout, border, radius, text color, and motion must stay on design tokens. 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, Tokens, Tools, Activity, and Productivity omit their additive recharts cards in loading/error/empty states, so non-populated data never leaves an empty chart shell. +- 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, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, and System omit their additive recharts cards in loading/error/empty states, so non-populated data never leaves an empty chart shell. - GitHub issue analytics is local and additive: empty filed/fixed totals keep the stat cards and historical backfill button available while omitting empty chart shells; 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. -- System telemetry keeps the previous snapshot visible during refresh failures, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections, 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. +- System telemetry keeps the previous snapshot visible during refresh failures, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections while omitting the zero-value task-distribution pie, 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, omits its status pie, and other Command Center metrics remain valid. ## Reliability View diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx index 6a69dcf436..3a3d19f29d 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx @@ -385,7 +385,21 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => { for (const tab of ["tokens", "tools", "activity", "productivity", "team", "ecosystem", "github", "signals", "system"]) { const panel = await openChartTab(tab); - if (tab === "system") await screen.findByTestId("cc-area-system"); + if (tab === "team") expect(screen.getByTestId("cc-team-pie")).toBeTruthy(); + if (tab === "ecosystem") { + expect(screen.getByTestId("cc-ecosystem-pie")).toBeTruthy(); + expect(screen.getByTestId("cc-ecosystem-line")).toBeTruthy(); + } + if (tab === "github") { + expect(screen.getByTestId("cc-github-pie")).toBeTruthy(); + expect(screen.getByTestId("cc-github-line")).toBeTruthy(); + } + if (tab === "signals") expect(screen.getByTestId("cc-signals-pie")).toBeTruthy(); + if (tab === "system") { + await screen.findByTestId("cc-area-system"); + expect(screen.getByTestId("cc-system-pie")).toBeTruthy(); + expect(screen.getByTestId("cc-system-line")).toBeTruthy(); + } assertScrollOwnerContract(panel); assertNoChartScrollSteal(panel); expect(panel.textContent).not.toContain("NaN"); diff --git a/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx index 9e046e417a..8878298c6e 100644 --- a/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx @@ -112,6 +112,10 @@ describe("SystemStatsArea", () => { expect(screen.getByTestId("cc-system-heap-gauge")).toHaveTextContent("90%"); expect(screen.getByTestId("cc-system-cpu-trend")).toBeInTheDocument(); expect(screen.getByTestId("cc-system-memory-trend")).toBeInTheDocument(); + expect(screen.getByTestId("cc-system-line")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Resource trend" })).toBeInTheDocument(); + expect(screen.getByTestId("cc-system-pie")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Task distribution" })).toBeInTheDocument(); expect(screen.getByTestId("cc-system-tasks-bar")).toHaveTextContent("in-progress"); expect(screen.getByTestId("cc-system-agents-bar")).toHaveTextContent("active"); expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("RSS"); @@ -127,6 +131,7 @@ describe("SystemStatsArea", () => { await screen.findByTestId("cc-area-system"); expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("—"); expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("Sampling"); + expect(screen.getByTestId("cc-system-line")).toBeInTheDocument(); expect(screen.getByTestId("cc-area-system")).not.toHaveTextContent("NaN"); }); @@ -149,6 +154,8 @@ describe("SystemStatsArea", () => { expect(within(agentBars).getByText("idle")).toBeInTheDocument(); expect(within(taskBars).getAllByText("0").length).toBeGreaterThan(0); expect(within(agentBars).getAllByText("0").length).toBeGreaterThan(0); + expect(screen.queryByTestId("cc-system-pie")).toBeNull(); + expect(screen.getByTestId("cc-system-line")).toBeInTheDocument(); expect(screen.getByTestId("cc-area-system")).not.toHaveTextContent("NaN"); }); @@ -177,6 +184,8 @@ describe("SystemStatsArea", () => { render(); expect(await screen.findByTestId("cc-area-system-error")).toHaveTextContent("initial failure"); + expect(screen.queryByTestId("cc-system-pie")).toBeNull(); + expect(screen.queryByTestId("cc-system-line")).toBeNull(); }); it("confirms before killing Vitest and persists settings changes", async () => { diff --git a/packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx b/packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx index 5622542167..fe9f0c2e00 100644 --- a/packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import type { TokenAnalytics } from "@fusion/core"; import type { DateRange } from "../DateRangePicker"; import { Bar } from "../charts/Bar"; +import { LineChart, PieChart } from "../charts/recharts"; import { AreaShell } from "./AreaShell"; import { useAnalyticsArea } from "./useAnalyticsArea"; import { formatCount } from "./areaShared"; @@ -19,7 +20,7 @@ import { formatCount } from "./areaShared"; export function EcosystemArea({ range }: { range: DateRange }) { const { t } = useTranslation("app"); const { data, isLoading, error } = useAnalyticsArea( - "/command-center/tokens?groupBy=model", + "/command-center/tokens?groupBy=model&granularity=day", range, ); @@ -42,6 +43,29 @@ export function EcosystemArea({ range }: { range: DateRange }) { })), [models, t], ); + /* + FNXC:CommandCenterCharts 2026-06-19-00:00: + Ecosystem charts must reuse the existing token analytics endpoint: per-model task counts become the pie, and optional token buckets become a trend line without fabricating series when the endpoint returns none. + */ + const perModelPieData = useMemo( + () => perModelBars.map((datum) => ({ label: datum.label, value: datum.value })), + [perModelBars], + ); + const tokenTrendSeries = useMemo( + () => [ + { + label: t("commandCenter.ecosystem.tokenTrendSeries", "Tokens"), + values: (data?.series ?? []).map((point) => point.totalTokens), + }, + { + label: t("commandCenter.ecosystem.taskTrendSeries", "Tasks"), + values: (data?.series ?? []).map((point) => point.nTasks), + }, + ], + [data?.series, t], + ); + const hasModelPie = perModelPieData.some((datum) => datum.value > 0); + const hasTokenTrend = (data?.series ?? []).length > 0; const isEmpty = !data || uniqueModels === 0; @@ -75,6 +99,20 @@ export function EcosystemArea({ range }: { range: DateRange }) { + {hasModelPie ? ( +
+

{t("commandCenter.ecosystem.modelShareTitle", "Task share by model")}

+ +
+ ) : null} + + {hasTokenTrend ? ( +
+

{t("commandCenter.ecosystem.trendTitle", "Ecosystem trend")}

+ +
+ ) : null} +

{t("commandCenter.ecosystem.perModelTitle", "Tasks per model")}

diff --git a/packages/dashboard/app/components/command-center/areas/GithubArea.tsx b/packages/dashboard/app/components/command-center/areas/GithubArea.tsx index 871b51d510..1ddf0714fd 100644 --- a/packages/dashboard/app/components/command-center/areas/GithubArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/GithubArea.tsx @@ -11,6 +11,7 @@ import type { GithubSourceIssueClosedAtBackfillResult } from "../../../api/legac import type { DateRange } from "../DateRangePicker"; import { Bar } from "../charts/Bar"; import { Sparkline } from "../charts/Sparkline"; +import { LineChart, PieChart } from "../charts/recharts"; import { AreaShell } from "./AreaShell"; import { useAnalyticsArea } from "./useAnalyticsArea"; import { formatCount } from "./areaShared"; @@ -97,12 +98,31 @@ export function GithubArea({ range }: { range: DateRange }) { })), [byRepo, t], ); + /* + FNXC:CommandCenterCharts 2026-06-19-00:00: + GitHub charts must remain local-task-store only: filed/fixed totals become the pie, and the already-fetched daily filed/fixed buckets become a real trend line while existing sparklines and repository bars stay additive. + */ + const issueFlowPieData = useMemo( + () => [ + { label: t("commandCenter.github.filed", "Filed by Fusion"), value: data?.filed ?? 0 }, + { label: t("commandCenter.github.fixed", "Fixed by Fusion"), value: data?.fixed ?? 0 }, + ], + [data?.filed, data?.fixed, t], + ); + const issueFlowLineSeries = useMemo( + () => [ + { label: t("commandCenter.github.filedTrend", "Filed"), values: filedValues }, + { label: t("commandCenter.github.fixedTrend", "Fixed"), values: fixedValues }, + ], + [filedValues, fixedValues, t], + ); const filed = data?.filed ?? 0; const fixed = data?.fixed ?? 0; const net = data?.net ?? filed - fixed; const isEmpty = !data || (filed === 0 && fixed === 0); const hasDailyTrend = daily.length > 0; + const hasIssueFlowPie = filed + fixed > 0; const hasRepoBreakdown = repoBars.length > 0; const backfillStatusClass = backfillError || (backfillResult?.errors ?? 0) > 0 ? "cc-github-backfill-status--error" @@ -189,6 +209,20 @@ export function GithubArea({ range }: { range: DateRange }) {
+ {hasIssueFlowPie ? ( +
+

{t("commandCenter.github.issueFlowShare", "Filed vs fixed share")}

+ +
+ ) : null} + + {hasDailyTrend ? ( +
+

{t("commandCenter.github.dailyLine", "Filed vs fixed line")}

+ +
+ ) : null} + {hasDailyTrend ? (

{t("commandCenter.github.dailyTrend", "Filed vs fixed trend")}

diff --git a/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx b/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx index b4c7c1c8aa..42d1e5d45f 100644 --- a/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { api } from "../../../api/legacy"; import type { DateRange } from "../DateRangePicker"; import { Bar } from "../charts/Bar"; +import { PieChart } from "../charts/recharts"; import { AreaShell } from "./AreaShell"; import { rangeQuery, formatCount, isInvalidRange } from "./areaShared"; @@ -73,8 +74,20 @@ export function SignalsArea({ range }: { range: DateRange }) { (data?.bySeverity ?? []).map((s) => ({ label: s.severity, value: s.count, valueLabel: formatCount(s.count) })), [data?.bySeverity], ); + /* + FNXC:CommandCenterCharts 2026-06-19-00:00: + Signals has no per-day series yet, so the chart affordance is an additive status pie from the already-fetched open/resolved counts; do not fabricate a line trend until the endpoint returns time buckets. + */ + const statusPieData = useMemo( + () => [ + { label: t("commandCenter.signals.open", "Open"), value: data?.open ?? 0 }, + { label: t("commandCenter.signals.resolved", "Resolved"), value: data?.resolved ?? 0 }, + ], + [data?.open, data?.resolved, t], + ); const isEmpty = !data || data.totalSignals === 0; + const hasStatusPie = !isEmpty && statusPieData.some((datum) => datum.value > 0); return (
+ {hasStatusPie ? ( +
+

{t("commandCenter.signals.statusShare", "Signal status share")}

+ +
+ ) : null} +

{t("commandCenter.signals.bySource", "By source")}

diff --git a/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx b/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx index 77ab97c1c3..4fcb2d3592 100644 --- a/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx @@ -12,6 +12,7 @@ import { import { Bar, type BarDatum } from "../charts/Bar"; import { RadialGauge } from "../charts/RadialGauge"; import { Sparkline } from "../charts/Sparkline"; +import { LineChart, PieChart } from "../charts/recharts"; import { AreaShell } from "./AreaShell"; import { formatCount } from "./areaShared"; import "./SystemStatsArea.css"; @@ -234,6 +235,27 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) { const labels = Object.keys(byColumn).length > 0 ? Object.keys(byColumn) : DEFAULT_TASK_COLUMNS; return labels.map((label) => ({ label, value: byColumn[label] ?? 0, valueLabel: formatCount(byColumn[label] ?? 0) })); }, [taskStats?.byColumn]); + /* + FNXC:CommandCenterCharts 2026-06-19-00:00: + System charts reuse the existing `/api/system-stats` payload: task columns become an additive workload pie, and the bounded resource sample buffer becomes a CPU/memory/heap line without introducing another polling endpoint or scroll owner. + */ + const taskPieData = useMemo( + () => taskBarData.map((datum) => ({ label: datum.label, value: datum.value })), + [taskBarData], + ); + const resourceLineSeries = useMemo( + () => [ + { label: t("commandCenter.system.cpuSeries", "CPU"), values: samples.map((sample) => clampPercent(sample.cpuPercent)) }, + { + label: t("commandCenter.system.memorySeries", "Memory"), + values: samples.map((sample) => clampPercent(sample.usedSystemMemPercent)), + }, + { label: t("commandCenter.system.heapSeries", "Heap"), values: samples.map((sample) => clampPercent(sample.heapUsedPercent)) }, + ], + [samples, t], + ); + const hasTaskPie = taskPieData.some((datum) => datum.value > 0); + const hasResourceTrend = samples.length > 0; const agentBarData = useMemo(() => { const agents = taskStats?.agents; @@ -312,6 +334,12 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {

{t("commandCenter.system.trendsTitle", "Live trends")}

+ {hasResourceTrend ? ( +
+
{t("commandCenter.system.resourceTrend", "Resource trend")}
+ +
+ ) : null}
{t("commandCenter.system.cpuTrend", "CPU over time")}
@@ -331,6 +359,12 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {

{t("commandCenter.system.workloadTitle", "Workload")}

+ {hasTaskPie ? ( +
+
{t("commandCenter.system.taskShare", "Task distribution")}
+ +
+ ) : null}
{t("systemStats.sectionTasks", "Tasks")}
diff --git a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx index 1ef1153aa6..89618883ea 100644 --- a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx @@ -8,6 +8,7 @@ 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 { PieChart } from "../charts/recharts"; import { AreaShell } from "./AreaShell"; import { useAnalyticsArea } from "./useAnalyticsArea"; import { formatCost, formatCount } from "./areaShared"; @@ -120,6 +121,14 @@ export function TeamArea({ range }: { range: DateRange }) { () => buildBarData(agents, (agent) => agent.tokens.totalTokens, unknownAgent), [agents, unknownAgent], ); + /* + FNXC:CommandCenterCharts 2026-06-19-00:00: + The Team surface needs a real per-agent pie chart without a new endpoint; map the existing per-agent token totals additively so loading, empty, bar, sparkline, and table affordances remain unchanged. + */ + const tokenPieData = useMemo( + () => tokenBarData.map((datum) => ({ label: datum.label, value: datum.value })), + [tokenBarData], + ); const completedBarData = useMemo( () => buildBarData(agents, (agent) => agent.tasksCompleted, unknownAgent), [agents, unknownAgent], @@ -178,6 +187,12 @@ export function TeamArea({ range }: { range: DateRange }) {
+ {hasTokenChart ? ( +
+

{t("commandCenter.team.tokenShareByAgent", "Token share by agent")}

+ +
+ ) : null}

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

{hasTokenChart ? ( diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index 382e0fbd8b..cdf8551cf2 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -19,6 +19,7 @@ import { ToolsArea } from "../ToolsArea"; import { ProductivityArea } from "../ProductivityArea"; import { GithubArea } from "../GithubArea"; import { SignalsArea } from "../SignalsArea"; +import { TeamArea } from "../TeamArea"; import { ActivityArea } from "../ActivityArea"; import { EcosystemArea } from "../EcosystemArea"; import { useAnalyticsArea } from "../useAnalyticsArea"; @@ -106,6 +107,50 @@ function githubFixture() { }; } +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 populatedTeamFixture() { + return { + ...emptyTeamFixture(), + totals: { + 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, + }, + agents: [ + { + 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, + }, + ], + }; +} + function activityFixture() { return { from: "2026-06-08", @@ -713,19 +758,114 @@ describe("ProductivityArea", () => { }); }); +describe("TeamArea", () => { + it("renders the per-agent pie for populated team analytics", async () => { + apiMock.mockResolvedValue({ + ...populatedTeamFixture(), + agents: [ + ...populatedTeamFixture().agents, + { + ...populatedTeamFixture().agents[0], + agentId: "agent-beta", + agentName: "Beta Agent", + tokens: { ...populatedTeamFixture().agents[0].tokens, totalTokens: 500 }, + tasksCompleted: 1, + }, + ], + }); + render(); + + await screen.findByTestId("cc-area-team"); + expect(screen.getByTestId("cc-team-pie")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Token share by agent" })).toBeTruthy(); + expect(screen.getByTestId("cc-team-tokens-chart")).toBeTruthy(); + expect(screen.getByTestId("cc-team-completed-chart")).toBeTruthy(); + expect(screen.getByTestId("cc-team-pie").textContent).not.toContain("NaN"); + }); + + it("keeps the team pie safe for single-item and non-finite data", async () => { + apiMock.mockResolvedValue({ + ...populatedTeamFixture(), + agents: [{ ...populatedTeamFixture().agents[0], tokens: { ...populatedTeamFixture().agents[0].tokens, totalTokens: Number.NaN } }], + }); + render(); + + await screen.findByTestId("cc-area-team"); + expect(screen.queryByTestId("cc-area-team-empty")).toBeNull(); + expect(screen.queryByTestId("cc-team-pie")).toBeNull(); + expect(screen.getByTestId("cc-area-team").textContent).not.toContain("NaN"); + }); + + it("renders empty, loading, and error states without a team pie shell", async () => { + apiMock.mockResolvedValueOnce(emptyTeamFixture()); + const empty = render(); + await screen.findByTestId("cc-area-team-empty"); + expect(screen.queryByTestId("cc-team-pie")).toBeNull(); + empty.unmount(); + + apiMock.mockImplementationOnce(() => new Promise(() => undefined)); + const pending = render(); + expect(screen.getByTestId("cc-area-team-loading")).toBeTruthy(); + expect(screen.queryByTestId("cc-team-pie")).toBeNull(); + pending.unmount(); + + apiMock.mockRejectedValueOnce(new Error("team failed")); + render(); + await screen.findByTestId("cc-area-team-error"); + expect(screen.queryByTestId("cc-team-pie")).toBeNull(); + }); +}); + describe("EcosystemArea", () => { - it("renders populated and empty model chart states without NaN or empty chart shells", async () => { + it("renders populated model pie and trend line without NaN", async () => { apiMock.mockResolvedValueOnce(tokenFixture()); - const { unmount } = render(); + render(); + await screen.findByTestId("cc-area-ecosystem"); expect(screen.getByRole("list", { name: "Tasks per model" })).toBeTruthy(); + expect(screen.getByTestId("cc-ecosystem-pie")).toBeTruthy(); + expect(screen.getByTestId("cc-ecosystem-line")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Task share by model" })).toBeTruthy(); + expect(screen.getByRole("img", { name: "Ecosystem trend" })).toBeTruthy(); expect(screen.getByTestId("cc-area-ecosystem").textContent).not.toContain("NaN"); - unmount(); + }); - apiMock.mockResolvedValueOnce({ ...tokenFixture(), groups: [], totals: { ...tokenFixture().totals, totalTokens: 0, nTasks: 0 } }); - render(); + it("renders empty, loading, and error states without ecosystem chart shells", async () => { + apiMock.mockResolvedValueOnce({ ...tokenFixture(), groups: [], series: [], totals: { ...tokenFixture().totals, totalTokens: 0, nTasks: 0 } }); + const empty = render(); await screen.findByTestId("cc-area-ecosystem-empty"); expect(screen.queryByRole("list", { name: "Tasks per model" })).toBeNull(); + expect(screen.queryByTestId("cc-ecosystem-pie")).toBeNull(); + expect(screen.queryByTestId("cc-ecosystem-line")).toBeNull(); + empty.unmount(); + + apiMock.mockImplementationOnce(() => new Promise(() => undefined)); + const pending = render(); + expect(screen.getByTestId("cc-area-ecosystem-loading")).toBeTruthy(); + expect(screen.queryByTestId("cc-ecosystem-pie")).toBeNull(); + expect(screen.queryByTestId("cc-ecosystem-line")).toBeNull(); + pending.unmount(); + + apiMock.mockRejectedValueOnce(new Error("ecosystem failed")); + render(); + await screen.findByTestId("cc-area-ecosystem-error"); + expect(screen.queryByTestId("cc-ecosystem-pie")).toBeNull(); + expect(screen.queryByTestId("cc-ecosystem-line")).toBeNull(); + }); + + it("keeps ecosystem recharts safe for single-item and non-finite data", async () => { + apiMock.mockResolvedValue({ + ...tokenFixture(), + groups: [{ ...tokenFixture().groups[0], nTasks: Number.NaN }], + series: [{ ...tokenFixture().series[0], totalTokens: Number.POSITIVE_INFINITY, nTasks: -1 }], + }); + render(); + + await screen.findByTestId("cc-area-ecosystem"); + expect(screen.queryByTestId("cc-ecosystem-pie")).toBeNull(); + expect(screen.getByTestId("cc-ecosystem-line")).toBeTruthy(); + expect(screen.getByTestId("cc-area-ecosystem").textContent).not.toContain("NaN"); + expect(screen.getByTestId("cc-area-ecosystem").textContent).not.toContain("Infinity"); }); }); @@ -738,6 +878,10 @@ describe("GithubArea", () => { expect(screen.getByTestId("cc-github-filed").textContent).toContain("5"); expect(screen.getByTestId("cc-github-fixed").textContent).toContain("3"); expect(screen.getByTestId("cc-github-net").textContent).toContain("2"); + expect(screen.getByTestId("cc-github-pie")).toBeTruthy(); + expect(screen.getByTestId("cc-github-line")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Filed vs fixed share" })).toBeTruthy(); + expect(screen.getByRole("img", { name: "Filed vs fixed line" })).toBeTruthy(); expect(screen.getByTestId("cc-github-daily-trend")).toBeTruthy(); expect(screen.getByRole("img", { name: "Filed" })).toBeTruthy(); expect(screen.getByRole("img", { name: "Fixed" })).toBeTruthy(); @@ -753,6 +897,8 @@ describe("GithubArea", () => { await screen.findByTestId("cc-area-github"); expect(screen.getByTestId("cc-area-github").textContent).toContain("No GitHub issue activity"); expect(screen.getByTestId("cc-github-backfill-button")).toBeTruthy(); + expect(screen.queryByTestId("cc-github-pie")).toBeNull(); + expect(screen.queryByTestId("cc-github-line")).toBeNull(); expect(screen.queryByTestId("cc-github-daily-trend")).toBeNull(); expect(screen.queryByTestId("cc-github-by-repo")).toBeNull(); }); @@ -761,12 +907,16 @@ describe("GithubArea", () => { apiMock.mockImplementationOnce(() => new Promise(() => undefined)); const { unmount } = render(); expect(screen.getByTestId("cc-area-github-loading")).toBeTruthy(); + expect(screen.queryByTestId("cc-github-pie")).toBeNull(); + expect(screen.queryByTestId("cc-github-line")).toBeNull(); unmount(); apiMock.mockRejectedValueOnce(new Error("github failed")); render(); await screen.findByTestId("cc-area-github-error"); expect(screen.getByTestId("cc-area-github-error").textContent).toContain("github failed"); + expect(screen.queryByTestId("cc-github-pie")).toBeNull(); + expect(screen.queryByTestId("cc-github-line")).toBeNull(); }); it("handles undefined chart arrays and zero values without NaN output", async () => { @@ -774,11 +924,31 @@ describe("GithubArea", () => { render(); await screen.findByTestId("cc-area-github"); + expect(screen.getByTestId("cc-github-pie")).toBeTruthy(); + expect(screen.queryByTestId("cc-github-line")).toBeNull(); expect(screen.queryByTestId("cc-github-daily-trend")).toBeNull(); expect(screen.queryByTestId("cc-github-by-repo")).toBeNull(); expect(screen.getByTestId("cc-area-github").textContent).not.toContain("NaN"); }); + it("keeps GitHub recharts safe for single-item and non-finite daily data", async () => { + apiMock.mockResolvedValue({ + ...githubFixture(), + filed: 1, + fixed: 1, + net: 0, + daily: [{ date: "2026-06-08", filed: Number.NaN, fixed: Number.POSITIVE_INFINITY }], + byRepo: [{ repo: "acme/broken", filed: Number.NaN, fixed: -1 }], + }); + render(); + + await screen.findByTestId("cc-area-github"); + expect(screen.getByTestId("cc-github-pie")).toBeTruthy(); + expect(screen.getByTestId("cc-github-line")).toBeTruthy(); + expect(screen.getByTestId("cc-area-github").textContent).not.toContain("NaN"); + expect(screen.getByTestId("cc-area-github").textContent).not.toContain("Infinity"); + }); + it("rejects an inverted custom range client-side without fetching", async () => { render(); await waitFor(() => expect(apiMock).not.toHaveBeenCalled()); @@ -917,6 +1087,7 @@ describe("GithubArea", () => { }); }); +// FN-6684 Mission Control decision: no extra pie/line test here because MissionControlPanel already renders the live SDLC Funnel for its only quantitative distribution; adding a pie would duplicate that affordance. describe("SignalsArea", () => { it("renders the empty state (not an error) when the signals endpoint is missing", async () => { apiMock.mockRejectedValue(new Error("API returned HTML instead of JSON (404)")); @@ -924,9 +1095,10 @@ describe("SignalsArea", () => { await screen.findByTestId("cc-area-signals-empty"); // Must not surface the error UI. expect(screen.queryByTestId("cc-area-signals-error")).toBeNull(); + expect(screen.queryByTestId("cc-signals-pie")).toBeNull(); }); - it("renders signal metrics when data is present", async () => { + it("renders signal metrics and status pie when data is present", async () => { apiMock.mockResolvedValue({ totalSignals: 8, open: 3, @@ -939,5 +1111,37 @@ describe("SignalsArea", () => { await screen.findByTestId("cc-area-signals"); expect(screen.getByTestId("cc-signals-total").textContent).toContain("8"); expect(screen.getByTestId("cc-signals-mttr").textContent).toContain("42"); + expect(screen.getByTestId("cc-signals-pie")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Signal status share" })).toBeTruthy(); + }); + + it("keeps signals pie safe for single-item and non-finite source/severity data", async () => { + apiMock.mockResolvedValue({ + totalSignals: 1, + open: 1, + resolved: 0, + mttr: { value: null, unavailable: true }, + bySource: [{ source: "broken", count: Number.NaN }], + bySeverity: [{ severity: "broken", count: Number.POSITIVE_INFINITY }], + }); + render(); + await screen.findByTestId("cc-area-signals"); + expect(screen.getByTestId("cc-signals-pie")).toBeTruthy(); + expect(screen.getByTestId("cc-area-signals").textContent).not.toContain("NaN"); + expect(screen.getByTestId("cc-area-signals").textContent).not.toContain("Infinity"); + }); + + it("renders settled zero signals without a pie shell", async () => { + apiMock.mockResolvedValue({ + totalSignals: 0, + open: 0, + resolved: 0, + mttr: { value: null, unavailable: true }, + bySource: [], + bySeverity: [], + }); + render(); + await screen.findByTestId("cc-area-signals-empty"); + expect(screen.queryByTestId("cc-signals-pie")).toBeNull(); }); });