diff --git a/.changeset/fn-6650-command-center-overview-charts.md b/.changeset/fn-6650-command-center-overview-charts.md new file mode 100644 index 0000000000..860a5f74be --- /dev/null +++ b/.changeset/fn-6650-command-center-overview-charts.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 69afd0dfab..fbec99aa89 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/dashboard/app/components/command-center/CommandCenter.css b/packages/dashboard/app/components/command-center/CommandCenter.css index e075569867..2cdd45cc28 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.css +++ b/packages/dashboard/app/components/command-center/CommandCenter.css @@ -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 ---- */ diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index cbb61a89b4..c95190170e 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -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( + () => + [...(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( + () => + [...(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 }) { /> + {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. + */ +
+ {tokensByModelData.length > 0 ? ( +
+
+

{t("commandCenter.overview.tokensByModel", "Tokens by model")}

+

{t("commandCenter.overview.tokensByModelHint", "Top model token consumers in this range")}

+
+ +
+ ) : null} + {toolCategoryData.length > 0 ? ( +
+
+

{t("commandCenter.overview.toolCategories", "Tool categories")}

+

{t("commandCenter.overview.toolCategoriesHint", "Autonomous work grouped by tool family")}

+
+ +
+ ) : null} + {dailyActivityValues.length > 0 ? ( +
+
+

{t("commandCenter.overview.dailyActivity", "Daily activity trend")}

+

{t("commandCenter.overview.dailyActivityHint", "Messages plus active agents per day")}

+
+ +
+ ) : null} +
+ ) : null} {throughputSection} ); diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx index 822fec74d4..e571788a67 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx @@ -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(); + + 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(); diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index 182f2e5207..1a4143ae82 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -163,7 +163,9 @@ describe("CommandCenter shell", () => { render(); 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(); + + 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(); + + 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 () => {