Skip to contentVibraUI

Usage headline

What the window cost across every provider, the requests and how many did not return 200, the tokens in and out, and the P95 latency beside the median; reads headline().

Preview

Install

npx shadcn@latest add @vibra/widget-ai-overview-usage-summary

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

Source

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/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",
}

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the AI cost and latency page