diff --git a/.changeset/fn-6723-command-center-activity-charts.md b/.changeset/fn-6723-command-center-activity-charts.md new file mode 100644 index 0000000000..b9d5318d6f --- /dev/null +++ b/.changeset/fn-6723-command-center-activity-charts.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix Command Center Activity trend charts so mixed-unit agent/activity series stay visually legible instead of being flattened by high-volume message counts. 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 65cdb4aca1..c7989f1fc4 100644 --- a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx @@ -289,6 +289,14 @@ describe("LineChart", () => { expectLineChartPointsInsideViewBox(chart); }); + it("coerces non-finite and negative values without leaking invalid SVG geometry", () => { + render(); + + const chart = screen.getByRole("img", { name: "invalid trend" }); + expect(chart.outerHTML).not.toMatch(/NaN|Infinity/); + expectLineChartPointsInsideViewBox(chart); + }); + it("renders an empty series as an empty valid SVG without throwing", () => { render(); diff --git a/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx b/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx index f32213f65b..6958500872 100644 --- a/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx @@ -123,9 +123,14 @@ export function ActivityArea({ range }: { range: DateRange }) { {daily.length > 0 ? (

{t("commandCenter.activity.rechartsLine", "Activity trend")}

+ {/* + FNXC:CommandCenterCharts 2026-06-19-07:58: + The Activity trend combines message volume with low-count agent/run series. Opt into per-series normalization so agent activity remains visible instead of being flattened by message-scale values. + */}
) : null} 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 8d54a55bee..cb9e206dad 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 @@ -201,6 +201,22 @@ function expectRechartsWrapperWithin(testId: string, label: string): void { expect(chart.outerHTML).not.toMatch(/NaN|Infinity/); } +function expectBarFillsFinite(testId: string): void { + const section = screen.getByTestId(testId); + for (const fill of Array.from(section.querySelectorAll(".cc-bar-fill"))) { + expect(fill.style.width).toMatch(/^\d+(?:\.\d+)?%$/); + expect(fill.style.width).not.toMatch(/NaN|Infinity/); + } +} + +function expectSparklineHeightsFinite(testId: string): void { + const section = screen.getByTestId(testId); + for (const bar of Array.from(section.querySelectorAll(".cc-sparkline-bar"))) { + expect(bar.style.height).toMatch(/^\d+(?:\.\d+)?%$/); + expect(bar.style.height).not.toMatch(/NaN|Infinity/); + } +} + function expectSvgLinePointsInsideViewBox(testId: string, label: string): void { const section = screen.getByTestId(testId); const chart = within(section).getByRole("img", { name: label }); @@ -306,12 +322,35 @@ describe("ActivityArea", () => { expect(screen.getByRole("img", { name: "Agent runs / day" })).toBeTruthy(); expect(screen.getByTestId("cc-activity-line-throughput")).toBeTruthy(); expectRechartsWrapperWithin("cc-activity-line", "Activity trend"); + expect(screen.getByRole("img", { name: "Activity trend" })).toHaveAttribute("data-scale-mode", "series"); expectRechartsWrapperWithin("cc-activity-pie", "Agent run outcome share"); expectSvgLinePointsInsideViewBox("cc-activity-line-messages", "Messages / day"); expectSvgLinePointsInsideViewBox("cc-activity-line-agents", "Active agents / day"); expectSvgLinePointsInsideViewBox("cc-activity-line-nodes", "Active nodes / day"); expectSvgLinePointsInsideViewBox("cc-activity-line-throughput", "Throughput / day"); expect(within(screen.getByTestId("cc-activity-agent-runs-sparkline")).getByRole("img", { name: "Agent runs / day" }).classList).toContain("cc-sparkline"); + expectSparklineHeightsFinite("cc-activity-agent-runs-sparkline"); + }); + + it("opts only the mixed-unit activity trend into per-series scaling", async () => { + apiMock.mockResolvedValue({ + ...activityFixture(), + messages: 3_300, + activeAgents: 2, + agentRuns: { total: 9, active: 1, completed: 7, failed: 1 }, + daily: [ + { day: "2026-06-08", messages: 1_000, activeNodes: 20, activeAgents: 1, agentRuns: 2 }, + { day: "2026-06-09", messages: 1_200, activeNodes: 22, activeAgents: 2, agentRuns: 4 }, + { day: "2026-06-10", messages: 1_100, activeNodes: 21, activeAgents: 1, agentRuns: 3 }, + ], + }); + render(); + + await screen.findByTestId("cc-area-activity"); + expect(screen.getByRole("img", { name: "Activity trend" })).toHaveAttribute("data-scale-mode", "series"); + expectSvgLinePointsInsideViewBox("cc-activity-line-agents", "Active agents / day"); + expectSvgLinePointsInsideViewBox("cc-activity-line-throughput", "Throughput / day"); + expectSparklineHeightsFinite("cc-activity-agent-runs-sparkline"); }); it("renders zero agent-run cards when counts are zero and other activity exists", async () => { @@ -813,6 +852,9 @@ describe("TeamArea", () => { expect(within(screen.getByTestId("cc-team-tokens-chart")).getByRole("list", { name: "Tokens by agent" }).classList).toContain("cc-bar-chart"); expect(within(screen.getByTestId("cc-team-completed-chart")).getByRole("list", { name: "Tasks done by agent" }).classList).toContain("cc-bar-chart"); expect(within(screen.getByTestId("cc-team-spread-chart")).getByRole("img", { name: "Team spread" }).classList).toContain("cc-sparkline"); + expectBarFillsFinite("cc-team-tokens-chart"); + expectBarFillsFinite("cc-team-completed-chart"); + expectSparklineHeightsFinite("cc-team-spread-chart"); }); it("keeps the team pie safe for single-item and non-finite data", async () => { diff --git a/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx b/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx index 4d15438ce4..c4a0ff197c 100644 --- a/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx +++ b/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx @@ -22,12 +22,18 @@ export interface LineChartProps { width?: number | string; height?: number | string; emptyLabel?: string; + /** + * `shared` keeps comparable values on one absolute axis. `series` normalizes each series to its own max so mixed-unit trends remain legible. + */ + scaleMode?: "shared" | "series"; } interface SanitizedLineChartSeries { label: string; dataKey: string; values: number[]; + plotKey: string; + plotValues: number[]; } type LineChartPoint = { index: number } & Record; @@ -45,13 +51,26 @@ function sanitizeLineValue(value: number): number { return Number.isFinite(value) && value > 0 ? value : 0; } -function sanitizeSeries(series: LineChartSeries[]): SanitizedLineChartSeries[] { +function normalizeSeriesValues(values: number[]): number[] { + const max = values.reduce((largest, value) => (value > largest ? value : largest), 0); + if (max <= 0) { + return values.map(() => 0); + } + return values.map((value) => (value / max) * 100); +} + +function sanitizeSeries(series: LineChartSeries[], scaleMode: LineChartProps["scaleMode"]): SanitizedLineChartSeries[] { return series - .map((entry, index) => ({ - label: entry.label, - dataKey: `series${index}`, - values: entry.values.map(sanitizeLineValue), - })) + .map((entry, index) => { + const values = entry.values.map(sanitizeLineValue); + return { + label: entry.label, + dataKey: `series${index}`, + values, + plotKey: scaleMode === "series" ? `series${index}Normalized` : `series${index}`, + plotValues: scaleMode === "series" ? normalizeSeriesValues(values) : values, + }; + }) .filter((entry) => entry.values.length > 0); } @@ -61,6 +80,7 @@ function lineChartData(series: SanitizedLineChartSeries[]): LineChartPoint[] { const point: LineChartPoint = { index: index + 1 }; for (const entry of series) { point[entry.dataKey] = entry.values[index] ?? 0; + point[entry.plotKey] = entry.plotValues[index] ?? 0; } return point; }); @@ -84,10 +104,13 @@ function responsiveDimension(value: number | string | undefined): ResponsiveDime * * FNXC:CommandCenterCharts 2026-06-19-05:24: * Recharts ResponsiveContainer renders blank when its parent has no measurable block-size. Import the shared chart CSS here so every Command Center surface using this wrapper gets the token-sized default parent height even if it does not also render a hand-rolled chart primitive. + * + * FNXC:CommandCenterCharts 2026-06-19-07:58: + * FN-6723 found the Activity trend still looked broken after the height/clipping fix because mixed-unit series shared one absolute axis; normalize only callers that opt into `scaleMode="series"` so low-count agent lines stay legible without changing comparable-unit charts elsewhere. */ -export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data" }: LineChartProps) { +export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data", scaleMode = "shared" }: LineChartProps) { const theme = getCommandCenterChartTheme(); - const chartSeries = sanitizeSeries(series); + const chartSeries = sanitizeSeries(series, scaleMode); const chartData = lineChartData(chartSeries); if (chartSeries.length === 0 || chartData.length === 0) { @@ -99,12 +122,17 @@ export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No c } return ( -
+
- + `${value}%` : undefined} + /> ); + return render(); +} + +function ySpanForDots(seriesName: string): number { + const values = Array.from(document.querySelectorAll(`.recharts-line-dot[name="${seriesName}"]`)).map((dot) => Number(dot.getAttribute("cy"))); + expect(values.length).toBeGreaterThan(1); + return Math.max(...values) - Math.min(...values); } afterEach(() => { @@ -56,6 +62,30 @@ describe("recharts LineChart", () => { expect(chartHtml("zero line")).not.toMatch(/NaN|Infinity/); }); + it("normalizes mixed-unit series so low-count activity lines are not flattened", () => { + renderChart([ + { label: "Messages", values: [1_000, 1_200, 1_100] }, + { label: "Active agents", values: [1, 2, 1] }, + { label: "Agent runs", values: [2, 4, 3] }, + ], "activity trend", "series"); + + const chart = screen.getByRole("img", { name: "activity trend" }); + expect(chart).toHaveAttribute("data-scale-mode", "series"); + expect(ySpanForDots("Active agents")).toBeGreaterThan(40); + expect(ySpanForDots("Agent runs")).toBeGreaterThan(40); + expect(chartHtml("activity trend")).not.toMatch(/NaN|Infinity/); + }); + + it("keeps shared absolute scale as the default for comparable series", () => { + renderChart([ + { label: "Filed", values: [3, 6, 9] }, + { label: "Fixed", values: [2, 4, 8] }, + ], "issue flow"); + + expect(screen.getByRole("img", { name: "issue flow" })).toHaveAttribute("data-scale-mode", "shared"); + expect(chartHtml("issue flow")).not.toContain("% of series"); + }); + it("coerces non-finite and negative values without leaking invalid output", () => { expect(() => renderChart([{ label: "Invalid", values: [Number.NaN, Number.POSITIVE_INFINITY, -4, 2] }], "invalid line")).not.toThrow();