mirror of
https://github.com/whit3rabbit/anyllm-proxy.git
synced 2026-09-21 16:00:49 +00:00
feat: React admin UI migration — React 19 + TypeScript + Traffic/Uptime tabs
Replace 1,928-line monolithic vanilla JS admin SPA with React 19 + TypeScript 5 application. Adds Traffic and Uptime tabs backed by new Rust API endpoints. - Vite + vite-plugin-singlefile: single dist/index.html with inlined JS/CSS - Custom closeBundle plugin preserves __CSP_NONCE__ placeholder for Rust injection - React Query v5 for server state, Zustand v5 for auth + WS connection state - All existing backend contracts preserved (CSRF, WS auth, sessionStorage key) - New: GET /admin/api/traffic (route load, P95, req/min, time series) - New: GET /admin/api/uptime (proxy start time, per-backend 30d history) - New: health_checks SQLite table + background Tokio health-checker (30s probe) - BackendHealthChanged WS event for immediate Uptime tab refresh on status flip - Dockerfile gains Node.js Stage 1 for frontend build - CI gains frontend job (tsc + lint + build) before Rust test job Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,10 +11,43 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: crates/proxy/admin-ui/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: crates/proxy/admin-ui
|
||||
run: npm ci
|
||||
- name: TypeScript check
|
||||
working-directory: crates/proxy/admin-ui
|
||||
run: npx tsc --noEmit
|
||||
- name: Lint
|
||||
working-directory: crates/proxy/admin-ui
|
||||
run: npm run lint
|
||||
- name: Build
|
||||
working-directory: crates/proxy/admin-ui
|
||||
run: npm run build
|
||||
- name: Upload dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: admin-ui-dist
|
||||
path: crates/proxy/admin-ui/dist/
|
||||
|
||||
test:
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: admin-ui-dist
|
||||
path: crates/proxy/admin-ui/dist/
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
@@ -63,6 +96,11 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: admin-ui-dist
|
||||
path: crates/proxy/admin-ui/dist/
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
@@ -41,3 +41,8 @@ Thumbs.db
|
||||
# Script caches and venv
|
||||
scripts/.cache/
|
||||
scripts/.venv/
|
||||
|
||||
# Admin UI build artifacts
|
||||
crates/proxy/admin-ui/node_modules/
|
||||
# dist/ is committed so `include_str!` works at compile time without a build step
|
||||
# crates/proxy/admin-ui/dist/
|
||||
|
||||
Generated
+101
@@ -29,6 +29,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@@ -89,6 +98,7 @@ dependencies = [
|
||||
"axum 0.8.8",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
"dashmap",
|
||||
"futures",
|
||||
@@ -530,6 +540,20 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -1253,6 +1277,30 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
@@ -3469,12 +3517,65 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
||||
+14
-4
@@ -1,18 +1,26 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ── Stage 1: install cargo-chef ──────────────────────────────────────────────
|
||||
# ── Stage 1: build frontend ───────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS frontend
|
||||
WORKDIR /app/crates/proxy/admin-ui
|
||||
COPY crates/proxy/admin-ui/package.json crates/proxy/admin-ui/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY crates/proxy/admin-ui/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: install cargo-chef ───────────────────────────────────────────────
|
||||
FROM rust:1-alpine AS chef
|
||||
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
|
||||
RUN cargo install cargo-chef --locked
|
||||
WORKDIR /app
|
||||
|
||||
# ── Stage 2: compute dependency recipe ───────────────────────────────────────
|
||||
# ── Stage 3: compute dependency recipe ───────────────────────────────────────
|
||||
FROM chef AS planner
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates crates
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
# ── Stage 3: build dependencies (cached layer) + compile binary ───────────────
|
||||
# ── Stage 4: build dependencies (cached layer) + compile binary ───────────────
|
||||
FROM chef AS builder
|
||||
# OPENSSL_STATIC must be set before cook so openssl-sys links statically
|
||||
# when building reqwest/native-tls dependencies.
|
||||
@@ -22,9 +30,11 @@ RUN cargo chef cook --release --recipe-path recipe.json -p anyllm_proxy
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates crates
|
||||
COPY assets assets
|
||||
# Inject frontend build output so include_str!() resolves at compile time.
|
||||
COPY --from=frontend /app/crates/proxy/admin-ui/dist/ crates/proxy/admin-ui/dist/
|
||||
RUN cargo build --release -p anyllm_proxy
|
||||
|
||||
# ── Stage 4: minimal Alpine runtime ──────────────────────────────────────────
|
||||
# ── Stage 5: minimal Alpine runtime ──────────────────────────────────────────
|
||||
FROM alpine:3.21 AS runtime
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
RUN addgroup -S -g 1001 anyllm && adduser -S -u 1001 -G anyllm anyllm
|
||||
|
||||
@@ -44,6 +44,7 @@ jsonwebtoken = "10"
|
||||
ipnetwork = "0.20"
|
||||
zeroize = "1"
|
||||
crc32fast = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[features]
|
||||
## Enables BashTool and ReadFileTool in the builtin tool registry.
|
||||
|
||||
+61
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+3450
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "anyllm-admin-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint src",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuthStore } from './store/auth'
|
||||
import { useWsStore } from './store/ws'
|
||||
import { connectWs, disconnectWs } from './api/websocket'
|
||||
import LoginPage from './components/layout/LoginPage'
|
||||
import Nav from './components/layout/Nav'
|
||||
import Dashboard from './tabs/dashboard/Dashboard'
|
||||
import RequestLog from './tabs/requests/RequestLog'
|
||||
import Settings from './tabs/settings/Settings'
|
||||
import Backends from './tabs/backends/Backends'
|
||||
import Keys from './tabs/keys/Keys'
|
||||
import Models from './tabs/models/Models'
|
||||
import Audit from './tabs/audit/Audit'
|
||||
import TrafficView from './tabs/traffic/TrafficView'
|
||||
import UptimeView from './tabs/uptime/UptimeView'
|
||||
|
||||
type Tab = 'dashboard' | 'requests' | 'settings' | 'backends' | 'keys' | 'models' | 'audit' | 'traffic' | 'uptime'
|
||||
|
||||
export default function App() {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const lastEvent = useWsStore((s) => s.lastEvent)
|
||||
const qc = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('dashboard')
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
connectWs()
|
||||
} else {
|
||||
disconnectWs()
|
||||
}
|
||||
}, [token])
|
||||
|
||||
// Invalidate query cache on relevant WS events.
|
||||
useEffect(() => {
|
||||
if (!lastEvent) return
|
||||
if (lastEvent.type === 'metrics_snapshot') {
|
||||
qc.setQueryData(['metrics'], lastEvent.data)
|
||||
} else if (lastEvent.type === 'backend_health_changed') {
|
||||
qc.invalidateQueries({ queryKey: ['uptime'] })
|
||||
}
|
||||
}, [lastEvent, qc])
|
||||
|
||||
if (!token) return <LoginPage />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Nav activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
<div className="tab-content">
|
||||
{activeTab === 'dashboard' && <Dashboard />}
|
||||
{activeTab === 'requests' && <RequestLog />}
|
||||
{activeTab === 'settings' && <Settings />}
|
||||
{activeTab === 'backends' && <Backends />}
|
||||
{activeTab === 'keys' && <Keys />}
|
||||
{activeTab === 'models' && <Models />}
|
||||
{activeTab === 'audit' && <Audit />}
|
||||
{activeTab === 'traffic' && <TrafficView />}
|
||||
{activeTab === 'uptime' && <UptimeView />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useAuthStore } from '../store/auth'
|
||||
|
||||
function getToken(): string {
|
||||
return useAuthStore.getState().token ?? ''
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${getToken()}`,
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
if (res.status === 401) {
|
||||
useAuthStore.getState().logout()
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText)
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a state-mutating request, fetching a fresh CSRF token first.
|
||||
* On 204 No Content, returns undefined. Declare T as void for DELETE/no-body endpoints.
|
||||
*/
|
||||
export async function mutatingFetch<T>(
|
||||
method: 'POST' | 'PUT' | 'DELETE' | 'PATCH',
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
// Fetch a one-time CSRF token before every state-mutating request.
|
||||
const csrfRes = await fetch('/admin/csrf-token', {
|
||||
headers: { 'Authorization': `Bearer ${getToken()}` },
|
||||
})
|
||||
if (!csrfRes.ok) throw new Error('Failed to fetch CSRF token')
|
||||
const { token: csrfToken } = await csrfRes.json() as { token: string }
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${getToken()}`,
|
||||
'X-CSRF-Token': csrfToken,
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
useAuthStore.getState().logout()
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText)
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
||||
return undefined as T
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiFetch, mutatingFetch } from './client'
|
||||
import type {
|
||||
Metrics, RequestsResponse, VirtualKey, KeySpend,
|
||||
Backend, ConfigResponse, ObservabilityResponse,
|
||||
ModelsResponse, AuditResponse, TrafficResponse, UptimeResponse,
|
||||
} from './types'
|
||||
|
||||
// ── Dashboard ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useMetrics() {
|
||||
return useQuery<Metrics>({
|
||||
queryKey: ['metrics'],
|
||||
queryFn: () => apiFetch('/admin/api/metrics'),
|
||||
refetchInterval: 5_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useObservability(window: number, backend: string) {
|
||||
return useQuery<ObservabilityResponse>({
|
||||
queryKey: ['observability', window, backend],
|
||||
queryFn: () => apiFetch(`/admin/api/observability/overview?window=${window}&backend=${encodeURIComponent(backend)}`),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Request log ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useRequests(params: {
|
||||
page: number
|
||||
page_size: number
|
||||
backend?: string
|
||||
status?: string
|
||||
since?: string
|
||||
until?: string
|
||||
model?: string
|
||||
}) {
|
||||
const query = new URLSearchParams()
|
||||
query.set('page', String(params.page))
|
||||
query.set('page_size', String(params.page_size))
|
||||
if (params.backend) query.set('backend', params.backend)
|
||||
if (params.status) query.set('status', params.status)
|
||||
if (params.since) query.set('since', params.since)
|
||||
if (params.until) query.set('until', params.until)
|
||||
if (params.model) query.set('model', params.model)
|
||||
return useQuery<RequestsResponse>({
|
||||
queryKey: ['requests', params],
|
||||
queryFn: () => apiFetch(`/admin/api/requests?${query}`),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Virtual keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function useKeys() {
|
||||
return useQuery<VirtualKey[]>({
|
||||
queryKey: ['keys'],
|
||||
queryFn: () => apiFetch('/admin/api/keys'),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
export function useKeySpend(id: number) {
|
||||
return useQuery<KeySpend>({
|
||||
queryKey: ['keys', id, 'spend'],
|
||||
queryFn: () => apiFetch(`/admin/api/keys/${id}/spend`),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateKey() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
mutatingFetch<{ key: string; id: number }>('POST', '/admin/api/keys', body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['keys'] }) },
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateKey() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: Record<string, unknown> }) =>
|
||||
mutatingFetch<VirtualKey>('PUT', `/admin/api/keys/${id}`, body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['keys'] }) },
|
||||
})
|
||||
}
|
||||
|
||||
export function useRevokeKey() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
mutatingFetch<void>('DELETE', `/admin/api/keys/${id}`),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['keys'] }) },
|
||||
})
|
||||
}
|
||||
|
||||
// ── Backends ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useBackends() {
|
||||
return useQuery<Backend[]>({
|
||||
queryKey: ['backends'],
|
||||
queryFn: () => apiFetch('/admin/api/backends'),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Settings / Config ─────────────────────────────────────────────────────────
|
||||
|
||||
export function useConfig() {
|
||||
return useQuery<ConfigResponse>({
|
||||
queryKey: ['config'],
|
||||
queryFn: () => apiFetch('/admin/api/config'),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveConfig() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Record<string, string>) =>
|
||||
mutatingFetch<void>('POST', '/admin/api/config', body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['config'] }) },
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteConfigOverride() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (key: string) =>
|
||||
mutatingFetch<void>('DELETE', `/admin/api/config/${encodeURIComponent(key)}`),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['config'] }) },
|
||||
})
|
||||
}
|
||||
|
||||
export function useEnv() {
|
||||
return useQuery<Record<string, string>>({
|
||||
queryKey: ['env'],
|
||||
queryFn: () => apiFetch('/admin/api/env'),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useModels() {
|
||||
return useQuery<ModelsResponse>({
|
||||
queryKey: ['models'],
|
||||
queryFn: () => apiFetch('/admin/api/models'),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddModel() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
mutatingFetch<void>('POST', '/admin/api/models', body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['models'] }) },
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveModel() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
mutatingFetch<void>('DELETE', `/admin/api/models/${encodeURIComponent(name)}`),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['models'] }) },
|
||||
})
|
||||
}
|
||||
|
||||
// ── Audit ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useAudit(params: { page: number; page_size: number }) {
|
||||
return useQuery<AuditResponse>({
|
||||
queryKey: ['audit', params],
|
||||
queryFn: () => apiFetch(`/admin/api/audit?page=${params.page}&page_size=${params.page_size}`),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Traffic (new) ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useTraffic(windowHours: number) {
|
||||
return useQuery<TrafficResponse>({
|
||||
queryKey: ['traffic', windowHours],
|
||||
queryFn: () => apiFetch(`/admin/api/traffic?window=${windowHours}`),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Uptime (new) ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function useUptime() {
|
||||
return useQuery<UptimeResponse>({
|
||||
queryKey: ['uptime'],
|
||||
queryFn: () => apiFetch('/admin/api/uptime'),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Mirrors the JSON shapes returned by /admin/api/* endpoints.
|
||||
// Keep in sync with Rust structs in crates/proxy/src/admin/state.rs and routes/.
|
||||
|
||||
export interface Metrics {
|
||||
total_requests: number
|
||||
successful_requests: number
|
||||
failed_requests: number
|
||||
requests_per_minute: number
|
||||
p50_latency_ms: number
|
||||
p95_latency_ms: number
|
||||
error_rate: number
|
||||
streams_started: number
|
||||
streams_completed: number
|
||||
streams_failed: number
|
||||
streams_client_disconnected: number
|
||||
}
|
||||
|
||||
export interface RequestLogEntry {
|
||||
request_id: string
|
||||
timestamp: string
|
||||
backend: string
|
||||
model_requested: string | null
|
||||
model_mapped: string | null
|
||||
status_code: number
|
||||
latency_ms: number
|
||||
input_tokens: number | null
|
||||
output_tokens: number | null
|
||||
is_streaming: boolean
|
||||
error_message: string | null
|
||||
error_kind: string | null
|
||||
key_id: number | null
|
||||
cost_usd: number | null
|
||||
}
|
||||
|
||||
export interface RequestsResponse {
|
||||
requests: RequestLogEntry[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export interface VirtualKey {
|
||||
id: number
|
||||
key_prefix: string
|
||||
description: string | null
|
||||
created_at: string
|
||||
expires_at: string | null
|
||||
revoked_at: string | null
|
||||
spend_limit: number | null
|
||||
rpm_limit: number | null
|
||||
tpm_limit: number | null
|
||||
total_spend: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
period_reset_at: string | null
|
||||
allowed_models: string[] | null
|
||||
status: 'active' | 'revoked' | 'expired' | 'override'
|
||||
}
|
||||
|
||||
export interface KeySpend {
|
||||
id: number
|
||||
total_spend: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
export interface Backend {
|
||||
name: string
|
||||
model: string
|
||||
provider: string
|
||||
status: string
|
||||
requests_total: number
|
||||
requests_ok: number
|
||||
requests_err: number
|
||||
p50_ms: number
|
||||
p95_ms: number
|
||||
}
|
||||
|
||||
export interface ConfigEntry {
|
||||
key: string
|
||||
value: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ConfigResponse {
|
||||
entries: ConfigEntry[]
|
||||
env: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ObservabilityPoint {
|
||||
bucket_start: number
|
||||
requests: number
|
||||
errors: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cost_usd: number
|
||||
}
|
||||
|
||||
export interface ObservabilityFailure {
|
||||
error_kind: string
|
||||
count: number
|
||||
last_seen: string
|
||||
last_message: string
|
||||
}
|
||||
|
||||
export interface ObservabilityTimeline {
|
||||
request_id: string
|
||||
timestamp: string
|
||||
backend: string
|
||||
model: string
|
||||
latency_ms: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface ObservabilityResponse {
|
||||
window_hours: number
|
||||
backend: string
|
||||
total_requests: number
|
||||
total_errors: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
total_cost_usd: number
|
||||
series: ObservabilityPoint[]
|
||||
failures: ObservabilityFailure[]
|
||||
timeline: ObservabilityTimeline[]
|
||||
}
|
||||
|
||||
export interface ModelEntry {
|
||||
name: string
|
||||
provider: string
|
||||
model: string
|
||||
weight: number | null
|
||||
rpm: number | null
|
||||
tpm: number | null
|
||||
}
|
||||
|
||||
export interface ModelsResponse {
|
||||
models: ModelEntry[]
|
||||
routing_strategy: string
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number
|
||||
timestamp: string
|
||||
action: string
|
||||
target_type: string
|
||||
target_id: string | null
|
||||
detail: string | null
|
||||
source_ip: string | null
|
||||
}
|
||||
|
||||
export interface AuditResponse {
|
||||
entries: AuditEntry[]
|
||||
total: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
// --- Traffic tab (new) ---
|
||||
|
||||
export interface RouteMetrics {
|
||||
path: string
|
||||
requests_per_min: number
|
||||
error_rate: number
|
||||
avg_latency_ms: number
|
||||
p95_latency_ms: number
|
||||
total_requests: number
|
||||
}
|
||||
|
||||
export interface TrafficSeriesPoint {
|
||||
bucket_start: number
|
||||
path: string
|
||||
requests: number
|
||||
}
|
||||
|
||||
export interface TrafficResponse {
|
||||
window_hours: number
|
||||
routes: RouteMetrics[]
|
||||
series: TrafficSeriesPoint[]
|
||||
}
|
||||
|
||||
// --- Uptime tab (new) ---
|
||||
|
||||
export interface HistoryDay {
|
||||
date: string
|
||||
status: 'up' | 'down' | 'degraded' | 'no-data'
|
||||
}
|
||||
|
||||
export interface ProxyUptimeInfo {
|
||||
started_at: number
|
||||
uptime_pct_30d: number
|
||||
history: HistoryDay[]
|
||||
}
|
||||
|
||||
export interface BackendUptimeInfo {
|
||||
name: string
|
||||
status: 'up' | 'down' | 'unknown'
|
||||
last_checked_at: number | null
|
||||
last_latency_ms: number | null
|
||||
uptime_pct_30d: number
|
||||
history: HistoryDay[]
|
||||
}
|
||||
|
||||
export interface UptimeResponse {
|
||||
proxy: ProxyUptimeInfo
|
||||
backends: BackendUptimeInfo[]
|
||||
}
|
||||
|
||||
// --- WebSocket events ---
|
||||
|
||||
export type WSEvent =
|
||||
| { type: 'request_completed'; data: RequestLogEntry }
|
||||
| { type: 'metrics_snapshot'; data: Metrics }
|
||||
| { type: 'config_changed'; data: { key: string; value: string } }
|
||||
| { type: 'backend_health_changed'; data: { backend: string; status: 'up' | 'down'; latency_ms: number | null } }
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { useWsStore } from '../store/ws'
|
||||
import type { WSEvent } from './types'
|
||||
|
||||
const MAX_RETRY_DELAY_MS = 30_000
|
||||
const BASE_DELAY_MS = 1_000
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let retryCount = 0
|
||||
let stopped = false
|
||||
|
||||
export function connectWs(): void {
|
||||
stopped = false
|
||||
// If already open or mid-handshake, don't create a second socket.
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
attemptConnect()
|
||||
}
|
||||
|
||||
export function disconnectWs(): void {
|
||||
stopped = true
|
||||
if (retryTimeout) clearTimeout(retryTimeout)
|
||||
ws?.close()
|
||||
ws = null
|
||||
useWsStore.getState().setStatus('disconnected')
|
||||
}
|
||||
|
||||
function attemptConnect(): void {
|
||||
if (stopped) return
|
||||
const token = useAuthStore.getState().token
|
||||
if (!token) return
|
||||
|
||||
useWsStore.getState().setStatus('connecting')
|
||||
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
ws = new WebSocket(`${protocol}//${location.host}/admin/ws`)
|
||||
|
||||
ws.onopen = () => {
|
||||
ws!.send(JSON.stringify({ token }))
|
||||
}
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
let data: unknown
|
||||
try { data = JSON.parse(evt.data) } catch { return }
|
||||
|
||||
if (
|
||||
data !== null &&
|
||||
typeof data === 'object' &&
|
||||
'status' in data &&
|
||||
(data as { status: unknown }).status === 'authenticated'
|
||||
) {
|
||||
retryCount = 0
|
||||
useWsStore.getState().setStatus('connected')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
data !== null &&
|
||||
typeof data === 'object' &&
|
||||
'type' in data
|
||||
) {
|
||||
useWsStore.getState().pushEvent(data as WSEvent)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (stopped) return
|
||||
useWsStore.getState().setStatus('disconnected')
|
||||
const delay = Math.min(BASE_DELAY_MS * 2 ** retryCount, MAX_RETRY_DELAY_MS)
|
||||
retryCount++
|
||||
retryTimeout = setTimeout(attemptConnect, delay)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { RequestLogEntry } from '../../api/types'
|
||||
|
||||
export default function FeedDetail({ req }: { req: RequestLogEntry }) {
|
||||
return (
|
||||
<div className="feed-detail">
|
||||
<span className="label">Request ID</span>
|
||||
<span className="val">{req.request_id}</span>
|
||||
<span className="label">Backend</span>
|
||||
<span className="val">{req.backend}</span>
|
||||
<span className="label">Model (req)</span>
|
||||
<span className="val">{req.model_requested ?? '—'}</span>
|
||||
<span className="label">Model (mapped)</span>
|
||||
<span className="val">{req.model_mapped ?? '—'}</span>
|
||||
<span className="label">Latency</span>
|
||||
<span className="val">{req.latency_ms} ms</span>
|
||||
<span className="label">Tokens in/out</span>
|
||||
<span className="val">
|
||||
{req.input_tokens ?? '—'} / {req.output_tokens ?? '—'}
|
||||
</span>
|
||||
<span className="label">Cost</span>
|
||||
<span className="val">
|
||||
{req.cost_usd != null ? `$${req.cost_usd.toFixed(6)}` : '—'}
|
||||
</span>
|
||||
{req.error_message && (
|
||||
<div className="error-msg">{req.error_message}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
import type { RequestLogEntry } from '../../api/types'
|
||||
import FeedDetail from './FeedDetail'
|
||||
|
||||
function statusClass(code: number) {
|
||||
if (code < 300) return 'status-2xx'
|
||||
if (code < 500) return 'status-4xx'
|
||||
return 'status-5xx'
|
||||
}
|
||||
|
||||
export default function FeedRow({ req }: { req: RequestLogEntry }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<div className="feed-row" onClick={() => setOpen((v) => !v)}>
|
||||
<span className="mono dim">{req.timestamp.slice(11, 19)}</span>
|
||||
<span className={`mono ${statusClass(req.status_code)}`}>{req.status_code}</span>
|
||||
<span className="mono">{req.latency_ms}ms</span>
|
||||
<span className="mono" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{req.model_requested ?? req.backend}
|
||||
{req.is_streaming && <span className="streaming-badge">stream</span>}
|
||||
</span>
|
||||
<span className="mono dim">{req.input_tokens ?? '—'}</span>
|
||||
<span className="mono dim">{req.output_tokens ?? '—'}</span>
|
||||
<span className="mono dim">
|
||||
{req.cost_usd != null ? `$${req.cost_usd.toFixed(5)}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{open && <FeedDetail req={req} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useWsStore } from '../../store/ws'
|
||||
import type { RequestLogEntry } from '../../api/types'
|
||||
import FeedRow from './FeedRow'
|
||||
|
||||
const MAX_FEED = 200
|
||||
|
||||
export default function LiveFeed({ initial }: { initial?: RequestLogEntry[] }) {
|
||||
const [rows, setRows] = useState<RequestLogEntry[]>(initial ?? [])
|
||||
const [paused, setPaused] = useState(false)
|
||||
const pausedRef = useRef(paused)
|
||||
pausedRef.current = paused
|
||||
|
||||
const lastEvent = useWsStore((s) => s.lastEvent)
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastEvent || lastEvent.type !== 'request_completed' || pausedRef.current) return
|
||||
setRows((prev) => [lastEvent.data, ...prev].slice(0, MAX_FEED))
|
||||
}, [lastEvent])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-header">
|
||||
<span className="section-label">Live Feed</span>
|
||||
<button
|
||||
className={`btn btn-sm ${paused ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setPaused((v) => !v)}
|
||||
>
|
||||
{paused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="feed">
|
||||
<div className="feed-header">
|
||||
<span>Time</span>
|
||||
<span>Status</span>
|
||||
<span>Latency</span>
|
||||
<span>Model</span>
|
||||
<span>In</span>
|
||||
<span>Out</span>
|
||||
<span>Cost</span>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="empty">Waiting for requests…</div>
|
||||
) : (
|
||||
rows.map((r) => <FeedRow key={r.request_id} req={r} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const token = (e.currentTarget.elements.namedItem('token') as HTMLInputElement).value.trim()
|
||||
if (!token) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
// Validate by hitting a lightweight endpoint with the candidate token.
|
||||
const res = await fetch('/admin/api/metrics', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok) throw new Error('Invalid token')
|
||||
login(token)
|
||||
} catch {
|
||||
setError('Invalid token')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-overlay">
|
||||
<div className="login-card">
|
||||
<div className="login-title">
|
||||
<span className="prompt">> </span>proxy admin
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
name="token"
|
||||
placeholder="Admin token"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Signing in\u2026' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="login-error">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
import { useWsStore } from '../../store/ws'
|
||||
|
||||
type Tab = 'dashboard' | 'requests' | 'settings' | 'backends' | 'keys' | 'models' | 'audit' | 'traffic' | 'uptime'
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'requests', label: 'Request Log' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
{ id: 'backends', label: 'Backends' },
|
||||
{ id: 'keys', label: 'Access Control' },
|
||||
{ id: 'models', label: 'Models' },
|
||||
{ id: 'audit', label: 'Audit' },
|
||||
{ id: 'traffic', label: 'Traffic' },
|
||||
{ id: 'uptime', label: 'Uptime' },
|
||||
]
|
||||
|
||||
interface NavProps {
|
||||
activeTab: Tab
|
||||
onTabChange: (tab: Tab) => void
|
||||
}
|
||||
|
||||
export default function Nav({ activeTab, onTabChange }: NavProps) {
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const wsStatus = useWsStore((s) => s.status)
|
||||
|
||||
return (
|
||||
<nav className="nav">
|
||||
<div className="nav-brand">anyllm</div>
|
||||
{TABS.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`nav-item${activeTab === t.id ? ' active' : ''}`}
|
||||
onClick={() => onTabChange(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</div>
|
||||
))}
|
||||
<div className="nav-right">
|
||||
<span
|
||||
className={`ws-status ${wsStatus === 'connected' ? 'connected' : 'disconnected'}`}
|
||||
>
|
||||
{wsStatus === 'connected' ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ marginLeft: 12 }}
|
||||
onClick={logout}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
type BadgeVariant = 'active' | 'revoked' | 'expired' | 'override'
|
||||
|
||||
export default function Badge({ variant }: { variant: BadgeVariant }) {
|
||||
return <span className={`badge badge-${variant}`}>{variant}</span>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
interface BudgetBarProps {
|
||||
spent: number
|
||||
limit: number | null
|
||||
}
|
||||
|
||||
export default function BudgetBar({ spent, limit }: BudgetBarProps) {
|
||||
if (!limit) return <span className="dim">—</span>
|
||||
const pct = Math.min((spent / limit) * 100, 100)
|
||||
const cls = pct >= 95 ? 'danger' : pct >= 80 ? 'warn' : ''
|
||||
return (
|
||||
<div>
|
||||
<div className="budget-bar">
|
||||
<div className={`budget-bar-fill${cls ? ` ${cls}` : ''}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="dim" style={{ fontSize: 10 }}>
|
||||
${spent.toFixed(4)} / ${limit.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
interface EmptyStateProps {
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
empty?: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export default function EmptyState({ loading, error, empty, message }: EmptyStateProps) {
|
||||
if (loading) return <div className="empty">Loading…</div>
|
||||
if (error) return <div className="empty error">{error}</div>
|
||||
if (empty) return <div className="empty">{message ?? 'No data'}</div>
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
interface Series {
|
||||
label: string
|
||||
color: string
|
||||
data: number[]
|
||||
secondary?: boolean
|
||||
}
|
||||
|
||||
interface LineChartProps {
|
||||
series: Series[]
|
||||
labels?: string[]
|
||||
gridColor?: string
|
||||
height?: number
|
||||
}
|
||||
|
||||
export default function LineChart({
|
||||
series,
|
||||
gridColor = 'var(--border-sub)',
|
||||
height = 130,
|
||||
}: LineChartProps) {
|
||||
const W = 600
|
||||
const H = height
|
||||
const PAD = { top: 8, right: 8, bottom: 0, left: 0 }
|
||||
const innerW = W - PAD.left - PAD.right
|
||||
const innerH = H - PAD.top - PAD.bottom
|
||||
|
||||
const allValues = series.flatMap((s) => s.data)
|
||||
const maxVal = Math.max(...allValues, 1)
|
||||
const len = Math.max(...series.map((s) => s.data.length), 2)
|
||||
|
||||
function x(i: number) {
|
||||
return PAD.left + (i / (len - 1)) * innerW
|
||||
}
|
||||
function y(v: number) {
|
||||
return PAD.top + innerH - (v / maxVal) * innerH
|
||||
}
|
||||
|
||||
const GRID_LINES = 4
|
||||
const gridYs = Array.from({ length: GRID_LINES }, (_, i) =>
|
||||
PAD.top + (i / (GRID_LINES - 1)) * innerH,
|
||||
)
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="chart-svg"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
style={{ height }}
|
||||
>
|
||||
{gridYs.map((gy, i) => (
|
||||
<line
|
||||
key={i}
|
||||
className="chart-grid-line"
|
||||
x1={PAD.left}
|
||||
y1={gy}
|
||||
x2={W - PAD.right}
|
||||
y2={gy}
|
||||
stroke={gridColor}
|
||||
/>
|
||||
))}
|
||||
{series.map((s, i) => {
|
||||
if (s.data.length < 2) return null
|
||||
const points = s.data.map((v, i) => `${x(i)},${y(v)}`).join(' ')
|
||||
const areaPoints = [
|
||||
`${x(0)},${PAD.top + innerH}`,
|
||||
...s.data.map((v, i) => `${x(i)},${y(v)}`),
|
||||
`${x(s.data.length - 1)},${PAD.top + innerH}`,
|
||||
].join(' ')
|
||||
return (
|
||||
<g key={i}>
|
||||
<polygon
|
||||
className="chart-area"
|
||||
points={areaPoints}
|
||||
fill={s.color}
|
||||
/>
|
||||
<polyline
|
||||
className={`chart-line${s.secondary ? ' secondary' : ''}`}
|
||||
points={points}
|
||||
stroke={s.color}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
hasMore: boolean
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export default function Pagination({ page, hasMore, onPrev, onNext }: PaginationProps) {
|
||||
return (
|
||||
<div className="pagination">
|
||||
<button className="btn btn-secondary btn-sm" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<span>Page {page}</span>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onNext} disabled={!hasMore}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
type DotStatus = 'ok' | 'warn' | 'err' | 'dim'
|
||||
|
||||
const COLOR: Record<DotStatus, string> = {
|
||||
ok: 'var(--ok)',
|
||||
warn: 'var(--warn)',
|
||||
err: 'var(--err)',
|
||||
dim: 'var(--text-3)',
|
||||
}
|
||||
|
||||
interface StatusDotProps {
|
||||
status: DotStatus
|
||||
pulse?: boolean
|
||||
}
|
||||
|
||||
export default function StatusDot({ status, pulse }: StatusDotProps) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: '50%',
|
||||
background: COLOR[status],
|
||||
animation: pulse ? 'pulse 2s ease-in-out infinite' : undefined,
|
||||
verticalAlign: 'middle',
|
||||
marginRight: 6,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './styles/globals.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
login: (token: string) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
token: sessionStorage.getItem('admin_token'),
|
||||
login(token) {
|
||||
sessionStorage.setItem('admin_token', token)
|
||||
set({ token })
|
||||
},
|
||||
logout() {
|
||||
sessionStorage.removeItem('admin_token')
|
||||
set({ token: null })
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,18 @@
|
||||
import { create } from 'zustand'
|
||||
import type { WSEvent } from '../api/types'
|
||||
|
||||
type WsStatus = 'disconnected' | 'connecting' | 'connected'
|
||||
|
||||
interface WsState {
|
||||
status: WsStatus
|
||||
lastEvent: WSEvent | null
|
||||
setStatus: (status: WsStatus) => void
|
||||
pushEvent: (event: WSEvent) => void
|
||||
}
|
||||
|
||||
export const useWsStore = create<WsState>((set) => ({
|
||||
status: 'disconnected',
|
||||
lastEvent: null,
|
||||
setStatus: (status) => set({ status }),
|
||||
pushEvent: (event) => set({ lastEvent: event }),
|
||||
}))
|
||||
@@ -0,0 +1,215 @@
|
||||
@import url('https://fonts.bunny.net/css?family=dm-sans:wght@400;500;600&family=dm-mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-base: #131516;
|
||||
--bg-raised: #1a1d1f;
|
||||
--bg-sunken: #0e1011;
|
||||
--bg-hover: #22272a;
|
||||
--border: #2a2f33;
|
||||
--border-sub: #1f2428;
|
||||
--text-1: #e8e3d9;
|
||||
--text-2: #7a8088;
|
||||
--text-3: #4e545b;
|
||||
--accent: #e8a030;
|
||||
--accent-dim: rgba(232, 160, 48, 0.12);
|
||||
--accent-bdr: rgba(232, 160, 48, 0.35);
|
||||
--ok: #4caf6e;
|
||||
--ok-dim: rgba(76, 175, 110, 0.12);
|
||||
--warn: #d4922b;
|
||||
--warn-dim: rgba(212, 146, 43, 0.12);
|
||||
--err: #e05252;
|
||||
--err-dim: rgba(224, 82, 82, 0.12);
|
||||
--font-ui: "DM Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "DM Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
--r: 2px;
|
||||
--rm: 3px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: var(--bg-base); color: var(--text-1); font-family: var(--font-ui); font-size: 14px; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
|
||||
/* Nav */
|
||||
.nav { display: flex; align-items: stretch; border-bottom: 1px solid var(--border); background: var(--bg-raised); position: sticky; top: 0; z-index: 10; }
|
||||
.nav-brand { padding: 0 16px 0 14px; font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--accent); border-right: 1px solid var(--border); display: flex; align-items: center; letter-spacing: 0.02em; }
|
||||
.nav-item { padding: 11px 16px; cursor: pointer; color: var(--text-2); border-bottom: 2px solid transparent; font-size: 13px; font-weight: 500; white-space: nowrap; user-select: none; }
|
||||
.nav-item.active { color: var(--text-1); border-bottom-color: var(--accent); font-weight: 600; }
|
||||
.nav-item:hover { color: var(--text-1); }
|
||||
.nav-right { margin-left: auto; padding: 0 16px; font-size: 12px; display: flex; align-items: center; }
|
||||
|
||||
/* WS status dot */
|
||||
.ws-status::before { content: ''; display: inline-block; width: 5px; height: 5px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
|
||||
.connected { color: var(--ok); }
|
||||
.connected::before { background: var(--ok); animation: pulse 2s ease-in-out infinite; }
|
||||
.disconnected { color: var(--err); }
|
||||
.disconnected::before { background: var(--err); }
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.9; } 50% { opacity: 0.4; } }
|
||||
|
||||
/* Layout */
|
||||
.tab-content { padding: 14px 20px; max-width: 1400px; margin: 0 auto; }
|
||||
|
||||
/* Stats */
|
||||
.stats-row { display: flex; border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; margin-bottom: 14px; background: var(--bg-raised); }
|
||||
.stat { flex: 1; padding: 12px 14px; border-right: 1px solid var(--border); }
|
||||
.stat:last-child { border-right: none; }
|
||||
.stat-label { color: var(--text-2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em; }
|
||||
.stat-value { font-family: var(--font-mono); font-size: 22px; margin-top: 3px; font-weight: 500; }
|
||||
|
||||
/* Backend cards */
|
||||
.backend-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
||||
.card { padding: 12px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--rm); }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-name { color: var(--accent); font-weight: 600; font-family: var(--font-mono); font-size: 13px; }
|
||||
.card-body { margin-top: 8px; color: var(--text-2); font-size: 12px; }
|
||||
|
||||
/* Section */
|
||||
.section-label { color: var(--text-2); font-size: 10px; text-transform: uppercase; margin-bottom: 8px; letter-spacing: 0.07em; font-weight: 500; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
|
||||
/* Feed */
|
||||
.feed { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; max-height: 400px; overflow-y: auto; }
|
||||
.feed-header, .feed-row { display: grid; grid-template-columns: 140px 50px 80px 1fr 65px 55px 55px; padding: 6px 12px; font-size: 12px; }
|
||||
.feed-header { background: var(--bg-hover); color: var(--text-2); border-bottom: 1px solid var(--border); position: sticky; top: 0; font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.feed-row { border-bottom: 1px solid var(--border-sub); cursor: pointer; }
|
||||
.feed-row:last-child { border-bottom: none; }
|
||||
.feed-row:hover { background: var(--bg-hover); }
|
||||
.feed-detail { padding: 12px 16px; background: var(--bg-sunken); border-bottom: 1px solid var(--border-sub); border-left: 2px solid var(--accent); font-size: 12px; display: grid; grid-template-columns: 120px 1fr; gap: 4px 12px; }
|
||||
.feed-detail .label { color: var(--text-2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.feed-detail .val { color: var(--text-1); font-family: var(--font-mono); word-break: break-all; }
|
||||
.feed-detail .error-msg { color: var(--err); grid-column: 1 / -1; margin-top: 4px; padding: 6px; background: var(--err-dim); border-left: 2px solid var(--err); border-radius: var(--r); }
|
||||
.streaming-badge { font-size: 9px; padding: 1px 5px; border-radius: 1px; background: var(--accent-dim); color: var(--accent); border: 1px solid var(--accent-bdr); margin-left: 4px; }
|
||||
.status-2xx { color: var(--ok); }
|
||||
.status-4xx { color: var(--warn); }
|
||||
.status-5xx { color: var(--err); }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 14px; padding: 12px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--rm); }
|
||||
.form-label { font-weight: 600; margin-bottom: 4px; }
|
||||
.form-hint { color: var(--text-2); font-size: 11px; margin-bottom: 8px; }
|
||||
.form-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
input, select { background: var(--bg-sunken); border: 1px solid var(--border); color: var(--text-1); padding: 6px 10px; border-radius: var(--r); font-size: 13px; font-family: var(--font-ui); }
|
||||
input:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); }
|
||||
button[disabled] { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { padding: 7px 14px; border-radius: var(--r); cursor: pointer; font-size: 13px; border: 1px solid; font-weight: 500; font-family: var(--font-ui); transition: none; }
|
||||
.btn-primary { background: transparent; border-color: var(--accent); color: var(--accent); }
|
||||
.btn-primary:hover { background: var(--accent-dim); }
|
||||
.btn-secondary { background: transparent; border-color: var(--border); color: var(--text-2); }
|
||||
.btn-secondary:hover { border-color: var(--text-2); color: var(--text-1); }
|
||||
.btn-danger { background: transparent; border-color: var(--err); color: var(--err); }
|
||||
.btn-danger:hover { background: var(--err-dim); }
|
||||
.btn-sm { padding: 3px 7px; font-size: 11px; }
|
||||
|
||||
/* Badges */
|
||||
.badge { font-size: 10px; padding: 2px 5px; border-radius: 1px; font-family: var(--font-mono); }
|
||||
.badge-override { background: var(--warn-dim); color: var(--warn); }
|
||||
.badge-active { background: var(--ok-dim); color: var(--ok); }
|
||||
.badge-revoked { background: var(--err-dim); color: var(--err); }
|
||||
.badge-expired { background: var(--warn-dim); color: var(--warn); }
|
||||
|
||||
/* Misc */
|
||||
.readonly-section { opacity: 0.75; padding: 12px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--rm); }
|
||||
.model-grid { display: grid; grid-template-columns: 120px 1fr; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.model-grid .label { color: var(--text-2); }
|
||||
.warn { color: var(--warn); }
|
||||
.error { color: var(--err); }
|
||||
.empty { text-align: center; padding: 40px; color: var(--text-2); }
|
||||
.pagination { display: flex; gap: 8px; margin-top: 12px; align-items: center; font-size: 13px; color: var(--text-2); }
|
||||
|
||||
/* Operator / Charts */
|
||||
.operator-controls { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.operator-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1px; margin-bottom: 14px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; }
|
||||
.chart-card { padding: 12px; background: var(--bg-raised); min-height: 210px; }
|
||||
.chart-card.wide { grid-column: 1 / -1; border-top: 1px solid var(--border); }
|
||||
.chart-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 10px; }
|
||||
.chart-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-2); font-weight: 500; }
|
||||
.chart-value { font-size: 20px; font-weight: 500; color: var(--text-1); font-family: var(--font-mono); }
|
||||
.chart-subtitle { font-size: 11px; color: var(--text-2); }
|
||||
.chart-svg { width: 100%; height: 130px; display: block; }
|
||||
.chart-grid-line { stroke: var(--border-sub); stroke-width: 1; }
|
||||
.chart-line { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.chart-line.secondary { stroke-width: 1.5; opacity: 0.8; }
|
||||
.chart-area { fill-opacity: 0.08; }
|
||||
.chart-axis { display: flex; justify-content: space-between; margin-top: 6px; font-size: 10px; color: var(--text-3); font-family: var(--font-mono); }
|
||||
|
||||
/* Failure table */
|
||||
.failure-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.failure-table th, .failure-table td { padding: 7px 10px; text-align: left; border-bottom: 1px solid var(--border-sub); vertical-align: top; }
|
||||
.failure-table th { color: var(--text-2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500; }
|
||||
.failure-summary { max-width: 340px; color: var(--text-1); }
|
||||
|
||||
/* Timeline */
|
||||
.timeline-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.timeline-item { padding: 10px; border: 1px solid var(--border-sub); border-radius: var(--r); background: var(--bg-sunken); }
|
||||
.timeline-item:hover { border-color: var(--border); }
|
||||
.timeline-meta { display: grid; grid-template-columns: 90px 52px 88px 1fr 70px; gap: 8px; align-items: center; font-size: 12px; margin-bottom: 8px; }
|
||||
.timeline-track { height: 6px; border-radius: 0; background: var(--border); overflow: hidden; }
|
||||
.timeline-bar { height: 100%; min-width: 4px; border-radius: 0; }
|
||||
.timeline-caption { display: flex; justify-content: space-between; gap: 8px; margin-top: 6px; font-size: 11px; color: var(--text-2); }
|
||||
|
||||
/* Utility */
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.dim { color: var(--text-2); }
|
||||
.accent { color: var(--accent); }
|
||||
.ok { color: var(--ok); }
|
||||
|
||||
/* Login */
|
||||
.login-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: var(--bg-base); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.login-card { background: var(--bg-raised); border: 1px solid var(--border); border-top: 2px solid var(--accent); border-radius: var(--rm); padding: 32px; width: 320px; }
|
||||
.login-title { font-family: var(--font-mono); font-size: 15px; font-weight: 500; margin-bottom: 24px; color: var(--text-1); }
|
||||
.login-title .prompt { color: var(--accent); }
|
||||
.login-card input[type="password"] { width: 100%; padding: 8px 12px; margin-bottom: 12px; font-size: 14px; background: var(--bg-sunken); border: 1px solid var(--border); color: var(--text-1); border-radius: var(--r); }
|
||||
.login-card input[type="password"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); }
|
||||
.login-card .btn { width: 100%; padding: 10px; font-size: 14px; }
|
||||
.login-error { color: var(--err); font-size: 12px; margin-top: 8px; min-height: 16px; }
|
||||
|
||||
/* Keys tab */
|
||||
.keys-grid { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.keys-grid th, .keys-grid td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border-sub); }
|
||||
.keys-grid th { background: var(--bg-hover); color: var(--text-2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500; position: sticky; top: 0; }
|
||||
.keys-grid tr:hover td { background: var(--bg-hover); }
|
||||
|
||||
/* Budget bar */
|
||||
.budget-bar { height: 4px; border-radius: 0; background: var(--border); margin-top: 4px; overflow: hidden; min-width: 60px; }
|
||||
.budget-bar-fill { height: 100%; background: var(--ok); transition: width 0.3s; }
|
||||
.budget-bar-fill.warn { background: var(--warn); }
|
||||
.budget-bar-fill.danger { background: var(--err); }
|
||||
|
||||
/* Tag chips */
|
||||
.tag-input-area { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; background: var(--bg-sunken); border: 1px solid var(--border); border-radius: var(--r); min-height: 34px; align-items: center; cursor: text; }
|
||||
.tag-input-area:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); }
|
||||
.chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: var(--bg-hover); border: 1px solid var(--border); border-radius: var(--r); font-size: 11px; color: var(--text-1); }
|
||||
.chip-remove { cursor: pointer; color: var(--text-3); font-size: 13px; line-height: 1; }
|
||||
.chip-remove:hover { color: var(--err); }
|
||||
.tag-input-bare { background: none; border: none; outline: none; color: var(--text-1); font-size: 13px; min-width: 80px; padding: 2px 4px; flex: 1; font-family: var(--font-ui); }
|
||||
|
||||
/* Key result */
|
||||
.key-result { background: var(--bg-sunken); border: 1px solid var(--border); border-left: 3px solid var(--ok); border-radius: var(--r); padding: 12px; margin-bottom: 12px; font-family: var(--font-mono); font-size: 12px; word-break: break-all; }
|
||||
.key-result-label { color: var(--ok); font-size: 11px; margin-bottom: 4px; font-weight: 600; }
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-top: 2px solid var(--accent); border-radius: var(--rm); padding: 20px; width: 460px; max-width: 95vw; max-height: 90vh; overflow-y: auto; }
|
||||
.modal-title { font-weight: 600; font-size: 15px; margin-bottom: 16px; color: var(--text-1); }
|
||||
|
||||
/* Traffic tab */
|
||||
.route-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.route-table th, .route-table td { padding: 7px 10px; text-align: left; border-bottom: 1px solid var(--border-sub); }
|
||||
.route-table th { background: var(--bg-hover); color: var(--text-2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500; position: sticky; top: 0; }
|
||||
.route-table tr:hover td { background: var(--bg-hover); }
|
||||
.route-table td.mono { font-family: var(--font-mono); }
|
||||
|
||||
/* Uptime tab */
|
||||
.uptime-proxy { padding: 14px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--rm); margin-bottom: 16px; }
|
||||
.uptime-proxy-stats { display: flex; gap: 24px; margin-bottom: 10px; font-size: 13px; }
|
||||
.uptime-pct { font-family: var(--font-mono); font-size: 22px; font-weight: 500; color: var(--ok); }
|
||||
.history-bar { display: flex; gap: 2px; margin-top: 8px; height: 16px; }
|
||||
.history-day { flex: 1; border-radius: 1px; min-width: 4px; }
|
||||
.history-day.up { background: var(--ok); }
|
||||
.history-day.down { background: var(--err); }
|
||||
.history-day.degraded { background: var(--warn); }
|
||||
.history-day.no-data { background: var(--border); }
|
||||
.backend-health-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.backend-health-table th, .backend-health-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border-sub); }
|
||||
.backend-health-table th { background: var(--bg-hover); color: var(--text-2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500; }
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react'
|
||||
import { useAudit } from '../../api/queries'
|
||||
import Pagination from '../../components/shared/Pagination'
|
||||
import EmptyState from '../../components/shared/EmptyState'
|
||||
|
||||
export default function Audit() {
|
||||
const [page, setPage] = useState(1)
|
||||
const { data, isLoading, error } = useAudit({ page, page_size: 50 })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EmptyState loading={isLoading} error={error?.message} />
|
||||
{data && (
|
||||
<>
|
||||
<table className="route-table">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Action</th><th>Target</th><th>Detail</th><th>IP</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.entries.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="mono dim">{e.timestamp.slice(0, 19)}</td>
|
||||
<td className="mono">{e.action}</td>
|
||||
<td className="dim">{e.target_type}{e.target_id ? ` #${e.target_id}` : ''}</td>
|
||||
<td className="dim" style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.detail ?? '—'}</td>
|
||||
<td className="mono dim">{e.source_ip ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
page={page}
|
||||
hasMore={data.has_more}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => p + 1)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useBackends } from '../../api/queries'
|
||||
import EmptyState from '../../components/shared/EmptyState'
|
||||
import StatusDot from '../../components/shared/StatusDot'
|
||||
|
||||
export default function Backends() {
|
||||
const { data, isLoading, error } = useBackends()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EmptyState loading={isLoading} error={error?.message} empty={data?.length === 0} />
|
||||
<div className="backend-cards">
|
||||
{data?.map((b) => (
|
||||
<div className="card" key={b.name}>
|
||||
<div className="card-header">
|
||||
<span className="card-name">{b.name}</span>
|
||||
<StatusDot status={b.status === 'ok' ? 'ok' : 'err'} pulse={b.status === 'ok'} />
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="mono">{b.model}</div>
|
||||
<div style={{ marginTop: 6, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4 }}>
|
||||
<span className="dim">Requests</span><span className="mono">{b.requests_total}</span>
|
||||
<span className="dim">P50</span><span className="mono">{b.p50_ms}ms</span>
|
||||
<span className="dim">P95</span><span className="mono">{b.p95_ms}ms</span>
|
||||
<span className="dim">Errors</span>
|
||||
<span className="mono" style={{ color: b.requests_err > 0 ? 'var(--err)' : undefined }}>
|
||||
{b.requests_err}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useMetrics } from '../../api/queries'
|
||||
import LiveFeed from '../../components/feed/LiveFeed'
|
||||
import ObservabilityPanel from './ObservabilityPanel'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: m } = useMetrics()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="stats-row">
|
||||
<div className="stat">
|
||||
<div className="stat-label">Requests/min</div>
|
||||
<div className="stat-value">{m ? m.requests_per_minute.toFixed(1) : '—'}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label">Error Rate</div>
|
||||
<div className="stat-value">{m ? `${(m.error_rate * 100).toFixed(1)}%` : '—'}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label">P50 Latency</div>
|
||||
<div className="stat-value">{m ? `${m.p50_latency_ms}ms` : '—'}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label">P95 Latency</div>
|
||||
<div className="stat-value">{m ? `${m.p95_latency_ms}ms` : '—'}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label">Total Requests</div>
|
||||
<div className="stat-value">{m ? m.total_requests.toLocaleString() : '0'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats-row" style={{ marginBottom: 16 }}>
|
||||
<div className="stat">
|
||||
<div className="stat-label">Streams Started</div>
|
||||
<div className="stat-value">{m?.streams_started ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label">Completed</div>
|
||||
<div className="stat-value ok">{m?.streams_completed ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label">Failed</div>
|
||||
<div className="stat-value" style={{ color: 'var(--err)' }}>{m?.streams_failed ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label">Client Disconnects</div>
|
||||
<div className="stat-value" style={{ color: 'var(--warn)' }}>{m?.streams_client_disconnected ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ObservabilityPanel />
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<LiveFeed />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react'
|
||||
import { useObservability } from '../../api/queries'
|
||||
import LineChart from '../../components/shared/LineChart'
|
||||
import EmptyState from '../../components/shared/EmptyState'
|
||||
|
||||
export default function ObservabilityPanel() {
|
||||
const [windowHours, setWindowHours] = useState(6)
|
||||
const [backend, setBackend] = useState('')
|
||||
const { data, isLoading, error } = useObservability(windowHours, backend)
|
||||
|
||||
const reqSeries = data
|
||||
? [
|
||||
{ label: 'Requests', color: '#e8a030', data: data.series.map((p) => p.requests) },
|
||||
{ label: 'Errors', color: '#e05252', data: data.series.map((p) => p.errors), secondary: true },
|
||||
]
|
||||
: []
|
||||
|
||||
const tokenSeries = data
|
||||
? [
|
||||
{ label: 'Input', color: '#4caf6e', data: data.series.map((p) => p.input_tokens) },
|
||||
{ label: 'Output', color: '#6eb5c0', data: data.series.map((p) => p.output_tokens), secondary: true },
|
||||
]
|
||||
: []
|
||||
|
||||
const costSeries = data
|
||||
? [{ label: 'Cost', color: '#c87dd4', data: data.series.map((p) => p.cost_usd) }]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="operator-controls">
|
||||
<span className="section-label" style={{ marginBottom: 0 }}>Operator View</span>
|
||||
<div className="form-row" style={{ flexWrap: 'wrap', gap: 6, marginTop: 0 }}>
|
||||
<select value={windowHours} onChange={(e) => setWindowHours(Number(e.target.value))}>
|
||||
<option value={1}>Last 1 hour</option>
|
||||
<option value={6}>Last 6 hours</option>
|
||||
<option value={24}>Last 24 hours</option>
|
||||
</select>
|
||||
<select value={backend} onChange={(e) => setBackend(e.target.value)}>
|
||||
<option value="">All backends</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="stats-row">
|
||||
<div className="stat">
|
||||
<div className="stat-label">Input Tokens</div>
|
||||
<div className="stat-value">{data.total_input_tokens.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label">Output Tokens</div>
|
||||
<div className="stat-value">{data.total_output_tokens.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label">Window Failures</div>
|
||||
<div className="stat-value">{data.total_errors}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label">Window Cost</div>
|
||||
<div className="stat-value">${data.total_cost_usd.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EmptyState loading={isLoading} error={error?.message} />
|
||||
|
||||
{data && (
|
||||
<div className="operator-grid">
|
||||
<div className="chart-card">
|
||||
<div className="chart-header">
|
||||
<div>
|
||||
<div className="chart-title">Request Volume</div>
|
||||
<div className="chart-subtitle">Rolling request count and errors</div>
|
||||
</div>
|
||||
<div className="chart-value">{data.total_requests}</div>
|
||||
</div>
|
||||
<LineChart series={reqSeries} />
|
||||
</div>
|
||||
<div className="chart-card">
|
||||
<div className="chart-header">
|
||||
<div>
|
||||
<div className="chart-title">Tokens</div>
|
||||
<div className="chart-subtitle">Input and output usage</div>
|
||||
</div>
|
||||
<div className="chart-value">{(data.total_input_tokens + data.total_output_tokens).toLocaleString()}</div>
|
||||
</div>
|
||||
<LineChart series={tokenSeries} />
|
||||
</div>
|
||||
<div className="chart-card">
|
||||
<div className="chart-header">
|
||||
<div>
|
||||
<div className="chart-title">Estimated Cost</div>
|
||||
<div className="chart-subtitle">USD by minute bucket</div>
|
||||
</div>
|
||||
<div className="chart-value">${data.total_cost_usd.toFixed(4)}</div>
|
||||
</div>
|
||||
<LineChart series={costSeries} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from 'react'
|
||||
import { useCreateKey } from '../../api/queries'
|
||||
|
||||
export default function KeyCreateForm({ onCreated }: { onCreated: (key: string) => void }) {
|
||||
const create = useCreateKey()
|
||||
const [desc, setDesc] = useState('')
|
||||
const [spendLimit, setSpendLimit] = useState('')
|
||||
const [rpmLimit, setRpmLimit] = useState('')
|
||||
|
||||
function handleSubmit() {
|
||||
create.mutate({
|
||||
description: desc || null,
|
||||
spend_limit: spendLimit ? Number(spendLimit) : null,
|
||||
rpm_limit: rpmLimit ? Number(rpmLimit) : null,
|
||||
}, {
|
||||
onSuccess: (res) => {
|
||||
setDesc(''); setSpendLimit(''); setRpmLimit('')
|
||||
onCreated(res.key)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="form-label">Create Key</div>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSubmit() }}>
|
||||
<div className="form-row" style={{ flexWrap: 'wrap' }}>
|
||||
<input placeholder="Description" value={desc} onChange={(e) => setDesc(e.target.value)} />
|
||||
<input placeholder="Spend limit USD" type="number" value={spendLimit} onChange={(e) => setSpendLimit(e.target.value)} style={{ width: 120 }} />
|
||||
<input placeholder="RPM limit" type="number" value={rpmLimit} onChange={(e) => setRpmLimit(e.target.value)} style={{ width: 100 }} />
|
||||
<button type="submit" className="btn btn-primary" disabled={create.isPending}>
|
||||
{create.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
import type { VirtualKey } from '../../api/types'
|
||||
import { useUpdateKey, useRevokeKey } from '../../api/queries'
|
||||
|
||||
interface KeyEditModalProps {
|
||||
vk: VirtualKey
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function KeyEditModal({ vk, onClose }: KeyEditModalProps) {
|
||||
const update = useUpdateKey()
|
||||
const revoke = useRevokeKey()
|
||||
const [desc, setDesc] = useState(vk.description ?? '')
|
||||
const [spendLimit, setSpendLimit] = useState(vk.spend_limit?.toString() ?? '')
|
||||
const [rpmLimit, setRpmLimit] = useState(vk.rpm_limit?.toString() ?? '')
|
||||
|
||||
function handleSave() {
|
||||
update.mutate({
|
||||
id: vk.id,
|
||||
body: {
|
||||
description: desc || null,
|
||||
spend_limit: spendLimit ? Number(spendLimit) : null,
|
||||
rpm_limit: rpmLimit ? Number(rpmLimit) : null,
|
||||
},
|
||||
}, { onSuccess: onClose })
|
||||
}
|
||||
|
||||
function handleRevoke() {
|
||||
if (!confirm('Revoke this key?')) return
|
||||
revoke.mutate(vk.id, { onSuccess: onClose })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-title">Edit Key — {vk.key_prefix}…</div>
|
||||
<div className="form-group">
|
||||
<div className="form-label">Description</div>
|
||||
<input value={desc} onChange={(e) => setDesc(e.target.value)} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="form-label">Spend limit (USD)</div>
|
||||
<input value={spendLimit} onChange={(e) => setSpendLimit(e.target.value)} type="number" min="0" step="0.01" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="form-label">RPM limit</div>
|
||||
<input value={rpmLimit} onChange={(e) => setRpmLimit(e.target.value)} type="number" min="0" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<button className="btn btn-primary" onClick={handleSave}>Save</button>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-danger" style={{ marginLeft: 'auto' }} onClick={handleRevoke}>Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react'
|
||||
import { useKeys } from '../../api/queries'
|
||||
import Badge from '../../components/shared/Badge'
|
||||
import BudgetBar from '../../components/shared/BudgetBar'
|
||||
import EmptyState from '../../components/shared/EmptyState'
|
||||
import KeyCreateForm from './KeyCreateForm'
|
||||
import KeyEditModal from './KeyEditModal'
|
||||
import type { VirtualKey } from '../../api/types'
|
||||
|
||||
export default function Keys() {
|
||||
const { data, isLoading, error } = useKeys()
|
||||
const [newKey, setNewKey] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<VirtualKey | null>(null)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<KeyCreateForm onCreated={setNewKey} />
|
||||
{newKey && (
|
||||
<div className="key-result">
|
||||
<div className="key-result-label">New key (copy now — not shown again)</div>
|
||||
{newKey}
|
||||
</div>
|
||||
)}
|
||||
<EmptyState loading={isLoading} error={error?.message} empty={data?.length === 0} message="No keys" />
|
||||
{data && data.length > 0 && (
|
||||
<table className="keys-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Prefix</th><th>Description</th><th>Status</th>
|
||||
<th>Spend</th><th>Requests</th><th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((k) => (
|
||||
<tr key={k.id} style={{ cursor: 'pointer' }} onClick={() => setEditing(k)}>
|
||||
<td className="mono">{k.key_prefix}…</td>
|
||||
<td className="dim">{k.description ?? '—'}</td>
|
||||
<td><Badge variant={k.status} /></td>
|
||||
<td><BudgetBar spent={k.total_spend} limit={k.spend_limit} /></td>
|
||||
<td className="mono">{k.total_requests.toLocaleString()}</td>
|
||||
<td className="mono dim">{k.created_at.slice(0, 10)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{editing && <KeyEditModal key={editing.id} vk={editing} onClose={() => setEditing(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react'
|
||||
import { useModels, useAddModel, useRemoveModel } from '../../api/queries'
|
||||
import EmptyState from '../../components/shared/EmptyState'
|
||||
|
||||
export default function Models() {
|
||||
const { data, isLoading, error } = useModels()
|
||||
const add = useAddModel()
|
||||
const remove = useRemoveModel()
|
||||
const [name, setName] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const [provider, setProvider] = useState('openai')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="form-group">
|
||||
<div className="form-label">Add Model</div>
|
||||
<div className="form-row" style={{ flexWrap: 'wrap' }}>
|
||||
<input placeholder="Virtual name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input placeholder="Model ID" value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
<select value={provider} onChange={(e) => setProvider(e.target.value)}>
|
||||
<option value="openai">openai</option>
|
||||
<option value="anthropic">anthropic</option>
|
||||
<option value="gemini">gemini</option>
|
||||
<option value="vertex">vertex</option>
|
||||
<option value="azure">azure</option>
|
||||
<option value="bedrock">bedrock</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => add.mutate({ name, model, provider })}
|
||||
disabled={!name || !model || add.isPending}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState loading={isLoading} error={error?.message} />
|
||||
{data && (
|
||||
<table className="route-table">
|
||||
<thead>
|
||||
<tr><th>Virtual Name</th><th>Model</th><th>Provider</th><th>Strategy</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.models.map((m) => (
|
||||
<tr key={`${m.name}-${m.model}`}>
|
||||
<td className="mono">{m.name}</td>
|
||||
<td className="mono">{m.model}</td>
|
||||
<td className="dim">{m.provider}</td>
|
||||
<td className="dim">{data.routing_strategy}</td>
|
||||
<td>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => remove.mutate(m.name)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react'
|
||||
import { useRequests } from '../../api/queries'
|
||||
import FeedRow from '../../components/feed/FeedRow'
|
||||
import Pagination from '../../components/shared/Pagination'
|
||||
import EmptyState from '../../components/shared/EmptyState'
|
||||
|
||||
export default function RequestLog() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [backend, setBackend] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const { data, isLoading, error } = useRequests({ page, page_size: 50, backend, status })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-header">
|
||||
<span className="section-label">Request Log</span>
|
||||
<div className="form-row" style={{ marginTop: 0 }}>
|
||||
<select value={backend} onChange={(e) => { setBackend(e.target.value); setPage(1) }}>
|
||||
<option value="">All backends</option>
|
||||
</select>
|
||||
<select value={status} onChange={(e) => { setStatus(e.target.value); setPage(1) }}>
|
||||
<option value="">All status</option>
|
||||
<option value="ok">2xx</option>
|
||||
<option value="error">4xx/5xx</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState loading={isLoading} error={error?.message} />
|
||||
{data && (
|
||||
<>
|
||||
<div className="feed">
|
||||
<div className="feed-header">
|
||||
<span>Time</span><span>Status</span><span>Latency</span>
|
||||
<span>Model</span><span>In</span><span>Out</span><span>Cost</span>
|
||||
</div>
|
||||
{data.requests.map((r) => <FeedRow key={r.request_id} req={r} />)}
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
hasMore={data.has_more}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => p + 1)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Fragment, useState } from 'react'
|
||||
import { useConfig, useSaveConfig, useDeleteConfigOverride, useEnv } from '../../api/queries'
|
||||
import EmptyState from '../../components/shared/EmptyState'
|
||||
|
||||
export default function Settings() {
|
||||
const { data: cfg, isLoading, error } = useConfig()
|
||||
const { data: envData } = useEnv()
|
||||
const save = useSaveConfig()
|
||||
const del = useDeleteConfigOverride()
|
||||
const [form, setForm] = useState<Record<string, string>>({})
|
||||
|
||||
function handleSave(key: string, currentValue: string) {
|
||||
save.mutate({ [key]: form[key] ?? currentValue })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EmptyState loading={isLoading} error={error?.message} />
|
||||
{cfg && (
|
||||
<div>
|
||||
{cfg.entries.map((entry) => (
|
||||
<div className="form-group" key={entry.key}>
|
||||
<div className="form-label">{entry.key}</div>
|
||||
<div className="form-row">
|
||||
<input
|
||||
value={form[entry.key] ?? entry.value}
|
||||
onChange={(e) => setForm((f) => ({ ...f, [entry.key]: e.target.value }))}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleSave(entry.key, entry.value)}>Save</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => del.mutate(entry.key)}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{envData && (
|
||||
<div className="readonly-section" style={{ marginTop: 16 }}>
|
||||
<div className="section-label">Environment</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', gap: '4px 12px', marginTop: 8, fontSize: 12 }}>
|
||||
{Object.entries(envData).map(([k, v]) => (
|
||||
<Fragment key={k}>
|
||||
<span className="dim">{k}</span>
|
||||
<span className="mono">{v}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { RouteMetrics } from '../../api/types'
|
||||
|
||||
export default function RouteTable({ routes }: { routes: RouteMetrics[] }) {
|
||||
const sorted = [...routes].sort((a, b) => b.requests_per_min - a.requests_per_min)
|
||||
return (
|
||||
<table className="route-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Route</th>
|
||||
<th>Req/min</th>
|
||||
<th>Error rate</th>
|
||||
<th>Avg latency</th>
|
||||
<th>P95 latency</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((r) => (
|
||||
<tr key={r.path}>
|
||||
<td className="mono">{r.path}</td>
|
||||
<td className="mono">{r.requests_per_min.toFixed(2)}</td>
|
||||
<td className="mono" style={{ color: r.error_rate > 0.05 ? 'var(--err)' : r.error_rate > 0.01 ? 'var(--warn)' : undefined }}>
|
||||
{(r.error_rate * 100).toFixed(1)}%
|
||||
</td>
|
||||
<td className="mono">{r.avg_latency_ms.toFixed(0)}ms</td>
|
||||
<td className="mono">{r.p95_latency_ms}ms</td>
|
||||
<td className="mono">{r.total_requests.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { useTraffic } from '../../api/queries'
|
||||
import RouteTable from './RouteTable'
|
||||
import LineChart from '../../components/shared/LineChart'
|
||||
import EmptyState from '../../components/shared/EmptyState'
|
||||
|
||||
// Amber palette for requests/min chart
|
||||
const AMBER_COLORS = ['#e8a030', '#d4922b', '#c07820', '#a86015', '#8c500a']
|
||||
// Teal palette for payload bar chart
|
||||
const TEAL_COLORS = ['#6eb5c0', '#5aa0ab', '#468b96', '#327681', '#1e616c']
|
||||
|
||||
|
||||
export default function TrafficView() {
|
||||
const [windowHours, setWindowHours] = useState(6)
|
||||
const { data, isLoading, error } = useTraffic(windowHours)
|
||||
|
||||
const routes = data?.routes ?? []
|
||||
const series = routes.slice(0, 5).map((r, i) => {
|
||||
const points = (data?.series ?? [])
|
||||
.filter((p) => p.path === r.path)
|
||||
.map((p) => p.requests)
|
||||
return { label: r.path, color: AMBER_COLORS[i % AMBER_COLORS.length], data: points }
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-header">
|
||||
<span className="section-label">Traffic</span>
|
||||
<select value={windowHours} onChange={(e) => setWindowHours(Number(e.target.value))}>
|
||||
<option value={1}>Last 1 hour</option>
|
||||
<option value={6}>Last 6 hours</option>
|
||||
<option value={24}>Last 24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<EmptyState loading={isLoading} error={error?.message} />
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<RouteTable routes={data.routes} />
|
||||
|
||||
<div className="operator-grid" style={{ marginTop: 16 }}>
|
||||
<div className="chart-card">
|
||||
<div className="chart-header">
|
||||
<div>
|
||||
<div className="chart-title">Requests / min by route</div>
|
||||
<div className="chart-subtitle">Stacked over time window</div>
|
||||
</div>
|
||||
</div>
|
||||
<LineChart series={series} />
|
||||
</div>
|
||||
<div className="chart-card">
|
||||
<div className="chart-header">
|
||||
<div>
|
||||
<div className="chart-title">Avg latency per route</div>
|
||||
<div className="chart-subtitle">ms</div>
|
||||
</div>
|
||||
</div>
|
||||
{routes.length === 0 ? (
|
||||
<div className="empty">No routes</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 8 }}>
|
||||
{routes.slice(0, 5).map((r, i) => {
|
||||
const maxLatency = Math.max(...routes.slice(0, 5).map((x) => x.avg_latency_ms), 1)
|
||||
const pct = (r.avg_latency_ms / maxLatency) * 100
|
||||
return (
|
||||
<div key={r.path}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, marginBottom: 2 }}>
|
||||
<span className="mono dim" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{r.path}</span>
|
||||
<span className="mono">{r.avg_latency_ms.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--border)', borderRadius: 0 }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: TEAL_COLORS[i % TEAL_COLORS.length], borderRadius: 0 }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { BackendUptimeInfo } from '../../api/types'
|
||||
import StatusDot from '../../components/shared/StatusDot'
|
||||
|
||||
export default function BackendHealthRow({ b }: { b: BackendUptimeInfo }) {
|
||||
const dotStatus: 'ok' | 'err' | 'dim' = b.status === 'up' ? 'ok' : b.status === 'down' ? 'err' : 'dim'
|
||||
const lastChecked = b.last_checked_at
|
||||
? new Date(b.last_checked_at * 1000).toLocaleTimeString()
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="mono">{b.name}</td>
|
||||
<td>
|
||||
<StatusDot status={dotStatus} pulse={b.status === 'up'} />
|
||||
{b.status}
|
||||
</td>
|
||||
<td className="mono">{b.uptime_pct_30d.toFixed(2)}%</td>
|
||||
<td className="mono dim">{lastChecked}</td>
|
||||
<td className="mono dim">{b.last_latency_ms != null ? `${b.last_latency_ms}ms` : '—'}</td>
|
||||
<td>
|
||||
<div className="history-bar" style={{ height: 12 }}>
|
||||
{b.history.map((day) => (
|
||||
<div
|
||||
key={day.date}
|
||||
className={`history-day ${day.status}`}
|
||||
title={`${day.date}: ${day.status}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ProxyUptimeInfo } from '../../api/types'
|
||||
|
||||
function formatDuration(startedAt: number) {
|
||||
const secs = Math.floor(Date.now() / 1000 - startedAt)
|
||||
const d = Math.floor(secs / 86400)
|
||||
const h = Math.floor((secs % 86400) / 3600)
|
||||
const m = Math.floor((secs % 3600) / 60)
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
export default function ProxyHealth({ proxy }: { proxy: ProxyUptimeInfo }) {
|
||||
return (
|
||||
<div className="uptime-proxy">
|
||||
<div className="uptime-proxy-stats">
|
||||
<div>
|
||||
<div className="section-label">Uptime (30d)</div>
|
||||
<div className="uptime-pct">{proxy.uptime_pct_30d.toFixed(2)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label">Running</div>
|
||||
<div className="stat-value" style={{ fontSize: 16 }}>{formatDuration(proxy.started_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-label" style={{ marginBottom: 4 }}>30-day history</div>
|
||||
<div className="history-bar">
|
||||
{proxy.history.map((day) => (
|
||||
<div
|
||||
key={day.date}
|
||||
className={`history-day ${day.status}`}
|
||||
title={`${day.date}: ${day.status}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useUptime } from '../../api/queries'
|
||||
import ProxyHealth from './ProxyHealth'
|
||||
import BackendHealthRow from './BackendHealthRow'
|
||||
import EmptyState from '../../components/shared/EmptyState'
|
||||
|
||||
export default function UptimeView() {
|
||||
const { data, isLoading, error } = useUptime()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EmptyState loading={isLoading} error={error?.message} />
|
||||
{data && (
|
||||
<>
|
||||
<ProxyHealth proxy={data.proxy} />
|
||||
<div className="section-label" style={{ marginTop: 16, marginBottom: 8 }}>Backend Availability</div>
|
||||
<table className="backend-health-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Backend</th>
|
||||
<th>Status</th>
|
||||
<th>Uptime (30d)</th>
|
||||
<th>Last checked</th>
|
||||
<th>Latency</th>
|
||||
<th>History</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.backends
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((b) => <BackendHealthRow key={b.name} b={b} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile'
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
function injectCspNonce() {
|
||||
return {
|
||||
name: 'inject-csp-nonce',
|
||||
apply: 'build' as const,
|
||||
closeBundle() {
|
||||
const outFile = resolve(__dirname, 'dist/index.html')
|
||||
let html = readFileSync(outFile, 'utf-8')
|
||||
// Add nonce to generated <style> and <script> tags (skip tags that already have one)
|
||||
html = html.replace(/<style(?![^>]*nonce)/g, '<style nonce="__CSP_NONCE__"')
|
||||
html = html.replace(/<script(?![^>]*nonce)/g, '<script nonce="__CSP_NONCE__"')
|
||||
writeFileSync(outFile, html)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), viteSingleFile(), injectCspNonce()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
target: 'es2020',
|
||||
assetsInlineLimit: 100_000_000,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -115,9 +115,117 @@ pub fn init_db(conn: &Connection) -> rusqlite::Result<()> {
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_log_key_id ON request_log(key_id);",
|
||||
)?;
|
||||
|
||||
// health_checks table for backend uptime tracking.
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS health_checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
backend TEXT NOT NULL,
|
||||
checked_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
latency_ms INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_health_checks_backend_time
|
||||
ON health_checks (backend, checked_at DESC);
|
||||
",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prune health_checks rows older than 31 days. Called each write cycle.
|
||||
pub fn prune_health_checks(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let cutoff = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
- 31 * 24 * 3600;
|
||||
conn.execute("DELETE FROM health_checks WHERE checked_at < ?1", [cutoff])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record one health check result and prune old rows.
|
||||
pub fn insert_health_check(
|
||||
conn: &Connection,
|
||||
backend: &str,
|
||||
status: &str,
|
||||
latency_ms: Option<u64>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
conn.execute(
|
||||
"INSERT INTO health_checks (backend, checked_at, status, latency_ms) VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![backend, now, status, latency_ms.map(|v| v as i64)],
|
||||
)?;
|
||||
prune_health_checks(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the uptime percentage for a backend over the last 30 days.
|
||||
pub fn backend_uptime_pct(conn: &Connection, backend: &str) -> rusqlite::Result<f64> {
|
||||
let cutoff = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
- 30 * 24 * 3600;
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM health_checks WHERE backend = ?1 AND checked_at >= ?2",
|
||||
rusqlite::params![backend, cutoff],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if total == 0 {
|
||||
return Ok(100.0);
|
||||
}
|
||||
let up: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM health_checks WHERE backend = ?1 AND checked_at >= ?2 AND status = 'up'",
|
||||
rusqlite::params![backend, cutoff],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok((up as f64 / total as f64) * 100.0)
|
||||
}
|
||||
|
||||
/// Returns per-day status for the last 30 days (date string -> 'up'|'down'|'degraded').
|
||||
pub fn backend_history_30d(
|
||||
conn: &Connection,
|
||||
backend: &str,
|
||||
) -> rusqlite::Result<Vec<(String, String)>> {
|
||||
let cutoff = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
- 30 * 24 * 3600;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
date(checked_at, 'unixepoch') AS day,
|
||||
SUM(CASE WHEN status='up' THEN 1 ELSE 0 END) AS ups,
|
||||
COUNT(*) AS total
|
||||
FROM health_checks
|
||||
WHERE backend = ?1 AND checked_at >= ?2
|
||||
GROUP BY day
|
||||
ORDER BY day ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![backend, cutoff], |r| {
|
||||
let day: String = r.get(0)?;
|
||||
let ups: i64 = r.get(1)?;
|
||||
let total: i64 = r.get(2)?;
|
||||
let status = if ups == total {
|
||||
"up".to_string()
|
||||
} else if ups == 0 {
|
||||
"down".to_string()
|
||||
} else {
|
||||
"degraded".to_string()
|
||||
};
|
||||
Ok((day, status))
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
/// Ensure an HMAC secret exists in the settings table. Creates one if missing.
|
||||
/// Returns the 32-byte secret used for HMAC-SHA256 key hashing.
|
||||
/// The secret is generated from two UUID v4s (uuid is already a dep) to avoid
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Background task: probes each backend every 30 seconds and records results.
|
||||
// Backend URLs are snapshotted at startup and passed directly; they do not
|
||||
// change at runtime (base URLs are static config, not admin-mutable).
|
||||
|
||||
use crate::admin::db::insert_health_check;
|
||||
use crate::admin::state::SharedState;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Probe a single backend URL. Returns (is_up, latency_ms).
|
||||
/// 401 is treated as "up" (server is reachable, just requires auth).
|
||||
async fn probe_backend(client: &reqwest::Client, base_url: &str) -> (bool, Option<u64>) {
|
||||
let url = format!("{}/v1/models", base_url.trim_end_matches('/'));
|
||||
let start = std::time::Instant::now();
|
||||
match client
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
let status = resp.status().as_u16();
|
||||
let is_up = resp.status().is_success() || matches!(status, 401 | 403 | 404);
|
||||
(is_up, Some(latency))
|
||||
}
|
||||
Err(_) => (false, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the health-checker loop. Call once at admin server startup.
|
||||
/// `backend_urls`: snapshot of (backend_name, base_url) pairs from the initial config.
|
||||
pub fn spawn(shared: SharedState, backend_urls: Vec<(String, String)>) {
|
||||
tokio::spawn(async move {
|
||||
let client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(6))
|
||||
.build()
|
||||
.expect("health check reqwest client"),
|
||||
);
|
||||
|
||||
// Track previous up/down state per backend to only broadcast on transitions.
|
||||
let mut last_status: HashMap<String, bool> = HashMap::new();
|
||||
|
||||
loop {
|
||||
for (name, base_url) in &backend_urls {
|
||||
let (is_up, latency_ms) = probe_backend(&client, base_url).await;
|
||||
let status_str = if is_up { "up" } else { "down" };
|
||||
|
||||
// Write to DB on the blocking threadpool (rusqlite is sync).
|
||||
let name_c = name.clone();
|
||||
let latency_c = latency_ms;
|
||||
let db = shared.db.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
let conn = db.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Err(e) = insert_health_check(&conn, &name_c, status_str, latency_c) {
|
||||
tracing::warn!(
|
||||
backend = %name_c,
|
||||
error = %e,
|
||||
"failed to record health check"
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
// Broadcast only on state transitions (up->down or down->up).
|
||||
let prev = last_status.insert(name.clone(), is_up);
|
||||
if prev != Some(is_up) {
|
||||
let _ =
|
||||
shared
|
||||
.events_tx
|
||||
.send(crate::admin::state::AdminEvent::BackendHealthChanged {
|
||||
backend: name.clone(),
|
||||
status: status_str.to_string(),
|
||||
latency_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
pub mod auth;
|
||||
/// SQLite persistence for request logs and config overrides.
|
||||
pub mod db;
|
||||
/// Background health-checker: probes backends every 30 seconds.
|
||||
pub mod health_check;
|
||||
/// Virtual API key generation, hashing, and rate limit state.
|
||||
pub mod keys;
|
||||
/// Admin HTTP router: config management, request log queries, metrics.
|
||||
|
||||
@@ -6,6 +6,8 @@ pub mod keys;
|
||||
pub mod logs;
|
||||
pub mod mcp;
|
||||
pub mod models;
|
||||
pub mod traffic;
|
||||
pub mod uptime;
|
||||
|
||||
use crate::admin::auth::{
|
||||
extract_csrf_cookie, generate_csrf_token, validate_admin_token, validate_csrf_tokens,
|
||||
@@ -407,6 +409,8 @@ pub fn admin_router(shared: SharedState, token: Arc<zeroize::Zeroizing<String>>)
|
||||
"/admin/api/mcp-servers/{name}",
|
||||
delete(mcp::remove_mcp_server),
|
||||
)
|
||||
.route("/admin/api/traffic", get(traffic::get_traffic))
|
||||
.route("/admin/api/uptime", get(uptime::get_uptime))
|
||||
.with_state(shared.clone())
|
||||
// Innermost: CSRF check runs after auth succeeds.
|
||||
.layer(middleware::from_fn_with_state(
|
||||
@@ -446,7 +450,7 @@ async fn health() -> Json<serde_json::Value> {
|
||||
}
|
||||
|
||||
/// Serve the embedded SPA HTML with a per-request CSP nonce.
|
||||
static SPA_HTML: &str = include_str!("../../../admin-ui/index.html");
|
||||
static SPA_HTML: &str = include_str!("../../../admin-ui/dist/index.html");
|
||||
|
||||
async fn serve_spa() -> axum::response::Response {
|
||||
// Generate a per-request nonce (128-bit, base64url-encoded).
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// GET /admin/api/traffic?window=N
|
||||
// Aggregates request_log by route (model_mapped/backend) and time bucket.
|
||||
|
||||
use crate::admin::state::{with_db, SharedState};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TrafficQuery {
|
||||
#[serde(default = "default_window")]
|
||||
window: u32,
|
||||
}
|
||||
|
||||
fn default_window() -> u32 {
|
||||
6
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RouteMetrics {
|
||||
path: String,
|
||||
requests_per_min: f64,
|
||||
error_rate: f64,
|
||||
avg_latency_ms: f64,
|
||||
p95_latency_ms: i64,
|
||||
total_requests: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TrafficSeriesPoint {
|
||||
bucket_start: i64,
|
||||
path: String,
|
||||
requests: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TrafficResponse {
|
||||
window_hours: u32,
|
||||
routes: Vec<RouteMetrics>,
|
||||
series: Vec<TrafficSeriesPoint>,
|
||||
}
|
||||
|
||||
pub(super) async fn get_traffic(
|
||||
State(shared): State<SharedState>,
|
||||
Query(q): Query<TrafficQuery>,
|
||||
) -> Json<TrafficResponse> {
|
||||
let window_hours = q.window.clamp(1, 168);
|
||||
let result = with_db(&shared.db, move |conn| query_traffic(conn, window_hours))
|
||||
.await
|
||||
.flatten()
|
||||
.unwrap_or_else(|| TrafficResponse {
|
||||
window_hours,
|
||||
routes: vec![],
|
||||
series: vec![],
|
||||
});
|
||||
Json(result)
|
||||
}
|
||||
|
||||
fn query_traffic(conn: &rusqlite::Connection, window_hours: u32) -> Option<TrafficResponse> {
|
||||
let since = chrono::Utc::now() - chrono::Duration::hours(window_hours as i64);
|
||||
let since_str = since.format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||
let window_min = window_hours as f64 * 60.0;
|
||||
|
||||
// Per-route aggregate.
|
||||
let mut routes: Vec<RouteMetrics> = {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT
|
||||
COALESCE(model_mapped, backend) AS path,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) AS errors,
|
||||
COUNT(*) * 1.0 / ?1 AS rpm,
|
||||
AVG(latency_ms) AS avg_latency
|
||||
FROM request_log
|
||||
WHERE timestamp >= ?2
|
||||
GROUP BY path
|
||||
ORDER BY total DESC",
|
||||
)
|
||||
.ok()?;
|
||||
let collected: Vec<RouteMetrics> = stmt
|
||||
.query_map(rusqlite::params![window_min, since_str], |r| {
|
||||
let path: String = r.get(0)?;
|
||||
let total: i64 = r.get(1)?;
|
||||
let errors: i64 = r.get(2)?;
|
||||
let rpm: f64 = r.get(3)?;
|
||||
let avg_latency: f64 = r.get::<_, Option<f64>>(4)?.unwrap_or(0.0);
|
||||
Ok(RouteMetrics {
|
||||
path,
|
||||
requests_per_min: rpm,
|
||||
error_rate: if total > 0 {
|
||||
errors as f64 / total as f64
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
avg_latency_ms: avg_latency,
|
||||
p95_latency_ms: 0,
|
||||
total_requests: total,
|
||||
})
|
||||
})
|
||||
.ok()?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
collected
|
||||
};
|
||||
|
||||
// P95 per route via percentile-offset query.
|
||||
for r in routes.iter_mut() {
|
||||
let path_c = r.path.clone();
|
||||
if let Ok(mut stmt) = conn.prepare(
|
||||
"SELECT latency_ms FROM request_log
|
||||
WHERE timestamp >= ?1 AND COALESCE(model_mapped, backend) = ?2
|
||||
ORDER BY latency_ms
|
||||
LIMIT 1 OFFSET CAST(0.95 * (
|
||||
SELECT COUNT(*) FROM request_log
|
||||
WHERE timestamp >= ?1 AND COALESCE(model_mapped, backend) = ?2
|
||||
) AS INTEGER)",
|
||||
) {
|
||||
if let Ok(p95) = stmt.query_row(
|
||||
rusqlite::params![since_str, path_c],
|
||||
|row| row.get::<_, i64>(0),
|
||||
) {
|
||||
r.p95_latency_ms = p95;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Time-bucketed series (5-minute buckets).
|
||||
let series: Vec<TrafficSeriesPoint> = {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT
|
||||
CAST(strftime('%s', timestamp) AS INTEGER) / 300 * 300 AS bucket,
|
||||
COALESCE(model_mapped, backend) AS path,
|
||||
COUNT(*) AS requests
|
||||
FROM request_log
|
||||
WHERE timestamp >= ?1
|
||||
GROUP BY bucket, path
|
||||
ORDER BY bucket ASC",
|
||||
)
|
||||
.ok()?;
|
||||
let collected: Vec<TrafficSeriesPoint> = stmt
|
||||
.query_map(rusqlite::params![since_str], |r| {
|
||||
let bucket: i64 = r.get(0)?;
|
||||
let path: String = r.get(1)?;
|
||||
let requests: i64 = r.get(2)?;
|
||||
Ok(TrafficSeriesPoint {
|
||||
bucket_start: bucket,
|
||||
path,
|
||||
requests,
|
||||
})
|
||||
})
|
||||
.ok()?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
collected
|
||||
};
|
||||
|
||||
Some(TrafficResponse {
|
||||
window_hours,
|
||||
routes,
|
||||
series,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// GET /admin/api/uptime
|
||||
|
||||
use crate::admin::db::{backend_history_30d, backend_uptime_pct};
|
||||
use crate::admin::state::{with_db, SharedState};
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HistoryDay {
|
||||
date: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ProxyUptimeInfo {
|
||||
started_at: u64,
|
||||
uptime_pct_30d: f64,
|
||||
history: Vec<HistoryDay>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BackendUptimeInfo {
|
||||
name: String,
|
||||
status: String,
|
||||
last_checked_at: Option<i64>,
|
||||
last_latency_ms: Option<i64>,
|
||||
uptime_pct_30d: f64,
|
||||
history: Vec<HistoryDay>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UptimeResponse {
|
||||
proxy: ProxyUptimeInfo,
|
||||
backends: Vec<BackendUptimeInfo>,
|
||||
}
|
||||
|
||||
pub(super) async fn get_uptime(State(shared): State<SharedState>) -> Json<UptimeResponse> {
|
||||
let started_at = shared
|
||||
.started_at
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Snapshot backend names from RuntimeConfig (holds the admin-visible backend list).
|
||||
let backend_names: Vec<String> = {
|
||||
let cfg = shared
|
||||
.runtime_config
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
cfg.model_mappings.keys().cloned().collect()
|
||||
};
|
||||
|
||||
let response = with_db(&shared.db, move |conn| {
|
||||
// Aggregate proxy-level history: any day where any backend was down counts as down.
|
||||
let proxy_history: Vec<HistoryDay> = conn
|
||||
.prepare(
|
||||
"SELECT date(checked_at, 'unixepoch') AS day,
|
||||
MIN(CASE WHEN status='down' THEN 0 ELSE 1 END) AS all_up
|
||||
FROM health_checks
|
||||
WHERE checked_at >= strftime('%s','now') - 30*86400
|
||||
GROUP BY day
|
||||
ORDER BY day ASC",
|
||||
)
|
||||
.ok()
|
||||
.and_then(|mut stmt| {
|
||||
stmt.query_map([], |r| {
|
||||
Ok(HistoryDay {
|
||||
date: r.get(0)?,
|
||||
status: if r.get::<_, i64>(1)? == 1 {
|
||||
"up".to_string()
|
||||
} else {
|
||||
"down".to_string()
|
||||
},
|
||||
})
|
||||
})
|
||||
.ok()
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let backends: Vec<BackendUptimeInfo> = backend_names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let uptime_pct = backend_uptime_pct(conn, name).unwrap_or(100.0);
|
||||
let history = backend_history_30d(conn, name)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(date, status)| HistoryDay { date, status })
|
||||
.collect();
|
||||
|
||||
let (last_checked_at, last_latency_ms, current_status) = conn
|
||||
.query_row(
|
||||
"SELECT checked_at, latency_ms, status FROM health_checks
|
||||
WHERE backend = ?1 ORDER BY checked_at DESC LIMIT 1",
|
||||
[name.as_str()],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, Option<i64>>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.map(|(ts, lat, st)| (Some(ts), lat, st))
|
||||
.unwrap_or((None, None, "unknown".to_string()));
|
||||
|
||||
BackendUptimeInfo {
|
||||
name: name.clone(),
|
||||
status: current_status,
|
||||
last_checked_at,
|
||||
last_latency_ms,
|
||||
uptime_pct_30d: uptime_pct,
|
||||
history,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let down_days = proxy_history.iter().filter(|d| d.status == "down").count();
|
||||
let total_days = proxy_history.len();
|
||||
let proxy_uptime_pct = if total_days == 0 {
|
||||
100.0
|
||||
} else {
|
||||
(total_days - down_days) as f64 / total_days as f64 * 100.0
|
||||
};
|
||||
|
||||
UptimeResponse {
|
||||
proxy: ProxyUptimeInfo {
|
||||
started_at,
|
||||
uptime_pct_30d: proxy_uptime_pct,
|
||||
history: proxy_history,
|
||||
},
|
||||
backends,
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Json(response.unwrap_or_else(|| UptimeResponse {
|
||||
proxy: ProxyUptimeInfo {
|
||||
started_at,
|
||||
uptime_pct_30d: 100.0,
|
||||
history: vec![],
|
||||
},
|
||||
backends: vec![],
|
||||
}))
|
||||
}
|
||||
@@ -55,6 +55,8 @@ pub struct SharedState {
|
||||
/// (one-time use). moka Cache enforces a hard cap of 1,000 entries and a
|
||||
/// 24-hour TTL, preventing unbounded growth from unauthenticated callers.
|
||||
pub issued_csrf_tokens: Arc<moka::sync::Cache<String, ()>>,
|
||||
/// Unix timestamp of admin server startup; used by /admin/api/uptime.
|
||||
pub started_at: std::time::SystemTime,
|
||||
}
|
||||
|
||||
/// Run a synchronous closure against the SQLite connection on the blocking
|
||||
@@ -100,6 +102,13 @@ pub enum AdminEvent {
|
||||
/// Config changed via admin UI.
|
||||
#[serde(rename = "config_changed")]
|
||||
ConfigChanged { key: String, value: String },
|
||||
/// Pushed when a backend flips up<->down so the Uptime tab refreshes immediately.
|
||||
#[serde(rename = "backend_health_changed")]
|
||||
BackendHealthChanged {
|
||||
backend: String,
|
||||
status: String,
|
||||
latency_ms: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Data recorded for each proxied request. Stored in SQLite and broadcast
|
||||
@@ -165,6 +174,7 @@ impl SharedState {
|
||||
.time_to_live(Duration::from_secs(86400))
|
||||
.build(),
|
||||
),
|
||||
started_at: std::time::SystemTime::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +441,7 @@ mod tests {
|
||||
.time_to_live(std::time::Duration::from_secs(86400))
|
||||
.build(),
|
||||
),
|
||||
started_at: std::time::SystemTime::now(),
|
||||
};
|
||||
|
||||
let vk_ctx = VirtualKeyContext {
|
||||
|
||||
@@ -501,6 +501,7 @@ async fn async_main(args: Vec<String>) {
|
||||
.time_to_live(std::time::Duration::from_secs(86400))
|
||||
.build(),
|
||||
),
|
||||
started_at: std::time::SystemTime::now(),
|
||||
};
|
||||
|
||||
// Admin token: use env var or generate 256-bit random hex written to a file.
|
||||
@@ -620,6 +621,16 @@ async fn async_main(args: Vec<String>) {
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn background health checker with a snapshot of backend base URLs.
|
||||
// base_url lives in BackendConfig (not RuntimeConfig), so we snapshot
|
||||
// it here once at startup rather than storing it in SharedState.
|
||||
let backend_urls: Vec<(String, String)> = multi_config
|
||||
.backends
|
||||
.iter()
|
||||
.map(|(name, bc)| (name.clone(), bc.base_url.clone()))
|
||||
.collect();
|
||||
admin::health_check::spawn(shared.clone(), backend_urls);
|
||||
|
||||
// Bind admin listener; spawned after the shutdown channel is created below.
|
||||
let admin_app = admin::routes::admin_router(shared.clone(), admin_token);
|
||||
let admin_bind = std::env::var("ADMIN_BIND").unwrap_or_else(|_| "127.0.0.1".into());
|
||||
|
||||
Reference in New Issue
Block a user