SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
13.4 KB · 300 lines tsx
Raw Blame History
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