FN-6652: add live token usage timeline
Adds live token-over-time analytics and charting to Command Center. - Add optional token analytics time-series buckets with hour, day, and week granularity. - Surface live-polled token series data through the Command Center tokens API. - Render an animated, reduced-motion-safe tokens-over-time chart with granularity controls. - Cover analytics bucketing, API query parsing, and Command Center chart behavior with tests. Files changed: .changeset/fn-6652-token-usage-over-time.md | 5 + docs/dashboard-guide.md | 4 +- .../core/src/__tests__/token-analytics.test.ts | 76 ++++++++++++ packages/core/src/index.ts | 2 + packages/core/src/token-analytics.ts | 65 ++++++++++- .../components/command-center/CommandCenter.css | 24 +++- .../components/command-center/CommandCenter.tsx | 12 +- .../__tests__/CommandCenter.test.tsx | 39 ++++++- .../command-center/__tests__/charts.test.tsx | 44 +++++++ .../components/command-center/areas/TokensArea.tsx | 46 +++++++- .../command-center/areas/__tests__/areas.test.tsx | 128 ++++++++++++++++++++- .../app/components/command-center/areas/areas.css | 57 +++++++++ .../command-center/areas/useAnalyticsArea.ts | 26 ++++- .../command-center/charts/TokenSeriesChart.tsx | 52 +++++++++ .../components/command-center/charts/charts.css | 76 ++++++++++++ .../register-command-center-routes.test.ts | 38 +++++- .../src/routes/register-command-center-routes.ts | 15 +++ 17 files changed, 691 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-6652 Fusion-Task-Lineage: ebfd1b5d-5b6c-48ef-bce3-f18bbc1605fa
This commit is contained in:
5
.changeset/fn-6652-token-usage-over-time.md
Normal file
5
.changeset/fn-6652-token-usage-over-time.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add a live-updating, animated Command Center token-usage-over-time view with hour/day/week granularity and bounded polling for token totals.
|
||||
@@ -663,8 +663,8 @@ 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. Its Live activity snapshot shows the current board-state count for tasks in progress, independent of the selected analytics date 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. 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.
|
||||
- **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. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes 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. 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. It also includes a live token-usage-over-time chart backed by per-task token timestamps; use the granularity control to switch the chart between hourly, daily, and weekly buckets. The token total and chart poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users.
|
||||
- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories.
|
||||
- **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.
|
||||
|
||||
@@ -154,4 +154,80 @@ describe("token-analytics", () => {
|
||||
const result = aggregateTokenAnalytics(db, {});
|
||||
expect(result.totals.totalTokens).toBe(36);
|
||||
});
|
||||
|
||||
it("omits series unless granularity is requested while preserving totals", () => {
|
||||
insertTask(db, { id: "t1", inputTokens: 10, totalTokens: 10, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" });
|
||||
|
||||
const result = aggregateTokenAnalytics(db, {});
|
||||
|
||||
expect(result.totals.totalTokens).toBe(10);
|
||||
expect(result).not.toHaveProperty("series");
|
||||
});
|
||||
|
||||
it("buckets token usage by UTC day in ascending order with inclusive bounds", () => {
|
||||
insertTask(db, { id: "before", inputTokens: 1, totalTokens: 1, lastUsedAt: "2026-02-29T23:59:59.999Z", modelId: "model-A" });
|
||||
insertTask(db, { id: "from", inputTokens: 100, outputTokens: 10, totalTokens: 110, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" });
|
||||
insertTask(db, { id: "same-day", inputTokens: 200, outputTokens: 20, totalTokens: 220, lastUsedAt: "2026-03-01T12:00:00.000Z", modelId: "model-A" });
|
||||
insertTask(db, { id: "to", inputTokens: 300, outputTokens: 30, totalTokens: 330, lastUsedAt: "2026-03-02T00:00:00.000Z", modelId: "model-A" });
|
||||
insertTask(db, { id: "after", inputTokens: 1, totalTokens: 1, lastUsedAt: "2026-03-02T00:00:00.001Z", modelId: "model-A" });
|
||||
|
||||
const result = aggregateTokenAnalytics(db, {
|
||||
from: "2026-03-01T00:00:00.000Z",
|
||||
to: "2026-03-02T00:00:00.000Z",
|
||||
granularity: "day",
|
||||
});
|
||||
|
||||
expect(result.series?.map((p) => p.bucket)).toEqual(["2026-03-01", "2026-03-02"]);
|
||||
expect(result.series?.map((p) => p.totalTokens)).toEqual([330, 330]);
|
||||
expect(result.totals.totalTokens).toBe(660);
|
||||
});
|
||||
|
||||
it("buckets token usage by UTC hour", () => {
|
||||
insertTask(db, { id: "h1a", inputTokens: 10, totalTokens: 10, lastUsedAt: "2026-03-01T01:05:00.000Z", modelId: "model-A" });
|
||||
insertTask(db, { id: "h1b", inputTokens: 20, totalTokens: 20, lastUsedAt: "2026-03-01T01:59:00.000Z", modelId: "model-A" });
|
||||
insertTask(db, { id: "h2", inputTokens: 30, totalTokens: 30, lastUsedAt: "2026-03-01T02:00:00.000Z", modelId: "model-A" });
|
||||
|
||||
const result = aggregateTokenAnalytics(db, { granularity: "hour" });
|
||||
|
||||
expect(result.series?.map((p) => [p.bucket, p.totalTokens])).toEqual([
|
||||
["2026-03-01T01", 30],
|
||||
["2026-03-01T02", 30],
|
||||
]);
|
||||
});
|
||||
|
||||
it("buckets token usage by ISO week across year boundaries", () => {
|
||||
insertTask(db, { id: "w1", inputTokens: 10, totalTokens: 10, lastUsedAt: "2026-12-31T12:00:00.000Z", modelId: "model-A" });
|
||||
insertTask(db, { id: "w1b", inputTokens: 20, totalTokens: 20, lastUsedAt: "2027-01-01T12:00:00.000Z", modelId: "model-A" });
|
||||
insertTask(db, { id: "w2", inputTokens: 30, totalTokens: 30, lastUsedAt: "2027-01-04T00:00:00.000Z", modelId: "model-A" });
|
||||
|
||||
const result = aggregateTokenAnalytics(db, { granularity: "week" });
|
||||
|
||||
expect(result.series?.map((p) => [p.bucket, p.totalTokens])).toEqual([
|
||||
["2026-W53", 30],
|
||||
["2027-W01", 30],
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes per-bucket cost with priced and unavailable models", () => {
|
||||
insertTask(db, { id: "priced", inputTokens: 1_000_000, outputTokens: 1_000_000, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 2_000_000, lastUsedAt: "2026-03-01T00:00:00.000Z", modelProvider: "openai", modelId: "gpt-4o" });
|
||||
insertTask(db, { id: "unknown", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T10:00:00.000Z", modelProvider: "unknown", modelId: "mystery" });
|
||||
|
||||
const result = aggregateTokenAnalytics(db, { granularity: "day" });
|
||||
|
||||
expect(result.series).toHaveLength(1);
|
||||
expect(result.series?.[0].cost).toEqual({ usd: 12.5, unavailable: true, stale: false });
|
||||
});
|
||||
|
||||
it("returns an empty series for an empty requested range", () => {
|
||||
insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" });
|
||||
|
||||
const result = aggregateTokenAnalytics(db, {
|
||||
from: "2027-01-01T00:00:00.000Z",
|
||||
to: "2027-12-31T00:00:00.000Z",
|
||||
granularity: "day",
|
||||
});
|
||||
|
||||
expect(result.series).toEqual([]);
|
||||
expect(result.totals.totalTokens).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -550,6 +550,8 @@ export type {
|
||||
TokenAnalyticsQuery,
|
||||
TokenGroupBy,
|
||||
TokenGroupSummary,
|
||||
TokenTimeGranularity,
|
||||
TokenTimePoint,
|
||||
TokenTotals,
|
||||
} from "./token-analytics.js";
|
||||
export { aggregateToolAnalytics, countInterventions } from "./tool-analytics.js";
|
||||
|
||||
@@ -17,6 +17,9 @@ import { costFor, type CostResult } from "./model-pricing.js";
|
||||
/** Dimension to group token totals by. */
|
||||
export type TokenGroupBy = "model" | "provider" | "node" | "agent";
|
||||
|
||||
/** Bucket size for optional token-usage time-series analytics. */
|
||||
export type TokenTimeGranularity = "hour" | "day" | "week";
|
||||
|
||||
/** Summed token counts for a group (or the grand total). */
|
||||
export interface TokenTotals {
|
||||
inputTokens: number;
|
||||
@@ -41,6 +44,14 @@ export interface TokenGroupSummary extends TokenTotals {
|
||||
cost: CostResult;
|
||||
}
|
||||
|
||||
/** One time bucket in the optional token-usage series. */
|
||||
export interface TokenTimePoint extends TokenTotals {
|
||||
/** UTC bucket key (`YYYY-MM-DDTHH`, `YYYY-MM-DD`, or ISO week `YYYY-Www`). */
|
||||
bucket: string;
|
||||
/** Derived USD cost for this bucket, summed per contributing task. */
|
||||
cost: CostResult;
|
||||
}
|
||||
|
||||
/** Result of {@link aggregateTokenAnalytics}. */
|
||||
export interface TokenAnalytics {
|
||||
from: string | null;
|
||||
@@ -56,6 +67,8 @@ export interface TokenAnalytics {
|
||||
cost: CostResult;
|
||||
/** Per-group totals; empty array when no `groupBy` requested. */
|
||||
groups: TokenGroupSummary[];
|
||||
/** Optional token-usage totals over time, present only when requested. */
|
||||
series?: TokenTimePoint[];
|
||||
}
|
||||
|
||||
export interface TokenAnalyticsQuery {
|
||||
@@ -64,6 +77,8 @@ export interface TokenAnalyticsQuery {
|
||||
/** ISO-8601 upper bound (inclusive) on `tokenUsageLastUsedAt`. */
|
||||
to?: string;
|
||||
groupBy?: TokenGroupBy;
|
||||
/** Optional UTC bucket size for a token-usage time series. */
|
||||
granularity?: TokenTimeGranularity;
|
||||
/**
|
||||
* Epoch ms "now" used only for pricing-staleness (U3). When omitted, derived
|
||||
* cost is never marked stale. Pure: the module never reads the clock itself.
|
||||
@@ -92,6 +107,7 @@ interface TaskTokenRow {
|
||||
modelId: string | null;
|
||||
checkoutNodeId: string | null;
|
||||
assignedAgentId: string | null;
|
||||
tokenUsageLastUsedAt: string;
|
||||
}
|
||||
|
||||
function groupKeyFor(row: TaskTokenRow, groupBy: TokenGroupBy): string | null {
|
||||
@@ -170,12 +186,36 @@ function addRow(totals: TokenTotals, row: TaskTokenRow): void {
|
||||
totals.nTasks += 1;
|
||||
}
|
||||
|
||||
function isoWeekBucket(isoTimestamp: string): string {
|
||||
const date = new Date(isoTimestamp);
|
||||
if (!Number.isFinite(date.getTime())) return isoTimestamp.slice(0, 10);
|
||||
const day = date.getUTCDay() || 7;
|
||||
const thursday = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 4 - day));
|
||||
const yearStart = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((thursday.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function bucketFor(row: TaskTokenRow, granularity: TokenTimeGranularity): string {
|
||||
switch (granularity) {
|
||||
case "hour":
|
||||
return row.tokenUsageLastUsedAt.slice(0, 13);
|
||||
case "day":
|
||||
return row.tokenUsageLastUsedAt.slice(0, 10);
|
||||
case "week":
|
||||
return isoWeekBucket(row.tokenUsageLastUsedAt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate per-task token usage over a date range, optionally grouped.
|
||||
*
|
||||
* Tasks are matched by `tokenUsageLastUsedAt` within `[from, to]` (inclusive).
|
||||
* Tasks with no token usage (`tokenUsageLastUsedAt IS NULL`) are excluded. An
|
||||
* empty range yields zeroed `totals` and an empty `groups` array — never nulls.
|
||||
*
|
||||
* FNXC:CommandCenter 2026-06-18-00:00:
|
||||
* The Command Center token view needs a live, scalable, animated token-over-time chart without changing existing CSV/OTel consumers. Keep `series` opt-in via `granularity`, bucket ISO timestamps in UTC (substring for hour/day, ISO-week in JS), and reuse per-task cost accumulation so each bucket prices mixed known/unknown models correctly.
|
||||
*/
|
||||
export function aggregateTokenAnalytics(
|
||||
db: Database,
|
||||
@@ -204,7 +244,8 @@ export function aggregateTokenAnalytics(
|
||||
modelProvider,
|
||||
modelId,
|
||||
checkoutNodeId,
|
||||
assignedAgentId
|
||||
assignedAgentId,
|
||||
tokenUsageLastUsedAt
|
||||
FROM tasks ${where}`,
|
||||
)
|
||||
.all(...params) as TaskTokenRow[];
|
||||
@@ -213,7 +254,10 @@ export function aggregateTokenAnalytics(
|
||||
const totalCost = emptyCostAccumulator();
|
||||
const groupMap = new Map<string | null, TokenGroupSummary>();
|
||||
const groupCostMap = new Map<string | null, CostAccumulator>();
|
||||
const seriesMap = new Map<string, TokenTimePoint>();
|
||||
const seriesCostMap = new Map<string, CostAccumulator>();
|
||||
const groupBy = query.groupBy;
|
||||
const granularity = query.granularity;
|
||||
const now = query.now;
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -230,6 +274,17 @@ export function aggregateTokenAnalytics(
|
||||
addRow(group, row);
|
||||
addRowCost(groupCostMap.get(key)!, row, now);
|
||||
}
|
||||
if (granularity) {
|
||||
const bucket = bucketFor(row, granularity);
|
||||
let point = seriesMap.get(bucket);
|
||||
if (!point) {
|
||||
point = { bucket, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
|
||||
seriesMap.set(bucket, point);
|
||||
seriesCostMap.set(bucket, emptyCostAccumulator());
|
||||
}
|
||||
addRow(point, row);
|
||||
addRowCost(seriesCostMap.get(bucket)!, row, now);
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize per-group cost from each group's accumulator.
|
||||
@@ -241,6 +296,13 @@ export function aggregateTokenAnalytics(
|
||||
(a, b) => b.totalTokens - a.totalTokens,
|
||||
);
|
||||
|
||||
for (const [bucket, point] of seriesMap) {
|
||||
point.cost = finalizeCost(seriesCostMap.get(bucket)!);
|
||||
}
|
||||
const series = granularity
|
||||
? [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
from: query.from ?? null,
|
||||
to: query.to ?? null,
|
||||
@@ -248,5 +310,6 @@ export function aggregateTokenAnalytics(
|
||||
totals,
|
||||
cost: finalizeCost(totalCost),
|
||||
groups,
|
||||
...(granularity ? { series } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
|
||||
|
||||
.cc-live-strip-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-2, 0.5rem);
|
||||
}
|
||||
|
||||
@@ -188,6 +188,25 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:CommandCenterTokens 2026-06-18-15:14:
|
||||
Overview token totals now live-poll and should visibly count up on change in both the stat card and live strip. The animation is decorative and must be disabled for reduced-motion users.
|
||||
*/
|
||||
.cc-token-count-live {
|
||||
animation: cc-token-count-pop var(--duration-normal) ease-out both;
|
||||
}
|
||||
|
||||
@keyframes cc-token-count-pop {
|
||||
from {
|
||||
transform: translateY(var(--space-1));
|
||||
opacity: 0.7;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-live-metric-label,
|
||||
.cc-live-trend-label {
|
||||
color: var(--text-muted);
|
||||
@@ -233,7 +252,8 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cc-live-strip::before,
|
||||
.cc-live-metric,
|
||||
.cc-live-trend .cc-sparkline-bar {
|
||||
.cc-live-trend .cc-sparkline-bar,
|
||||
.cc-token-count-live {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +59,13 @@ interface OverviewStatCard {
|
||||
FNXC:CommandCenter 2026-06-17-00:00:
|
||||
Overview is the Command Center landing surface, so it must reflect real analytics instead of shell placeholders. Show loading while core analytics have not settled, show the empty state only after settled zero data, and treat Signals as best-effort because that endpoint can be absent without invalidating tokens/tools/activity metrics.
|
||||
*/
|
||||
const OVERVIEW_TOKEN_REFRESH_MS = 15_000;
|
||||
|
||||
function OverviewTab({ range }: { range: DateRange }) {
|
||||
const { t } = useTranslation("app");
|
||||
const tokens = useAnalyticsArea<TokenAnalytics>("/command-center/tokens?groupBy=model", range);
|
||||
const tokens = useAnalyticsArea<TokenAnalytics>("/command-center/tokens?groupBy=model", range, {
|
||||
pollMs: OVERVIEW_TOKEN_REFRESH_MS,
|
||||
});
|
||||
const tools = useAnalyticsArea<ToolAnalytics>("/command-center/tools", range);
|
||||
const activity = useAnalyticsArea<ActivityAnalytics>("/command-center/activity", range);
|
||||
const [signals, setSignals] = useState<SignalsAnalytics | null>(null);
|
||||
@@ -257,7 +261,7 @@ function OverviewTab({ range }: { range: DateRange }) {
|
||||
{cards.map((card) => (
|
||||
<div key={card.id} className="card cc-stat-card" data-testid={`command-center-stat-${card.id}`}>
|
||||
<div className="cc-stat-label">{card.label}</div>
|
||||
<div className="cc-stat-value">{card.value}</div>
|
||||
<div key={card.value} className={`cc-stat-value ${card.id === "tokens" ? "cc-token-count-live" : ""}`}>{card.value}</div>
|
||||
{card.subLabel ? <span className="cc-stat-sub">{card.subLabel}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
@@ -283,6 +287,10 @@ function OverviewTab({ range }: { range: DateRange }) {
|
||||
<span className="cc-live-metric-value">{formatCount(activeAgents)}</span>
|
||||
<span className="cc-live-metric-label">{t("commandCenter.overview.agentsWorking", "agents working")}</span>
|
||||
</span>
|
||||
<span className="cc-live-metric" data-testid="command-center-live-tokens">
|
||||
<span key={tokenTotal} className="cc-live-metric-value cc-token-count-live">{formatCount(tokenTotal)}</span>
|
||||
<span className="cc-live-metric-label">{t("commandCenter.overview.liveTokens", "tokens")}</span>
|
||||
</span>
|
||||
<span className="cc-live-metric" data-testid="command-center-live-open-signals">
|
||||
<span className="cc-live-metric-value">{signalsLoading ? "—" : signals ? formatCount(signals.open ?? 0) : "—"}</span>
|
||||
<span className="cc-live-metric-label">{t("commandCenter.overview.openSignals", "open signals")}</span>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
FNXC:CommandCenter 2026-06-17-00:00:
|
||||
Command Center Overview must consume the same analytics endpoints as the detail tabs. These tests reproduce the prior always-empty landing page, then pin loading-before-empty, range re-derivation, and best-effort Signals behavior so Overview cannot regress into shell placeholders again.
|
||||
*/
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, within, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, within, waitFor, act } from "@testing-library/react";
|
||||
import { CommandCenter } from "../CommandCenter";
|
||||
|
||||
const apiMock = vi.fn();
|
||||
@@ -171,6 +171,10 @@ beforeEach(() => {
|
||||
mockEmptyOverviewApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("CommandCenter shell", () => {
|
||||
it("renders with the Overview tab active by default", () => {
|
||||
render(<CommandCenter />);
|
||||
@@ -207,6 +211,7 @@ describe("CommandCenter shell", () => {
|
||||
expect(screen.getByTestId("command-center-live-snapshot")).toBeTruthy();
|
||||
await waitFor(() => expect(liveMetricValue()).toBe("3"));
|
||||
expect(screen.getByTestId("command-center-live-agents-working").textContent).toContain("2");
|
||||
expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,500");
|
||||
expect(screen.getByTestId("command-center-live-open-signals").textContent).toContain("2");
|
||||
expect(screen.getByTestId("command-center-throughput-trend")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Recent activity throughput trend" })).toBeTruthy();
|
||||
@@ -219,6 +224,36 @@ describe("CommandCenter shell", () => {
|
||||
expect(screen.getByRole("img", { name: "Daily activity trend" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("live-polls token totals for the Overview card and live strip", async () => {
|
||||
vi.useFakeTimers();
|
||||
let tokenTotal = 1_500;
|
||||
apiMock.mockImplementation((path: string) => {
|
||||
if (path.startsWith("/command-center/tokens")) return Promise.resolve(tokenFixture(tokenTotal));
|
||||
if (path.startsWith("/command-center/tools")) return Promise.resolve(toolsFixture());
|
||||
if (path.startsWith("/command-center/activity")) return Promise.resolve(activityFixture());
|
||||
if (path.startsWith("/command-center/signals")) return Promise.resolve(signalsFixture(2));
|
||||
if (path === "/command-center/live") return Promise.resolve(liveFixture([{ column: "in-progress", count: 3 }]));
|
||||
return Promise.reject(new Error(`Unhandled api path: ${path}`));
|
||||
});
|
||||
|
||||
render(<CommandCenter />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(statValue("command-center-stat-tokens")).toBe("1,500");
|
||||
|
||||
tokenTotal = 1_700;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(statValue("command-center-stat-tokens")).toBe("1,700");
|
||||
expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,700");
|
||||
});
|
||||
|
||||
it("sources live tasks in progress from current column counts instead of funnel entered", async () => {
|
||||
mockOverviewApi({
|
||||
activity: activityFixture({ inProgress: 12 }),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Sparkline } from "../charts/Sparkline";
|
||||
import { Funnel } from "../charts/Funnel";
|
||||
import { RadialGauge } from "../charts/RadialGauge";
|
||||
import { LineChart } from "../charts/LineChart";
|
||||
import { TokenSeriesChart } from "../charts/TokenSeriesChart";
|
||||
|
||||
function widthOf(el: HTMLElement): string {
|
||||
return el.style.width;
|
||||
@@ -101,6 +102,49 @@ describe("Sparkline", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TokenSeriesChart", () => {
|
||||
it("renders proportional token buckets with an accessible label", () => {
|
||||
render(
|
||||
<TokenSeriesChart
|
||||
ariaLabel="tokens over time"
|
||||
points={[
|
||||
{ bucket: "2026-06-08", inputTokens: 50, outputTokens: 50, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 100, nTasks: 1, cost: { usd: null, unavailable: true, stale: false } },
|
||||
{ bucket: "2026-06-09", inputTokens: 25, outputTokens: 25, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 50, nTasks: 1, cost: { usd: null, unavailable: true, stale: false } },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const chart = screen.getByRole("img", { name: "tokens over time" });
|
||||
const bars = chart.querySelectorAll<HTMLElement>(".cc-token-series-bar");
|
||||
expect(bars).toHaveLength(2);
|
||||
expect(heightOf(bars[0])).toBe("100%");
|
||||
expect(heightOf(bars[1])).toBe("50%");
|
||||
});
|
||||
|
||||
it("renders an empty zero state without NaN geometry", () => {
|
||||
render(<TokenSeriesChart ariaLabel="empty tokens" points={[]} />);
|
||||
|
||||
const chart = screen.getByRole("img", { name: "empty tokens" });
|
||||
expect(screen.getByTestId("cc-token-series-empty")).toBeTruthy();
|
||||
expect(chart.innerHTML).not.toMatch(/NaN|Infinity/);
|
||||
});
|
||||
|
||||
it("renders all-zero buckets as zero-height bars", () => {
|
||||
render(
|
||||
<TokenSeriesChart
|
||||
ariaLabel="zero tokens"
|
||||
points={[
|
||||
{ bucket: "2026-06-08", inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0, cost: { usd: null, unavailable: false, stale: false } },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const bar = screen.getByRole("img", { name: "zero tokens" }).querySelector<HTMLElement>(".cc-token-series-bar");
|
||||
expect(bar?.style.height).toBe("0%");
|
||||
expect(bar?.outerHTML).not.toMatch(/NaN|Infinity/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LineChart", () => {
|
||||
it("renders a populated finite SVG line with an accessible label", () => {
|
||||
render(<LineChart ariaLabel="activity trend" series={[{ label: "messages", values: [2, 4, 1] }]} />);
|
||||
|
||||
@@ -8,15 +8,20 @@ import type {
|
||||
CostResult,
|
||||
TokenAnalytics,
|
||||
TokenGroupSummary,
|
||||
TokenTimeGranularity,
|
||||
} from "@fusion/core";
|
||||
import type { DateRange } from "../DateRangePicker";
|
||||
import { Bar } from "../charts/Bar";
|
||||
import { TokenSeriesChart } from "../charts/TokenSeriesChart";
|
||||
import { AreaShell } from "./AreaShell";
|
||||
import { useAnalyticsArea } from "./useAnalyticsArea";
|
||||
import { formatCost, formatCount } from "./areaShared";
|
||||
|
||||
type SortKey = "key" | "totalTokens" | "cost";
|
||||
|
||||
const TOKENS_LIVE_REFRESH_MS = 15_000;
|
||||
const GRANULARITIES: TokenTimeGranularity[] = ["hour", "day", "week"];
|
||||
|
||||
function costSortValue(cost: CostResult): number {
|
||||
return cost.unavailable || cost.usd === null ? -1 : cost.usd;
|
||||
}
|
||||
@@ -46,12 +51,14 @@ function sortGroups(groups: TokenGroupSummary[], key: SortKey, dir: 1 | -1): Tok
|
||||
*/
|
||||
export function TokensArea({ range }: { range: DateRange }) {
|
||||
const { t } = useTranslation("app");
|
||||
const { data, isLoading, error } = useAnalyticsArea<TokenAnalytics>(
|
||||
"/command-center/tokens?groupBy=model",
|
||||
range,
|
||||
);
|
||||
const [granularity, setGranularity] = useState<TokenTimeGranularity>("day");
|
||||
const endpoint = `/command-center/tokens?groupBy=model&granularity=${granularity}`;
|
||||
const { data, isLoading, error } = useAnalyticsArea<TokenAnalytics>(endpoint, range, {
|
||||
pollMs: TOKENS_LIVE_REFRESH_MS,
|
||||
});
|
||||
|
||||
const groups = useMemo(() => data?.groups ?? [], [data?.groups]);
|
||||
const series = useMemo(() => data?.series ?? [], [data?.series]);
|
||||
|
||||
const [sortKey, setSortKey] = useState<SortKey>("totalTokens");
|
||||
const [sortDir, setSortDir] = useState<1 | -1>(-1);
|
||||
@@ -104,7 +111,9 @@ export function TokensArea({ range }: { range: DateRange }) {
|
||||
}
|
||||
|
||||
const totals = data?.totals;
|
||||
const isEmpty = !data || (totals?.totalTokens ?? 0) === 0;
|
||||
const seriesBucketsSig = useMemo(() => series.map((point) => point.bucket).join(" "), [series]);
|
||||
const totalTokenValue = totals?.totalTokens ?? 0;
|
||||
const isEmpty = !data || totalTokenValue === 0;
|
||||
|
||||
return (
|
||||
<AreaShell testId="tokens" isLoading={isLoading} error={error} isEmpty={isEmpty}>
|
||||
@@ -113,7 +122,7 @@ export function TokensArea({ range }: { range: DateRange }) {
|
||||
<div className="cc-stat-grid">
|
||||
<div className="card cc-stat-card" data-testid="cc-tokens-total">
|
||||
<div className="cc-stat-label">{t("commandCenter.tokens.totalTokens", "Total tokens")}</div>
|
||||
<div className="cc-stat-value">{formatCount(totals?.totalTokens ?? 0)}</div>
|
||||
<div key={totalTokenValue} className="cc-stat-value cc-token-count-live">{formatCount(totalTokenValue)}</div>
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-tokens-cost">
|
||||
<div className="cc-stat-label">{t("commandCenter.tokens.cost", "Estimated cost")}</div>
|
||||
@@ -131,6 +140,31 @@ export function TokensArea({ range }: { range: DateRange }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section">
|
||||
<div className="cc-area-section-header">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.tokens.overTimeChart", "Tokens over time")}</h3>
|
||||
<div className="cc-token-granularity" role="group" aria-label={t("commandCenter.tokens.granularity", "Token chart granularity")}>
|
||||
{GRANULARITIES.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={`btn ${option === granularity ? "active" : ""}`}
|
||||
aria-pressed={option === granularity}
|
||||
data-testid={`cc-token-granularity-${option}`}
|
||||
onClick={() => setGranularity(option)}
|
||||
>
|
||||
{t(`commandCenter.tokens.granularity.${option}`, option)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TokenSeriesChart
|
||||
key={seriesBucketsSig}
|
||||
points={series}
|
||||
ariaLabel={t("commandCenter.tokens.overTimeChart", "Tokens over time")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.tokens.byModelChart", "Tokens by model")}</h3>
|
||||
<Bar data={barData} ariaLabel={t("commandCenter.tokens.byModelChart", "Tokens by model")} />
|
||||
|
||||
@@ -3,7 +3,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, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, within, act, renderHook } from "@testing-library/react";
|
||||
|
||||
// Mock the api() helper so the areas fetch deterministic fixtures.
|
||||
const apiMock = vi.fn();
|
||||
@@ -16,6 +16,7 @@ import { ToolsArea } from "../ToolsArea";
|
||||
import { ProductivityArea } from "../ProductivityArea";
|
||||
import { SignalsArea } from "../SignalsArea";
|
||||
import { ActivityArea } from "../ActivityArea";
|
||||
import { useAnalyticsArea } from "../useAnalyticsArea";
|
||||
import type { DateRange } from "../DateRangePicker";
|
||||
|
||||
const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" };
|
||||
@@ -35,6 +36,28 @@ function tokenFixture() {
|
||||
nTasks: 5,
|
||||
},
|
||||
cost: { usd: 12.5, unavailable: false, stale: false },
|
||||
series: [
|
||||
{
|
||||
bucket: "2026-06-08",
|
||||
inputTokens: 400,
|
||||
outputTokens: 200,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 600,
|
||||
nTasks: 2,
|
||||
cost: { usd: 4.5, unavailable: false, stale: false },
|
||||
},
|
||||
{
|
||||
bucket: "2026-06-09",
|
||||
inputTokens: 600,
|
||||
outputTokens: 300,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 900,
|
||||
nTasks: 3,
|
||||
cost: { usd: 8, unavailable: false, stale: false },
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
key: "gpt-4o",
|
||||
@@ -101,6 +124,71 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("useAnalyticsArea", () => {
|
||||
it("polls only when pollMs is provided and clears the interval on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
apiMock.mockResolvedValue({ ok: true });
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useAnalyticsArea<{ ok: boolean }>("/command-center/tokens", range7d, { pollMs: 1_000 }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
unmount();
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not poll by default", async () => {
|
||||
vi.useFakeTimers();
|
||||
apiMock.mockResolvedValue({ ok: true });
|
||||
|
||||
renderHook(() => useAnalyticsArea<{ ok: boolean }>("/command-center/tools", range7d));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fetch or schedule polling for inverted custom ranges", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
renderHook(() =>
|
||||
useAnalyticsArea<{ ok: boolean }>(
|
||||
"/command-center/tokens",
|
||||
customRange("2026-06-10", "2026-06-01"),
|
||||
{ pollMs: 1_000 },
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityArea", () => {
|
||||
it("renders summary stats and the live line chart sections for populated daily activity", async () => {
|
||||
apiMock.mockResolvedValue(activityFixture());
|
||||
@@ -184,10 +272,47 @@ describe("TokensArea", () => {
|
||||
await screen.findByTestId("cc-area-tokens");
|
||||
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500");
|
||||
expect(screen.getByTestId("cc-tokens-cost").textContent).toContain("$12.50");
|
||||
expect(screen.getByTestId("cc-token-series-chart")).toBeTruthy();
|
||||
expect(screen.getByLabelText("2026-06-09: 900")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-tokens-row-gpt-4o")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-tokens-row-claude-sonnet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("changes the requested endpoint when granularity changes", async () => {
|
||||
apiMock.mockResolvedValue(tokenFixture());
|
||||
render(<TokensArea range={range7d} />);
|
||||
await screen.findByTestId("cc-area-tokens");
|
||||
expect(apiMock.mock.calls.at(-1)?.[0]).toContain("granularity=day");
|
||||
|
||||
fireEvent.click(screen.getByTestId("cc-token-granularity-hour"));
|
||||
await waitFor(() => expect(apiMock.mock.calls.at(-1)?.[0]).toContain("granularity=hour"));
|
||||
});
|
||||
|
||||
it("polls the live token value while preserving rendered content", async () => {
|
||||
vi.useFakeTimers();
|
||||
apiMock
|
||||
.mockResolvedValueOnce(tokenFixture())
|
||||
.mockResolvedValueOnce({
|
||||
...tokenFixture(),
|
||||
totals: { ...tokenFixture().totals, totalTokens: 1700 },
|
||||
});
|
||||
|
||||
render(<TokensArea range={range7d} />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500");
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,700");
|
||||
expect(screen.getByTestId("cc-token-series-chart")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refetches when the date range changes", async () => {
|
||||
apiMock.mockResolvedValue(tokenFixture());
|
||||
const { rerender } = render(<TokensArea range={range7d} />);
|
||||
@@ -208,6 +333,7 @@ describe("TokensArea", () => {
|
||||
totals: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 },
|
||||
cost: { usd: null, unavailable: true, stale: false },
|
||||
groups: [],
|
||||
series: [],
|
||||
});
|
||||
render(<TokensArea range={range7d} />);
|
||||
await screen.findByTestId("cc-area-tokens-empty");
|
||||
|
||||
@@ -19,6 +19,13 @@ Area headings, table metadata, and empty states use --text-muted so the analytic
|
||||
gap: var(--space-2, 0.5rem);
|
||||
}
|
||||
|
||||
.cc-area-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.cc-area-section-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm, 0.85rem);
|
||||
@@ -40,6 +47,56 @@ Area headings, table metadata, and empty states use --text-muted so the analytic
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:CommandCenterTokens 2026-06-18-15:14:
|
||||
The Tokens area needs a real hour/day/week control and live token-number motion. Keep controls on existing .btn styling, make count-up motion decorative, and collapse the control cleanly on mobile.
|
||||
*/
|
||||
.cc-token-granularity {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.cc-token-granularity .btn.active {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, var(--surface-1));
|
||||
}
|
||||
|
||||
.cc-token-count-live {
|
||||
animation: cc-token-count-pop var(--duration-normal) ease-out both;
|
||||
}
|
||||
|
||||
@keyframes cc-token-count-pop {
|
||||
from {
|
||||
transform: translateY(var(--space-1));
|
||||
opacity: 0.7;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cc-token-count-live {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cc-area-section-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cc-token-granularity {
|
||||
justify-content: flex-start;
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Tables ---- */
|
||||
.cc-table-wrap {
|
||||
overflow-x: auto;
|
||||
|
||||
@@ -11,6 +11,17 @@ export interface AnalyticsAreaState<T> {
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export interface AnalyticsAreaOptions {
|
||||
/** Opt-in bounded polling interval in milliseconds; omitted means no polling. */
|
||||
pollMs?: number;
|
||||
}
|
||||
|
||||
function withRangeQuery(endpoint: string, query: string): string {
|
||||
if (query === "") return endpoint;
|
||||
const suffix = query.slice(1);
|
||||
return `${endpoint}${endpoint.includes("?") ? "&" : "?"}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one Command Center analytics endpoint for the selected date range.
|
||||
*
|
||||
@@ -20,6 +31,8 @@ export interface AnalyticsAreaState<T> {
|
||||
* - Keeps the previous `data` visible across a refetch so revalidation does not
|
||||
* flash the empty/loading state (and so consumers' derived-keyed effects can
|
||||
* distinguish a real content change from a re-fetch of identical content).
|
||||
* - Polling is opt-in via `options.pollMs`; invalid ranges never schedule an
|
||||
* interval, and the interval is cleaned up on unmount/range/endpoint changes.
|
||||
*
|
||||
* NOTE on the SWR-identity trap: this hook intentionally replaces `data`
|
||||
* identity on every successful fetch. Consumers MUST key any selection / sort /
|
||||
@@ -29,6 +42,7 @@ export interface AnalyticsAreaState<T> {
|
||||
export function useAnalyticsArea<T>(
|
||||
endpoint: string,
|
||||
range: DateRange,
|
||||
options: AnalyticsAreaOptions = {},
|
||||
): AnalyticsAreaState<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -46,7 +60,7 @@ export function useAnalyticsArea<T>(
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api<T>(`${endpoint}${query}`);
|
||||
const result = await api<T>(withRangeQuery(endpoint, query));
|
||||
setData(result);
|
||||
} catch (loadError: unknown) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load analytics");
|
||||
@@ -59,6 +73,16 @@ export function useAnalyticsArea<T>(
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (invalid || options.pollMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const interval = window.setInterval(() => {
|
||||
void load();
|
||||
}, options.pollMs);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [invalid, load, options.pollMs]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TokenTimePoint } from "@fusion/core";
|
||||
import { formatCount } from "../areas/areaShared";
|
||||
import "./charts.css";
|
||||
|
||||
export interface TokenSeriesChartProps {
|
||||
points: TokenTimePoint[];
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
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(100, (value / denom) * 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:CommandCenterCharts 2026-06-18-15:14:
|
||||
* Token usage over time must render as a reduced-motion-safe, hand-rolled CSS chart that handles empty, sparse, and all-zero buckets without NaN geometry. Bars are positional because adjacent buckets can repeat labels or totals.
|
||||
*/
|
||||
export function TokenSeriesChart({ points, ariaLabel }: TokenSeriesChartProps) {
|
||||
const max = points.reduce((m, p) => (p.totalTokens > m ? p.totalTokens : m), 0);
|
||||
|
||||
return (
|
||||
<div className="cc-token-series" role="img" aria-label={ariaLabel} data-testid="cc-token-series-chart">
|
||||
<div className="cc-token-series-plot">
|
||||
{points.length === 0 ? (
|
||||
<div className="cc-token-series-empty" aria-hidden="true" data-testid="cc-token-series-empty" />
|
||||
) : (
|
||||
points.map((point, i) => {
|
||||
const height = safeHeightPercent(point.totalTokens, max);
|
||||
const label = `${point.bucket}: ${formatCount(point.totalTokens)}`;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="cc-token-series-bar"
|
||||
style={{ height: `${height}%` }}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="cc-token-series-axis" aria-hidden="true">
|
||||
<span>{points[0]?.bucket ?? "—"}</span>
|
||||
<span>{points.at(-1)?.bucket ?? "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +118,82 @@ Chart labels and legends must use --text-muted so command-center CSS stays align
|
||||
transition: height var(--transition-normal);
|
||||
}
|
||||
|
||||
/* ---- TokenSeriesChart ---- */
|
||||
/*
|
||||
FNXC:CommandCenterStyling 2026-06-18-15:14:
|
||||
The token-over-time chart is live-updated and animated, but the motion is decorative. Use tokenized dimensions/colors and disable height animation for reduced-motion users while keeping empty and all-zero data readable.
|
||||
*/
|
||||
.cc-token-series {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.cc-token-series-plot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-1);
|
||||
block-size: clamp(var(--space-16), 24vw, calc(var(--space-20) * 2));
|
||||
padding: var(--space-3);
|
||||
border: var(--border-width) solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--color-accent) 10%, transparent), transparent), var(--surface-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cc-token-series-bar {
|
||||
flex: 1 1 0;
|
||||
min-inline-size: var(--space-1);
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
background: linear-gradient(180deg, var(--color-accent), color-mix(in srgb, var(--color-accent) 45%, var(--surface-2)));
|
||||
box-shadow: 0 0 var(--space-2) color-mix(in srgb, var(--color-accent) 24%, transparent);
|
||||
transition: height var(--transition-normal);
|
||||
animation: cc-token-series-rise var(--duration-normal) ease-out both;
|
||||
}
|
||||
|
||||
.cc-token-series-empty {
|
||||
inline-size: 100%;
|
||||
block-size: var(--border-width-thick);
|
||||
align-self: flex-end;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
.cc-token-series-axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@keyframes cc-token-series-rise {
|
||||
from {
|
||||
transform: scaleY(0.82);
|
||||
opacity: 0.72;
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cc-token-series-bar {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cc-token-series-plot {
|
||||
block-size: clamp(var(--space-14), 38vw, calc(var(--space-20) + var(--space-12)));
|
||||
padding: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- LineChart ---- */
|
||||
/*
|
||||
FNXC:CommandCenterStyling 2026-06-18-14:29:
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
registerCommandCenterRoutes,
|
||||
resolveRange,
|
||||
resolveGroupBy,
|
||||
resolveTokenGranularity,
|
||||
DEFAULT_WINDOW_DAYS,
|
||||
} from "../routes/register-command-center-routes.js";
|
||||
import type { ApiRoutesContext } from "../routes/types.js";
|
||||
@@ -132,10 +133,37 @@ describe("register-command-center-routes", () => {
|
||||
expect(body).toHaveProperty("totals");
|
||||
expect(body).toHaveProperty("cost");
|
||||
expect(body).toHaveProperty("groups");
|
||||
expect(body).not.toHaveProperty("series");
|
||||
expect(body.groupBy).toBe("model");
|
||||
expect((body.totals as { totalTokens: number }).totalTokens).toBe(200);
|
||||
});
|
||||
|
||||
it("returns token time-series buckets when granularity is requested", async () => {
|
||||
const res = await request(
|
||||
app,
|
||||
"GET",
|
||||
"/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&groupBy=model&granularity=day&projectId=proj-a",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { series?: { bucket: string; totalTokens: number; cost: unknown }[] };
|
||||
expect(body.series).toEqual([
|
||||
expect.objectContaining({ bucket: "2026-03-01", totalTokens: 200 }),
|
||||
]);
|
||||
expect(body.series?.[0]).toHaveProperty("cost");
|
||||
});
|
||||
|
||||
it("ignores invalid token granularity rather than erroring", async () => {
|
||||
const res = await request(
|
||||
app,
|
||||
"GET",
|
||||
"/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&granularity=minute&projectId=proj-a",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body as Record<string, unknown>).not.toHaveProperty("series");
|
||||
});
|
||||
|
||||
it("returns the tools / activity / productivity aggregator shapes", async () => {
|
||||
const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z";
|
||||
const tools = await request(app, "GET", `/api/command-center/tools?${range}&projectId=proj-a`);
|
||||
@@ -317,7 +345,7 @@ describe("register-command-center-routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRange / resolveGroupBy (param parsing)", () => {
|
||||
describe("resolveRange / resolveGroupBy / resolveTokenGranularity (param parsing)", () => {
|
||||
const NOW = Date.parse("2026-06-15T00:00:00.000Z");
|
||||
|
||||
it("uses valid, ordered ISO bounds as-is", () => {
|
||||
@@ -358,6 +386,14 @@ describe("resolveRange / resolveGroupBy (param parsing)", () => {
|
||||
expect(resolveGroupBy({ groupBy: "bogus" })).toBeUndefined();
|
||||
expect(resolveGroupBy({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts known token granularities and ignores unknown ones", () => {
|
||||
expect(resolveTokenGranularity({ granularity: "hour" })).toBe("hour");
|
||||
expect(resolveTokenGranularity({ granularity: "day" })).toBe("day");
|
||||
expect(resolveTokenGranularity({ granularity: "week" })).toBe("week");
|
||||
expect(resolveTokenGranularity({ granularity: "minute" })).toBeUndefined();
|
||||
expect(resolveTokenGranularity({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("vite /api proxy negative-lookahead (proxy verification)", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
aggregateProductivityAnalytics,
|
||||
composeLiveSnapshot,
|
||||
type TokenGroupBy,
|
||||
type TokenTimeGranularity,
|
||||
} from "@fusion/core";
|
||||
import type { Request, Response } from "express";
|
||||
import { ApiError } from "../api-error.js";
|
||||
@@ -55,6 +56,12 @@ const VALID_GROUP_BY: ReadonlySet<string> = new Set<TokenGroupBy>([
|
||||
"agent",
|
||||
]);
|
||||
|
||||
const VALID_TOKEN_GRANULARITY: ReadonlySet<string> = new Set<TokenTimeGranularity>([
|
||||
"hour",
|
||||
"day",
|
||||
"week",
|
||||
]);
|
||||
|
||||
/** A resolved, always-valid `[from, to]` ISO range. */
|
||||
export interface ResolvedRange {
|
||||
from: string;
|
||||
@@ -103,6 +110,12 @@ export function resolveGroupBy(query: Request["query"]): TokenGroupBy | undefine
|
||||
return raw !== undefined && VALID_GROUP_BY.has(raw) ? (raw as TokenGroupBy) : undefined;
|
||||
}
|
||||
|
||||
/** Resolve the token-series `granularity` query param, ignoring unknown values. */
|
||||
export function resolveTokenGranularity(query: Request["query"]): TokenTimeGranularity | undefined {
|
||||
const raw = typeof query.granularity === "string" ? query.granularity : undefined;
|
||||
return raw !== undefined && VALID_TOKEN_GRANULARITY.has(raw) ? (raw as TokenTimeGranularity) : undefined;
|
||||
}
|
||||
|
||||
/** True when the caller asked for CSV via `?format=csv` (case-insensitive). */
|
||||
export function wantsCsv(query: Request["query"]): boolean {
|
||||
const raw = typeof query.format === "string" ? query.format : undefined;
|
||||
@@ -133,10 +146,12 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const store = await getScopedStore(req);
|
||||
const range = resolveRange(req.query);
|
||||
const groupBy = resolveGroupBy(req.query);
|
||||
const granularity = resolveTokenGranularity(req.query);
|
||||
const result = aggregateTokenAnalytics(store.getDatabase(), {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
groupBy,
|
||||
granularity,
|
||||
now: Date.now(),
|
||||
});
|
||||
if (wantsCsv(req.query)) {
|
||||
|
||||
Reference in New Issue
Block a user