feat: add React admin dashboard
Tailwind v4 dark design (DM Sans/Mono, violet accent). Pages: Overview, Logs, API Keys, Providers, Models, Analytics, Routing, Settings. Auth gate uses localStorage token validated against /api/admin/providers. Vite builds to internal/web/dist which is embedded into the binary.
This commit is contained in:
@@ -5,6 +5,9 @@
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LMR Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
725
backend/dashboard/package-lock.json
generated
725
backend/dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,40 @@
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
import { useState } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { getToken } from './lib/api'
|
||||
import Login from './pages/Login'
|
||||
import Layout from './components/Layout'
|
||||
import Overview from './pages/Overview'
|
||||
import Logs from './pages/Logs'
|
||||
import ApiKeys from './pages/ApiKeys'
|
||||
import Providers from './pages/Providers'
|
||||
import Routing from './pages/Routing'
|
||||
import Analytics from './pages/Analytics'
|
||||
import Models from './pages/Models'
|
||||
import Settings from './pages/Settings'
|
||||
|
||||
export default function App() {
|
||||
const [authed, setAuthed] = useState(() => !!getToken())
|
||||
|
||||
if (!authed) {
|
||||
return <Login onLogin={() => setAuthed(true)} />
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/overview" replace />} />
|
||||
<Route path="overview" element={<Overview />} />
|
||||
<Route path="logs" element={<Logs />} />
|
||||
<Route path="keys" element={<ApiKeys />} />
|
||||
<Route path="providers" element={<Providers />} />
|
||||
<Route path="routing" element={<Routing />} />
|
||||
<Route path="analytics" element={<Analytics />} />
|
||||
<Route path="models" element={<Models />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/overview" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
182
backend/dashboard/src/components/Layout.tsx
Normal file
182
backend/dashboard/src/components/Layout.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { clearToken } from '../lib/api'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/overview', label: 'Overview', icon: <IconGrid /> },
|
||||
{ to: '/logs', label: 'Request Logs', icon: <IconLogs /> },
|
||||
{ to: '/keys', label: 'API Keys', icon: <IconKey /> },
|
||||
{ to: '/providers',label: 'Providers', icon: <IconServer /> },
|
||||
{ to: '/routing', label: 'Routing Rules', icon: <IconRoute /> },
|
||||
{ to: '/analytics',label: 'Analytics', icon: <IconChart /> },
|
||||
{ to: '/models', label: 'Model Catalog', icon: <IconBox /> },
|
||||
]
|
||||
|
||||
function navStyle(active: boolean) {
|
||||
return {
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '7px 10px', borderRadius: '7px', cursor: 'pointer',
|
||||
fontSize: '13px', fontWeight: 500,
|
||||
color: active ? '#f0eeff' : 'rgba(237,237,240,0.5)',
|
||||
background: active ? 'rgba(124,58,237,0.18)' : 'transparent',
|
||||
transition: 'all 0.12s',
|
||||
}
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const loc = useLocation()
|
||||
const section = NAV.find(n => loc.pathname.startsWith(n.to))?.label ?? 'Settings'
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden', background: '#09090b' }}>
|
||||
{/* Sidebar */}
|
||||
<aside style={{
|
||||
width: 224, minWidth: 224, background: '#111115',
|
||||
borderRight: '1px solid rgba(255,255,255,0.07)',
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
}}>
|
||||
{/* Brand */}
|
||||
<div style={{ padding: '18px 16px 14px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 30, height: 30, flexShrink: 0, borderRadius: 8,
|
||||
background: 'linear-gradient(145deg,#7c3aed 0%,#4f1d96 100%)',
|
||||
boxShadow: '0 0 0 1px rgba(124,58,237,0.4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none"
|
||||
stroke="rgba(255,255,255,0.9)" strokeWidth="1.6" strokeLinecap="round">
|
||||
<circle cx="8" cy="8" r="2.5"/>
|
||||
<line x1="1.5" y1="8" x2="5" y2="8"/>
|
||||
<line x1="11" y1="8" x2="14.5" y2="8"/>
|
||||
<line x1="8" y1="1.5" x2="8" y2="5"/>
|
||||
<line x1="8" y1="11" x2="8" y2="14.5"/>
|
||||
<line x1="3.1" y1="3.1" x2="5.5" y2="5.5"/>
|
||||
<line x1="10.5" y1="10.5" x2="12.9" y2="12.9"/>
|
||||
<line x1="12.9" y1="3.1" x2="10.5" y2="5.5"/>
|
||||
<line x1="5.5" y1="10.5" x2="3.1" y2="12.9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: '#f0eeff', letterSpacing: '-0.3px' }}>
|
||||
Local Model Routex
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: 'rgba(237,237,240,0.32)', marginTop: 1, display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span className="dot-pulse" style={{ width: 5, height: 5, borderRadius: '50%', background: '#22c55e', display: 'inline-block', flexShrink: 0 }}/>
|
||||
running
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav style={{ flex: 1, padding: '10px 8px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.09em', color: 'rgba(237,237,240,0.22)', padding: '0 8px', margin: '2px 0 4px', textTransform: 'uppercase' }}>
|
||||
Main
|
||||
</div>
|
||||
{NAV.map(({ to, label, icon }) => (
|
||||
<NavLink key={to} to={to} style={({ isActive }) => navStyle(isActive)}>
|
||||
{icon}
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Bottom */}
|
||||
<div style={{ padding: '8px 8px 14px', borderTop: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<NavLink to="/settings" style={({ isActive }) => navStyle(isActive)}>
|
||||
<IconSettings />
|
||||
Settings
|
||||
</NavLink>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '10px 10px 2px', marginTop: 2 }}>
|
||||
<div style={{
|
||||
width: 27, height: 27, borderRadius: '50%', flexShrink: 0,
|
||||
background: 'linear-gradient(135deg,#7c3aed,#2563eb)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11.5, fontWeight: 600, color: 'white',
|
||||
}}>A</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 500, color: '#ededf0' }}>admin</div>
|
||||
<button onClick={() => { clearToken(); window.location.reload() }}
|
||||
style={{ fontSize: 10, color: 'rgba(237,237,240,0.3)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Topbar */}
|
||||
<header style={{
|
||||
height: 52, borderBottom: '1px solid rgba(255,255,255,0.07)',
|
||||
display: 'flex', alignItems: 'center', padding: '0 24px', gap: 12, flexShrink: 0,
|
||||
}}>
|
||||
<h1 style={{ fontSize: 15, fontWeight: 600, color: '#f0eeff', letterSpacing: '-0.3px', flex: 1 }}>
|
||||
{section}
|
||||
</h1>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px',
|
||||
borderRadius: 5, background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.18)',
|
||||
}}>
|
||||
<span className="dot-pulse" style={{ width: 5, height: 5, borderRadius: '50%', background: '#22c55e', display: 'inline-block' }}/>
|
||||
<span style={{ fontSize: 11, fontFamily: "'DM Mono',monospace", color: 'rgba(34,197,94,0.85)' }}>
|
||||
/v1
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main style={{ flex: 1, overflowY: 'auto', padding: '24px 28px' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Icons ── */
|
||||
function IconGrid() {
|
||||
return <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1.5"/><rect x="9" y="1" width="6" height="6" rx="1.5"/>
|
||||
<rect x="1" y="9" width="6" height="6" rx="1.5"/><rect x="9" y="9" width="6" height="6" rx="1.5"/>
|
||||
</svg>
|
||||
}
|
||||
function IconLogs() {
|
||||
return <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<line x1="2" y1="4" x2="14" y2="4"/><line x1="2" y1="8" x2="11" y2="8"/><line x1="2" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
}
|
||||
function IconKey() {
|
||||
return <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<circle cx="6" cy="9.5" r="4"/><line x1="9.5" y1="5.5" x2="14.5" y2="5.5"/><line x1="12.5" y1="5.5" x2="12.5" y2="8"/>
|
||||
</svg>
|
||||
}
|
||||
function IconServer() {
|
||||
return <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<rect x="1" y="2" width="14" height="4" rx="1.2"/><rect x="1" y="10" width="14" height="4" rx="1.2"/>
|
||||
<circle cx="4" cy="4" r="0.9" fill="currentColor"/><circle cx="4" cy="12" r="0.9" fill="currentColor"/>
|
||||
</svg>
|
||||
}
|
||||
function IconRoute() {
|
||||
return <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="1,4 4,4 11,12 15,12"/><polyline points="11,4 15,4"/><polyline points="1,12 4,12"/>
|
||||
</svg>
|
||||
}
|
||||
function IconChart() {
|
||||
return <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="1,13 5,7 9,10 13,3"/><line x1="1" y1="13" x2="15" y2="13"/>
|
||||
</svg>
|
||||
}
|
||||
function IconBox() {
|
||||
return <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="8,1 14,4.5 14,11.5 8,15 2,11.5 2,4.5 8,1"/>
|
||||
<polyline points="2,4.5 8,8 14,4.5"/><line x1="8" y1="8" x2="8" y2="15"/>
|
||||
</svg>
|
||||
}
|
||||
function IconSettings() {
|
||||
return <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<circle cx="8" cy="8" r="2.5"/>
|
||||
<path d="M8 1.5v1.8M8 12.7v1.8M1.5 8h1.8M12.7 8h1.8M3.4 3.4l1.3 1.3M11.3 11.3l1.3 1.3M12.6 3.4l-1.3 1.3M4.7 11.3l-1.3 1.3"/>
|
||||
</svg>
|
||||
}
|
||||
@@ -1,13 +1,46 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-bg: #09090b;
|
||||
--color-sidebar: #111115;
|
||||
--color-card: #17171c;
|
||||
--color-primary: #7c3aed;
|
||||
--color-primary-h: #6d28d9;
|
||||
--color-text: #f0eeff;
|
||||
--color-green: #22c55e;
|
||||
--color-red: #ef4444;
|
||||
--color-v3: #c4b5fd;
|
||||
--color-v4: #a78bfa;
|
||||
|
||||
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||
--font-mono: 'DM Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #09090b;
|
||||
color: #f0eeff;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
input, select, button, textarea { font-family: inherit; }
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.animate-fade-up { animation: fadeUp 0.18s ease; }
|
||||
.dot-pulse { animation: pulse 2.4s ease infinite; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
|
||||
|
||||
67
backend/dashboard/src/lib/api.ts
Normal file
67
backend/dashboard/src/lib/api.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
Provider, VirtualKey, AdminModel, RequestLog,
|
||||
CreateProviderParams, CreateKeyParams, ModelOverride,
|
||||
} from './types'
|
||||
|
||||
const BASE = '/api/admin'
|
||||
|
||||
export function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
export function setToken(t: string) { localStorage.setItem('admin_token', t) }
|
||||
export function clearToken() { localStorage.removeItem('admin_token') }
|
||||
|
||||
async function req<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
...opts,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${getToken()}`,
|
||||
...opts?.headers,
|
||||
},
|
||||
})
|
||||
if (res.status === 204) return undefined as T
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) { clearToken(); window.location.reload() }
|
||||
const body = await res.text().catch(() => res.statusText)
|
||||
throw new Error(body || res.statusText)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
providers: {
|
||||
list: () => req<Provider[]>('/providers'),
|
||||
create: (d: CreateProviderParams) =>
|
||||
req<{ id: string }>('/providers', { method: 'POST', body: JSON.stringify(d) }),
|
||||
delete: (id: string) => req<void>(`/providers/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
keys: {
|
||||
list: () => req<VirtualKey[]>('/keys'),
|
||||
create: (d: CreateKeyParams) =>
|
||||
req<{ id: string; key: string; prefix: string }>('/keys', { method: 'POST', body: JSON.stringify(d) }),
|
||||
revoke: (id: string) => req<void>(`/keys/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
models: {
|
||||
list: () => req<AdminModel[]>('/models'),
|
||||
update: (id: string, d: ModelOverride) =>
|
||||
req<void>(`/models/${id}`, { method: 'PATCH', body: JSON.stringify(d) }),
|
||||
},
|
||||
settings: {
|
||||
get: () => req<Record<string, string>>('/settings'),
|
||||
put: (key: string, value: string) =>
|
||||
req<void>(`/settings/${encodeURIComponent(key)}`, { method: 'PUT', body: JSON.stringify(value) }),
|
||||
},
|
||||
catalog: {
|
||||
refresh: () =>
|
||||
req<{ status: string; models: number }>('/catalog/refresh', { method: 'POST' }),
|
||||
},
|
||||
logs: {
|
||||
list: (params: { provider?: string; status?: string; since?: string } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.provider) qs.set('provider', params.provider)
|
||||
if (params.status) qs.set('status', params.status)
|
||||
if (params.since) qs.set('since', params.since)
|
||||
const s = qs.toString()
|
||||
return req<RequestLog[]>('/logs' + (s ? '?' + s : ''))
|
||||
},
|
||||
},
|
||||
}
|
||||
83
backend/dashboard/src/lib/types.ts
Normal file
83
backend/dashboard/src/lib/types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
base_url: string
|
||||
enabled: boolean
|
||||
has_credentials: boolean
|
||||
}
|
||||
|
||||
export interface VirtualKey {
|
||||
id: string
|
||||
name: string
|
||||
prefix: string
|
||||
type: string
|
||||
allow_all_models: boolean
|
||||
expires_at: string | null
|
||||
revoked_at: string | null
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
export interface AdminModel {
|
||||
id: string
|
||||
provider_name: string
|
||||
slug: string
|
||||
display_name: string
|
||||
source: string
|
||||
available: boolean
|
||||
enabled: boolean
|
||||
blocked: boolean
|
||||
context_length: number | null
|
||||
disc_input_per_mtok: number | null
|
||||
disc_output_per_mtok: number | null
|
||||
price_input_per_mtok: number | null
|
||||
price_output_per_mtok: number | null
|
||||
}
|
||||
|
||||
export interface RequestLog {
|
||||
ID: string
|
||||
CreatedAt: string
|
||||
RequestID: string
|
||||
KeyPrefix: string
|
||||
ModelName: string
|
||||
ProviderName: string
|
||||
StatusCode: number
|
||||
Streamed: boolean
|
||||
Error: string
|
||||
PromptTokens: number
|
||||
CompletionTokens: number
|
||||
TotalTokens: number
|
||||
Cost: number
|
||||
LatencyMS: number
|
||||
TTFTMs: number | null
|
||||
}
|
||||
|
||||
export interface CatalogEntry {
|
||||
id: string
|
||||
object: string
|
||||
owned_by: string
|
||||
}
|
||||
|
||||
export interface CreateProviderParams {
|
||||
name: string
|
||||
type: string
|
||||
base_url: string
|
||||
credentials: Record<string, string>
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface CreateKeyParams {
|
||||
name: string
|
||||
allow_all_models: boolean
|
||||
rules: string[]
|
||||
expires_in_days: number
|
||||
}
|
||||
|
||||
export interface ModelOverride {
|
||||
enabled?: boolean
|
||||
blocked?: boolean
|
||||
input_price_per_mtok?: number
|
||||
output_price_per_mtok?: number
|
||||
rpm_limit?: number
|
||||
tpm_limit?: number
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
136
backend/dashboard/src/pages/Analytics.tsx
Normal file
136
backend/dashboard/src/pages/Analytics.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import type { RequestLog } from '../lib/types'
|
||||
|
||||
const D = 'rgba(237,237,240,'
|
||||
|
||||
const RANGES = [
|
||||
{ label: '24h', ms: 86400000 },
|
||||
{ label: '7d', ms: 604800000 },
|
||||
{ label: '30d', ms: 2592000000 },
|
||||
]
|
||||
|
||||
function SparkLine({ data, color = '#7c3aed' }: { data: number[]; color?: string }) {
|
||||
if (data.length < 2) return <div style={{ height: 110, background: 'rgba(124,58,237,0.04)', borderRadius: 6 }}/>
|
||||
const max = Math.max(...data, 1)
|
||||
const w = 560, h = 110, step = w / (data.length - 1)
|
||||
const y = (v: number) => h - (v / max) * (h - 8) - 4
|
||||
const pts = data.map((v, i) => `${i * step},${y(v)}`).join(' ')
|
||||
const area = `M0,${h} L${pts.split(' ').map(p => `L${p}`).join('')} L${w},${h} Z`
|
||||
const line = `M${pts.split(' ').join(' L')}`
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} style={{ width: '100%', height: 110 }} preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="ag" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.28"/>
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#ag)"/>
|
||||
<path d={line} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Analytics() {
|
||||
const [logs, setLogs] = useState<RequestLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [rangeIdx, setRangeIdx] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
api.logs.list({ since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString() })
|
||||
.then(r => setLogs(r ?? []))
|
||||
.finally(() => setLoading(false))
|
||||
}, [rangeIdx])
|
||||
|
||||
// Build hourly buckets
|
||||
const buckets = 24
|
||||
const bucketMs = RANGES[rangeIdx].ms / buckets
|
||||
const now = Date.now()
|
||||
const reqByBucket = Array(buckets).fill(0)
|
||||
logs.forEach(l => {
|
||||
const age = now - new Date(l.CreatedAt).getTime()
|
||||
const idx = Math.floor(age / bucketMs)
|
||||
if (idx >= 0 && idx < buckets) reqByBucket[buckets - 1 - idx]++
|
||||
})
|
||||
|
||||
const totalReqs = logs.length
|
||||
const totalTokens = logs.reduce((s, l) => s + l.TotalTokens, 0)
|
||||
const totalCost = logs.reduce((s, l) => s + l.Cost, 0)
|
||||
const avgLatency = logs.length ? Math.round(logs.reduce((s, l) => s + l.LatencyMS, 0) / logs.length) : 0
|
||||
|
||||
// Top models by token usage
|
||||
const byModel: Record<string, number> = {}
|
||||
logs.forEach(l => { byModel[l.ModelName] = (byModel[l.ModelName] ?? 0) + l.TotalTokens })
|
||||
const topModels = Object.entries(byModel).sort((a, b) => b[1] - a[1]).slice(0, 6)
|
||||
const maxTokens = topModels[0]?.[1] ?? 1
|
||||
|
||||
const stats = [
|
||||
{ label: 'Total requests', value: totalReqs.toLocaleString() },
|
||||
{ label: 'Total tokens', value: totalTokens > 1e6 ? `${(totalTokens/1e6).toFixed(1)}M` : totalTokens.toLocaleString() },
|
||||
{ label: 'Total cost', value: `$${totalCost.toFixed(4)}` },
|
||||
{ label: 'Avg latency', value: `${avgLatency}ms` },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="animate-fade-up">
|
||||
{/* Time range picker */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginBottom: 20, background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 7, padding: 3, width: 'fit-content' }}>
|
||||
{RANGES.map((r, i) => (
|
||||
<button key={r.label} onClick={() => setRangeIdx(i)} style={{
|
||||
padding: '5px 16px', borderRadius: 5, fontSize: 12.5, fontWeight: 500, cursor: 'pointer', border: 'none',
|
||||
background: i === rangeIdx ? '#7c3aed' : 'transparent',
|
||||
color: i === rangeIdx ? 'white' : `${D}0.42)`,
|
||||
}}>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12, marginBottom: 18 }}>
|
||||
{stats.map(s => (
|
||||
<div key={s.label} style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, padding: '16px 18px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: `${D}0.32)`, textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 8 }}>{s.label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600, fontFamily: "'DM Mono',monospace", color: '#f0eeff', letterSpacing: -0.8, lineHeight: 1 }}>
|
||||
{loading ? '—' : s.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 320px', gap: 14, alignItems: 'start' }}>
|
||||
{/* Line chart */}
|
||||
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, padding: '20px 22px' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff', marginBottom: 2 }}>Request Volume</div>
|
||||
<div style={{ fontSize: 11.5, color: `${D}0.35)`, marginBottom: 16 }}>
|
||||
{RANGES[rangeIdx].label === '24h' ? 'Hourly' : 'Periodic'}, last {RANGES[rangeIdx].label}
|
||||
</div>
|
||||
<SparkLine data={reqByBucket} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 6 }}>
|
||||
{['start','','','','now'].map((t, i) => (
|
||||
<span key={i} style={{ fontSize: 10, color: `${D}0.22)`, fontFamily: "'DM Mono',monospace" }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top models */}
|
||||
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, padding: '20px 22px' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff', marginBottom: 2 }}>Top Models</div>
|
||||
<div style={{ fontSize: 11.5, color: `${D}0.35)`, marginBottom: 18 }}>By token usage</div>
|
||||
{topModels.length === 0 && <div style={{ color: `${D}0.3)`, fontSize: 13 }}>No data yet</div>}
|
||||
{topModels.map(([name, tokens]) => (
|
||||
<div key={name} style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.62)`, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{name}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.38)` }}>{tokens > 1e6 ? `${(tokens/1e6).toFixed(1)}M` : tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ height: 3, background: 'rgba(255,255,255,0.06)', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', background: '#7c3aed', borderRadius: 2, width: `${(tokens / maxTokens) * 100}%`, transition: 'width 0.3s' }}/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
backend/dashboard/src/pages/ApiKeys.tsx
Normal file
161
backend/dashboard/src/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import type { VirtualKey } from '../lib/types'
|
||||
|
||||
const D = 'rgba(237,237,240,'
|
||||
|
||||
function fmtDate(s: string | null) {
|
||||
if (!s) return '—'
|
||||
return new Date(s).toLocaleDateString()
|
||||
}
|
||||
|
||||
function Modal({ onClose, onCreate }: { onClose: () => void; onCreate: (key: string) => void }) {
|
||||
const [name, setName] = useState('')
|
||||
const [allModels, setAllModels] = useState(true)
|
||||
const [rules, setRules] = useState('')
|
||||
const [expiry, setExpiry] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [newKey, setNewKey] = useState('')
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.keys.create({
|
||||
name, allow_all_models: allModels,
|
||||
rules: rules.split('\n').map(s => s.trim()).filter(Boolean),
|
||||
expires_in_days: expiry,
|
||||
})
|
||||
setNewKey(r.key)
|
||||
onCreate(r.key)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50 }}
|
||||
onClick={e => e.target === e.currentTarget && !newKey && onClose()}>
|
||||
<div style={{ width: 420, background: '#17171c', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 12, padding: '24px 24px' }}>
|
||||
<h2 style={{ fontSize: 15, fontWeight: 600, color: '#f0eeff', marginBottom: 20 }}>
|
||||
{newKey ? 'Key created' : 'Create API key'}
|
||||
</h2>
|
||||
|
||||
{newKey ? (
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: `${D}0.5)`, marginBottom: 12 }}>Copy this key now — it won't be shown again.</p>
|
||||
<div style={{ background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 7, padding: '10px 14px', fontFamily: "'DM Mono',monospace", fontSize: 12, color: '#c4b5fd', wordBreak: 'break-all', marginBottom: 20 }}>{newKey}</div>
|
||||
<button onClick={onClose} style={{ width: '100%', background: '#7c3aed', border: 'none', borderRadius: 7, padding: '9px 0', fontSize: 13.5, fontWeight: 500, color: 'white', cursor: 'pointer' }}>Done</button>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: `${D}0.55)`, marginBottom: 5 }}>Name</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} required
|
||||
style={{ width: '100%', background: 'rgba(0,0,0,0.28)', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 6, padding: '8px 12px', fontSize: 13, color: '#ededf0', outline: 'none', marginBottom: 14 }}/>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: `${D}0.65)`, marginBottom: 14, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={allModels} onChange={e => setAllModels(e.target.checked)}/>
|
||||
Allow all models
|
||||
</label>
|
||||
|
||||
{!allModels && (
|
||||
<>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: `${D}0.55)`, marginBottom: 5 }}>
|
||||
Model rules (one per line, e.g. <code style={{ fontFamily: 'monospace' }}>openai/*</code>)
|
||||
</label>
|
||||
<textarea value={rules} onChange={e => setRules(e.target.value)} rows={3}
|
||||
style={{ width: '100%', background: 'rgba(0,0,0,0.28)', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 6, padding: '8px 12px', fontSize: 12, color: '#ededf0', outline: 'none', resize: 'vertical', marginBottom: 14, fontFamily: "'DM Mono',monospace" }}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: `${D}0.55)`, marginBottom: 5 }}>Expires in days (0 = never)</label>
|
||||
<input type="number" min={0} value={expiry} onChange={e => setExpiry(Number(e.target.value))}
|
||||
style={{ width: '100%', background: 'rgba(0,0,0,0.28)', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 6, padding: '8px 12px', fontSize: 13, color: '#ededf0', outline: 'none', marginBottom: 20, fontFamily: "'DM Mono',monospace" }}/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={onClose} style={{ flex: 1, background: 'transparent', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 7, padding: '8px 0', fontSize: 13, color: `${D}0.5)`, cursor: 'pointer' }}>Cancel</button>
|
||||
<button type="submit" disabled={loading} style={{ flex: 1, background: '#7c3aed', border: 'none', borderRadius: 7, padding: '8px 0', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer' }}>
|
||||
{loading ? 'Creating…' : 'Create key'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ApiKeys() {
|
||||
const [keys, setKeys] = useState<VirtualKey[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
function load() {
|
||||
setLoading(true)
|
||||
api.keys.list().then(r => setKeys(r ?? [])).finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
async function revoke(id: string) {
|
||||
if (!confirm('Revoke this key?')) return
|
||||
await api.keys.revoke(id)
|
||||
load()
|
||||
}
|
||||
|
||||
const cols = '162px 1fr 110px 110px 80px 130px 60px'
|
||||
|
||||
return (
|
||||
<div className="animate-fade-up">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 18, gap: 12 }}>
|
||||
<p style={{ fontSize: 13, color: `${D}0.45)`, lineHeight: 1.5 }}>
|
||||
Keys authenticate requests to the OpenAI-compatible endpoint. Each key can be scoped to specific models.
|
||||
</p>
|
||||
<button onClick={() => setShowModal(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, background: '#7c3aed', border: 'none',
|
||||
borderRadius: 7, padding: '8px 15px', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
|
||||
}}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="6" y1="1" x2="6" y2="11"/><line x1="1" y1="6" x2="11" y2="6"/>
|
||||
</svg>
|
||||
Create key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: cols, padding: '8px 18px', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
|
||||
{['Name','Key','Created','Last used','Requests','Permissions',''].map(h => (
|
||||
<span key={h} style={{ fontSize: 10, fontWeight: 700, color: `${D}0.28)`, textTransform: 'uppercase', letterSpacing: '0.07em' }}>{h}</span>
|
||||
))}
|
||||
</div>
|
||||
{loading && <div style={{ padding: '24px 18px', color: `${D}0.3)` }}>Loading…</div>}
|
||||
{!loading && keys.length === 0 && <div style={{ padding: '24px 18px', color: `${D}0.3)` }}>No keys yet</div>}
|
||||
{keys.map(k => (
|
||||
<div key={k.id} style={{ display: 'grid', gridTemplateColumns: cols, padding: '13px 18px', borderBottom: '1px solid rgba(255,255,255,0.04)', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0, background: k.revoked_at ? '#6b7280' : '#22c55e' }}/>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: '#ededf0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{k.name}</span>
|
||||
</div>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.45)` }}>{k.prefix}…</span>
|
||||
<span style={{ fontSize: 12, color: `${D}0.38)` }}>{fmtDate(k.revoked_at ? null : new Date().toISOString())}</span>
|
||||
<span style={{ fontSize: 12, color: `${D}0.38)` }}>{fmtDate(k.last_used_at)}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: `${D}0.48)` }}>—</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, background: k.allow_all_models ? 'rgba(124,58,237,0.14)' : 'rgba(255,255,255,0.05)', color: k.allow_all_models ? '#a78bfa' : `${D}0.4)`, padding: '2px 7px', borderRadius: 3, border: `1px solid ${k.allow_all_models ? 'rgba(124,58,237,0.25)' : 'transparent'}` }}>
|
||||
{k.allow_all_models ? 'all models' : 'restricted'}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 5, justifyContent: 'flex-end' }}>
|
||||
{!k.revoked_at && (
|
||||
<button onClick={() => revoke(k.id)} title="Revoke" style={{ width: 27, height: 27, borderRadius: 5, border: '1px solid rgba(255,255,255,0.08)', background: 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="rgba(237,237,240,0.42)" strokeWidth="1.6" strokeLinecap="round">
|
||||
<line x1="1" y1="3" x2="13" y2="3"/><path d="M5 3V2a1 1 0 011-1h2a1 1 0 011 1v1"/><path d="M2 3l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showModal && <Modal onClose={() => { setShowModal(false); load() }} onCreate={() => {}} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
backend/dashboard/src/pages/Login.tsx
Normal file
104
backend/dashboard/src/pages/Login.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react'
|
||||
import { setToken } from '../lib/api'
|
||||
|
||||
export default function Login({ onLogin }: { onLogin: () => void }) {
|
||||
const [token, setTok] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setErr('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/providers', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
setErr('Invalid token')
|
||||
return
|
||||
}
|
||||
if (!res.ok && res.status !== 404) {
|
||||
setErr('Server error — is the backend running?')
|
||||
return
|
||||
}
|
||||
setToken(token)
|
||||
onLogin()
|
||||
} catch {
|
||||
setErr('Cannot reach server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh', background: '#09090b',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 360, background: '#111115', border: '1px solid rgba(255,255,255,0.07)',
|
||||
borderRadius: 12, padding: '32px 28px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 28 }}>
|
||||
<div style={{
|
||||
width: 34, height: 34, borderRadius: 9,
|
||||
background: 'linear-gradient(145deg,#7c3aed 0%,#4f1d96 100%)',
|
||||
boxShadow: '0 0 0 1px rgba(124,58,237,0.4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
|
||||
stroke="rgba(255,255,255,0.9)" strokeWidth="1.6" strokeLinecap="round">
|
||||
<circle cx="8" cy="8" r="2.5"/>
|
||||
<line x1="1.5" y1="8" x2="5" y2="8"/>
|
||||
<line x1="11" y1="8" x2="14.5" y2="8"/>
|
||||
<line x1="8" y1="1.5" x2="8" y2="5"/>
|
||||
<line x1="8" y1="11" x2="8" y2="14.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#f0eeff' }}>Local Model Routex</div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(237,237,240,0.35)' }}>Admin Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'rgba(237,237,240,0.55)', marginBottom: 6 }}>
|
||||
Admin token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={e => setTok(e.target.value)}
|
||||
placeholder="sk-…"
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%', background: 'rgba(0,0,0,0.28)',
|
||||
border: '1px solid rgba(255,255,255,0.09)', borderRadius: 7,
|
||||
padding: '9px 12px', fontSize: 13, color: '#ededf0',
|
||||
outline: 'none', fontFamily: "'DM Mono',monospace",
|
||||
marginBottom: err ? 8 : 16,
|
||||
}}
|
||||
/>
|
||||
{err && <div style={{ fontSize: 12, color: '#f87171', marginBottom: 12 }}>{err}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!token || loading}
|
||||
style={{
|
||||
width: '100%', background: '#7c3aed', border: 'none',
|
||||
borderRadius: 7, padding: '9px 0', fontSize: 13.5,
|
||||
fontWeight: 500, color: 'white', cursor: 'pointer',
|
||||
opacity: !token || loading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Verifying…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ fontSize: 11, color: 'rgba(237,237,240,0.28)', marginTop: 20, textAlign: 'center' }}>
|
||||
Set <span style={{ fontFamily: 'monospace' }}>GATEWAY_SECURITY_ADMIN_TOKEN</span> in .env
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
backend/dashboard/src/pages/Logs.tsx
Normal file
89
backend/dashboard/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import type { RequestLog } from '../lib/types'
|
||||
|
||||
const D = 'rgba(237,237,240,'
|
||||
|
||||
const RANGES = [
|
||||
{ label: '1h', ms: 3600000 },
|
||||
{ label: '24h', ms: 86400000 },
|
||||
{ label: '7d', ms: 604800000 },
|
||||
]
|
||||
|
||||
function StatusBadge({ code }: { code: number }) {
|
||||
const ok = code < 400
|
||||
return <span style={{ fontSize: 11, fontFamily: "'DM Mono',monospace", fontWeight: 600, color: ok ? '#4ade80' : '#f87171', background: ok ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)', padding: '2px 7px', borderRadius: 4 }}>{code}</span>
|
||||
}
|
||||
|
||||
function fmt(iso: string) {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const [logs, setLogs] = useState<RequestLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [rangeIdx, setRangeIdx] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
function load(idx = rangeIdx) {
|
||||
setLoading(true)
|
||||
api.logs.list({ since: new Date(Date.now() - RANGES[idx].ms).toISOString() })
|
||||
.then(r => setLogs(r ?? []))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [rangeIdx])
|
||||
|
||||
const visible = search
|
||||
? logs.filter(l => l.ModelName.includes(search) || l.ProviderName.includes(search) || l.RequestID.includes(search))
|
||||
: logs
|
||||
|
||||
const cols = '86px 106px 1fr 100px 72px 72px 80px 60px'
|
||||
const heads = ['Time','Request ID','Model','Provider','In tok.','Out tok.','Latency','Status']
|
||||
|
||||
return (
|
||||
<div className="animate-fade-up">
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{RANGES.map((r, i) => (
|
||||
<button key={r.label} onClick={() => setRangeIdx(i)} style={{
|
||||
padding: '6px 13px', borderRadius: 6, fontSize: 12.5, cursor: 'pointer', border: 'none',
|
||||
background: i === rangeIdx ? '#7c3aed' : '#17171c',
|
||||
color: i === rangeIdx ? 'white' : `${D}0.5)`,
|
||||
}}>{r.label}</button>
|
||||
))}
|
||||
<div style={{ flex: 1, minWidth: 160, display: 'flex', alignItems: 'center', gap: 6, background: '#17171c', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 6, padding: '6px 11px' }}>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="rgba(237,237,240,0.28)" strokeWidth="1.9" strokeLinecap="round">
|
||||
<circle cx="6.5" cy="6.5" r="4.5"/><line x1="10.5" y1="10.5" x2="14" y2="14"/>
|
||||
</svg>
|
||||
<input value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Filter by model, request ID…"
|
||||
style={{ background: 'none', border: 'none', outline: 'none', fontSize: 12.5, color: `${D}0.7)`, width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: 11.5, color: `${D}0.28)`, whiteSpace: 'nowrap' }}>{visible.length} results</span>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: cols, padding: '8px 18px', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
|
||||
{heads.map(h => <span key={h} style={{ fontSize: 10, fontWeight: 700, color: `${D}0.28)`, textTransform: 'uppercase', letterSpacing: '0.07em' }}>{h}</span>)}
|
||||
</div>
|
||||
{loading && <div style={{ padding: '24px 18px', color: `${D}0.3)` }}>Loading…</div>}
|
||||
{!loading && visible.length === 0 && <div style={{ padding: '24px 18px', color: `${D}0.3)` }}>No logs found</div>}
|
||||
{visible.map(l => (
|
||||
<div key={l.ID} style={{ display: 'grid', gridTemplateColumns: cols, padding: '10px 18px', borderBottom: '1px solid rgba(255,255,255,0.03)', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.32)` }}>{fmt(l.CreatedAt)}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.4)` }}>{l.RequestID.slice(0,10) || l.ID.slice(0,8)}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: '#c4b5fd', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: 8 }}>{l.ModelName}</span>
|
||||
<span style={{ fontSize: 12, color: `${D}0.55)` }}>{l.ProviderName}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.42)` }}>{l.PromptTokens.toLocaleString()}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.42)` }}>{l.CompletionTokens.toLocaleString()}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.45)` }}>{l.LatencyMS}ms</span>
|
||||
<StatusBadge code={l.StatusCode} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
backend/dashboard/src/pages/Models.tsx
Normal file
139
backend/dashboard/src/pages/Models.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import type { AdminModel } from '../lib/types'
|
||||
|
||||
const D = 'rgba(237,237,240,'
|
||||
|
||||
function OverrideModal({ model, onClose }: { model: AdminModel; onClose: (refresh: boolean) => void }) {
|
||||
const [inputPrice, setInputPrice] = useState(String(model.price_input_per_mtok ?? model.disc_input_per_mtok ?? ''))
|
||||
const [outputPrice, setOutputPrice] = useState(String(model.price_output_per_mtok ?? model.disc_output_per_mtok ?? ''))
|
||||
const [rpmLimit, setRpmLimit] = useState(String(''))
|
||||
const [tpmLimit, setTpmLimit] = useState(String(''))
|
||||
const [blocked, setBlocked] = useState(model.blocked)
|
||||
const [enabled, setEnabled] = useState(model.enabled)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
const patch: Record<string, unknown> = { enabled, blocked }
|
||||
if (inputPrice) patch.input_price_per_mtok = parseFloat(inputPrice)
|
||||
if (outputPrice) patch.output_price_per_mtok = parseFloat(outputPrice)
|
||||
if (rpmLimit) patch.rpm_limit = parseInt(rpmLimit)
|
||||
if (tpmLimit) patch.tpm_limit = parseInt(tpmLimit)
|
||||
try { await api.models.update(model.id, patch as any); onClose(true) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50 }}
|
||||
onClick={e => e.target === e.currentTarget && onClose(false)}>
|
||||
<div style={{ width: 400, background: '#17171c', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 12, padding: 24 }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: '#f0eeff', marginBottom: 4 }}>Override — {model.slug}</h2>
|
||||
<p style={{ fontSize: 11, color: `${D}0.35)`, fontFamily: "'DM Mono',monospace", marginBottom: 20 }}>{model.provider_name}</p>
|
||||
<form onSubmit={save}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 14 }}>
|
||||
{[
|
||||
{ label: 'Input $/MTok', val: inputPrice, set: setInputPrice },
|
||||
{ label: 'Output $/MTok', val: outputPrice, set: setOutputPrice },
|
||||
{ label: 'RPM limit', val: rpmLimit, set: setRpmLimit },
|
||||
{ label: 'TPM limit', val: tpmLimit, set: setTpmLimit },
|
||||
].map(f => (
|
||||
<div key={f.label}>
|
||||
<label style={{ display: 'block', fontSize: 11, fontWeight: 500, color: `${D}0.5)`, marginBottom: 4 }}>{f.label}</label>
|
||||
<input type="number" min={0} step="any" value={f.val} onChange={e => f.set(e.target.value)}
|
||||
style={{ width: '100%', background: 'rgba(0,0,0,0.28)', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 6, padding: '7px 10px', fontSize: 13, color: '#ededf0', outline: 'none', fontFamily: "'DM Mono',monospace" }}/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 20 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 13, color: `${D}0.65)`, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)}/> Enabled
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 13, color: '#f87171', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={blocked} onChange={e => setBlocked(e.target.checked)}/> Blocked
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={() => onClose(false)} style={{ flex: 1, background: 'transparent', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 7, padding: '8px 0', fontSize: 13, color: `${D}0.5)`, cursor: 'pointer' }}>Cancel</button>
|
||||
<button type="submit" disabled={loading} style={{ flex: 1, background: '#7c3aed', border: 'none', borderRadius: 7, padding: '8px 0', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer' }}>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Models() {
|
||||
const [models, setModels] = useState<AdminModel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState<'all' | 'available' | 'unavailable'>('all')
|
||||
const [selected, setSelected] = useState<AdminModel | null>(null)
|
||||
|
||||
function load() {
|
||||
setLoading(true)
|
||||
api.models.list().then(r => setModels(r ?? [])).finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
const visible = models.filter(m => {
|
||||
if (search && !m.slug.includes(search) && !m.provider_name.includes(search) && !m.display_name.includes(search)) return false
|
||||
if (filter === 'available' && !m.available) return false
|
||||
if (filter === 'unavailable' && m.available) return false
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="animate-fade-up">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18 }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: '#17171c', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 6, padding: '8px 12px', maxWidth: 320 }}>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="rgba(237,237,240,0.28)" strokeWidth="1.9" strokeLinecap="round">
|
||||
<circle cx="6.5" cy="6.5" r="4.5"/><line x1="10.5" y1="10.5" x2="14" y2="14"/>
|
||||
</svg>
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search models…"
|
||||
style={{ background: 'none', border: 'none', outline: 'none', fontSize: 12.5, color: 'rgba(237,237,240,0.7)', width: '100%' }}/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{(['all','available','unavailable'] as const).map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)} style={{
|
||||
padding: '6px 13px', borderRadius: 6, fontSize: 12, cursor: 'pointer', border: 'none',
|
||||
background: f === filter ? 'rgba(124,58,237,0.15)' : '#17171c',
|
||||
color: f === filter ? '#a78bfa' : 'rgba(237,237,240,0.45)',
|
||||
}}>{f.charAt(0).toUpperCase() + f.slice(1)}</button>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'rgba(237,237,240,0.3)' }}>{visible.length} models</span>
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ color: 'rgba(237,237,240,0.3)' }}>Loading…</div>}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(238px, 1fr))', gap: 10 }}>
|
||||
{visible.map(m => (
|
||||
<div key={m.id} onClick={() => setSelected(m)} style={{ background: '#17171c', border: `1px solid ${m.blocked ? 'rgba(239,68,68,0.25)' : 'rgba(255,255,255,0.07)'}`, borderRadius: 9, padding: '14px 16px', cursor: 'pointer', transition: 'border-color 0.12s' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 9, gap: 6 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontFamily: "'DM Mono',monospace", fontSize: 12.5, fontWeight: 500, color: '#c4b5fd', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.slug}</div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(237,237,240,0.32)', marginTop: 2 }}>{m.display_name || m.provider_name}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, padding: '2px 6px', borderRadius: 3, whiteSpace: 'nowrap', flexShrink: 0,
|
||||
background: m.blocked ? 'rgba(239,68,68,0.12)' : m.available ? 'rgba(34,197,94,0.1)' : 'rgba(255,255,255,0.05)',
|
||||
color: m.blocked ? '#f87171' : m.available ? '#4ade80' : 'rgba(237,237,240,0.3)',
|
||||
}}>
|
||||
{m.blocked ? 'blocked' : m.available ? 'live' : 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: '2px 6px', borderRadius: 3, background: 'rgba(124,58,237,0.1)', color: '#a78bfa' }}>{m.provider_name}</span>
|
||||
{m.context_length && (
|
||||
<span style={{ fontSize: 10.5, color: 'rgba(237,237,240,0.3)' }}>ctx {(m.context_length/1000).toFixed(0)}k</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && <OverrideModal model={selected} onClose={r => { setSelected(null); if (r) load() }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
backend/dashboard/src/pages/Overview.tsx
Normal file
118
backend/dashboard/src/pages/Overview.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import type { Provider, RequestLog } from '../lib/types'
|
||||
|
||||
const D = 'rgba(237,237,240,'
|
||||
|
||||
function StatusBadge({ code }: { code: number }) {
|
||||
const ok = code < 400
|
||||
return (
|
||||
<span style={{
|
||||
fontSize: 11, fontFamily: "'DM Mono',monospace", fontWeight: 600,
|
||||
color: ok ? '#4ade80' : '#f87171',
|
||||
background: ok ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)',
|
||||
padding: '2px 7px', borderRadius: 4,
|
||||
}}>{code}</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ago(iso: string) {
|
||||
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||
if (s < 60) return `${s}s ago`
|
||||
if (s < 3600) return `${Math.floor(s/60)}m ago`
|
||||
return `${Math.floor(s/3600)}h ago`
|
||||
}
|
||||
|
||||
export default function Overview() {
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [logs, setLogs] = useState<RequestLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.providers.list(), api.logs.list({ since: new Date(Date.now() - 86400000).toISOString() })])
|
||||
.then(([p, l]) => { setProviders(p ?? []); setLogs((l ?? []).slice(0, 8)) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const todayLogs = logs
|
||||
const totalReqs = todayLogs.length
|
||||
const totalCost = todayLogs.reduce((s, l) => s + l.Cost, 0)
|
||||
const errRate = totalReqs ? Math.round(todayLogs.filter(l => l.StatusCode >= 400).length / totalReqs * 100) : 0
|
||||
|
||||
const stats = [
|
||||
{ label: 'Providers', value: providers.length, delta: '' },
|
||||
{ label: 'Requests (24h)', value: totalReqs, delta: '' },
|
||||
{ label: 'Cost (24h)', value: `$${totalCost.toFixed(4)}`, delta: '' },
|
||||
{ label: 'Error rate', value: `${errRate}%`, delta: '' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="animate-fade-up">
|
||||
{/* Stat cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12, marginBottom: 22 }}>
|
||||
{stats.map(s => (
|
||||
<div key={s.label} style={{
|
||||
background: '#17171c', border: '1px solid rgba(255,255,255,0.07)',
|
||||
borderRadius: 10, padding: '16px 18px',
|
||||
}}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: `${D}0.35)`, textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 9 }}>
|
||||
{s.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 600, fontFamily: "'DM Mono',monospace", color: '#f0eeff', letterSpacing: -1, lineHeight: 1 }}>
|
||||
{loading ? '—' : s.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 268px', gap: 14, alignItems: 'start' }}>
|
||||
{/* Recent requests */}
|
||||
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '13px 18px', borderBottom: '1px solid rgba(255,255,255,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff' }}>Recent Requests</span>
|
||||
<Link to="/logs" style={{ fontSize: 12, color: '#a78bfa', textDecoration: 'none' }}>View all →</Link>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '108px 1fr 100px 76px 74px 56px', padding: '8px 18px', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
{['Time','Model','Provider','Tokens','Latency','Status'].map(h => (
|
||||
<span key={h} style={{ fontSize: 10, fontWeight: 700, color: `${D}0.28)`, textTransform: 'uppercase', letterSpacing: '0.07em' }}>{h}</span>
|
||||
))}
|
||||
</div>
|
||||
{logs.length === 0 && !loading && (
|
||||
<div style={{ padding: '24px 18px', color: `${D}0.3)`, fontSize: 13 }}>No requests yet</div>
|
||||
)}
|
||||
{logs.map(l => (
|
||||
<div key={l.ID} style={{ display: 'grid', gridTemplateColumns: '108px 1fr 100px 76px 74px 56px', padding: '9px 18px', borderBottom: '1px solid rgba(255,255,255,0.03)', alignItems: 'center' }}>
|
||||
<span title={l.CreatedAt} style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.38)` }}>{ago(l.CreatedAt)}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: '#c4b5fd', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: 8 }}>{l.ModelName}</span>
|
||||
<span style={{ fontSize: 12, color: `${D}0.55)` }}>{l.ProviderName}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.45)` }}>{l.TotalTokens.toLocaleString()}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.45)` }}>{l.LatencyMS}ms</span>
|
||||
<StatusBadge code={l.StatusCode} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Provider health */}
|
||||
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '13px 16px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff' }}>Provider Health</span>
|
||||
</div>
|
||||
{providers.length === 0 && !loading && (
|
||||
<div style={{ padding: '20px 16px', color: `${D}0.3)`, fontSize: 13 }}>
|
||||
No providers configured.{' '}
|
||||
<Link to="/providers" style={{ color: '#a78bfa' }}>Add one →</Link>
|
||||
</div>
|
||||
)}
|
||||
{providers.map(p => (
|
||||
<div key={p.id} style={{ padding: '10px 16px', borderBottom: '1px solid rgba(255,255,255,0.04)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="dot-pulse" style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0, background: p.enabled ? '#22c55e' : '#6b7280' }}/>
|
||||
<span style={{ fontSize: 12.5, color: `${D}0.72)`, flex: 1 }}>{p.name}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: '2px 6px', borderRadius: 3, background: 'rgba(124,58,237,0.12)', color: '#a78bfa', border: '1px solid rgba(124,58,237,0.2)' }}>{p.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
backend/dashboard/src/pages/Providers.tsx
Normal file
146
backend/dashboard/src/pages/Providers.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import type { Provider } from '../lib/types'
|
||||
|
||||
const D = 'rgba(237,237,240,'
|
||||
|
||||
const TYPES = ['openai','openrouter','anthropic','llamacpp','ollama','github_copilot']
|
||||
|
||||
function AddModal({ onClose }: { onClose: (refresh: boolean) => void }) {
|
||||
const [form, setForm] = useState({ name: '', type: 'openrouter', base_url: '', api_key: '', enabled: true })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
function set(k: string, v: string | boolean) { setForm(f => ({ ...f, [k]: v })) }
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setErr('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.providers.create({
|
||||
name: form.name, type: form.type, base_url: form.base_url,
|
||||
credentials: form.api_key ? { api_key: form.api_key } : {},
|
||||
enabled: form.enabled,
|
||||
})
|
||||
onClose(true)
|
||||
} catch (e: any) {
|
||||
setErr(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50 }}
|
||||
onClick={e => e.target === e.currentTarget && onClose(false)}>
|
||||
<div style={{ width: 420, background: '#17171c', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 12, padding: '24px' }}>
|
||||
<h2 style={{ fontSize: 15, fontWeight: 600, color: '#f0eeff', marginBottom: 20 }}>Add provider</h2>
|
||||
<form onSubmit={submit}>
|
||||
{[
|
||||
{ label: 'Name', key: 'name', placeholder: 'my-openai', type: 'text', required: true },
|
||||
{ label: 'Base URL (optional)', key: 'base_url', placeholder: 'https://api.openai.com/v1', type: 'text', required: false },
|
||||
{ label: 'API key', key: 'api_key', placeholder: 'sk-…', type: 'password', required: false },
|
||||
].map(f => (
|
||||
<div key={f.key} style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: `${D}0.55)`, marginBottom: 5 }}>{f.label}</label>
|
||||
<input type={f.type} value={(form as any)[f.key]} onChange={e => set(f.key, e.target.value)}
|
||||
placeholder={f.placeholder} required={f.required}
|
||||
style={{ width: '100%', background: 'rgba(0,0,0,0.28)', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 6, padding: '8px 12px', fontSize: 13, color: '#ededf0', outline: 'none', fontFamily: f.key === 'api_key' ? "'DM Mono',monospace" : 'inherit' }}/>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: `${D}0.55)`, marginBottom: 5 }}>Type</label>
|
||||
<select value={form.type} onChange={e => set('type', e.target.value)}
|
||||
style={{ width: '100%', background: 'rgba(0,0,0,0.28)', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 6, padding: '8px 12px', fontSize: 13, color: '#ededf0', cursor: 'pointer' }}>
|
||||
{TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{err && <div style={{ fontSize: 12, color: '#f87171', marginBottom: 12 }}>{err}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<button type="button" onClick={() => onClose(false)} style={{ flex: 1, background: 'transparent', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 7, padding: '8px 0', fontSize: 13, color: `${D}0.5)`, cursor: 'pointer' }}>Cancel</button>
|
||||
<button type="submit" disabled={loading} style={{ flex: 1, background: '#7c3aed', border: 'none', borderRadius: 7, padding: '8px 0', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer' }}>
|
||||
{loading ? 'Adding…' : 'Add provider'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Providers() {
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
function load() {
|
||||
setLoading(true)
|
||||
api.providers.list().then(r => setProviders(r ?? [])).finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
async function del(id: string, name: string) {
|
||||
if (!confirm(`Delete provider "${name}"?`)) return
|
||||
await api.providers.delete(id)
|
||||
load()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-up">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18 }}>
|
||||
<p style={{ fontSize: 13, color: `${D}0.45)` }}>Configure upstream inference providers.</p>
|
||||
<button onClick={() => setShowModal(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, background: '#17171c', border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 7, padding: '8px 15px', fontSize: 13, color: `${D}0.65)`, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="6" y1="1" x2="6" y2="11"/><line x1="1" y1="6" x2="11" y2="6"/>
|
||||
</svg>
|
||||
Add provider
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ color: `${D}0.3)` }}>Loading…</div>}
|
||||
{!loading && providers.length === 0 && (
|
||||
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, padding: '32px', textAlign: 'center', color: `${D}0.3)` }}>
|
||||
No providers configured yet. Add one to get started.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(310px, 1fr))', gap: 12 }}>
|
||||
{providers.map(p => (
|
||||
<div key={p.id} style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, padding: 18, transition: 'border-color 0.15s' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 5 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#f0eeff' }}>{p.name}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: '2px 6px', borderRadius: 3, background: 'rgba(124,58,237,0.12)', color: '#a78bfa', border: '1px solid rgba(124,58,237,0.2)' }}>{p.type}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span className="dot-pulse" style={{ width: 5, height: 5, borderRadius: '50%', background: p.enabled ? '#22c55e' : '#6b7280' }}/>
|
||||
<span style={{ fontSize: 11.5, color: `${D}0.38)` }}>{p.enabled ? 'enabled' : 'disabled'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => del(p.id, p.name)} title="Delete" style={{ width: 28, height: 28, borderRadius: 6, border: '1px solid rgba(255,255,255,0.07)', background: 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}>
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="rgba(237,237,240,0.42)" strokeWidth="1.6" strokeLinecap="round">
|
||||
<line x1="1" y1="3" x2="13" y2="3"/><path d="M5 3V2a1 1 0 011-1h2a1 1 0 011 1v1"/><path d="M2 3l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{p.base_url && (
|
||||
<div style={{ background: 'rgba(0,0,0,0.28)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: 5, padding: '7px 10px', marginBottom: 14, fontFamily: "'DM Mono',monospace", fontSize: 10.5, color: `${D}0.4)`, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.base_url}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<span style={{ fontSize: 11, color: `${D}0.35)` }}>Credentials: {p.has_credentials ? '✓ set' : 'none'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showModal && <AddModal onClose={r => { setShowModal(false); if (r) load() }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
backend/dashboard/src/pages/Routing.tsx
Normal file
89
backend/dashboard/src/pages/Routing.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
const D = 'rgba(237,237,240,'
|
||||
|
||||
const STRATEGIES = [
|
||||
{ value: 'round_robin', label: 'Round robin', desc: 'Distribute requests evenly across providers serving the same model' },
|
||||
{ value: 'lowest_cost', label: 'Lowest cost', desc: 'Always route to the cheapest provider for the requested model' },
|
||||
{ value: 'priority', label: 'Priority', desc: 'Use provider priority weights when selecting among candidates' },
|
||||
]
|
||||
|
||||
export default function Routing() {
|
||||
const [strategy, setStrategy] = useState('round_robin')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.get()
|
||||
.then(s => { if (s['routing.strategy']) setStrategy(s['routing.strategy']) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
await api.settings.put('routing.strategy', strategy)
|
||||
setSaving(false)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-up" style={{ maxWidth: 620 }}>
|
||||
<p style={{ fontSize: 13, color: `${D}0.45)`, marginBottom: 24, lineHeight: 1.6 }}>
|
||||
Configure how the <code style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: '#c4b5fd' }}>any/<slug></code> pseudo-provider selects
|
||||
among multiple backends that serve the same model. Specific <code style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: '#c4b5fd' }}>provider/model</code> addresses always route directly.
|
||||
</p>
|
||||
|
||||
{/* Strategy picker */}
|
||||
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
|
||||
<div style={{ padding: '13px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff' }}>any/* routing strategy</span>
|
||||
</div>
|
||||
<div style={{ padding: 16, display: 'flex', flexDirection: 'column' as const, gap: 10 }}>
|
||||
{STRATEGIES.map(s => (
|
||||
<label key={s.value} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '12px 14px', borderRadius: 8, cursor: 'pointer', border: `1px solid ${strategy === s.value ? 'rgba(124,58,237,0.4)' : 'rgba(255,255,255,0.06)'}`, background: strategy === s.value ? 'rgba(124,58,237,0.08)' : 'transparent', transition: 'all 0.12s' }}>
|
||||
<input type="radio" name="strategy" value={s.value} checked={strategy === s.value} onChange={() => setStrategy(s.value)} style={{ marginTop: 2, accentColor: '#7c3aed' }}/>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: '#f0eeff', marginBottom: 3 }}>{s.label}</div>
|
||||
<div style={{ fontSize: 12, color: `${D}0.4)`, lineHeight: 1.5 }}>{s.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model addressing reference */}
|
||||
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden', marginBottom: 20 }}>
|
||||
<div style={{ padding: '13px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff' }}>Model addressing</span>
|
||||
</div>
|
||||
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column' as const, gap: 12 }}>
|
||||
{[
|
||||
{ addr: 'openrouter/openai/gpt-4o', desc: 'Direct — always routes to OpenRouter' },
|
||||
{ addr: 'anthropic/claude-opus-4-8', desc: 'Direct — always routes to Anthropic' },
|
||||
{ addr: 'any/gpt-4o', desc: 'Cross-provider — selects by strategy' },
|
||||
{ addr: 'any/claude-opus-4-8', desc: 'Cross-provider — selects by strategy' },
|
||||
].map(r => (
|
||||
<div key={r.addr} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<code style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: '#c4b5fd', background: 'rgba(124,58,237,0.1)', padding: '3px 8px', borderRadius: 4, flexShrink: 0 }}>{r.addr}</code>
|
||||
<span style={{ fontSize: 12, color: `${D}0.4)` }}>{r.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button onClick={save} disabled={saving || loading} style={{
|
||||
background: '#7c3aed', border: 'none', borderRadius: 7, padding: '8px 20px',
|
||||
fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer',
|
||||
opacity: saving ? 0.7 : 1,
|
||||
}}>
|
||||
{saved ? 'Saved ✓' : saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
backend/dashboard/src/pages/Settings.tsx
Normal file
119
backend/dashboard/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
const D = 'rgba(237,237,240,'
|
||||
|
||||
function Section({ title, badge, children }: { title: string; badge?: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
|
||||
<div style={{ padding: '13px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff' }}>{title}</span>
|
||||
{badge}
|
||||
</div>
|
||||
<div style={{ padding: 20 }}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: `${D}0.55)`, marginBottom: 6 }}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle = { width: '100%', background: 'rgba(0,0,0,0.28)', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 6, padding: '8px 12px', fontSize: 13, color: '#ededf0', outline: 'none' }
|
||||
|
||||
export default function Settings() {
|
||||
const [syncLoading, setSyncLoading] = useState(false)
|
||||
const [syncMsg, setSyncMsg] = useState('')
|
||||
|
||||
async function forceSync() {
|
||||
setSyncLoading(true)
|
||||
setSyncMsg('')
|
||||
try {
|
||||
const r = await api.catalog.refresh()
|
||||
setSyncMsg(`Synced — ${r.models} models available`)
|
||||
} catch (e: any) {
|
||||
setSyncMsg(`Error: ${e.message}`)
|
||||
} finally {
|
||||
setSyncLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-up" style={{ maxWidth: 620 }}>
|
||||
<Section title="General">
|
||||
<div style={{ display: 'grid', gap: 15 }}>
|
||||
<Field label="Router name">
|
||||
<input type="text" defaultValue="Local Model Routex" style={inputStyle}/>
|
||||
</Field>
|
||||
<Field label="Base URL">
|
||||
<input type="text" defaultValue="http://localhost:8080" style={{ ...inputStyle, fontFamily: "'DM Mono',monospace" }}/>
|
||||
</Field>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<Field label="Log level">
|
||||
<select style={{ ...inputStyle, cursor: 'pointer' }}>
|
||||
<option>info</option><option>debug</option><option>warn</option><option>error</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Log retention">
|
||||
<select style={{ ...inputStyle, cursor: 'pointer' }}>
|
||||
<option>7 days</option><option>30 days</option><option>90 days</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Authentication"
|
||||
badge={<span style={{ fontSize: 10, fontWeight: 600, padding: '2px 7px', borderRadius: 3, background: 'rgba(34,197,94,0.12)', color: '#4ade80', border: '1px solid rgba(34,197,94,0.2)' }}>OIDC</span>}>
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<Field label="OIDC Issuer URL">
|
||||
<input type="text" placeholder="https://auth.example.com/application/o/routex/" style={{ ...inputStyle, fontFamily: "'DM Mono',monospace" }}/>
|
||||
</Field>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<Field label="Client ID">
|
||||
<input type="text" placeholder="routex-dashboard" style={{ ...inputStyle, fontFamily: "'DM Mono',monospace" }}/>
|
||||
</Field>
|
||||
<Field label="Admin group">
|
||||
<input type="text" placeholder="llm-admins" style={{ ...inputStyle, fontFamily: "'DM Mono',monospace" }}/>
|
||||
</Field>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: `${D}0.3)`, lineHeight: 1.5 }}>
|
||||
OIDC is configured via environment variables (<code style={{ fontFamily: 'monospace' }}>GATEWAY_OIDC_*</code>). Restart the server after changing them.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Catalog sync">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div>
|
||||
<p style={{ fontSize: 13, color: `${D}0.5)`, lineHeight: 1.5 }}>
|
||||
Force an immediate model catalog refresh from all enabled providers.
|
||||
</p>
|
||||
{syncMsg && <p style={{ fontSize: 12, color: syncMsg.startsWith('Error') ? '#f87171' : '#4ade80', marginTop: 6 }}>{syncMsg}</p>}
|
||||
</div>
|
||||
<button onClick={forceSync} disabled={syncLoading} style={{
|
||||
background: '#17171c', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 7,
|
||||
padding: '8px 16px', fontSize: 13, color: `${D}0.65)`, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
|
||||
opacity: syncLoading ? 0.6 : 1,
|
||||
}}>
|
||||
{syncLoading ? 'Syncing…' : 'Sync now'}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button style={{ background: 'transparent', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 7, padding: '8px 16px', fontSize: 13, color: `${D}0.5)`, cursor: 'pointer' }}>
|
||||
Discard
|
||||
</button>
|
||||
<button style={{ background: '#7c3aed', border: 'none', borderRadius: 7, padding: '8px 20px', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer' }}>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
// Build straight into the Go embed package so `go build` bundles the UI.
|
||||
// emptyOutDir is required because the target is outside the project root.
|
||||
|
||||
Reference in New Issue
Block a user