mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-07 19:20:34 +00:00
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:
parent
21175ba46f
commit
7c3d2551b6
20
README.md
20
README.md
@ -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
|
||||
|
||||
12
README_zh.md
12
README_zh.md
@ -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 能做“几乎任何事”的关键。
|
||||
|
||||
@ -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
|
||||
|
||||
73
frontend/docs/token-usage-dashboard.md
Normal file
73
frontend/docs/token-usage-dashboard.md
Normal 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.
|
||||
25
frontend/src/app/workspace/usage/page.tsx
Normal file
25
frontend/src/app/workspace/usage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/workspace/usage/query-error.tsx
Normal file
36
frontend/src/components/workspace/usage/query-error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
frontend/src/components/workspace/usage/usage-dashboard.tsx
Normal file
174
frontend/src/components/workspace/usage/usage-dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
frontend/src/components/workspace/usage/usage-overview.tsx
Normal file
204
frontend/src/components/workspace/usage/usage-overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/workspace/usage/usage-runs.tsx
Normal file
135
frontend/src/components/workspace/usage/usage-runs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "中文",
|
||||
|
||||
37
frontend/src/core/usage/api.ts
Normal file
37
frontend/src/core/usage/api.ts
Normal 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);
|
||||
}
|
||||
31
frontend/src/core/usage/format.ts
Normal file
31
frontend/src/core/usage/format.ts
Normal 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);
|
||||
}
|
||||
38
frontend/src/core/usage/hooks.ts
Normal file
38
frontend/src/core/usage/hooks.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
45
frontend/src/core/usage/types.ts
Normal file
45
frontend/src/core/usage/types.ts
Normal 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";
|
||||
200
frontend/tests/e2e/usage-dashboard.spec.ts
Normal file
200
frontend/tests/e2e/usage-dashboard.spec.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
53
frontend/tests/unit/core/usage/api.test.ts
Normal file
53
frontend/tests/unit/core/usage/api.test.ts
Normal 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 });
|
||||
},
|
||||
);
|
||||
246
frontend/tests/unit/core/usage/dashboard.dom.test.tsx
Normal file
246
frontend/tests/unit/core/usage/dashboard.dom.test.tsx
Normal 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();
|
||||
});
|
||||
27
frontend/tests/unit/core/usage/format.test.ts
Normal file
27
frontend/tests/unit/core/usage/format.test.ts
Normal 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"]);
|
||||
});
|
||||
53
frontend/tests/unit/core/usage/hooks.dom.test.tsx
Normal file
53
frontend/tests/unit/core/usage/hooks.dom.test.tsx
Normal 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);
|
||||
});
|
||||
66
frontend/tests/unit/core/usage/runs.dom.test.tsx
Normal file
66
frontend/tests/unit/core/usage/runs.dom.test.tsx
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user