diff --git a/README.md b/README.md index 961b9aad76..e14f644409 100644 --- a/README.md +++ b/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 diff --git a/README_zh.md b/README_zh.md index fb49a20979..18fda15983 100644 --- a/README_zh.md +++ b/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 能做“几乎任何事”的关键。 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 3db665c1d8..d29d582224 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 diff --git a/frontend/docs/token-usage-dashboard.md b/frontend/docs/token-usage-dashboard.md new file mode 100644 index 0000000000..e5b3a10e11 --- /dev/null +++ b/frontend/docs/token-usage-dashboard.md @@ -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. diff --git a/frontend/src/app/workspace/usage/page.tsx b/frontend/src/app/workspace/usage/page.tsx new file mode 100644 index 0000000000..a7802b8f5c --- /dev/null +++ b/frontend/src/app/workspace/usage/page.tsx @@ -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 ( + + +
+ +
+
+ ); +} diff --git a/frontend/src/components/workspace/usage/query-error.tsx b/frontend/src/components/workspace/usage/query-error.tsx new file mode 100644 index 0000000000..1b8b812e10 --- /dev/null +++ b/frontend/src/components/workspace/usage/query-error.tsx @@ -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 ( +
+

{message}

+ {status !== 403 && status !== 404 && ( + + )} +
+ ); +} diff --git a/frontend/src/components/workspace/usage/usage-dashboard.tsx b/frontend/src/components/workspace/usage/usage-dashboard.tsx new file mode 100644 index 0000000000..2fc676954c --- /dev/null +++ b/frontend/src/components/workspace/usage/usage-dashboard.tsx @@ -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(7); + const [page, setPage] = useState(0); + const [status, setStatus] = useState("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 ( +

+ {text.staticUnavailable} +

+ ); + if (!allowed) + return ( +

+ {user ? text.forbidden : text.unavailable} +

+ ); + + return ( +
+
+
+

+ {text.title} +

+

+ {text.description} +

+
+ +
+

{text.reportingNote}

+
+ {([7, 30] as const).map((days) => ( + + ))} +
+
+ {usage.isError ? ( + void usage.refetch()} + text={text} + /> + ) : usage.data ? ( + + ) : ( + + )} +
+ + + +
+

{text.recentRuns}

+ +
+

{text.recentRunsHint}

+
+ + {runs.isError ? ( + void runs.refetch()} + text={text} + /> + ) : runs.data ? ( + + ) : ( + + )} +
+ + {text.page(page + 1)} + +
+
+
+
+ ); +} + +function Loading({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} diff --git a/frontend/src/components/workspace/usage/usage-overview.tsx b/frontend/src/components/workspace/usage/usage-overview.tsx new file mode 100644 index 0000000000..5a9454b1dc --- /dev/null +++ b/frontend/src/components/workspace/usage/usage-overview.tsx @@ -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 ( +
+
+ + +
+ + + +

{text.daily}

+

{text.timezoneNote}

+

+ {text.inputTokens}: {count(input)} · {text.outputTokens}:{" "} + {count(output)} +

+
+ + {!hasRuns ? ( +

+ {text.noUsage} +

+ ) : ( + + ); +} + +function Summary({ + label, + value, + testId, +}: { + label: string; + value: string; + testId: string; +}) { + return ( + + {label} + + {value} + + + ); +} diff --git a/frontend/src/components/workspace/usage/usage-runs.tsx b/frontend/src/components/workspace/usage/usage-runs.tsx new file mode 100644 index 0000000000..03055bf260 --- /dev/null +++ b/frontend/src/components/workspace/usage/usage-runs.tsx @@ -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 ( +

+ {text.noRuns} +

+ ); + const statusLabels: Record = { + 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 ( +
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
+ {text.conversation} + + {text.status} + + {text.model} + + {text.started} + + {text.duration} + + {text.tokens} +
+ + {run.thread_title?.trim() + ? run.thread_title + : text.untitled} + + {run.error && ( +
+ + {text.details} + +

+ {run.error} +

+
+ )} +
+ + {Object.hasOwn(statusLabels, run.status) + ? statusLabels[run.status] + : text.unknownStatus} + + + {run.model_name?.trim() ? run.model_name : text.unknown} + {dateLabel} + {formatRunDuration( + run.duration_seconds, + locale, + text.unknown, + )} + + {run.total_tokens.toLocaleString(locale)} +
+
+ ); +} diff --git a/frontend/src/components/workspace/workspace-container.tsx b/frontend/src/components/workspace/workspace-container.tsx index 143781df00..7d6c6d29ac 100644 --- a/frontend/src/components/workspace/workspace-container.tsx +++ b/frontend/src/components/workspace/workspace-container.tsx @@ -24,6 +24,7 @@ const LINKABLE_SECTIONS: Record = { 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); } diff --git a/frontend/src/components/workspace/workspace-nav-chat-list.tsx b/frontend/src/components/workspace/workspace-nav-chat-list.tsx index 643e320f46..faec9de57e 100644 --- a/frontend/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend/src/components/workspace/workspace-nav-chat-list.tsx @@ -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 ( @@ -94,6 +103,19 @@ export function WorkspaceNavChatList() { + {usageEnabled && ( + + + + + {t.usage.title} + + + + )} `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", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index bff434ae17..43ba12ba1a 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index b7f3495f26..8c3cd963e9 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "中文", diff --git a/frontend/src/core/usage/api.ts b/frontend/src/core/usage/api.ts new file mode 100644 index 0000000000..5b80e0623d --- /dev/null +++ b/frontend/src/core/usage/api.ts @@ -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(path: string, signal?: AbortSignal): Promise { + 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; +} + +export function fetchUsage( + days: number, + timezoneOffsetMinutes: number, + signal?: AbortSignal, +): Promise { + 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 { + const query = new URLSearchParams({ limit: "20", offset: String(offset) }); + if (status !== "all") query.set("status", status); + return readConsole(`runs?${query.toString()}`, signal); +} diff --git a/frontend/src/core/usage/format.ts b/frontend/src/core/usage/format.ts new file mode 100644 index 0000000000..857e86b9c7 --- /dev/null +++ b/frontend/src/core/usage/format.ts @@ -0,0 +1,31 @@ +import type { ModelUsage } from "./types"; + +export function modelUsageRows(models: Record) { + 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); +} diff --git a/frontend/src/core/usage/hooks.ts b/frontend/src/core/usage/hooks.ts new file mode 100644 index 0000000000..c5f0d08a07 --- /dev/null +++ b/frontend/src/core/usage/hooks.ts @@ -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, + }); +} diff --git a/frontend/src/core/usage/types.ts b/frontend/src/core/usage/types.ts new file mode 100644 index 0000000000..8a7f1a82f4 --- /dev/null +++ b/frontend/src/core/usage/types.ts @@ -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; + 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"; diff --git a/frontend/tests/e2e/usage-dashboard.spec.ts b/frontend/tests/e2e/usage-dashboard.spec.ts new file mode 100644 index 0000000000..36280e35e7 --- /dev/null +++ b/frontend/tests/e2e/usage-dashboard.spec.ts @@ -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", + }); +}); diff --git a/frontend/tests/unit/core/usage/api.test.ts b/frontend/tests/unit/core/usage/api.test.ts new file mode 100644 index 0000000000..9874383cc3 --- /dev/null +++ b/frontend/tests/unit/core/usage/api.test.ts @@ -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 }); + }, +); diff --git a/frontend/tests/unit/core/usage/dashboard.dom.test.tsx b/frontend/tests/unit/core/usage/dashboard.dom.test.tsx new file mode 100644 index 0000000000..f58fc7cf8c --- /dev/null +++ b/frontend/tests/unit/core/usage/dashboard.dom.test.tsx @@ -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( + + + + + , + ); +} + +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((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(); +}); diff --git a/frontend/tests/unit/core/usage/format.test.ts b/frontend/tests/unit/core/usage/format.test.ts new file mode 100644 index 0000000000..81b0c7ad9f --- /dev/null +++ b/frontend/tests/unit/core/usage/format.test.ts @@ -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"]); +}); diff --git a/frontend/tests/unit/core/usage/hooks.dom.test.tsx b/frontend/tests/unit/core/usage/hooks.dom.test.tsx new file mode 100644 index 0000000000..1af5cfd4f2 --- /dev/null +++ b/frontend/tests/unit/core/usage/hooks.dom.test.tsx @@ -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 }) => ( + {children} + ), + }, + ); + 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); +}); diff --git a/frontend/tests/unit/core/usage/runs.dom.test.tsx b/frontend/tests/unit/core/usage/runs.dom.test.tsx new file mode 100644 index 0000000000..d12421d6f2 --- /dev/null +++ b/frontend/tests/unit/core/usage/runs.dom.test.tsx @@ -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) { + render( + , + ); + 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); +});