feat(frontend): add a token usage dashboard for the console APIs (#7636)

* feat(frontend): add token usage dashboard over console APIs

* fix(frontend): address token usage dashboard review feedback

* fix(frontend): route usage runs to their custom agent chats

* docs(frontend): keep agent guidance within the module budget
This commit is contained in:
minibeartwo 2026-11-02 15:29:46 +08:00 committed by GitHub
parent 21175ba46f
commit 7c3d2551b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1689 additions and 18 deletions

View File

@ -1696,6 +1696,26 @@ Use it as-is. Or tear it apart and make it yours.
## Core Features
### Token usage dashboard
Open **Token usage** in the workspace sidebar (`/workspace/usage`) to review
your recorded tokens over the last 7 or 30 days, daily input/output totals,
cache-hit input tokens by model, and paginated run history with failure filters.
Run history covers all dates independently of the selected chart period.
Conversation links use each run's recorded assistant to open the default or
matching custom-agent chat.
Periods with no recorded runs show an empty message without a date axis or
zero-filled daily details; runs with zero reported tokens still show details.
Reporting requires `runs:read` permission and a SQLite or PostgreSQL database;
it is unavailable in the static demo. Counts reflect persisted provider usage,
may be incomplete during active runs or when providers omit usage, and are not
a provider bill. Cache hits are included in input tokens. Daily buckets use the
browser's current UTC offset and attribute a run to its creation date.
The page reuses the existing `/api/console` APIs; their optional cost and currency
fields and server-side pricing configuration remain supported.
See [dashboard behavior and design](frontend/docs/token-usage-dashboard.md).
### Workspace
The workspace keeps a collapsed icon sidebar visible on narrow screens. Its

View File

@ -712,6 +712,18 @@ DeerFlow 2.0 不再是一个需要你自己拼装的 framework。它是一个开
## 核心特性
### Token 用量统计
点击侧边栏的 **Token 用量**(`/workspace/usage`),查看近 7/30 天的用量、
每日输入/输出 Token、各模型的缓存命中输入 Token,以及支持失败筛选和分页的
运行记录。运行记录包含所有日期,不受图表所选周期限制。
此功能需要 `runs:read` 权限和 SQLite 或 PostgreSQL 数据库,静态演示不提供统计。
数据来自已持久化的服务商用量;运行过程中或服务商未返回用量时可能不完整,不能
作为服务商账单。缓存命中 Token 已包含在输入 Token 中。每日分桶使用浏览器当前
UTC 偏移,并按运行的创建日期归属。页面复用现有 `/api/console` 接口,保留服务端
价格配置及可选的费用、币种字段。
### Skills 与 Tools
Skills 是 DeerFlow 能做“几乎任何事”的关键。

View File

@ -1,16 +1,16 @@
# AGENTS.md
DeerFlow frontend guidance source of truth; `CLAUDE.md` imports `@AGENTS.md`.
Frontend guidance; `CLAUDE.md` imports `@AGENTS.md`.
## Project Overview
DeerFlow's Next.js 16 frontend provides thread-based AI conversations with streaming responses, artifacts, skills, and tools through a LangGraph backend.
Next.js 16 frontend for LangGraph conversations, streaming, artifacts, skills and tools.
**Stack**: Next.js 16, React 19, TypeScript 5.8, Tailwind CSS 4, pnpm 10.26.2. Requires Node.js 22+ and pnpm 10.26.2+.
Core dependencies: `@langchain/langgraph-sdk` ^1.5.3 for orchestration/streaming,
`@langchain/core` ^1.1.15, and `@tanstack/react-query` ^5.90.17 for server state.
Registry UI: Shadcn, MagicUI, React Bits, Vercel AI SDK (see Code Style).
Dependencies: `@langchain/langgraph-sdk` ^1.5.3, `@langchain/core` ^1.1.15,
`@tanstack/react-query` ^5.90.17. Registry UI: Shadcn, MagicUI, React Bits,
Vercel AI SDK (see Code Style).
`pnpm-workspace.yaml` pins vulnerable `@xmldom/xmldom` 0.9.x to patched
0.9.12 (GHSA-965w-775f-mr7g) and Nextra 4.6.1's Zod dependencies to 4.3.6
@ -22,28 +22,32 @@ docs builds after changes.
Run from `frontend/`; host pnpm calls use `../scripts/pnpm.py`.
- `pnpm dev`: Webpack development server; `pnpm build` / `pnpm start`: production build/server.
- `pnpm check`: ESLint + TypeScript (required before committing); `pnpm lint` / `pnpm typecheck`: individual checks; `pnpm lint:fix`: ESLint fixes.
- `pnpm dev`: development; `pnpm build` / `pnpm start`: production build/server.
- Before committing: `pnpm check` (ESLint + TypeScript). Separately: `pnpm lint` / `pnpm typecheck`; ESLint fixes: `pnpm lint:fix`.
- `pnpm format` / `pnpm format:write`: Prettier check/write.
- `pnpm test`: Rstest unit tests; `pnpm test:e2e`: Playwright Chromium E2E tests.
Unit tests under `tests/unit/` mirror `src/`; import source via `@/`.
Webpack is the default development bundler. Use `DEER_FLOW_DEV_BUNDLER=turbo` with `pnpm dev` to opt in to Turbopack when diagnosing a local Next.js bundler issue.
Webpack is the default; use `DEER_FLOW_DEV_BUNDLER=turbo pnpm dev` to diagnose with Turbopack.
On Windows `pnpm dev` binds `127.0.0.1` by default because Hyper-V/winnat excluded port ranges can reject Next's default `0.0.0.0` bind with `EACCES` (#2870). Pass `pnpm dev -- --hostname 0.0.0.0` to listen on a LAN interface instead.
Windows dev binds `127.0.0.1` to avoid Hyper-V/winnat `EACCES` (#2870).
For LAN access: `pnpm dev -- --hostname 0.0.0.0`.
`rstest.config.ts`: **node** runs `*.test.*` pure logic; **happy-dom** runs
`*.dom.test.*` components/hooks via `@testing-library/react` render/renderHook.
DOM costs ~3x: keep the split. Effect ordering, unmount cleanup and store
re-renders need DOM tests with real React.
`rstest.config.ts`: **node** for `*.test.*` logic; **happy-dom** for `*.dom.test.*`
components/hooks via `@testing-library/react` render/renderHook. Keep the split
(DOM costs ~3x). Test effect ordering, unmount cleanup and store re-renders with real React/DOM.
Content/state suites may mock decorative `FlipDisplay` with
`tests/unit/helpers/flip-display.tsx`, preserving children, classes and keyed
remounts. Happy DOM 21's animation cancellation rejects `finished`; keep rejection
reporting enabled. Real Motion behavior belongs in Playwright.
Content/state suites may use `tests/unit/helpers/flip-display.tsx` to mock
decorative `FlipDisplay`; preserve children, classes and keyed remounts.
Happy DOM 21 animation cancellation rejects `finished`; keep rejection reporting
enabled. Test real Motion in Playwright.
E2E tests live under `tests/e2e/` and use Playwright with Chromium. They mock all backend APIs via `page.route()` network interception and test real page interactions (navigation, chat input, streaming responses). Config: `playwright.config.ts`. The real-backend auth contract in `tests/e2e-real-backend/auth-disabled-contract.spec.ts` and `backend/tests/test_auth_me_permissions.py` pin the complete route-permission list; update both when adding registered permissions (including `projects:read/write/delete`).
Playwright Chromium E2E: `tests/e2e/`, configured by `playwright.config.ts`.
Mock backend APIs with `page.route()`; exercise real navigation, chat input and streaming.
When registering permissions (including `projects:read/write/delete`), update both
`tests/e2e-real-backend/auth-disabled-contract.spec.ts` and
`backend/tests/test_auth_me_permissions.py`, which pin the complete route-permission list.
CI runs four E2E shards; only shard 1 runs auth recovery. Suite reports stay separate.
@ -77,6 +81,15 @@ Keep IM notification preferences viewer-fenced and separate from browser alerts;
## Architecture
Token usage: `app/workspace/usage` composes `components/workspace/usage` and
`core/usage` over the existing owner-scoped console APIs. Keep owner, period,
timezone, status and page in query keys; consume AbortSignals and never show
another owner's or period's placeholder totals. Gate on `runs:read` and static
mode. Display tokens without changing optional backend pricing fields.
See [design and reporting semantics](docs/token-usage-dashboard.md).
Run links reuse `pathOfThread`; `lead_agent` and absent assistant IDs select
normal chats, while custom assistant aliases follow Gateway normalization.
Model controls: `core/models/use-model-control` + HumanInputCard.
Knowledge evidence is parsed in `core/citations/sources.ts` from version-1

View File

@ -0,0 +1,73 @@
# Token usage dashboard
The workspace page at `/workspace/usage` presents the existing current-user
reporting APIs introduced in upstream PR #3920. It adds no database tables,
permissions, provider calls, billing logic, dependencies, or backend API changes.
## Extension-first evaluation
A packaged extension can register a page and sidebar navigation through
`PluginContribution` and `BrowserAssets`. That is a viable alternative when
operators want an optional, independently distributed reporting product.
This contribution proposes a built-in view of DeerFlow's existing console APIs.
It is presentation of an existing core capability, with shared React components,
locale types, authenticated fetch and account-scoped query caching. The page
does not introduce a second reporting service or token collector. A packaged
implementation would additionally need its installation/lifecycle and a browser
module or namespaced backend action adapter; the page mount context currently
exposes `callBackend` for plugin actions rather than a typed console reader.
Both approaches can express the feature. The built-in proposal keeps the existing
API and its authorization boundary unchanged and makes its reports discoverable
without an extra deployment installation. Maintainer agreement on this product
placement is still needed before upstream submission; this rationale does not
claim that extension hooks are incapable of supporting it.
The author's fork associated with #3920 already contains a console UI. This
contribution complements the merged API; it does not claim to introduce token
accounting or the first dashboard implementation.
## Reporting contract
- `GET /api/console/usage` supplies 7/30-day totals, daily buckets and per-model
counts. Browser UTC offset is sent as `tz_offset_minutes`. Existing fixed-offset
semantics apply across the whole window, including daylight-saving transitions.
- The API attributes all recorded tokens in a run to its creation date, rather
than the wall-clock date of each individual model call. Active runs may still
have partial persisted counters; providers that omit usage can leave gaps.
- Cache-hit input tokens are a subset of input tokens, not an additional amount
to add to total tokens. Model run counts are non-exclusive for multi-model runs.
- `GET /api/console/runs` supplies 20 rows per page and a `has_more` sentinel.
All/error/timeout filters reset pagination. This history is independent of the
chart's period. Offset pagination is live, not a snapshot.
- Run links reuse `pathOfThread` and the recorded `assistant_id`. The built-in
`lead_agent` and absent/empty legacy IDs open normal chats. Custom assistant
aliases are trimmed, lowercased and have underscores changed to hyphens,
matching the Gateway identity convention; they open that agent's chat route.
Thread IDs are encoded as opaque path segments by the shared route helper.
- Counts are provider-reported usage recorded by DeerFlow, not provider invoices.
The view displays no estimated prices. Existing optional `cost`, `total_cost`
and `currency` response fields, configuration and tests remain unchanged.
## Access and failure behavior
The sidebar entry and queries require a current user with `runs:read`. Static
demos issue no usage requests. The Gateway enforces authorization and owner scope;
the frontend does not grant access. Memory-only storage reports 503 and the page
explains the SQLite/PostgreSQL requirement. Forbidden, unavailable, loading and
empty reports remain distinct; failed requests never become fabricated zeroes.
Query keys include owner and report filters. The authenticated workspace owns
cache lifetime; consumed AbortSignals cancel obsolete reads. Switching period
does not display the previous period as placeholder data. Only the first run
page refreshes on focus/reconnect. Refresh explicitly reloads both panels.
## Verification
Run `pnpm check`, `pnpm rstest run tests/unit/core/usage` and
`pnpm exec playwright test tests/e2e/usage-dashboard.spec.ts` from `frontend/`.
The browser suite uses mocked APIs and exercises navigation, periods, timezone,
failure recovery, filtering, pagination, English and Chinese mobile layout.
The dashboard tests deliberately include the upstream monetary response fields
to prove that token-only presentation does not require removing those fields.

View File

@ -0,0 +1,25 @@
"use client";
import { useEffect } from "react";
import { UsageDashboard } from "@/components/workspace/usage/usage-dashboard";
import {
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
export default function UsagePage() {
const { t } = useI18n();
useEffect(() => {
document.title = `${t.usage.title} - ${t.pages.appName}`;
}, [t.usage.title, t.pages.appName]);
return (
<WorkspaceContainer>
<WorkspaceHeader />
<main className="min-h-0 flex-1 overflow-y-auto">
<UsageDashboard />
</main>
</WorkspaceContainer>
);
}

View File

@ -0,0 +1,36 @@
import { Button } from "@/components/ui/button";
import { GatewayApiError } from "@/core/api/errors";
import type { Translations } from "@/core/i18n/locales/types";
export function UsageQueryError({
error,
retry,
text,
}: {
error: Error;
retry: () => void;
text: Translations["usage"];
}) {
const status = error instanceof GatewayApiError ? error.status : null;
const message =
status === 403
? text.forbidden
: status === 404
? text.unavailable
: status === 503
? text.databaseRequired
: text.loadFailed;
return (
<div
role="alert"
className="flex flex-wrap items-center gap-3 rounded-xl border p-5 text-sm"
>
<p className="flex-1">{message}</p>
{status !== 403 && status !== 404 && (
<Button variant="outline" size="sm" onClick={retry}>
{text.retry}
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,174 @@
"use client";
import { RefreshCw } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { useAuth } from "@/core/auth/AuthProvider";
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
import { useI18n } from "@/core/i18n/hooks";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { useUsage, useUsageRuns } from "@/core/usage/hooks";
import type { UsagePeriod, UsageRunFilter } from "@/core/usage/types";
import { UsageQueryError } from "./query-error";
import { UsageOverview } from "./usage-overview";
import { UsageRuns } from "./usage-runs";
export function UsageDashboard() {
const { user } = useAuth();
const { t, locale } = useI18n();
const text = t.usage;
const [period, setPeriod] = useState<UsagePeriod>(7);
const [page, setPage] = useState(0);
const [status, setStatus] = useState<UsageRunFilter>("all");
const timezoneOffsetMinutes = -new Date().getTimezoneOffset();
const allowed = !!user && hasPermission(user, PERMISSIONS.RUNS_READ);
const staticMode = isStaticWebsiteOnly();
const usage = useUsage(
user?.id,
period,
timezoneOffsetMinutes,
allowed && !staticMode,
);
const runs = useUsageRuns(user?.id, page, status, allowed && !staticMode);
const busy = usage.isFetching || runs.isFetching;
if (staticMode)
return (
<p role="status" className="p-6">
{text.staticUnavailable}
</p>
);
if (!allowed)
return (
<p role="alert" className="p-6">
{user ? text.forbidden : text.unavailable}
</p>
);
return (
<div className="mx-auto w-full max-w-6xl space-y-6 p-4 pb-12 sm:p-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{text.title}
</h1>
<p className="text-muted-foreground mt-1 text-sm">
{text.description}
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => {
void usage.refetch();
void runs.refetch();
}}
>
<RefreshCw
className={busy ? "size-4 animate-spin" : "size-4"}
aria-hidden="true"
/>
{text.refresh}
</Button>
</header>
<p className="text-muted-foreground text-sm">{text.reportingNote}</p>
<div role="group" aria-label={text.period} className="flex gap-2">
{([7, 30] as const).map((days) => (
<Button
key={days}
size="sm"
variant={period === days ? "default" : "outline"}
aria-pressed={period === days}
onClick={() => setPeriod(days)}
>
{days === 7 ? text.last7Days : text.last30Days}
</Button>
))}
</div>
<section aria-label={text.period} aria-busy={usage.isFetching}>
{usage.isError ? (
<UsageQueryError
error={usage.error}
retry={() => void usage.refetch()}
text={text}
/>
) : usage.data ? (
<UsageOverview usage={usage.data} locale={locale} text={text} />
) : (
<Loading label={text.loading} />
)}
</section>
<Card>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="font-semibold">{text.recentRuns}</h2>
<label className="flex items-center gap-2 text-sm">
{text.status}
<select
className="bg-background rounded-md border px-3 py-2"
value={status}
onChange={(event) => {
setStatus(event.target.value as UsageRunFilter);
setPage(0);
}}
>
<option value="all">{text.allStatuses}</option>
<option value="error">{text.error}</option>
<option value="timeout">{text.timeout}</option>
</select>
</label>
</div>
<p className="text-muted-foreground text-sm">{text.recentRunsHint}</p>
</CardHeader>
<CardContent className="space-y-4" aria-busy={runs.isFetching}>
{runs.isError ? (
<UsageQueryError
error={runs.error}
retry={() => void runs.refetch()}
text={text}
/>
) : runs.data ? (
<UsageRuns runs={runs.data.runs} locale={locale} text={text} />
) : (
<Loading label={text.loading} />
)}
<div className="flex flex-wrap items-center justify-end gap-3 text-sm">
<Button
variant="outline"
size="sm"
disabled={page === 0 || runs.isFetching}
onClick={() => setPage((value) => Math.max(0, value - 1))}
>
{text.previous}
</Button>
<span aria-live="polite">{text.page(page + 1)}</span>
<Button
variant="outline"
size="sm"
disabled={!runs.data?.has_more || runs.isFetching || runs.isError}
onClick={() => setPage((value) => value + 1)}
>
{text.next}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
function Loading({ label }: { label: string }) {
return (
<div
role="status"
className="text-muted-foreground animate-pulse rounded-xl border p-8 text-sm"
>
{label}
</div>
);
}

View File

@ -0,0 +1,204 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import type { Translations } from "@/core/i18n/locales/types";
import { formatUsageDate, modelUsageRows } from "@/core/usage/format";
import type { UsageSummary } from "@/core/usage/types";
const cell = "px-4 py-3 text-right whitespace-nowrap tabular-nums";
export function UsageOverview({
usage,
locale,
text,
}: {
usage: UsageSummary;
locale: string;
text: Translations["usage"];
}) {
const count = (value: number) => value.toLocaleString(locale);
const models = modelUsageRows(usage.by_model);
const peak = Math.max(1, ...usage.days.map((day) => day.total_tokens));
const input = usage.days.reduce((sum, day) => sum + day.input_tokens, 0);
const output = usage.days.reduce((sum, day) => sum + day.output_tokens, 0);
const hasRuns = usage.total_runs > 0;
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Summary
label={text.tokens}
value={count(usage.total_tokens)}
testId="usage-total-tokens"
/>
<Summary
label={text.runs}
value={count(usage.total_runs)}
testId="usage-total-runs"
/>
</div>
<Card>
<CardHeader>
<h2 className="font-semibold">{text.daily}</h2>
<p className="text-muted-foreground text-sm">{text.timezoneNote}</p>
<p className="text-muted-foreground text-sm tabular-nums">
{text.inputTokens}: {count(input)} · {text.outputTokens}:{" "}
{count(output)}
</p>
</CardHeader>
<CardContent className="space-y-5">
{!hasRuns ? (
<p className="text-muted-foreground py-8 text-center text-sm">
{text.noUsage}
</p>
) : (
<div
aria-hidden="true"
className="flex h-40 items-end gap-1 border-b px-1"
>
{usage.days.map((day) => (
<div
key={day.date}
className="flex h-full min-w-0 flex-1 items-end"
title={`${formatUsageDate(day.date, locale)}: ${count(day.total_tokens)}`}
>
<div
className="bg-primary/75 hover:bg-primary w-full rounded-t-sm transition-colors"
style={{
height: `${Math.max(day.total_tokens > 0 ? 2 : 0, (day.total_tokens / peak) * 100)}%`,
}}
/>
</div>
))}
</div>
)}
{hasRuns && usage.days.length > 0 && (
<div
className="text-muted-foreground flex justify-between text-xs"
aria-hidden="true"
>
<span>{formatUsageDate(usage.days[0]!.date, locale)}</span>
<span>
{formatUsageDate(
usage.days[usage.days.length - 1]!.date,
locale,
)}
</span>
</div>
)}
{hasRuns && (
<details>
<summary className="cursor-pointer text-sm font-medium">
{text.dailyDetails}
</summary>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm" aria-label={text.daily}>
<thead>
<tr className="text-muted-foreground border-b">
<th scope="col" className="px-4 py-3 text-left">
{text.date}
</th>
<th scope="col" className={cell}>
{text.inputTokens}
</th>
<th scope="col" className={cell}>
{text.outputTokens}
</th>
<th scope="col" className={cell}>
{text.runs}
</th>
</tr>
</thead>
<tbody>
{usage.days.map((day) => (
<tr key={day.date} className="border-b last:border-0">
<th
scope="row"
className="px-4 py-3 text-left font-normal whitespace-nowrap"
>
{formatUsageDate(day.date, locale)}
</th>
<td className={cell}>{count(day.input_tokens)}</td>
<td className={cell}>{count(day.output_tokens)}</td>
<td className={cell}>{count(day.runs)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="font-semibold">{text.models}</h2>
</CardHeader>
<CardContent>
{models.length === 0 ? (
<p className="text-muted-foreground py-4 text-sm">
{text.noModels}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label={text.models}>
<thead>
<tr className="text-muted-foreground border-b">
<th scope="col" className="px-4 py-3 text-left">
{text.model}
</th>
<th scope="col" className={cell}>
{text.tokens}
</th>
<th scope="col" className={cell}>
{text.cachedTokens}
</th>
<th scope="col" className={cell}>
{text.runs}
</th>
</tr>
</thead>
<tbody>
{models.map((model) => (
<tr key={model.name} className="border-b last:border-0">
<th
scope="row"
className="max-w-60 px-4 py-3 text-left font-medium break-words"
>
{model.name}
</th>
<td className={cell}>{count(model.tokens)}</td>
<td className={cell}>{count(model.cache_read_tokens)}</td>
<td className={cell}>{count(model.runs)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
function Summary({
label,
value,
testId,
}: {
label: string;
value: string;
testId: string;
}) {
return (
<Card className="gap-2">
<CardHeader className="text-muted-foreground text-sm">{label}</CardHeader>
<CardContent
className="text-2xl font-semibold tabular-nums"
data-testid={testId}
>
{value}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,135 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import type { Translations } from "@/core/i18n/locales/types";
import { pathOfThread } from "@/core/threads/utils";
import { formatRunDuration } from "@/core/usage/format";
import type { UsageRun } from "@/core/usage/types";
export function UsageRuns({
runs,
locale,
text,
}: {
runs: UsageRun[];
locale: string;
text: Translations["usage"];
}) {
if (runs.length === 0)
return (
<p className="text-muted-foreground py-8 text-center text-sm">
{text.noRuns}
</p>
);
const statusLabels: Record<string, string> = {
pending: text.pending,
running: text.running,
success: text.success,
error: text.error,
timeout: text.timeout,
interrupted: text.interrupted,
};
const cell = "px-4 py-3 text-right whitespace-nowrap tabular-nums";
return (
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label={text.recentRuns}>
<thead>
<tr className="text-muted-foreground border-b">
<th scope="col" className="px-4 py-3 text-left">
{text.conversation}
</th>
<th scope="col" className="px-4 py-3 text-left">
{text.status}
</th>
<th scope="col" className="px-4 py-3 text-left">
{text.model}
</th>
<th scope="col" className={cell}>
{text.started}
</th>
<th scope="col" className={cell}>
{text.duration}
</th>
<th scope="col" className={cell}>
{text.tokens}
</th>
</tr>
</thead>
<tbody>
{runs.map((run) => {
// Match the Gateway's custom_agent_name normalization. The built-in
// lead_agent and legacy rows without an assistant use normal chats.
const agentName =
run.assistant_id && run.assistant_id !== "lead_agent"
? run.assistant_id.trim().toLowerCase().replaceAll("_", "-")
: undefined;
const date = run.created_at ? new Date(run.created_at) : null;
const dateLabel =
date && Number.isFinite(date.getTime())
? new Intl.DateTimeFormat(locale, {
dateStyle: "short",
timeStyle: "short",
}).format(date)
: text.unknown;
return (
<tr key={run.run_id} className="border-b align-top last:border-0">
<th
scope="row"
className="max-w-72 min-w-40 px-4 py-3 text-left font-normal break-words"
>
<Link
href={pathOfThread(run.thread_id, {
agent_name: agentName,
})}
className="underline-offset-4 hover:underline"
>
{run.thread_title?.trim()
? run.thread_title
: text.untitled}
</Link>
{run.error && (
<details className="mt-2 text-xs">
<summary className="text-muted-foreground cursor-pointer">
{text.details}
</summary>
<p className="mt-2 max-w-72 break-words whitespace-pre-wrap">
{run.error}
</p>
</details>
)}
</th>
<td className="px-4 py-3">
<Badge
variant={
run.status === "error" || run.status === "timeout"
? "destructive"
: "secondary"
}
>
{Object.hasOwn(statusLabels, run.status)
? statusLabels[run.status]
: text.unknownStatus}
</Badge>
</td>
<td className="max-w-48 px-4 py-3 break-words">
{run.model_name?.trim() ? run.model_name : text.unknown}
</td>
<td className={cell}>{dateLabel}</td>
<td className={cell}>
{formatRunDuration(
run.duration_seconds,
locale,
text.unknown,
)}
</td>
<td className={cell}>
{run.total_tokens.toLocaleString(locale)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@ -24,6 +24,7 @@ const LINKABLE_SECTIONS: Record<string, true> = {
agents: true,
chats: true,
"scheduled-tasks": true,
usage: true,
};
export function WorkspaceContainer({
@ -142,5 +143,6 @@ function nameOfSegment(
if (segment === "chats") return t.breadcrumb.chats;
if (segment === "scheduled-tasks") return t.sidebar.scheduledTasks;
if (segment === "agents") return t.sidebar.agents;
if (segment === "usage") return t.usage.title;
return segment[0]?.toUpperCase() + segment.slice(1);
}

View File

@ -5,6 +5,7 @@ import {
CalendarClock,
MessagesSquare,
BlocksIcon,
ChartColumn,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@ -21,12 +22,20 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAgentsApiEnabled } from "@/core/agents";
import { useAuth } from "@/core/auth/AuthProvider";
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
import { useI18n } from "@/core/i18n/hooks";
import { isStaticWebsiteOnly } from "@/core/static-mode";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
const { enabled: agentsEnabled } = useAgentsApiEnabled();
const { user } = useAuth();
const usageEnabled =
!!user &&
hasPermission(user, PERMISSIONS.RUNS_READ) &&
!isStaticWebsiteOnly();
return (
<SidebarGroup className="pt-1">
<SidebarMenu>
@ -94,6 +103,19 @@ export function WorkspaceNavChatList() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{usageEnabled && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/workspace/usage")}
asChild
>
<Link className="text-muted-foreground" href="/workspace/usage">
<ChartColumn />
<span>{t.usage.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/workspace/capabilities")}

View File

@ -8,6 +8,7 @@ export const PERMISSIONS = {
THREADS_DELETE: "threads:delete",
RUNS_CANCEL: "runs:cancel",
RUNS_CREATE: "runs:create",
RUNS_READ: "runs:read",
} as const;
/**

View File

@ -12,6 +12,60 @@ import {
import type { Translations } from "./types";
export const enUS: Translations = {
usage: {
title: "Token usage",
description: "View input, output and cache-hit token usage.",
reportingNote:
"Recorded provider usage; active runs and missing provider reports may be incomplete. Cache hits are included in input tokens.",
period: "Usage period",
last7Days: "Last 7 days",
last30Days: "Last 30 days",
refresh: "Refresh",
tokens: "Total tokens",
runs: "Runs",
inputTokens: "Input tokens",
outputTokens: "Output tokens",
cachedTokens: "Cache-hit input tokens",
daily: "Daily token usage",
dailyDetails: "View daily details",
models: "Usage by model",
noUsage: "No usage recorded in this period.",
unknown: "Unknown",
timezoneNote: "Daily buckets use your browser's current UTC offset.",
date: "Date",
model: "Model",
noModels: "No model usage recorded in this period.",
noRuns: "No runs match this filter.",
recentRuns: "Recent runs",
recentRunsHint:
"All dates, newest first. Independent of the usage period above.",
status: "Run status",
allStatuses: "All statuses",
pending: "Queued",
running: "Running",
success: "Completed",
error: "Failed",
timeout: "Timed out",
interrupted: "Interrupted",
cancelled: "Cancelled",
unknownStatus: "Other status",
previous: "Previous page",
next: "Next page",
page: (page) => `Page ${page}`,
started: "Started",
duration: "Duration",
conversation: "Conversation",
untitled: "Untitled conversation",
details: "Error details",
loadFailed: "Usage data could not be loaded. Try again.",
retry: "Retry",
forbidden: "You do not have permission to view usage and runs.",
unavailable: "Usage reporting is unavailable on this server.",
databaseRequired:
"Usage reporting is unavailable. It requires a SQLite or PostgreSQL database. Contact your administrator if the problem persists.",
staticUnavailable: "Usage reporting is not available in the static demo.",
loading: "Loading usage…",
},
// Locale meta
locale: {
localName: "English",

View File

@ -1,6 +1,57 @@
import type { LucideIcon } from "lucide-react";
export interface Translations {
usage: {
title: string;
description: string;
reportingNote: string;
period: string;
last7Days: string;
last30Days: string;
refresh: string;
tokens: string;
runs: string;
inputTokens: string;
outputTokens: string;
cachedTokens: string;
daily: string;
dailyDetails: string;
models: string;
noUsage: string;
unknown: string;
timezoneNote: string;
date: string;
model: string;
noModels: string;
noRuns: string;
recentRuns: string;
recentRunsHint: string;
status: string;
allStatuses: string;
pending: string;
running: string;
success: string;
error: string;
timeout: string;
interrupted: string;
cancelled: string;
unknownStatus: string;
previous: string;
next: string;
page: (page: number) => string;
started: string;
duration: string;
conversation: string;
untitled: string;
details: string;
loadFailed: string;
retry: string;
forbidden: string;
unavailable: string;
databaseRequired: string;
staticUnavailable: string;
loading: string;
};
// Locale meta
locale: {
localName: string;

View File

@ -12,6 +12,59 @@ import {
import type { Translations } from "./types";
export const zhCN: Translations = {
usage: {
title: "Token 用量",
description: "查看输入、输出及缓存命中 Token 用量。",
reportingNote:
"统计来自已记录的服务商用量;运行中或服务商未返回用量时可能不完整。缓存命中已包含在输入 Token 中。",
period: "统计周期",
last7Days: "近 7 天",
last30Days: "近 30 天",
refresh: "刷新",
tokens: "Token 总量",
runs: "运行次数",
inputTokens: "输入 Token",
outputTokens: "输出 Token",
cachedTokens: "缓存命中输入 Token",
daily: "每日 Token 用量",
dailyDetails: "查看每日明细",
models: "模型用量分布",
noUsage: "此周期内暂无用量记录。",
unknown: "未知",
timezoneNote: "每日统计使用浏览器当前的 UTC 时区偏移。",
date: "日期",
model: "模型",
noModels: "此周期内暂无模型用量记录。",
noRuns: "没有符合筛选条件的运行。",
recentRuns: "最近运行",
recentRunsHint: "包含所有日期,按最新时间排序,不受上方统计周期限制。",
status: "运行状态",
allStatuses: "全部状态",
pending: "排队中",
running: "运行中",
success: "已完成",
error: "失败",
timeout: "超时",
interrupted: "已中断",
cancelled: "已取消",
unknownStatus: "其他状态",
previous: "上一页",
next: "下一页",
page: (page) => `第 ${page} 页`,
started: "开始时间",
duration: "耗时",
conversation: "会话",
untitled: "未命名会话",
details: "错误详情",
loadFailed: "用量数据加载失败,请重试。",
retry: "重试",
forbidden: "你没有查看用量与运行记录的权限。",
unavailable: "当前服务器暂不支持用量统计。",
databaseRequired:
"用量统计暂不可用,需要 SQLite 或 PostgreSQL 数据库。如问题持续,请联系管理员。",
staticUnavailable: "静态演示模式不提供用量统计。",
loading: "正在加载用量…",
},
// Locale meta
locale: {
localName: "中文",

View File

@ -0,0 +1,37 @@
import { throwGatewayApiError } from "@/core/api/errors";
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type { UsageRunFilter, UsageRunsResponse, UsageSummary } from "./types";
async function readConsole<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(`${getBackendBaseURL()}/api/console/${path}`, {
signal,
});
if (!response.ok) {
await throwGatewayApiError(response, "Unable to load usage data");
}
return response.json() as Promise<T>;
}
export function fetchUsage(
days: number,
timezoneOffsetMinutes: number,
signal?: AbortSignal,
): Promise<UsageSummary> {
const query = new URLSearchParams({
days: String(days),
tz_offset_minutes: String(timezoneOffsetMinutes),
});
return readConsole(`usage?${query.toString()}`, signal);
}
export function fetchUsageRuns(
offset: number,
status: UsageRunFilter,
signal?: AbortSignal,
): Promise<UsageRunsResponse> {
const query = new URLSearchParams({ limit: "20", offset: String(offset) });
if (status !== "all") query.set("status", status);
return readConsole(`runs?${query.toString()}`, signal);
}

View File

@ -0,0 +1,31 @@
import type { ModelUsage } from "./types";
export function modelUsageRows(models: Record<string, ModelUsage>) {
return Object.entries(models)
.map(([name, usage]) => ({ name, ...usage }))
.sort((a, b) => b.tokens - a.tokens || a.name.localeCompare(b.name));
}
/** Day buckets are calendar dates, not UTC instants to shift a second time. */
export function formatUsageDate(date: string, locale: string): string {
return new Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
timeZone: "UTC",
}).format(new Date(`${date}T12:00:00Z`));
}
export function formatRunDuration(
seconds: number | null,
locale: string,
unknown: string,
): string {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0)
return unknown;
return new Intl.NumberFormat(locale, {
style: "unit",
unit: seconds >= 60 ? "minute" : "second",
unitDisplay: "short",
maximumFractionDigits: 1,
}).format(seconds >= 60 ? seconds / 60 : seconds);
}

View File

@ -0,0 +1,38 @@
import { useQuery } from "@tanstack/react-query";
import { fetchUsage, fetchUsageRuns } from "./api";
import type { UsagePeriod, UsageRunFilter } from "./types";
// The workspace QueryClient is already account-scoped; explicit owner keys
// also fence requests when auth is unknown or a caller mounts us elsewhere.
export function useUsage(
owner: string | undefined,
days: UsagePeriod,
timezoneOffsetMinutes: number,
enabled: boolean,
) {
return useQuery({
queryKey: ["usage", owner, "window", days, timezoneOffsetMinutes],
queryFn: ({ signal }) => fetchUsage(days, timezoneOffsetMinutes, signal),
enabled: enabled && !!owner,
staleTime: 30_000,
retry: false,
});
}
export function useUsageRuns(
owner: string | undefined,
page: number,
status: UsageRunFilter,
enabled: boolean,
) {
return useQuery({
queryKey: ["usage", owner, "runs", status, page],
queryFn: ({ signal }) => fetchUsageRuns(page * 20, status, signal),
enabled: enabled && !!owner,
staleTime: 30_000,
refetchOnWindowFocus: page === 0,
refetchOnReconnect: page === 0,
retry: false,
});
}

View File

@ -0,0 +1,45 @@
/** Token projection of /api/console responses; optional monetary fields are ignored. */
export interface UsageDay {
date: string;
total_tokens: number;
input_tokens: number;
output_tokens: number;
runs: number;
}
export interface ModelUsage {
tokens: number;
runs: number;
input_tokens: number;
cache_read_tokens: number;
}
export interface UsageSummary {
days: UsageDay[];
by_model: Record<string, ModelUsage>;
total_tokens: number;
total_runs: number;
}
export interface UsageRun {
run_id: string;
thread_id: string;
thread_title: string | null;
assistant_id: string | null;
status: string;
model_name: string | null;
created_at: string | null;
updated_at: string | null;
duration_seconds: number | null;
total_tokens: number;
message_count: number;
error: string | null;
}
export interface UsageRunsResponse {
runs: UsageRun[];
has_more: boolean;
}
export type UsagePeriod = 7 | 30;
export type UsageRunFilter = "all" | "error" | "timeout";

View File

@ -0,0 +1,200 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
test.use({ timezoneId: "Asia/Shanghai" });
function summary(days: number) {
const tokens = days === 7 ? 3000 : 10000;
return {
total_tokens: tokens,
total_runs: 3,
total_cost: 0.012,
currency: "USD",
days: Array.from({ length: days }, (_, index) => {
const weight = [0.2, 0.3, 0.5][index - (days - 3)] ?? 0;
const totalTokens = tokens * weight;
const outputTokens = index === days - 1 ? 100 : 0;
return {
date: new Date(Date.UTC(2026, 9, 25 - days + index))
.toISOString()
.slice(0, 10),
total_tokens: totalTokens,
input_tokens: totalTokens - outputTokens,
output_tokens: outputTokens,
runs: weight > 0 ? 1 : 0,
cost: 0.012 * weight,
};
}),
by_model: {
"deepseek-flash": {
tokens,
runs: 3,
cost: 0.012,
input_tokens: tokens - 100,
cache_read_tokens: 500,
},
},
};
}
test("switches the usage period, sends browser offset and exposes daily details", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1200 });
mockLangGraphAPI(page, { threads: [] });
const offsets: string[] = [];
await page.route("**/api/console/usage?*", async (route) => {
const query = new URL(route.request().url()).searchParams;
offsets.push(query.get("tz_offset_minutes") ?? "");
await route.fulfill({ json: summary(Number(query.get("days"))) });
});
await page.route("**/api/console/runs?*", (route) =>
route.fulfill({ json: { runs: [], has_more: false } }),
);
await page.goto("/workspace/chats/new");
await page
.getByRole("link", { name: "Token usage", exact: true })
.first()
.click();
await expect(page.getByTestId("usage-total-tokens")).toHaveText("3,000");
await expect(page.getByTestId("usage-total-cost")).toHaveCount(0);
await page.getByRole("button", { name: "Last 30 days" }).click();
await expect(page.getByTestId("usage-total-tokens")).toHaveText("10,000");
await page.getByText("View daily details", { exact: true }).click();
await expect(
page.getByRole("table", { name: "Daily token usage", exact: true }),
).toBeVisible();
expect(offsets.length).toBeGreaterThan(1);
expect(offsets.every((value) => value === "480")).toBe(true);
await expect(
page.getByRole("link", { name: "Token usage", exact: true }).first(),
).toBeVisible();
await page.getByText("View daily details", { exact: true }).click();
await page
.getByRole("heading", { name: "Token usage", exact: true })
.scrollIntoViewIfNeeded();
await testInfo.attach("usage-desktop", {
body: await page.screenshot({
path: testInfo.outputPath("usage-desktop.png"),
fullPage: true,
}),
contentType: "image/png",
});
});
test("recovers from reporting unavailability and shows token counts", async ({
page,
}) => {
mockLangGraphAPI(page, { threads: [] });
let available = false;
await page.route("**/api/console/usage?*", (route) => {
return !available
? route.fulfill({ status: 503, json: { detail: "database unavailable" } })
: route.fulfill({ json: summary(7) });
});
await page.route("**/api/console/runs?*", (route) =>
route.fulfill({ json: { runs: [], has_more: false } }),
);
await page.goto("/workspace/usage");
await expect(
page.getByRole("alert").filter({ hasText: "SQLite or PostgreSQL" }),
).toBeVisible();
await expect(page.getByTestId("usage-total-cost")).toHaveCount(0);
available = true;
await page.getByRole("button", { name: "Retry", exact: true }).click();
await expect(page.getByTestId("usage-total-tokens")).toHaveText("3,000");
});
test("pages run history and resets its offset when filtering failures", async ({
page,
}) => {
mockLangGraphAPI(page, { threads: [] });
await page.route("**/api/console/usage?*", (route) =>
route.fulfill({ json: summary(7) }),
);
const requests: Array<{ offset: string; status: string | null }> = [];
await page.route("**/api/console/runs?*", (route) => {
const query = new URL(route.request().url()).searchParams;
const offset = query.get("offset") ?? "0";
const status = query.get("status");
requests.push({ offset, status });
return route.fulfill({
json: {
has_more: offset === "0",
runs: [
{
run_id: `run-${offset}-${status ?? "all"}`,
thread_id: "thread/a #?",
thread_title: offset === "0" ? "Latest report" : "Older report",
assistant_id: null,
status: status ?? "success",
model_name: "deepseek-flash",
created_at: "2026-10-25T08:00:00Z",
updated_at: null,
duration_seconds: 6.2,
total_tokens: 100,
message_count: 2,
cost: 0.012,
error: status === "error" ? "An upstream request failed" : null,
},
],
},
});
});
await page.goto("/workspace/usage");
await expect(
page.getByRole("link", { name: "Latest report" }),
).toHaveAttribute("href", "/workspace/chats/thread%2Fa%20%23%3F");
await page.getByRole("button", { name: "Next page" }).click();
await expect(page.getByRole("link", { name: "Older report" })).toBeVisible();
await page
.getByRole("combobox", { name: "Run status" })
.selectOption("error");
await expect(page.getByText("Page 1", { exact: true })).toBeVisible();
await expect(page.getByText("Failed", { exact: true }).last()).toBeVisible();
await page.getByText("Error details", { exact: true }).click();
await expect(page.getByText("An upstream request failed")).toBeVisible();
expect(requests).toContainEqual({ offset: "20", status: null });
expect(requests).toContainEqual({ offset: "0", status: "error" });
});
test("renders Chinese on mobile without widening the document", async ({
page,
context,
baseURL,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await context.addCookies([
{ name: "locale", value: "zh-CN", url: baseURL ?? "http://localhost:3000" },
]);
mockLangGraphAPI(page, { threads: [] });
await page.route("**/api/console/usage?*", (route) =>
route.fulfill({ json: summary(7) }),
);
await page.route("**/api/console/runs?*", (route) =>
route.fulfill({ json: { runs: [], has_more: false } }),
);
await page.goto("/workspace/usage");
await expect(
page.getByRole("heading", { name: "Token 用量", exact: true }),
).toBeVisible();
await expect(page.getByTestId("usage-total-cost")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "近 30 天", exact: true }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
)
.toBe(true);
await testInfo.attach("usage-mobile-zh", {
body: await page.screenshot({
path: testInfo.outputPath("usage-mobile-zh.png"),
fullPage: true,
}),
contentType: "image/png",
});
});

View File

@ -0,0 +1,53 @@
import { beforeEach, expect, rs, test } from "@rstest/core";
const mocks = rs.hoisted(() => ({ fetch: rs.fn() }));
rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch }));
rs.mock("@/core/config", () => ({
getBackendBaseURL: () => "https://gateway.example",
}));
import { GatewayApiError } from "@/core/api/errors";
import { fetchUsage, fetchUsageRuns } from "@/core/usage/api";
beforeEach(() => mocks.fetch.mockReset());
test("sends local-day offset and propagates cancellation to the authenticated fetcher", async () => {
const controller = new AbortController();
mocks.fetch.mockResolvedValue(Response.json({ days: [] }));
await fetchUsage(30, 480, controller.signal);
const [address, options] = mocks.fetch.mock.calls[0]!;
const url = new URL(address as string);
expect(url.origin).toBe("https://gateway.example");
expect(url.pathname).toBe("/api/console/usage");
expect(url.searchParams.get("days")).toBe("30");
expect(url.searchParams.get("tz_offset_minutes")).toBe("480");
expect((options as RequestInit).signal).toBe(controller.signal);
});
test("all-status pages omit the status filter; failures send the real backend status", async () => {
mocks.fetch.mockImplementation(async () =>
Response.json({ runs: [], has_more: false }),
);
await fetchUsageRuns(20, "all");
await fetchUsageRuns(0, "timeout");
const first = new URL(mocks.fetch.mock.calls[0]![0] as string);
const second = new URL(mocks.fetch.mock.calls[1]![0] as string);
expect(first.searchParams.get("offset")).toBe("20");
expect(first.searchParams.get("limit")).toBe("20");
expect(first.searchParams.has("status")).toBe(false);
expect(second.searchParams.get("status")).toBe("timeout");
});
test.each([403, 404, 503])(
"preserves HTTP %s for localized recovery UI",
async (status) => {
mocks.fetch.mockResolvedValue(
Response.json({ detail: "Unavailable" }, { status }),
);
await expect(fetchUsage(7, 0)).rejects.toBeInstanceOf(GatewayApiError);
mocks.fetch.mockResolvedValue(
Response.json({ detail: "Unavailable" }, { status }),
);
await expect(fetchUsageRuns(0, "all")).rejects.toMatchObject({ status });
},
);

View File

@ -0,0 +1,246 @@
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
const mocks = rs.hoisted(() => ({
usage: rs.fn(),
runs: rs.fn(),
permissions: ["runs:read"],
staticMode: false,
}));
rs.mock("@/core/usage/api", () => ({
fetchUsage: mocks.usage,
fetchUsageRuns: mocks.runs,
}));
rs.mock("@/core/auth/AuthProvider", () => ({
useAuth: () => ({ user: { id: "alice", permissions: mocks.permissions } }),
}));
rs.mock("@/core/static-mode", () => ({
isStaticWebsiteOnly: () => mocks.staticMode,
}));
import { UsageDashboard } from "@/components/workspace/usage/usage-dashboard";
import { GatewayApiError } from "@/core/api/errors";
import { I18nProvider } from "@/core/i18n/context";
import { formatUsageDate } from "@/core/usage/format";
const clients: QueryClient[] = [];
const usage = {
total_cost: 0.005,
currency: "USD",
days: [
{
date: "2026-10-25",
total_tokens: 100,
input_tokens: 70,
output_tokens: 30,
runs: 1,
cost: 0.005,
},
],
by_model: {
flash: {
tokens: 100,
runs: 1,
input_tokens: 70,
cache_read_tokens: 10,
cost: 0.005,
},
},
total_tokens: 100,
total_runs: 1,
};
function renderDashboard() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
clients.push(client);
render(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en-US">
<UsageDashboard />
</I18nProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
mocks.permissions = ["runs:read"];
mocks.staticMode = false;
mocks.usage.mockReset().mockResolvedValue(usage);
mocks.runs.mockReset().mockResolvedValue({ runs: [], has_more: false });
});
afterEach(() => {
cleanup();
clients.splice(0).forEach((client) => client.clear());
});
test("switches the server-side period and shows only token usage", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Usage by model" });
expect(screen.getByTestId("usage-total-tokens").textContent).toBe("100");
expect(screen.queryByTestId("usage-total-cost")).toBeNull();
expect(screen.queryByText("Estimated cost")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Last 30 days" }));
await waitFor(() =>
expect(mocks.usage.mock.calls.some((call) => call[0] === 30)).toBe(true),
);
});
test("does not issue requests without runs:read", () => {
mocks.permissions = [];
renderDashboard();
expect(screen.getByText(/You do not have permission/)).toBeTruthy();
expect(mocks.usage).not.toHaveBeenCalled();
expect(mocks.runs).not.toHaveBeenCalled();
});
test("empty periods hide the daily date axis and zero-filled details", async () => {
mocks.usage.mockResolvedValue({
...usage,
total_runs: 0,
total_tokens: 0,
by_model: {},
days: usage.days.map((day) => ({
...day,
total_tokens: 0,
input_tokens: 0,
output_tokens: 0,
runs: 0,
})),
});
renderDashboard();
await screen.findByText("No usage recorded in this period.");
expect(screen.getByTestId("usage-total-tokens").textContent).toBe("0");
expect(screen.queryByText("View daily details")).toBeNull();
expect(screen.queryByRole("table", { name: "Daily token usage" })).toBeNull();
expect(
screen.queryAllByText(formatUsageDate(usage.days[0]!.date, "en-US")),
).toHaveLength(0);
});
test("runs with zero reported tokens still expose daily details", async () => {
mocks.usage.mockResolvedValue({
...usage,
total_tokens: 0,
days: usage.days.map((day) => ({
...day,
total_tokens: 0,
input_tokens: 0,
output_tokens: 0,
})),
});
renderDashboard();
await screen.findByText("View daily details");
expect(screen.getByTestId("usage-total-runs").textContent).toBe("1");
expect(screen.queryByText("No usage recorded in this period.")).toBeNull();
expect(
screen.getByRole("table", { name: "Daily token usage", hidden: true }),
).toBeTruthy();
});
test("accepts upstream cost fields while presenting run tokens", async () => {
mocks.runs.mockResolvedValue({
runs: [
{
run_id: "r1",
thread_id: "t1",
thread_title: "Recent chat",
assistant_id: null,
status: "success",
model_name: "flash",
created_at: null,
updated_at: null,
duration_seconds: 1,
total_tokens: 42,
message_count: 2,
error: null,
cost: 0.1,
currency: "CNY",
},
],
has_more: false,
});
renderDashboard();
await screen.findByRole("link", { name: "Recent chat" });
expect(screen.getByText("42")).toBeTruthy();
expect(
screen.queryByRole("columnheader", { name: "Estimated cost" }),
).toBeNull();
});
test("static demos never request personal usage data", () => {
mocks.staticMode = true;
renderDashboard();
expect(
screen.getByText("Usage reporting is not available in the static demo."),
).toBeTruthy();
expect(mocks.usage).not.toHaveBeenCalled();
expect(mocks.runs).not.toHaveBeenCalled();
});
test("does not display the previous period's totals while a new period loads", async () => {
let finish: (value: typeof usage) => void = () => undefined;
const pending = new Promise<typeof usage>((resolve) => {
finish = resolve;
});
mocks.usage.mockImplementation((days: number) =>
days === 30 ? pending : Promise.resolve(usage),
);
renderDashboard();
await screen.findByTestId("usage-total-tokens");
fireEvent.click(screen.getByRole("button", { name: "Last 30 days" }));
expect(screen.queryByTestId("usage-total-tokens")).toBeNull();
finish({ ...usage, total_tokens: 300 });
await waitFor(() =>
expect(screen.getByTestId("usage-total-tokens").textContent).toBe("300"),
);
});
test("errors show a retry instead of zero-valued summary cards", async () => {
mocks.usage.mockRejectedValue(
new GatewayApiError({
message: "unavailable",
status: 503,
code: null,
params: {},
rawMessage: "",
}),
);
renderDashboard();
await screen.findByText(/requires a SQLite or PostgreSQL/);
expect(screen.queryByTestId("usage-total-tokens")).toBeNull();
mocks.usage.mockResolvedValue(usage);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await screen.findByTestId("usage-total-tokens");
});
test("run pagination resets when the failure filter changes", async () => {
mocks.runs.mockResolvedValue({ runs: [], has_more: true });
renderDashboard();
const next = await screen.findByRole("button", { name: "Next page" });
await waitFor(() => expect(next).toHaveProperty("disabled", false));
fireEvent.click(next);
await waitFor(() =>
expect(mocks.runs.mock.calls.some((call) => call[0] === 20)).toBe(true),
);
fireEvent.change(screen.getByRole("combobox", { name: "Run status" }), {
target: { value: "error" },
});
await waitFor(() =>
expect(
mocks.runs.mock.calls.some(
(call) => call[0] === 0 && call[1] === "error",
),
).toBe(true),
);
expect(screen.getByText("Page 1")).toBeTruthy();
});

View File

@ -0,0 +1,27 @@
import { expect, test } from "@rstest/core";
import { modelUsageRows } from "@/core/usage/format";
test("model breakdown sorts by usage without mutating server data", () => {
const models = {
flash: {
tokens: 30,
runs: 1,
input_tokens: 20,
cache_read_tokens: 10,
},
pro: {
tokens: 50,
runs: 2,
input_tokens: 40,
cache_read_tokens: 0,
},
};
expect(modelUsageRows(models).map((row) => row.name)).toEqual([
"pro",
"flash",
]);
expect(Object.keys(models)).toEqual(["flash", "pro"]);
});

View File

@ -0,0 +1,53 @@
import { afterEach, expect, rs, test } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
const mocks = rs.hoisted(() => ({ usage: rs.fn() }));
rs.mock("@/core/usage/api", () => ({ fetchUsage: mocks.usage }));
import { useUsage } from "@/core/usage/hooks";
const clients: QueryClient[] = [];
afterEach(() => {
cleanup();
clients.splice(0).forEach((client) => client.clear());
mocks.usage.mockReset();
});
test("account changes cancel obsolete reports and never reuse another owner's totals", async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
clients.push(client);
let finishAlice!: (value: { total_tokens: number }) => void;
const alice = new Promise<{ total_tokens: number }>((resolve) => {
finishAlice = resolve;
});
mocks.usage
.mockReturnValueOnce(alice)
.mockResolvedValue({ total_tokens: 200 });
const { result, rerender } = renderHook(
({ owner }: { owner: string | undefined }) => useUsage(owner, 7, 480, true),
{
initialProps: { owner: "alice" as string | undefined },
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
},
);
await waitFor(() => expect(mocks.usage).toHaveBeenCalledTimes(1));
const aliceSignal = mocks.usage.mock.calls[0]![2] as AbortSignal;
rerender({ owner: undefined });
expect(aliceSignal.aborted).toBe(true);
expect(result.current.data).toBeUndefined();
expect(mocks.usage).toHaveBeenCalledTimes(1);
rerender({ owner: "bob" });
await waitFor(() => expect(result.current.data?.total_tokens).toBe(200));
await act(async () => {
finishAlice({ total_tokens: 999 });
await alice;
});
expect(result.current.data?.total_tokens).toBe(200);
});

View File

@ -0,0 +1,66 @@
import { afterEach, expect, test } from "@rstest/core";
import { cleanup, render, screen } from "@testing-library/react";
import { UsageRuns } from "@/components/workspace/usage/usage-runs";
import { enUS } from "@/core/i18n/locales/en-US";
import type { UsageRun } from "@/core/usage/types";
const run: UsageRun = {
run_id: "run-1",
thread_id: "thread-1",
thread_title: "Run conversation",
assistant_id: null,
status: "success",
model_name: "example-model",
created_at: null,
updated_at: null,
duration_seconds: 1,
total_tokens: 100,
message_count: 2,
error: null,
};
afterEach(cleanup);
function conversationLink(overrides: Partial<UsageRun>) {
render(
<UsageRuns
runs={[{ ...run, ...overrides }]}
locale="en-US"
text={enUS.usage}
/>,
);
return screen
.getByRole("link", { name: "Run conversation" })
.getAttribute("href");
}
test.each([null, "", "lead_agent"])(
"default assistant %s opens the normal conversation",
(assistantId) => {
expect(conversationLink({ assistant_id: assistantId })).toBe(
"/workspace/chats/thread-1",
);
},
);
test("custom-agent runs open the matching agent conversation", () => {
expect(conversationLink({ assistant_id: "researcher" })).toBe(
"/workspace/agents/researcher/chats/thread-1",
);
});
test("normalizes custom assistant aliases using the Gateway identity convention", () => {
expect(conversationLink({ assistant_id: " Research_Agent " })).toBe(
"/workspace/agents/research-agent/chats/thread-1",
);
});
test.each([
[null, "/workspace/chats/thread%2Fa%20%23%3F%25"],
["researcher", "/workspace/agents/researcher/chats/thread%2Fa%20%23%3F%25"],
])("encodes opaque thread ids for assistant %s", (assistantId, expected) => {
expect(
conversationLink({ assistant_id: assistantId, thread_id: "thread/a #?%" }),
).toBe(expected);
});