FN-6684: add Command Center chart coverage

Adds remaining Command Center pie and line chart surfaces with regression coverage.

- Add pie and trend charts to Team, Ecosystem, GitHub, Signals, and System areas using existing analytics data.
- Extend Command Center tests for the new chart surfaces, empty states, mobile scrolling, and system stats rendering.
- Document the expanded Command Center visualizations and add a patch changeset for the published package.

Files changed:
 .changeset/fn-6684-command-center-charts.md        |   5 +
 docs/dashboard-guide.md                            |  18 +-
 .../__tests__/CommandCenter.mobile-scroll.test.tsx |  16 +-
 .../__tests__/SystemStatsArea.test.tsx             |   9 +
 .../command-center/areas/EcosystemArea.tsx         |  40 +++-
 .../components/command-center/areas/GithubArea.tsx |  34 ++++
 .../command-center/areas/SignalsArea.tsx           |  20 ++
 .../command-center/areas/SystemStatsArea.tsx       |  34 ++++
 .../components/command-center/areas/TeamArea.tsx   |  15 ++
 .../command-center/areas/__tests__/areas.test.tsx  | 216 ++++++++++++++++++++-
 10 files changed, 390 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-6684

Fusion-Task-Lineage: dc4eda2f-bc1b-4b51-9317-19ab6ddcdba7
This commit is contained in:
gsxdsm
2026-06-19 01:02:50 -07:00
parent 99d799cb31
commit 5e1a4ff3fd
10 changed files with 390 additions and 17 deletions

View File

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

View File

@@ -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. - **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. - **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. - **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. - **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. - **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, filed-vs-fixed daily sparklines, and a by-repository bar breakdown. - **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. - **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 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. - **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. Motion-heavy accents respect reduced-motion preferences. - **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)`. - 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: 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. - 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: 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. - 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. - 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. - 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 and other Command Center metrics remain valid. - Signals is best-effort: if the Signals endpoint is absent or no signal source is connected, the Signals area falls back to its empty state, omits its status pie, and other Command Center metrics remain valid.
## Reliability View ## Reliability View

View File

@@ -385,7 +385,21 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
for (const tab of ["tokens", "tools", "activity", "productivity", "team", "ecosystem", "github", "signals", "system"]) { for (const tab of ["tokens", "tools", "activity", "productivity", "team", "ecosystem", "github", "signals", "system"]) {
const panel = await openChartTab(tab); 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); assertScrollOwnerContract(panel);
assertNoChartScrollSteal(panel); assertNoChartScrollSteal(panel);
expect(panel.textContent).not.toContain("NaN"); expect(panel.textContent).not.toContain("NaN");

View File

@@ -112,6 +112,10 @@ describe("SystemStatsArea", () => {
expect(screen.getByTestId("cc-system-heap-gauge")).toHaveTextContent("90%"); expect(screen.getByTestId("cc-system-heap-gauge")).toHaveTextContent("90%");
expect(screen.getByTestId("cc-system-cpu-trend")).toBeInTheDocument(); expect(screen.getByTestId("cc-system-cpu-trend")).toBeInTheDocument();
expect(screen.getByTestId("cc-system-memory-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-tasks-bar")).toHaveTextContent("in-progress");
expect(screen.getByTestId("cc-system-agents-bar")).toHaveTextContent("active"); expect(screen.getByTestId("cc-system-agents-bar")).toHaveTextContent("active");
expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("RSS"); expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("RSS");
@@ -127,6 +131,7 @@ describe("SystemStatsArea", () => {
await screen.findByTestId("cc-area-system"); await screen.findByTestId("cc-area-system");
expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("—"); expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("—");
expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("Sampling"); 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"); expect(screen.getByTestId("cc-area-system")).not.toHaveTextContent("NaN");
}); });
@@ -149,6 +154,8 @@ describe("SystemStatsArea", () => {
expect(within(agentBars).getByText("idle")).toBeInTheDocument(); expect(within(agentBars).getByText("idle")).toBeInTheDocument();
expect(within(taskBars).getAllByText("0").length).toBeGreaterThan(0); expect(within(taskBars).getAllByText("0").length).toBeGreaterThan(0);
expect(within(agentBars).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"); expect(screen.getByTestId("cc-area-system")).not.toHaveTextContent("NaN");
}); });
@@ -177,6 +184,8 @@ describe("SystemStatsArea", () => {
render(<SystemStatsArea />); render(<SystemStatsArea />);
expect(await screen.findByTestId("cc-area-system-error")).toHaveTextContent("initial failure"); 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 () => { it("confirms before killing Vitest and persists settings changes", async () => {

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import type { TokenAnalytics } from "@fusion/core"; import type { TokenAnalytics } from "@fusion/core";
import type { DateRange } from "../DateRangePicker"; import type { DateRange } from "../DateRangePicker";
import { Bar } from "../charts/Bar"; import { Bar } from "../charts/Bar";
import { LineChart, PieChart } from "../charts/recharts";
import { AreaShell } from "./AreaShell"; import { AreaShell } from "./AreaShell";
import { useAnalyticsArea } from "./useAnalyticsArea"; import { useAnalyticsArea } from "./useAnalyticsArea";
import { formatCount } from "./areaShared"; import { formatCount } from "./areaShared";
@@ -19,7 +20,7 @@ import { formatCount } from "./areaShared";
export function EcosystemArea({ range }: { range: DateRange }) { export function EcosystemArea({ range }: { range: DateRange }) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { data, isLoading, error } = useAnalyticsArea<TokenAnalytics>( const { data, isLoading, error } = useAnalyticsArea<TokenAnalytics>(
"/command-center/tokens?groupBy=model", "/command-center/tokens?groupBy=model&granularity=day",
range, range,
); );
@@ -42,6 +43,29 @@ export function EcosystemArea({ range }: { range: DateRange }) {
})), })),
[models, t], [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; const isEmpty = !data || uniqueModels === 0;
@@ -75,6 +99,20 @@ export function EcosystemArea({ range }: { range: DateRange }) {
</div> </div>
</div> </div>
{hasModelPie ? (
<div className="cc-area-section" data-testid="cc-ecosystem-pie">
<h3 className="cc-area-section-title">{t("commandCenter.ecosystem.modelShareTitle", "Task share by model")}</h3>
<PieChart data={perModelPieData} ariaLabel={t("commandCenter.ecosystem.modelShareTitle", "Task share by model")} />
</div>
) : null}
{hasTokenTrend ? (
<div className="cc-area-section" data-testid="cc-ecosystem-line">
<h3 className="cc-area-section-title">{t("commandCenter.ecosystem.trendTitle", "Ecosystem trend")}</h3>
<LineChart series={tokenTrendSeries} ariaLabel={t("commandCenter.ecosystem.trendTitle", "Ecosystem trend")} />
</div>
) : null}
<div className="cc-area-section"> <div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.ecosystem.perModelTitle", "Tasks per model")}</h3> <h3 className="cc-area-section-title">{t("commandCenter.ecosystem.perModelTitle", "Tasks per model")}</h3>
<Bar data={perModelBars} ariaLabel={t("commandCenter.ecosystem.perModelTitle", "Tasks per model")} /> <Bar data={perModelBars} ariaLabel={t("commandCenter.ecosystem.perModelTitle", "Tasks per model")} />

View File

@@ -11,6 +11,7 @@ import type { GithubSourceIssueClosedAtBackfillResult } from "../../../api/legac
import type { DateRange } from "../DateRangePicker"; import type { DateRange } from "../DateRangePicker";
import { Bar } from "../charts/Bar"; import { Bar } from "../charts/Bar";
import { Sparkline } from "../charts/Sparkline"; import { Sparkline } from "../charts/Sparkline";
import { LineChart, PieChart } from "../charts/recharts";
import { AreaShell } from "./AreaShell"; import { AreaShell } from "./AreaShell";
import { useAnalyticsArea } from "./useAnalyticsArea"; import { useAnalyticsArea } from "./useAnalyticsArea";
import { formatCount } from "./areaShared"; import { formatCount } from "./areaShared";
@@ -97,12 +98,31 @@ export function GithubArea({ range }: { range: DateRange }) {
})), })),
[byRepo, t], [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 filed = data?.filed ?? 0;
const fixed = data?.fixed ?? 0; const fixed = data?.fixed ?? 0;
const net = data?.net ?? filed - fixed; const net = data?.net ?? filed - fixed;
const isEmpty = !data || (filed === 0 && fixed === 0); const isEmpty = !data || (filed === 0 && fixed === 0);
const hasDailyTrend = daily.length > 0; const hasDailyTrend = daily.length > 0;
const hasIssueFlowPie = filed + fixed > 0;
const hasRepoBreakdown = repoBars.length > 0; const hasRepoBreakdown = repoBars.length > 0;
const backfillStatusClass = backfillError || (backfillResult?.errors ?? 0) > 0 const backfillStatusClass = backfillError || (backfillResult?.errors ?? 0) > 0
? "cc-github-backfill-status--error" ? "cc-github-backfill-status--error"
@@ -189,6 +209,20 @@ export function GithubArea({ range }: { range: DateRange }) {
</div> </div>
</div> </div>
{hasIssueFlowPie ? (
<div className="cc-area-section" data-testid="cc-github-pie">
<h3 className="cc-area-section-title">{t("commandCenter.github.issueFlowShare", "Filed vs fixed share")}</h3>
<PieChart data={issueFlowPieData} ariaLabel={t("commandCenter.github.issueFlowShare", "Filed vs fixed share")} />
</div>
) : null}
{hasDailyTrend ? (
<div className="cc-area-section" data-testid="cc-github-line">
<h3 className="cc-area-section-title">{t("commandCenter.github.dailyLine", "Filed vs fixed line")}</h3>
<LineChart series={issueFlowLineSeries} ariaLabel={t("commandCenter.github.dailyLine", "Filed vs fixed line")} />
</div>
) : null}
{hasDailyTrend ? ( {hasDailyTrend ? (
<div className="cc-area-section" data-testid="cc-github-daily-trend"> <div className="cc-area-section" data-testid="cc-github-daily-trend">
<h3 className="cc-area-section-title">{t("commandCenter.github.dailyTrend", "Filed vs fixed trend")}</h3> <h3 className="cc-area-section-title">{t("commandCenter.github.dailyTrend", "Filed vs fixed trend")}</h3>

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { api } from "../../../api/legacy"; import { api } from "../../../api/legacy";
import type { DateRange } from "../DateRangePicker"; import type { DateRange } from "../DateRangePicker";
import { Bar } from "../charts/Bar"; import { Bar } from "../charts/Bar";
import { PieChart } from "../charts/recharts";
import { AreaShell } from "./AreaShell"; import { AreaShell } from "./AreaShell";
import { rangeQuery, formatCount, isInvalidRange } from "./areaShared"; 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 ?? []).map((s) => ({ label: s.severity, value: s.count, valueLabel: formatCount(s.count) })),
[data?.bySeverity], [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 isEmpty = !data || data.totalSignals === 0;
const hasStatusPie = !isEmpty && statusPieData.some((datum) => datum.value > 0);
return ( return (
<AreaShell <AreaShell
@@ -120,6 +133,13 @@ export function SignalsArea({ range }: { range: DateRange }) {
</div> </div>
</div> </div>
{hasStatusPie ? (
<div className="cc-area-section" data-testid="cc-signals-pie">
<h3 className="cc-area-section-title">{t("commandCenter.signals.statusShare", "Signal status share")}</h3>
<PieChart data={statusPieData} ariaLabel={t("commandCenter.signals.statusShare", "Signal status share")} />
</div>
) : null}
<div className="cc-area-section"> <div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.signals.bySource", "By source")}</h3> <h3 className="cc-area-section-title">{t("commandCenter.signals.bySource", "By source")}</h3>
<Bar data={sourceBars} ariaLabel={t("commandCenter.signals.bySource", "By source")} /> <Bar data={sourceBars} ariaLabel={t("commandCenter.signals.bySource", "By source")} />

View File

@@ -12,6 +12,7 @@ import {
import { Bar, type BarDatum } from "../charts/Bar"; import { Bar, type BarDatum } from "../charts/Bar";
import { RadialGauge } from "../charts/RadialGauge"; import { RadialGauge } from "../charts/RadialGauge";
import { Sparkline } from "../charts/Sparkline"; import { Sparkline } from "../charts/Sparkline";
import { LineChart, PieChart } from "../charts/recharts";
import { AreaShell } from "./AreaShell"; import { AreaShell } from "./AreaShell";
import { formatCount } from "./areaShared"; import { formatCount } from "./areaShared";
import "./SystemStatsArea.css"; 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; 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) })); return labels.map((label) => ({ label, value: byColumn[label] ?? 0, valueLabel: formatCount(byColumn[label] ?? 0) }));
}, [taskStats?.byColumn]); }, [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<BarDatum[]>(() => { const agentBarData = useMemo<BarDatum[]>(() => {
const agents = taskStats?.agents; const agents = taskStats?.agents;
@@ -312,6 +334,12 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
<div className="cc-area-section"> <div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.system.trendsTitle", "Live trends")}</h3> <h3 className="cc-area-section-title">{t("commandCenter.system.trendsTitle", "Live trends")}</h3>
{hasResourceTrend ? (
<div className="card cc-stat-card" data-testid="cc-system-line">
<div className="cc-stat-label">{t("commandCenter.system.resourceTrend", "Resource trend")}</div>
<LineChart series={resourceLineSeries} ariaLabel={t("commandCenter.system.resourceTrend", "Resource trend")} />
</div>
) : null}
<div className="cc-stat-grid"> <div className="cc-stat-grid">
<div className="card cc-stat-card" data-testid="cc-system-cpu-trend"> <div className="card cc-stat-card" data-testid="cc-system-cpu-trend">
<div className="cc-stat-label">{t("commandCenter.system.cpuTrend", "CPU over time")}</div> <div className="cc-stat-label">{t("commandCenter.system.cpuTrend", "CPU over time")}</div>
@@ -331,6 +359,12 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
<div className="cc-area-section"> <div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.system.workloadTitle", "Workload")}</h3> <h3 className="cc-area-section-title">{t("commandCenter.system.workloadTitle", "Workload")}</h3>
<div className="cc-system-chart-grid"> <div className="cc-system-chart-grid">
{hasTaskPie ? (
<div className="card cc-stat-card" data-testid="cc-system-pie">
<div className="cc-stat-label">{t("commandCenter.system.taskShare", "Task distribution")}</div>
<PieChart data={taskPieData} ariaLabel={t("commandCenter.system.taskShare", "Task distribution")} />
</div>
) : null}
<div className="card cc-stat-card" data-testid="cc-system-tasks-bar"> <div className="card cc-stat-card" data-testid="cc-system-tasks-bar">
<div className="cc-stat-label">{t("systemStats.sectionTasks", "Tasks")}</div> <div className="cc-stat-label">{t("systemStats.sectionTasks", "Tasks")}</div>
<Bar data={taskBarData} ariaLabel={t("systemStats.sectionTasksAriaLabel", "Task stats")} /> <Bar data={taskBarData} ariaLabel={t("systemStats.sectionTasksAriaLabel", "Task stats")} />

View File

@@ -8,6 +8,7 @@ import type { CostResult, TeamAgentSummary, TeamAnalytics } from "@fusion/core";
import type { DateRange } from "../DateRangePicker"; import type { DateRange } from "../DateRangePicker";
import { Bar, type BarDatum } from "../charts/Bar"; import { Bar, type BarDatum } from "../charts/Bar";
import { Sparkline } from "../charts/Sparkline"; import { Sparkline } from "../charts/Sparkline";
import { PieChart } from "../charts/recharts";
import { AreaShell } from "./AreaShell"; import { AreaShell } from "./AreaShell";
import { useAnalyticsArea } from "./useAnalyticsArea"; import { useAnalyticsArea } from "./useAnalyticsArea";
import { formatCost, formatCount } from "./areaShared"; import { formatCost, formatCount } from "./areaShared";
@@ -120,6 +121,14 @@ export function TeamArea({ range }: { range: DateRange }) {
() => buildBarData(agents, (agent) => agent.tokens.totalTokens, unknownAgent), () => buildBarData(agents, (agent) => agent.tokens.totalTokens, unknownAgent),
[agents, 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( const completedBarData = useMemo(
() => buildBarData(agents, (agent) => agent.tasksCompleted, unknownAgent), () => buildBarData(agents, (agent) => agent.tasksCompleted, unknownAgent),
[agents, unknownAgent], [agents, unknownAgent],
@@ -178,6 +187,12 @@ export function TeamArea({ range }: { range: DateRange }) {
</div> </div>
<div className="cc-area-section cc-team-chart-grid"> <div className="cc-area-section cc-team-chart-grid">
{hasTokenChart ? (
<div className="cc-team-chart-panel" data-testid="cc-team-pie">
<h3 className="cc-area-section-title">{t("commandCenter.team.tokenShareByAgent", "Token share by agent")}</h3>
<PieChart data={tokenPieData} ariaLabel={t("commandCenter.team.tokenShareByAgent", "Token share by agent")} />
</div>
) : null}
<div className="cc-team-chart-panel" data-testid="cc-team-tokens-chart"> <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> <h3 className="cc-area-section-title">{t("commandCenter.team.tokensByAgent", "Tokens by agent")}</h3>
{hasTokenChart ? ( {hasTokenChart ? (

View File

@@ -19,6 +19,7 @@ import { ToolsArea } from "../ToolsArea";
import { ProductivityArea } from "../ProductivityArea"; import { ProductivityArea } from "../ProductivityArea";
import { GithubArea } from "../GithubArea"; import { GithubArea } from "../GithubArea";
import { SignalsArea } from "../SignalsArea"; import { SignalsArea } from "../SignalsArea";
import { TeamArea } from "../TeamArea";
import { ActivityArea } from "../ActivityArea"; import { ActivityArea } from "../ActivityArea";
import { EcosystemArea } from "../EcosystemArea"; import { EcosystemArea } from "../EcosystemArea";
import { useAnalyticsArea } from "../useAnalyticsArea"; 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() { function activityFixture() {
return { return {
from: "2026-06-08", 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(<TeamArea range={range7d} />);
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(<TeamArea range={range7d} />);
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(<TeamArea range={range7d} />);
await screen.findByTestId("cc-area-team-empty");
expect(screen.queryByTestId("cc-team-pie")).toBeNull();
empty.unmount();
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
const pending = render(<TeamArea range={range7d} />);
expect(screen.getByTestId("cc-area-team-loading")).toBeTruthy();
expect(screen.queryByTestId("cc-team-pie")).toBeNull();
pending.unmount();
apiMock.mockRejectedValueOnce(new Error("team failed"));
render(<TeamArea range={range7d} />);
await screen.findByTestId("cc-area-team-error");
expect(screen.queryByTestId("cc-team-pie")).toBeNull();
});
});
describe("EcosystemArea", () => { 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()); apiMock.mockResolvedValueOnce(tokenFixture());
const { unmount } = render(<EcosystemArea range={range7d} />); render(<EcosystemArea range={range7d} />);
await screen.findByTestId("cc-area-ecosystem"); await screen.findByTestId("cc-area-ecosystem");
expect(screen.getByRole("list", { name: "Tasks per model" })).toBeTruthy(); 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"); expect(screen.getByTestId("cc-area-ecosystem").textContent).not.toContain("NaN");
unmount(); });
apiMock.mockResolvedValueOnce({ ...tokenFixture(), groups: [], totals: { ...tokenFixture().totals, totalTokens: 0, nTasks: 0 } }); it("renders empty, loading, and error states without ecosystem chart shells", async () => {
render(<EcosystemArea range={range7d} />); apiMock.mockResolvedValueOnce({ ...tokenFixture(), groups: [], series: [], totals: { ...tokenFixture().totals, totalTokens: 0, nTasks: 0 } });
const empty = render(<EcosystemArea range={range7d} />);
await screen.findByTestId("cc-area-ecosystem-empty"); await screen.findByTestId("cc-area-ecosystem-empty");
expect(screen.queryByRole("list", { name: "Tasks per model" })).toBeNull(); 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(<EcosystemArea range={range7d} />);
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(<EcosystemArea range={range7d} />);
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(<EcosystemArea range={range7d} />);
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-filed").textContent).toContain("5");
expect(screen.getByTestId("cc-github-fixed").textContent).toContain("3"); expect(screen.getByTestId("cc-github-fixed").textContent).toContain("3");
expect(screen.getByTestId("cc-github-net").textContent).toContain("2"); 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.getByTestId("cc-github-daily-trend")).toBeTruthy();
expect(screen.getByRole("img", { name: "Filed" })).toBeTruthy(); expect(screen.getByRole("img", { name: "Filed" })).toBeTruthy();
expect(screen.getByRole("img", { name: "Fixed" })).toBeTruthy(); expect(screen.getByRole("img", { name: "Fixed" })).toBeTruthy();
@@ -753,6 +897,8 @@ describe("GithubArea", () => {
await screen.findByTestId("cc-area-github"); await screen.findByTestId("cc-area-github");
expect(screen.getByTestId("cc-area-github").textContent).toContain("No GitHub issue activity"); expect(screen.getByTestId("cc-area-github").textContent).toContain("No GitHub issue activity");
expect(screen.getByTestId("cc-github-backfill-button")).toBeTruthy(); 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-daily-trend")).toBeNull();
expect(screen.queryByTestId("cc-github-by-repo")).toBeNull(); expect(screen.queryByTestId("cc-github-by-repo")).toBeNull();
}); });
@@ -761,12 +907,16 @@ describe("GithubArea", () => {
apiMock.mockImplementationOnce(() => new Promise(() => undefined)); apiMock.mockImplementationOnce(() => new Promise(() => undefined));
const { unmount } = render(<GithubArea range={range7d} />); const { unmount } = render(<GithubArea range={range7d} />);
expect(screen.getByTestId("cc-area-github-loading")).toBeTruthy(); expect(screen.getByTestId("cc-area-github-loading")).toBeTruthy();
expect(screen.queryByTestId("cc-github-pie")).toBeNull();
expect(screen.queryByTestId("cc-github-line")).toBeNull();
unmount(); unmount();
apiMock.mockRejectedValueOnce(new Error("github failed")); apiMock.mockRejectedValueOnce(new Error("github failed"));
render(<GithubArea range={range7d} />); render(<GithubArea range={range7d} />);
await screen.findByTestId("cc-area-github-error"); await screen.findByTestId("cc-area-github-error");
expect(screen.getByTestId("cc-area-github-error").textContent).toContain("github failed"); 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 () => { it("handles undefined chart arrays and zero values without NaN output", async () => {
@@ -774,11 +924,31 @@ describe("GithubArea", () => {
render(<GithubArea range={range7d} />); render(<GithubArea range={range7d} />);
await screen.findByTestId("cc-area-github"); 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-daily-trend")).toBeNull();
expect(screen.queryByTestId("cc-github-by-repo")).toBeNull(); expect(screen.queryByTestId("cc-github-by-repo")).toBeNull();
expect(screen.getByTestId("cc-area-github").textContent).not.toContain("NaN"); 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(<GithubArea range={range7d} />);
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 () => { it("rejects an inverted custom range client-side without fetching", async () => {
render(<GithubArea range={customRange("2026-06-10", "2026-06-01")} />); render(<GithubArea range={customRange("2026-06-10", "2026-06-01")} />);
await waitFor(() => expect(apiMock).not.toHaveBeenCalled()); 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", () => { describe("SignalsArea", () => {
it("renders the empty state (not an error) when the signals endpoint is missing", async () => { 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)")); apiMock.mockRejectedValue(new Error("API returned HTML instead of JSON (404)"));
@@ -924,9 +1095,10 @@ describe("SignalsArea", () => {
await screen.findByTestId("cc-area-signals-empty"); await screen.findByTestId("cc-area-signals-empty");
// Must not surface the error UI. // Must not surface the error UI.
expect(screen.queryByTestId("cc-area-signals-error")).toBeNull(); 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({ apiMock.mockResolvedValue({
totalSignals: 8, totalSignals: 8,
open: 3, open: 3,
@@ -939,5 +1111,37 @@ describe("SignalsArea", () => {
await screen.findByTestId("cc-area-signals"); await screen.findByTestId("cc-area-signals");
expect(screen.getByTestId("cc-signals-total").textContent).toContain("8"); expect(screen.getByTestId("cc-signals-total").textContent).toContain("8");
expect(screen.getByTestId("cc-signals-mttr").textContent).toContain("42"); 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(<SignalsArea range={range7d} />);
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(<SignalsArea range={range7d} />);
await screen.findByTestId("cc-area-signals-empty");
expect(screen.queryByTestId("cc-signals-pie")).toBeNull();
}); });
}); });