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:
|
Features:
|
||||||
- Global date-range picker in the header scopes the analytics tabs; **Mission Control** remains live rather than historical.
|
- 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.
|
- **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.
|
- **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, 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) {
|
@media (max-width: 768px) {
|
||||||
.cc-live-strip {
|
.cc-live-strip {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -246,6 +349,14 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
|
|||||||
.cc-live-strip-metrics {
|
.cc-live-strip-metrics {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cc-overview-charts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-overview-chart-card--trend {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- States ---- */
|
/* ---- 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 { useTranslation } from "react-i18next";
|
||||||
import { AlertCircle, Gauge } from "lucide-react";
|
import { AlertCircle, Gauge } from "lucide-react";
|
||||||
import type { ActivityAnalytics, TokenAnalytics, ToolAnalytics } from "@fusion/core";
|
import type { ActivityAnalytics, TokenAnalytics, ToolAnalytics } from "@fusion/core";
|
||||||
@@ -12,6 +12,7 @@ import { EcosystemArea } from "./areas/EcosystemArea";
|
|||||||
import { SignalsArea } from "./areas/SignalsArea";
|
import { SignalsArea } from "./areas/SignalsArea";
|
||||||
import { MissionControlPanel } from "./MissionControlPanel";
|
import { MissionControlPanel } from "./MissionControlPanel";
|
||||||
import { SdlcFunnel } from "./SdlcFunnel";
|
import { SdlcFunnel } from "./SdlcFunnel";
|
||||||
|
import { Bar, type BarDatum } from "./charts/Bar";
|
||||||
import { Sparkline } from "./charts/Sparkline";
|
import { Sparkline } from "./charts/Sparkline";
|
||||||
import { useAnalyticsArea } from "./areas/useAnalyticsArea";
|
import { useAnalyticsArea } from "./areas/useAnalyticsArea";
|
||||||
import { formatCost, formatCount, isInvalidRange, rangeQuery } from "./areas/areaShared";
|
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 tasksDone = activity.data?.funnel?.doneInRange ?? 0;
|
||||||
const inProgressTasks = activity.data?.funnel?.stages.find((stage) => stage.stage === "in-progress")?.entered ?? 0;
|
const inProgressTasks = activity.data?.funnel?.stages.find((stage) => stage.stage === "in-progress")?.entered ?? 0;
|
||||||
const uniqueModels = tokens.data?.groups?.length ?? 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 =
|
const activityTrendValues =
|
||||||
activity.data && activity.data.daily.length > 0
|
dailyActivityValues.length > 0
|
||||||
? activity.data.daily.map((day) => day.messages + day.activeAgents)
|
? dailyActivityValues
|
||||||
: [activity.data?.sessions ?? 0, activity.data?.messages ?? 0, activeAgents, activeNodes, tasksDone];
|
: [activity.data?.sessions ?? 0, activity.data?.messages ?? 0, activeAgents, activeNodes, tasksDone];
|
||||||
|
const hasOverviewChartData = tokensByModelData.length > 0 || toolCategoryData.length > 0 || dailyActivityValues.length > 0;
|
||||||
const hasActivityData =
|
const hasActivityData =
|
||||||
(activity.data?.sessions ?? 0) > 0 ||
|
(activity.data?.sessions ?? 0) > 0 ||
|
||||||
(activity.data?.messages ?? 0) > 0 ||
|
(activity.data?.messages ?? 0) > 0 ||
|
||||||
@@ -237,6 +266,44 @@ function OverviewTab({ range }: { range: DateRange }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{throughputSection}
|
||||||
</div>
|
</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) => {
|
apiMock.mockImplementation((path: string) => {
|
||||||
if (path.startsWith("/command-center/tokens")) return Promise.resolve(emptyTokenFixture());
|
if (path.startsWith("/command-center/tokens")) return Promise.resolve(populated ? populatedTokenFixture() : emptyTokenFixture());
|
||||||
if (path.startsWith("/command-center/tools")) return Promise.resolve(emptyToolsFixture());
|
if (path.startsWith("/command-center/tools")) return Promise.resolve(populated ? populatedToolsFixture() : emptyToolsFixture());
|
||||||
if (path.startsWith("/command-center/activity")) return Promise.resolve(emptyActivityFixture());
|
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: [] });
|
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}`));
|
return Promise.reject(new Error(`Unhandled api path: ${path}`));
|
||||||
});
|
});
|
||||||
@@ -111,7 +165,7 @@ function assertScrollOwnerContract(panel: HTMLElement) {
|
|||||||
describe("CommandCenter mobile scroll regression (FN-6595)", () => {
|
describe("CommandCenter mobile scroll regression (FN-6595)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiMock.mockReset();
|
apiMock.mockReset();
|
||||||
mockEmptyOverviewApi();
|
mockOverviewApi();
|
||||||
injectCommandCenterCss();
|
injectCommandCenterCss();
|
||||||
mockMobileMatchMedia(true);
|
mockMobileMatchMedia(true);
|
||||||
});
|
});
|
||||||
@@ -129,6 +183,15 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
|
|||||||
assertScrollOwnerContract(tokensPanel);
|
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", () => {
|
it("keeps the same flex-fill scroll-owner contract outside the mobile breakpoint", () => {
|
||||||
mockMobileMatchMedia(false);
|
mockMobileMatchMedia(false);
|
||||||
render(<CommandCenter />);
|
render(<CommandCenter />);
|
||||||
|
|||||||
@@ -163,7 +163,9 @@ describe("CommandCenter shell", () => {
|
|||||||
render(<CommandCenter />);
|
render(<CommandCenter />);
|
||||||
expect(screen.queryByTestId("command-center-empty")).toBeNull();
|
expect(screen.queryByTestId("command-center-empty")).toBeNull();
|
||||||
expect(screen.getByTestId("command-center-overview-loading")).toBeTruthy();
|
expect(screen.getByTestId("command-center-overview-loading")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("command-center-overview-charts")).toBeNull();
|
||||||
await screen.findByTestId("command-center-empty");
|
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 () => {
|
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.getByTestId("command-center-throughput-trend")).toBeTruthy();
|
||||||
expect(screen.getByRole("img", { name: "Recent activity throughput trend" })).toBeTruthy();
|
expect(screen.getByRole("img", { name: "Recent activity throughput trend" })).toBeTruthy();
|
||||||
expect(screen.getByTestId("command-center-throughput")).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 () => {
|
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(screen.queryByTestId("command-center-empty")).toBeNull();
|
||||||
expect(statValue("command-center-stat-tokens")).toBe("0");
|
expect(statValue("command-center-stat-tokens")).toBe("0");
|
||||||
expect(statValue("command-center-stat-nodes")).toBe("1");
|
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 () => {
|
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.getByTestId("command-center-overview-error").textContent).toContain("tokens failed");
|
||||||
expect(screen.queryByTestId("command-center-overview-loading")).toBeNull();
|
expect(screen.queryByTestId("command-center-overview-loading")).toBeNull();
|
||||||
expect(screen.queryByTestId("command-center-empty")).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 () => {
|
it("re-fetches and re-derives the Overview empty state when the range changes", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user