Skip to contentVibraUI

AI cost and latency

What the product spends on hosted models: spend, tokens, P50 and P95 latency by day, the provider split, the status codes, and a trace log with an inspector.

Open the live page

Every number is db.aiRequests aggregated over one window — thirty days ending at REFERENCE_DATE, never at a clock, so the same days are summarised on every render. Spend is the sum of costCents, the percentiles are read off the window's sorted latencies by nearest rank, and the provider and status splits are counts. A trace is a request: the log row already carries every column the table and the panel show, so nothing is copied into a second shape. The table's timestamps are pinned to UTC like the day buckets, so a row and the point it belongs to never fall on different days. Only the traces table is a client island — it holds the view it is on and the row being inspected — and the panel opens as an overlay so it works at phone widths too. Composes AppShell, PageHeader, StatCardGroup, StatCard, DashboardGrid, ChartCard, LineChart, RankList, PercentageBar, SectionHeader, SegmentedControl, SimpleTable, StatusBadge, InspectorPanel, DescriptionList and JsonViewer.

Preview

Install

npx shadcn@latest add @vibra/ai-overview

Needs the @vibra registry in your components.json — set it up once.

Source

app/ai/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { LatencyChart } from "./components/latency-chart"
import { ProviderMixChart } from "./components/provider-mix-chart"
import { StatusMix } from "./components/status-mix"
import { TracesTable } from "./components/traces-table"
import { UsageSummary } from "./components/usage-summary"
import {
  currentUser,
  latencyByDay,
  providerMix,
  shellNotifications,
  statusMix,
  tracesByView,
  WINDOW_LABEL,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ai/nav"

/**
 * What the product spends on hosted models. The page is a server component: it
 * aggregates `db.aiRequests` over the window and hands each summary to the
 * component that draws it, and only the traces table — which holds the view it
 * is on and the row being inspected — is a client island. It is handed every
 * view's rows, so the sample store stays on the server.
 */
export default function AiUsagePage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.overview}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="AI usage"
        description="Spend, throughput and latency across every hosted model the product calls."
        meta={WINDOW_LABEL}
      />

      <UsageSummary />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
          <LatencyChart points={latencyByDay()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
          <ProviderMixChart rows={providerMix()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={12}>
          <StatusMix rows={statusMix()} />
        </DashboardGridItem>
      </DashboardGrid>

      <TracesTable traces={tracesByView()} />
    </AppShell>
  )
}
app/ai/data.ts
/**
 * What this page reads. Everything on it is `db.aiRequests`, aggregated:
 * spend is the sum of `costCents`, tokens are the two token columns, the
 * latency percentiles are read off the sorted `latencyMs` of the window, and
 * the provider and status splits are counts. The window ends at
 * `REFERENCE_DATE`, never at a clock, so the same 30 days are summarised every
 * time the page renders.
 */
import { formatCompact, formatCurrency, formatDuration, formatNumber, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type AiRequest, type Member } from "@/lib/sample-data"

import { dayLabel } from "./vocabulary"

const DAY_MS = 86_400_000
const WINDOW_DAYS = 30

/** The window every number on this page is measured over: 30 days to "now". */
export const WINDOW_FROM = new Date(REFERENCE_DATE.getTime() - WINDOW_DAYS * DAY_MS)

/** The window the page covers, stated in the zone its days are counted in. */
export const WINDOW_LABEL = `${dayLabel.format(WINDOW_FROM)} – ${dayLabel.format(REFERENCE_DATE)} UTC`

const REQUESTS: AiRequest[] = db.aiRequests
  .all()
  .filter((request) => request.at >= WINDOW_FROM && request.at <= REFERENCE_DATE)
  .sort((a, b) => b.at.getTime() - a.at.getTime())

/**
 * The `p`th percentile of `values` by nearest rank — 0.5 is the median. The
 * caller sorts once and asks twice, so this takes an already-sorted list.
 */
function percentile(sorted: number[], p: number): number {
  if (sorted.length === 0) return 0
  const rank = Math.ceil(p * sorted.length)
  return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))]
}

const LATENCIES = REQUESTS.map((request) => request.latencyMs).sort((a, b) => a - b)

/** The four headline numbers, already formatted. */
export function headline() {
  const spendCents = REQUESTS.reduce((total, request) => total + request.costCents, 0)
  const promptTokens = REQUESTS.reduce((total, request) => total + request.promptTokens, 0)
  const completionTokens = REQUESTS.reduce((total, request) => total + request.completionTokens, 0)
  const failed = REQUESTS.filter((request) => request.status !== 200).length

  return {
    spend: formatCurrency(spendCents / 100, "USD", { maximumFractionDigits: 0 }),
    requests: formatNumber(REQUESTS.length, { maximumFractionDigits: 0 }),
    failed: formatNumber(failed, { maximumFractionDigits: 0 }),
    tokens: formatCompact(promptTokens + completionTokens),
    tokensIn: formatCompact(promptTokens),
    tokensOut: formatCompact(completionTokens),
    p50: formatDuration(percentile(LATENCIES, 0.5)),
    p95: formatDuration(percentile(LATENCIES, 0.95)),
  }
}

export type LatencyPoint = { date: string; p50: number; p95: number }

/** P50 and P95 for every day in the window, oldest first. */
export function latencyByDay(): LatencyPoint[] {
  const buckets = new Map<string, number[]>()
  for (const request of REQUESTS) {
    const key = request.at.toISOString().slice(0, 10)
    const bucket = buckets.get(key)
    if (bucket) bucket.push(request.latencyMs)
    else buckets.set(key, [request.latencyMs])
  }

  return [...buckets.entries()]
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([date, values]) => {
      const sorted = [...values].sort((a, b) => a - b)
      return { date, p50: percentile(sorted, 0.5), p95: percentile(sorted, 0.95) }
    })
}

export type ProviderRow = { provider: string; spend: number; requests: number }

/** What each provider cost, in whole currency units, biggest bill first. */
export function providerMix(): ProviderRow[] {
  const totals = new Map<string, { spend: number; requests: number }>()
  for (const request of REQUESTS) {
    const row = totals.get(request.provider) ?? { spend: 0, requests: 0 }
    row.spend += request.costCents
    row.requests += 1
    totals.set(request.provider, row)
  }

  return [...totals.entries()]
    .map(([provider, row]) => ({
      provider,
      spend: Math.round(row.spend / 100),
      requests: row.requests,
    }))
    .sort((a, b) => b.spend - a.spend)
}

export type StatusRow = { status: number; label: string; count: number }

// What each code means here, in the order a reader wants them.
const STATUS_LABELS: Record<number, string> = {
  200: "OK",
  429: "Rate limited",
  500: "Server error",
}

/** How the window's requests ended, most common first. */
export function statusMix(): StatusRow[] {
  const counts = new Map<number, number>()
  for (const request of REQUESTS) {
    counts.set(request.status, (counts.get(request.status) ?? 0) + 1)
  }
  return [...counts.entries()]
    .map(([status, count]) => ({ status, label: STATUS_LABELS[status] ?? "Other", count }))
    .sort((a, b) => b.count - a.count)
}

/**
 * A trace is a request — the log row already carries every column the table and
 * the panel show, so the page reads `AiRequest` rather than copying it.
 */
export type Trace = AiRequest

/** Which slice of the log the table shows. */
export type TraceView = "recent" | "slowest" | "errors"

const TRACE_LIMIT = 12

/** The twelve traces the chosen view puts first. */
export function traces(view: TraceView): Trace[] {
  const rows =
    view === "errors"
      ? REQUESTS.filter((request) => request.status !== 200)
      : view === "slowest"
        ? [...REQUESTS].sort((a, b) => b.latencyMs - a.latencyMs)
        : REQUESTS

  return rows.slice(0, TRACE_LIMIT)
}

/**
 * Every view's twelve at once, for the table: it switches between them in the
 * browser, and the panel opens on a row it already holds.
 */
export function tracesByView(): Record<TraceView, Trace[]> {
  return { recent: traces("recent"), slowest: traces("slowest"), errors: traces("errors") }
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/ai/vocabulary.ts
/**
 * How this page writes a day and a moment. Vocabulary, not data — neither
 * reads `db` — so the islands import them from here rather than from
 * `data.ts`, which reads the store at module scope and must never reach the
 * browser.
 */

/**
 * The days are bucketed by UTC date, so they are named in UTC too — a local
 * formatter would move every point one day earlier west of Greenwich, and
 * `formatDate` has no `timeZone` to pin it with.
 */
export const dayLabel = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  timeZone: "UTC",
})

/**
 * When a request happened. Pinned to UTC like the day labels, because the
 * table and the chart have to agree: a row dated in the reader's zone would
 * sit under a different day's point for anyone west of Greenwich.
 */
export const traceTime = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  hour: "numeric",
  minute: "2-digit",
  timeZone: "UTC",
})

/**
 * What each provider in the log is called. The providers are fictional —
 * three hosted labs and Northwind's own in-house models — like every AI name
 * in the kit.
 */
export const PROVIDER_NAMES: Record<string, string> = {
  sparrowmere: "Sparrowmere Labs",
  galewright: "Galewright AI",
  ternstead: "Ternstead AI",
  northwind: "Northwind",
}
app/ai/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/ai/components/usage-summary.tsx
import { BanknoteIcon, GaugeIcon, SendIcon, SparklesIcon } from "lucide-react"

import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { headline } from "../data"

const TITLE_ID = "ai-usage-summary"

/** The four numbers that answer "what did the last 30 days cost us?". */
export function UsageSummary() {
  const totals = headline()

  return (
    <section data-widget="widget-ai-overview-usage-summary" aria-labelledby={TITLE_ID}>
      <h2 id={TITLE_ID} className="sr-only">
        Usage summary
      </h2>
      <StatCardGroup columns={4} divided>
        <StatCard
          label="Spend"
          value={totals.spend}
          description="across all providers"
          icon={<BanknoteIcon />}
        />
        <StatCard
          label="Requests"
          value={totals.requests}
          description={`${totals.failed} did not return 200`}
          icon={<SendIcon />}
        />
        <StatCard
          label="Tokens"
          value={totals.tokens}
          description={`${totals.tokensIn} in · ${totals.tokensOut} out`}
          icon={<SparklesIcon />}
        />
        <StatCard
          label="P95 latency"
          value={totals.p95}
          description={`P50 ${totals.p50}`}
          icon={<GaugeIcon />}
        />
      </StatCardGroup>
    </section>
  )
}
app/ai/components/latency-chart.tsx
"use client"

import { formatDuration } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { LineChart } from "@/components/ui/line-chart"

import { type LatencyPoint } from "../data"
import { dayLabel } from "../vocabulary"

// Chart vocabulary — which key is drawn, what it is called, what colour it
// takes — belongs with the chart, not with the selectors that read the data.
const LATENCY_SERIES = [
  { key: "p50", label: "P50", color: "chart-1" as const },
  { key: "p95", label: "P95", color: "chart-4" as const },
]

/** P50 and P95 across the window, one point per day. */
export function LatencyChart({ points }: { points: LatencyPoint[] }) {
  return (
    <ChartCard
      data-widget="widget-ai-overview-latency-chart"
      title="Latency"
      description="Median and 95th percentile, by day."
      height={240}
    >
      <LineChart
        data={points}
        index="date"
        series={LATENCY_SERIES}
        height={240}
        // The key is a UTC date, so it is read back as one.
        indexFormatter={(value) => dayLabel.format(new Date(`${value}T00:00:00Z`))}
        valueFormatter={(value) => formatDuration(value)}
      />
    </ChartCard>
  )
}
app/ai/components/provider-mix-chart.tsx
"use client"

import { formatCurrency } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { RankList } from "@/components/ui/rank-list"

import { type ProviderRow } from "../data"
import { PROVIDER_NAMES } from "../vocabulary"

/**
 * What each provider billed over the window. A ranking rather than a chart:
 * four providers ordered by bill is a list, and the bar beside each row is the
 * whole comparison.
 */
export function ProviderMixChart({ rows }: { rows: ProviderRow[] }) {
  return (
    <ChartCard
      data-widget="widget-ai-overview-provider-mix-chart"
      title="Spend by provider"
      description="Whole dollars over the window."
      height={200}
    >
      <RankList
        color="chart-2"
        items={rows.map((row) => ({
          label: PROVIDER_NAMES[row.provider] ?? row.provider,
          value: row.spend,
        }))}
        format={(value) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })}
      />
    </ChartCard>
  )
}
app/ai/components/status-mix.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { chartTone } from "@/components/ui/chart-core"
import { PercentageBar } from "@/components/ui/percentage-bar"

import { type StatusRow } from "../data"

// A status code is a meaning, not a category, so it resolves to the semantic
// tokens rather than to a palette slot: --chart-positive for the requests that
// worked, the warning tone for throttling, --chart-negative for failures. The
// words are in the legend, so the colour only ever reinforces them.
const STATUS_COLORS: Record<number, string> = {
  200: chartTone("positive"),
  429: chartTone("warning"),
  500: chartTone("negative"),
}

/** How the window's requests ended, as one bar and its legend. */
export function StatusMix({ rows }: { rows: StatusRow[] }) {
  return (
    <ChartCard data-widget="widget-ai-overview-status-mix" title="Status codes" description="Every request in the window." height={96}>
      <div className="flex flex-col justify-center gap-4 py-2">
        <PercentageBar
          segments={rows.map((row) => ({
            label: `${row.status} ${row.label}`,
            value: row.count,
            color: STATUS_COLORS[row.status],
          }))}
          format={(value, percent) => `${formatNumber(value, { maximumFractionDigits: 0 })} (${percent}%)`}
        />
      </div>
    </ChartCard>
  )
}
app/ai/components/traces-table.tsx
"use client"

import * as React from "react"

import { formatCurrency, formatDuration, formatNumber } from "@/lib/format"
import { DescriptionList } from "@/components/ui/description-list"
import { InspectorPanel } from "@/components/ui/inspector-panel"
import { JsonViewer } from "@/components/ui/json-viewer"
import { SectionHeader } from "@/components/ui/section-header"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"

import { type Trace, type TraceView } from "../data"
import { PROVIDER_NAMES, traceTime } from "../vocabulary"

const VIEWS = [
  { value: "recent", label: "Recent" },
  { value: "slowest", label: "Slowest" },
  { value: "errors", label: "Errors" },
]

// The pill each code takes. 429 is a warning rather than a failure: the request
// was turned away, not lost.
const STATUS_VARIANT: Record<number, StatusVariant> = {
  200: "success",
  429: "warning",
  500: "danger",
}

const COLUMNS: SimpleTableColumn<Trace>[] = [
  {
    key: "at",
    header: "When",
    width: "10rem",
    cell: (row) => <span className="tabular-nums">{traceTime.format(row.at)}</span>,
  },
  {
    key: "model",
    header: "Model",
    cell: (row) => (
      <span className="flex flex-col">
        <span className="font-mono text-xs">{row.model}</span>
        <span className="text-xs text-muted-foreground">
          {PROVIDER_NAMES[row.provider] ?? row.provider}
        </span>
      </span>
    ),
  },
  {
    key: "tokens",
    header: "Tokens",
    align: "right",
    cell: (row) =>
      `${formatNumber(row.promptTokens, { maximumFractionDigits: 0 })} / ${formatNumber(row.completionTokens, { maximumFractionDigits: 0 })}`,
  },
  {
    key: "latencyMs",
    header: "Latency",
    align: "right",
    cell: (row) => formatDuration(row.latencyMs),
  },
  {
    key: "costCents",
    header: "Cost",
    align: "right",
    cell: (row) => formatCurrency(row.costCents / 100, "USD", { maximumFractionDigits: 2 }),
  },
  {
    key: "status",
    header: "Status",
    width: "8rem",
    cell: (row) => (
      <StatusBadge
        status={String(row.status)}
        variant={STATUS_VARIANT[row.status]}
        label={String(row.status)}
        size="sm"
      />
    ),
  },
]

export type TracesTableProps = {
  /** Each view's rows, read on the server: switching views is the browser's, and nothing is fetched. */
  traces: Record<TraceView, Trace[]>
}

/**
 * The request log, and the panel that opens onto whichever row is clicked.
 * Every view's rows arrive from the server page, so the table never reads the
 * store itself; the panel opens on a row one of those views holds.
 */
export function TracesTable({ traces }: TracesTableProps) {
  const [view, setView] = React.useState<TraceView>("recent")
  const [selectedId, setSelectedId] = React.useState<string | null>(null)

  const rows = traces[view]
  const selected = selectedId
    ? Object.values(traces)
        .flat()
        .find((row) => row.id === selectedId)
    : undefined

  return (
    <section data-widget="widget-ai-overview-traces-table" aria-label="Traces" className="flex flex-col gap-4">
      <SectionHeader
        as="h2"
        title="Traces"
        description="The most recent twelve, the slowest twelve, or everything that did not return 200."
        actions={
          <SegmentedControl
            size="sm"
            aria-label="Which traces"
            options={VIEWS}
            value={view}
            onValueChange={(value) => setView(value as TraceView)}
          />
        }
      />

      <div className="overflow-hidden panel">
        <SimpleTable
          columns={COLUMNS}
          rows={rows}
          rowKey="id"
          size="sm"
          onRowClick={(row) => setSelectedId(row.id)}
          emptyMessage="No requests in this view."
          caption="Click a row to inspect the request."
        />
      </div>

      <InspectorPanel
        open={Boolean(selected)}
        onOpenChange={(open) => {
          if (!open) setSelectedId(null)
        }}
        title={selected?.model ?? ""}
        description={selected ? `${PROVIDER_NAMES[selected.provider]} · ${selected.status}` : undefined}
      >
        {selected ? (
          <div className="flex flex-col gap-4">
            <DescriptionList
              items={[
                {
                  term: "Trace",
                  description: <span className="font-mono text-xs">{selected.traceId}</span>,
                },
                {
                  term: "Request",
                  description: <span className="font-mono text-xs">{selected.id}</span>,
                },
                { term: "When", description: `${traceTime.format(selected.at)} UTC` },
                {
                  term: "Tokens",
                  description: `${formatNumber(selected.promptTokens, { maximumFractionDigits: 0 })} in · ${formatNumber(selected.completionTokens, { maximumFractionDigits: 0 })} out`,
                },
                { term: "Latency", description: formatDuration(selected.latencyMs) },
                {
                  term: "Cost",
                  description: formatCurrency(selected.costCents / 100, "USD", {
                    maximumFractionDigits: 2,
                  }),
                },
              ]}
            />

            <div className="flex flex-col gap-2">
              <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
                Log record
              </h3>
              <JsonViewer
                data={{ ...selected, at: selected.at.toISOString() }}
                rootName="request"
                copyable
                maxHeight={220}
              />
            </div>
          </div>
        ) : null}
      </InspectorPanel>
    </section>
  )
}