feat: faster

This commit is contained in:
2026-07-01 16:40:17 +02:00
parent dc11a8dde5
commit 41865c5440
8 changed files with 261 additions and 65 deletions

View File

@@ -1,6 +1,6 @@
import type {
Provider, VirtualKey, AdminModel, RequestLog, KeyStat, CurrentUser,
CreateProviderParams, CreateKeyParams, ModelOverride,
CreateProviderParams, CreateKeyParams, ModelOverride, LogsSummary, ModelStat,
} from './types'
const BASE = '/api/admin'
@@ -56,7 +56,7 @@ export const api = {
req<{ status: string; models: number }>('/catalog/refresh', { method: 'POST' }),
},
logs: {
list: (params: { provider?: string; key?: string; status?: string; since?: string; before?: string; beforeId?: string } = {}) => {
list: (params: { provider?: string; key?: string; status?: string; since?: string; before?: string; beforeId?: string; limit?: number; q?: string } = {}) => {
const qs = new URLSearchParams()
if (params.provider) qs.set('provider', params.provider)
if (params.key) qs.set('key', params.key)
@@ -64,6 +64,8 @@ export const api = {
if (params.since) qs.set('since', params.since)
if (params.before) qs.set('before', params.before)
if (params.beforeId) qs.set('before_id', params.beforeId)
if (params.limit) qs.set('limit', String(params.limit))
if (params.q) qs.set('q', params.q)
const s = qs.toString()
return req<RequestLog[]>('/logs' + (s ? '?' + s : ''))
},
@@ -75,6 +77,24 @@ export const api = {
const qs = since ? '?since=' + encodeURIComponent(since) : ''
return req<KeyStat[]>('/stats/keys' + qs)
},
summary: (since?: string) => {
const qs = since ? '?since=' + encodeURIComponent(since) : ''
return req<LogsSummary>('/stats/summary' + qs)
},
models: (since?: string, limit?: number) => {
const qs = new URLSearchParams()
if (since) qs.set('since', since)
if (limit) qs.set('limit', String(limit))
const s = qs.toString()
return req<ModelStat[]>('/stats/models' + (s ? '?' + s : ''))
},
series: (since?: string, buckets?: number) => {
const qs = new URLSearchParams()
if (since) qs.set('since', since)
if (buckets) qs.set('buckets', String(buckets))
const s = qs.toString()
return req<number[]>('/stats/series' + (s ? '?' + s : ''))
},
},
me: () => req<CurrentUser>('/me', undefined, BASE_API),
}

View File

@@ -65,6 +65,19 @@ export interface KeyStat {
errors: number
}
export interface LogsSummary {
requests: number
total_tokens: number
total_cost: number
avg_latency_ms: number
errors: number
}
export interface ModelStat {
model_name: string
total_tokens: number
}
export interface CurrentUser {
id: string
email: string

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { api } from '../lib/api'
import type { RequestLog, KeyStat } from '../lib/types'
import type { KeyStat, LogsSummary, ModelStat } from '../lib/types'
const D = 'rgba(237,237,240,'
@@ -32,8 +32,12 @@ function SparkLine({ data, color = '#7c3aed' }: { data: number[]; color?: string
)
}
const BUCKETS = 24
export default function Analytics() {
const [logs, setLogs] = useState<RequestLog[]>([])
const [summary, setSummary] = useState<LogsSummary | null>(null)
const [topModels, setTopModels] = useState<ModelStat[]>([])
const [reqByBucket, setReqByBucket] = useState<number[]>(Array(BUCKETS).fill(0))
const [keyStats, setKeyStats] = useState<KeyStat[]>([])
const [loading, setLoading] = useState(true)
const [rangeIdx, setRangeIdx] = useState(0)
@@ -42,40 +46,28 @@ export default function Analytics() {
setLoading(true)
const since = new Date(Date.now() - RANGES[rangeIdx].ms).toISOString()
Promise.all([
api.logs.list({ since }),
api.stats.summary(since),
api.stats.models(since, 6),
api.stats.series(since, BUCKETS),
api.stats.keys(since),
])
.then(([l, k]) => { setLogs(l ?? []); setKeyStats(k ?? []) })
.then(([s, m, series, k]) => {
setSummary(s)
setTopModels(m ?? [])
setReqByBucket(series ?? Array(BUCKETS).fill(0))
setKeyStats(k ?? [])
})
.finally(() => setLoading(false))
}, [rangeIdx])
// Build hourly buckets
const buckets = 24
const bucketMs = RANGES[rangeIdx].ms / buckets
const now = Date.now()
const reqByBucket = Array(buckets).fill(0)
logs.forEach(l => {
const age = now - new Date(l.CreatedAt).getTime()
const idx = Math.floor(age / bucketMs)
if (idx >= 0 && idx < buckets) reqByBucket[buckets - 1 - idx]++
})
const totalReqs = logs.length
const totalTokens = logs.reduce((s, l) => s + l.TotalTokens, 0)
const totalCost = logs.reduce((s, l) => s + l.Cost, 0)
const avgLatency = logs.length ? Math.round(logs.reduce((s, l) => s + l.LatencyMS, 0) / logs.length) : 0
// Top models by token usage
const byModel: Record<string, number> = {}
logs.forEach(l => { byModel[l.ModelName] = (byModel[l.ModelName] ?? 0) + l.TotalTokens })
const topModels = Object.entries(byModel).sort((a, b) => b[1] - a[1]).slice(0, 6)
const maxTokens = topModels[0]?.[1] ?? 1
const totalTokens = summary?.total_tokens ?? 0
const maxTokens = topModels[0]?.total_tokens ?? 1
const stats = [
{ label: 'Total requests', value: totalReqs.toLocaleString() },
{ label: 'Total requests', value: (summary?.requests ?? 0).toLocaleString() },
{ label: 'Total tokens', value: totalTokens > 1e6 ? `${(totalTokens/1e6).toFixed(1)}M` : totalTokens.toLocaleString() },
{ label: 'Total cost', value: `$${totalCost.toFixed(4)}` },
{ label: 'Avg latency', value: `${avgLatency}ms` },
{ label: 'Total cost', value: `$${(summary?.total_cost ?? 0).toFixed(4)}` },
{ label: 'Avg latency', value: `${summary?.avg_latency_ms ?? 0}ms` },
]
return (
@@ -123,14 +115,14 @@ export default function Analytics() {
<div style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff', marginBottom: 2 }}>Top Models</div>
<div style={{ fontSize: 11.5, color: `${D}0.35)`, marginBottom: 18 }}>By token usage</div>
{topModels.length === 0 && <div style={{ color: `${D}0.3)`, fontSize: 13 }}>No data yet</div>}
{topModels.map(([name, tokens]) => (
<div key={name} style={{ marginBottom: 14 }}>
{topModels.map(({ model_name, total_tokens }) => (
<div key={model_name} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.62)`, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{name}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.38)` }}>{tokens > 1e6 ? `${(tokens/1e6).toFixed(1)}M` : tokens.toLocaleString()}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.62)`, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{model_name}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.38)` }}>{total_tokens > 1e6 ? `${(total_tokens/1e6).toFixed(1)}M` : total_tokens.toLocaleString()}</span>
</div>
<div style={{ height: 3, background: 'rgba(255,255,255,0.06)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', background: '#7c3aed', borderRadius: 2, width: `${(tokens / maxTokens) * 100}%`, transition: 'width 0.3s' }}/>
<div style={{ height: '100%', background: '#7c3aed', borderRadius: 2, width: `${(total_tokens / maxTokens) * 100}%`, transition: 'width 0.3s' }}/>
</div>
</div>
))}

View File

@@ -66,19 +66,26 @@ export default function Logs() {
const [hasMore, setHasMore] = useState(false)
const [rangeIdx, setRangeIdx] = useState(1)
const [search, setSearch] = useState(() => searchParams.get('key') ?? '')
const [debouncedSearch, setDebouncedSearch] = useState(search)
const [expanded, setExpanded] = useState<string | null>(null)
const [content, setContent] = useState<Record<string, Pick<RequestLog, 'Messages' | 'Completion'>>>({})
const [contentLoading, setContentLoading] = useState<string | null>(null)
// Debounce the search box so typing doesn't fire a request per keystroke.
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 300)
return () => clearTimeout(t)
}, [search])
useEffect(() => {
setLoading(true)
api.logs.list({ since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString() })
api.logs.list({ since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString(), q: debouncedSearch || undefined })
.then(r => {
setLogs(r ?? [])
setHasMore((r ?? []).length === PAGE_SIZE)
})
.finally(() => setLoading(false))
}, [rangeIdx])
}, [rangeIdx, debouncedSearch])
function loadMore() {
const last = logs[logs.length - 1]
@@ -88,6 +95,7 @@ export default function Logs() {
since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString(),
before: last.CreatedAt,
beforeId: last.ID,
q: debouncedSearch || undefined,
})
.then(r => {
setLogs(prev => [...prev, ...(r ?? [])])
@@ -102,13 +110,7 @@ export default function Logs() {
else setSearchParams({}, { replace: true })
}, [search])
const visible = logs.filter(l =>
!search ||
l.ModelName.includes(search) ||
l.ProviderName.includes(search) ||
l.KeyPrefix.includes(search) ||
l.RequestID.includes(search)
)
const visible = logs
function toggleExpand(id: string) {
if (expanded === id) {

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import type { Provider, RequestLog } from '../lib/types'
import type { Provider, RequestLog, LogsSummary } from '../lib/types'
const D = 'rgba(237,237,240,'
@@ -27,18 +27,23 @@ function ago(iso: string) {
export default function Overview() {
const [providers, setProviders] = useState<Provider[]>([])
const [logs, setLogs] = useState<RequestLog[]>([])
const [summary, setSummary] = useState<LogsSummary | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([api.providers.list(), api.logs.list({ since: new Date(Date.now() - 86400000).toISOString() })])
.then(([p, l]) => { setProviders(p ?? []); setLogs((l ?? []).slice(0, 8)) })
const since = new Date(Date.now() - 86400000).toISOString()
Promise.all([
api.providers.list(),
api.logs.list({ since, limit: 8 }),
api.stats.summary(since),
])
.then(([p, l, s]) => { setProviders(p ?? []); setLogs(l ?? []); setSummary(s) })
.finally(() => setLoading(false))
}, [])
const todayLogs = logs
const totalReqs = todayLogs.length
const totalCost = todayLogs.reduce((s, l) => s + l.Cost, 0)
const errRate = totalReqs ? Math.round(todayLogs.filter(l => l.StatusCode >= 400).length / totalReqs * 100) : 0
const totalReqs = summary?.requests ?? 0
const totalCost = summary?.total_cost ?? 0
const errRate = totalReqs ? Math.round((summary?.errors ?? 0) / totalReqs * 100) : 0
const stats = [
{ label: 'Providers', value: providers.length, delta: '' },

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
@@ -282,17 +283,18 @@ func (a *API) adminPutSetting(w http.ResponseWriter, r *http.Request) {
func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
// Admins see all logs; regular users see only their own keys' logs.
au := userFrom(r.Context())
var userID *uuid.UUID
if au != nil && !au.IsAdmin {
userID = &au.User.ID
limit := 100
if l := q.Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 {
limit = n
}
}
p := store.ListLogsParams{
Provider: q.Get("provider"),
KeyPrefix: q.Get("key"),
UserID: userID,
Limit: 100,
UserID: scopedUserID(r),
Query: q.Get("q"),
Limit: limit,
}
if s := q.Get("status"); s == "error" {
p.StatusGte = 400
@@ -343,19 +345,29 @@ func (a *API) adminGetLogContent(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"Messages": messages, "Completion": completion})
}
func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) {
// sinceParam parses the "since" query param (RFC3339), defaulting to 24h ago.
func sinceParam(r *http.Request) time.Time {
since := time.Now().Add(-24 * time.Hour)
if s := r.URL.Query().Get("since"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil {
since = t
}
}
return since
}
// scopedUserID returns the current user's ID for non-admins, or nil for
// admins (who see stats across all users).
func scopedUserID(r *http.Request) *uuid.UUID {
au := userFrom(r.Context())
var userID *uuid.UUID
if au != nil && !au.IsAdmin {
userID = &au.User.ID
return &au.User.ID
}
stats, err := a.Store.KeyStats(r.Context(), since, userID)
return nil
}
func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) {
stats, err := a.Store.KeyStats(r.Context(), sinceParam(r), scopedUserID(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -366,6 +378,48 @@ func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, stats)
}
func (a *API) adminStatsSummary(w http.ResponseWriter, r *http.Request) {
summary, err := a.Store.GetLogsSummary(r.Context(), sinceParam(r), scopedUserID(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, summary)
}
func (a *API) adminStatsModels(w http.ResponseWriter, r *http.Request) {
limit := 6
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
}
}
models, err := a.Store.TopModels(r.Context(), sinceParam(r), scopedUserID(r), limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if models == nil {
models = []store.ModelStat{}
}
writeJSON(w, http.StatusOK, models)
}
func (a *API) adminStatsSeries(w http.ResponseWriter, r *http.Request) {
buckets := 24
if b := r.URL.Query().Get("buckets"); b != "" {
if n, err := strconv.Atoi(b); err == nil && n > 0 {
buckets = n
}
}
series, err := a.Store.RequestVolumeSeries(r.Context(), sinceParam(r), scopedUserID(r), buckets)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, series)
}
// --- catalog ---
func (a *API) adminRefreshCatalog(w http.ResponseWriter, r *http.Request) {

View File

@@ -101,6 +101,9 @@ func (a *API) Mount(r chi.Router) {
r.Get("/logs", a.adminListLogs)
r.Get("/logs/{id}/content", a.adminGetLogContent)
r.Get("/stats/keys", a.adminKeyStats)
r.Get("/stats/summary", a.adminStatsSummary)
r.Get("/stats/models", a.adminStatsModels)
r.Get("/stats/series", a.adminStatsSeries)
// The following routes require admin group membership.
r.Group(func(r chi.Router) {

View File

@@ -99,7 +99,10 @@ type ListLogsParams struct {
StatusGte int // 0 = all; e.g. 500 filters ≥500
StatusLt int // 0 = no upper bound
Since time.Time
Limit int
// Query is a free-text search matched (case-insensitively, substring) against
// model_name, provider_name, key_prefix, and request_id. Empty = no filter.
Query string
Limit int
// Before/BeforeID page backwards from a cursor: only rows strictly older
// than (Before, BeforeID) in the (created_at DESC, id DESC) ordering are
// returned. Pass the last row of the previous page. Zero value = first page.
@@ -140,9 +143,13 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
AND ($5 = 0 OR rl.status_code < $5)
AND ($6::timestamptz IS NULL OR rl.created_at >= $6)
AND ($7::timestamptz IS NULL OR (rl.created_at, rl.id) < ($7, $8))
AND ($9 = '' OR rl.model_name ILIKE '%' || $9 || '%'
OR rl.provider_name ILIKE '%' || $9 || '%'
OR rl.key_prefix ILIKE '%' || $9 || '%'
OR rl.request_id ILIKE '%' || $9 || '%')
ORDER BY rl.created_at DESC, rl.id DESC
LIMIT $9`,
p.Provider, p.KeyPrefix, p.UserID, p.StatusGte, p.StatusLt, since, before, beforeID, p.Limit,
LIMIT $10`,
p.Provider, p.KeyPrefix, p.UserID, p.StatusGte, p.StatusLt, since, before, beforeID, p.Query, p.Limit,
)
if err != nil {
return nil, err
@@ -233,6 +240,106 @@ func (s *Store) KeyStats(ctx context.Context, since time.Time, userID *uuid.UUID
return out, rows.Err()
}
// LogsSummary is aggregate totals for a time window (dashboard stat cards).
type LogsSummary struct {
Requests int `json:"requests"`
TotalTokens int64 `json:"total_tokens"`
TotalCost float64 `json:"total_cost"`
AvgLatencyMS int `json:"avg_latency_ms"`
Errors int `json:"errors"`
}
// GetLogsSummary computes aggregate totals since the given time in a single
// query, instead of shipping raw rows to the client to sum in JS.
func (s *Store) GetLogsSummary(ctx context.Context, since time.Time, userID *uuid.UUID) (LogsSummary, error) {
var out LogsSummary
err := s.pool.QueryRow(ctx, `
SELECT COUNT(*)::int,
COALESCE(SUM(total_tokens),0)::bigint,
COALESCE(SUM(cost),0),
COALESCE(AVG(latency_ms),0)::int,
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END)::int
FROM request_logs
WHERE created_at >= $1
AND ($2::uuid IS NULL OR user_id = $2)`,
since, userID,
).Scan(&out.Requests, &out.TotalTokens, &out.TotalCost, &out.AvgLatencyMS, &out.Errors)
return out, err
}
// ModelStat is token usage for one model address, for the "top models" chart.
type ModelStat struct {
ModelName string `json:"model_name"`
TotalTokens int64 `json:"total_tokens"`
}
// TopModels returns the top models by token usage since the given time.
func (s *Store) TopModels(ctx context.Context, since time.Time, userID *uuid.UUID, limit int) ([]ModelStat, error) {
if limit <= 0 {
limit = 6
}
rows, err := s.pool.Query(ctx, `
SELECT COALESCE(model_name,''), COALESCE(SUM(total_tokens),0)::bigint
FROM request_logs
WHERE created_at >= $1
AND ($2::uuid IS NULL OR user_id = $2)
GROUP BY model_name
ORDER BY SUM(total_tokens) DESC
LIMIT $3`,
since, userID, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ModelStat
for rows.Next() {
var m ModelStat
if err := rows.Scan(&m.ModelName, &m.TotalTokens); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// RequestVolumeSeries buckets request counts evenly between since and now
// into `buckets` buckets (oldest first), for the dashboard sparkline.
func (s *Store) RequestVolumeSeries(ctx context.Context, since time.Time, userID *uuid.UUID, buckets int) ([]int, error) {
if buckets <= 0 {
buckets = 24
}
rows, err := s.pool.Query(ctx, `
SELECT width_bucket(extract(epoch from rl.created_at), extract(epoch from $1::timestamptz), extract(epoch from now()), $3) AS bucket,
COUNT(*)::int
FROM request_logs rl
WHERE rl.created_at >= $1
AND ($2::uuid IS NULL OR rl.user_id = $2)
GROUP BY bucket`,
since, userID, buckets,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]int, buckets)
for rows.Next() {
var bucket, count int
if err := rows.Scan(&bucket, &count); err != nil {
return nil, err
}
// width_bucket returns 1..buckets for in-range values; clamp the rare
// edge case (e.g. a row created exactly at `now()`) into the last bucket.
if bucket < 1 {
bucket = 1
} else if bucket > buckets {
bucket = buckets
}
out[bucket-1] += count
}
return out, rows.Err()
}
func nullInet(s string) any {
if a, err := netip.ParseAddr(s); err == nil {
return a