FN-6656: add live activity trend charts

Add live Command Center activity line charts with safe animated rendering.

- Replace activity sparklines with reusable SVG line charts for messages, agents, nodes, and throughput trends.
- Refresh activity analytics on a bounded interval while preserving existing data during revalidation.
- Add zero/NaN-safe chart geometry, reduced-motion styling, tests, docs, and a patch changeset.

Files changed:
 .../fn-6656-command-center-activity-line-charts.md |   5 +
 docs/dashboard-guide.md                            |   2 +-
 .../command-center/__tests__/charts.test.tsx       |  41 ++++++++
 .../command-center/areas/ActivityArea.tsx          |  62 ++++++++---
 .../command-center/areas/__tests__/areas.test.tsx  | 115 ++++++++++++++++++++-
 .../components/command-center/charts/LineChart.tsx | 108 +++++++++++++++++++
 .../components/command-center/charts/charts.css    |  62 +++++++++++
 7 files changed, 381 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-6656

Fusion-Task-Lineage: 301de13a-91c0-425d-b743-8688fed48d41
This commit is contained in:
gsxdsm
2026-06-18 14:51:47 -07:00
parent 2367918fdf
commit 662a09b631
7 changed files with 381 additions and 14 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add live animated Command Center Activity line charts for messages, active agents, active nodes, and combined throughput, backed by a reusable zero/NaN-safe LineChart primitive.

View File

@@ -666,7 +666,7 @@ Features:
- **Overview** summarizes token usage/cost, autonomy, active nodes, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. It also shows a graph-rich software-factory snapshot with tokens-by-model, tool-category, and daily activity trend charts that reuse the already-loaded tokens, tools, and activity analytics; no extra endpoint is called. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. - **Overview** summarizes token usage/cost, autonomy, active nodes, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. It also shows a graph-rich software-factory snapshot with tokens-by-model, tool-category, and daily activity trend charts that reuse the already-loaded tokens, tools, and activity analytics; no extra endpoint is called. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. - **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage.
- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. - **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories.
- **Activity** tracks sessions, messages, active nodes, active agents, stickiness, and daily activity sparklines. - **Activity** tracks sessions, messages, active nodes, active agents, and stickiness, then renders live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`). These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users.
- **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language. - **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language.
- **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.
- **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.

View File

@@ -7,6 +7,7 @@ import { StackedBar } from "../charts/StackedBar";
import { Sparkline } from "../charts/Sparkline"; import { Sparkline } from "../charts/Sparkline";
import { Funnel } from "../charts/Funnel"; import { Funnel } from "../charts/Funnel";
import { RadialGauge } from "../charts/RadialGauge"; import { RadialGauge } from "../charts/RadialGauge";
import { LineChart } from "../charts/LineChart";
function widthOf(el: HTMLElement): string { function widthOf(el: HTMLElement): string {
return el.style.width; return el.style.width;
@@ -100,6 +101,46 @@ describe("Sparkline", () => {
}); });
}); });
describe("LineChart", () => {
it("renders a populated finite SVG line with an accessible label", () => {
render(<LineChart ariaLabel="activity trend" series={[{ label: "messages", values: [2, 4, 1] }]} />);
const chart = screen.getByRole("img", { name: "activity trend" });
const line = chart.querySelector(".cc-line-chart-path");
const points = line?.getAttribute("points") ?? "";
expect(line).toBeTruthy();
expect(points).not.toBe("");
expect(points).not.toMatch(/NaN|Infinity/);
});
it("renders all-zero values as valid baseline geometry without NaN", () => {
render(<LineChart ariaLabel="zero trend" series={[{ label: "zero", values: [0, 0] }]} />);
const points = screen.getByRole("img", { name: "zero trend" }).querySelector(".cc-line-chart-path")?.getAttribute("points") ?? "";
expect(points).toBe("0,100 100,100");
expect(points).not.toMatch(/NaN|Infinity/);
});
it("renders a single-point series as a visible point without a malformed line", () => {
render(<LineChart ariaLabel="single trend" series={[{ label: "single", values: [5] }]} />);
const chart = screen.getByRole("img", { name: "single trend" });
expect(chart.querySelector(".cc-line-chart-path")).toBeNull();
const point = chart.querySelector(".cc-line-chart-point");
expect(point?.getAttribute("cx")).toBe("50");
expect(point?.getAttribute("cy")).not.toMatch(/NaN|Infinity/);
});
it("renders an empty series as an empty valid SVG without throwing", () => {
render(<LineChart ariaLabel="empty line" series={[{ label: "empty", values: [] }]} />);
const chart = screen.getByRole("img", { name: "empty line" });
expect(chart.querySelector(".cc-line-chart-path")).toBeNull();
expect(chart.querySelector(".cc-line-chart-point")).toBeNull();
});
});
describe("RadialGauge", () => { describe("RadialGauge", () => {
it("renders the percentage for a valid ratio with an accessible label", () => { it("renders the percentage for a valid ratio with an accessible label", () => {
render(<RadialGauge value={0.73} label="Completion" ariaLabel="Completion rate" />); render(<RadialGauge value={0.73} label="Completion" ariaLabel="Completion rate" />);

View File

@@ -1,30 +1,49 @@
import { useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ActivityAnalytics } from "@fusion/core"; import type { ActivityAnalytics } from "@fusion/core";
import type { DateRange } from "../DateRangePicker"; import type { DateRange } from "../DateRangePicker";
import { Sparkline } from "../charts/Sparkline"; import { LineChart } from "../charts/LineChart";
import { AreaShell } from "./AreaShell"; import { AreaShell } from "./AreaShell";
import { useAnalyticsArea } from "./useAnalyticsArea"; import { useAnalyticsArea } from "./useAnalyticsArea";
import { formatCount } from "./areaShared"; import { formatCount, isInvalidRange } from "./areaShared";
const ACTIVITY_LIVE_REFRESH_MS = 15_000;
/** /**
* Activity area: sessions / messages / active-nodes / stickiness (DAU/MAU) over * FNXC:CommandCenter 2026-06-18-14:29:
* the range, plus per-day sparklines for messages and active nodes. * Activity metrics surface as live, animated line charts auto-refreshed via reload() on a bounded interval; motion is decorative and reduced-motion-safe, uses the existing activity endpoint, and keeps prior data visible during polling revalidation.
*/ */
export function ActivityArea({ range }: { range: DateRange }) { export function ActivityArea({ range }: { range: DateRange }) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { data, isLoading, error } = useAnalyticsArea<ActivityAnalytics>("/command-center/activity", range); const { data, isLoading, error, reload } = useAnalyticsArea<ActivityAnalytics>("/command-center/activity", range);
const daily = useMemo(() => data?.daily ?? [], [data?.daily]); const daily = useMemo(() => data?.daily ?? [], [data?.daily]);
const messagesSeries = useMemo(() => daily.map((d) => d.messages), [daily]); const messagesSeries = useMemo(() => daily.map((d) => d.messages), [daily]);
const agentsSeries = useMemo(() => daily.map((d) => d.activeAgents), [daily]);
const nodesSeries = useMemo(() => daily.map((d) => d.activeNodes), [daily]); const nodesSeries = useMemo(() => daily.map((d) => d.activeNodes), [daily]);
const throughputSeries = useMemo(
() => daily.map((d) => d.messages + d.activeAgents + d.activeNodes),
[daily],
);
const invalidRange = isInvalidRange(range);
const isInitialLoading = isLoading && data === null;
useEffect(() => {
if (invalidRange) {
return undefined;
}
const interval = window.setInterval(() => {
reload();
}, ACTIVITY_LIVE_REFRESH_MS);
return () => window.clearInterval(interval);
}, [invalidRange, reload]);
const isEmpty = const isEmpty =
!data || !data ||
(data.sessions === 0 && data.messages === 0 && data.activeNodes === 0 && data.activeAgents === 0); (data.sessions === 0 && data.messages === 0 && data.activeNodes === 0 && data.activeAgents === 0);
return ( return (
<AreaShell testId="activity" isLoading={isLoading} error={error} isEmpty={isEmpty}> <AreaShell testId="activity" isLoading={isInitialLoading} error={error} isEmpty={isEmpty}>
<div className="cc-area-section"> <div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.activity.summaryTitle", "Summary")}</h3> <h3 className="cc-area-section-title">{t("commandCenter.activity.summaryTitle", "Summary")}</h3>
<div className="cc-stat-grid"> <div className="cc-stat-grid">
@@ -52,17 +71,36 @@ export function ActivityArea({ range }: { range: DateRange }) {
</div> </div>
</div> </div>
<div className="cc-area-section"> <div className="cc-area-section" data-testid="cc-activity-line-messages">
<h3 className="cc-area-section-title">{t("commandCenter.activity.messagesPerDay", "Messages / day")}</h3> <h3 className="cc-area-section-title">{t("commandCenter.activity.messagesPerDay", "Messages / day")}</h3>
<Sparkline <LineChart
values={messagesSeries} series={[{ label: t("commandCenter.activity.messages", "Messages"), values: messagesSeries }]}
ariaLabel={t("commandCenter.activity.messagesPerDay", "Messages / day")} ariaLabel={t("commandCenter.activity.messagesPerDay", "Messages / day")}
/> />
</div> </div>
<div className="cc-area-section"> <div className="cc-area-section" data-testid="cc-activity-line-agents">
<h3 className="cc-area-section-title">{t("commandCenter.activity.agentsPerDay", "Active agents / day")}</h3>
<LineChart
series={[{ label: t("commandCenter.activity.activeAgents", "Active agents"), values: agentsSeries }]}
ariaLabel={t("commandCenter.activity.agentsPerDay", "Active agents / day")}
/>
</div>
<div className="cc-area-section" data-testid="cc-activity-line-nodes">
<h3 className="cc-area-section-title">{t("commandCenter.activity.nodesPerDay", "Active nodes / day")}</h3> <h3 className="cc-area-section-title">{t("commandCenter.activity.nodesPerDay", "Active nodes / day")}</h3>
<Sparkline values={nodesSeries} ariaLabel={t("commandCenter.activity.nodesPerDay", "Active nodes / day")} /> <LineChart
series={[{ label: t("commandCenter.activity.activeNodes", "Active nodes"), values: nodesSeries }]}
ariaLabel={t("commandCenter.activity.nodesPerDay", "Active nodes / day")}
/>
</div>
<div className="cc-area-section" data-testid="cc-activity-line-throughput">
<h3 className="cc-area-section-title">{t("commandCenter.activity.throughputPerDay", "Throughput / day")}</h3>
<LineChart
series={[{ label: t("commandCenter.activity.throughput", "Throughput"), values: throughputSeries }]}
ariaLabel={t("commandCenter.activity.throughputPerDay", "Throughput / day")}
/>
</div> </div>
</AreaShell> </AreaShell>
); );

View File

@@ -2,7 +2,7 @@
FNXC:CommandCenter 2026-06-16-09:42: FNXC:CommandCenter 2026-06-16-09:42:
Command Center area component tests (PR #1683). Pin loading/error/unavailable-vs-zero rendering for each analytics area against mocked fixtures so the "—" sentinel and cost-unavailable contracts can't regress. Command Center area component tests (PR #1683). Pin loading/error/unavailable-vs-zero rendering for each analytics area against mocked fixtures so the "—" sentinel and cost-unavailable contracts can't regress.
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
// Mock the api() helper so the areas fetch deterministic fixtures. // Mock the api() helper so the areas fetch deterministic fixtures.
@@ -15,6 +15,7 @@ import { TokensArea } from "../TokensArea";
import { ToolsArea } from "../ToolsArea"; import { ToolsArea } from "../ToolsArea";
import { ProductivityArea } from "../ProductivityArea"; import { ProductivityArea } from "../ProductivityArea";
import { SignalsArea } from "../SignalsArea"; import { SignalsArea } from "../SignalsArea";
import { ActivityArea } from "../ActivityArea";
import type { DateRange } from "../DateRangePicker"; import type { DateRange } from "../DateRangePicker";
const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" }; const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" };
@@ -59,10 +60,122 @@ function tokenFixture() {
}; };
} }
function activityFixture() {
return {
from: "2026-06-08",
to: null,
sessions: 4,
messages: 12,
activeNodes: 3,
activeAgents: 2,
daily: [
{ day: "2026-06-08", messages: 2, activeNodes: 1, activeAgents: 1 },
{ day: "2026-06-09", messages: 4, activeNodes: 2, activeAgents: 1 },
{ day: "2026-06-10", messages: 6, activeNodes: 3, activeAgents: 2 },
],
stickiness: 0.5,
mttr: { value: null, unavailable: true, sampleCount: 0 },
monitor: {
mttr: { value: null, unavailable: true, sampleCount: 0 },
incidentsOpened: 0,
incidentsResolved: 0,
openIncidents: 0,
deployments: 0,
},
funnel: {
stages: [],
enteredInRange: 0,
doneInRange: 0,
completionRate: null,
throughputPerDay: 0,
rangeDays: 7,
},
};
}
beforeEach(() => { beforeEach(() => {
apiMock.mockReset(); apiMock.mockReset();
}); });
afterEach(() => {
vi.useRealTimers();
});
describe("ActivityArea", () => {
it("renders summary stats and the live line chart sections for populated daily activity", async () => {
apiMock.mockResolvedValue(activityFixture());
render(<ActivityArea range={range7d} />);
await screen.findByTestId("cc-area-activity");
expect(screen.getByTestId("cc-activity-sessions").textContent).toContain("4");
expect(screen.getByTestId("cc-activity-messages").textContent).toContain("12");
expect(screen.getByTestId("cc-activity-nodes").textContent).toContain("3");
expect(screen.getByTestId("cc-activity-agents").textContent).toContain("2");
expect(screen.getByTestId("cc-activity-stickiness").textContent).toContain("50%");
expect(screen.getByTestId("cc-activity-line-messages")).toBeTruthy();
expect(screen.getByTestId("cc-activity-line-agents")).toBeTruthy();
expect(screen.getByTestId("cc-activity-line-nodes")).toBeTruthy();
expect(screen.getByTestId("cc-activity-line-throughput")).toBeTruthy();
});
it("renders the empty state for zero activity without empty chart shells", async () => {
apiMock.mockResolvedValue({
...activityFixture(),
sessions: 0,
messages: 0,
activeNodes: 0,
activeAgents: 0,
daily: [],
stickiness: 0,
});
render(<ActivityArea range={range7d} />);
await screen.findByTestId("cc-area-activity-empty");
expect(screen.queryByTestId("cc-activity-line-messages")).toBeNull();
expect(screen.queryByTestId("cc-activity-line-agents")).toBeNull();
expect(screen.queryByTestId("cc-activity-line-nodes")).toBeNull();
expect(screen.queryByTestId("cc-activity-line-throughput")).toBeNull();
});
it("polls activity while mounted, keeps content during refresh, and clears the interval on unmount", async () => {
vi.useFakeTimers();
apiMock.mockResolvedValue(activityFixture());
const { unmount } = render(<ActivityArea range={range7d} />);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("cc-area-activity")).toBeTruthy();
expect(apiMock).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(15_000);
await Promise.resolve();
});
expect(apiMock).toHaveBeenCalledTimes(2);
expect(screen.getByTestId("cc-area-activity")).toBeTruthy();
expect(screen.queryByTestId("cc-area-activity-loading")).toBeNull();
unmount();
await act(async () => {
vi.advanceTimersByTime(15_000);
await Promise.resolve();
});
expect(apiMock).toHaveBeenCalledTimes(2);
});
it("does not poll or fetch for an inverted custom activity range", async () => {
vi.useFakeTimers();
render(<ActivityArea range={customRange("2026-06-10", "2026-06-01")} />);
await act(async () => {
vi.advanceTimersByTime(30_000);
await Promise.resolve();
});
expect(apiMock).not.toHaveBeenCalled();
});
});
describe("TokensArea", () => { describe("TokensArea", () => {
it("shows per-model totals + cost and renders rows", async () => { it("shows per-model totals + cost and renders rows", async () => {
apiMock.mockResolvedValue(tokenFixture()); apiMock.mockResolvedValue(tokenFixture());

View File

@@ -0,0 +1,108 @@
import "./charts.css";
export interface LineChartSeries {
label: string;
values: number[];
}
export interface LineChartProps {
/** One or more named time-series rendered against the same 0..max scale. */
series: LineChartSeries[];
/** Accessible label for the whole chart. */
ariaLabel?: string;
/** Max value mapped to full height. Defaults to the largest finite series value. */
max?: number;
}
const VIEWBOX_SIZE = 100;
const SINGLE_POINT_X = VIEWBOX_SIZE / 2;
const POINT_RADIUS = 1.8;
function safeHeightPercent(value: number, max: number): number {
if (!Number.isFinite(value) || value <= 0) {
return 0;
}
const denom = Number.isFinite(max) && max > 0 ? max : 1;
return Math.max(0, Math.min(VIEWBOX_SIZE, (value / denom) * VIEWBOX_SIZE));
}
function safeCoord(value: number): number {
return Number.isFinite(value) ? value : 0;
}
function pointFor(value: number, index: number, count: number, max: number): { x: number; y: number } {
const x = count <= 1 ? SINGLE_POINT_X : (index / (count - 1)) * VIEWBOX_SIZE;
const height = safeHeightPercent(value, max);
return {
x: safeCoord(x),
y: safeCoord(VIEWBOX_SIZE - height),
};
}
function pointsFor(values: number[], max: number): { x: number; y: number }[] {
return values.map((value, index) => pointFor(value, index, values.length, max));
}
function pointsAttribute(points: { x: number; y: number }[]): string {
return points.map((point) => `${point.x},${point.y}`).join(" ");
}
function computedMaxFor(series: LineChartSeries[], max?: number): number {
if (Number.isFinite(max) && max !== undefined && max > 0) {
return max;
}
return series.reduce((largest, next) => {
const seriesMax = next.values.reduce(
(innerLargest, value) => (Number.isFinite(value) && value > innerLargest ? value : innerLargest),
0,
);
return seriesMax > largest ? seriesMax : largest;
}, 0);
}
/**
* FNXC:CommandCenterCharts 2026-06-18-14:29:
* Command Center needed a true, zero/NaN-safe, reduced-motion-aware animated line chart for time-series metrics; reuse the Bar/Sparkline safe-height convention so malformed analytics values never leak NaN or Infinity into SVG geometry.
*/
export function LineChart({ series, ariaLabel, max }: LineChartProps) {
const computedMax = computedMaxFor(series, max);
return (
<svg
className="cc-line-chart"
role="img"
aria-label={ariaLabel}
viewBox={`0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`}
preserveAspectRatio="none"
>
{series.map((entry, seriesIndex) => {
const points = pointsFor(entry.values, computedMax);
const pointString = pointsAttribute(points);
return (
<g key={seriesIndex} className="cc-line-chart-series" aria-label={entry.label}>
{points.length > 1 ? (
<polyline
className="cc-line-chart-path"
points={pointString}
pathLength={VIEWBOX_SIZE}
vectorEffect="non-scaling-stroke"
aria-hidden="true"
/>
) : null}
{points.map((point, pointIndex) => (
<circle
key={pointIndex}
className="cc-line-chart-point"
cx={point.x}
cy={point.y}
r={POINT_RADIUS}
vectorEffect="non-scaling-stroke"
aria-hidden="true"
/>
))}
</g>
);
})}
</svg>
);
}

View File

@@ -118,6 +118,68 @@ Chart labels and legends must use --text-muted so command-center CSS stays align
transition: height var(--transition-normal); transition: height var(--transition-normal);
} }
/* ---- LineChart ---- */
/*
FNXC:CommandCenterStyling 2026-06-18-14:29:
Line-chart motion is decorative, token-timed, and disabled for reduced-motion users; sizing and stroke colors stay on design tokens so the Activity area remains readable across desktop and mobile without chart-specific hardcoded colors or lengths.
*/
.cc-line-chart {
display: block;
inline-size: 100%;
block-size: clamp(var(--space-16), 22vw, calc(var(--space-20) * 2));
aspect-ratio: 5 / 2;
color: var(--color-accent);
overflow: visible;
}
.cc-line-chart-series {
color: var(--color-accent);
}
.cc-line-chart-series:nth-child(2n) {
color: var(--color-success);
}
.cc-line-chart-series:nth-child(3n) {
color: var(--color-warning);
}
.cc-line-chart-path {
fill: none;
stroke: currentColor;
stroke-width: var(--border-width-thick, var(--border-width));
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: cc-line-chart-draw calc(var(--duration-slow) * 4) ease-out forwards;
}
.cc-line-chart-point {
fill: var(--surface-1);
stroke: currentColor;
stroke-width: var(--border-width-thick, var(--border-width));
}
@keyframes cc-line-chart-draw {
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cc-line-chart-path {
animation: none;
stroke-dashoffset: 0;
}
}
@media (max-width: 768px) {
.cc-line-chart {
block-size: clamp(var(--space-14), 34vw, calc(var(--space-20) + var(--space-12)));
}
}
/* ---- RadialGauge ---- */ /* ---- RadialGauge ---- */
.cc-radial-gauge { .cc-radial-gauge {
display: grid; display: grid;