FN-6650: add Overview analytics charts
Add graph-rich Overview visuals to the Command Center using existing analytics data. - Add tokens-by-model, tool-category, and daily activity chart cards to the Overview tab. - Style the chart section with responsive dashboard-token layouts and reduced-motion safeguards. - Cover populated, partial, edge-case, and mobile-scroll chart rendering in tests. - Document the Overview charts and add a patch changeset for the published CLI package. Files changed: .../fn-6650-command-center-overview-charts.md | 5 + docs/dashboard-guide.md | 2 +- .../components/command-center/CommandCenter.css | 111 +++++++++++++++++++++ .../components/command-center/CommandCenter.tsx | 73 +++++++++++++- .../__tests__/CommandCenter.mobile-scroll.test.tsx | 73 +++++++++++++- .../__tests__/CommandCenter.test.tsx | 58 +++++++++++ 6 files changed, 313 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-6650 Fusion-Task-Lineage: 227f5809-ed71-42fb-8847-b137dbef6ac7
This commit is contained in:
5
.changeset/fn-6650-command-center-overview-charts.md
Normal file
5
.changeset/fn-6650-command-center-overview-charts.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add attractive Command Center Overview charts for tokens by model, tool categories, and daily activity using existing analytics data.
|
||||
@@ -663,7 +663,7 @@ Navigation:
|
||||
|
||||
Features:
|
||||
- Global date-range picker in the header scopes the analytics tabs; **Mission Control** remains live rather than historical.
|
||||
- **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. 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.
|
||||
- **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.
|
||||
|
||||
@@ -238,6 +238,109 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:CommandCenterStyling 2026-06-18-00:00:
|
||||
Overview charts must use dashboard tokens only and keep motion decorative; animations use --duration-* values and are disabled for reduced-motion users so the graph-rich snapshot does not violate accessibility or the mobile scroll contract.
|
||||
*/
|
||||
.cc-overview-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.cc-overview-chart-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 22%, var(--border-subtle));
|
||||
background:
|
||||
linear-gradient(145deg, color-mix(in srgb, var(--color-accent) 10%, transparent), transparent),
|
||||
var(--surface-1);
|
||||
box-shadow: 0 0 var(--space-4) color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
overflow: hidden;
|
||||
animation: cc-overview-chart-rise var(--duration-normal) ease-out both;
|
||||
}
|
||||
|
||||
.cc-overview-chart-card--trend {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.cc-overview-chart-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--color-accent) 16%, transparent), transparent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
animation: cc-overview-chart-sheen calc(var(--duration-slow) * 7) ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cc-overview-chart-header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.cc-overview-chart-header p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.cc-overview-chart-card .cc-bar-chart,
|
||||
.cc-overview-chart-card .cc-sparkline {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cc-overview-chart-card .cc-sparkline {
|
||||
height: var(--space-16);
|
||||
}
|
||||
|
||||
.cc-overview-chart-card .cc-bar-fill,
|
||||
.cc-overview-chart-card .cc-sparkline-bar {
|
||||
box-shadow: 0 0 var(--space-2) color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
@keyframes cc-overview-chart-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--space-2));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cc-overview-chart-sheen {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
45%,
|
||||
55% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cc-overview-chart-card,
|
||||
.cc-overview-chart-card::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cc-live-strip {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -246,6 +349,14 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
|
||||
.cc-live-strip-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cc-overview-charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cc-overview-chart-card--trend {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- States ---- */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Gauge } from "lucide-react";
|
||||
import type { ActivityAnalytics, TokenAnalytics, ToolAnalytics } from "@fusion/core";
|
||||
@@ -12,6 +12,7 @@ import { EcosystemArea } from "./areas/EcosystemArea";
|
||||
import { SignalsArea } from "./areas/SignalsArea";
|
||||
import { MissionControlPanel } from "./MissionControlPanel";
|
||||
import { SdlcFunnel } from "./SdlcFunnel";
|
||||
import { Bar, type BarDatum } from "./charts/Bar";
|
||||
import { Sparkline } from "./charts/Sparkline";
|
||||
import { useAnalyticsArea } from "./areas/useAnalyticsArea";
|
||||
import { formatCost, formatCount, isInvalidRange, rangeQuery } from "./areas/areaShared";
|
||||
@@ -105,10 +106,38 @@ function OverviewTab({ range }: { range: DateRange }) {
|
||||
const tasksDone = activity.data?.funnel?.doneInRange ?? 0;
|
||||
const inProgressTasks = activity.data?.funnel?.stages.find((stage) => stage.stage === "in-progress")?.entered ?? 0;
|
||||
const uniqueModels = tokens.data?.groups?.length ?? 0;
|
||||
const tokensByModelData = useMemo<BarDatum[]>(
|
||||
() =>
|
||||
[...(tokens.data?.groups ?? [])]
|
||||
.sort((a, b) => b.totalTokens - a.totalTokens || (a.key ?? "").localeCompare(b.key ?? ""))
|
||||
.slice(0, 8)
|
||||
.map((g) => ({
|
||||
label: g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)"),
|
||||
value: g.totalTokens,
|
||||
valueLabel: formatCount(g.totalTokens),
|
||||
})),
|
||||
[tokens.data?.groups, t],
|
||||
);
|
||||
const toolCategoryData = useMemo<BarDatum[]>(
|
||||
() =>
|
||||
[...(tools.data?.byCategory ?? [])]
|
||||
.sort((a, b) => b.count - a.count || a.category.localeCompare(b.category))
|
||||
.map((c) => ({
|
||||
label: c.category,
|
||||
value: c.count,
|
||||
valueLabel: formatCount(c.count),
|
||||
})),
|
||||
[tools.data?.byCategory],
|
||||
);
|
||||
const dailyActivityValues = useMemo(
|
||||
() => (activity.data?.daily ?? []).map((day) => day.messages + day.activeAgents),
|
||||
[activity.data?.daily],
|
||||
);
|
||||
const activityTrendValues =
|
||||
activity.data && activity.data.daily.length > 0
|
||||
? activity.data.daily.map((day) => day.messages + day.activeAgents)
|
||||
dailyActivityValues.length > 0
|
||||
? dailyActivityValues
|
||||
: [activity.data?.sessions ?? 0, activity.data?.messages ?? 0, activeAgents, activeNodes, tasksDone];
|
||||
const hasOverviewChartData = tokensByModelData.length > 0 || toolCategoryData.length > 0 || dailyActivityValues.length > 0;
|
||||
const hasActivityData =
|
||||
(activity.data?.sessions ?? 0) > 0 ||
|
||||
(activity.data?.messages ?? 0) > 0 ||
|
||||
@@ -237,6 +266,44 @@ function OverviewTab({ range }: { range: DateRange }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasOverviewChartData ? (
|
||||
/*
|
||||
FNXC:CommandCenter 2026-06-18-00:00:
|
||||
Overview must present an attractive, graph-rich software-factory snapshot reusing existing tokens/tools/activity analytics with no new endpoint, additive to the live strip and funnel.
|
||||
*/
|
||||
<section className="cc-overview-charts" data-testid="command-center-overview-charts">
|
||||
{tokensByModelData.length > 0 ? (
|
||||
<div className="card cc-overview-chart-card" data-testid="command-center-overview-chart-tokens">
|
||||
<div className="cc-overview-chart-header">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.overview.tokensByModel", "Tokens by model")}</h3>
|
||||
<p>{t("commandCenter.overview.tokensByModelHint", "Top model token consumers in this range")}</p>
|
||||
</div>
|
||||
<Bar data={tokensByModelData} ariaLabel={t("commandCenter.overview.tokensByModel", "Tokens by model")} />
|
||||
</div>
|
||||
) : null}
|
||||
{toolCategoryData.length > 0 ? (
|
||||
<div className="card cc-overview-chart-card" data-testid="command-center-overview-chart-tools">
|
||||
<div className="cc-overview-chart-header">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.overview.toolCategories", "Tool categories")}</h3>
|
||||
<p>{t("commandCenter.overview.toolCategoriesHint", "Autonomous work grouped by tool family")}</p>
|
||||
</div>
|
||||
<Bar data={toolCategoryData} ariaLabel={t("commandCenter.overview.toolCategories", "Tool categories")} />
|
||||
</div>
|
||||
) : null}
|
||||
{dailyActivityValues.length > 0 ? (
|
||||
<div className="card cc-overview-chart-card cc-overview-chart-card--trend" data-testid="command-center-overview-chart-activity">
|
||||
<div className="cc-overview-chart-header">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.overview.dailyActivity", "Daily activity trend")}</h3>
|
||||
<p>{t("commandCenter.overview.dailyActivityHint", "Messages plus active agents per day")}</p>
|
||||
</div>
|
||||
<Sparkline
|
||||
values={dailyActivityValues}
|
||||
ariaLabel={t("commandCenter.overview.dailyActivityAria", "Daily activity trend")}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
{throughputSection}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,11 +54,65 @@ function emptyActivityFixture() {
|
||||
};
|
||||
}
|
||||
|
||||
function mockEmptyOverviewApi() {
|
||||
function populatedTokenFixture() {
|
||||
return {
|
||||
...emptyTokenFixture(),
|
||||
totals: { inputTokens: 600, outputTokens: 300, cachedTokens: 100, cacheWriteTokens: 0, totalTokens: 1000, nTasks: 3 },
|
||||
cost: { usd: 9, unavailable: false, stale: false },
|
||||
groups: [
|
||||
{
|
||||
key: "gpt-4o",
|
||||
inputTokens: 600,
|
||||
outputTokens: 300,
|
||||
cachedTokens: 100,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 1000,
|
||||
nTasks: 3,
|
||||
cost: { usd: 9, unavailable: false, stale: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function populatedToolsFixture() {
|
||||
return {
|
||||
...emptyToolsFixture(),
|
||||
toolCalls: 12,
|
||||
byCategory: [{ category: "read", count: 12 }],
|
||||
sessions: 2,
|
||||
autonomyRatio: 6,
|
||||
fullyAutonomous: false,
|
||||
};
|
||||
}
|
||||
|
||||
function populatedActivityFixture() {
|
||||
return {
|
||||
...emptyActivityFixture(),
|
||||
sessions: 2,
|
||||
messages: 8,
|
||||
activeNodes: 2,
|
||||
activeAgents: 1,
|
||||
daily: [{ day: "2026-06-08", activeNodes: 2, activeAgents: 1, messages: 8 }],
|
||||
funnel: {
|
||||
...emptyActivityFixture().funnel,
|
||||
stages: [
|
||||
{ stage: "triage", entered: 2, current: 0 },
|
||||
{ stage: "in-progress", entered: 1, current: 1 },
|
||||
{ stage: "done", entered: 2, current: 2 },
|
||||
],
|
||||
enteredInRange: 2,
|
||||
doneInRange: 2,
|
||||
completionRate: 1,
|
||||
throughputPerDay: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) {
|
||||
apiMock.mockImplementation((path: string) => {
|
||||
if (path.startsWith("/command-center/tokens")) return Promise.resolve(emptyTokenFixture());
|
||||
if (path.startsWith("/command-center/tools")) return Promise.resolve(emptyToolsFixture());
|
||||
if (path.startsWith("/command-center/activity")) return Promise.resolve(emptyActivityFixture());
|
||||
if (path.startsWith("/command-center/tokens")) return Promise.resolve(populated ? populatedTokenFixture() : emptyTokenFixture());
|
||||
if (path.startsWith("/command-center/tools")) return Promise.resolve(populated ? populatedToolsFixture() : emptyToolsFixture());
|
||||
if (path.startsWith("/command-center/activity")) return Promise.resolve(populated ? populatedActivityFixture() : emptyActivityFixture());
|
||||
if (path.startsWith("/command-center/signals")) return Promise.resolve({ totalSignals: 0, open: 0, resolved: 0, mttr: { value: null, unavailable: true }, bySource: [], bySeverity: [] });
|
||||
return Promise.reject(new Error(`Unhandled api path: ${path}`));
|
||||
});
|
||||
@@ -111,7 +165,7 @@ function assertScrollOwnerContract(panel: HTMLElement) {
|
||||
describe("CommandCenter mobile scroll regression (FN-6595)", () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
mockEmptyOverviewApi();
|
||||
mockOverviewApi();
|
||||
injectCommandCenterCss();
|
||||
mockMobileMatchMedia(true);
|
||||
});
|
||||
@@ -129,6 +183,15 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
|
||||
assertScrollOwnerContract(tokensPanel);
|
||||
});
|
||||
|
||||
it("preserves the mobile scroll owner when the populated Overview charts render", async () => {
|
||||
mockOverviewApi({ populated: true });
|
||||
render(<CommandCenter />);
|
||||
|
||||
await screen.findByTestId("command-center-overview-charts");
|
||||
expect(screen.getByTestId("command-center-overview-chart-tokens")).toBeTruthy();
|
||||
assertScrollOwnerContract(screen.getByTestId("command-center-panel-overview"));
|
||||
});
|
||||
|
||||
it("keeps the same flex-fill scroll-owner contract outside the mobile breakpoint", () => {
|
||||
mockMobileMatchMedia(false);
|
||||
render(<CommandCenter />);
|
||||
|
||||
@@ -163,7 +163,9 @@ describe("CommandCenter shell", () => {
|
||||
render(<CommandCenter />);
|
||||
expect(screen.queryByTestId("command-center-empty")).toBeNull();
|
||||
expect(screen.getByTestId("command-center-overview-loading")).toBeTruthy();
|
||||
expect(screen.queryByTestId("command-center-overview-charts")).toBeNull();
|
||||
await screen.findByTestId("command-center-empty");
|
||||
expect(screen.queryByTestId("command-center-overview-charts")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders live Overview headline values when analytics data exists", async () => {
|
||||
@@ -188,6 +190,12 @@ describe("CommandCenter shell", () => {
|
||||
expect(screen.getByTestId("command-center-throughput-trend")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Recent activity throughput trend" })).toBeTruthy();
|
||||
expect(screen.getByTestId("command-center-throughput")).toBeTruthy();
|
||||
|
||||
const charts = screen.getByTestId("command-center-overview-charts");
|
||||
expect(within(charts).getByText("Tokens by model")).toBeTruthy();
|
||||
expect(within(screen.getByTestId("command-center-overview-chart-tokens")).getByText("gpt-4o")).toBeTruthy();
|
||||
expect(within(screen.getByTestId("command-center-overview-chart-tools")).getByText("read")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Daily activity trend" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders cards for partially populated analytics instead of the empty state", async () => {
|
||||
@@ -198,6 +206,55 @@ describe("CommandCenter shell", () => {
|
||||
expect(screen.queryByTestId("command-center-empty")).toBeNull();
|
||||
expect(statValue("command-center-stat-tokens")).toBe("0");
|
||||
expect(statValue("command-center-stat-nodes")).toBe("1");
|
||||
expect(screen.queryByTestId("command-center-overview-charts")).toBeNull();
|
||||
expect(screen.queryByTestId("command-center-overview-loading")).toBeNull();
|
||||
expect(screen.queryByTestId("command-center-overview-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders no empty chart shell when some populated sources have no chart rows", async () => {
|
||||
mockOverviewApi({ tokens: tokenFixture(), tools: toolsFixture(0), activity: activityFixture(), signals: signalsFixture(0) });
|
||||
render(<CommandCenter />);
|
||||
|
||||
await screen.findByTestId("command-center-overview-charts");
|
||||
expect(screen.getByTestId("command-center-overview-chart-tokens")).toBeTruthy();
|
||||
expect(screen.queryByTestId("command-center-overview-chart-tools")).toBeNull();
|
||||
expect(screen.getByTestId("command-center-overview-chart-activity")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles empty, undefined, single-item, and zero chart data without NaN output", async () => {
|
||||
const tokensWithSingleZeroGroup = {
|
||||
...tokenFixture(0),
|
||||
groups: [
|
||||
{
|
||||
key: "idle-model",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 0,
|
||||
nTasks: 0,
|
||||
cost: { usd: null, unavailable: true, stale: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
const toolsWithoutCategories = { ...toolsFixture(1), byCategory: undefined };
|
||||
const activityWithSingleZeroDay = {
|
||||
...activityFixture({ sessions: 0, messages: 0, activeNodes: 1, activeAgents: 0, doneInRange: 0 }),
|
||||
daily: [{ day: "2026-06-08", activeNodes: 0, activeAgents: 0, messages: 0 }],
|
||||
};
|
||||
mockOverviewApi({
|
||||
tokens: tokensWithSingleZeroGroup,
|
||||
tools: toolsWithoutCategories,
|
||||
activity: activityWithSingleZeroDay,
|
||||
signals: signalsFixture(0),
|
||||
});
|
||||
render(<CommandCenter />);
|
||||
|
||||
await screen.findByTestId("command-center-overview-charts");
|
||||
expect(screen.getByTestId("command-center-overview-chart-tokens").textContent).toContain("idle-model");
|
||||
expect(screen.queryByTestId("command-center-overview-chart-tools")).toBeNull();
|
||||
expect(screen.getByTestId("command-center-overview-chart-activity")).toBeTruthy();
|
||||
expect(screen.getByTestId("command-center-panel-overview").textContent).not.toContain("NaN");
|
||||
});
|
||||
|
||||
it("keeps Overview populated when the signals endpoint is missing", async () => {
|
||||
@@ -224,6 +281,7 @@ describe("CommandCenter shell", () => {
|
||||
expect(screen.getByTestId("command-center-overview-error").textContent).toContain("tokens failed");
|
||||
expect(screen.queryByTestId("command-center-overview-loading")).toBeNull();
|
||||
expect(screen.queryByTestId("command-center-empty")).toBeNull();
|
||||
expect(screen.queryByTestId("command-center-overview-charts")).toBeNull();
|
||||
});
|
||||
|
||||
it("re-fetches and re-derives the Overview empty state when the range changes", async () => {
|
||||
|
||||
Reference in New Issue
Block a user