TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import Link from "next/link";4import { useRouter, useSearchParams } from "next/navigation";5import { BarChart3, Download, Info, Menu, RefreshCw } from "lucide-react";6import { PageHeader, EmptyState, Skeleton } from "@/components/ui/misc";7import { Button } from "@/components/ui/button";8import { Tooltip } from "@/components/ui/tooltip";9import { ProviderIcon } from "@/components/brand/provider-icon";10import { useApp } from "@/components/app/store";11import { useApi } from "@/lib/client/api";12import { providerName } from "@/lib/client/providers";13import { formatUsd, formatNumber, cn } from "@/lib/utils";14import { errorMessage } from "@/lib/client/humanize";15import { isProviderId } from "@/lib/ai/core/types";16import type { ProviderId } from "@/lib/client/types";17import { ChartCard, Legend, providerColor, type ModelRow } from "./charts";18import { CostOverTime, RequestsOverTime, TokensOverTime, ProviderDonut, ModelBars, LatencyByModel } from "./lazy-charts";19import { KpiGrid, KpiSkeleton } from "./kpi-tiles";20import { RangePicker, FilterChips, ActiveFilters, DEFAULT_QUERY, rangeLabel, type UsageQueryState } from "./usage-filters";21import { ProjectionCard, SavingsCard } from "./insight-cards";22import { RecentActivity } from "./recent-activity";23import type { UsageSummary, UsageRangeKey } from "./types";2425const RANGE_KEYS: UsageRangeKey[] = ["today", "7d", "30d", "90d", "custom", "all"];2627function readQuery(sp: URLSearchParams | null): UsageQueryState {28 if (!sp) return DEFAULT_QUERY;29 const r = sp.get("range");30 const provider = sp.get("provider") ?? "";31 return {32 range: r && (RANGE_KEYS as string[]).includes(r) ? (r as UsageRangeKey) : DEFAULT_QUERY.range,33 from: sp.get("from") ?? "",34 to: sp.get("to") ?? "",35 provider: isProviderId(provider) ? provider : "",36 modelKey: sp.get("modelKey") ?? "",37 projectId: sp.get("projectId") ?? "",38 };39}4041function toSearch(q: UsageQueryState, tz: string): string {42 const sp = new URLSearchParams();43 sp.set("range", q.range);44 if (q.range === "custom") {45 if (q.from) sp.set("from", q.from);46 if (q.to) sp.set("to", q.to);47 }48 if (q.provider) sp.set("provider", q.provider);49 if (q.modelKey) sp.set("modelKey", q.modelKey);50 if (q.projectId) sp.set("projectId", q.projectId);51 sp.set("tz", tz);52 return sp.toString();53}5455export function UsageDashboard() {56 const router = useRouter();57 const searchParams = useSearchParams();58 const { modelsByKey, labels, setSidebarOpen } = useApp();59 const tz = React.useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", []);60 const [query, setQueryState] = React.useState<UsageQueryState>(() => readQuery(searchParams));6162 const setQuery = React.useCallback(63 (patch: Partial<UsageQueryState>) => {64 const next = { ...query, ...patch };65 setQueryState(next);66 // Keep the URL shareable (deep links from model pages / palette); tz is derived client-side.67 const sp = new URLSearchParams(toSearch(next, tz));68 sp.delete("tz");69 router.replace(`/app/usage?${sp.toString()}`, { scroll: false });70 },71 [query, router, tz],72 );7374 const search = toSearch(query, tz);75 const q = useApi<UsageSummary>(`/api/usage?${search}`, { keepPreviousData: true, revalidateOnFocus: true });76 const data = q.data;77 const hourly = data?.range.bucket === "hour";7879 const modelLabel = React.useCallback(80 (key: string) => {81 const m = modelsByKey.get(key);82 return labels[key] ?? m?.displayName ?? (key.startsWith("custom/") ? key.split(":").slice(1).join(":") : key.split("/").slice(1).join("/") || key);83 },84 [modelsByKey, labels],85 );8687 const modelRows = React.useMemo<ModelRow[]>(88 () =>89 (data?.byModel ?? []).map((m) => ({90 modelKey: m.modelKey,91 provider: m.provider,92 label: modelLabel(m.modelKey),93 requests: m.requests,94 costUsd: m.costUsd,95 inputTokens: m.inputTokens,96 outputTokens: m.outputTokens,97 tokensPerSec: m.tokensPerSec,98 avgLatencyMs: m.avgLatencyMs,99 avgTtftMs: m.avgTtftMs,100 failures: m.failures,101 })),102 [data?.byModel, modelLabel],103 );104105 const providersUsed = React.useMemo(() => (data?.byProvider ?? []).filter((p) => p.requests > 0).map((p) => p.provider), [data?.byProvider]);106 const empty = data ? data.kpis.requests === 0 : false;107 const hasFilters = Boolean(query.provider || query.modelKey || query.projectId);108 const exportHref = `/api/usage/export.csv?${search}`;109110 const header = (111 <>112 {/* Phone: compact 48 px bar */}113 <div className="sticky top-0 z-20 -mx-4 flex h-12 items-center gap-1 border-b border-hairline bg-bg/90 px-2 backdrop-blur md:hidden">114 <Button variant="ghost" size="icon-lg" aria-label="Open menu" onClick={() => setSidebarOpen(true)}>115 <Menu />116 </Button>117 <h1 className="flex-1 truncate text-[16px] font-semibold tracking-tight">Usage</h1>118 <Button variant="ghost" size="icon-lg" aria-label="Refresh" onClick={() => void q.mutate()} disabled={q.isValidating}>119 <RefreshCw className={cn(q.isValidating && "animate-spin")} />120 </Button>121 <Button variant="ghost" size="icon-lg" aria-label="Export CSV" asChild>122 <a href={exportHref} download>123 <Download />124 </a>125 </Button>126 </div>127 {/* Desktop */}128 <div className="hidden md:block">129 <PageHeader130 title="Usage"131 description={132 <span className="inline-flex flex-wrap items-center gap-1.5">133 Requests, tokens, latency and estimated spend across your providers.134 <Tooltip content="Costs are estimates computed from public list prices at request time. Your provider's invoice is the source of truth; cached and batch discounts may differ.">135 <span className="inline-flex cursor-help items-center gap-1 text-fg-subtle underline decoration-dotted underline-offset-2">136 <Info className="size-3.5" /> Costs are estimates137 </span>138 </Tooltip>139 </span>140 }141 actions={142 <>143 <Button variant="ghost" size="sm" onClick={() => void q.mutate()} loading={q.isValidating && Boolean(data)}>144 <RefreshCw /> Refresh145 </Button>146 <Button variant="outline" size="sm" asChild>147 <a href={exportHref} download>148 <Download /> Export CSV149 </a>150 </Button>151 </>152 }153 />154 </div>155 </>156 );157158 return (159 <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">160 <div className="mx-auto w-full max-w-7xl px-4 pb-8 md:py-6 md:px-6 lg:px-8">161 {header}162163 {/* One filter row scopes every chart below it */}164 <div className="mt-3 space-y-3 md:mt-5">165 <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">166 <div className="-mx-4 overflow-x-auto px-4 scrollbar-none md:mx-0 md:overflow-visible md:px-0">167 <RangePicker value={query} onChange={setQuery} compact />168 </div>169 {data ? (170 <p className="text-[11px] text-fg-subtle md:text-right">171 {rangeLabel(query)} · {hourly ? "hourly" : "daily"} · {data.range.tz}172 </p>173 ) : null}174 </div>175 <FilterChips value={query} onChange={setQuery} facets={data?.facets ?? null} modelLabel={modelLabel} />176 <ActiveFilters value={query} onChange={setQuery} modelLabel={modelLabel} projects={data?.facets.projects ?? []} />177 </div>178179 {q.error ? (180 <div className="mt-6">181 <EmptyState title="Could not load usage" description={errorMessage(q.error)} action={<Button size="sm" variant="outline" onClick={() => void q.mutate()}>Retry</Button>} />182 </div>183 ) : !data ? (184 <div className="mt-5 space-y-4" aria-busy="true" aria-label="Loading usage">185 <KpiSkeleton />186 <div className="grid gap-3 lg:grid-cols-2">187 <Skeleton className="h-[260px] rounded-xl" />188 <Skeleton className="h-[260px] rounded-xl" />189 </div>190 <div className="grid gap-3 lg:grid-cols-3">191 <Skeleton className="h-[220px] rounded-xl" />192 <Skeleton className="h-[220px] rounded-xl" />193 <Skeleton className="h-[220px] rounded-xl" />194 </div>195 </div>196 ) : empty ? (197 <div className="mt-6">198 <EmptyState199 icon={<BarChart3 />}200 title={hasFilters ? "No requests match these filters" : query.range === "all" ? "No usage yet" : `No usage in ${rangeLabel(query).toLowerCase()}`}201 description={hasFilters ? "Try clearing a filter or widening the time range." : "Every chat and Arena request is recorded here with tokens, latency and an estimated cost."}202 action={203 <div className="flex flex-wrap justify-center gap-2">204 {hasFilters ? (205 <Button variant="outline" size="sm" onClick={() => setQuery({ provider: "", modelKey: "", projectId: "" })}>206 Clear filters207 </Button>208 ) : null}209 {query.range !== "all" ? (210 <Button variant="outline" size="sm" onClick={() => setQuery({ range: "all", from: "", to: "" })}>211 Show all time212 </Button>213 ) : null}214 <Button size="sm" asChild>215 <Link href="/app/chat">Start a chat</Link>216 </Button>217 </div>218 }219 />220 </div>221 ) : (222 <div className={cn("mt-4 space-y-4 transition-opacity duration-200 md:mt-5", q.isValidating && "opacity-70")}>223 <KpiGrid kpis={data.kpis} projection={data.projection} series={data.series} />224225 {/* Time series */}226 <div className="grid gap-3 lg:grid-cols-2">227 <ChartCard title="Estimated cost over time" hint={hourly ? "Per hour" : "Per day"}>228 <CostOverTime data={data.series} hourly={Boolean(hourly)} />229 </ChartCard>230 <ChartCard231 title="Requests over time"232 hint={hourly ? "Per hour" : "Per day"}233 legend={234 data.kpis.failures ? (235 <Legend236 items={[237 { label: "Succeeded", color: "var(--accent)" },238 { label: "Failed", color: "var(--danger)" },239 ]}240 />241 ) : undefined242 }243 >244 <RequestsOverTime data={data.series} hourly={Boolean(hourly)} />245 </ChartCard>246 </div>247248 <div className="grid gap-3 lg:grid-cols-5">249 <ChartCard250 title="Tokens in / out"251 hint="Stacked per period"252 className="lg:col-span-3"253 legend={254 <Legend255 items={[256 { label: "Input", color: "var(--accent)", opacity: 0.55 },257 { label: "Output", color: "var(--fg)", opacity: 0.85 },258 ]}259 />260 }261 >262 <TokensOverTime data={data.series} hourly={Boolean(hourly)} />263 </ChartCard>264 <ChartCard title="Cost by provider" hint="Share of estimated spend" className="lg:col-span-2" legend={providersUsed.length > 1 ? <Legend items={providersUsed.map((p) => ({ label: providerName(p), color: providerColor(p), icon: <ProviderIcon provider={p as ProviderId} size={11} /> }))} /> : undefined}>265 <ProviderDonut data={data.byProvider} metric="costUsd" />266 </ChartCard>267 </div>268269 {/* Insights */}270 <div className="grid gap-3 lg:grid-cols-5">271 <ProjectionCard projection={data.projection} kpis={data.kpis} rangeDays={data.range.days} className="lg:col-span-2" />272 <SavingsCard savings={data.savings} className="lg:col-span-3" />273 </div>274275 {/* Per model */}276 <div className="grid gap-3 lg:grid-cols-2">277 <ChartCard title="Cost by model" hint="Estimated, USD">278 <ModelBars data={modelRows} metric="costUsd" format={(v) => formatUsd(v)} emptyText="No priced requests in this period." />279 </ChartCard>280 <ChartCard title="Requests by model" hint="Most used first">281 <ModelBars data={modelRows} metric="requests" format={(v) => formatNumber(v)} />282 </ChartCard>283 </div>284 <ChartCard title="Latency by model" hint="Streaming requests only · averages over the period">285 <LatencyByModel data={modelRows} />286 </ChartCard>287288 <RecentActivity rows={data.recent} modelLabel={modelLabel} />289290 <p className="text-[11px] leading-4 text-fg-subtle">291 All costs on this page are estimates from public list prices at request time; billing happens on your provider accounts.292 {data.kpis.unpricedRequests ? ` ${formatNumber(data.kpis.unpricedRequests)} request${data.kpis.unpricedRequests === 1 ? "" : "s"} had no known price (custom endpoints or unpriced models) and count as $0.` : ""}293 </p>294 </div>295 )}296 </div>297 </main>298 );299}300