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:
whit3rabbit
2026-04-05 15:29:47 -05:00
co-authored by Claude Sonnet 4.6
56 changed files with 6371 additions and 1931 deletions
+39 -1
View File
@@ -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 }}
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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.
File diff suppressed because one or more lines are too long
+25
View File
@@ -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
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
}
}
+62
View File
@@ -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>
)
}
+63
View File
@@ -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>
}
+200
View File
@@ -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,
})
}
+215
View File
@@ -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">&gt;&nbsp;</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,
}}
/>
)
}
+22
View File
@@ -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>,
)
+19
View File
@@ -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 })
},
}))
+18
View File
@@ -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>
)
}
+20
View File
@@ -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"]
}
+34
View File
@@ -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,
},
},
},
})
+108
View File
@@ -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
+84
View File
@@ -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
View File
@@ -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.
+5 -1
View File
@@ -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).
+165
View File
@@ -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,
})
}
+145
View File
@@ -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![],
}))
}
+10
View File
@@ -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(),
}
}
}
+1
View File
@@ -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 {
+11
View File
@@ -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());