From 662a09b6313596af7db35861ced1f545a4819eb6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 18 Jun 2026 14:51:47 -0700 Subject: [PATCH] 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 --- ...656-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 ++++++++-- .../areas/__tests__/areas.test.tsx | 115 +++++++++++++++++- .../command-center/charts/LineChart.tsx | 108 ++++++++++++++++ .../command-center/charts/charts.css | 62 ++++++++++ 7 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 .changeset/fn-6656-command-center-activity-line-charts.md create mode 100644 packages/dashboard/app/components/command-center/charts/LineChart.tsx diff --git a/.changeset/fn-6656-command-center-activity-line-charts.md b/.changeset/fn-6656-command-center-activity-line-charts.md new file mode 100644 index 0000000000..d32d8cfd9b --- /dev/null +++ b/.changeset/fn-6656-command-center-activity-line-charts.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index fbec99aa89..8a2b6203a9 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. - **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. -- **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. - **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. diff --git a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx index ec4409de46..e20fdd524b 100644 --- a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx @@ -7,6 +7,7 @@ import { StackedBar } from "../charts/StackedBar"; import { Sparkline } from "../charts/Sparkline"; import { Funnel } from "../charts/Funnel"; import { RadialGauge } from "../charts/RadialGauge"; +import { LineChart } from "../charts/LineChart"; function widthOf(el: HTMLElement): string { return el.style.width; @@ -100,6 +101,46 @@ describe("Sparkline", () => { }); }); +describe("LineChart", () => { + it("renders a populated finite SVG line with an accessible label", () => { + render(); + + 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(); + + 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(); + + 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(); + + 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", () => { it("renders the percentage for a valid ratio with an accessible label", () => { render(); diff --git a/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx b/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx index e29bc49e3a..b3694e0b2d 100644 --- a/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx @@ -1,30 +1,49 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { ActivityAnalytics } from "@fusion/core"; import type { DateRange } from "../DateRangePicker"; -import { Sparkline } from "../charts/Sparkline"; +import { LineChart } from "../charts/LineChart"; import { AreaShell } from "./AreaShell"; 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 - * the range, plus per-day sparklines for messages and active nodes. + * FNXC:CommandCenter 2026-06-18-14:29: + * 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 }) { const { t } = useTranslation("app"); - const { data, isLoading, error } = useAnalyticsArea("/command-center/activity", range); + const { data, isLoading, error, reload } = useAnalyticsArea("/command-center/activity", range); const daily = useMemo(() => data?.daily ?? [], [data?.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 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 = !data || (data.sessions === 0 && data.messages === 0 && data.activeNodes === 0 && data.activeAgents === 0); return ( - +

{t("commandCenter.activity.summaryTitle", "Summary")}

@@ -52,17 +71,36 @@ export function ActivityArea({ range }: { range: DateRange }) {
-
+

{t("commandCenter.activity.messagesPerDay", "Messages / day")}

-
-
+
+

{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 ( + + {series.map((entry, seriesIndex) => { + const points = pointsFor(entry.values, computedMax); + const pointString = pointsAttribute(points); + return ( + + {points.length > 1 ? ( + + ) : null} + {points.map((point, pointIndex) => ( + + ))} + + ); + })} + + ); +} 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;