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
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-ai-overview-traces-tableNeeds the @vibra registry in your components.json — set it up once.
Source
"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>
)
}/**
* 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 }))
}/**
* 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