Add dd0c/route Dashboard UI: React + Vite + Tailwind SPA
- Layout with sidebar navigation (Dashboard, Rules, Keys, Settings) - Dashboard page: stat cards, cost savings area chart (Recharts), model usage table - Rules page: routing rules CRUD with modal editor, strategy/complexity/model matching - Keys page: API key generation, copy-once reveal, revocation, quick-start code snippet - Settings page: org info, provider config, danger zone - API client (SWR + fetch wrapper) with full TypeScript types - dd0c dark theme: indigo primary, cyan accent, dark surfaces - Vite proxy config for local dev against API on :3000
This commit is contained in:
55
products/01-llm-cost-router/ui/src/pages/Dashboard.tsx
Normal file
55
products/01-llm-cost-router/ui/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import useSWR from 'swr';
|
||||
import { api, type AnalyticsSummary, type TimeseriesPoint, type ModelBreakdown } from '../lib/api';
|
||||
import CostChart from '../components/CostChart';
|
||||
import StatCard from '../components/StatCard';
|
||||
import ModelTable from '../components/ModelTable';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: summary } = useSWR('summary', api.getSummary);
|
||||
const { data: timeseries } = useSWR('timeseries', () => api.getTimeseries('hour'));
|
||||
const { data: models } = useSWR('models', api.getModels);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">Dashboard</h2>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total Requests"
|
||||
value={summary?.total_requests?.toLocaleString() ?? '—'}
|
||||
sub="Last 7 days"
|
||||
/>
|
||||
<StatCard
|
||||
label="Cost Saved"
|
||||
value={summary ? `$${summary.total_cost_saved.toFixed(2)}` : '—'}
|
||||
sub={summary ? `${summary.savings_pct.toFixed(1)}% savings` : ''}
|
||||
accent="success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Latency"
|
||||
value={summary ? `${summary.avg_latency_ms}ms` : '—'}
|
||||
sub={summary ? `P99: ${summary.p99_latency_ms}ms` : ''}
|
||||
/>
|
||||
<StatCard
|
||||
label="Routed"
|
||||
value={summary ? `${((summary.routing_decisions.cheapest / Math.max(summary.total_requests, 1)) * 100).toFixed(0)}%` : '—'}
|
||||
sub="Requests downgraded"
|
||||
accent="primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cost savings chart */}
|
||||
<div className="bg-dd0c-surface border border-dd0c-border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-dd0c-muted mb-4">Cost Savings Over Time</h3>
|
||||
<CostChart data={timeseries ?? []} />
|
||||
</div>
|
||||
|
||||
{/* Model breakdown */}
|
||||
<div className="bg-dd0c-surface border border-dd0c-border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-dd0c-muted mb-4">Model Usage</h3>
|
||||
<ModelTable data={models ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
products/01-llm-cost-router/ui/src/pages/Keys.tsx
Normal file
128
products/01-llm-cost-router/ui/src/pages/Keys.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { api, type ApiKey } from '../lib/api';
|
||||
|
||||
export default function Keys() {
|
||||
const { data: keys } = useSWR('keys', api.listKeys);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
|
||||
const create = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
const result = await api.createKey(newKeyName.trim());
|
||||
setCreatedKey(result.key);
|
||||
setNewKeyName('');
|
||||
mutate('keys');
|
||||
};
|
||||
|
||||
const revoke = async (id: string, name: string) => {
|
||||
if (!confirm(`Revoke key "${name}"? This cannot be undone.`)) return;
|
||||
await api.revokeKey(id);
|
||||
mutate('keys');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">API Keys</h2>
|
||||
|
||||
{/* Create key */}
|
||||
<div className="bg-dd0c-surface border border-dd0c-border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-dd0c-muted mb-3">Create New Key</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Key name (e.g. Production, Staging)"
|
||||
className="flex-1 bg-dd0c-bg border border-dd0c-border rounded px-3 py-1.5 text-sm"
|
||||
onKeyDown={(e) => e.key === 'Enter' && create()}
|
||||
/>
|
||||
<button
|
||||
onClick={create}
|
||||
className="px-4 py-1.5 bg-dd0c-primary text-white text-sm rounded-md hover:bg-dd0c-primary/80"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createdKey && (
|
||||
<div className="mt-3 p-3 bg-dd0c-success/10 border border-dd0c-success/30 rounded-md">
|
||||
<p className="text-xs text-dd0c-success mb-1">Copy this key now — it won't be shown again.</p>
|
||||
<code className="text-sm font-mono text-dd0c-text break-all select-all">{createdKey}</code>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(createdKey); }}
|
||||
className="ml-2 text-xs text-dd0c-muted hover:text-dd0c-text"
|
||||
>
|
||||
📋 Copy
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keys table */}
|
||||
<div className="bg-dd0c-surface border border-dd0c-border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm" role="table">
|
||||
<thead>
|
||||
<tr className="text-dd0c-muted text-xs uppercase tracking-wide border-b border-dd0c-border">
|
||||
<th className="text-left py-2 px-3">Name</th>
|
||||
<th className="text-left py-2 px-3">Key</th>
|
||||
<th className="text-left py-2 px-3">Scopes</th>
|
||||
<th className="text-left py-2 px-3">Last Used</th>
|
||||
<th className="text-left py-2 px-3">Created</th>
|
||||
<th className="text-right py-2 px-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(keys ?? []).map((key) => (
|
||||
<tr key={key.id} className="border-b border-dd0c-border/50 hover:bg-dd0c-border/20">
|
||||
<td className="py-2 px-3">{key.name}</td>
|
||||
<td className="py-2 px-3 font-mono text-dd0c-muted">{key.key_prefix}••••••••</td>
|
||||
<td className="py-2 px-3">
|
||||
{key.scopes.map((s) => (
|
||||
<span key={s} className="px-1.5 py-0.5 rounded text-xs bg-dd0c-border text-dd0c-muted mr-1">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-dd0c-muted text-xs">
|
||||
{key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never'}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-dd0c-muted text-xs">
|
||||
{new Date(key.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<button
|
||||
onClick={() => revoke(key.id, key.name)}
|
||||
className="text-dd0c-danger/60 hover:text-dd0c-danger text-xs"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(keys ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-dd0c-muted">
|
||||
No API keys. Generate one to start routing.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Usage instructions */}
|
||||
<div className="bg-dd0c-surface border border-dd0c-border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-dd0c-muted mb-2">Quick Start</h3>
|
||||
<pre className="text-xs font-mono text-dd0c-text bg-dd0c-bg p-3 rounded overflow-x-auto">
|
||||
{`# Replace your OpenAI base URL
|
||||
export OPENAI_BASE_URL=https://route.dd0c.dev/v1
|
||||
export OPENAI_API_KEY=dd0c_your_key_here
|
||||
|
||||
# Your existing code works unchanged
|
||||
from openai import OpenAI
|
||||
client = OpenAI() # Uses dd0c proxy automatically`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
products/01-llm-cost-router/ui/src/pages/Rules.tsx
Normal file
208
products/01-llm-cost-router/ui/src/pages/Rules.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { api, type RoutingRule } from '../lib/api';
|
||||
|
||||
const STRATEGIES = ['passthrough', 'cheapest', 'quality-first', 'cascading'];
|
||||
const COMPLEXITIES = ['low', 'medium', 'high'];
|
||||
|
||||
export default function Rules() {
|
||||
const { data: rules } = useSWR('rules', api.listRules);
|
||||
const [editing, setEditing] = useState<RoutingRule | null>(null);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
|
||||
const blank: RoutingRule = {
|
||||
priority: (rules?.length ?? 0) + 1,
|
||||
name: '',
|
||||
strategy: 'cheapest',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!editing) return;
|
||||
if (isNew) {
|
||||
await api.createRule(editing);
|
||||
} else if (editing.id) {
|
||||
await api.updateRule(editing.id, editing);
|
||||
}
|
||||
setEditing(null);
|
||||
setIsNew(false);
|
||||
mutate('rules');
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
if (!confirm('Delete this rule?')) return;
|
||||
await api.deleteRule(id);
|
||||
mutate('rules');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Routing Rules</h2>
|
||||
<button
|
||||
onClick={() => { setEditing(blank); setIsNew(true); }}
|
||||
className="px-3 py-1.5 bg-dd0c-primary text-white text-sm rounded-md hover:bg-dd0c-primary/80"
|
||||
>
|
||||
+ Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rules table */}
|
||||
<div className="bg-dd0c-surface border border-dd0c-border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm" role="table">
|
||||
<thead>
|
||||
<tr className="text-dd0c-muted text-xs uppercase tracking-wide border-b border-dd0c-border">
|
||||
<th className="text-left py-2 px-3">Priority</th>
|
||||
<th className="text-left py-2 px-3">Name</th>
|
||||
<th className="text-left py-2 px-3">Match</th>
|
||||
<th className="text-left py-2 px-3">Strategy</th>
|
||||
<th className="text-left py-2 px-3">Target</th>
|
||||
<th className="text-center py-2 px-3">Enabled</th>
|
||||
<th className="text-right py-2 px-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(rules ?? []).map((rule) => (
|
||||
<tr key={rule.id} className="border-b border-dd0c-border/50 hover:bg-dd0c-border/20">
|
||||
<td className="py-2 px-3 font-mono">{rule.priority}</td>
|
||||
<td className="py-2 px-3">{rule.name}</td>
|
||||
<td className="py-2 px-3 text-dd0c-muted text-xs font-mono">
|
||||
{[
|
||||
rule.match_model && `model=${rule.match_model}`,
|
||||
rule.match_feature && `feature=${rule.match_feature}`,
|
||||
rule.match_team && `team=${rule.match_team}`,
|
||||
rule.match_complexity && `complexity=${rule.match_complexity}`,
|
||||
].filter(Boolean).join(', ') || '*'}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-dd0c-primary/20 text-dd0c-primary">
|
||||
{rule.strategy}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 font-mono text-dd0c-accent text-xs">
|
||||
{rule.target_model || '—'}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{rule.enabled ? '✅' : '⏸️'}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => { setEditing(rule); setIsNew(false); }}
|
||||
className="text-dd0c-muted hover:text-dd0c-text text-xs"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rule.id && remove(rule.id)}
|
||||
className="text-dd0c-danger/60 hover:text-dd0c-danger text-xs"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(rules ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-dd0c-muted">
|
||||
No routing rules yet. Add one to start saving.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Edit modal */}
|
||||
{editing && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" role="dialog" aria-modal="true">
|
||||
<div className="bg-dd0c-surface border border-dd0c-border rounded-lg p-6 w-full max-w-lg space-y-4">
|
||||
<h3 className="text-lg font-semibold">{isNew ? 'New Rule' : 'Edit Rule'}</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="text-xs text-dd0c-muted">Name</span>
|
||||
<input
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
className="mt-1 w-full bg-dd0c-bg border border-dd0c-border rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-dd0c-muted">Priority</span>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.priority}
|
||||
onChange={(e) => setEditing({ ...editing, priority: parseInt(e.target.value) || 0 })}
|
||||
className="mt-1 w-full bg-dd0c-bg border border-dd0c-border rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-dd0c-muted">Match Model</span>
|
||||
<input
|
||||
value={editing.match_model ?? ''}
|
||||
onChange={(e) => setEditing({ ...editing, match_model: e.target.value || undefined })}
|
||||
placeholder="gpt-4o"
|
||||
className="mt-1 w-full bg-dd0c-bg border border-dd0c-border rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-dd0c-muted">Match Complexity</span>
|
||||
<select
|
||||
value={editing.match_complexity ?? ''}
|
||||
onChange={(e) => setEditing({ ...editing, match_complexity: e.target.value || undefined })}
|
||||
className="mt-1 w-full bg-dd0c-bg border border-dd0c-border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{COMPLEXITIES.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-dd0c-muted">Strategy</span>
|
||||
<select
|
||||
value={editing.strategy}
|
||||
onChange={(e) => setEditing({ ...editing, strategy: e.target.value })}
|
||||
className="mt-1 w-full bg-dd0c-bg border border-dd0c-border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{STRATEGIES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-dd0c-muted">Target Model</span>
|
||||
<input
|
||||
value={editing.target_model ?? ''}
|
||||
onChange={(e) => setEditing({ ...editing, target_model: e.target.value || undefined })}
|
||||
placeholder="gpt-4o-mini"
|
||||
className="mt-1 w-full bg-dd0c-bg border border-dd0c-border rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.enabled}
|
||||
onChange={(e) => setEditing({ ...editing, enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="text-sm">Enabled</span>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => { setEditing(null); setIsNew(false); }}
|
||||
className="px-3 py-1.5 text-sm text-dd0c-muted hover:text-dd0c-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
className="px-3 py-1.5 bg-dd0c-primary text-white text-sm rounded-md hover:bg-dd0c-primary/80"
|
||||
>
|
||||
{isNew ? 'Create' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
products/01-llm-cost-router/ui/src/pages/Settings.tsx
Normal file
73
products/01-llm-cost-router/ui/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import useSWR from 'swr';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
export default function Settings() {
|
||||
const { data: org } = useSWR('org', api.getOrg);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">Settings</h2>
|
||||
|
||||
{/* Org info */}
|
||||
<div className="bg-dd0c-surface border border-dd0c-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-dd0c-muted">Organization</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-dd0c-muted">Name</p>
|
||||
<p>{org?.name ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-dd0c-muted">Slug</p>
|
||||
<p className="font-mono">{org?.slug ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-dd0c-muted">Tier</p>
|
||||
<p>
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-dd0c-primary/20 text-dd0c-primary uppercase">
|
||||
{org?.tier ?? '—'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-dd0c-muted">ID</p>
|
||||
<p className="font-mono text-xs text-dd0c-muted">{org?.id ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider configs */}
|
||||
<div className="bg-dd0c-surface border border-dd0c-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-dd0c-muted">Providers</h3>
|
||||
<p className="text-xs text-dd0c-muted">
|
||||
Configure your LLM provider API keys. Keys are encrypted at rest.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<ProviderRow name="OpenAI" provider="openai" />
|
||||
<ProviderRow name="Anthropic" provider="anthropic" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="bg-dd0c-surface border border-dd0c-danger/30 rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-dd0c-danger">Danger Zone</h3>
|
||||
<button className="px-3 py-1.5 border border-dd0c-danger/50 text-dd0c-danger text-sm rounded-md hover:bg-dd0c-danger/10">
|
||||
Delete Organization
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({ name, provider }: { name: string; provider: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 bg-dd0c-bg rounded-md">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{name}</p>
|
||||
<p className="text-xs text-dd0c-muted">{provider}</p>
|
||||
</div>
|
||||
<button className="px-3 py-1 text-xs border border-dd0c-border rounded hover:bg-dd0c-border/50 text-dd0c-muted hover:text-dd0c-text">
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user