freeDashboard · Next.js + Tailwind
Tollgate
A dashboard for an AI API's developer portal: keys you create, reveal once and revoke behind a confirm, requests and latency by endpoint, rate limits per plan and webhook endpoints with test deliveries.
loopkitui.com/t/api-keys-portalscroll inside ↓
Built from
- Dash kit (shell, charts, tables)in the zip
For: API-first AI companies that need a self-serve portal for keys, usage and webhooks.
prompt
You are building a complete dashboard app screen with Loopkit: the "Tollgate" template.
Goal: A dashboard for an AI API's developer portal: keys you create, reveal once and revoke behind a confirm, requests and latency by endpoint, rate limits per plan and webhook endpoints with test deliveries.
Audience: API-first AI companies that need a self-serve portal for keys, usage and webhooks.
Stack: Next.js App Router, TypeScript, Tailwind CSS v4, framer-motion. Colour tokens are shadcn's plus Loopkit's --signal, --ok, --warn, --info (see https://loopkitui.com/docs/theming). Light theme by default, dark via the .dark class.
1. Install the Loopkit components this page uses:
npx shadcn@latest add https://loopkitui.com/r/dash-kit.json # dash-kit
2. Create app/page.tsx with the source below. Keep the section order and the copy structure; replace the product name, copy and links with mine.
3. Check it at 360, 768, 1440 and 2560 px wide, in light and dark, with reduced motion on and off. The console must be clean.
Page source:
```tsx
"use client";
import * as React from "react";
import {
DashShell,
Panel,
Kpi,
AreaChart,
BarChart,
DataTable,
Pill,
Seg,
Btn,
Sheet,
Facts,
Confirm,
Progress,
Toggle,
Dropdown,
Empty,
Icon,
useToast,
series,
fmt,
type Column,
type Tone,
} from "@/components/loopkit/dash-kit";
/**
* Tollgate — a developer portal for an AI API: keys you can create, reveal once and revoke,
* usage by endpoint, rate limits per plan and webhook endpoints with delivery history.
*/
type Env = "live" | "test";
interface ApiKey {
id: string;
name: string;
env: Env;
prefix: string;
scopes: string[];
created: string;
lastUsed: string;
calls: number;
revoked?: boolean;
}
const KEYS: ApiKey[] = [
{ id: "k1", name: "Production backend", env: "live", prefix: "tg_live_7Hq2", scopes: ["completions", "embeddings"], created: "Aug 02", lastUsed: "2 min ago", calls: 1840210 },
{ id: "k2", name: "Search indexer", env: "live", prefix: "tg_live_Lm9c", scopes: ["embeddings"], created: "Sep 14", lastUsed: "11 min ago", calls: 922400 },
{ id: "k3", name: "Support assistant", env: "live", prefix: "tg_live_xP4e", scopes: ["completions", "moderation"], created: "Oct 01", lastUsed: "1 h ago", calls: 311980 },
{ id: "k4", name: "Local development", env: "test", prefix: "tg_test_Qa1v", scopes: ["completions", "embeddings", "moderation"], created: "Oct 20", lastUsed: "yesterday", calls: 18240 },
{ id: "k5", name: "CI smoke tests", env: "test", prefix: "tg_test_Zr8k", scopes: ["completions"], created: "Nov 03", lastUsed: "3 h ago", calls: 4410 },
];
const DAYS = Array.from({ length: 14 }, (_, i) => `Nov ${i + 2}`);
const ENDPOINTS = [
{ id: "e1", path: "/v1/completions", calls: 1622000, p50: 420, p95: 1310, errors: 0.4 },
{ id: "e2", path: "/v1/embeddings", calls: 1190400, p50: 38, p95: 96, errors: 0.1 },
{ id: "e3", path: "/v1/moderation", calls: 210300, p50: 52, p95: 140, errors: 0.2 },
{ id: "e4", path: "/v1/rerank", calls: 64100, p50: 88, p95: 230, errors: 0.9 },
{ id: "e5", path: "/v1/files", calls: 12030, p50: 160, p95: 610, errors: 1.6 },
];
const TITLES: Record<string, [string, string]> = {
overview: ["Overview", "Requests, errors and spend across every key"],
keys: ["API keys", "Create, scope and revoke the keys your services use"],
usage: ["Usage", "Requests and latency by endpoint"],
limits: ["Rate limits", "What your plan allows per minute and per day"],
webhooks: ["Webhooks", "Endpoints we call when something happens"],
};
export default function Page() {
const [view, setView] = React.useState("overview");
return (
<DashShell
brand="Tollgate"
mark="T"
workspace="Fernway Labs · Scale plan"
accent="teal"
variant="panel"
nav={[
{ id: "overview", label: "Overview", icon: "home" },
{ id: "keys", label: "API keys", icon: "key", badge: KEYS.length },
{ id: "usage", label: "Usage", icon: "chart" },
{ id: "limits", label: "Rate limits", icon: "clock" },
{ id: "webhooks", label: "Webhooks", icon: "bolt" },
]}
navFooter={[{ id: "docs", label: "API reference", icon: "book" }]}
active={view}
onNavigate={(v) => setView(v === "docs" ? "overview" : v)}
title={TITLES[view][0]}
subtitle={TITLES[view][1]}
searchPlaceholder="Search keys and endpoints"
user={{ name: "Priya Raman", role: "Owner" }}
>
{view === "overview" && <Overview />}
{view === "keys" && <Keys />}
{view === "usage" && <Usage />}
{view === "limits" && <Limits />}
{view === "webhooks" && <Webhooks />}
</DashShell>
);
}
function Overview() {
const [range, setRange] = React.useState<"7d" | "14d">("14d");
const n = range === "7d" ? 7 : 14;
const ok = series(21, 14, 180, 30, 6).map((v) => Math.round(v * 1000));
const err = series(22, 14, 1.4, 0.6).map((v) => Math.round(v * 1000));
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
<Kpi label="Requests (14d)" value="3.1M" delta="+18%" hint="vs prior" spark={ok.slice(-10)} tone="accent" />
<Kpi label="Error rate" value="0.42%" delta="-0.1 pt" spark={series(23, 10, 0.5, 0.1)} tone="ok" />
<Kpi label="p95 latency" value="1.1 s" delta="+60 ms" good={false} tone="warn" />
<Kpi label="Spend this month" value="$2,184" delta="44% of budget" tone="info" />
</div>
<Panel title="Requests per day" subtitle="Successful and failed calls" action={<Seg size="sm" label="Range" options={["7d", "14d"] as const} value={range} onChange={setRange} />}>
<AreaChart labels={DAYS.slice(-n)} series={[{ name: "Successful", data: ok.slice(-n), tone: "accent" }, { name: "Failed", data: err.slice(-n), tone: "danger" }]} height={240} />
</Panel>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Panel title="Getting started" subtitle="Call the API with a key from the Keys page">
<pre className="overflow-x-auto rounded-xl bg-muted p-4 font-mono text-xs leading-relaxed scrollbar-thin">{`curl https://api.tollgate.dev/v1/completions \\
-H "Authorization: Bearer $TOLLGATE_KEY" \\
-d '{"model":"fast","input":"Hello"}'`}</pre>
</Panel>
<Panel title="Monthly budget" subtitle="Requests are refused once the hard cap is reached">
<p className="font-display text-3xl font-bold tabular-nums">$2,184 <span className="text-base font-medium text-muted-foreground">of $5,000</span></p>
<Progress className="mt-4 h-2" value={43.7} />
<p className="mt-3 text-sm text-muted-foreground">Alerts go to the owners at 50%, 80% and 100%.</p>
</Panel>
</div>
</div>
);
}
function Keys() {
const toast = useToast();
const [keys, setKeys] = React.useState(KEYS);
const [env, setEnv] = React.useState<"all" | Env>("all");
const [creating, setCreating] = React.useState(false);
const [revealed, setRevealed] = React.useState<string | null>(null);
const [revoke, setRevoke] = React.useState<ApiKey | null>(null);
const [open, setOpen] = React.useState<ApiKey | null>(null);
const [name, setName] = React.useState("");
const [newEnv, setNewEnv] = React.useState<Env>("test");
const rows = keys.filter((k) => env === "all" || k.env === env);
const cols: Column<ApiKey>[] = [
{ key: "name", label: "Name", sort: (k) => k.name, render: (k) => <span className={k.revoked ? "text-muted-foreground line-through" : "font-medium"}>{k.name}</span> },
{ key: "prefix", label: "Key", render: (k) => <span className="font-mono text-xs">{k.prefix}••••••••</span> },
{ key: "env", label: "Env", render: (k) => <Pill tone={k.env === "live" ? "ok" : "info"}>{k.env}</Pill> },
{ key: "lastUsed", label: "Last used" },
{ key: "calls", label: "Calls (30d)", align: "right", sort: (k) => k.calls, render: (k) => fmt.num(k.calls) },
{ key: "status", label: "Status", render: (k) => (k.revoked ? <Pill tone="danger" dot>revoked</Pill> : <Pill tone="ok" dot>active</Pill>) },
];
return (
<div className="space-y-6">
{revealed && (
<div className="rounded-2xl border border-[var(--acc)] bg-card p-5">
<p className="text-sm font-semibold">Copy your new key now — it won't be shown again.</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded-lg bg-muted px-3 py-2 font-mono text-xs">{revealed}</code>
<Btn icon="copy" onClick={() => { navigator.clipboard?.writeText(revealed).catch(() => {}); toast("Key copied", "ok"); }}>Copy</Btn>
<Btn variant="plain" onClick={() => setRevealed(null)}>Done</Btn>
</div>
</div>
)}
<Panel
title="Keys"
subtitle="Click a key for scopes and history"
pad={false}
action={
<div className="flex flex-wrap gap-2">
<Seg size="sm" label="Environment" options={[{ value: "all", label: "All" }, { value: "live", label: "Live" }, { value: "test", label: "Test" }]} value={env} onChange={setEnv} />
<Btn variant="primary" icon="plus" onClick={() => setCreating(true)}>Create key</Btn>
</div>
}
>
<DataTable columns={cols} rows={rows} onRowClick={setOpen} selected={open?.id} empty="No keys in this environment." />
</Panel>
<Sheet
open={creating}
onClose={() => setCreating(false)}
title="Create a key"
footer={
<>
<Btn onClick={() => setCreating(false)}>Cancel</Btn>
<Btn
variant="primary"
disabled={!name.trim()}
onClick={() => {
const tail = Math.abs(name.split("").reduce((a, c) => a * 31 + c.charCodeAt(0), 7)).toString(36).padEnd(6, "x").slice(0, 6);
const prefix = `tg_${newEnv}_${tail.slice(0, 4)}`;
setKeys((ks) => [{ id: `k${ks.length + 1}`, name: name.trim(), env: newEnv, prefix, scopes: ["completions"], created: "Today", lastUsed: "never", calls: 0 }, ...ks]);
setRevealed(`${prefix}${tail}9fK2mWq7Lr3TzA0`);
setCreating(false);
setName("");
toast("Key created", "ok");
}}
>
Create
</Btn>
</>
}
>
<div className="space-y-4">
<label className="block text-sm font-medium">
Name
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Billing worker" className="mt-1.5 h-9 w-full rounded-lg border border-border bg-background px-3 text-sm outline-none focus:border-[var(--acc)]" />
</label>
<div>
<p className="mb-1.5 text-sm font-medium">Environment</p>
<Seg label="Environment" options={[{ value: "test", label: "Test" }, { value: "live", label: "Live" }]} value={newEnv} onChange={setNewEnv} />
</div>
<p className="text-xs text-muted-foreground">The full key is shown once after creation. We store only a hash.</p>
</div>
</Sheet>
<Sheet
open={!!open}
onClose={() => setOpen(null)}
title={open?.name ?? ""}
footer={open && !open.revoked && <Btn variant="danger" icon="trash" onClick={() => setRevoke(open)}>Revoke key</Btn>}
>
{open && (
<div className="space-y-5">
<Facts items={[["Key", <span key="p" className="font-mono text-xs">{open.prefix}••••</span>], ["Environment", open.env], ["Created", open.created], ["Last used", open.lastUsed], ["Calls (30d)", fmt.num(open.calls)]]} />
<div>
<p className="mb-2 text-sm font-semibold">Scopes</p>
<div className="flex flex-wrap gap-2">{open.scopes.map((s) => <Pill key={s} tone="accent">{s}</Pill>)}</div>
</div>
</div>
)}
</Sheet>
<Confirm
open={!!revoke}
title={`Revoke "${revoke?.name}"?`}
body="Requests using this key start failing immediately. This can't be undone; create a new key instead."
confirmLabel="Revoke"
danger
onCancel={() => setRevoke(null)}
onConfirm={() => {
setKeys((ks) => ks.map((k) => (k.id === revoke?.id ? { ...k, revoked: true } : k)));
toast(`${revoke?.name} revoked`, "warn");
setRevoke(null);
setOpen(null);
}}
/>
</div>
);
}
function Usage() {
const [metric, setMetric] = React.useState<"calls" | "p95" | "errors">("calls");
const cols: Column<(typeof ENDPOINTS)[number]>[] = [
{ key: "path", label: "Endpoint", render: (e) => <span className="font-mono text-xs">{e.path}</span> },
{ key: "calls", label: "Calls", align: "right", sort: (e) => e.calls, render: (e) => fmt.num(e.calls) },
{ key: "p50", label: "p50", align: "right", sort: (e) => e.p50, render: (e) => `${e.p50} ms` },
{ key: "p95", label: "p95", align: "right", sort: (e) => e.p95, render: (e) => `${e.p95} ms` },
{ key: "errors", label: "Errors", align: "right", sort: (e) => e.errors, render: (e) => <span className={e.errors > 1 ? "text-destructive" : ""}>{fmt.pct(e.errors)}</span> },
];
const pick = (e: (typeof ENDPOINTS)[number]) => (metric === "calls" ? e.calls : metric === "p95" ? e.p95 : e.errors);
return (
<div className="space-y-6">
<Panel title="By endpoint" subtitle="Last 14 days" action={<Seg size="sm" label="Metric" options={[{ value: "calls", label: "Calls" }, { value: "p95", label: "p95" }, { value: "errors", label: "Errors" }]} value={metric} onChange={setMetric} />}>
<BarChart labels={ENDPOINTS.map((e) => e.path.replace("/v1/", ""))} data={[{ name: metric, data: ENDPOINTS.map(pick), tone: metric === "errors" ? "danger" : "accent" }]} format={(n) => (metric === "calls" ? fmt.num(n) : metric === "p95" ? `${Math.round(n)} ms` : fmt.pct(n))} />
</Panel>
<Panel title="Endpoints" pad={false}>
<DataTable columns={cols} rows={ENDPOINTS} />
</Panel>
</div>
);
}
function Limits() {
const rows = [
{ name: "Requests per minute", used: 2840, cap: 6000 },
{ name: "Tokens per minute", used: 712000, cap: 1000000 },
{ name: "Requests per day", used: 214000, cap: 500000 },
{ name: "Concurrent streams", used: 41, cap: 50 },
];
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{rows.map((r) => {
const p = (r.used / r.cap) * 100;
const tone: Tone = p > 80 ? "warn" : "accent";
return (
<Panel key={r.name} title={r.name} subtitle="Peak in the last hour" action={<Pill tone={tone} dot>{p > 80 ? "near limit" : "healthy"}</Pill>}>
<p className="font-display text-2xl font-bold tabular-nums">
{fmt.num(r.used)} <span className="text-sm font-medium text-muted-foreground">/ {fmt.num(r.cap)}</span>
</p>
<Progress className="mt-3 h-2" value={p} tone={tone} />
</Panel>
);
})}
<Panel title="When you hit a limit" className="lg:col-span-2">
<p className="text-sm text-muted-foreground">
The API answers <span className="font-mono text-foreground">429</span> with a <span className="font-mono text-foreground">retry-after</span> header. Client libraries back off and retry on their own.
</p>
</Panel>
</div>
);
}
const EVENTS = ["usage.threshold", "key.revoked", "batch.completed", "invoice.paid"];
function Webhooks() {
const toast = useToast();
const [hooks, setHooks] = React.useState([
{ id: "w1", url: "https://hooks.fernway.dev/tollgate", event: "batch.completed", on: true, ok: 99.8 },
{ id: "w2", url: "https://ops.fernway.dev/alerts", event: "usage.threshold", on: true, ok: 100 },
{ id: "w3", url: "https://billing.fernway.dev/in", event: "invoice.paid", on: false, ok: 97.1 },
]);
const [del, setDel] = React.useState<string | null>(null);
const [event, setEvent] = React.useState(EVENTS[0]);
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<Panel title="Endpoints" pad={false}>
{hooks.length === 0 ? (
<div className="p-5"><Empty icon="bolt" title="No webhooks" body="Add an endpoint to get notified." /></div>
) : (
<ul className="divide-y divide-border">
{hooks.map((h) => (
<li key={h.id} className="flex flex-wrap items-center gap-3 px-5 py-3.5">
<Icon name="globe" className="text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-xs">{h.url}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{h.event} · {h.ok}% delivered</p>
</div>
<Toggle label={`Enable ${h.url}`} checked={h.on} onChange={(v) => { setHooks((hs) => hs.map((x) => (x.id === h.id ? { ...x, on: v } : x))); toast(v ? "Webhook enabled" : "Webhook paused", v ? "ok" : "warn"); }} />
<Btn variant="plain" icon="refresh" onClick={() => toast(`Test event sent to ${h.url}`, "info")}>Test</Btn>
<Btn variant="plain" icon="trash" aria-label="Delete webhook" onClick={() => setDel(h.id)} />
</li>
))}
</ul>
)}
</Panel>
<Panel title="Add an endpoint" subtitle="We sign every delivery with your secret">
<div className="space-y-3">
<Dropdown label="Event" value={event} options={EVENTS.map((e) => ({ value: e, label: e }))} onChange={setEvent} />
<Btn
variant="primary"
icon="plus"
className="w-full"
onClick={() => {
setHooks((hs) => [...hs, { id: `w${Date.now()}`, url: `https://hooks.fernway.dev/${event.split(".")[0]}`, event, on: true, ok: 100 }]);
toast(`Endpoint added for ${event}`, "ok");
}}
>
Add endpoint
</Btn>
</div>
</Panel>
<Confirm
open={!!del}
title="Delete this webhook?"
body="Pending deliveries are dropped."
confirmLabel="Delete"
danger
onCancel={() => setDel(null)}
onConfirm={() => {
setHooks((hs) => hs.filter((h) => h.id !== del));
setDel(null);
toast("Webhook deleted", "warn");
}}
/>
</div>
);
}
```