Latency
The median and 95th-percentile response time, a point a day over the thirty-day window; reads latencyByDay().
Preview
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-ai-overview-latency-chartNeeds the @vibra registry in your components.json — set it up once.
Source
"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>
)
}/**
* 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