+
+
{t("commandCenter.activity.agentsPerDay", "Active agents / day")}
+
+
+
+
{t("commandCenter.activity.nodesPerDay", "Active nodes / day")}
-
+
+
+
+
+
{t("commandCenter.activity.throughputPerDay", "Throughput / day")}
+
);
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 77dfd6e1e3..c0988e464b 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
@@ -2,7 +2,7 @@
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.
*/
-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";
// Mock the api() helper so the areas fetch deterministic fixtures.
@@ -15,6 +15,7 @@ import { TokensArea } from "../TokensArea";
import { ToolsArea } from "../ToolsArea";
import { ProductivityArea } from "../ProductivityArea";
import { SignalsArea } from "../SignalsArea";
+import { ActivityArea } from "../ActivityArea";
import type { DateRange } from "../DateRangePicker";
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(() => {
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(
);
+
+ 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(
);
+
+ 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(
);
+
+ 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(
);
+
+ await act(async () => {
+ vi.advanceTimersByTime(30_000);
+ await Promise.resolve();
+ });
+ expect(apiMock).not.toHaveBeenCalled();
+ });
+});
+
describe("TokensArea", () => {
it("shows per-model totals + cost and renders rows", async () => {
apiMock.mockResolvedValue(tokenFixture());
diff --git a/packages/dashboard/app/components/command-center/charts/LineChart.tsx b/packages/dashboard/app/components/command-center/charts/LineChart.tsx
new file mode 100644
index 0000000000..2837f26fc9
--- /dev/null
+++ b/packages/dashboard/app/components/command-center/charts/LineChart.tsx
@@ -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 (
+
+ );
+}
diff --git a/packages/dashboard/app/components/command-center/charts/charts.css b/packages/dashboard/app/components/command-center/charts/charts.css
index 0e2f551b3c..26f6e9efa5 100644
--- a/packages/dashboard/app/components/command-center/charts/charts.css
+++ b/packages/dashboard/app/components/command-center/charts/charts.css
@@ -118,6 +118,68 @@ Chart labels and legends must use --text-muted so command-center CSS stays align
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 ---- */
.cc-radial-gauge {
display: grid;