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:
2026-03-01 02:36:32 +00:00
parent 0fe25b8aa6
commit a486373d93
18 changed files with 882 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dd0c/route — LLM Cost Router</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body class="bg-dd0c-bg text-dd0c-text antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{
"name": "dd0c-route-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.0",
"recharts": "^2.12.0",
"swr": "^2.2.5",
"clsx": "^2.1.1"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.11"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,20 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Rules from './pages/Rules';
import Keys from './pages/Keys';
import Settings from './pages/Settings';
export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/rules" element={<Rules />} />
<Route path="/keys" element={<Keys />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
);
}

View File

@@ -0,0 +1,45 @@
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
import type { TimeseriesPoint } from '../lib/api';
interface Props {
data: TimeseriesPoint[];
}
export default function CostChart({ data }: Props) {
const formatted = data.map((d) => ({
...d,
time: new Date(d.bucket).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
}));
if (formatted.length === 0) {
return <p className="text-dd0c-muted text-sm py-8 text-center">No data yet. Start routing requests to see savings.</p>;
}
return (
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={formatted}>
<defs>
<linearGradient id="savedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e2e" />
<XAxis dataKey="time" stroke="#64748b" fontSize={11} />
<YAxis stroke="#64748b" fontSize={11} tickFormatter={(v: number) => `$${v.toFixed(2)}`} />
<Tooltip
contentStyle={{ backgroundColor: '#12121a', border: '1px solid #1e1e2e', borderRadius: 8 }}
labelStyle={{ color: '#64748b' }}
formatter={(value: number) => [`$${value.toFixed(4)}`, 'Saved']}
/>
<Area
type="monotone"
dataKey="cost_saved"
stroke="#22c55e"
fill="url(#savedGrad)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,53 @@
import { Outlet, NavLink } from 'react-router-dom';
import clsx from 'clsx';
const nav = [
{ to: '/dashboard', label: 'Dashboard', icon: '📊' },
{ to: '/rules', label: 'Routing Rules', icon: '🔀' },
{ to: '/keys', label: 'API Keys', icon: '🔑' },
{ to: '/settings', label: 'Settings', icon: '⚙️' },
];
export default function Layout() {
return (
<div className="flex h-screen">
{/* Sidebar */}
<aside className="w-56 bg-dd0c-surface border-r border-dd0c-border flex flex-col">
<div className="p-4 border-b border-dd0c-border">
<h1 className="text-lg font-bold">
<span className="text-dd0c-primary">dd0c</span>
<span className="text-dd0c-muted">/route</span>
</h1>
<p className="text-xs text-dd0c-muted mt-1">LLM Cost Router</p>
</div>
<nav className="flex-1 p-2 space-y-1" aria-label="Main navigation">
{nav.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
clsx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors',
isActive
? 'bg-dd0c-primary/10 text-dd0c-primary'
: 'text-dd0c-muted hover:text-dd0c-text hover:bg-dd0c-border/50'
)
}
>
<span>{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className="p-4 border-t border-dd0c-border text-xs text-dd0c-muted">
v0.1.0
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto p-6">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import type { ModelBreakdown } from '../lib/api';
interface Props {
data: ModelBreakdown[];
}
export default function ModelTable({ data }: Props) {
if (data.length === 0) {
return <p className="text-dd0c-muted text-sm py-4 text-center">No model usage data yet.</p>;
}
return (
<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">Model</th>
<th className="text-right py-2 px-3">Requests</th>
<th className="text-right py-2 px-3">Tokens</th>
<th className="text-right py-2 px-3">Cost</th>
</tr>
</thead>
<tbody>
{data.map((m) => (
<tr key={m.model} className="border-b border-dd0c-border/50 hover:bg-dd0c-border/20">
<td className="py-2 px-3 font-mono text-dd0c-accent">{m.model}</td>
<td className="py-2 px-3 text-right">{m.request_count.toLocaleString()}</td>
<td className="py-2 px-3 text-right">{m.total_tokens.toLocaleString()}</td>
<td className="py-2 px-3 text-right text-dd0c-warning">${m.total_cost.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,27 @@
import clsx from 'clsx';
interface Props {
label: string;
value: string;
sub?: string;
accent?: 'primary' | 'success' | 'warning' | 'danger';
}
const accentColors = {
primary: 'text-dd0c-primary',
success: 'text-dd0c-success',
warning: 'text-dd0c-warning',
danger: 'text-dd0c-danger',
};
export default function StatCard({ label, value, sub, accent }: Props) {
return (
<div className="bg-dd0c-surface border border-dd0c-border rounded-lg p-4">
<p className="text-xs text-dd0c-muted uppercase tracking-wide">{label}</p>
<p className={clsx('text-2xl font-bold mt-1', accent ? accentColors[accent] : 'text-dd0c-text')}>
{value}
</p>
{sub && <p className="text-xs text-dd0c-muted mt-1">{sub}</p>}
</div>
);
}

View File

@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}

View File

@@ -0,0 +1,111 @@
const API_BASE = '/api/v1';
async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
const token = localStorage.getItem('dd0c_token') || '';
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options?.headers,
},
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error || res.statusText);
}
return res.json();
}
// --- Types ---
export interface AnalyticsSummary {
total_requests: number;
total_cost_original: number;
total_cost_actual: number;
total_cost_saved: number;
savings_pct: number;
avg_latency_ms: number;
p99_latency_ms: number;
routing_decisions: {
passthrough: number;
cheapest: number;
cascading: number;
};
}
export interface TimeseriesPoint {
bucket: string;
request_count: number;
cost_saved: number;
avg_latency_ms: number;
}
export interface ModelBreakdown {
model: string;
request_count: number;
total_tokens: number;
total_cost: number;
}
export interface RoutingRule {
id?: string;
priority: number;
name: string;
match_model?: string;
match_feature?: string;
match_team?: string;
match_complexity?: string;
strategy: string;
target_model?: string;
target_provider?: string;
fallback_models?: string[];
enabled: boolean;
}
export interface ApiKey {
id: string;
name: string;
key_prefix: string;
scopes: string[];
last_used_at?: string;
created_at: string;
}
export interface ApiKeyCreated {
id: string;
key: string;
name: string;
}
export interface OrgInfo {
id: string;
name: string;
slug: string;
tier: string;
}
// --- API calls ---
export const api = {
getSummary: () => fetchApi<AnalyticsSummary>('/analytics/summary'),
getTimeseries: (interval = 'hour') =>
fetchApi<TimeseriesPoint[]>(`/analytics/timeseries?interval=${interval}`),
getModels: () => fetchApi<ModelBreakdown[]>('/analytics/models'),
listRules: () => fetchApi<RoutingRule[]>('/rules'),
createRule: (rule: RoutingRule) =>
fetchApi<RoutingRule>('/rules', { method: 'POST', body: JSON.stringify(rule) }),
updateRule: (id: string, rule: RoutingRule) =>
fetchApi<void>(`/rules/${id}`, { method: 'PUT', body: JSON.stringify(rule) }),
deleteRule: (id: string) =>
fetchApi<void>(`/rules/${id}`, { method: 'DELETE' }),
listKeys: () => fetchApi<ApiKey[]>('/keys'),
createKey: (name: string) =>
fetchApi<ApiKeyCreated>('/keys', { method: 'POST', body: JSON.stringify({ name }) }),
revokeKey: (id: string) =>
fetchApi<void>(`/keys/${id}`, { method: 'DELETE' }),
getOrg: () => fetchApi<OrgInfo>('/org'),
};

View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);

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

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

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

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

View File

@@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
dd0c: {
bg: '#0a0a0f',
surface: '#12121a',
border: '#1e1e2e',
primary: '#6366f1',
accent: '#06b6d4',
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
text: '#e2e8f0',
muted: '#64748b',
},
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}

View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3000',
},
},
build: {
outDir: 'dist',
},
});