feat: redesign dashboard on shared UI primitives

Shared tokens and components, responsive layout, real health badge,
request volume chart with hover, log detail with errors and cost,
provider enable and edit, override clearing, OIDC re-login that
returns to the current page.
This commit is contained in:
2026-10-03 16:17:11 +02:00
parent 5988e01906
commit 3ec52d82b7
19 changed files with 2035 additions and 1361 deletions

View File

@@ -1,46 +1,30 @@
# Getting Started with Create React App
# LMR dashboard
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
The management UI for the gateway. Vite + React + TypeScript, styled with
Tailwind utilities and a small set of component classes in `src/index.css`.
## Available Scripts
## Develop
In the project directory, you can run:
```bash
npm install
npm run dev # http://localhost:5173, proxies /api, /v1 and /auth to :8080
```
### `npm start`
Run the Go server next to it, or use `make dev` from `backend/`.
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
## Build
The page will reload if you make edits.\
You will also see any lint errors in the console.
```bash
npm run build # type-checks, then writes ../internal/web/dist for go:embed
```
### `npm test`
## Layout
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
| Path | Purpose |
|---|---|
| `src/lib/api.ts` | Typed API client, token storage, OIDC re-login on 401 |
| `src/lib/format.ts` | Number, cost, time and token formatting |
| `src/components/ui.tsx` | Shared primitives: cards, buttons, inputs, badges, modal, toasts |
| `src/components/VolumeChart.tsx` | Request volume line chart with hover tooltip |
| `src/components/Layout.tsx` | Sidebar, header, health indicator |
| `src/pages/` | One file per route |

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { getToken, clearToken, api } from './lib/api'
import { getToken, setToken, clearToken, takeReturnPath, api } from './lib/api'
import type { CurrentUser } from './lib/types'
import { ToastProvider, Spinner } from './components/ui'
import Login from './pages/Login'
import Layout from './components/Layout'
import Overview from './pages/Overview'
@@ -14,41 +15,66 @@ import Chat from './pages/Chat'
import Models from './pages/Models'
import Settings from './pages/Settings'
/** Pull a token out of the OIDC callback fragment, if present. */
function consumeFragmentToken(): boolean {
if (!window.location.hash.startsWith('#')) return false
const hash = new URLSearchParams(window.location.hash.slice(1))
const tok = hash.get('token')
if (!tok) return false
setToken(tok)
window.history.replaceState({}, '', takeReturnPath())
return true
}
export default function App() {
const [authed, setAuthed] = useState(() => !!getToken())
const [authed, setAuthed] = useState(() => consumeFragmentToken() || !!getToken())
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
const [authError, setAuthError] = useState('')
useEffect(() => {
if (!authed) return
api.me().then(setCurrentUser).catch(() => {
api.me().then(setCurrentUser).catch(e => {
clearToken()
setAuthed(false)
setAuthError(e instanceof Error ? e.message : 'Sign-in failed')
})
}, [authed])
if (!authed) {
return <Login onLogin={() => setAuthed(true)} />
return <Login error={authError} />
}
if (!currentUser) {
return (
<div className="h-full flex items-center justify-center text-ink-3 text-[13px] gap-2">
<Spinner /> Signing in…
</div>
)
}
const admin = currentUser.is_admin
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout currentUser={currentUser} />}>
<Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<Overview />} />
<Route path="chat" element={<Chat />} />
<Route path="logs" element={<Logs />} />
<Route path="keys" element={<ApiKeys />} />
<Route path="analytics" element={<Analytics />} />
{currentUser?.is_admin && <>
<Route path="providers" element={<Providers />} />
<Route path="routing" element={<Routing />} />
<Route path="models" element={<Models />} />
<Route path="settings" element={<Settings />} />
</>}
<Route path="*" element={<Navigate to="/overview" replace />} />
</Route>
</Routes>
</BrowserRouter>
<ToastProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout currentUser={currentUser} />}>
<Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<Overview currentUser={currentUser} />} />
<Route path="chat" element={<Chat />} />
<Route path="logs" element={<Logs />} />
<Route path="keys" element={<ApiKeys />} />
<Route path="analytics" element={<Analytics />} />
{admin && <>
<Route path="providers" element={<Providers />} />
<Route path="routing" element={<Routing />} />
<Route path="models" element={<Models />} />
<Route path="settings" element={<Settings />} />
</>}
<Route path="*" element={<Navigate to="/overview" replace />} />
</Route>
</Routes>
</BrowserRouter>
</ToastProvider>
)
}

View File

@@ -1,155 +1,133 @@
import { useEffect, useState } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { clearToken } from '../lib/api'
import { api, clearToken } from '../lib/api'
import type { CurrentUser } from '../lib/types'
import { Dot } from './ui'
const USER_NAV = [
{ to: '/overview', label: 'Overview', icon: <IconGrid /> },
{ to: '/chat', label: 'Test Chat', icon: <IconChat /> },
{ to: '/logs', label: 'Request Logs', icon: <IconLogs /> },
{ to: '/keys', label: 'API Keys', icon: <IconKey /> },
{ to: '/analytics',label: 'Analytics', icon: <IconChart /> },
{ to: '/overview', label: 'Overview', icon: <IconGrid /> },
{ to: '/chat', label: 'Test Chat', icon: <IconChat /> },
{ to: '/logs', label: 'Request Logs', icon: <IconLogs /> },
{ to: '/keys', label: 'API Keys', icon: <IconKey /> },
{ to: '/analytics', label: 'Analytics', icon: <IconChart /> },
]
const ADMIN_NAV = [
{ to: '/providers',label: 'Providers', icon: <IconServer /> },
{ to: '/routing', label: 'Routing Rules', icon: <IconRoute /> },
{ to: '/models', label: 'Model Catalog', icon: <IconBox /> },
{ to: '/providers', label: 'Providers', icon: <IconServer /> },
{ to: '/models', label: 'Model Catalog', icon: <IconBox /> },
{ to: '/routing', label: 'Routing', icon: <IconRoute /> },
{ to: '/settings', label: 'Settings', icon: <IconSettings /> },
]
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 function Logo({ size = 30 }: { size?: number }) {
return (
<div style={{
width: size, height: size, flexShrink: 0, borderRadius: Math.round(size * 0.27),
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={size / 2} height={size / 2} viewBox="0 0 16 16" fill="none" stroke="rgba(255,255,255,0.9)" strokeWidth="1.6" strokeLinecap="round" aria-hidden>
<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>
)
}
export default function Layout({ currentUser }: { currentUser: CurrentUser | null }) {
/** Polls the readiness endpoint so the header reflects real gateway health. */
function useHealth(intervalMs = 30000) {
const [ok, setOk] = useState<boolean | null>(null)
useEffect(() => {
let alive = true
const check = () => api.ready().then(r => { if (alive) setOk(r) })
check()
const t = setInterval(check, intervalMs)
return () => { alive = false; clearInterval(t) }
}, [intervalMs])
return ok
}
export default function Layout({ currentUser }: { currentUser: CurrentUser }) {
const loc = useLocation()
const allNav = [...USER_NAV, ...(currentUser?.is_admin ? ADMIN_NAV : [])]
const section = allNav.find(n => loc.pathname.startsWith(n.to))?.label ?? 'Settings'
const health = useHealth()
const nav = [...USER_NAV, ...(currentUser.is_admin ? ADMIN_NAV : [])]
const section = nav.find(n => loc.pathname.startsWith(n.to))?.label ?? ''
useEffect(() => {
document.title = section ? `${section} · ${currentUser.app_name}` : currentUser.app_name
}, [section, currentUser.app_name])
function signOut() {
clearToken()
window.location.href = '/'
}
const initial = (currentUser.name || currentUser.email || 'U')[0].toUpperCase()
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 className="shell">
<aside className="sidebar">
<div className="brand px-4 pt-[18px] pb-[14px]" style={{ borderBottom: '1px solid var(--color-line)' }}>
<div className="flex items-center gap-[10px]">
<Logo />
<div className="min-w-0">
<div className="text-[13.5px] font-semibold text-ink tracking-[-0.3px] ellip">{currentUser.app_name}</div>
<div className="brand-sub text-[10.5px] text-ink-4 mt-[1px] flex items-center gap-[5px]">
<Dot tone={health === null ? 'neutral' : health ? 'good' : 'bad'} pulse={health !== false} />
{health === null ? 'checking' : health ? 'gateway ready' : 'database unreachable'}
</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 className="nav-scroll">
<div className="nav-section label px-2 mt-[2px] mb-1">Main</div>
{USER_NAV.map(({ to, label, icon }) => (
<NavLink key={to} to={to} style={({ isActive }) => navStyle(isActive)}>
{icon}
{label}
<NavLink key={to} to={to} className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}>
{icon}{label}
</NavLink>
))}
{currentUser?.is_admin && <>
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.09em', color: 'rgba(237,237,240,0.22)', padding: '0 8px', margin: '8px 0 4px', textTransform: 'uppercase' }}>
Admin
</div>
{currentUser.is_admin && <>
<div className="nav-section label px-2 mt-3 mb-1">Admin</div>
{ADMIN_NAV.map(({ to, label, icon }) => (
<NavLink key={to} to={to} style={({ isActive }) => navStyle(isActive)}>
{icon}
{label}
<NavLink key={to} to={to} className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}>
{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',
}}>{(currentUser?.name || currentUser?.email || 'U')[0].toUpperCase()}</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 500, color: '#ededf0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{currentUser?.name || currentUser?.email || '…'}
<div className="nav-bottom px-2 pt-2 pb-[14px]" style={{ borderTop: '1px solid var(--color-line)' }}>
<div className="user-box flex items-center gap-[9px] px-[10px] pt-[6px]">
<div className="w-[27px] h-[27px] rounded-full shrink-0 flex items-center justify-center text-[11.5px] font-semibold text-white"
style={{ background: 'linear-gradient(135deg,#7c3aed,#2563eb)' }} title={currentUser.email}>{initial}</div>
<div className="min-w-0 flex-1">
<div className="text-[12.5px] font-medium text-ink ellip" title={currentUser.email}>{currentUser.name || currentUser.email}</div>
<div className="flex items-center gap-2 text-[10.5px] text-ink-4">
{currentUser.is_admin ? 'admin' : 'member'}
<span>·</span>
<button onClick={signOut} className="bg-transparent border-none p-0 text-[10.5px] text-ink-3 hover:text-ink">Sign out</button>
</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>
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
<header className="h-[52px] shrink-0 flex items-center px-6 gap-3" style={{ borderBottom: '1px solid var(--color-line)' }}>
<h1 className="text-[15px] font-semibold text-ink tracking-[-0.3px] flex-1">{section}</h1>
<a href="/v1/models" target="_blank" rel="noreferrer" title="OpenAI-compatible endpoint"
className="flex items-center gap-[6px] px-[10px] py-1 rounded-[5px] text-[11px] mono no-underline hover:no-underline"
style={{ background: 'rgba(124,58,237,0.08)', border: '1px solid rgba(124,58,237,0.2)', color: 'var(--color-accent-2)' }}>
{window.location.host}/v1
</a>
</header>
{/* Content */}
<main style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px', display: 'flex', flexDirection: 'column' }}>
<Outlet />
</div>
<main className="content">
<Outlet />
</main>
</div>
</div>
@@ -157,52 +135,31 @@ export default function Layout({ currentUser }: { currentUser: CurrentUser | nul
}
/* ── Icons ── */
const I = { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const }
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>
return <svg {...I}><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>
return <svg {...I}><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>
return <svg {...I}><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>
return <svg {...I}><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>
return <svg {...I}><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>
return <svg {...I}><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>
return <svg {...I}><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 IconChat() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
return <svg {...I} viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /></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>
return <svg {...I}><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>
}

View File

@@ -0,0 +1,126 @@
import { useMemo, useRef, useState } from 'react'
const SERIES = '#8b5cf6'
interface Props {
data: number[]
since: number // epoch ms of the first bucket start
until: number // epoch ms of the last bucket end
height?: number
loading?: boolean
}
function niceMax(v: number): number {
if (v <= 5) return 5
const p = Math.pow(10, Math.floor(Math.log10(v)))
const m = v / p
const step = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10
return step * p
}
function fmtBucketTime(ms: number, spanMs: number): string {
const d = new Date(ms)
if (spanMs <= 2 * 86400000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
/**
* Single-series request volume over time: a 2px line with a 10% area wash,
* hairline gridlines, clean y ticks, and a snapping crosshair tooltip.
*/
export default function VolumeChart({ data, since, until, height = 150, loading }: Props) {
const wrapRef = useRef<HTMLDivElement>(null)
const [hover, setHover] = useState<number | null>(null)
const W = 600, H = height
const PAD = { l: 36, r: 12, t: 10, b: 22 }
const iw = W - PAD.l - PAD.r
const ih = H - PAD.t - PAD.b
const n = data.length
const span = until - since
const bucketMs = n ? span / n : 0
const yMax = niceMax(Math.max(...data, 1))
const x = (i: number) => PAD.l + (n > 1 ? (i / (n - 1)) * iw : iw / 2)
const y = (v: number) => PAD.t + ih - (v / yMax) * ih
const { line, area } = useMemo(() => {
if (n === 0) return { line: '', area: '' }
const pts = data.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`)
const line = 'M' + pts.join(' L')
const area = `${line} L${x(n - 1).toFixed(1)},${(PAD.t + ih).toFixed(1)} L${x(0).toFixed(1)},${(PAD.t + ih).toFixed(1)} Z`
return { line, area }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, yMax, n])
const yTicks = [0, 0.25, 0.5, 0.75, 1].map(f => Math.round(yMax * f))
const xTickIdx = n <= 1 ? [0] : [0, Math.floor((n - 1) / 3), Math.floor(((n - 1) * 2) / 3), n - 1]
function onMove(e: React.PointerEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect()
const px = ((e.clientX - rect.left) / rect.width) * W
if (n === 0) return
const i = Math.round(((px - PAD.l) / iw) * (n - 1))
setHover(Math.max(0, Math.min(n - 1, i)))
}
const total = data.reduce((a, b) => a + b, 0)
const hv = hover !== null ? data[hover] : null
const hoverStart = hover !== null ? since + hover * bucketMs : 0
return (
<div ref={wrapRef} className="relative" style={{ opacity: loading ? 0.5 : 1, transition: 'opacity 0.2s' }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height, display: 'block', touchAction: 'none' }}
onPointerMove={onMove} onPointerLeave={() => setHover(null)}
role="img" aria-label={`Request volume, ${total.toLocaleString()} requests across ${n} intervals`}>
<defs>
<linearGradient id="vc-wash" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={SERIES} stopOpacity="0.16" />
<stop offset="100%" stopColor={SERIES} stopOpacity="0.02" />
</linearGradient>
</defs>
{yTicks.map((t, i) => (
<g key={i}>
<line x1={PAD.l} x2={W - PAD.r} y1={y(t)} y2={y(t)} stroke="rgba(255,255,255,0.06)" strokeWidth="1" />
<text x={PAD.l - 8} y={y(t) + 3.5} textAnchor="end" fontSize="10" fontFamily="var(--font-mono)" fill="rgba(236,236,241,0.35)">
{t >= 1000 ? `${(t / 1000).toFixed(t % 1000 ? 1 : 0)}k` : t}
</text>
</g>
))}
{n > 0 && <>
<path d={area} fill="url(#vc-wash)" />
<path d={line} fill="none" stroke={SERIES} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
<circle cx={x(n - 1)} cy={y(data[n - 1])} r="4" fill={SERIES} stroke="var(--color-card)" strokeWidth="2" />
</>}
{xTickIdx.map(i => (
<text key={i} x={x(i)} y={H - 6} fontSize="10" fontFamily="var(--font-mono)" fill="rgba(236,236,241,0.35)"
textAnchor={i === 0 ? 'start' : i === n - 1 ? 'end' : 'middle'}>
{fmtBucketTime(since + i * bucketMs, span)}
</text>
))}
{hover !== null && hv !== null && <>
<line x1={x(hover)} x2={x(hover)} y1={PAD.t} y2={PAD.t + ih} stroke="rgba(236,236,241,0.25)" strokeWidth="1" />
<circle cx={x(hover)} cy={y(hv)} r="4.5" fill={SERIES} stroke="var(--color-card)" strokeWidth="2" />
</>}
</svg>
{hover !== null && hv !== null && (
<div className="absolute pointer-events-none text-[11px] rounded-md px-[10px] py-[7px]"
style={{
top: 4, left: `${(x(hover) / W) * 100}%`,
transform: `translateX(${hover > n / 2 ? 'calc(-100% - 10px)' : '10px'})`,
background: 'var(--color-card-2)', border: '1px solid var(--color-line-2)', boxShadow: '0 6px 20px rgba(0,0,0,0.4)',
}}>
<div className="text-[14px] font-semibold text-ink leading-tight">{hv.toLocaleString()} <span className="text-[11px] font-normal text-ink-3">requests</span></div>
<div className="text-ink-3 mt-[3px] num">
{fmtBucketTime(hoverStart, span)}{bucketMs ? ` – ${fmtBucketTime(hoverStart + bucketMs, span)}` : ''}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,278 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
/* ── Cards ── */
export function Card({ children, className = '', style }: { children: ReactNode; className?: string; style?: React.CSSProperties }) {
return <div className={`card ${className}`} style={style}>{children}</div>
}
export function CardHead({ title, sub, right }: { title: ReactNode; sub?: ReactNode; right?: ReactNode }) {
return (
<div className="card-head">
<div className="min-w-0">
<div>{title}</div>
{sub && <div className="text-[11.5px] font-normal text-ink-3 mt-0.5">{sub}</div>}
</div>
{right && <div className="flex items-center gap-2 shrink-0">{right}</div>}
</div>
)
}
export function StatCard({ label, value, sub, loading, tone }: {
label: string; value: ReactNode; sub?: ReactNode; loading?: boolean; tone?: 'good' | 'bad'
}) {
const color = tone === 'bad' ? 'var(--color-bad-2)' : tone === 'good' ? 'var(--color-good-2)' : 'var(--color-ink)'
return (
<div className="card px-[18px] py-4">
<div className="label mb-2">{label}</div>
<div className="text-[26px] font-semibold leading-none tracking-[-0.5px]" style={{ color, opacity: loading ? 0.35 : 1, transition: 'opacity 0.15s' }}>
{value}
</div>
{sub && <div className="text-[11.5px] text-ink-3 mt-2">{sub}</div>}
</div>
)
}
/* ── Buttons & inputs ── */
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'ghost' | 'danger'
size?: 'sm' | 'md'
loading?: boolean
}
export function Button({ variant = 'ghost', size = 'md', loading, children, className = '', disabled, ...rest }: ButtonProps) {
return (
<button className={`btn btn-${variant} ${size === 'sm' ? 'btn-sm' : ''} ${className}`} disabled={disabled || loading} {...rest}>
{loading && <Spinner size={12} />}
{children}
</button>
)
}
export function IconButton({ title, danger, children, className = '', ...rest }: React.ButtonHTMLAttributes<HTMLButtonElement> & { title: string; danger?: boolean }) {
return (
<button className={`btn-icon ${danger ? 'danger' : ''} ${className}`} title={title} aria-label={title} {...rest}>
{children}
</button>
)
}
export function Input(props: React.InputHTMLAttributes<HTMLInputElement> & { mono?: boolean }) {
const { mono, className = '', ...rest } = props
return <input className={`input ${mono ? 'mono' : ''} ${className}`} {...rest} />
}
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { mono?: boolean }) {
const { mono, className = '', ...rest } = props
return <textarea className={`input ${mono ? 'mono' : ''} ${className}`} style={{ resize: 'vertical', ...rest.style }} {...rest} />
}
export function Select(props: React.SelectHTMLAttributes<HTMLSelectElement>) {
const { className = '', ...rest } = props
return <select className={`input cursor-pointer ${className}`} {...rest} />
}
export function Field({ label, hint, children, className = '' }: { label: ReactNode; hint?: ReactNode; children: ReactNode; className?: string }) {
return (
<label className={`block ${className}`}>
<span className="block text-xs font-medium text-ink-2 mb-1.5">{label}</span>
{children}
{hint && <span className="block text-[11px] text-ink-3 mt-1.5 leading-relaxed">{hint}</span>}
</label>
)
}
export function Checkbox({ label, checked, onChange, tone }: { label: ReactNode; checked: boolean; onChange: (v: boolean) => void; tone?: 'bad' }) {
return (
<label className="inline-flex items-center gap-2 text-[13px] cursor-pointer select-none" style={{ color: tone === 'bad' ? 'var(--color-bad-2)' : 'var(--color-ink-2)' }}>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ accentColor: 'var(--color-accent)' }} />
{label}
</label>
)
}
export function Toggle({ checked, onChange, disabled, title }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; title?: string }) {
return (
<button type="button" role="switch" aria-checked={checked} title={title} disabled={disabled}
onClick={() => onChange(!checked)}
style={{
width: 34, height: 20, borderRadius: 10, padding: 2, border: 'none', position: 'relative', flexShrink: 0,
background: checked ? 'var(--color-accent)' : 'rgba(255,255,255,0.12)', transition: 'background 0.15s', opacity: disabled ? 0.5 : 1,
}}>
<span style={{
display: 'block', width: 16, height: 16, borderRadius: '50%', background: '#fff',
transform: `translateX(${checked ? 14 : 0}px)`, transition: 'transform 0.15s',
}} />
</button>
)
}
export function Segmented<T extends string>({ options, value, onChange }: { options: { value: T; label: string }[]; value: T; onChange: (v: T) => void }) {
return (
<div className="seg" role="group">
{options.map(o => (
<button key={o.value} type="button" aria-pressed={o.value === value} onClick={() => onChange(o.value)}>{o.label}</button>
))}
</div>
)
}
export function SearchBox({ value, onChange, placeholder, className = '' }: { value: string; onChange: (v: string) => void; placeholder?: string; className?: string }) {
return (
<div className={`flex items-center gap-2 card px-3 py-[7px] ${className}`} style={{ borderRadius: 7 }}>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" className="text-ink-4 shrink-0">
<circle cx="6.5" cy="6.5" r="4.5" /><line x1="10.5" y1="10.5" x2="14" y2="14" />
</svg>
<input value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder} aria-label={placeholder}
className="bg-transparent border-none outline-none text-[12.5px] text-ink-2 w-full placeholder:text-ink-4" />
{value && (
<button type="button" onClick={() => onChange('')} aria-label="Clear search" className="text-ink-4 hover:text-ink-2 border-none bg-transparent text-sm leading-none">×</button>
)}
</div>
)
}
/* ── Badges & status ── */
export type Tone = 'neutral' | 'accent' | 'good' | 'bad' | 'warn'
export function Badge({ tone = 'neutral', children, className = '', title }: { tone?: Tone; children: ReactNode; className?: string; title?: string }) {
return <span className={`badge badge-${tone} ${className}`} title={title}>{children}</span>
}
export function StatusCode({ code }: { code: number }) {
const tone: Tone = code === 0 ? 'neutral' : code < 400 ? 'good' : code < 500 ? 'warn' : 'bad'
return <Badge tone={tone} className="num">{code || '—'}</Badge>
}
export function Dot({ tone, pulse }: { tone: Tone; pulse?: boolean }) {
const color = { neutral: 'rgba(236,236,241,0.3)', accent: 'var(--color-accent-2)', good: 'var(--color-good)', bad: 'var(--color-bad)', warn: 'var(--color-warn)' }[tone]
return <span className={pulse ? 'dot-pulse' : ''} style={{ width: 6, height: 6, borderRadius: '50%', background: color, display: 'inline-block', flexShrink: 0 }} />
}
export function Spinner({ size = 14 }: { size?: number }) {
return (
<svg className="spin" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
)
}
export function Empty({ children, action }: { children: ReactNode; action?: ReactNode }) {
return (
<div className="px-5 py-7 text-center text-[13px] text-ink-3 flex flex-col items-center gap-3">
<div>{children}</div>
{action}
</div>
)
}
export function Loading({ label = 'Loading…' }: { label?: string }) {
return <div className="px-5 py-5 text-[13px] text-ink-3 flex items-center gap-2"><Spinner /> {label}</div>
}
export function ErrorNote({ children }: { children: ReactNode }) {
if (!children) return null
return (
<div className="text-[12.5px] px-3 py-2 rounded-lg" style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.22)', color: 'var(--color-bad-2)' }}>
{children}
</div>
)
}
/* ── Table header ── */
export function TableHead({ cols, labels }: { cols: string; labels: string[] }) {
return (
<div className="row-head" style={{ gridTemplateColumns: cols }}>
{labels.map((h, i) => <span key={i} className="label">{h}</span>)}
</div>
)
}
/* ── Modal ── */
export function Modal({ title, sub, onClose, children, width }: { title: ReactNode; sub?: ReactNode; onClose: () => void; children: ReactNode; width?: number }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="modal-back" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal" role="dialog" aria-modal="true" style={width ? { maxWidth: width } : undefined}>
<div className="mb-5">
<h2 className="text-[15px] font-semibold text-ink">{title}</h2>
{sub && <div className="text-[11.5px] text-ink-3 mt-1 mono">{sub}</div>}
</div>
{children}
</div>
</div>
)
}
/* ── Toasts ── */
interface ToastItem { id: number; message: string; tone: 'good' | 'bad' | 'neutral' }
const ToastCtx = createContext<(message: string, tone?: ToastItem['tone']) => void>(() => {})
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([])
const seq = useRef(0)
const push = useCallback((message: string, tone: ToastItem['tone'] = 'neutral') => {
const id = ++seq.current
setItems(prev => [...prev, { id, message, tone }])
setTimeout(() => setItems(prev => prev.filter(t => t.id !== id)), tone === 'bad' ? 6000 : 3200)
}, [])
return (
<ToastCtx.Provider value={push}>
{children}
<div className="toast-wrap" aria-live="polite">
{items.map(t => <div key={t.id} className={`toast ${t.tone}`}>{t.message}</div>)}
</div>
</ToastCtx.Provider>
)
}
export function useToast() { return useContext(ToastCtx) }
/* ── Confirm dialog ── */
export function ConfirmDialog({ title, body, confirmLabel = 'Confirm', danger, onConfirm, onClose }: {
title: string; body: ReactNode; confirmLabel?: string; danger?: boolean; onConfirm: () => Promise<void> | void; onClose: () => void
}) {
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
async function go() {
setBusy(true); setErr('')
try { await onConfirm(); onClose() }
catch (e) { setErr(e instanceof Error ? e.message : String(e)) }
finally { setBusy(false) }
}
return (
<Modal title={title} onClose={onClose} width={400}>
<div className="text-[13px] text-ink-2 leading-relaxed mb-5">{body}</div>
{err && <div className="mb-3"><ErrorNote>{err}</ErrorNote></div>}
<div className="flex gap-2">
<Button className="flex-1" onClick={onClose} disabled={busy}>Cancel</Button>
<Button className="flex-1" variant={danger ? 'danger' : 'primary'} onClick={go} loading={busy}>{confirmLabel}</Button>
</div>
</Modal>
)
}
/* ── Copy button ── */
export function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) {
const [done, setDone] = useState(false)
async function copy() {
try {
await navigator.clipboard.writeText(text)
setDone(true)
setTimeout(() => setDone(false), 1500)
} catch { /* clipboard unavailable */ }
}
return <Button size="sm" onClick={copy}>{done ? 'Copied' : label}</Button>
}

View File

@@ -2,32 +2,51 @@
@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;
--color-sidebar: #0e0e12;
--color-card: #15151a;
--color-card-2: #1b1b21;
--color-line: rgba(255, 255, 255, 0.07);
--color-line-2: rgba(255, 255, 255, 0.12);
--color-ink: #ececf1;
--color-ink-2: rgba(236, 236, 241, 0.62);
--color-ink-3: rgba(236, 236, 241, 0.4);
--color-ink-4: rgba(236, 236, 241, 0.26);
--color-accent: #7c3aed;
--color-accent-2: #a78bfa;
--color-accent-3: #c4b5fd;
--color-good: #22c55e;
--color-good-2: #4ade80;
--color-bad: #ef4444;
--color-bad-2: #f87171;
--color-warn: #f59e0b;
--color-warn-2: #fbbf24;
--font-sans: 'DM Sans', system-ui, sans-serif;
--font-mono: 'DM Mono', ui-monospace, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@layer base {
html, body, #root { height: 100%; }
body {
background: #09090b;
color: #f0eeff;
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
body {
background: var(--color-bg);
color: var(--color-ink);
font-family: var(--font-sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
input, select, button, textarea { font-family: inherit; color: inherit; }
button { cursor: pointer; }
button:disabled { cursor: default; }
a { color: var(--color-accent-2); text-decoration: none; }
a:hover { text-decoration: underline; }
:focus-visible { outline: 2px solid var(--color-accent-2); outline-offset: 1px; border-radius: 4px; }
select option { background: var(--color-card-2); color: var(--color-ink); }
}
input, select, button, textarea { font-family: inherit; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
@@ -36,11 +55,163 @@ input, select, button, textarea { font-family: inherit; }
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes bounce3 {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-3px); opacity: 1; }
}
.animate-fade-up { animation: fadeUp 0.18s ease; }
.dot-pulse { animation: pulse 2.4s ease infinite; }
.spin { animation: spin 0.8s linear infinite; }
.typing > span { animation: bounce3 1.2s ease infinite; }
.typing > span:nth-child(2) { animation-delay: 0.15s; }
.typing > span:nth-child(3) { animation-delay: 0.3s; }
::-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); }
@layer components {
.card {
background: var(--color-card);
border: 1px solid var(--color-line);
border-radius: 10px;
}
.card-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 18px;
border-bottom: 1px solid var(--color-line);
font-size: 13px; font-weight: 600; color: var(--color-ink);
}
.label {
font-size: 10px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase;
color: var(--color-ink-4);
}
.mono { font-family: var(--font-mono); }
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.input {
width: 100%;
background: rgba(0, 0, 0, 0.28);
border: 1px solid var(--color-line-2);
border-radius: 7px;
padding: 8px 11px;
font-size: 13px;
color: var(--color-ink);
outline: none;
transition: border-color 0.12s;
}
.input::placeholder { color: var(--color-ink-4); }
.input:focus { border-color: rgba(167, 139, 250, 0.6); outline: none; }
.input:disabled { opacity: 0.5; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
border-radius: 7px; padding: 7px 14px;
font-size: 13px; font-weight: 500; line-height: 1.2;
border: 1px solid transparent;
white-space: nowrap;
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
}
.btn:disabled { opacity: 0.45; }
.btn-primary { background: var(--color-accent); color: #fff; }
.btn-primary:hover:not(:disabled) { background: #6d28d9; }
.btn-ghost { background: transparent; border-color: var(--color-line-2); color: var(--color-ink-2); }
.btn-ghost:hover:not(:disabled) { background: rgba(255,255,255,0.04); color: var(--color-ink); }
.btn-danger { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.25); color: var(--color-bad-2); }
.btn-danger:hover:not(:disabled) { background: rgba(239,68,68,0.2); }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.btn-icon {
width: 28px; height: 28px; padding: 0; border-radius: 6px;
border: 1px solid var(--color-line); background: transparent; color: var(--color-ink-3);
display: inline-flex; align-items: center; justify-content: center;
}
.btn-icon:hover:not(:disabled) { color: var(--color-ink); border-color: var(--color-line-2); background: rgba(255,255,255,0.04); }
.btn-icon.danger:hover:not(:disabled) { color: var(--color-bad-2); border-color: rgba(239,68,68,0.35); }
.badge {
display: inline-flex; align-items: center; gap: 4px;
font-size: 10px; font-weight: 600; letter-spacing: 0.02em;
padding: 2px 7px; border-radius: 4px; white-space: nowrap; line-height: 1.5;
border: 1px solid transparent; width: fit-content;
}
.badge-neutral { background: rgba(255,255,255,0.06); color: var(--color-ink-3); }
.badge-accent { background: rgba(124,58,237,0.14); color: var(--color-accent-2); border-color: rgba(124,58,237,0.25); }
.badge-good { background: rgba(34,197,94,0.1); color: var(--color-good-2); }
.badge-bad { background: rgba(239,68,68,0.1); color: var(--color-bad-2); }
.badge-warn { background: rgba(245,158,11,0.1); color: var(--color-warn-2); }
.seg { display: inline-flex; gap: 2px; padding: 3px; background: var(--color-card); border: 1px solid var(--color-line); border-radius: 7px; }
.seg > button { padding: 4px 12px; border-radius: 5px; font-size: 12px; font-weight: 500; border: none; background: transparent; color: var(--color-ink-3); }
.seg > button:hover { color: var(--color-ink-2); }
.seg > button[aria-pressed="true"] { background: var(--color-accent); color: #fff; }
.row { display: grid; align-items: center; padding: 9px 18px; border-bottom: 1px solid rgba(255,255,255,0.04); }
.row:last-child { border-bottom: none; }
.row-head { display: grid; padding: 8px 18px; border-bottom: 1px solid var(--color-line); }
.row-hover:hover { background: rgba(255,255,255,0.02); }
.ellip { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.modal-back {
position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,0.6); backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal {
width: 100%; max-width: 440px; max-height: calc(100vh - 32px); overflow-y: auto;
background: var(--color-card); border: 1px solid var(--color-line-2);
border-radius: 12px; padding: 22px 24px;
animation: fadeUp 0.16s ease;
}
.grid-stats { display: grid; gap: 12px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid-main { display: grid; gap: 14px; align-items: start; grid-template-columns: minmax(0, 1fr) 300px; }
.grid-main.wide { grid-template-columns: minmax(0, 1fr) 340px; }
.table-scroll { overflow-x: auto; }
.table-scroll > * { min-width: 640px; }
.toast-wrap { position: fixed; right: 18px; bottom: 18px; z-index: 100; display: flex; flex-direction: column; gap: 8px; }
.toast {
min-width: 220px; max-width: 380px; padding: 10px 14px; border-radius: 8px; font-size: 12.5px;
background: var(--color-card-2); border: 1px solid var(--color-line-2); color: var(--color-ink);
box-shadow: 0 8px 24px rgba(0,0,0,0.4); animation: fadeUp 0.16s ease;
}
.toast.bad { border-color: rgba(239,68,68,0.4); }
.toast.good { border-color: rgba(34,197,94,0.4); }
.shell { display: flex; height: 100vh; overflow: hidden; background: var(--color-bg); }
.sidebar {
width: 224px; min-width: 224px; background: var(--color-sidebar);
border-right: 1px solid var(--color-line);
display: flex; flex-direction: column; overflow: hidden;
}
.nav-scroll { display: flex; flex-direction: column; gap: 1px; flex: 1; padding: 10px 8px; overflow-y: auto; }
.nav-link {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: 7px;
font-size: 13px; font-weight: 500; color: var(--color-ink-3);
transition: background 0.12s, color 0.12s;
}
.nav-link:hover { color: var(--color-ink); text-decoration: none; background: rgba(255,255,255,0.03); }
.nav-link.active { color: var(--color-ink); background: rgba(124,58,237,0.18); }
.content { flex: 1; overflow-y: auto; padding: 24px 28px; display: flex; flex-direction: column; }
@media (max-width: 1100px) {
.grid-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-main, .grid-main.wide { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 820px) {
.shell { flex-direction: column; }
.sidebar { width: 100%; min-width: 0; border-right: none; border-bottom: 1px solid var(--color-line); }
.sidebar .nav-scroll { flex-direction: row; overflow-x: auto; overflow-y: hidden; padding: 6px 8px; gap: 2px; }
.sidebar .nav-section { display: none; }
.sidebar .nav-link { white-space: nowrap; }
.sidebar .brand-sub, .sidebar .user-box { display: none; }
.sidebar .brand { padding: 10px 14px; border-bottom: none; }
.sidebar .nav-bottom { display: none; }
.content { padding: 16px; }
.hide-sm { display: none !important; }
}
}

View File

@@ -1,38 +1,106 @@
import type {
Provider, VirtualKey, AdminModel, RequestLog, KeyStat, CurrentUser,
CreateProviderParams, CreateKeyParams, ModelOverride, LogsSummary, ModelStat,
Provider, VirtualKey, AdminModel, RequestLog, KeyStat, CurrentUser, LogContent,
CreateProviderParams, UpdateProviderParams, CreateKeyParams, ModelOverride, LogsSummary, ModelStat, Settings,
CatalogEntry,
} from './types'
const BASE = '/api/admin'
const BASE_API = '/api'
const TOKEN_KEY = 'admin_token'
const RETURN_KEY = 'post_login_path'
const LAST_LOGIN_KEY = 'last_auto_login'
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') }
export function getToken() { return localStorage.getItem(TOKEN_KEY) ?? '' }
export function setToken(t: string) { localStorage.setItem(TOKEN_KEY, t) }
export function clearToken() { localStorage.removeItem(TOKEN_KEY) }
/** Remember where the user was so the OIDC callback can return there. */
export function rememberReturnPath() {
const p = window.location.pathname + window.location.search
if (p && p !== '/') sessionStorage.setItem(RETURN_KEY, p)
}
export function takeReturnPath(): string {
const p = sessionStorage.getItem(RETURN_KEY)
sessionStorage.removeItem(RETURN_KEY)
return p || '/overview'
}
/**
* Start a fresh OIDC login. The identity provider usually still has a live
* session, so an expired ID token is replaced without the user noticing.
* A login that happened within the last 30 seconds is not repeated, so a
* misconfigured provider cannot bounce the browser in a loop.
*/
export function beginLogin() {
clearToken()
const last = Number(sessionStorage.getItem(LAST_LOGIN_KEY) ?? 0)
if (Date.now() - last < 30_000) {
window.location.replace('/')
return
}
sessionStorage.setItem(LAST_LOGIN_KEY, String(Date.now()))
rememberReturnPath()
window.location.href = '/auth/login'
}
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
/** Extract a readable message from an OpenAI-style error envelope or raw text. */
export async function errorMessage(res: Response): Promise<string> {
const text = await res.text().catch(() => '')
try {
const j = JSON.parse(text)
const m = j?.error?.message ?? j?.message
if (typeof m === 'string' && m) return m
} catch { /* not JSON */ }
return text.trim() || res.statusText || `HTTP ${res.status}`
}
export function authHeaders(extra?: HeadersInit): HeadersInit {
return { 'Authorization': `Bearer ${getToken()}`, ...extra }
}
async function req<T>(path: string, opts?: RequestInit, base = BASE): Promise<T> {
const res = await fetch(base + path, {
...opts,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
...opts?.headers,
},
headers: authHeaders({ 'Content-Type': 'application/json', ...(opts?.headers as Record<string, string> | undefined) }),
})
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)
if (res.status === 401) beginLogin()
throw new ApiError(res.status, await errorMessage(res))
}
return res.json()
}
function qs(params: Record<string, string | number | undefined>): string {
const q = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== '' && v !== 0) q.set(k, String(v))
}
const s = q.toString()
return s ? '?' + s : ''
}
export interface ListLogsParams {
provider?: string; key?: string; status?: string; since?: string
before?: string; before_id?: string; limit?: number; q?: string
}
export const api = {
providers: {
list: () => req<Provider[]>('/providers'),
create: (d: CreateProviderParams) =>
req<{ id: string }>('/providers', { method: 'POST', body: JSON.stringify(d) }),
update: (id: string, d: UpdateProviderParams) =>
req<void>(`/providers/${id}`, { method: 'PATCH', body: JSON.stringify(d) }),
delete: (id: string) => req<void>(`/providers/${id}`, { method: 'DELETE' }),
},
keys: {
@@ -47,54 +115,30 @@ export const api = {
req<void>(`/models/${id}`, { method: 'PATCH', body: JSON.stringify(d) }),
},
settings: {
get: () => req<Record<string, any>>('/settings'),
put: (key: string, value: any) =>
get: () => req<Settings>('/settings'),
put: (key: keyof Settings, value: unknown) =>
req<void>(`/settings/${encodeURIComponent(key)}`, { method: 'PUT', body: JSON.stringify(value) }),
},
catalog: {
refresh: () =>
req<{ status: string; models: number }>('/catalog/refresh', { method: 'POST' }),
refresh: () => req<{ status: string; models: number }>('/catalog/refresh', { method: 'POST' }),
/** Models the current user may call from the test chat. */
available: () => req<{ data: CatalogEntry[] }>('/models', undefined, BASE_API),
},
logs: {
list: (params: { provider?: string; key?: string; status?: string; since?: string; before?: string; beforeId?: string; limit?: number; q?: string } = {}) => {
const qs = new URLSearchParams()
if (params.provider) qs.set('provider', params.provider)
if (params.key) qs.set('key', params.key)
if (params.status) qs.set('status', params.status)
if (params.since) qs.set('since', params.since)
if (params.before) qs.set('before', params.before)
if (params.beforeId) qs.set('before_id', params.beforeId)
if (params.limit) qs.set('limit', String(params.limit))
if (params.q) qs.set('q', params.q)
const s = qs.toString()
return req<RequestLog[]>('/logs' + (s ? '?' + s : ''))
},
content: (id: string) =>
req<{ Messages: RequestLog['Messages']; Completion: RequestLog['Completion'] }>(`/logs/${id}/content`),
list: (p: ListLogsParams = {}) => req<RequestLog[]>('/logs' + qs(p as Record<string, string | number | undefined>)),
content: (id: string) => req<LogContent>(`/logs/${id}/content`),
},
stats: {
keys: (since?: string) => {
const qs = since ? '?since=' + encodeURIComponent(since) : ''
return req<KeyStat[]>('/stats/keys' + qs)
},
summary: (since?: string) => {
const qs = since ? '?since=' + encodeURIComponent(since) : ''
return req<LogsSummary>('/stats/summary' + qs)
},
models: (since?: string, limit?: number) => {
const qs = new URLSearchParams()
if (since) qs.set('since', since)
if (limit) qs.set('limit', String(limit))
const s = qs.toString()
return req<ModelStat[]>('/stats/models' + (s ? '?' + s : ''))
},
series: (since?: string, buckets?: number) => {
const qs = new URLSearchParams()
if (since) qs.set('since', since)
if (buckets) qs.set('buckets', String(buckets))
const s = qs.toString()
return req<number[]>('/stats/series' + (s ? '?' + s : ''))
},
keys: (since?: string) => req<KeyStat[]>('/stats/keys' + qs({ since })),
summary: (since?: string) => req<LogsSummary>('/stats/summary' + qs({ since })),
models: (since?: string, limit?: number) => req<ModelStat[]>('/stats/models' + qs({ since, limit })),
series: (since?: string, buckets?: number) => req<number[]>('/stats/series' + qs({ since, buckets })),
},
me: () => req<CurrentUser>('/me', undefined, BASE_API),
ready: async (): Promise<boolean> => {
try {
const r = await fetch('/readyz', { cache: 'no-store' })
return r.ok
} catch { return false }
},
}

View File

@@ -0,0 +1,59 @@
export function fmtTokens(n: number | null | undefined): string {
const v = n ?? 0
if (v >= 1e9) return `${(v / 1e9).toFixed(2)}B`
if (v >= 1e6) return `${(v / 1e6).toFixed(v >= 1e7 ? 1 : 2)}M`
if (v >= 1e4) return `${(v / 1e3).toFixed(1)}K`
return v.toLocaleString()
}
export function fmtCost(n: number | null | undefined): string {
const v = n ?? 0
if (v === 0) return '$0'
if (v < 0.01) return `$${v.toFixed(4)}`
if (v < 1) return `$${v.toFixed(3)}`
return `$${v.toFixed(2)}`
}
export function fmtMs(ms: number | null | undefined): string {
if (ms == null) return '—'
if (ms >= 10000) return `${(ms / 1000).toFixed(1)}s`
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
export function fmtDate(iso: string | null | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
export function fmtDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit',
})
}
export function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
export function ago(iso: string | null | undefined): string {
if (!iso) return '—'
const s = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
if (s < 5) return 'just now'
if (s < 60) return `${s}s ago`
if (s < 3600) return `${Math.floor(s / 60)}m ago`
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
return `${Math.floor(s / 86400)}d ago`
}
export function fmtPct(part: number, whole: number): string {
if (!whole) return '—'
return `${Math.round((part / whole) * 100)}%`
}
export function fmtContext(n: number | null | undefined): string {
if (!n) return ''
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M ctx`
return `${Math.round(n / 1000)}k ctx`
}

View File

@@ -4,7 +4,13 @@ export interface Provider {
type: string
base_url: string
enabled: boolean
live: boolean
has_credentials: boolean
config: Record<string, string>
created_at: string
model_count: number
available_count: number
last_synced_at: string | null
}
export interface VirtualKey {
@@ -13,6 +19,8 @@ export interface VirtualKey {
prefix: string
type: string
allow_all_models: boolean
rules: string[]
created_at: string
expires_at: string | null
revoked_at: string | null
last_used_at: string | null
@@ -28,12 +36,18 @@ export interface AdminModel {
enabled: boolean
blocked: boolean
context_length: number | null
last_seen_at: string | 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
price_cached_per_mtok: number | null
rpm_limit: number | null
tpm_limit: number | null
}
export interface ChatMessage { role: string; content: unknown }
export interface RequestLog {
ID: string
CreatedAt: string
@@ -46,12 +60,15 @@ export interface RequestLog {
Error: string
PromptTokens: number
CompletionTokens: number
CachedTokens: number
TotalTokens: number
Cost: number
LatencyMS: number
TTFTMs: number | null
// Content (null when not yet logged or not available)
Messages: Array<{ role: string; content: string }> | null
}
export interface LogContent {
Messages: ChatMessage[] | null
Completion: string | null
}
@@ -83,6 +100,7 @@ export interface CurrentUser {
email: string
name: string
is_admin: boolean
app_name: string
}
export interface CatalogEntry {
@@ -96,9 +114,17 @@ export interface CreateProviderParams {
type: string
base_url: string
credentials: Record<string, string>
config?: Record<string, string>
enabled: boolean
}
export interface UpdateProviderParams {
base_url?: string
enabled?: boolean
credentials?: Record<string, string>
config?: Record<string, string>
}
export interface CreateKeyParams {
name: string
allow_all_models: boolean
@@ -106,11 +132,20 @@ export interface CreateKeyParams {
expires_in_days: number
}
/** null clears an override; undefined leaves it unchanged. */
export interface ModelOverride {
enabled?: boolean
blocked?: boolean
input_price_per_mtok?: number
output_price_per_mtok?: number
rpm_limit?: number
tpm_limit?: number
input_price_per_mtok?: number | null
output_price_per_mtok?: number | null
cached_price_per_mtok?: number | null
rpm_limit?: number | null
tpm_limit?: number | null
}
export type Settings = {
'routing.strategy'?: string
'app.name'?: string
'app.log_level'?: string
'log.retention_days'?: number
}

View File

@@ -1,160 +1,120 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import type { KeyStat, LogsSummary, ModelStat } from '../lib/types'
import { fmtCost, fmtMs, fmtPct, fmtTokens } from '../lib/format'
import { Card, CardHead, Empty, ErrorNote, Segmented, StatCard, TableHead } from '../components/ui'
import VolumeChart from '../components/VolumeChart'
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>
)
type Range = '24h' | '7d' | '30d'
const RANGES: Record<Range, { ms: number; buckets: number; unit: string }> = {
'24h': { ms: 86400000, buckets: 24, unit: 'hourly' },
'7d': { ms: 604800000, buckets: 28, unit: '6-hour intervals' },
'30d': { ms: 2592000000, buckets: 30, unit: 'daily' },
}
const BUCKETS = 24
const KEY_COLS = '150px minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) 80px 64px'
export default function Analytics() {
const [range, setRange] = useState<Range>(() => (localStorage.getItem('analytics_range') as Range) || '24h')
const [summary, setSummary] = useState<LogsSummary | null>(null)
const [topModels, setTopModels] = useState<ModelStat[]>([])
const [reqByBucket, setReqByBucket] = useState<number[]>(Array(BUCKETS).fill(0))
const [series, setSeries] = useState<number[]>([])
const [window_, setWindow] = useState({ since: Date.now() - RANGES['24h'].ms, until: Date.now() })
const [keyStats, setKeyStats] = useState<KeyStat[]>([])
const [loading, setLoading] = useState(true)
const [rangeIdx, setRangeIdx] = useState(0)
const [error, setError] = useState('')
useEffect(() => {
localStorage.setItem('analytics_range', range)
setLoading(true)
const since = new Date(Date.now() - RANGES[rangeIdx].ms).toISOString()
setError('')
const r = RANGES[range]
const until = Date.now()
const sinceMs = until - r.ms
const since = new Date(sinceMs).toISOString()
Promise.all([
api.stats.summary(since),
api.stats.models(since, 6),
api.stats.series(since, BUCKETS),
api.stats.models(since, 8),
api.stats.series(since, r.buckets),
api.stats.keys(since),
])
.then(([s, m, series, k]) => {
.then(([s, m, ser, k]) => {
setSummary(s)
setTopModels(m ?? [])
setReqByBucket(series ?? Array(BUCKETS).fill(0))
setSeries(ser ?? [])
setWindow({ since: sinceMs, until })
setKeyStats(k ?? [])
})
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load'))
.finally(() => setLoading(false))
}, [rangeIdx])
}, [range])
const totalTokens = summary?.total_tokens ?? 0
const maxTokens = topModels[0]?.total_tokens ?? 1
const stats = [
{ label: 'Total requests', value: (summary?.requests ?? 0).toLocaleString() },
{ label: 'Total tokens', value: totalTokens > 1e6 ? `${(totalTokens/1e6).toFixed(1)}M` : totalTokens.toLocaleString() },
{ label: 'Total cost', value: `$${(summary?.total_cost ?? 0).toFixed(4)}` },
{ label: 'Avg latency', value: `${summary?.avg_latency_ms ?? 0}ms` },
]
const maxTokens = Math.max(topModels[0]?.total_tokens ?? 0, 1)
const reqs = summary?.requests ?? 0
const errs = summary?.errors ?? 0
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 className="animate-fade-up flex flex-col gap-4">
<div className="flex items-center gap-3 flex-wrap">
<Segmented value={range} onChange={setRange}
options={[{ value: '24h', label: 'Last 24h' }, { value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' }]} />
<span className="text-xs text-ink-4">All charts and tables use this range.</span>
</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>
))}
{error && <ErrorNote>{error}</ErrorNote>}
<div className="grid-stats">
<StatCard label="Requests" value={reqs.toLocaleString()} loading={loading} sub={`${errs.toLocaleString()} errors · ${fmtPct(errs, reqs)}`} />
<StatCard label="Tokens" value={fmtTokens(summary?.total_tokens)} loading={loading} sub={summary ? `${(summary.total_tokens).toLocaleString()} total` : undefined} />
<StatCard label="Cost" value={fmtCost(summary?.total_cost)} loading={loading} sub={reqs ? `${fmtCost((summary?.total_cost ?? 0) / reqs)} per request` : undefined} />
<StatCard label="Avg latency" value={fmtMs(summary?.avg_latency_ms)} loading={loading} sub="end to end, all requests" />
</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 className="grid-main wide">
<Card className="px-[22px] pt-[18px] pb-[14px]">
<div className="text-[13px] font-semibold text-ink">Request volume</div>
<div className="text-[11.5px] text-ink-3 mb-3">{RANGES[range].unit}, last {range}</div>
<VolumeChart data={series} since={window_.since} until={window_.until} loading={loading} />
</Card>
<Card className="px-[22px] pt-[18px] pb-[18px]">
<div className="text-[13px] font-semibold text-ink">Top models</div>
<div className="text-[11.5px] text-ink-3 mb-4">by tokens</div>
{topModels.length === 0 && !loading && <div className="text-[13px] text-ink-3">No usage in this range.</div>}
<div className="flex flex-col gap-[14px]" style={{ opacity: loading ? 0.5 : 1, transition: 'opacity 0.2s' }}>
{topModels.map(({ model_name, total_tokens }) => (
<div key={model_name} title={`${model_name}: ${total_tokens.toLocaleString()} tokens`}>
<div className="flex justify-between gap-3 mb-[5px]">
<span className="mono text-[11.5px] text-ink-2 ellip" style={{ maxWidth: '72%' }}>{model_name}</span>
<span className="num text-[11.5px] text-ink-3">{fmtTokens(total_tokens)}</span>
</div>
<div className="h-[6px] rounded-[3px] overflow-hidden" style={{ background: 'rgba(255,255,255,0.06)' }}>
<div className="h-full" style={{ background: '#8b5cf6', borderRadius: '0 3px 3px 0', width: `${Math.max((total_tokens / maxTokens) * 100, 1)}%`, transition: 'width 0.3s' }} />
</div>
</div>
))}
</div>
</div>
</Card>
</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(({ model_name, total_tokens }) => (
<div key={model_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%' }}>{model_name}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.38)` }}>{total_tokens > 1e6 ? `${(total_tokens/1e6).toFixed(1)}M` : total_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: `${(total_tokens / maxTokens) * 100}%`, transition: 'width 0.3s' }}/>
</div>
<Card className="overflow-hidden">
<CardHead title="Usage by API key" sub="Includes the dashboard test chat under its own key." />
<div className="table-scroll">
<TableHead cols={KEY_COLS} labels={['Key', 'Requests', 'Tokens', 'Cost', 'Errors', 'Err %']} />
{keyStats.length === 0 && !loading && <Empty>No usage in this range.</Empty>}
{keyStats.map(k => (
<div key={k.key_prefix} className="row row-hover" style={{ gridTemplateColumns: KEY_COLS }}>
<Link to={`/logs?key=${encodeURIComponent(k.key_prefix)}`} className="mono text-[11.5px] text-accent-3 ellip" title="Show requests for this key">{k.key_prefix}</Link>
<span className="num text-xs text-ink-2">{k.requests.toLocaleString()}</span>
<span className="num text-xs text-ink-2" title={`${k.prompt_tokens.toLocaleString()} prompt · ${k.completion_tokens.toLocaleString()} completion`}>{fmtTokens(k.total_tokens)}</span>
<span className="num text-xs text-ink-2">{fmtCost(k.cost)}</span>
<span className="num text-xs" style={{ color: k.errors > 0 ? 'var(--color-bad-2)' : 'var(--color-ink-3)' }}>{k.errors}</span>
<span className="num text-xs" style={{ color: k.errors > 0 ? 'var(--color-bad-2)' : 'var(--color-ink-3)' }}>{fmtPct(k.errors, k.requests)}</span>
</div>
))}
</div>
</div>
{/* Per-key stats */}
<div style={{ marginTop: 14, background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ padding: '13px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff' }}>Usage by API Key</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '140px 1fr 1fr 1fr 100px 60px', padding: '8px 18px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
{['Key','Requests','Tokens','Cost','Errors','Err %'].map(h => (
<span key={h} style={{ fontSize: 10, fontWeight: 700, color: `${D}0.28)`, textTransform: 'uppercase', letterSpacing: '0.07em' }}>{h}</span>
))}
</div>
{keyStats.length === 0 && !loading && <div style={{ padding: '18px 20px', color: `${D}0.3)`, fontSize: 13 }}>No data yet</div>}
{keyStats.map(k => (
<div key={k.key_prefix} style={{ display: 'grid', gridTemplateColumns: '140px 1fr 1fr 1fr 100px 60px', padding: '10px 18px', borderBottom: '1px solid rgba(255,255,255,0.04)', alignItems: 'center' }}>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: '#c4b5fd' }}>{k.key_prefix}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: `${D}0.62)` }}>{k.requests.toLocaleString()}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: `${D}0.62)` }}>
{k.total_tokens > 1e6 ? `${(k.total_tokens/1e6).toFixed(1)}M` : k.total_tokens.toLocaleString()}
</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: `${D}0.62)` }}>${k.cost.toFixed(4)}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: k.errors > 0 ? '#f87171' : `${D}0.35)` }}>{k.errors}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: k.errors > 0 ? '#f87171' : `${D}0.35)` }}>
{k.requests ? `${Math.round(k.errors/k.requests*100)}%` : '—'}
</span>
</div>
))}
</div>
</Card>
</div>
)
}

View File

@@ -1,169 +1,179 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import type { VirtualKey } from '../lib/types'
import { ago, fmtDate } from '../lib/format'
import {
Badge, Button, Card, Checkbox, ConfirmDialog, CopyButton, Dot, Empty, ErrorNote, Field, IconButton, Input,
Loading, Modal, TableHead, Textarea, useToast,
} from '../components/ui'
const D = 'rgba(237,237,240,'
const COLS = 'minmax(140px,1.4fr) 120px 100px 100px 100px minmax(120px,1fr) 64px'
function fmtDate(s: string | null) {
if (!s) return '—'
return new Date(s).toLocaleDateString()
function keyState(k: VirtualKey): { label: string; tone: 'good' | 'neutral' | 'warn' | 'bad' } {
if (k.revoked_at) return { label: 'revoked', tone: 'neutral' }
if (k.expires_at && new Date(k.expires_at).getTime() < Date.now()) return { label: 'expired', tone: 'bad' }
if (k.expires_at && new Date(k.expires_at).getTime() - Date.now() < 7 * 86400000) return { label: 'expires soon', tone: 'warn' }
return { label: 'active', tone: 'good' }
}
function Modal({ onClose, onCreate }: { onClose: () => void; onCreate: (key: string) => void }) {
function CreateModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [name, setName] = useState('')
const [allModels, setAllModels] = useState(true)
const [rules, setRules] = useState('')
const [expiry, setExpiry] = useState(0)
const [expiry, setExpiry] = useState('0')
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
const [newKey, setNewKey] = useState('')
async function submit(e: React.FormEvent) {
e.preventDefault()
setErr('')
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,
name: name.trim(), allow_all_models: allModels,
rules: allModels ? [] : rules.split('\n').map(s => s.trim()).filter(Boolean),
expires_in_days: Math.max(0, Number(expiry) || 0),
})
setNewKey(r.key)
onCreate(r.key)
onCreated()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
if (newKey) {
return (
<Modal title="Key created" onClose={onClose}>
<p className="text-[13px] text-ink-2 mb-3">Copy this key now. It is shown only once and cannot be recovered.</p>
<div className="mono text-xs text-accent-3 break-all rounded-lg px-[14px] py-[10px] mb-3" style={{ background: 'rgba(0,0,0,0.3)', border: '1px solid var(--color-line-2)' }}>{newKey}</div>
<div className="flex gap-2 justify-end">
<CopyButton text={newKey} label="Copy key" />
<Button variant="primary" onClick={onClose}>Done</Button>
</div>
</Modal>
)
}
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>
<Modal title="Create API key" onClose={onClose}>
<form onSubmit={submit} className="flex flex-col gap-4">
<Field label="Name">
<Input value={name} onChange={e => setName(e.target.value)} required autoFocus placeholder="e.g. laptop-cli" />
</Field>
<Checkbox label="Allow all models" checked={allModels} onChange={setAllModels} />
{!allModels && (
<Field label="Model rules" hint={<>One per line. Use <code className="mono">provider/*</code> for every model of a provider, or an exact address like <code className="mono">openai/gpt-4o</code>.</>}>
<Textarea mono value={rules} onChange={e => setRules(e.target.value)} rows={3} placeholder={'openai/*\nollama/llama3.1:8b'} required />
</Field>
)}
</div>
</div>
<Field label="Expires in days" hint="0 keeps the key valid until it is revoked.">
<Input mono type="number" min={0} value={expiry} onChange={e => setExpiry(e.target.value)} />
</Field>
{err && <ErrorNote>{err}</ErrorNote>}
<div className="flex gap-2 pt-1">
<Button type="button" className="flex-1" onClick={onClose}>Cancel</Button>
<Button type="submit" variant="primary" className="flex-1" loading={loading}>Create key</Button>
</div>
</form>
</Modal>
)
}
export default function ApiKeys() {
const navigate = useNavigate()
const toast = useToast()
const [keys, setKeys] = useState<VirtualKey[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [revoking, setRevoking] = useState<VirtualKey | null>(null)
const [showRevoked, setShowRevoked] = useState(false)
function load() {
setLoading(true)
api.keys.list().then(r => setKeys(r ?? [])).finally(() => setLoading(false))
api.keys.list()
.then(r => setKeys(r ?? []))
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load'))
.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 64px'
const visible = showRevoked ? keys : keys.filter(k => !k.revoked_at)
const revokedCount = keys.filter(k => k.revoked_at).length
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.
<div className="animate-fade-up flex flex-col gap-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<p className="text-[13px] text-ink-2 leading-relaxed max-w-[560px]">
Keys authenticate requests to the OpenAI-compatible endpoint. Send one as <code className="mono text-accent-3">Authorization: Bearer sk-…</code>.
A key can be limited to specific providers or 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>
<Button variant="primary" onClick={() => setShowCreate(true)}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden><line x1="6" y1="1" x2="6" y2="11" /><line x1="1" y1="6" x2="11" y2="6" /></svg>
Create key
</button>
</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>
))}
{error && <ErrorNote>{error}</ErrorNote>}
<Card className="overflow-hidden">
<div className="table-scroll">
<TableHead cols={COLS} labels={['Name', 'Key', 'Created', 'Last used', 'Expires', 'Access', '']} />
{loading && <Loading />}
{!loading && visible.length === 0 && <Empty>{keys.length === 0 ? 'No keys yet. Create one to start sending requests.' : 'No active keys.'}</Empty>}
{visible.map(k => {
const st = keyState(k)
return (
<div key={k.id} className="row row-hover" style={{ gridTemplateColumns: COLS, padding: '12px 18px', opacity: k.revoked_at ? 0.55 : 1 }}>
<div className="flex items-center gap-2 min-w-0">
<Dot tone={st.tone} />
<div className="min-w-0">
<div className="text-[13px] font-medium text-ink ellip">{k.name || <span className="text-ink-4">unnamed</span>}</div>
<div className="text-[10.5px] text-ink-4">{st.label}{k.revoked_at ? ` ${ago(k.revoked_at)}` : ''}</div>
</div>
</div>
<span className="mono text-[11.5px] text-ink-3">{k.prefix}…</span>
<span className="text-xs text-ink-3" title={new Date(k.created_at).toLocaleString()}>{fmtDate(k.created_at)}</span>
<span className="text-xs text-ink-3" title={k.last_used_at ? new Date(k.last_used_at).toLocaleString() : 'never used'}>{k.last_used_at ? ago(k.last_used_at) : 'never'}</span>
<span className="text-xs text-ink-3">{k.expires_at ? fmtDate(k.expires_at) : 'never'}</span>
<div className="flex flex-wrap gap-1 min-w-0">
{k.allow_all_models
? <Badge tone="accent">all models</Badge>
: k.rules.slice(0, 2).map(r => <Badge key={r} className="mono" title={r}>{r}</Badge>)}
{!k.allow_all_models && k.rules.length > 2 && <Badge title={k.rules.join('\n')}>+{k.rules.length - 2}</Badge>}
</div>
<div className="flex gap-[5px] justify-end">
<Link to={`/logs?q=${encodeURIComponent(k.prefix)}&range=7d`} className="btn-icon no-underline hover:no-underline" title="View requests" aria-label="View requests">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden><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>
</Link>
{!k.revoked_at && (
<IconButton title="Revoke key" danger onClick={() => setRevoking(k)}>
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden><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>
</IconButton>
)}
</div>
</div>
)
})}
</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' }}>
<button onClick={() => navigate(`/logs?key=${k.prefix}`)} title="View logs"
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 16 16" fill="none" stroke="rgba(237,237,240,0.42)" 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>
</button>
{!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>
{revokedCount > 0 && (
<div className="px-[18px] py-2 text-center" style={{ borderTop: '1px solid var(--color-line)' }}>
<button onClick={() => setShowRevoked(v => !v)} className="bg-transparent border-none text-xs text-ink-3 hover:text-ink">
{showRevoked ? 'Hide' : 'Show'} {revokedCount} revoked key{revokedCount === 1 ? '' : 's'}
</button>
</div>
))}
</div>
)}
</Card>
{showModal && <Modal onClose={() => { setShowModal(false); load() }} onCreate={() => {}} />}
{showCreate && <CreateModal onClose={() => setShowCreate(false)} onCreated={load} />}
{revoking && (
<ConfirmDialog title="Revoke key" danger confirmLabel="Revoke" onClose={() => setRevoking(null)}
body={<>Requests using <span className="mono text-ink">{revoking.prefix}…</span> ({revoking.name}) will be rejected immediately. This cannot be undone.</>}
onConfirm={async () => { await api.keys.revoke(revoking.id); toast('Key revoked', 'good'); load() }} />
)}
</div>
)
}

View File

@@ -1,53 +1,71 @@
import { useState, useEffect, useRef } from 'react'
import { getToken } from '../lib/api'
import { useState, useEffect, useRef, useMemo } from 'react'
import { api, authHeaders, beginLogin, errorMessage } from '../lib/api'
import type { CatalogEntry } from '../lib/types'
import { fmtMs } from '../lib/format'
import { Button, ErrorNote, Select } from '../components/ui'
interface Stats {
promptTokens: number
completionTokens: number
totalTokens: number
tps: number
ttftMs: number | null
elapsedMs: number
}
interface Message {
role: 'user' | 'assistant'
content: string
reasoning?: string // thinking tokens, separate from the final answer
reasoning?: string
pending?: boolean
stats?: Stats
}
interface ModelOption { id: string }
const MODEL_KEY = 'chat_model'
const SYSTEM_KEY = 'chat_system'
export default function Chat() {
const [models, setModels] = useState<ModelOption[]>([])
const [model, setModel] = useState('')
const [models, setModels] = useState<CatalogEntry[]>([])
const [model, setModel] = useState(() => localStorage.getItem(MODEL_KEY) ?? '')
const [system, setSystem] = useState(() => localStorage.getItem(SYSTEM_KEY) ?? '')
const [showSystem, setShowSystem] = useState(false)
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const [error, setError] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
fetch('/api/models', { headers: { Authorization: `Bearer ${getToken()}` } })
.then(r => r.json())
api.catalog.available()
.then(d => {
const list: ModelOption[] = d.data ?? []
const list = (d.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id))
setModels(list)
if (list.length > 0) setModel(list[0].id)
setModel(cur => (cur && list.some(m => m.id === cur)) ? cur : (list[0]?.id ?? ''))
})
.catch(() => setError('Failed to load models'))
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load models'))
}, [])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
useEffect(() => { if (model) localStorage.setItem(MODEL_KEY, model) }, [model])
useEffect(() => { localStorage.setItem(SYSTEM_KEY, system) }, [system])
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
const grouped = useMemo(() => {
const g = new Map<string, CatalogEntry[]>()
for (const m of models) {
const list = g.get(m.owned_by) ?? []
list.push(m)
g.set(m.owned_by, list)
}
return Array.from(g.entries())
}, [models])
async function send() {
const text = input.trim()
if (!text || streaming || !model) return
setInput('')
if (inputRef.current) inputRef.current.style.height = 'auto'
setError('')
const next: Message[] = [...messages, { role: 'user', content: text }]
@@ -59,31 +77,32 @@ export default function Chat() {
const startMs = performance.now()
let firstTokenMs: number | null = null
let reply = ''
let reasoning = ''
let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null = null
try {
const res = await fetch('/api/chat/completions', {
method: 'POST',
signal: abort.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`,
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
model,
stream: true,
stream_options: { include_usage: true },
messages: next.map(m => ({ role: m.role, content: m.content })),
messages: [
...(system.trim() ? [{ role: 'system', content: system.trim() }] : []),
...next.map(m => ({ role: m.role, content: m.content })),
],
}),
})
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText))
if (res.status === 401) { beginLogin(); return }
if (!res.ok) throw new Error(await errorMessage(res))
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buf = ''
let reply = ''
let reasoning = ''
let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null = null
while (true) {
const { done, value } = await reader.read()
@@ -98,44 +117,46 @@ export default function Chat() {
try {
const chunk = JSON.parse(data)
if (chunk.usage) usage = chunk.usage
if (chunk.error?.message) throw new Error(chunk.error.message)
const delta = chunk.choices?.[0]?.delta ?? {}
const reasoningDetails: string = (delta.reasoning_details ?? [])
.map((d: { text?: string; summary?: string }) => d.text ?? d.summary ?? '')
.join('')
const reasoningDelta: string = (delta.reasoning_content ?? '') + (delta.reasoning ?? '') + reasoningDetails
const details: string = (delta.reasoning_details ?? [])
.map((d: { text?: string; summary?: string }) => d.text ?? d.summary ?? '').join('')
const reasoningDelta: string = (delta.reasoning_content ?? '') + (delta.reasoning ?? '') + details
const contentDelta: string = delta.content ?? ''
if (reasoningDelta || contentDelta) {
if (firstTokenMs === null) firstTokenMs = performance.now()
reasoning += reasoningDelta
reply += contentDelta
setMessages([...next, { role: 'assistant', content: reply, reasoning: reasoning || undefined }])
setMessages([...next, { role: 'assistant', content: reply, reasoning: reasoning || undefined, pending: true }])
}
} catch {}
} catch (e) {
if (e instanceof SyntaxError) continue
throw e
}
}
}
const endMs = performance.now()
const elapsedMs = endMs - (firstTokenMs ?? startMs)
const completionTokens = usage?.completion_tokens ?? 0
const tps = elapsedMs > 0 && completionTokens > 0 ? completionTokens / (elapsedMs / 1000) : 0
const stats: Stats | undefined = usage ? {
promptTokens: usage.prompt_tokens,
completionTokens: usage.completion_tokens,
totalTokens: usage.total_tokens,
tps,
const genMs = endMs - (firstTokenMs ?? startMs)
const u = usage as { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null
const stats: Stats | undefined = u ? {
promptTokens: u.prompt_tokens,
completionTokens: u.completion_tokens,
totalTokens: u.total_tokens,
tps: genMs > 0 && u.completion_tokens > 0 ? u.completion_tokens / (genMs / 1000) : 0,
ttftMs: firstTokenMs !== null ? firstTokenMs - startMs : null,
elapsedMs: endMs - startMs,
} : undefined
setMessages([...next, {
role: 'assistant',
content: reply || '(empty response)',
reasoning: reasoning || undefined,
stats,
}])
} catch (e: any) {
if (e.name !== 'AbortError') { setError(e.message); setMessages(next) }
else setMessages(prev => prev.map((m, i) => i === prev.length - 1 ? { ...m, pending: false } : m))
setMessages([...next, { role: 'assistant', content: reply || '(empty response)', reasoning: reasoning || undefined, stats }])
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') {
setMessages([...next, { role: 'assistant', content: reply || '(stopped)', reasoning: reasoning || undefined }])
} else {
setError(e instanceof Error ? e.message : String(e))
setMessages(next)
setInput(text)
}
} finally {
setStreaming(false)
abortRef.current = null
@@ -147,59 +168,52 @@ export default function Chat() {
}
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, maxWidth: 780, width: '100%', margin: '0 auto' }}>
{/* Toolbar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14, flexShrink: 0 }}>
<select value={model} onChange={e => setModel(e.target.value)} disabled={streaming} style={{
flex: 1, background: '#17171c', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 7,
padding: '7px 12px', fontSize: 13, color: '#ededf0', cursor: 'pointer', outline: 'none',
fontFamily: "'DM Mono',monospace",
}}>
{models.length === 0
? <option value="">No models available</option>
: models.map(m => <option key={m.id} value={m.id}>{m.id}</option>)}
</select>
{messages.length > 0 && (
<button onClick={() => { setMessages([]); setError('') }} disabled={streaming} style={{
background: 'transparent', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 7,
padding: '7px 12px', fontSize: 12, color: 'rgba(237,237,240,0.4)', cursor: 'pointer',
}}>Clear</button>
)}
<div className="flex-1 flex flex-col min-h-0 w-full max-w-[820px] mx-auto">
<div className="flex items-center gap-2 mb-3 shrink-0 flex-wrap">
<Select value={model} onChange={e => setModel(e.target.value)} disabled={streaming} className="mono flex-1 min-w-[220px]" style={{ width: 'auto' }} aria-label="Model">
{models.length === 0 && <option value="">No models available</option>}
{grouped.map(([prov, list]) => (
<optgroup key={prov} label={prov}>
{list.map(m => <option key={m.id} value={m.id}>{m.id}</option>)}
</optgroup>
))}
</Select>
<Button size="sm" variant={showSystem || system ? 'primary' : 'ghost'} onClick={() => setShowSystem(v => !v)} aria-pressed={showSystem}>System prompt</Button>
{messages.length > 0 && <Button size="sm" onClick={() => { setMessages([]); setError('') }} disabled={streaming}>Clear</Button>}
</div>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14, paddingBottom: 14, minHeight: 0 }}>
{showSystem && (
<textarea value={system} onChange={e => setSystem(e.target.value)} rows={3} placeholder="Optional system prompt, sent with every message."
className="input mb-3 shrink-0" style={{ resize: 'vertical', fontSize: 12.5 }} />
)}
<div className="flex-1 overflow-y-auto flex flex-col gap-[14px] pb-3 min-h-0">
{messages.length === 0 && !error && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'rgba(237,237,240,0.2)', fontSize: 13 }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-ink-4 text-[13px]">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
Send a message to test the model
Send a message to test <span className="mono text-ink-3">{model || 'a model'}</span>
<span className="text-[11px]">Requests from here are logged under the key "dashboard".</span>
</div>
)}
{messages.map((m, i) => (
<div key={i} style={{ display: 'flex', flexDirection: m.role === 'user' ? 'row-reverse' : 'row', gap: 10, alignItems: 'flex-start' }}>
<div style={{
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
background: m.role === 'user' ? 'linear-gradient(135deg,#7c3aed,#2563eb)' : 'rgba(255,255,255,0.07)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 600, color: 'rgba(237,237,240,0.7)',
}}>{m.role === 'user' ? 'U' : 'AI'}</div>
<div style={{ maxWidth: '80%', display: 'flex', flexDirection: 'column', gap: 5 }}>
{m.reasoning && <ThinkBlock text={m.reasoning} streaming={streaming && i === messages.length - 1} />}
<div style={{
background: m.role === 'user' ? 'rgba(124,58,237,0.15)' : '#17171c',
border: `1px solid ${m.role === 'user' ? 'rgba(124,58,237,0.25)' : 'rgba(255,255,255,0.07)'}`,
borderRadius: m.role === 'user' ? '12px 4px 12px 12px' : '4px 12px 12px 12px',
padding: '10px 14px', fontSize: 13.5, lineHeight: 1.6, color: '#ededf0',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
<div key={i} className="flex gap-[10px] items-start" style={{ flexDirection: m.role === 'user' ? 'row-reverse' : 'row' }}>
<div className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-[11px] font-semibold"
style={{ background: m.role === 'user' ? 'linear-gradient(135deg,#7c3aed,#2563eb)' : 'rgba(255,255,255,0.07)', color: m.role === 'user' ? '#fff' : 'var(--color-ink-2)' }}>
{m.role === 'user' ? 'U' : 'AI'}
</div>
<div className="flex flex-col gap-[5px] min-w-0" style={{ maxWidth: '82%' }}>
{m.reasoning && <ThinkBlock text={m.reasoning} streaming={!!m.pending && !m.content} />}
<div className="px-[14px] py-[10px] text-[13.5px] leading-[1.6] text-ink whitespace-pre-wrap break-words"
style={{
background: m.role === 'user' ? 'rgba(124,58,237,0.15)' : 'var(--color-card)',
border: `1px solid ${m.role === 'user' ? 'rgba(124,58,237,0.25)' : 'var(--color-line)'}`,
borderRadius: m.role === 'user' ? '12px 4px 12px 12px' : '4px 12px 12px 12px',
}}>
{m.content || (m.pending && !m.reasoning
? <span style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
{[0,1,2].map(j => <span key={j} style={{ width: 5, height: 5, borderRadius: '50%', background: 'rgba(237,237,240,0.3)', display: 'inline-block' }}/>)}
</span>
? <span className="typing inline-flex gap-1 items-center h-[18px]">{[0, 1, 2].map(j => <span key={j} className="w-[5px] h-[5px] rounded-full inline-block" style={{ background: 'var(--color-ink-3)' }} />)}</span>
: '')}
</div>
{m.stats && <StatsBar stats={m.stats} />}
@@ -207,21 +221,13 @@ export default function Chat() {
</div>
))}
{error && (
<div style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: 8, padding: '10px 14px', fontSize: 12.5, color: '#f87171' }}>
{error}
</div>
)}
{error && <ErrorNote>{error}</ErrorNote>}
<div ref={bottomRef} />
</div>
{/* Input */}
<div style={{
flexShrink: 0, display: 'flex', gap: 8, alignItems: 'flex-end',
background: '#17171c', border: '1px solid rgba(255,255,255,0.09)',
borderRadius: 10, padding: '10px 10px 10px 14px', marginTop: 4,
}}>
<div className="shrink-0 flex gap-2 items-end rounded-[10px] py-[10px] pr-[10px] pl-[14px] mt-1" style={{ background: 'var(--color-card)', border: '1px solid var(--color-line-2)' }}>
<textarea
ref={inputRef}
value={input}
onChange={e => {
setInput(e.target.value)
@@ -229,19 +235,15 @@ export default function Chat() {
e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px'
}}
onKeyDown={handleKey}
disabled={streaming || !model}
placeholder={model ? 'Message… (Enter to send, Shift+Enter for newline)' : 'No model selected'}
disabled={!model}
placeholder={model ? 'Message… (Enter to send, Shift+Enter for a new line)' : 'No model selected'}
rows={1}
style={{
flex: 1, background: 'transparent', border: 'none', outline: 'none',
resize: 'none', fontSize: 13.5, color: '#ededf0', lineHeight: 1.55,
fontFamily: 'inherit', padding: 0, minHeight: 24, maxHeight: 160, overflowY: 'auto',
}}
aria-label="Message"
className="flex-1 bg-transparent border-none outline-none resize-none text-[13.5px] text-ink leading-[1.55] p-0 min-h-6 max-h-40 overflow-y-auto placeholder:text-ink-4"
/>
{streaming
? <button onClick={() => abortRef.current?.abort()} style={{ background: 'rgba(239,68,68,0.15)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: 7, padding: '6px 12px', fontSize: 12, color: '#f87171', cursor: 'pointer', flexShrink: 0 }}>Stop</button>
: <button onClick={send} disabled={!input.trim() || !model} style={{ background: '#7c3aed', border: 'none', borderRadius: 7, padding: '6px 14px', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer', flexShrink: 0, opacity: !input.trim() || !model ? 0.4 : 1 }}>Send</button>
}
? <Button size="sm" variant="danger" onClick={() => abortRef.current?.abort()}>Stop</Button>
: <Button size="sm" variant="primary" onClick={send} disabled={!input.trim() || !model}>Send</Button>}
</div>
</div>
)
@@ -249,57 +251,26 @@ export default function Chat() {
function ThinkBlock({ text, streaming }: { text: string; streaming: boolean }) {
const [open, setOpen] = useState(true)
// Auto-collapse when streaming finishes
const wasStreaming = useRef(streaming)
useEffect(() => {
if (!streaming) setOpen(false)
if (wasStreaming.current && !streaming) setOpen(false)
wasStreaming.current = streaming
}, [streaming])
const wordCount = text.trim().split(/\s+/).length
const words = text.trim().split(/\s+/).filter(Boolean).length
return (
<div style={{
border: '1px solid rgba(251,191,36,0.2)',
borderRadius: 8,
background: 'rgba(251,191,36,0.04)',
overflow: 'hidden',
}}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 7,
padding: '7px 12px', background: 'none', border: 'none', cursor: 'pointer',
textAlign: 'left',
}}
>
<svg
width="11" height="11" viewBox="0 0 10 10" fill="none"
stroke="rgba(251,191,36,0.6)" strokeWidth="1.6" strokeLinecap="round"
style={{ flexShrink: 0, transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}
>
<polyline points="3,1 7,5 3,9"/>
<div className="rounded-lg overflow-hidden" style={{ border: '1px solid rgba(251,191,36,0.2)', background: 'rgba(251,191,36,0.04)' }}>
<button onClick={() => setOpen(o => !o)} aria-expanded={open} className="w-full flex items-center gap-[7px] px-3 py-[7px] bg-transparent border-none text-left">
<svg width="11" height="11" viewBox="0 0 10 10" fill="none" stroke="rgba(251,191,36,0.6)" strokeWidth="1.6" strokeLinecap="round"
style={{ flexShrink: 0, transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }} aria-hidden>
<polyline points="3,1 7,5 3,9" />
</svg>
<span style={{ fontSize: 11.5, fontWeight: 500, color: 'rgba(251,191,36,0.7)', flex: 1 }}>
{streaming ? 'Thinking…' : `Thought for ${wordCount} words`}
<span className="text-[11.5px] font-medium flex-1" style={{ color: 'rgba(251,191,36,0.75)' }}>
{streaming ? 'Thinking…' : `Reasoning · ${words} words`}
</span>
{streaming && (
<span style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
{[0,1,2].map(j => (
<span key={j} style={{ width: 4, height: 4, borderRadius: '50%', background: 'rgba(251,191,36,0.5)', display: 'inline-block' }}/>
))}
</span>
)}
</button>
{open && (
<div style={{
padding: '0 12px 10px',
fontSize: 12.5, lineHeight: 1.65,
color: 'rgba(251,191,36,0.55)',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
maxHeight: 320, overflowY: 'auto',
borderTop: '1px solid rgba(251,191,36,0.1)',
}}>
<div style={{ paddingTop: 10 }}>{text}</div>
<div className="px-3 pb-[10px] text-[12.5px] leading-[1.65] whitespace-pre-wrap break-words max-h-[320px] overflow-y-auto" style={{ color: 'rgba(251,191,36,0.6)', borderTop: '1px solid rgba(251,191,36,0.1)' }}>
<div className="pt-[10px]">{text}</div>
</div>
)}
</div>
@@ -307,20 +278,17 @@ function ThinkBlock({ text, streaming }: { text: string; streaming: boolean }) {
}
function StatsBar({ stats }: { stats: Stats }) {
const c = 'rgba(237,237,240,0.3)'
const items = [
{ label: 'prompt', value: stats.promptTokens.toLocaleString() + ' tok' },
{ label: 'completion', value: stats.completionTokens.toLocaleString() + ' tok' },
{ label: 'total', value: stats.totalTokens.toLocaleString() + ' tok' },
...(stats.tps > 0 ? [{ label: 'speed', value: stats.tps.toFixed(1) + ' tok/s' }] : []),
{ label: 'time', value: (stats.elapsedMs / 1000).toFixed(2) + 's' },
{ label: 'in', value: `${stats.promptTokens.toLocaleString()} tok` },
{ label: 'out', value: `${stats.completionTokens.toLocaleString()} tok` },
...(stats.tps > 0 ? [{ label: 'speed', value: `${stats.tps.toFixed(1)} tok/s` }] : []),
...(stats.ttftMs != null ? [{ label: 'first token', value: fmtMs(Math.round(stats.ttftMs)) }] : []),
{ label: 'total', value: fmtMs(Math.round(stats.elapsedMs)) },
]
return (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="flex gap-[10px] flex-wrap px-1">
{items.map(({ label, value }) => (
<span key={label} style={{ fontSize: 11, fontFamily: "'DM Mono',monospace", color: c }}>
<span style={{ color: 'rgba(237,237,240,0.18)' }}>{label} </span>{value}
</span>
<span key={label} className="num text-[11px] text-ink-3"><span className="text-ink-4">{label} </span>{value}</span>
))}
</div>
)

View File

@@ -1,60 +1,30 @@
import { useEffect } from 'react'
import { setToken } from '../lib/api'
const LOGO = (
<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', flexShrink: 0,
}}>
<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>
)
export default function Login({ onLogin }: { onLogin: () => void }) {
useEffect(() => {
const hash = new URLSearchParams(window.location.hash.slice(1))
const fragToken = hash.get('token')
if (fragToken) {
window.history.replaceState({}, '', '/')
setToken(decodeURIComponent(fragToken))
onLogin()
}
}, [])
import { rememberReturnPath } from '../lib/api'
import { Logo } from '../components/Layout'
import { ErrorNote } from '../components/ui'
export default function Login({ error }: { error?: string }) {
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 }}>
{LOGO}
<div className="min-h-full flex items-center justify-center p-4" style={{ background: 'var(--color-bg)' }}>
<div className="w-full max-w-[360px] rounded-xl p-7" style={{ background: 'var(--color-sidebar)', border: '1px solid var(--color-line)' }}>
<div className="flex items-center gap-3 mb-7">
<Logo size={34} />
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: '#f0eeff' }}>Local Model Routex</div>
<div style={{ fontSize: 11, color: 'rgba(237,237,240,0.35)' }}>Dashboard</div>
<div className="text-[14px] font-semibold text-ink">LMR</div>
<div className="text-[11px] text-ink-3">Local Model Route dashboard</div>
</div>
</div>
<a href="/auth/login" style={{ textDecoration: 'none', display: 'block' }}>
<button style={{
width: '100%', background: '#7c3aed', border: 'none',
borderRadius: 7, padding: '10px 0', fontSize: 13.5,
fontWeight: 500, color: 'white', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
<path d="M12 8v4l3 3"/>
</svg>
Continue with Authentik
</button>
{error && <div className="mb-4"><ErrorNote>{error}</ErrorNote></div>}
<a href="/auth/login" onClick={rememberReturnPath} className="btn btn-primary w-full py-[10px] text-[13.5px] no-underline hover:no-underline">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
</svg>
Sign in with SSO
</a>
<p className="text-[11px] text-ink-4 mt-4 leading-relaxed">
Access is granted through your organization's identity provider. Admin features require membership in the configured admin group.
</p>
</div>
</div>
)

View File

@@ -1,54 +1,102 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../lib/api'
import type { RequestLog } from '../lib/types'
import type { RequestLog, LogContent, ChatMessage } from '../lib/types'
import { fmtCost, fmtDateTime, fmtMs, fmtTime, fmtTokens } from '../lib/format'
import { Badge, Button, Card, Empty, ErrorNote, Loading, SearchBox, Segmented, StatusCode, TableHead } from '../components/ui'
const D = 'rgba(237,237,240,'
type Range = '1h' | '24h' | '7d' | '30d'
const RANGE_MS: Record<Range, number> = { '1h': 3600000, '24h': 86400000, '7d': 604800000, '30d': 2592000000 }
const PAGE_SIZE = 100
const COLS = '78px minmax(0,1fr) 110px 96px 84px 76px 64px 22px'
const RANGES = [
{ label: '1h', ms: 3600000 },
{ label: '24h', ms: 86400000 },
{ label: '7d', ms: 604800000 },
]
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
function roleTone(role: string) {
if (role === 'user') return 'accent'
if (role === 'assistant') return 'good'
if (role === 'tool') return 'warn'
return 'neutral'
}
function ContentDrawer({ log, loading }: { log: RequestLog; loading: boolean }) {
if (loading) {
return <div style={{ padding: '12px 16px', color: `${D}0.28)`, fontSize: 12 }}>Loading…</div>
}
if (!log.Messages && !log.Completion) {
return <div style={{ padding: '12px 16px', color: `${D}0.28)`, fontSize: 12 }}>No content logged</div>
function messageText(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content.map(p => {
if (p && typeof p === 'object' && 'type' in p) {
const part = p as { type: string; text?: string; image_url?: { url?: string } }
if (part.type === 'text') return part.text ?? ''
if (part.type === 'image_url') return `[image: ${part.image_url?.url ?? ''}]`
}
return JSON.stringify(p)
}).join('\n')
}
if (content == null) return ''
return JSON.stringify(content, null, 2)
}
/** Splits a stored completion into its think block and the visible answer. */
function splitThink(s: string): { reasoning: string; answer: string } {
const m = s.match(/^<think>\n?([\s\S]*?)\n?<\/think>\n*/)
if (!m) return { reasoning: '', answer: s }
return { reasoning: m[1], answer: s.slice(m[0].length) }
}
function Detail({ log, content, loading }: { log: RequestLog; content?: LogContent; loading: boolean }) {
const meta = [
['Request ID', log.RequestID || '—'],
['Streamed', log.Streamed ? 'yes' : 'no'],
['Prompt tokens', log.PromptTokens.toLocaleString()],
['Cached tokens', log.CachedTokens.toLocaleString()],
['Completion tokens', log.CompletionTokens.toLocaleString()],
['Cost', fmtCost(log.Cost)],
['Latency', fmtMs(log.LatencyMS)],
['First token', log.TTFTMs != null ? fmtMs(log.TTFTMs) : '—'],
]
const completion = content?.Completion ? splitThink(content.Completion) : null
const messages: ChatMessage[] = content?.Messages ?? []
return (
<div style={{ padding: '14px 18px', background: 'rgba(0,0,0,0.25)', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{log.Messages && log.Messages.length > 0 && (
<div style={{ marginBottom: log.Completion ? 14 : 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: `${D}0.32)`, textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 8 }}>Prompt</div>
<div style={{ display: 'flex', flexDirection: 'column' as const, gap: 6 }}>
{log.Messages.map((m, i) => (
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 3, whiteSpace: 'nowrap', flexShrink: 0, marginTop: 1,
background: m.role === 'user' ? 'rgba(124,58,237,0.15)' : m.role === 'assistant' ? 'rgba(34,197,94,0.1)' : 'rgba(255,255,255,0.06)',
color: m.role === 'user' ? '#a78bfa' : m.role === 'assistant' ? '#4ade80' : `${D}0.45)`,
}}>{m.role}</span>
<span style={{ fontSize: 12.5, color: `${D}0.72)`, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}
</span>
<div className="px-[18px] py-[14px] flex flex-col gap-4" style={{ background: 'rgba(0,0,0,0.25)', borderTop: '1px solid var(--color-line)' }}>
{log.Error && <ErrorNote><span className="font-medium">Error:</span> {log.Error}</ErrorNote>}
<div className="grid gap-x-6 gap-y-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))' }}>
{meta.map(([k, v]) => (
<div key={k}>
<div className="label mb-[2px]">{k}</div>
<div className="num text-xs text-ink-2 break-all">{v}</div>
</div>
))}
</div>
{loading && <Loading label="Loading content…" />}
{!loading && content && messages.length === 0 && !content.Completion && (
<div className="text-xs text-ink-3">No prompt or completion was recorded for this request.</div>
)}
{messages.length > 0 && (
<div>
<div className="label mb-2">Prompt · {messages.length} message{messages.length === 1 ? '' : 's'}</div>
<div className="flex flex-col gap-[6px]">
{messages.map((m, i) => (
<div key={i} className="flex gap-[10px] items-start">
<Badge tone={roleTone(m.role)} className="mt-[1px]">{m.role}</Badge>
<pre className="text-[12.5px] text-ink-2 leading-[1.6] whitespace-pre-wrap break-words font-sans flex-1 min-w-0 max-h-[320px] overflow-y-auto">{messageText(m.content)}</pre>
</div>
))}
</div>
</div>
)}
{log.Completion && (
{completion && (
<div>
<div style={{ fontSize: 10, fontWeight: 700, color: `${D}0.32)`, textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 8 }}>Completion</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 3, whiteSpace: 'nowrap', flexShrink: 0, marginTop: 1, background: 'rgba(34,197,94,0.1)', color: '#4ade80' }}>assistant</span>
<span style={{ fontSize: 12.5, color: `${D}0.72)`, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{log.Completion}</span>
<div className="label mb-2">Completion</div>
{completion.reasoning && (
<details className="mb-2 rounded-lg" style={{ border: '1px solid rgba(251,191,36,0.2)', background: 'rgba(251,191,36,0.04)' }}>
<summary className="cursor-pointer px-3 py-[6px] text-[11.5px]" style={{ color: 'var(--color-warn-2)' }}>Reasoning</summary>
<pre className="px-3 pb-3 text-xs leading-relaxed whitespace-pre-wrap break-words font-sans max-h-[320px] overflow-y-auto" style={{ color: 'rgba(251,191,36,0.6)' }}>{completion.reasoning}</pre>
</details>
)}
<div className="flex gap-[10px] items-start">
<Badge tone="good" className="mt-[1px]">assistant</Badge>
<pre className="text-[12.5px] text-ink-2 leading-[1.6] whitespace-pre-wrap break-words font-sans flex-1 min-w-0 max-h-[480px] overflow-y-auto">{completion.answer || <span className="text-ink-4">(empty)</span>}</pre>
</div>
</div>
)}
@@ -56,155 +104,132 @@ function ContentDrawer({ log, loading }: { log: RequestLog; loading: boolean })
)
}
const PAGE_SIZE = 100
export default function Logs() {
const [searchParams, setSearchParams] = useSearchParams()
const [params, setParams] = useSearchParams()
const [logs, setLogs] = useState<RequestLog[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [rangeIdx, setRangeIdx] = useState(1)
const [search, setSearch] = useState(() => searchParams.get('key') ?? '')
const [debouncedSearch, setDebouncedSearch] = useState(search)
const [error, setError] = useState('')
const [range, setRange] = useState<Range>(() => (params.get('range') as Range) || '24h')
const [onlyErrors, setOnlyErrors] = useState(params.get('status') === 'error')
const [search, setSearch] = useState(() => params.get('key') ?? params.get('q') ?? '')
const [debounced, setDebounced] = useState(search)
const [expanded, setExpanded] = useState<string | null>(null)
const [content, setContent] = useState<Record<string, Pick<RequestLog, 'Messages' | 'Completion'>>>({})
const [content, setContent] = useState<Record<string, LogContent>>({})
const [contentLoading, setContentLoading] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
// Debounce the search box so typing doesn't fire a request per keystroke.
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 300)
const t = setTimeout(() => setDebounced(search.trim()), 300)
return () => clearTimeout(t)
}, [search])
// Keep filters in the URL so links from other pages and reloads work.
useEffect(() => {
const next = new URLSearchParams()
if (debounced) next.set('q', debounced)
if (range !== '24h') next.set('range', range)
if (onlyErrors) next.set('status', 'error')
setParams(next, { replace: true })
}, [debounced, range, onlyErrors, setParams])
const filters = () => ({
since: new Date(Date.now() - RANGE_MS[range]).toISOString(),
q: debounced || undefined,
status: onlyErrors ? 'error' : undefined,
})
useEffect(() => {
let alive = true
setLoading(true)
api.logs.list({ since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString(), q: debouncedSearch || undefined })
.then(r => {
setLogs(r ?? [])
setHasMore((r ?? []).length === PAGE_SIZE)
})
.finally(() => setLoading(false))
}, [rangeIdx, debouncedSearch])
setError('')
api.logs.list(filters())
.then(r => { if (!alive) return; setLogs(r ?? []); setHasMore((r ?? []).length === PAGE_SIZE) })
.catch(e => alive && setError(e instanceof Error ? e.message : 'Failed to load'))
.finally(() => alive && setLoading(false))
return () => { alive = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [range, debounced, onlyErrors, refreshTick])
function loadMore() {
const last = logs[logs.length - 1]
if (!last) return
setLoadingMore(true)
api.logs.list({
since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString(),
before: last.CreatedAt,
beforeId: last.ID,
q: debouncedSearch || undefined,
})
.then(r => {
setLogs(prev => [...prev, ...(r ?? [])])
setHasMore((r ?? []).length === PAGE_SIZE)
})
api.logs.list({ ...filters(), before: last.CreatedAt, before_id: last.ID })
.then(r => { setLogs(prev => [...prev, ...(r ?? [])]); setHasMore((r ?? []).length === PAGE_SIZE) })
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load'))
.finally(() => setLoadingMore(false))
}
// Sync search → URL param so links from ApiKeys page work.
useEffect(() => {
if (search) setSearchParams({ key: search }, { replace: true })
else setSearchParams({}, { replace: true })
}, [search])
const visible = logs
function toggleExpand(id: string) {
if (expanded === id) {
setExpanded(null)
return
}
function toggle(id: string) {
if (expanded === id) { setExpanded(null); return }
setExpanded(id)
if (!content[id]) {
setContentLoading(id)
api.logs.content(id)
.then(c => setContent(prev => ({ ...prev, [id]: c })))
.catch(() => setContent(prev => ({ ...prev, [id]: { Messages: null, Completion: null } })))
.finally(() => setContentLoading(null))
}
}
const cols = '80px 1fr 110px 80px 90px 72px 58px 22px'
return (
<div className="animate-fade-up">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 2, background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 6, padding: 3 }}>
{RANGES.map((r, i) => (
<button key={r.label} onClick={() => setRangeIdx(i)} style={{
padding: '4px 13px', borderRadius: 4, fontSize: 12, fontWeight: 500, cursor: 'pointer', border: 'none',
background: i === rangeIdx ? '#7c3aed' : 'transparent',
color: i === rangeIdx ? 'white' : `${D}0.42)`,
}}>{r.label}</button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, background: '#17171c', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 6, padding: '7px 11px', flex: 1, maxWidth: 320 }}>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke={`${D}0.28)`} strokeWidth="2" 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, provider, key…"
style={{ background: 'none', border: 'none', outline: 'none', fontSize: 12.5, color: `${D}0.7)`, width: '100%' }}/>
</div>
<span style={{ marginLeft: 'auto', fontSize: 12, color: `${D}0.3)` }}>{visible.length} entries</span>
<div className="animate-fade-up flex flex-col gap-4">
<div className="flex items-center gap-2 flex-wrap">
<Segmented value={range} onChange={setRange}
options={[{ value: '1h', label: '1h' }, { value: '24h', label: '24h' }, { value: '7d', label: '7d' }, { value: '30d', label: '30d' }]} />
<SearchBox value={search} onChange={setSearch} placeholder="Filter by model, provider, key or request id…" className="flex-1 min-w-[200px] max-w-[360px]" />
<Button size="sm" variant={onlyErrors ? 'danger' : 'ghost'} onClick={() => setOnlyErrors(v => !v)} aria-pressed={onlyErrors}>
Errors only
</Button>
<Button size="sm" onClick={() => setRefreshTick(t => t + 1)} loading={loading && logs.length > 0}>Refresh</Button>
<span className="ml-auto text-xs text-ink-4">{logs.length}{hasMore ? '+' : ''} entries</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 16px', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
{['Time','Model','Provider','Key','Tokens','Latency','Status',''].map(h => (
<span key={h} style={{ fontSize: 10, fontWeight: 700, color: `${D}0.28)`, textTransform: 'uppercase', letterSpacing: '0.07em' }}>{h}</span>
))}
{error && <ErrorNote>{error}</ErrorNote>}
<Card className="overflow-hidden">
<div className="table-scroll">
<TableHead cols={COLS} labels={['Time', 'Model', 'Provider', 'Key', 'Tokens', 'Latency', 'Status', '']} />
{loading && logs.length === 0 && <Loading />}
{!loading && logs.length === 0 && <Empty>No requests match these filters.</Empty>}
<div style={{ opacity: loading && logs.length > 0 ? 0.5 : 1, transition: 'opacity 0.15s' }}>
{logs.map(l => {
const open = expanded === l.ID
return (
<div key={l.ID} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<div role="button" tabIndex={0} aria-expanded={open}
onClick={() => toggle(l.ID)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(l.ID) } }}
className="row row-hover cursor-pointer" style={{ gridTemplateColumns: COLS, borderBottom: 'none' }}>
<span className="num text-[11px] text-ink-3" title={fmtDateTime(l.CreatedAt)}>{fmtTime(l.CreatedAt)}</span>
<span className="mono text-[11.5px] text-accent-3 ellip pr-2" title={l.ModelName}>{l.ModelName}</span>
<span className="text-xs text-ink-2 ellip">{l.ProviderName}</span>
<span className="mono text-[11px] text-ink-3 ellip">{l.KeyPrefix || '—'}</span>
<span className="num text-[11px] text-ink-3" title={`${l.PromptTokens.toLocaleString()} in · ${l.CompletionTokens.toLocaleString()} out`}>{fmtTokens(l.TotalTokens)}</span>
<span className="num text-[11px] text-ink-3">{fmtMs(l.LatencyMS)}</span>
<div className="flex items-center gap-1">
<StatusCode code={l.StatusCode} />
{l.Error && l.StatusCode < 400 && <span title={l.Error} className="text-warn-2 text-[11px]">!</span>}
</div>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-ink-4 justify-self-center"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} aria-hidden>
<polyline points="2,4 6,8 10,4" />
</svg>
</div>
{open && <Detail log={l} content={content[l.ID]} loading={contentLoading === l.ID} />}
</div>
)
})}
</div>
</div>
{loading && <div style={{ padding: '20px 16px', color: `${D}0.3)`, fontSize: 13 }}>Loading…</div>}
{!loading && visible.length === 0 && <div style={{ padding: '20px 16px', color: `${D}0.3)`, fontSize: 13 }}>No requests yet</div>}
{visible.map(l => {
const isOpen = expanded === l.ID
const ok = l.StatusCode < 400
return (
<div key={l.ID} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<div
onClick={() => toggleExpand(l.ID)}
style={{ display: 'grid', gridTemplateColumns: cols, padding: '9px 16px', alignItems: 'center', cursor: 'pointer' }}
>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.38)` }}>{fmtTime(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.38)` }}>{l.KeyPrefix || '—'}</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>
<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 6px', borderRadius: 4, width: 'fit-content',
}}>{l.StatusCode}</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke={`${D}0.3)`} strokeWidth="1.5" strokeLinecap="round"
style={{ transform: isOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s', justifySelf: 'center' }}>
<polyline points="2,4 6,8 10,4"/>
</svg>
</div>
{isOpen && (
<ContentDrawer
log={content[l.ID] ? { ...l, ...content[l.ID] } : l}
loading={contentLoading === l.ID}
/>
)}
</div>
)
})}
{!loading && hasMore && (
<div style={{ padding: '12px 16px', textAlign: 'center' as const }}>
<button onClick={loadMore} disabled={loadingMore} style={{
padding: '6px 16px', borderRadius: 6, fontSize: 12, fontWeight: 500, cursor: loadingMore ? 'default' : 'pointer',
border: '1px solid rgba(255,255,255,0.08)', background: 'transparent', color: `${D}0.5)`,
}}>{loadingMore ? 'Loading…' : 'Load more'}</button>
<div className="p-3 text-center">
<Button size="sm" onClick={loadMore} loading={loadingMore}>Load more</Button>
</div>
)}
</div>
</Card>
</div>
)
}

View File

@@ -1,139 +1,206 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../lib/api'
import type { AdminModel } from '../lib/types'
import type { AdminModel, ModelOverride } from '../lib/types'
import { ago, fmtContext } from '../lib/format'
import { Badge, Button, Card, Checkbox, Empty, ErrorNote, Field, Input, Loading, Modal, SearchBox, Segmented, Select, useToast } from '../components/ui'
const D = 'rgba(237,237,240,'
type Filter = 'all' | 'live' | 'offline' | 'blocked' | 'overridden'
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)
function hasOverride(m: AdminModel) {
return m.price_input_per_mtok != null || m.price_output_per_mtok != null || m.price_cached_per_mtok != null || m.rpm_limit != null || m.tpm_limit != null
}
function price(v: number | null | undefined) {
return v == null ? '' : String(v)
}
/** Parses an override field: empty clears, a number sets, anything else is an error. */
function parseField(raw: string, int = false): number | null | 'invalid' {
const s = raw.trim()
if (s === '') return null
const n = int ? parseInt(s, 10) : parseFloat(s)
if (!Number.isFinite(n) || n < 0) return 'invalid'
return n
}
function OverrideModal({ model, onClose, onSaved }: { model: AdminModel; onClose: () => void; onSaved: () => void }) {
const [inPrice, setInPrice] = useState(price(model.price_input_per_mtok))
const [outPrice, setOutPrice] = useState(price(model.price_output_per_mtok))
const [cachedPrice, setCachedPrice] = useState(price(model.price_cached_per_mtok))
const [rpm, setRpm] = useState(price(model.rpm_limit))
const [tpm, setTpm] = useState(price(model.tpm_limit))
const [enabled, setEnabled] = useState(model.enabled)
const [blocked, setBlocked] = useState(model.blocked)
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
async function save(e: React.FormEvent) {
e.preventDefault()
setErr('')
const fields = {
input_price_per_mtok: parseField(inPrice),
output_price_per_mtok: parseField(outPrice),
cached_price_per_mtok: parseField(cachedPrice),
rpm_limit: parseField(rpm, true),
tpm_limit: parseField(tpm, true),
}
if (Object.values(fields).includes('invalid')) {
setErr('Prices and limits must be empty or a number of 0 or more.')
return
}
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) }
try {
await api.models.update(model.id, { enabled, blocked, ...(fields as Partial<ModelOverride>) })
onSaved()
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
const discIn = model.disc_input_per_mtok, discOut = model.disc_output_per_mtok
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>
<Modal title={model.display_name || model.slug} sub={`${model.provider_name}/${model.slug}`} onClose={onClose}>
<form onSubmit={save} className="flex flex-col gap-4">
<div className="text-[11.5px] text-ink-3 leading-relaxed">
{discIn != null || discOut != null
? <>Provider-reported price: <span className="num text-ink-2">${discIn ?? '?'}</span> in / <span className="num text-ink-2">${discOut ?? '?'}</span> out per 1M tokens. Overrides below replace it. Leave a field empty to use the reported price.</>
: <>This provider does not report prices. Set them here so requests are billed. Empty means free.</>}
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Input $/1M tokens"><Input mono inputMode="decimal" value={inPrice} onChange={e => setInPrice(e.target.value)} placeholder={discIn != null ? String(discIn) : '0'} /></Field>
<Field label="Output $/1M tokens"><Input mono inputMode="decimal" value={outPrice} onChange={e => setOutPrice(e.target.value)} placeholder={discOut != null ? String(discOut) : '0'} /></Field>
<Field label="Cached input $/1M" hint="Falls back to the input price."><Input mono inputMode="decimal" value={cachedPrice} onChange={e => setCachedPrice(e.target.value)} placeholder="same as input" /></Field>
<div />
<Field label="Requests / minute"><Input mono inputMode="numeric" value={rpm} onChange={e => setRpm(e.target.value)} placeholder="unlimited" /></Field>
<Field label="Tokens / minute"><Input mono inputMode="numeric" value={tpm} onChange={e => setTpm(e.target.value)} placeholder="unlimited" /></Field>
</div>
<div className="flex gap-5">
<Checkbox label="Enabled" checked={enabled} onChange={setEnabled} />
<Checkbox label="Blocked" tone="bad" checked={blocked} onChange={setBlocked} />
</div>
{err && <ErrorNote>{err}</ErrorNote>}
<div className="flex gap-2 pt-1">
<Button type="button" className="flex-1" onClick={onClose}>Cancel</Button>
<Button type="submit" variant="primary" className="flex-1" loading={loading}>Save</Button>
</div>
</form>
</Modal>
)
}
export default function Models() {
const toast = useToast()
const [params, setParams] = useSearchParams()
const [models, setModels] = useState<AdminModel[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<'all' | 'available' | 'unavailable'>('all')
const [error, setError] = useState('')
const [search, setSearch] = useState(params.get('q') ?? '')
const [provider, setProvider] = useState(params.get('provider') ?? '')
const [filter, setFilter] = useState<Filter>('all')
const [selected, setSelected] = useState<AdminModel | null>(null)
const [syncing, setSyncing] = useState(false)
function load() {
setLoading(true)
api.models.list().then(r => setModels(r ?? [])).finally(() => setLoading(false))
api.models.list()
.then(r => setModels(r ?? []))
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load'))
.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
})
useEffect(() => {
const next = new URLSearchParams()
if (search) next.set('q', search)
if (provider) next.set('provider', provider)
setParams(next, { replace: true })
}, [search, provider, setParams])
const providers = useMemo(() => Array.from(new Set(models.map(m => m.provider_name))).sort(), [models])
const visible = useMemo(() => {
const q = search.trim().toLowerCase()
return models.filter(m => {
if (provider && m.provider_name !== provider) return false
if (q && !`${m.provider_name}/${m.slug} ${m.display_name}`.toLowerCase().includes(q)) return false
switch (filter) {
case 'live': return m.available && m.enabled && !m.blocked
case 'offline': return !m.available
case 'blocked': return m.blocked || !m.enabled
case 'overridden': return hasOverride(m)
}
return true
})
}, [models, search, provider, filter])
async function sync() {
setSyncing(true)
try {
const r = await api.catalog.refresh()
toast(`Catalog synced: ${r.models} models servable`, 'good')
load()
} catch (e) {
toast(e instanceof Error ? e.message : 'Sync failed', 'bad')
} finally {
setSyncing(false)
}
}
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 className="animate-fade-up flex flex-col gap-4">
<div className="flex items-center gap-2 flex-wrap">
<SearchBox value={search} onChange={setSearch} placeholder="Search models…" className="flex-1 min-w-[180px] max-w-[320px]" />
<Select value={provider} onChange={e => setProvider(e.target.value)} className="w-auto" style={{ width: 'auto' }}>
<option value="">All providers</option>
{providers.map(p => <option key={p} value={p}>{p}</option>)}
</Select>
<Segmented value={filter} onChange={setFilter} options={[
{ value: 'all', label: 'All' }, { value: 'live', label: 'Live' }, { value: 'offline', label: 'Offline' },
{ value: 'blocked', label: 'Blocked' }, { value: 'overridden', label: 'Overridden' },
]} />
<div className="ml-auto flex items-center gap-3">
<span className="text-xs text-ink-4">{visible.length} of {models.length}</span>
<Button size="sm" onClick={sync} loading={syncing}>Sync now</Button>
</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>
{error && <ErrorNote>{error}</ErrorNote>}
{loading && <Loading />}
{!loading && models.length === 0 && <Card><Empty>No models discovered yet. Add a provider, then sync.</Empty></Card>}
{!loading && models.length > 0 && visible.length === 0 && <Card><Empty>No models match these filters.</Empty></Card>}
<div className="grid gap-[10px]" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))' }}>
{visible.map(m => {
const state = m.blocked ? { label: 'blocked', tone: 'bad' as const } : !m.enabled ? { label: 'disabled', tone: 'neutral' as const }
: m.available ? { label: 'live', tone: 'good' as const } : { label: 'offline', tone: 'neutral' as const }
return (
<button key={m.id} onClick={() => setSelected(m)} className="card text-left p-[14px] flex flex-col gap-2 hover:border-line-2 transition-colors"
style={{ borderColor: m.blocked ? 'rgba(239,68,68,0.3)' : undefined, opacity: m.available || m.blocked ? 1 : 0.6, borderRadius: 9 }}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="mono text-[12.5px] font-medium text-accent-3 ellip" title={m.slug}>{m.slug}</div>
<div className="text-[11px] text-ink-3 ellip mt-[2px]">{m.display_name || <span className="text-ink-4">no display name</span>}</div>
</div>
<Badge tone={state.tone}>{state.label}</Badge>
</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 className="flex items-center justify-between gap-2 text-[10.5px] text-ink-4">
<Badge tone="accent">{m.provider_name}</Badge>
<span className="flex items-center gap-2">
{hasOverride(m) && <span title="Has price or limit overrides" className="text-warn-2">override</span>}
{m.context_length ? <span>{fmtContext(m.context_length)}</span> : null}
{!m.available && m.last_seen_at && <span title={`Last seen ${new Date(m.last_seen_at).toLocaleString()}`}>seen {ago(m.last_seen_at)}</span>}
</span>
</div>
</button>
)
})}
</div>
{selected && <OverrideModal model={selected} onClose={r => { setSelected(null); if (r) load() }} />}
{selected && <OverrideModal model={selected} onClose={() => setSelected(null)} onSaved={() => { toast('Model updated', 'good'); load() }} />}
</div>
)
}

View File

@@ -1,122 +1,97 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import type { Provider, RequestLog, LogsSummary } from '../lib/types'
import type { Provider, RequestLog, LogsSummary, CurrentUser } from '../lib/types'
import { ago, fmtCost, fmtMs, fmtPct, fmtTokens } from '../lib/format'
import { Badge, Card, CardHead, Dot, Empty, ErrorNote, StatCard, StatusCode, TableHead } from '../components/ui'
const D = 'rgba(237,237,240,'
const COLS = '96px minmax(0,1fr) 110px 76px 74px 60px'
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[]>([])
export default function Overview({ currentUser }: { currentUser: CurrentUser }) {
const [providers, setProviders] = useState<Provider[] | null>(null)
const [logs, setLogs] = useState<RequestLog[]>([])
const [summary, setSummary] = useState<LogsSummary | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
const since = new Date(Date.now() - 86400000).toISOString()
Promise.all([
api.providers.list(),
api.logs.list({ since, limit: 8 }),
api.stats.summary(since),
])
.then(([p, l, s]) => { setProviders(p ?? []); setLogs(l ?? []); setSummary(s) })
const tasks: Promise<void>[] = [
api.logs.list({ since, limit: 8 }).then(l => setLogs(l ?? [])),
api.stats.summary(since).then(setSummary),
]
// Provider configuration is admin-only. Members get a usage-only overview.
if (currentUser.is_admin) tasks.push(api.providers.list().then(p => setProviders(p ?? [])))
Promise.all(tasks)
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load'))
.finally(() => setLoading(false))
}, [])
}, [currentUser.is_admin])
const totalReqs = summary?.requests ?? 0
const totalCost = summary?.total_cost ?? 0
const errRate = totalReqs ? Math.round((summary?.errors ?? 0) / 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: '' },
]
const reqs = summary?.requests ?? 0
const errs = summary?.errors ?? 0
const liveProviders = providers?.filter(p => p.enabled && p.live).length ?? 0
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 className="animate-fade-up flex flex-col gap-4">
{error && <ErrorNote>{error}</ErrorNote>}
<div className="grid-stats">
<StatCard label="Requests (24h)" value={reqs.toLocaleString()} loading={loading}
sub={summary ? `${fmtTokens(summary.total_tokens)} tokens` : undefined} />
<StatCard label="Cost (24h)" value={fmtCost(summary?.total_cost)} loading={loading}
sub={summary ? `avg ${fmtMs(summary.avg_latency_ms)} latency` : undefined} />
<StatCard label="Error rate (24h)" value={fmtPct(errs, reqs)} loading={loading} tone={errs > 0 ? 'bad' : undefined}
sub={`${errs.toLocaleString()} failed`} />
{currentUser.is_admin
? <StatCard label="Providers" value={providers ? `${liveProviders}/${providers.length}` : '—'} loading={loading}
sub={providers ? `${providers.reduce((n, p) => n + p.available_count, 0).toLocaleString()} models available` : undefined}
tone={providers && liveProviders < providers.filter(p => p.enabled).length ? 'bad' : undefined} />
: <StatCard label="Avg latency (24h)" value={fmtMs(summary?.avg_latency_ms)} loading={loading} sub="end to end, your requests" />}
</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 className="grid-main" style={currentUser.is_admin ? undefined : { gridTemplateColumns: 'minmax(0,1fr)' }}>
<Card className="overflow-hidden">
<CardHead title="Recent requests" right={<Link to="/logs" className="text-xs">View all →</Link>} />
<div className="table-scroll">
<TableHead cols={COLS} labels={['Time', 'Model', 'Provider', 'Tokens', 'Latency', 'Status']} />
{!loading && logs.length === 0 && <Empty>No requests in the last 24 hours.</Empty>}
{logs.map(l => (
<div key={l.ID} className="row row-hover" style={{ gridTemplateColumns: COLS }}>
<span className="num text-[11px] text-ink-3" title={new Date(l.CreatedAt).toLocaleString()}>{ago(l.CreatedAt)}</span>
<span className="mono text-[11.5px] text-accent-3 ellip pr-2" title={l.ModelName}>{l.ModelName}</span>
<span className="text-xs text-ink-2 ellip">{l.ProviderName}</span>
<span className="num text-[11px] text-ink-3">{fmtTokens(l.TotalTokens)}</span>
<span className="num text-[11px] text-ink-3">{fmtMs(l.LatencyMS)}</span>
<StatusCode code={l.StatusCode} />
</div>
))}
</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>
</Card>
{/* 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>
{currentUser.is_admin && (
<Card className="overflow-hidden">
<CardHead title="Providers" right={<Link to="/providers" className="text-xs">Manage →</Link>} />
{providers && providers.length === 0 && (
<Empty>No providers configured. <Link to="/providers">Add one →</Link></Empty>
)}
{providers?.map(p => {
const tone = !p.enabled ? 'neutral' : p.live ? 'good' : 'bad'
return (
<div key={p.id} className="row" style={{ gridTemplateColumns: 'auto minmax(0,1fr) auto', gap: 8, padding: '10px 16px' }}>
<Dot tone={tone} pulse={tone === 'good'} />
<div className="min-w-0">
<div className="text-[12.5px] text-ink ellip">{p.name}</div>
<div className="text-[10.5px] text-ink-4">
{!p.enabled ? 'disabled' : !p.live ? 'failed to start' : `${p.available_count} models`}
{p.last_synced_at && p.enabled ? ` · synced ${ago(p.last_synced_at)}` : ''}
</div>
</div>
<Badge tone="accent">{p.type}</Badge>
</div>
)
})}
</Card>
)}
</div>
</div>
)

View File

@@ -1,146 +1,201 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import type { Provider } from '../lib/types'
import { ago } from '../lib/format'
import {
Badge, Button, Card, ConfirmDialog, Dot, Empty, ErrorNote, Field, IconButton, Input, Loading, Modal, Select, Toggle, useToast,
} from '../components/ui'
const D = 'rgba(237,237,240,'
interface TypeInfo {
label: string
credential: string | null // label for the credential field, null = none
credentialHint?: string
defaultBase: string
baseHint?: string
extraSettings?: { key: string; label: string; placeholder: string }[]
}
const TYPES = ['openai','openrouter','anthropic','llamacpp','ollama','github_copilot']
const TYPES: Record<string, TypeInfo> = {
openai: { label: 'OpenAI', credential: 'API key', defaultBase: 'https://api.openai.com/v1' },
openrouter: { label: 'OpenRouter', credential: 'API key', defaultBase: 'https://openrouter.ai/api/v1',
extraSettings: [{ key: 'site_url', label: 'Site URL (attribution)', placeholder: 'https://example.com' }, { key: 'app_name', label: 'App name (attribution)', placeholder: 'LMR' }] },
anthropic: { label: 'Anthropic', credential: 'API key', defaultBase: 'https://api.anthropic.com' },
llamacpp: { label: 'llama.cpp / llama-swap', credential: 'API key (optional)', credentialHint: 'Only when the server runs with --api-key.', defaultBase: 'http://localhost:8080/v1', baseHint: 'Include the /v1 suffix.' },
ollama: { label: 'Ollama', credential: 'API key (optional)', defaultBase: 'http://localhost:11434/v1', baseHint: 'Include the /v1 suffix.' },
github_copilot:{ label: 'GitHub Copilot', credential: 'GitHub token', credentialHint: 'A GitHub token with Copilot access. It is exchanged for a short-lived Copilot token on each request.', defaultBase: 'https://api.githubcopilot.com' },
}
function AddModal({ onClose }: { onClose: (refresh: boolean) => void }) {
const [form, setForm] = useState({ name: '', type: 'openrouter', base_url: '', api_key: '', enabled: true })
function ProviderModal({ existing, onClose, onSaved }: { existing?: Provider; onClose: () => void; onSaved: () => void }) {
const [name, setName] = useState(existing?.name ?? '')
const [type, setType] = useState(existing?.type ?? 'openrouter')
const [baseUrl, setBaseUrl] = useState(existing?.base_url ?? '')
const [secret, setSecret] = useState('')
const [settings, setSettings] = useState<Record<string, string>>(existing?.config ?? {})
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
function set(k: string, v: string | boolean) { setForm(f => ({ ...f, [k]: v })) }
const info = TYPES[type] ?? TYPES.openai
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)
const credentials: Record<string, string> = secret.trim() ? { api_key: secret.trim() } : {}
const config = Object.fromEntries(Object.entries(settings).filter(([, v]) => v.trim()))
if (existing) {
await api.providers.update(existing.id, { base_url: baseUrl.trim(), credentials, config })
} else {
await api.providers.create({ name: name.trim(), type, base_url: baseUrl.trim(), credentials, config, enabled: true })
}
onSaved()
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} 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>
<Modal title={existing ? `Edit ${existing.name}` : 'Add provider'} sub={existing ? TYPES[existing.type]?.label : undefined} onClose={onClose}>
<form onSubmit={submit} className="flex flex-col gap-4">
{!existing && <>
<Field label="Type">
<Select value={type} onChange={e => setType(e.target.value)}>
{Object.entries(TYPES).map(([k, t]) => <option key={k} value={k}>{t.label}</option>)}
</Select>
</Field>
<Field label="Name" hint={<>Becomes the address prefix: <code className="mono">{name || 'name'}/&lt;model&gt;</code>. Letters, digits, '-', '_' and '.' only.</>}>
<Input value={name} onChange={e => setName(e.target.value)} required autoFocus placeholder={type === 'openrouter' ? 'openrouter' : type === 'llamacpp' ? 'local' : type} pattern="[A-Za-z0-9._-]+" />
</Field>
</>}
<Field label="Base URL" hint={info.baseHint ?? `Leave empty for the default: ${info.defaultBase}`}>
<Input mono value={baseUrl} onChange={e => setBaseUrl(e.target.value)} placeholder={info.defaultBase} />
</Field>
{info.credential && (
<Field label={existing ? `${info.credential} (leave empty to keep)` : info.credential} hint={info.credentialHint}>
<Input mono type="password" value={secret} onChange={e => setSecret(e.target.value)} placeholder={existing?.has_credentials ? '••••••••' : ''} autoComplete="off" />
</Field>
)}
{info.extraSettings?.map(s => (
<Field key={s.key} label={s.label}>
<Input value={settings[s.key] ?? ''} onChange={e => setSettings(prev => ({ ...prev, [s.key]: e.target.value }))} placeholder={s.placeholder} />
</Field>
))}
{err && <ErrorNote>{err}</ErrorNote>}
<div className="flex gap-2 pt-1">
<Button type="button" className="flex-1" onClick={onClose}>Cancel</Button>
<Button type="submit" variant="primary" className="flex-1" loading={loading}>{existing ? 'Save' : 'Add provider'}</Button>
</div>
</form>
</Modal>
)
}
export default function Providers() {
const toast = useToast()
const [providers, setProviders] = useState<Provider[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [error, setError] = useState('')
const [modal, setModal] = useState<'add' | Provider | null>(null)
const [deleting, setDeleting] = useState<Provider | null>(null)
const [busy, setBusy] = useState<string | null>(null)
function load() {
setLoading(true)
api.providers.list().then(r => setProviders(r ?? [])).finally(() => setLoading(false))
api.providers.list()
.then(r => setProviders(r ?? []))
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load'))
.finally(() => setLoading(false))
}
useEffect(load, [])
async function del(id: string, name: string) {
if (!confirm(`Delete provider "${name}"?`)) return
await api.providers.delete(id)
load()
async function toggle(p: Provider, enabled: boolean) {
setBusy(p.id)
try {
await api.providers.update(p.id, { enabled })
toast(`${p.name} ${enabled ? 'enabled' : 'disabled'}`, 'good')
load()
} catch (e) {
toast(e instanceof Error ? e.message : 'Update failed', 'bad')
} finally {
setBusy(null)
}
}
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>
<div className="animate-fade-up flex flex-col gap-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<p className="text-[13px] text-ink-2 leading-relaxed max-w-[560px]">
Upstream inference backends. Each provider's models are discovered on a schedule and exposed as <code className="mono text-accent-3">&lt;name&gt;/&lt;model&gt;</code>.
</p>
<Button variant="primary" onClick={() => setModal('add')}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden><line x1="6" y1="1" x2="6" y2="11" /><line x1="1" y1="6" x2="11" y2="6" /></svg>
Add provider
</button>
</Button>
</div>
{loading && <div style={{ color: `${D}0.3)` }}>Loading…</div>}
{error && <ErrorNote>{error}</ErrorNote>}
{loading && <Loading />}
{!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>
<Card><Empty action={<Button variant="primary" onClick={() => setModal('add')}>Add your first provider</Button>}>No providers configured yet.</Empty></Card>
)}
<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 className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}>
{providers.map(p => {
const tone = !p.enabled ? 'neutral' : p.live ? 'good' : 'bad'
const status = !p.enabled ? 'disabled' : p.live ? 'running' : 'failed to start — check credentials'
return (
<Card key={p.id} className="p-[18px] flex flex-col gap-3" style={{ opacity: p.enabled ? 1 : 0.7 }}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[14px] font-semibold text-ink ellip">{p.name}</span>
<Badge tone="accent">{TYPES[p.type]?.label ?? p.type}</Badge>
</div>
<div className="flex items-center gap-[6px] text-[11.5px] text-ink-3">
<Dot tone={tone} pulse={tone === 'good'} />{status}
</div>
</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>
<Toggle checked={p.enabled} onChange={v => toggle(p, v)} disabled={busy === p.id} title={p.enabled ? 'Disable provider' : 'Enable provider'} />
</div>
<div className="mono text-[10.5px] text-ink-3 ellip rounded-[5px] px-[10px] py-[7px]" style={{ background: 'rgba(0,0,0,0.28)', border: '1px solid var(--color-line)' }} title={p.base_url || TYPES[p.type]?.defaultBase}>
{p.base_url || <span className="text-ink-4">{TYPES[p.type]?.defaultBase} (default)</span>}
</div>
<div className="flex items-center justify-between gap-2 text-[11.5px] text-ink-3">
<span>
{p.has_credentials ? 'Credentials set' : TYPES[p.type]?.credential ? <span className="text-warn-2">No credentials</span> : 'No credentials needed'}
</span>
<Link to={`/models?provider=${encodeURIComponent(p.name)}`} className="text-xs">
{p.available_count}/{p.model_count} models
</Link>
</div>
<div className="flex items-center justify-between gap-2 pt-1" style={{ borderTop: '1px solid var(--color-line)' }}>
<span className="text-[10.5px] text-ink-4">{p.last_synced_at ? `synced ${ago(p.last_synced_at)}` : 'not synced yet'}</span>
<div className="flex gap-[5px]">
<IconButton title="Edit" onClick={() => setModal(p)}>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M11.5 2.5l2 2L5 13H3v-2z" /></svg>
</IconButton>
<IconButton title="Delete" danger onClick={() => setDeleting(p)}>
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden><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>
</IconButton>
</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>
))}
</Card>
)
})}
</div>
{showModal && <AddModal onClose={r => { setShowModal(false); if (r) load() }} />}
{modal === 'add' && <ProviderModal onClose={() => setModal(null)} onSaved={() => { toast('Provider added. Syncing models…', 'good'); load() }} />}
{modal && modal !== 'add' && <ProviderModal existing={modal} onClose={() => setModal(null)} onSaved={() => { toast('Provider updated', 'good'); load() }} />}
{deleting && (
<ConfirmDialog title={`Delete ${deleting.name}`} danger confirmLabel="Delete" onClose={() => setDeleting(null)}
body={<>All {deleting.model_count} catalog entries and their overrides for <span className="text-ink">{deleting.name}</span> will be removed. Request logs are kept. Keys with rules for <span className="mono text-ink">{deleting.name}/*</span> will stop matching.</>}
onConfirm={async () => { await api.providers.delete(deleting.id); toast('Provider deleted', 'good'); load() }} />
)}
</div>
)
}

View File

@@ -1,88 +1,95 @@
import { useEffect, useState } from 'react'
import { api } from '../lib/api'
const D = 'rgba(237,237,240,'
import { Button, Card, CardHead, ErrorNote, Loading, useToast } from '../components/ui'
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' },
{ value: 'round_robin', label: 'Round robin', desc: 'Alternate between every provider that serves the requested model.' },
{ value: 'lowest_cost', label: 'Lowest cost', desc: 'Always pick the provider with the lowest input + output price. Local providers with no price win.' },
]
const EXAMPLES = [
{ addr: 'openrouter/openai/gpt-4o', desc: 'Direct. Always routed to the provider named "openrouter".' },
{ addr: 'local/llama3.1:8b', desc: 'Direct. Always routed to the provider named "local".' },
{ addr: 'any/gpt-4o', desc: 'Cross-provider. Matches every provider whose model slug is "gpt-4o" or ends in "/gpt-4o".' },
]
export default function Routing() {
const toast = useToast()
const [strategy, setStrategy] = useState('round_robin')
const [saved, setSaved] = useState('round_robin')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
api.settings.get()
.then(s => { if (s['routing.strategy']) setStrategy(s['routing.strategy']) })
.then(s => {
const v = STRATEGIES.some(x => x.value === s['routing.strategy']) ? s['routing.strategy']! : 'round_robin'
setStrategy(v); setSaved(v)
})
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load'))
.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)
try {
await api.settings.put('routing.strategy', strategy)
setSaved(strategy)
toast('Routing strategy saved', 'good')
} catch (e) {
toast(e instanceof Error ? e.message : 'Save failed', 'bad')
} finally {
setSaving(false)
}
}
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/&lt;slug&gt;</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.
<div className="animate-fade-up flex flex-col gap-4 max-w-[640px]">
<p className="text-[13px] text-ink-2 leading-relaxed">
Requests name a model as <code className="mono text-accent-3">&lt;provider&gt;/&lt;model&gt;</code>. The reserved prefix <code className="mono text-accent-3">any/</code> lets
the gateway choose among every provider that serves that model. The strategy below decides how.
</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>
{error && <ErrorNote>{error}</ErrorNote>}
{/* 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>
<Card className="overflow-hidden">
<CardHead title="Strategy for any/* addresses" />
{loading ? <Loading /> : (
<div className="p-4 flex flex-col gap-[10px]">
{STRATEGIES.map(s => {
const on = strategy === s.value
return (
<label key={s.value} className="flex items-start gap-3 px-[14px] py-3 rounded-lg cursor-pointer transition-colors"
style={{ border: `1px solid ${on ? 'rgba(124,58,237,0.45)' : 'var(--color-line)'}`, background: on ? 'rgba(124,58,237,0.08)' : 'transparent' }}>
<input type="radio" name="strategy" value={s.value} checked={on} onChange={() => setStrategy(s.value)} className="mt-[3px]" style={{ accentColor: 'var(--color-accent)' }} />
<div>
<div className="text-[13px] font-medium text-ink mb-[3px]">{s.label}</div>
<div className="text-xs text-ink-3 leading-relaxed">{s.desc}</div>
</div>
</label>
)
})}
</div>
)}
</Card>
<Card className="overflow-hidden">
<CardHead title="Address examples" />
<div className="px-5 py-4 grid gap-x-4 gap-y-3 items-start" style={{ gridTemplateColumns: 'auto minmax(0,1fr)' }}>
{EXAMPLES.map(r => (
<div key={r.addr} className="contents">
<code className="mono text-xs text-accent-3 px-2 py-[3px] rounded w-fit" style={{ background: 'rgba(124,58,237,0.1)' }}>{r.addr}</code>
<span className="text-xs text-ink-3 leading-relaxed pt-[3px]">{r.desc}</span>
</div>
))}
</div>
</div>
</Card>
<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 className="flex justify-end">
<Button variant="primary" onClick={save} loading={saving} disabled={loading || strategy === saved}>
{strategy === saved ? 'Saved' : 'Save changes'}
</Button>
</div>
</div>
)

View File

@@ -1,149 +1,106 @@
import { useState, useEffect } from 'react'
import { api } from '../lib/api'
import { Button, Card, CardHead, ErrorNote, Field, Input, Loading, Select, useToast } from '../components/ui'
const D = 'rgba(237,237,240,'
function Section({ title, children }: { title: string; 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)' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff' }}>{title}</span>
</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: React.CSSProperties = {
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',
boxSizing: 'border-box',
}
const RETENTION_OPTIONS = [
{ label: '7 days', value: 7 },
{ label: '30 days', value: 30 },
{ label: '90 days', value: 90 },
{ label: '180 days', value: 180 },
{ label: '1 year', value: 365 },
{ label: '2 years', value: 730 },
{ label: 'Indefinite',value: 0 },
const RETENTION = [
{ label: '7 days', value: 7 }, { label: '30 days', value: 30 }, { label: '90 days', value: 90 },
{ label: '180 days', value: 180 }, { label: '1 year', value: 365 }, { label: '2 years', value: 730 }, { label: 'Keep forever', value: 0 },
]
const LOG_LEVELS = ['debug', 'info', 'warn', 'error']
export default function Settings() {
const [name, setName] = useState('Local Model Routex')
const [logLevel, setLogLevel] = useState('info')
const [retentionDays, setRetentionDays] = useState(365)
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState('')
interface Form { name: string; logLevel: string; retention: number }
const [syncLoading, setSyncLoading] = useState(false)
const [syncMsg, setSyncMsg] = useState('')
export default function Settings() {
const toast = useToast()
const [form, setForm] = useState<Form>({ name: 'LMR', logLevel: 'info', retention: 365 })
const [saved, setSaved] = useState<Form | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [syncing, setSyncing] = useState(false)
useEffect(() => {
api.settings.get().then(s => {
if (s['app.name']) setName(s['app.name'])
if (s['app.log_level']) setLogLevel(s['app.log_level'])
if (s['log.retention_days'] !== undefined) setRetentionDays(Number(s['log.retention_days']))
}).catch(() => {})
const f: Form = {
name: s['app.name'] || 'LMR',
logLevel: s['app.log_level'] || 'info',
retention: s['log.retention_days'] ?? 365,
}
setForm(f); setSaved(f)
}).catch(e => setError(e instanceof Error ? e.message : 'Failed to load'))
.finally(() => setLoading(false))
}, [])
function markDirty() { setDirty(true); setSaveMsg('') }
const dirty = saved !== null && (form.name !== saved.name || form.logLevel !== saved.logLevel || form.retention !== saved.retention)
async function save() {
if (!saved) return
setSaving(true)
setSaveMsg('')
try {
await Promise.all([
api.settings.put('app.name', name),
api.settings.put('app.log_level', logLevel),
api.settings.put('log.retention_days', retentionDays),
])
setDirty(false)
setSaveMsg('Saved')
} catch (e: any) {
setSaveMsg('Error: ' + e.message)
const tasks: Promise<void>[] = []
if (form.name !== saved.name) tasks.push(api.settings.put('app.name', form.name.trim()))
if (form.logLevel !== saved.logLevel) tasks.push(api.settings.put('app.log_level', form.logLevel))
if (form.retention !== saved.retention) tasks.push(api.settings.put('log.retention_days', form.retention))
await Promise.all(tasks)
setSaved(form)
toast(form.name !== saved.name ? 'Saved. The new name shows after a reload.' : 'Settings saved', 'good')
} catch (e) {
toast(e instanceof Error ? e.message : 'Save failed', 'bad')
} finally {
setSaving(false)
}
}
async function forceSync() {
setSyncLoading(true)
setSyncMsg('')
async function sync() {
setSyncing(true)
try {
const r = await api.catalog.refresh()
setSyncMsg(`Synced — ${r.models} models available`)
} catch (e: any) {
setSyncMsg(`Error: ${e.message}`)
toast(`Catalog synced: ${r.models} models servable`, 'good')
} catch (e) {
toast(e instanceof Error ? e.message : 'Sync failed', 'bad')
} finally {
setSyncLoading(false)
setSyncing(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" value={name} onChange={e => { setName(e.target.value); markDirty() }} style={inputStyle} />
</Field>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Field label="Log level">
<select value={logLevel} onChange={e => { setLogLevel(e.target.value); markDirty() }} style={{ ...inputStyle, cursor: 'pointer' }}>
{LOG_LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</Field>
<Field label="Log retention">
<select value={retentionDays} onChange={e => { setRetentionDays(Number(e.target.value)); markDirty() }} style={{ ...inputStyle, cursor: 'pointer' }}>
{RETENTION_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</Field>
</div>
</div>
</Section>
<div className="animate-fade-up flex flex-col gap-4 max-w-[640px]">
{error && <ErrorNote>{error}</ErrorNote>}
<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>}
<Card className="overflow-hidden">
<CardHead title="General" />
{loading ? <Loading /> : (
<div className="p-5 grid gap-4">
<Field label="Dashboard name" hint="Shown in the sidebar and browser title.">
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} maxLength={64} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Server log level" hint="Applies immediately to the gateway process.">
<Select value={form.logLevel} onChange={e => setForm({ ...form, logLevel: e.target.value })}>
{LOG_LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</Select>
</Field>
<Field label="Request log retention" hint="Older request logs and their content are deleted once a day.">
<Select value={form.retention} onChange={e => setForm({ ...form, retention: Number(e.target.value) })}>
{RETENTION.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select>
</Field>
</div>
</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>
)}
</Card>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 10 }}>
{saveMsg && <span style={{ fontSize: 12, color: saveMsg.startsWith('Error') ? '#f87171' : '#4ade80' }}>{saveMsg}</span>}
<button onClick={() => { setDirty(false); setSaveMsg('') }} disabled={!dirty || saving}
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', opacity: !dirty ? 0.4 : 1 }}>
Discard
</button>
<button onClick={save} disabled={!dirty || saving}
style={{ background: '#7c3aed', border: 'none', borderRadius: 7, padding: '8px 20px', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer', opacity: !dirty || saving ? 0.5 : 1 }}>
{saving ? 'Saving…' : 'Save changes'}
</button>
<Card className="overflow-hidden">
<CardHead title="Model catalog" right={<Button size="sm" onClick={sync} loading={syncing}>Sync now</Button>} />
<div className="p-5 text-[13px] text-ink-2 leading-relaxed">
The catalog refreshes from every enabled provider on a schedule set by <code className="mono text-accent-3">GATEWAY_CATALOG_SYNC_INTERVAL</code>.
A manual sync picks up new models right away and marks models the provider no longer lists as offline.
</div>
</Card>
<div className="flex justify-end items-center gap-2">
<Button onClick={() => saved && setForm(saved)} disabled={!dirty || saving}>Discard</Button>
<Button variant="primary" onClick={save} disabled={!dirty} loading={saving}>Save changes</Button>
</div>
</div>
)