Skip to contentVibraUI

Traces

The newest twelve requests, the slowest twelve or every one that failed, and a panel on the one clicked with its tokens, cost and log record; reads tracesByView().

Preview

Install

npx shadcn@latest add @vibra/widget-ai-overview-traces-table

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

Source

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>
  )
}
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