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:
13
products/01-llm-cost-router/ui/index.html
Normal file
13
products/01-llm-cost-router/ui/index.html
Normal 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>
|
||||||
29
products/01-llm-cost-router/ui/package.json
Normal file
29
products/01-llm-cost-router/ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
products/01-llm-cost-router/ui/postcss.config.js
Normal file
6
products/01-llm-cost-router/ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
20
products/01-llm-cost-router/ui/src/App.tsx
Normal file
20
products/01-llm-cost-router/ui/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
products/01-llm-cost-router/ui/src/components/CostChart.tsx
Normal file
45
products/01-llm-cost-router/ui/src/components/CostChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
products/01-llm-cost-router/ui/src/components/Layout.tsx
Normal file
53
products/01-llm-cost-router/ui/src/components/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
products/01-llm-cost-router/ui/src/components/ModelTable.tsx
Normal file
34
products/01-llm-cost-router/ui/src/components/ModelTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
products/01-llm-cost-router/ui/src/components/StatCard.tsx
Normal file
27
products/01-llm-cost-router/ui/src/components/StatCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
products/01-llm-cost-router/ui/src/index.css
Normal file
7
products/01-llm-cost-router/ui/src/index.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
111
products/01-llm-cost-router/ui/src/lib/api.ts
Normal file
111
products/01-llm-cost-router/ui/src/lib/api.ts
Normal 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'),
|
||||||
|
};
|
||||||
13
products/01-llm-cost-router/ui/src/main.tsx
Normal file
13
products/01-llm-cost-router/ui/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
products/01-llm-cost-router/ui/tailwind.config.js
Normal file
23
products/01-llm-cost-router/ui/tailwind.config.js
Normal 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: [],
|
||||||
|
};
|
||||||
22
products/01-llm-cost-router/ui/tsconfig.json
Normal file
22
products/01-llm-cost-router/ui/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
15
products/01-llm-cost-router/ui/vite.config.ts
Normal file
15
products/01-llm-cost-router/ui/vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user