From 0e24c05eee93101f562c8a165ee5f53991822e71 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:47:18 +0800 Subject: [PATCH] feat(admin usage): add token views, data tables, and preloading --- .../src/services/usage_workspace_service.rs | 10 +- .../services/usage_workspace_service/tests.rs | 11 +- frontend/e2e/billing-analytics.spec.ts | 27 ++- .../billing-analytics/analytics-canvas.tsx | 40 +++- .../billing-analytics/chart-view.test.tsx | 76 +++++++ .../billing-analytics/chart-view.tsx | 170 ++++++++------ .../billing-analytics/controls.test.tsx | 37 +++- .../components/billing-analytics/controls.tsx | 16 +- .../token-metric-picker.test.tsx | 79 +++++++ .../billing-analytics/token-metric-picker.tsx | 140 ++++++++++++ .../billing-analytics/token-metrics.ts | 36 +++ .../billing-analytics/usage-list.tsx | 209 ++++++++++++++---- frontend/src/hooks/use-admin-usage.test.tsx | 73 +++++- frontend/src/hooks/use-admin-usage.ts | 57 ++++- frontend/src/hooks/use-usage-analytics.ts | 3 +- .../src/pages/admin-usage.router.test.tsx | 11 +- frontend/src/pages/admin-usage.test.tsx | 15 ++ frontend/src/pages/admin-usage.tsx | 16 +- frontend/src/schemas/usage-analytics.ts | 1 + 19 files changed, 874 insertions(+), 153 deletions(-) create mode 100644 frontend/src/components/billing-analytics/chart-view.test.tsx create mode 100644 frontend/src/components/billing-analytics/token-metric-picker.test.tsx create mode 100644 frontend/src/components/billing-analytics/token-metric-picker.tsx create mode 100644 frontend/src/components/billing-analytics/token-metrics.ts diff --git a/backend/src/services/usage_workspace_service.rs b/backend/src/services/usage_workspace_service.rs index 98f537f46..4b87357de 100644 --- a/backend/src/services/usage_workspace_service.rs +++ b/backend/src/services/usage_workspace_service.rs @@ -34,6 +34,8 @@ pub struct WorkspacePanel { pub height: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub interval: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub table_display: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] @@ -133,8 +135,14 @@ pub fn validate(config: &WorkspaceConfig) -> AppResult<()> { .height .as_deref() .is_some_and(|height| !["compact", "standard", "tall"].contains(&height)) + || panel + .table_display + .as_deref() + .is_some_and(|display| !["always", "accordion"].contains(&display)) { - return Err(invalid("Choose 1 to 3 columns and a valid panel height")); + return Err(invalid( + "Choose a valid panel layout and data table display", + )); } AnalyticsQuery { period: (filters.period != "custom").then(|| filters.period.clone()), diff --git a/backend/src/services/usage_workspace_service/tests.rs b/backend/src/services/usage_workspace_service/tests.rs index 5e4c31f03..ee6212df0 100644 --- a/backend/src/services/usage_workspace_service/tests.rs +++ b/backend/src/services/usage_workspace_service/tests.rs @@ -29,6 +29,7 @@ fn workspace_rejects_invalid_queries_and_duplicate_panels() { fn workspace_preserves_legacy_width_and_validates_panel_layout_and_interval() { let legacy = config(); assert!(legacy.draft.panels[0].span.is_none()); + assert!(legacy.draft.panels[0].table_display.is_none()); assert!(legacy.draft.panels[0].wide); assert!(validate(&legacy).is_ok()); for span in [1, 2, 3] { @@ -37,20 +38,26 @@ fn workspace_preserves_legacy_width_and_validates_panel_layout_and_interval() { panel.span = Some(span); panel.height = Some("tall".into()); panel.interval = Some("week".into()); + panel.table_display = Some("accordion".into()); panel.measure = "prompt_tokens".into(); assert!(validate(&value).is_ok()); let restored: WorkspaceConfig = serde_json::from_value(serde_json::to_value(&value).unwrap()).unwrap(); assert_eq!(restored.draft.panels[0].span, Some(span)); assert_eq!(restored.draft.panels[0].interval.as_deref(), Some("week")); + assert_eq!( + restored.draft.panels[0].table_display.as_deref(), + Some("accordion") + ); } - for field in ["span", "height", "interval"] { + for field in ["span", "height", "interval", "table_display"] { let mut value = config(); let panel = &mut value.draft.panels[0]; match field { "span" => panel.span = Some(4), "height" => panel.height = Some("unbounded".into()), - _ => panel.interval = Some("minute".into()), + "interval" => panel.interval = Some("minute".into()), + _ => panel.table_display = Some("missing".into()), } assert!(validate(&value).is_err()); } diff --git a/frontend/e2e/billing-analytics.spec.ts b/frontend/e2e/billing-analytics.spec.ts index ba107ac3c..02c0f2fa3 100644 --- a/frontend/e2e/billing-analytics.spec.ts +++ b/frontend/e2e/billing-analytics.spec.ts @@ -21,6 +21,7 @@ test("all three analytics approaches are interactive and saved views survive rel .click(); await select(page, "Show", "Top 10 + Other"); await select(page, "Chart type", "Bar"); + await select(page, "Data table", "Accordion"); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog")).toHaveCount(0); await expect( @@ -29,6 +30,26 @@ test("all three analytics approaches are interactive and saved views survive rel exact: true, }), ).toBeVisible(); + const serviceMix = page.getByRole("article", { name: "Service mix" }); + await expect( + serviceMix.getByRole("table", { name: "Service mix data" }), + ).toBeHidden(); + await serviceMix.getByText("Data table", { exact: true }).click(); + await expect( + serviceMix.getByRole("table", { name: "Service mix data" }), + ).toBeVisible(); + await serviceMix.getByText("Data table", { exact: true }).click(); + await expect( + serviceMix.getByRole("table", { name: "Service mix data" }), + ).toBeHidden(); + await page + .getByRole("button", { name: "Configure Service mix", exact: true }) + .click(); + await select(page, "Data table", "Always open"); + await page.keyboard.press("Escape"); + await expect( + serviceMix.getByRole("table", { name: "Service mix data" }), + ).toBeVisible(); await page.getByRole("button", { name: /Overview GA inspired/ }).click(); await expect( page.getByRole("img", { name: /Service mix: bar chart/ }), @@ -103,8 +124,9 @@ test("all three analytics approaches are interactive and saved views survive rel await expect( page.getByRole("img", { name: /Explore service usage: pie/ }), ).toBeVisible(); - await page.getByText("View data table", { exact: true }).click(); - await expect(page.getByRole("table")).toBeVisible(); + await expect( + page.getByRole("table", { name: "Explore service usage data" }), + ).toBeVisible(); await page .getByRole("button", { name: "Use in Operations", exact: true }) .click(); @@ -979,7 +1001,6 @@ for (const chart of ["bar", "line", "combo", "pie"] as const) { await expect( panel.locator('[title="Service 0: 9,007,199,254.740993 credits"]'), ).toBeVisible(); - await panel.getByText("View data table", { exact: true }).click(); await expect(panel.getByRole("table")).toContainText( "9,007,199,254.740993", ); diff --git a/frontend/src/components/billing-analytics/analytics-canvas.tsx b/frontend/src/components/billing-analytics/analytics-canvas.tsx index d337a08e8..19041a43e 100644 --- a/frontend/src/components/billing-analytics/analytics-canvas.tsx +++ b/frontend/src/components/billing-analytics/analytics-canvas.tsx @@ -58,6 +58,8 @@ import { cn } from "@/lib/utils"; import { ChartView } from "./chart-view"; import { AnalyticsSelect, PanelControls } from "./controls"; import { SortablePanel } from "./sortable-panel"; +import { TokenMetricCaption, TokenMetricPicker } from "./token-metric-picker"; +import { sumTokenMetrics, type TokenMetric } from "./token-metrics"; import "./operations.css"; export type AnalyticsSample = ( @@ -162,6 +164,7 @@ function Summary({ view: AnalyticsView; sample?: AnalyticsSample; }) { + const [tokenTypes, setTokenTypes] = useState(["total_tokens"]); const query = useUsageAnalytics( view.filters, { @@ -204,10 +207,14 @@ function Summary({ note: "One count per metered request", }, { - label: "Total tokens", - value: formatAnalyticsValue(data.totals.total_tokens, "tokens", true), + label: "Tokens", + value: formatAnalyticsValue( + sumTokenMetrics(data.totals, tokenTypes), + "tokens", + true, + ), suffix: "tokens", - note: "Input + output tokens", + tokenControl: true, }, { label: "Active users", @@ -225,9 +232,18 @@ function Summary({ > {stats.map((stat) => ( -

- {stat.label} -

+ {stat.tokenControl ? ( + + ) : ( +

+ {stat.label} +

+ )}
{stat.value} @@ -236,9 +252,15 @@ function Summary({ {stat.suffix}
-

- {stat.note} -

+ {stat.tokenControl ? ( + + ) : ( + stat.note && ( +

+ {stat.note} +

+ ) + )}
))} diff --git a/frontend/src/components/billing-analytics/chart-view.test.tsx b/frontend/src/components/billing-analytics/chart-view.test.tsx new file mode 100644 index 000000000..553135751 --- /dev/null +++ b/frontend/src/components/billing-analytics/chart-view.test.tsx @@ -0,0 +1,76 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, it } from "vitest"; +import { newPanel } from "@/lib/usage-analytics"; +import type { AnalyticsResult } from "@/schemas/usage-analytics"; +import { usageStats } from "@/test/admin-usage-fixture"; +import { ChartView } from "./chart-view"; + +const data: AnalyticsResult = { + window: { + from: "2026-09-18T00:00:00Z", + to: "2026-09-19T00:00:00Z", + period: "24h", + }, + freshness: { + rolled_up_through: "2026-09-19T00:00:00Z", + tail_rows: 0, + validated: true, + }, + granularity: "hour", + unit: "requests", + total: 7, + totals: usageStats(), + points: [], + series: [], + slices: [ + { + id: "service-1", + label: "Example model", + value: 7, + unknown_cost_events: 0, + is_other: false, + }, + ], +}; + +it("shows the plotted data table by default", () => { + render( + , + ); + const table = screen.getByRole("table", { name: "Request traffic data" }); + expect(table).toBeVisible(); + expect(within(table).getByText("Example model")).toBeVisible(); + expect(screen.queryByText("View data table")).not.toBeInTheDocument(); +}); + +it("opens and closes the data table in accordion mode", async () => { + render( + , + ); + const table = screen.getByRole("table", { + name: "Request traffic data", + hidden: true, + }); + const toggle = screen.getByText("Data table"); + expect(table).not.toBeVisible(); + await userEvent.click(toggle); + expect(table).toBeVisible(); + await userEvent.click(toggle); + expect(table).not.toBeVisible(); +}); diff --git a/frontend/src/components/billing-analytics/chart-view.tsx b/frontend/src/components/billing-analytics/chart-view.tsx index 3feaea9b0..f7c346eb0 100644 --- a/frontend/src/components/billing-analytics/chart-view.tsx +++ b/frontend/src/components/billing-analytics/chart-view.tsx @@ -31,6 +31,7 @@ import type { } from "@/schemas/usage-analytics"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { ChevronDown } from "lucide-react"; import "./visualization.css"; const COLORS = [ @@ -628,81 +629,108 @@ export function ChartView({ )}
-
- - View data table - -
- - - - - - - {temporal && - panel.top !== 0 && - data.series.map((series, index) => ( - - ))} - {panel.chart === "combo" && ( - + ))} + +
{panel.title} data
- {temporal ? "Time (UTC)" : "Group"} - {unit} - {series.label} - - Requests +
+
+ + Data table + + + {panel.table_display !== "accordion" && ( +

+ Data table +

+ )} +
+ + + + + - )} - - - - {temporal - ? data.points.map((point, pointIndex) => ( - - - - {panel.top !== 0 && - data.series.map((series, index) => ( - + {temporal && + panel.top !== 0 && + data.series.map((series, index) => ( + + ))} + {panel.chart === "combo" && ( + + )} + + + + {temporal + ? data.points.map((point, pointIndex) => ( + + + + {panel.top !== 0 && + data.series.map((series, index) => ( + + ))} + {panel.chart === "combo" && ( + - ))} - {panel.chart === "combo" && ( + )} + + )) + : data.slices.map((slice, index) => ( + + - )} - - )) - : data.slices.map((slice, index) => ( - - - - - ))} - -
{panel.title} data
+ {temporal ? "Time (UTC)" : "Group"}
- {timeLabel(point.bucket)} - - {format(analyticsAmount(point))} - - {format( - analyticsAmount( - series.points[pointIndex] ?? { value: null }, - ), - )} + {unit} + {series.label} + + Requests +
+ {timeLabel(point.bucket)} + + {format(analyticsAmount(point))} + + {format( + analyticsAmount( + series.points[pointIndex] ?? { + value: null, + }, + ), + )} + + {point.requests.toLocaleString()}
{slice.label} - {point.requests.toLocaleString()} + {format(analyticsAmount(slice))}
{slice.label} - {format(analyticsAmount(slice))} -
-
-
+
+
+
+
); diff --git a/frontend/src/components/billing-analytics/controls.test.tsx b/frontend/src/components/billing-analytics/controls.test.tsx index 916a07954..fcb31e4c7 100644 --- a/frontend/src/components/billing-analytics/controls.test.tsx +++ b/frontend/src/components/billing-analytics/controls.test.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; import { EMPTY_FILTERS } from "@/lib/usage-analytics"; import type { AnalyticsFilters } from "@/schemas/usage-analytics"; import { FilterBar, type SampleOptions } from "./controls"; +import { api } from "@/lib/api-client"; const services = Array.from({ length: 22 }, (_, index) => ({ id: `svc-${index}`, @@ -43,6 +44,40 @@ function renderBar(initial: Partial = {}) { const picker = () => within(screen.getByRole("dialog")); describe("admin usage FilterBar picker", () => { + it("loads and caches every dropdown before any is opened", async () => { + const get = vi + .spyOn(api, "get") + .mockImplementation(async (path) => + path === "/services" + ? { services: [{ id: "svc-1", name: "Service One", slug: "one" }] } + : { users: [], total: 0 }, + ); + try { + render( + + + , + ); + await waitFor(() => expect(get).toHaveBeenCalledTimes(4)); + expect(get).toHaveBeenCalledWith("/services"); + expect(get).toHaveBeenCalledWith( + "/admin/users?page=1&per_page=50&user_type=org&search=", + ); + expect(get).toHaveBeenCalledWith( + "/admin/users?page=1&per_page=50&user_type=person&search=", + ); + await userEvent.click( + screen.getByRole("button", { name: "Filter services" }), + ); + expect( + await picker().findByRole("button", { name: /Service One/ }), + ).toBeVisible(); + expect(get).toHaveBeenCalledTimes(4); + } finally { + get.mockRestore(); + } + }); + it("matches a selected slug to its service and keeps it through Apply", async () => { const onChange = renderBar({ services: ["service-3"] }); await userEvent.click( diff --git a/frontend/src/components/billing-analytics/controls.tsx b/frontend/src/components/billing-analytics/controls.tsx index 1012bbd79..32f5be5b7 100644 --- a/frontend/src/components/billing-analytics/controls.tsx +++ b/frontend/src/components/billing-analytics/controls.tsx @@ -73,7 +73,7 @@ function AnalyticsFilterPicker({ const [search, setSearch] = useState(""); const deferred = useDeferredValue(search); const label = FILTER_FIELDS.find((field) => field.key === kind)!.label; - const query = useAnalyticsOptions(kind, deferred, open, sample); + const query = useAnalyticsOptions(kind, deferred, true, sample); return ( + + onChange({ + ...panel, + table_display: table_display as AnalyticsPanel["table_display"], + }) + } + options={[ + { value: "always", label: "Always open" }, + { value: "accordion", label: "Accordion" }, + ]} + /> (["total_tokens"]); + return ( + <> + + + {sumTokenMetrics(usageStats(), selected)} + + + + ); +} + +it("adds selected token counts and labels multi-selections as custom", async () => { + render( + + + , + ); + const user = userEvent.setup(); + const picker = screen.getByRole("button", { name: "Summary token types" }); + expect(picker).toHaveTextContent("Total tokens"); + expect(screen.getByLabelText("Selected token sum")).toHaveTextContent("120"); + + await user.click(picker); + await user.click(screen.getByRole("checkbox", { name: "Input tokens" })); + expect(picker).toHaveTextContent("Custom (2)"); + expect(screen.getByLabelText("Selected token sum")).toHaveTextContent("120"); + expect(screen.getByText("Total tokens · Input tokens")).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "How selected token counts are calculated", + }).parentElement, + ).toHaveTextContent("Total tokens · Input tokens"); + await user.hover( + screen.getByRole("button", { + name: "How selected token counts are calculated", + }), + ); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Selected input and output counts are shown but not added again.", + ); + + await user.click(screen.getByRole("checkbox", { name: "Output tokens" })); + await user.click(screen.getByRole("checkbox", { name: "Cache-read tokens" })); + await user.click( + screen.getByRole("checkbox", { name: "Cache-write tokens" }), + ); + expect(picker).toHaveTextContent("Custom (5)"); + expect(screen.getByLabelText("Selected token sum")).toHaveTextContent("155"); + + await user.click(screen.getByRole("checkbox", { name: "Total tokens" })); + expect(picker).toHaveTextContent("Custom (4)"); + expect(screen.getByLabelText("Selected token sum")).toHaveTextContent("155"); +}); + +it("adds input and output when Total is absent, with cache counts added separately", () => { + const usage = usageStats(); + expect(sumTokenMetrics(usage, ["prompt_tokens", "completion_tokens"])).toBe( + 120, + ); + expect(sumTokenMetrics(usage, ["prompt_tokens", "cached_tokens"])).toBe(130); + expect( + sumTokenMetrics(usage, ["total_tokens", "prompt_tokens", "cached_tokens"]), + ).toBe(150); +}); diff --git a/frontend/src/components/billing-analytics/token-metric-picker.tsx b/frontend/src/components/billing-analytics/token-metric-picker.tsx new file mode 100644 index 000000000..c9aa6eb12 --- /dev/null +++ b/frontend/src/components/billing-analytics/token-metric-picker.tsx @@ -0,0 +1,140 @@ +import { ChevronDown, Info } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { MEASURE_LABELS } from "@/lib/usage-analytics"; +import { cn } from "@/lib/utils"; +import { + TOKEN_METRICS, + hasRedundantTokenSelection, + type TokenMetric, +} from "./token-metrics"; + +export function TokenOverlapHint({ + selected, +}: { + selected: readonly TokenMetric[]; +}) { + if (!hasRedundantTokenSelection(selected)) return null; + return ( + + + + + + Total tokens already includes input and output. Selected input and + output counts are shown but not added again. + + + ); +} + +export function TokenMetricCaption({ + selected, +}: { + selected: readonly TokenMetric[]; +}) { + return ( +
+ + {selected.map((metric) => MEASURE_LABELS[metric]).join(" · ")} + {hasRedundantTokenSelection(selected) && ( + <> + {" "} + + + )} + +
+ ); +} + +export function TokenMetricPicker({ + label, + selected, + onChange, + compact = false, + allLabel, +}: { + label: string; + selected: readonly TokenMetric[]; + onChange: (selected: TokenMetric[]) => void; + compact?: boolean; + allLabel?: string; +}) { + const allSelected = selected.length === TOKEN_METRICS.length; + const custom = selected.length > 1 && !(allSelected && allLabel); + const name = + allSelected && allLabel + ? allLabel + : selected.length === 1 + ? MEASURE_LABELS[selected[0]!] + : "Custom"; + + function toggle(metric: TokenMetric, checked: boolean) { + const next = TOKEN_METRICS.filter((field) => + field === metric ? checked : selected.includes(field), + ); + if (next.length > 0) onChange([...next]); + } + + return ( + + + + + + {TOKEN_METRICS.map((metric) => ( + + ))} + + + ); +} diff --git a/frontend/src/components/billing-analytics/token-metrics.ts b/frontend/src/components/billing-analytics/token-metrics.ts new file mode 100644 index 000000000..ead7937c7 --- /dev/null +++ b/frontend/src/components/billing-analytics/token-metrics.ts @@ -0,0 +1,36 @@ +import type { AdminUsageStats } from "@/types/admin"; + +export const TOKEN_METRICS = [ + "total_tokens", + "prompt_tokens", + "completion_tokens", + "cached_tokens", + "cache_creation_tokens", +] as const; +export type TokenMetric = (typeof TOKEN_METRICS)[number]; + +export function sumTokenMetrics( + usage: AdminUsageStats, + selected: readonly TokenMetric[], +): number { + const hasTotal = selected.includes("total_tokens"); + return selected.reduce( + (sum, metric) => + sum + + (hasTotal && + (metric === "prompt_tokens" || metric === "completion_tokens") + ? 0 + : usage[metric]), + 0, + ); +} + +export function hasRedundantTokenSelection( + selected: readonly TokenMetric[], +): boolean { + return ( + selected.includes("total_tokens") && + (selected.includes("prompt_tokens") || + selected.includes("completion_tokens")) + ); +} diff --git a/frontend/src/components/billing-analytics/usage-list.tsx b/frontend/src/components/billing-analytics/usage-list.tsx index 669309d20..3ab500bf1 100644 --- a/frontend/src/components/billing-analytics/usage-list.tsx +++ b/frontend/src/components/billing-analytics/usage-list.tsx @@ -1,8 +1,19 @@ -import { Fragment, useEffect, useRef, useState, type ReactNode } from "react"; +import { + Fragment, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import type { AnalyticsFilters } from "@/schemas/usage-analytics"; -import { useAdminUsage, type AdminUsageParams } from "@/hooks/use-admin-usage"; +import { + useAdminUsage, + usePreloadAdminUsageDetails, + type AdminUsageParams, +} from "@/hooks/use-admin-usage"; import { credentialClassLabel } from "@/lib/billing-units"; import { ErrorBanner } from "@/components/shared/error-banner"; import { ArticleIcon } from "@/components/icons/empty-state"; @@ -32,6 +43,13 @@ import { import { DataTableBadgeCell } from "@/components/data-table/data-table-columns"; import { formatNumber, formatEstimatedCredits } from "@/lib/billing-format"; import { BILLING_METRICS, metricLabel } from "@/schemas/billing-metrics"; +import { MEASURE_LABELS } from "@/lib/usage-analytics"; +import { TokenMetricPicker, TokenOverlapHint } from "./token-metric-picker"; +import { + TOKEN_METRICS, + sumTokenMetrics, + type TokenMetric, +} from "./token-metrics"; import { normalizeAdminUsageSearch, usageRangeError, @@ -55,6 +73,60 @@ const SORT_LABELS: Record = { cached_tokens: "Cache-read tokens", cache_creation_tokens: "Cache-write tokens", }; +function listUsageParams( + search: AdminUsageSearch, + filters: AnalyticsFilters, +): AdminUsageParams { + return { + ...search, + period: filters.period, + from: filters.from ?? undefined, + to: filters.to ?? undefined, + user: undefined, + service: undefined, + services: filters.services, + actors: filters.actors, + owners: filters.owners, + }; +} + +function detailUsageParams( + search: AdminUsageParams, + data: { window: { from: string; to: string } } | undefined, +): AdminUsageParams { + return data + ? { + ...search, + period: "custom", + from: data.window.from, + to: data.window.to, + sort: "requests", + metric: "tokens", + page: 1, + per_page: 25, + } + : search; +} + +export function AdminUsagePreload({ + filters, + search, +}: { + filters: AnalyticsFilters; + search: AdminUsageSearch; +}) { + const params = useMemo( + () => listUsageParams(search, filters), + [search, filters], + ); + const usage = useAdminUsage(params); + const detail = useMemo( + () => detailUsageParams(params, usage.data), + [params, usage.data], + ); + usePreloadAdminUsageDetails(usage.data, detail); + return null; +} function Choice({ label, @@ -105,7 +177,14 @@ function Identity({ user }: { user: AdminUsageIdentity }) { ); } -function Quantities({ usage }: { usage: AdminUsageStats }) { +function Quantities({ + usage, + tokenView, +}: { + usage: AdminUsageStats; + tokenView: readonly TokenMetric[]; +}) { + const allSelected = tokenView.length === TOKEN_METRICS.length; return (
{Object.entries(usage.quantities).map(([metric, quantity]) => ( @@ -113,14 +192,39 @@ function Quantities({ usage }: { usage: AdminUsageStats }) { {formatNumber(quantity)} {metricLabel(metric)}
))} - {usage.total_tokens > 0 && ( + {(usage.total_tokens > 0 || + usage.cached_tokens > 0 || + usage.cache_creation_tokens > 0) && (
- {formatNumber(usage.total_tokens)} total tokens · in{" "} - {formatNumber(usage.prompt_tokens)} · out{" "} - {formatNumber(usage.completion_tokens)} -
- cache read {formatNumber(usage.cached_tokens)} · cache write{" "} - {formatNumber(usage.cache_creation_tokens)} + {allSelected ? ( + <> + {formatNumber(usage.total_tokens)} total tokens · in{" "} + {formatNumber(usage.prompt_tokens)} · out{" "} + {formatNumber(usage.completion_tokens)} +
+ cache read {formatNumber(usage.cached_tokens)} · cache write{" "} + {formatNumber(usage.cache_creation_tokens)} + + ) : tokenView.length === 1 ? ( + <> + {formatNumber(usage[tokenView[0]!])}{" "} + {MEASURE_LABELS[tokenView[0]!].toLowerCase()} + + ) : ( + <> + + {formatNumber(sumTokenMetrics(usage, tokenView))} selected + tokens + + + {tokenView.map((metric) => ( +
+ {formatNumber(usage[metric])}{" "} + {MEASURE_LABELS[metric].toLowerCase()} +
+ ))} + + )}
)} @@ -169,9 +273,11 @@ function ServiceName({ function StatsTable({ rows, firstHeading = "Service", + tokenView, }: { rows: { key: string; label: ReactNode; usage: AdminUsageStats }[]; firstHeading?: string; + tokenView: readonly TokenMetric[]; }) { return ( <> @@ -193,7 +299,7 @@ function StatsTable({ {formatNumber(row.usage.requests)} - + @@ -213,7 +319,7 @@ function StatsTable({
{formatNumber(row.usage.requests)} requests
- + ))} @@ -221,9 +327,16 @@ function StatsTable({ ); } -function ServiceTable({ services }: { services: AdminUsageService[] }) { +function ServiceTable({ + services, + tokenView, +}: { + services: AdminUsageService[]; + tokenView: readonly TokenMetric[]; +}) { return ( ({ key: `${service.service_id}:${service.service_slug}`, label: ( @@ -252,9 +365,11 @@ function ServiceTable({ services }: { services: AdminUsageService[] }) { function UserServices({ user, search, + tokenView, }: { user: AdminUsageIdentity; search: AdminUsageParams; + tokenView: readonly TokenMetric[]; }) { const usage = useAdminUsage({ ...search, @@ -275,7 +390,12 @@ function UserServices({ onRetry={() => void usage.refetch()} /> ) : ( - usage.data && + usage.data && ( + + ) )} ); @@ -283,9 +403,11 @@ function UserServices({ function RankingTable({ rows, search, + tokenView, }: { rows: AdminUsageRanking[]; search: AdminUsageParams; + tokenView: readonly TokenMetric[]; }) { const [expanded, setExpanded] = useState(null); const rowKey = (row: AdminUsageRanking) => @@ -335,7 +457,7 @@ function RankingTable({
- + {formatNumber(row.requests)} @@ -347,7 +469,11 @@ function RankingTable({ {expanded === rowKey(row) && ( - + )} @@ -367,10 +493,14 @@ function RankingTable({

{formatNumber(row.requests)} requests

- + {expanded === rowKey(row) && ( - + )} ))} @@ -382,6 +512,7 @@ export function AdminUsageList({ filters }: { filters: AnalyticsFilters }) { const routeSearch = useSearch({ from: "/dashboard/admin/usage" }); const search = normalizeAdminUsageSearch(routeSearch); const navigate = useNavigate(); + const [tokenView, setTokenView] = useState([...TOKEN_METRICS]); const filterKey = JSON.stringify(filters); const previousFilters = useRef(filterKey); useEffect(() => { @@ -395,18 +526,7 @@ export function AdminUsageList({ filters }: { filters: AnalyticsFilters }) { }); } }, [filterKey, navigate, routeSearch, search.page]); - const effectiveSearch: AdminUsageParams = { - ...search, - page: search.page, - period: filters.period, - from: filters.from ?? undefined, - to: filters.to ?? undefined, - user: undefined, - service: undefined, - services: filters.services, - actors: filters.actors, - owners: filters.owners, - }; + const effectiveSearch = listUsageParams(search, filters); const usage = useAdminUsage(effectiveSearch); const data = usage.data; const change = (patch: Partial) => @@ -424,14 +544,7 @@ export function AdminUsageList({ filters }: { filters: AnalyticsFilters }) { ); // Expansion reads the exact response window so an interaction cannot shift // a rolling boundary between the summary and the user's detail. - const detailSearch: AdminUsageParams = data - ? { - ...effectiveSearch, - period: "custom", - from: data.window.from, - to: data.window.to, - } - : effectiveSearch; + const detailSearch = detailUsageParams(effectiveSearch, data); return (
{rangeError ? ( @@ -510,15 +623,27 @@ export function AdminUsageList({ filters }: { filters: AnalyticsFilters }) { overlap input. Quantities include each billing component and resale event.

+
+ +

By service

- +

Platform key vs own key

({ key: lane.credential_class, @@ -568,7 +693,11 @@ export function AdminUsageList({ filters }: { filters: AnalyticsFilters }) { )}
- + {data.ranking.length === 0 && (

No ranking rows on this page. diff --git a/frontend/src/hooks/use-admin-usage.test.tsx b/frontend/src/hooks/use-admin-usage.test.tsx index fba6c822b..a92ef6250 100644 --- a/frontend/src/hooks/use-admin-usage.test.tsx +++ b/frontend/src/hooks/use-admin-usage.test.tsx @@ -1,24 +1,30 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import type { PropsWithChildren } from "react"; -import { beforeEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { api } from "@/lib/api-client"; import { usageFixture } from "@/test/admin-usage-fixture"; import { normalizeAdminUsageSearch } from "@/schemas/admin-usage"; -import { adminUsagePath, useAdminUsage } from "./use-admin-usage"; +import { useAuthStore } from "@/stores/auth-store"; +import { + adminUsagePath, + adminUsageQueryOptions, + useAdminUsage, + usePreloadAdminUsageDetails, +} from "./use-admin-usage"; vi.mock("@/lib/api-client", () => ({ api: { get: vi.fn() } })); +const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); +const originalAuth = useAuthStore.getState(); function wrapper({ children }: PropsWithChildren) { - return ( - - {children} - - ); + return {children}; } -beforeEach(() => vi.clearAllMocks()); +beforeEach(() => { + vi.clearAllMocks(); + client.clear(); +}); +afterEach(() => useAuthStore.setState(originalAuth)); it("builds a validated query with every control and parses the receipt", async () => { const params = normalizeAdminUsageSearch({ period: "7d", @@ -59,3 +65,46 @@ it("rejects a malformed response instead of showing invented zero totals", async ); await waitFor(() => expect(result.current.isError).toBe(true)); }); +it("preloads every visible user's services with a reusable five-minute cache", async () => { + const data = usageFixture(); + const secondUser = "33333333-3333-4333-8333-333333333333"; + data.ranking.push({ + ...data.ranking[0]!, + user: { ...data.ranking[0]!.user, id: secondUser }, + }); + useAuthStore.setState({ + user: { ...originalAuth.user!, id: "admin-1" }, + }); + vi.mocked(api.get).mockResolvedValue(usageFixture()); + const params = { + ...normalizeAdminUsageSearch({ + period: "custom", + from: data.window.from, + to: data.window.to, + }), + services: [], + actors: [], + owners: [], + }; + const { unmount } = renderHook( + () => usePreloadAdminUsageDetails(data, params), + { wrapper }, + ); + await waitFor(() => expect(api.get).toHaveBeenCalledTimes(2)); + const first = { ...params, user: data.ranking[0]!.user.id }; + expect(adminUsageQueryOptions(first, "admin-1")).toMatchObject({ + staleTime: 5 * 60_000, + gcTime: 10 * 60_000, + }); + expect( + client.getQueryData(adminUsageQueryOptions(first, "admin-1").queryKey), + ).toBeDefined(); + expect( + client.getQueryState(adminUsageQueryOptions(first, "admin-1").queryKey) + ?.isInvalidated, + ).toBe(false); + const { result } = renderHook(() => useAdminUsage(first), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.get).toHaveBeenCalledTimes(2); + unmount(); +}); diff --git a/frontend/src/hooks/use-admin-usage.ts b/frontend/src/hooks/use-admin-usage.ts index 394fdb4c3..2b1207123 100644 --- a/frontend/src/hooks/use-admin-usage.ts +++ b/frontend/src/hooks/use-admin-usage.ts @@ -1,4 +1,5 @@ -import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/api-client"; import { adminUsageResponseSchema, @@ -6,6 +7,8 @@ import { } from "@/schemas/admin-usage"; import type { AnalyticsFilters } from "@/schemas/usage-analytics"; import type { AdminUsageSearch } from "@/types/admin"; +import type { AdminUsageResponse } from "@/types/admin"; +import { useAuthStore } from "@/stores/auth-store"; export type AdminUsageParams = AdminUsageSearch & Partial>; @@ -30,17 +33,55 @@ export function adminUsagePath(params: AdminUsageParams): string { query.set("per_page", String(params.per_page)); return `/admin/usage?${query.toString()}`; } +export function adminUsageQueryOptions( + params: AdminUsageParams, + userId?: string, +) { + const path = adminUsagePath(params); + return queryOptions({ + queryKey: ["admin", "usage", userId, path], + queryFn: async () => + adminUsageResponseSchema.parse(await api.get(path)), + staleTime: 5 * 60_000, + gcTime: 10 * 60_000, + retry: false, + }); +} + export function useAdminUsage(params: AdminUsageParams, enabled = true) { + const userId = useAuthStore((state) => state.user?.id); return useQuery({ - queryKey: ["admin", "usage", params], - queryFn: async () => - adminUsageResponseSchema.parse( - await api.get(adminUsagePath(params)), - ), + ...adminUsageQueryOptions(params, userId), enabled: enabled && (params.period !== "custom" || !usageRangeError(params.from, params.to)), - staleTime: 30_000, - retry: false, }); } + +export function usePreloadAdminUsageDetails( + data: AdminUsageResponse | undefined, + search: AdminUsageParams, +) { + const client = useQueryClient(); + const userId = useAuthStore((state) => state.user?.id); + const path = adminUsagePath(search); + useEffect(() => { + if (!data || !userId) return; + const users = [...new Set(data.ranking.map((row) => row.user.id))]; + let next = 0; + let active = true; + const preload = async () => { + while (active && next < users.length) { + const user = users[next++]; + await client.prefetchQuery( + adminUsageQueryOptions({ ...search, user }, userId), + ); + } + }; + // Keep the reporting API responsive while warming every visible user's detail. + for (let i = 0; i < Math.min(3, users.length); i++) void preload(); + return () => { + active = false; + }; + }, [client, data, path, search, userId]); +} diff --git a/frontend/src/hooks/use-usage-analytics.ts b/frontend/src/hooks/use-usage-analytics.ts index 84eca0b11..bd7595cf8 100644 --- a/frontend/src/hooks/use-usage-analytics.ts +++ b/frontend/src/hooks/use-usage-analytics.ts @@ -79,7 +79,8 @@ export function useAnalyticsOptions( }; }, enabled, - staleTime: 30_000, + staleTime: 5 * 60_000, + gcTime: 10 * 60_000, retry: false, }); } diff --git a/frontend/src/pages/admin-usage.router.test.tsx b/frontend/src/pages/admin-usage.router.test.tsx index a89fd4ce7..170e9cd3b 100644 --- a/frontend/src/pages/admin-usage.router.test.tsx +++ b/frontend/src/pages/admin-usage.router.test.tsx @@ -77,6 +77,13 @@ it("keeps the dashboard and list on one route with a shared saved time range", a expect(get).toHaveBeenCalledWith( expect.stringContaining("period=7d&sort=cost"), ); + await waitFor(() => + expect(get).toHaveBeenCalledWith( + expect.stringMatching( + /\/admin\/usage\?from=.*&to=.*&user=11111111-1111-4111-8111-111111111111/, + ), + ), + ); await userEvent.click(screen.getByRole("combobox", { name: "Time range" })); await userEvent.click(screen.getByRole("option", { name: "Last 30 days" })); @@ -98,9 +105,7 @@ it("keeps the dashboard and list on one route with a shared saved time range", a "aria-selected", "true", ); - expect( - screen.getAllByRole("heading", { name: /^Usage$/ }), - ).toHaveLength(1); + expect(screen.getAllByRole("heading", { name: /^Usage$/ })).toHaveLength(1); await waitFor(() => expect(api.put).toHaveBeenCalledWith( "/admin/usage/workspace", diff --git a/frontend/src/pages/admin-usage.test.tsx b/frontend/src/pages/admin-usage.test.tsx index 01e15c5ce..267728683 100644 --- a/frontend/src/pages/admin-usage.test.tsx +++ b/frontend/src/pages/admin-usage.test.tsx @@ -53,6 +53,21 @@ it("renders totals, token classes, costs, user names and org attribution without screen.getByRole("heading", { name: "Platform key vs own key" }), ).toBeInTheDocument(); }); +it("keeps the full token breakdown by default and sums custom token selections", async () => { + renderPage(); + expect( + screen.getAllByText(/cache read 30 · cache write 5/).length, + ).toBeGreaterThan(0); + await userEvent.click(screen.getByRole("button", { name: "Token view" })); + await userEvent.click(screen.getByRole("checkbox", { name: "Total tokens" })); + expect(screen.getByRole("button", { name: "Token view" })).toHaveTextContent( + "Custom (4)", + ); + expect(screen.getAllByText("155 selected tokens").length).toBeGreaterThan(0); + expect( + screen.queryByText(/cache read 30 · cache write 5/), + ).not.toBeInTheDocument(); +}); it("writes ranking and pagination changes into the list URL", async () => { const user = userEvent.setup(); renderPage(); diff --git a/frontend/src/pages/admin-usage.tsx b/frontend/src/pages/admin-usage.tsx index 2518413c7..a6c6c442c 100644 --- a/frontend/src/pages/admin-usage.tsx +++ b/frontend/src/pages/admin-usage.tsx @@ -40,9 +40,14 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { ErrorBanner } from "@/components/shared/error-banner"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { AdminUsageList } from "@/components/billing-analytics/usage-list"; +import { + AdminUsageList, + AdminUsagePreload, +} from "@/components/billing-analytics/usage-list"; import { PageHeader } from "@/components/shared/page-header"; import { cn } from "@/lib/utils"; +import { normalizeAdminUsageSearch } from "@/schemas/admin-usage"; +import type { AdminUsageSearch } from "@/types/admin"; const SamplePage = import.meta.env.DEV && import.meta.env.MODE === "test" @@ -65,6 +70,7 @@ export function AdminUsagePage() { return ( void navigate({ to: "/admin/usage", search: { ...search, tab: next } }) } @@ -74,9 +80,11 @@ export function AdminUsagePage() { function LiveAnalytics({ tab, onTabChange, + preloadSearch, }: { tab: "dashboard" | "list"; onTabChange: (tab: "dashboard" | "list") => void; + preloadSearch: AdminUsageSearch; }) { const user = useAuthStore((state) => state.user); const query = useUsageWorkspace(user?.id ?? ""); @@ -102,6 +110,7 @@ function LiveAnalytics({ editable={user?.role === "admin" || user?.is_admin === true} tab={tab} onTabChange={onTabChange} + preloadSearch={preloadSearch} /> ); } @@ -114,6 +123,7 @@ export function AnalyticsWorkspace({ initialLayout, tab, onTabChange, + preloadSearch, }: { initial: WorkspaceResponse; userId: string; @@ -123,6 +133,7 @@ export function AnalyticsWorkspace({ initialLayout?: AnalyticsLayout; tab?: "dashboard" | "list"; onTabChange?: (tab: "dashboard" | "list") => void; + preloadSearch?: AdminUsageSearch; }) { const [starting] = useState(() => initialLayout && !initial.config @@ -515,6 +526,9 @@ export function AnalyticsWorkspace({ + {preloadSearch && !workspace.pendingRecovery && ( + + )}

Usage windows are UTC and end-exclusive. Gross cost includes wallet, diff --git a/frontend/src/schemas/usage-analytics.ts b/frontend/src/schemas/usage-analytics.ts index 5d11b80fe..174d09452 100644 --- a/frontend/src/schemas/usage-analytics.ts +++ b/frontend/src/schemas/usage-analytics.ts @@ -67,6 +67,7 @@ export const analyticsPanelSchema = z span: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(), height: z.enum(["compact", "standard", "tall"]).optional(), interval: z.enum(ANALYTICS_INTERVALS).optional(), + table_display: z.enum(["always", "accordion"]).optional(), }) .refine( (panel) => panel.chart !== "combo" || panel.measure !== "requests",