feat(gateway): add provider health tracking and dashboard stats API

This commit is contained in:
Ray Andrew 2026-02-15 03:09:14 -06:00
parent 98822eadb8
commit 2b6e20544b
Signed by: rayandrew
SSH key fingerprint: SHA256:EUCV+qCSqkap8rR+p+zGjxHfKI06G0GJKgo1DIOniQY
14 changed files with 1309 additions and 74 deletions

View file

@ -90,6 +90,9 @@ func main() {
}
log.Printf("Registered %d models", len(cfg.Models))
// Provider health tracker
healthTracker := provider.NewHealthTracker(5 * time.Minute)
// Auth store (static tokens checked in-memory, not seeded to DB)
var staticTokens []auth.StaticToken
for _, t := range cfg.Tokens {
@ -114,12 +117,20 @@ func main() {
m := metrics.New()
// Handlers
proxyHandler := proxy.NewHandler(registry, asyncLogger, c, m, cfg)
proxyHandler := proxy.NewHandler(registry, asyncLogger, c, m, cfg, healthTracker)
modelsHandler := proxy.NewModelsHandler(registry)
proxyAuth := proxy.NewAuthMiddleware(authStore)
rateLimiter := proxy.NewRateLimiter(db)
statsAPI := dashboard.NewStatsAPI(db, authStore)
statsAPI.SetHealthTracker(healthTracker)
if c != nil {
statsAPI.SetCache(c)
}
dash := dashboard.NewDashboard(authStore, statsAPI)
dash.SetRegistry(registry)
if c != nil {
dash.SetCache(c)
}
// Router
r := chi.NewRouter()
@ -172,6 +183,8 @@ func main() {
// Dashboard pages (HTMX)
r.Get("/dashboard", dash.DashboardPage)
r.Get("/logs", dash.LogsPage)
r.Get("/models", dash.ModelsPage)
r.Get("/tokens", dash.TokensPage)
r.Get("/settings", dash.SettingsPage)
@ -205,6 +218,11 @@ func main() {
r.Get("/api/stats/providers", statsAPI.Providers)
r.Get("/api/stats/tokens", statsAPI.Tokens)
r.Get("/api/stats/timeseries", statsAPI.Timeseries)
r.Get("/api/stats/logs", statsAPI.Logs)
r.Get("/api/stats/latency", statsAPI.Latency)
r.Get("/api/stats/cost-breakdown", statsAPI.CostBreakdown)
r.Get("/api/stats/provider-health", statsAPI.ProviderHealthHandler)
r.Get("/api/stats/cache", statsAPI.CacheStats)
// Admin-only: user management
r.Group(func(r chi.Router) {

View file

@ -56,6 +56,120 @@ func (c *Cache) Close() error {
return c.client.Close()
}
// CacheStats holds cache statistics from the Valkey/Redis server.
type CacheStats struct {
Hits int64 `json:"hits"`
Misses int64 `json:"misses"`
HitRate float64 `json:"hit_rate"`
MemoryUsed string `json:"memory_used"`
Keys int64 `json:"keys"`
Connected bool `json:"connected"`
}
// Stats returns cache statistics by querying Valkey/Redis INFO.
func (c *Cache) Stats(ctx context.Context) *CacheStats {
stats := &CacheStats{}
// Check connectivity
if err := c.client.Ping(ctx).Err(); err != nil {
return stats
}
stats.Connected = true
// Parse INFO stats for hits/misses
info, err := c.client.Info(ctx, "stats").Result()
if err == nil {
stats.Hits = parseInfoInt(info, "keyspace_hits")
stats.Misses = parseInfoInt(info, "keyspace_misses")
total := stats.Hits + stats.Misses
if total > 0 {
stats.HitRate = float64(stats.Hits) / float64(total)
}
}
// Parse INFO memory
memInfo, err := c.client.Info(ctx, "memory").Result()
if err == nil {
stats.MemoryUsed = parseInfoString(memInfo, "used_memory_human")
}
// Parse INFO keyspace
ksInfo, err := c.client.Info(ctx, "keyspace").Result()
if err == nil {
stats.Keys = parseKeyspaceKeys(ksInfo)
}
return stats
}
func parseInfoInt(info, key string) int64 {
prefix := key + ":"
for _, line := range splitLines(info) {
if len(line) > len(prefix) && line[:len(prefix)] == prefix {
var v int64
fmt.Sscanf(line[len(prefix):], "%d", &v)
return v
}
}
return 0
}
func parseInfoString(info, key string) string {
prefix := key + ":"
for _, line := range splitLines(info) {
if len(line) > len(prefix) && line[:len(prefix)] == prefix {
val := line[len(prefix):]
// Trim trailing \r
if len(val) > 0 && val[len(val)-1] == '\r' {
val = val[:len(val)-1]
}
return val
}
}
return ""
}
func parseKeyspaceKeys(info string) int64 {
// Format: db0:keys=123,expires=45,avg_ttl=6789
for _, line := range splitLines(info) {
if len(line) > 3 && line[:2] == "db" {
prefix := "keys="
idx := -1
for i := 0; i <= len(line)-len(prefix); i++ {
if line[i:i+len(prefix)] == prefix {
idx = i + len(prefix)
break
}
}
if idx >= 0 {
end := idx
for end < len(line) && line[end] >= '0' && line[end] <= '9' {
end++
}
var v int64
fmt.Sscanf(line[idx:end], "%d", &v)
return v
}
}
}
return 0
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
func (c *Cache) cacheKey(model string, requestBody []byte) string {
h := sha256.New()
h.Write([]byte(model))

View file

@ -3,9 +3,13 @@ package dashboard
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"time"
"llm-gateway/internal/auth"
"llm-gateway/internal/cache"
"llm-gateway/internal/provider"
"llm-gateway/internal/storage"
)
@ -52,15 +56,70 @@ type TokenUsageStats struct {
CostUSD float64 `json:"cost_usd"`
}
// RequestLogEntry represents a single request log row.
type RequestLogEntry struct {
Timestamp int64 `json:"timestamp"`
TokenName string `json:"token_name"`
Model string `json:"model"`
Provider string `json:"provider"`
ProviderModel string `json:"provider_model"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CostUSD float64 `json:"cost_usd"`
LatencyMS int64 `json:"latency_ms"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
Streaming bool `json:"streaming"`
Cached bool `json:"cached"`
}
// LogsResult holds paginated logs.
type LogsResult struct {
Logs []RequestLogEntry `json:"logs"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
Total int `json:"total"`
}
// LatencyResult holds latency percentiles.
type LatencyResult struct {
P50 float64 `json:"p50"`
P95 float64 `json:"p95"`
P99 float64 `json:"p99"`
Avg float64 `json:"avg"`
Min float64 `json:"min"`
Max float64 `json:"max"`
}
// CostBreakdownEntry holds cost data grouped by day and dimension.
type CostBreakdownEntry struct {
Day string `json:"day"`
GroupBy string `json:"group_by"`
CostUSD float64 `json:"cost_usd"`
Requests int `json:"requests"`
}
type StatsAPI struct {
db *storage.DB
authStore *auth.Store
db *storage.DB
authStore *auth.Store
healthTracker *provider.HealthTracker
cache *cache.Cache
}
func NewStatsAPI(db *storage.DB, authStore *auth.Store) *StatsAPI {
return &StatsAPI{db: db, authStore: authStore}
}
// SetHealthTracker sets the provider health tracker.
func (s *StatsAPI) SetHealthTracker(ht *provider.HealthTracker) {
s.healthTracker = ht
}
// SetCache sets the cache for stats.
func (s *StatsAPI) SetCache(c *cache.Cache) {
s.cache = c
}
// TokenNamesForUser returns the token names that belong to the user.
// Admins get nil (no filter), non-admins get their token names.
func (s *StatsAPI) TokenNamesForUser(user *auth.User) []string {
@ -227,6 +286,217 @@ func (s *StatsAPI) GetTokenUsage(tokenNames []string) []TokenUsageStats {
return results
}
// GetLogs returns paginated request logs with filters.
func (s *StatsAPI) GetLogs(tokenNames []string, page int, model, token, status string) *LogsResult {
if page < 1 {
page = 1
}
limit := 50
offset := (page - 1) * limit
tokenFilter, filterArgs := buildTokenFilter(tokenNames)
where := "WHERE 1=1" + tokenFilter
args := make([]any, 0)
args = append(args, filterArgs...)
if model != "" {
where += " AND model = ?"
args = append(args, model)
}
if token != "" {
where += " AND token_name = ?"
args = append(args, token)
}
if status != "" {
where += " AND status = ?"
args = append(args, status)
}
// Get total count
var total int
countArgs := make([]any, len(args))
copy(countArgs, args)
s.db.QueryRow("SELECT COUNT(*) FROM request_logs "+where, countArgs...).Scan(&total)
totalPages := (total + limit - 1) / limit
if totalPages < 1 {
totalPages = 1
}
// Get page
query := `SELECT timestamp, token_name, model, provider, provider_model,
input_tokens, output_tokens, cost_usd, latency_ms, status,
COALESCE(error_message, ''), streaming, cached
FROM request_logs ` + where + ` ORDER BY timestamp DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := s.db.Query(query, args...)
if err != nil {
return &LogsResult{Logs: []RequestLogEntry{}, Page: page, TotalPages: totalPages, Total: total}
}
defer rows.Close()
var logs []RequestLogEntry
for rows.Next() {
var l RequestLogEntry
var streaming, cached int
rows.Scan(&l.Timestamp, &l.TokenName, &l.Model, &l.Provider, &l.ProviderModel,
&l.InputTokens, &l.OutputTokens, &l.CostUSD, &l.LatencyMS, &l.Status,
&l.ErrorMessage, &streaming, &cached)
l.Streaming = streaming == 1
l.Cached = cached == 1
logs = append(logs, l)
}
if logs == nil {
logs = []RequestLogEntry{}
}
return &LogsResult{
Logs: logs,
Page: page,
TotalPages: totalPages,
Total: total,
}
}
// GetDistinctModels returns distinct model names from logs.
func (s *StatsAPI) GetDistinctModels() []string {
rows, err := s.db.Query("SELECT DISTINCT model FROM request_logs ORDER BY model")
if err != nil {
return nil
}
defer rows.Close()
var models []string
for rows.Next() {
var m string
rows.Scan(&m)
models = append(models, m)
}
return models
}
// GetDistinctTokens returns distinct token names from logs.
func (s *StatsAPI) GetDistinctTokens() []string {
rows, err := s.db.Query("SELECT DISTINCT token_name FROM request_logs ORDER BY token_name")
if err != nil {
return nil
}
defer rows.Close()
var tokens []string
for rows.Next() {
var t string
rows.Scan(&t)
tokens = append(tokens, t)
}
return tokens
}
// GetLatency computes latency percentiles from request_logs.
func (s *StatsAPI) GetLatency(tokenNames []string, period, model, providerName string) *LatencyResult {
var since int64
switch period {
case "7d":
since = time.Now().AddDate(0, 0, -7).Unix()
case "30d":
since = time.Now().AddDate(0, -1, 0).Unix()
default:
since = time.Now().Add(-24 * time.Hour).Unix()
}
tokenFilter, filterArgs := buildTokenFilter(tokenNames)
where := "WHERE timestamp >= ? AND status = 'success'" + tokenFilter
args := []any{since}
args = append(args, filterArgs...)
if model != "" {
where += " AND model = ?"
args = append(args, model)
}
if providerName != "" {
where += " AND provider = ?"
args = append(args, providerName)
}
rows, err := s.db.Query("SELECT latency_ms FROM request_logs "+where+" ORDER BY latency_ms", args...)
if err != nil {
return &LatencyResult{}
}
defer rows.Close()
var latencies []float64
for rows.Next() {
var l float64
rows.Scan(&l)
latencies = append(latencies, l)
}
if len(latencies) == 0 {
return &LatencyResult{}
}
sort.Float64s(latencies)
n := len(latencies)
var sum float64
for _, l := range latencies {
sum += l
}
return &LatencyResult{
P50: latencies[n*50/100],
P95: latencies[n*95/100],
P99: latencies[min(n*99/100, n-1)],
Avg: sum / float64(n),
Min: latencies[0],
Max: latencies[n-1],
}
}
// GetCostBreakdown returns cost data grouped by day and dimension.
func (s *StatsAPI) GetCostBreakdown(tokenNames []string, period, groupBy string) []CostBreakdownEntry {
var since int64
switch period {
case "30d":
since = time.Now().AddDate(0, -1, 0).Unix()
case "7d":
since = time.Now().AddDate(0, 0, -7).Unix()
default:
since = time.Now().Add(-24 * time.Hour).Unix()
}
tokenFilter, filterArgs := buildTokenFilter(tokenNames)
groupCol := "model"
if groupBy == "token" {
groupCol = "token_name"
} else if groupBy == "provider" {
groupCol = "provider"
}
args := []any{since}
args = append(args, filterArgs...)
query := `SELECT date(timestamp, 'unixepoch') as day, ` + groupCol + `,
COALESCE(SUM(cost_usd), 0), COUNT(*)
FROM request_logs WHERE timestamp >= ?` + tokenFilter + `
GROUP BY day, ` + groupCol + ` ORDER BY day, ` + groupCol
rows, err := s.db.Query(query, args...)
if err != nil {
return nil
}
defer rows.Close()
var results []CostBreakdownEntry
for rows.Next() {
var e CostBreakdownEntry
rows.Scan(&e.Day, &e.GroupBy, &e.CostUSD, &e.Requests)
results = append(results, e)
}
return results
}
// JSON HTTP handlers (thin wrappers).
func (s *StatsAPI) Summary(w http.ResponseWriter, r *http.Request) {
@ -302,6 +572,58 @@ func (s *StatsAPI) Timeseries(w http.ResponseWriter, r *http.Request) {
writeJSON(w, results)
}
// Logs serves the paginated logs API.
func (s *StatsAPI) Logs(w http.ResponseWriter, r *http.Request) {
tokenNames := s.tokenNamesForUser(r)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
model := r.URL.Query().Get("model")
token := r.URL.Query().Get("token")
status := r.URL.Query().Get("status")
result := s.GetLogs(tokenNames, page, model, token, status)
writeJSON(w, result)
}
// Latency serves latency percentiles API.
func (s *StatsAPI) Latency(w http.ResponseWriter, r *http.Request) {
tokenNames := s.tokenNamesForUser(r)
period := r.URL.Query().Get("period")
model := r.URL.Query().Get("model")
providerName := r.URL.Query().Get("provider")
result := s.GetLatency(tokenNames, period, model, providerName)
writeJSON(w, result)
}
// CostBreakdown serves cost breakdown API.
func (s *StatsAPI) CostBreakdown(w http.ResponseWriter, r *http.Request) {
tokenNames := s.tokenNamesForUser(r)
period := r.URL.Query().Get("period")
groupBy := r.URL.Query().Get("group_by")
if groupBy == "" {
groupBy = "model"
}
result := s.GetCostBreakdown(tokenNames, period, groupBy)
writeJSON(w, result)
}
// ProviderHealthHandler serves provider health status API.
func (s *StatsAPI) ProviderHealthHandler(w http.ResponseWriter, r *http.Request) {
if s.healthTracker == nil {
writeJSON(w, []provider.ProviderHealth{})
return
}
writeJSON(w, s.healthTracker.Status())
}
// CacheStats serves cache statistics API.
func (s *StatsAPI) CacheStats(w http.ResponseWriter, r *http.Request) {
if s.cache == nil {
writeJSON(w, map[string]any{"enabled": false})
return
}
stats := s.cache.Stats(r.Context())
writeJSON(w, stats)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)

View file

@ -5,9 +5,12 @@ import (
"fmt"
"html/template"
"net/http"
"strconv"
"time"
"llm-gateway/internal/auth"
"llm-gateway/internal/cache"
"llm-gateway/internal/provider"
)
//go:embed templates/*.html templates/partials/*.html
@ -20,9 +23,18 @@ var templateFuncs = template.FuncMap{
}
return time.Unix(ts, 0).Format("2006-01-02")
},
"formatTimeDetail": func(ts int64) string {
if ts == 0 {
return "never"
}
return time.Unix(ts, 0).Format("2006-01-02 15:04:05")
},
"addInt": func(a, b int) int {
return a + b
},
"subInt": func(a, b int) int {
return a - b
},
"formatCost": func(v float64) string {
if v == 0 {
return "$0.00"
@ -32,19 +44,87 @@ var templateFuncs = template.FuncMap{
}
return fmt.Sprintf("$%.4f", v)
},
"formatPrice": func(v float64) string {
if v == 0 {
return "-"
}
return fmt.Sprintf("$%.2f", v)
},
"formatPct": func(v float64) string {
return fmt.Sprintf("%.1f%%", v*100)
},
"budgetPct": func(spend, budget float64) float64 {
if budget <= 0 {
return 0
}
return spend / budget * 100
},
"budgetColor": func(pct float64) string {
if pct >= 80 {
return "#f87171"
}
if pct >= 50 {
return "#fbbf24"
}
return "#4ade80"
},
"seq": func(start, end int) []int {
var s []int
for i := start; i <= end; i++ {
s = append(s, i)
}
return s
},
"paginationStart": func(page, totalPages int) int {
start := page - 2
if start < 1 {
start = 1
}
if totalPages-start < 4 && totalPages > 4 {
start = totalPages - 4
}
return start
},
"paginationEnd": func(page, totalPages int) int {
start := page - 2
if start < 1 {
start = 1
}
end := start + 4
if end > totalPages {
end = totalPages
}
return end
},
}
// PageData is the common data passed to all templates.
type PageData struct {
ActivePage string
User *auth.User
// Page-specific data
Summary *SummaryResult
Models []ModelStats
Providers []ProviderStats
TokenStats []TokenUsageStats
// Dashboard data
Summary *SummaryResult
Models []ModelStats
Providers []ProviderStats
TokenStats []TokenUsageStats
ProviderHealth []provider.ProviderHealth
Latency *LatencyResult
CacheEnabled bool
CacheInfo *cache.CacheStats
// Tokens page data
Tokens []auth.APIToken
Users []auth.User
TokenSpend map[string]float64
// Users page data
Users []auth.User
// Logs page data
LogsResult *LogsResult
LogModels []string
LogTokens []string
FilterModel string
FilterToken string
FilterStatus string
// Models routing page data
ModelRoutes []provider.ModelRouteInfo
}
// Dashboard serves the HTMX-based dashboard pages.
@ -52,6 +132,8 @@ type Dashboard struct {
templates *template.Template
authStore *auth.Store
statsAPI *StatsAPI
registry *provider.Registry
cache *cache.Cache
}
// NewDashboard creates a new Dashboard handler.
@ -70,6 +152,16 @@ func NewDashboard(authStore *auth.Store, statsAPI *StatsAPI) *Dashboard {
}
}
// SetRegistry sets the provider registry for model routing display.
func (d *Dashboard) SetRegistry(r *provider.Registry) {
d.registry = r
}
// SetCache sets the cache reference for cache stats display.
func (d *Dashboard) SetCache(c *cache.Cache) {
d.cache = c
}
// LoginPage serves the login page.
func (d *Dashboard) LoginPage(w http.ResponseWriter, r *http.Request) {
if !d.authStore.HasAnyUser() {
@ -106,11 +198,66 @@ func (d *Dashboard) DashboardPage(w http.ResponseWriter, r *http.Request) {
Models: d.statsAPI.GetModels(tokenNames),
Providers: d.statsAPI.GetProviders(tokenNames),
TokenStats: d.statsAPI.GetTokenUsage(tokenNames),
Latency: d.statsAPI.GetLatency(tokenNames, "24h", "", ""),
}
// Provider health
if d.statsAPI.healthTracker != nil {
data.ProviderHealth = d.statsAPI.healthTracker.Status()
}
// Cache stats
if d.cache != nil {
data.CacheEnabled = true
data.CacheInfo = d.cache.Stats(r.Context())
}
d.renderDashboardPage(w, r, "partials/dashboard.html", data)
}
// LogsPage serves the request logs view.
func (d *Dashboard) LogsPage(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
tokenNames := d.statsAPI.TokenNamesForUser(user)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
model := r.URL.Query().Get("model")
token := r.URL.Query().Get("token")
status := r.URL.Query().Get("status")
data := PageData{
ActivePage: "logs",
User: user,
LogsResult: d.statsAPI.GetLogs(tokenNames, page, model, token, status),
LogModels: d.statsAPI.GetDistinctModels(),
LogTokens: d.statsAPI.GetDistinctTokens(),
FilterModel: model,
FilterToken: token,
FilterStatus: status,
}
d.renderDashboardPage(w, r, "partials/logs.html", data)
}
// ModelsPage serves the model routing table view.
func (d *Dashboard) ModelsPage(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
data := PageData{
ActivePage: "models",
User: user,
}
if d.registry != nil {
data.ModelRoutes = d.registry.AllRoutes()
}
d.renderDashboardPage(w, r, "partials/models-page.html", data)
}
// TokensPage serves the tokens management view.
func (d *Dashboard) TokensPage(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
@ -125,10 +272,17 @@ func (d *Dashboard) TokensPage(w http.ResponseWriter, r *http.Request) {
tokens = []auth.APIToken{}
}
// Get today's spend for budget display
spend, _ := d.statsAPI.db.TodaySpendAll()
if spend == nil {
spend = make(map[string]float64)
}
d.renderDashboardPage(w, r, "partials/tokens.html", PageData{
ActivePage: "tokens",
User: user,
Tokens: tokens,
TokenSpend: spend,
})
}

View file

@ -5,93 +5,228 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM Gateway</title>
<script>
// Prevent flash of wrong theme
(function() {
var pref = localStorage.getItem('theme') || 'auto';
var effective = pref;
if (pref === 'auto') effective = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
if (effective === 'light') document.documentElement.setAttribute('data-theme', 'light');
})();
</script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.3/json-enc.js"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--border-color: #334155;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--text-heading: #f8fafc;
--text-subheading: #cbd5e1;
--accent-blue: #3b82f6;
--accent-blue-hover: #2563eb;
--accent-blue-bg: #3b82f620;
--accent-green: #4ade80;
--accent-green-bg: #4ade8020;
--accent-red: #f87171;
--accent-red-bg: #7f1d1d40;
--accent-red-border: #991b1b;
--accent-red-text: #fca5a5;
--accent-yellow: #fbbf24;
--accent-yellow-bg: #92400e40;
--accent-purple: #a78bfa;
--accent-purple-bg: #a78bfa20;
--success-bg: #14532d40;
--success-border: #166534;
--success-text: #86efac;
--modal-overlay: #00000080;
--chart-grid: #1e293b;
}
[data-theme="light"] {
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #e2e8f0;
--border-color: #cbd5e1;
--text-primary: #1e293b;
--text-secondary: #475569;
--text-muted: #94a3b8;
--text-heading: #0f172a;
--text-subheading: #334155;
--accent-blue: #2563eb;
--accent-blue-hover: #1d4ed8;
--accent-blue-bg: #dbeafe;
--accent-green: #16a34a;
--accent-green-bg: #dcfce7;
--accent-red: #dc2626;
--accent-red-bg: #fef2f2;
--accent-red-border: #fca5a5;
--accent-red-text: #991b1b;
--accent-yellow: #d97706;
--accent-yellow-bg: #fef3c7;
--accent-purple: #7c3aed;
--accent-purple-bg: #ede9fe;
--success-bg: #f0fdf4;
--success-border: #86efac;
--success-text: #166534;
--modal-overlay: #00000040;
--chart-grid: #e2e8f0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; display: flex; }
/* Sidebar */
.sidebar { width: 220px; background: #1e293b; border-right: 1px solid #334155; min-height: 100vh; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; }
.sidebar-brand { padding: 20px 16px; font-size: 1.1rem; font-weight: 700; color: #f8fafc; border-bottom: 1px solid #334155; }
.sidebar { width: 220px; background: var(--bg-secondary); border-right: 1px solid var(--border-color); min-height: 100vh; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; }
.sidebar-brand { padding: 20px 16px; font-size: 1.1rem; font-weight: 700; color: var(--text-heading); border-bottom: 1px solid var(--border-color); }
.sidebar-nav { flex: 1; padding: 12px 0; }
.sidebar-nav a { display: block; padding: 10px 20px; color: #94a3b8; text-decoration: none; font-size: 0.9rem; transition: all 0.15s; }
.sidebar-nav a:hover { background: #334155; color: #e2e8f0; }
.sidebar-nav a.active { background: #3b82f620; color: #3b82f6; border-right: 3px solid #3b82f6; }
.sidebar-footer { padding: 16px; border-top: 1px solid #334155; }
.sidebar-footer .user-info { font-size: 0.85rem; color: #94a3b8; margin-bottom: 8px; }
.sidebar-footer a { display: block; padding: 6px 0; color: #94a3b8; text-decoration: none; font-size: 0.85rem; }
.sidebar-footer a:hover { color: #f87171; }
.sidebar-nav a { display: block; padding: 10px 20px; color: var(--text-secondary); text-decoration: none; font-size: 0.9rem; transition: all 0.15s; }
.sidebar-nav a:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.sidebar-nav a.active { background: var(--accent-blue-bg); color: var(--accent-blue); border-right: 3px solid var(--accent-blue); }
.sidebar-footer { padding: 16px; border-top: 1px solid var(--border-color); }
.sidebar-footer .user-info { font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 8px; }
.sidebar-footer a { display: block; padding: 6px 0; color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; }
.sidebar-footer a:hover { color: var(--accent-red); }
.theme-toggle { cursor: pointer; background: var(--bg-tertiary); border: 1px solid var(--border-color); color: var(--text-secondary); padding: 6px 12px; border-radius: 6px; font-size: 0.8rem; width: 100%; margin-bottom: 8px; }
.theme-toggle:hover { color: var(--text-primary); }
/* Main content */
.main { flex: 1; margin-left: 220px; padding: 24px; min-height: 100vh; max-width: calc(100vw - 220px); overflow-x: hidden; }
/* Cards & tables */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; max-width: 100%; }
.card { background: #1e293b; border-radius: 8px; padding: 16px; }
.card .label { font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; }
.card { background: var(--bg-secondary); border-radius: 8px; padding: 16px; }
.card .label { font-size: 0.75rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
.card .value { font-size: 1.5rem; font-weight: 700; margin-top: 4px; }
.card .sub { font-size: 0.75rem; color: #64748b; margin-top: 2px; }
.section { background: #1e293b; border-radius: 8px; padding: 16px; margin-bottom: 16px; overflow-x: auto; }
.section h2 { font-size: 1.1rem; margin-bottom: 12px; color: #cbd5e1; }
.card .sub { font-size: 0.75rem; color: var(--text-muted); margin-top: 2px; }
.section { background: var(--bg-secondary); border-radius: 8px; padding: 16px; margin-bottom: 16px; overflow-x: auto; }
.section h2 { font-size: 1.1rem; margin-bottom: 12px; color: var(--text-subheading); }
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.tabs button { background: #1e293b; border: 1px solid #334155; color: #94a3b8; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
.tabs button.active { background: #3b82f6; border-color: #3b82f6; color: #fff; }
.tabs button { background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-secondary); padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
.tabs button.active { background: var(--accent-blue); border-color: var(--accent-blue); color: #fff; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; padding: 8px; color: #94a3b8; border-bottom: 1px solid #334155; font-weight: 500; }
td { padding: 8px; border-bottom: 1px solid #334155; }
.green { color: #4ade80; }
.red { color: #f87171; }
.blue { color: #60a5fa; }
th { text-align: left; padding: 8px; color: var(--text-secondary); border-bottom: 1px solid var(--border-color); font-weight: 500; }
td { padding: 8px; border-bottom: 1px solid var(--border-color); }
.green { color: var(--accent-green); }
.red { color: var(--accent-red); }
.blue { color: var(--accent-blue); }
.yellow { color: var(--accent-yellow); }
/* Buttons */
.btn { display: inline-block; padding: 10px 20px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.9rem; font-weight: 500; text-decoration: none; }
.btn-primary { background: #3b82f6; color: #fff; }
.btn-primary:hover { background: #2563eb; }
.btn-primary { background: var(--accent-blue); color: #fff; }
.btn-primary:hover { background: var(--accent-blue-hover); }
.btn-danger { background: #ef4444; color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: 6px 12px; font-size: 0.8rem; }
.btn-outline { background: transparent; border: 1px solid #334155; color: #94a3b8; }
.btn-outline:hover { border-color: #64748b; color: #e2e8f0; }
.btn-outline { background: transparent; border: 1px solid var(--border-color); color: var(--text-secondary); }
.btn-outline:hover { border-color: var(--text-muted); color: var(--text-primary); }
/* Forms */
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 4px; }
.form-group input, .form-group select { width: 100%; padding: 10px 12px; background: #0f172a; border: 1px solid #334155; border-radius: 6px; color: #e2e8f0; font-size: 0.95rem; }
.form-group input:focus { outline: none; border-color: #3b82f6; }
.error-msg { background: #7f1d1d40; border: 1px solid #991b1b; color: #fca5a5; padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; }
.success-msg { background: #14532d40; border: 1px solid #166534; color: #86efac; padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; }
.form-group label { display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px; }
.form-group input, .form-group select { width: 100%; padding: 10px 12px; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); font-size: 0.95rem; }
.form-group input:focus, .form-group select:focus { outline: none; border-color: var(--accent-blue); }
.error-msg { background: var(--accent-red-bg); border: 1px solid var(--accent-red-border); color: var(--accent-red-text); padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; }
.success-msg { background: var(--success-bg); border: 1px solid var(--success-border); color: var(--success-text); padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; }
/* Modal */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: #00000080; display: none; align-items: center; justify-content: center; z-index: 100; }
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--modal-overlay); display: none; align-items: center; justify-content: center; z-index: 100; }
.modal-overlay.show { display: flex; }
.modal { background: #1e293b; border-radius: 12px; padding: 24px; width: 100%; max-width: 440px; }
.modal h2 { margin-bottom: 16px; color: #cbd5e1; }
.modal { background: var(--bg-secondary); border-radius: 12px; padding: 24px; width: 100%; max-width: 440px; }
.modal h2 { margin-bottom: 16px; color: var(--text-subheading); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
/* Token display */
.token-key { background: #0f172a; padding: 8px 12px; border-radius: 6px; font-family: monospace; font-size: 0.85rem; word-break: break-all; margin: 8px 0; display: flex; align-items: center; gap: 8px; }
.token-key { background: var(--bg-primary); padding: 8px 12px; border-radius: 6px; font-family: monospace; font-size: 0.85rem; word-break: break-all; margin: 8px 0; display: flex; align-items: center; gap: 8px; }
.token-key code { flex: 1; }
.copy-btn { background: #334155; border: none; color: #94a3b8; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }
.copy-btn:hover { color: #e2e8f0; }
.copy-btn { background: var(--bg-tertiary); border: none; color: var(--text-secondary); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }
.copy-btn:hover { color: var(--text-primary); }
/* Badge */
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.7rem; font-weight: 600; }
.badge-admin { background: #3b82f620; color: #60a5fa; }
.badge-user { background: #4ade8020; color: #4ade80; }
.badge-totp { background: #a78bfa20; color: #a78bfa; }
.badge-admin { background: var(--accent-blue-bg); color: var(--accent-blue); }
.badge-user { background: var(--accent-green-bg); color: var(--accent-green); }
.badge-totp { background: var(--accent-purple-bg); color: var(--accent-purple); }
.badge-healthy { background: var(--accent-green-bg); color: var(--accent-green); }
.badge-degraded { background: var(--accent-yellow-bg); color: var(--accent-yellow); }
.badge-down { background: var(--accent-red-bg); color: var(--accent-red); }
.badge-success { background: var(--accent-green-bg); color: var(--accent-green); }
.badge-error { background: var(--accent-red-bg); color: var(--accent-red); }
.badge-cached { background: var(--accent-blue-bg); color: var(--accent-blue); }
.badge-priority { background: var(--bg-tertiary); color: var(--text-secondary); }
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.page-header h1 { font-size: 1.3rem; color: #f8fafc; }
.page-header h1 { font-size: 1.3rem; color: var(--text-heading); }
/* Filter bar */
.filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.filter-bar select { padding: 6px 10px; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); font-size: 0.85rem; }
.filter-bar select:focus { outline: none; border-color: var(--accent-blue); }
/* Pagination */
.pagination { display: flex; gap: 4px; align-items: center; margin-top: 16px; justify-content: center; }
.pagination button, .pagination span { padding: 6px 12px; border-radius: 6px; font-size: 0.8rem; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-secondary); cursor: pointer; }
.pagination button:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.pagination button.active { background: var(--accent-blue); border-color: var(--accent-blue); color: #fff; }
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.pagination .page-info { border: none; background: none; cursor: default; color: var(--text-muted); font-size: 0.8rem; }
/* Progress bar */
.progress-bar { width: 100%; height: 8px; background: var(--bg-tertiary); border-radius: 4px; overflow: hidden; }
.progress-bar-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
.budget-info { font-size: 0.75rem; color: var(--text-muted); margin-top: 2px; }
/* Expandable row */
.expandable { cursor: pointer; }
.expand-content { display: none; padding: 8px 12px; background: var(--bg-primary); border-radius: 6px; margin: 4px 0; font-size: 0.8rem; font-family: monospace; white-space: pre-wrap; word-break: break-all; }
.expand-content.show { display: block; }
/* Health status row */
.health-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.health-item { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: var(--bg-secondary); border-radius: 8px; }
.health-item .provider-name { font-weight: 600; font-size: 0.85rem; }
</style>
</head>
<body>
<script>
function getThemePref() { return localStorage.getItem('theme') || 'auto'; }
function applyTheme(pref) {
var effective = pref;
if (pref === 'auto') effective = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
if (effective === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
}
function themeLabel(pref) {
if (pref === 'light') return 'Light';
if (pref === 'dark') return 'Dark';
return 'Auto';
}
function toggleTheme() {
var order = ['dark', 'light', 'auto'];
var cur = getThemePref();
var next = order[(order.indexOf(cur) + 1) % order.length];
localStorage.setItem('theme', next);
applyTheme(next);
var btn = document.getElementById('theme-btn');
if (btn) btn.textContent = 'Theme: ' + themeLabel(next);
}
// Follow system changes when set to auto
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function() {
if (getThemePref() === 'auto') applyTheme('auto');
});
</script>
<div class="sidebar">
<div class="sidebar-brand">LLM Gateway</div>
<nav class="sidebar-nav">
<a href="/dashboard" hx-get="/dashboard" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "dashboard"}}class="active"{{end}}>Dashboard</a>
<a href="/logs" hx-get="/logs" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "logs"}}class="active"{{end}}>Logs</a>
<a href="/models" hx-get="/models" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "models"}}class="active"{{end}}>Models</a>
<a href="/tokens" hx-get="/tokens" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "tokens"}}class="active"{{end}}>API Tokens</a>
{{if .User.IsAdmin}}
<a href="/users" hx-get="/users" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "users"}}class="active"{{end}}>Users</a>
@ -99,6 +234,7 @@
<a href="/settings" hx-get="/settings" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "settings"}}class="active"{{end}}>Settings</a>
</nav>
<div class="sidebar-footer">
<button class="theme-toggle" onclick="toggleTheme()" id="theme-btn">Switch Theme</button>
<div class="user-info">{{.User.Username}}</div>
<a href="#" hx-post="/api/auth/logout" hx-swap="none" onclick="setTimeout(()=>window.location='/login',100)">Logout</a>
</div>
@ -115,6 +251,11 @@ document.body.addEventListener('htmx:pushedIntoHistory', function(e) {
a.classList.toggle('active', a.getAttribute('href') === window.location.pathname);
});
});
// Set initial theme button label
(function() {
var btn = document.getElementById('theme-btn');
if (btn) btn.textContent = 'Theme: ' + themeLabel(getThemePref());
})();
</script>
</body>
</html>

View file

@ -17,6 +17,38 @@
{{end}}
</div>
{{if .ProviderHealth}}
<div class="section">
<h2>Provider Health</h2>
<div class="health-row">
{{range .ProviderHealth}}
<div class="health-item">
<span class="provider-name">{{.Provider}}</span>
<span class="badge badge-{{.Status}}">{{.Status}}</span>
<span style="font-size:0.75rem;color:var(--text-muted)">{{printf "%.0f" .AvgLatency}}ms avg | {{formatPct .ErrorRate}} errors</span>
</div>
{{end}}
</div>
</div>
{{end}}
{{if .Latency}}{{if gt .Latency.Max 0.0}}
<div class="cards">
<div class="card"><div class="label">P50 Latency</div><div class="value">{{printf "%.0f" .Latency.P50}}ms</div></div>
<div class="card"><div class="label">P95 Latency</div><div class="value yellow">{{printf "%.0f" .Latency.P95}}ms</div></div>
<div class="card"><div class="label">P99 Latency</div><div class="value red">{{printf "%.0f" .Latency.P99}}ms</div></div>
<div class="card"><div class="label">Avg Latency</div><div class="value">{{printf "%.0f" .Latency.Avg}}ms</div></div>
</div>
{{end}}{{end}}
{{if .CacheEnabled}}{{if .CacheInfo}}{{if .CacheInfo.Connected}}
<div class="cards">
<div class="card"><div class="label">Cache Hit Rate</div><div class="value green">{{formatPct .CacheInfo.HitRate}}</div><div class="sub">{{.CacheInfo.Hits}} hits / {{.CacheInfo.Misses}} misses</div></div>
<div class="card"><div class="label">Cache Memory</div><div class="value">{{.CacheInfo.MemoryUsed}}</div></div>
<div class="card"><div class="label">Cached Keys</div><div class="value">{{.CacheInfo.Keys}}</div></div>
</div>
{{end}}{{end}}{{end}}
<div class="tabs">
<button class="active" onclick="loadTimeseries('24h', this)">24h</button>
<button onclick="loadTimeseries('7d', this)">7d</button>
@ -27,6 +59,16 @@
<canvas id="chart" height="200"></canvas>
</div>
<div class="section">
<h2>Cost Breakdown</h2>
<div class="tabs" id="cost-tabs">
<button class="active" onclick="loadCostBreakdown('model', this)">By Model</button>
<button onclick="loadCostBreakdown('token', this)">By Token</button>
<button onclick="loadCostBreakdown('provider', this)">By Provider</button>
</div>
<canvas id="cost-chart" height="200"></canvas>
</div>
{{if .Models}}
<div class="section">
<h2>Models</h2>
@ -88,7 +130,22 @@
{{end}}
<script>
var _chart;
var _chart, _costChart;
function chartColors() {
var isLight = document.documentElement.hasAttribute('data-theme');
return {
text: isLight ? '#475569' : '#94a3b8',
grid: isLight ? '#e2e8f020' : '#334155',
green: '#4ade80',
legend: isLight ? '#1e293b' : '#e2e8f0'
};
}
function formatCostTick(v) {
if (v === 0) return '$0';
if (v < 0.001) return '$' + v.toFixed(6);
if (v < 0.01) return '$' + v.toFixed(4);
return '$' + v.toFixed(2);
}
function loadTimeseries(period, btn) {
document.querySelectorAll('.tabs button').forEach(function(b) { b.classList.remove('active'); });
if (btn) btn.classList.add('active');
@ -96,6 +153,7 @@ function loadTimeseries(period, btn) {
fetch('/api/stats/timeseries?period=' + period, {credentials: 'same-origin'})
.then(function(r) { return r.json(); })
.then(function(data) {
var c = chartColors();
var labels = (data||[]).map(function(d) { return d.bucket; });
var requests = (data||[]).map(function(d) { return d.requests; });
var costs = (data||[]).map(function(d) { return d.cost_usd; });
@ -105,24 +163,68 @@ function loadTimeseries(period, btn) {
data: {
labels: labels,
datasets: [
{ label: 'Requests', data: requests, backgroundColor: '#3b82f680', yAxisID: 'y' },
{ label: 'Cost ($)', data: costs, type: 'line', borderColor: '#4ade80', backgroundColor: '#4ade8020', yAxisID: 'y1', tension: 0.3 }
{ label: 'Requests', data: requests, backgroundColor: 'rgba(59,130,246,0.5)', yAxisID: 'y' },
{ label: 'Cost ($)', data: costs, type: 'line', borderColor: c.green, backgroundColor: 'rgba(74,222,128,0.1)', yAxisID: 'y1', tension: 0.3, pointRadius: 3 }
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
y: { position: 'left', ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
y1: { position: 'right', ticks: { color: '#4ade80' }, grid: { display: false } },
x: { ticks: { color: '#94a3b8', maxRotation: 45 }, grid: { color: '#1e293b' } }
y: { position: 'left', beginAtZero: true, ticks: { color: c.text, precision: 0 }, grid: { color: c.grid } },
y1: { position: 'right', beginAtZero: true, ticks: { color: c.green, callback: formatCostTick }, grid: { display: false } },
x: { ticks: { color: c.text, maxRotation: 45 }, grid: { color: c.grid } }
},
plugins: { legend: { labels: { color: '#e2e8f0' } } }
plugins: { legend: { labels: { color: c.legend } } }
}
});
}).catch(function(){});
}
loadTimeseries('24h');
function loadCostBreakdown(groupBy, btn) {
document.querySelectorAll('#cost-tabs button').forEach(function(b) { b.classList.remove('active'); });
if (btn) btn.classList.add('active');
fetch('/api/stats/cost-breakdown?period=7d&group_by=' + groupBy, {credentials: 'same-origin'})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data || data.length === 0) {
if (_costChart) _costChart.destroy();
return;
}
var c = chartColors();
var days = [], groups = {};
(data||[]).forEach(function(d) {
if (days.indexOf(d.day) === -1) days.push(d.day);
if (!groups[d.group_by]) groups[d.group_by] = {};
groups[d.group_by][d.day] = d.cost_usd;
});
var palette = ['#3b82f6','#4ade80','#f87171','#fbbf24','#a78bfa','#f472b6','#22d3ee','#fb923c'];
var datasets = [], ci = 0;
for (var g in groups) {
datasets.push({
label: g,
data: days.map(function(day) { return groups[g][day] || 0; }),
backgroundColor: palette[ci % palette.length] + '80'
});
ci++;
}
if (_costChart) _costChart.destroy();
_costChart = new Chart(document.getElementById('cost-chart'), {
type: 'bar',
data: { labels: days, datasets: datasets },
options: {
responsive: true,
scales: {
x: { stacked: true, ticks: { color: c.text }, grid: { color: c.grid } },
y: { stacked: true, beginAtZero: true, ticks: { color: c.text, callback: formatCostTick }, grid: { color: c.grid } }
},
plugins: { legend: { labels: { color: c.legend } } }
}
});
}).catch(function(){});
}
loadCostBreakdown('model');
</script>
</div>
{{end}}

View file

@ -0,0 +1,120 @@
{{define "content"}}
<div class="page-header">
<h1>Request Logs</h1>
<span style="font-size:0.85rem;color:var(--text-muted)">{{.LogsResult.Total}} total</span>
</div>
<div class="filter-bar">
<select id="filter-model" onchange="applyLogsFilter()">
<option value="">All Models</option>
{{range .LogModels}}<option value="{{.}}" {{if eq . $.FilterModel}}selected{{end}}>{{.}}</option>{{end}}
</select>
<select id="filter-token" onchange="applyLogsFilter()">
<option value="">All Tokens</option>
{{range .LogTokens}}<option value="{{.}}" {{if eq . $.FilterToken}}selected{{end}}>{{.}}</option>{{end}}
</select>
<select id="filter-status" onchange="applyLogsFilter()">
<option value="">All Status</option>
<option value="success" {{if eq .FilterStatus "success"}}selected{{end}}>Success</option>
<option value="error" {{if eq .FilterStatus "error"}}selected{{end}}>Errors Only</option>
<option value="cached" {{if eq .FilterStatus "cached"}}selected{{end}}>Cached</option>
</select>
<button class="btn btn-sm btn-outline" onclick="clearLogsFilter()">Clear</button>
</div>
<div class="section">
<table>
<thead>
<tr>
<th>Time</th>
<th>Token</th>
<th>Model</th>
<th>Provider</th>
<th>Status</th>
<th>Latency</th>
<th>Tokens</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
{{range $i, $log := .LogsResult.Logs}}
<tr class="{{if $log.ErrorMessage}}expandable{{end}}" {{if $log.ErrorMessage}}onclick="toggleExpand('expand-{{$i}}')"{{end}}>
<td>{{formatTimeDetail $log.Timestamp}}</td>
<td>{{$log.TokenName}}</td>
<td>{{$log.Model}}</td>
<td>{{$log.Provider}}</td>
<td>
{{if eq $log.Status "success"}}<span class="badge badge-success">success</span>
{{else if eq $log.Status "error"}}<span class="badge badge-error">error</span>
{{else if eq $log.Status "cached"}}<span class="badge badge-cached">cached</span>
{{else}}<span class="badge">{{$log.Status}}</span>{{end}}
{{if $log.Streaming}} <span class="badge badge-totp">stream</span>{{end}}
</td>
<td>{{$log.LatencyMS}}ms</td>
<td>{{$log.InputTokens}} / {{$log.OutputTokens}}</td>
<td class="green">{{formatCost $log.CostUSD}}</td>
</tr>
{{if $log.ErrorMessage}}
<tr>
<td colspan="8" style="padding:0;">
<div id="expand-{{$i}}" class="expand-content {{if eq $.FilterStatus "error"}}show{{end}}">{{$log.ErrorMessage}}</div>
</td>
</tr>
{{end}}
{{end}}
{{if not .LogsResult.Logs}}
<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:24px;">No logs found</td></tr>
{{end}}
</tbody>
</table>
{{if gt .LogsResult.TotalPages 1}}
<div class="pagination">
<button {{if le .LogsResult.Page 1}}disabled{{end}} onclick="goToLogsPage(1)">First</button>
<button {{if le .LogsResult.Page 1}}disabled{{end}} onclick="goToLogsPage({{subInt .LogsResult.Page 1}})">Prev</button>
{{$page := .LogsResult.Page}}
{{$total := .LogsResult.TotalPages}}
{{range seq (paginationStart $page $total) (paginationEnd $page $total)}}
<button class="{{if eq . $page}}active{{end}}" onclick="goToLogsPage({{.}})">{{.}}</button>
{{end}}
<button {{if ge .LogsResult.Page .LogsResult.TotalPages}}disabled{{end}} onclick="goToLogsPage({{addInt .LogsResult.Page 1}})">Next</button>
<button {{if ge .LogsResult.Page .LogsResult.TotalPages}}disabled{{end}} onclick="goToLogsPage({{.LogsResult.TotalPages}})">Last</button>
<span class="page-info">Page {{.LogsResult.Page}} of {{.LogsResult.TotalPages}}</span>
</div>
{{end}}
</div>
<script>
function buildLogsURL(page) {
var params = [];
var model = document.getElementById('filter-model').value;
var token = document.getElementById('filter-token').value;
var status = document.getElementById('filter-status').value;
if (model) params.push('model=' + encodeURIComponent(model));
if (token) params.push('token=' + encodeURIComponent(token));
if (status) params.push('status=' + encodeURIComponent(status));
if (page > 1) params.push('page=' + page);
return '/logs' + (params.length ? '?' + params.join('&') : '');
}
function applyLogsFilter() {
var url = buildLogsURL(1);
htmx.ajax('GET', url, {target: '#content', swap: 'innerHTML'});
history.pushState({}, '', url);
}
function goToLogsPage(page) {
var url = buildLogsURL(page);
htmx.ajax('GET', url, {target: '#content', swap: 'innerHTML'});
history.pushState({}, '', url);
}
function clearLogsFilter() {
document.getElementById('filter-model').value = '';
document.getElementById('filter-token').value = '';
document.getElementById('filter-status').value = '';
applyLogsFilter();
}
function toggleExpand(id) {
var el = document.getElementById(id);
if (el) el.classList.toggle('show');
}
</script>
{{end}}

View file

@ -0,0 +1,39 @@
{{define "content"}}
<div class="page-header">
<h1>Model Routing</h1>
</div>
{{if .ModelRoutes}}
{{range .ModelRoutes}}
<div class="section">
<h2>{{.Name}}</h2>
<table>
<thead>
<tr>
<th>Provider</th>
<th>Provider Model</th>
<th>Priority</th>
<th>Input Price (per 1M)</th>
<th>Output Price (per 1M)</th>
</tr>
</thead>
<tbody>
{{range .Routes}}
<tr>
<td>{{.ProviderName}}</td>
<td><code>{{.ProviderModel}}</code></td>
<td><span class="badge badge-priority">{{.Priority}}</span></td>
<td>{{formatPrice .InputPrice}}</td>
<td>{{formatPrice .OutputPrice}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{else}}
<div class="section" style="text-align:center;color:var(--text-muted);padding:24px;">
No models configured
</div>
{{end}}
{{end}}

View file

@ -10,9 +10,9 @@
</div>
<div class="section">
<h2>Static Tokens <span style="font-size:0.75rem;color:#64748b;font-weight:400;">(from config, managed via environment variables)</span></h2>
<h2>Static Tokens <span style="font-size:0.75rem;color:var(--text-muted);font-weight:400;">(from config, managed via environment variables)</span></h2>
<table>
<thead><tr><th>Name</th><th>Prefix</th><th>Rate Limit</th><th>Budget</th><th></th></tr></thead>
<thead><tr><th>Name</th><th>Prefix</th><th>Rate Limit</th><th>Budget</th><th>Today's Spend</th><th></th></tr></thead>
<tbody>
{{range .Tokens}}{{if lt .ID 0}}
<tr>
@ -20,6 +20,18 @@
<td><code>{{.KeyPrefix}}...</code></td>
<td>{{if eq .RateLimitRPM 0}}unlimited{{else}}{{.RateLimitRPM}} rpm{{end}}</td>
<td>{{if gt .DailyBudgetUSD 0.0}}${{printf "%.2f" .DailyBudgetUSD}}{{else}}unlimited{{end}}</td>
<td>
{{$spend := index $.TokenSpend .Name}}
{{if gt .DailyBudgetUSD 0.0}}
{{$pct := budgetPct $spend .DailyBudgetUSD}}
<div style="min-width:120px;">
<div class="progress-bar"><div class="progress-bar-fill" style="width:{{if gt $pct 100.0}}100{{else}}{{printf "%.0f" $pct}}{{end}}%;background:{{budgetColor $pct}};"></div></div>
<div class="budget-info">${{printf "%.4f" $spend}} / ${{printf "%.2f" .DailyBudgetUSD}} ({{printf "%.1f" $pct}}%)</div>
</div>
{{else}}
{{if gt $spend 0.0}}{{formatCost $spend}}{{else}}-{{end}}
{{end}}
</td>
<td><span class="badge badge-totp">config</span></td>
</tr>
{{end}}{{end}}
@ -28,17 +40,28 @@
</div>
<div class="section">
<h2>Dynamic Tokens <span style="font-size:0.75rem;color:#64748b;font-weight:400;">(created via dashboard)</span></h2>
<h2>Dynamic Tokens <span style="font-size:0.75rem;color:var(--text-muted);font-weight:400;">(created via dashboard)</span></h2>
<table>
<thead><tr><th>Name</th><th>Prefix</th><th>Rate Limit</th><th>Budget</th><th>Created</th><th>Last Used</th><th></th></tr></thead>
<thead><tr><th>Name</th><th>Prefix</th><th>Rate Limit</th><th>Budget</th><th>Today's Spend</th><th>Created</th><th>Last Used</th><th></th></tr></thead>
<tbody id="tokens-tbody">
{{$hasDynamic := false}}
{{range .Tokens}}{{if gt .ID 0}}
<tr>
<td>{{.Name}}</td>
<td><code>{{.KeyPrefix}}...</code></td>
<td>{{if eq .RateLimitRPM 0}}unlimited{{else}}{{.RateLimitRPM}} rpm{{end}}</td>
<td>{{if gt .DailyBudgetUSD 0.0}}${{printf "%.2f" .DailyBudgetUSD}}{{else}}unlimited{{end}}</td>
<td>
{{$spend := index $.TokenSpend .Name}}
{{if gt .DailyBudgetUSD 0.0}}
{{$pct := budgetPct $spend .DailyBudgetUSD}}
<div style="min-width:120px;">
<div class="progress-bar"><div class="progress-bar-fill" style="width:{{if gt $pct 100.0}}100{{else}}{{printf "%.0f" $pct}}{{end}}%;background:{{budgetColor $pct}};"></div></div>
<div class="budget-info">${{printf "%.4f" $spend}} / ${{printf "%.2f" .DailyBudgetUSD}} ({{printf "%.1f" $pct}}%)</div>
</div>
{{else}}
{{if gt $spend 0.0}}{{formatCost $spend}}{{else}}-{{end}}
{{end}}
</td>
<td>{{formatTime .CreatedAt}}</td>
<td>{{if gt .LastUsedAt 0}}{{formatTime .LastUsedAt}}{{else}}never{{end}}</td>
<td><button class="btn btn-sm btn-danger" onclick="deleteToken({{.ID}})">Revoke</button></td>

View file

@ -0,0 +1,121 @@
package provider
import (
"sync"
"time"
)
// HealthEvent represents a single request outcome for a provider.
type HealthEvent struct {
Timestamp time.Time
LatencyMS int64
IsError bool
ErrorMsg string
}
// ProviderHealth is the computed health status for a provider.
type ProviderHealth struct {
Provider string `json:"provider"`
Status string `json:"status"` // healthy, degraded, down
ErrorRate float64 `json:"error_rate"`
AvgLatency float64 `json:"avg_latency_ms"`
Total int `json:"total"`
Errors int `json:"errors"`
}
// HealthTracker tracks per-provider health using a sliding window.
type HealthTracker struct {
mu sync.RWMutex
windows map[string][]HealthEvent
windowDu time.Duration
}
// NewHealthTracker creates a health tracker with the given window duration.
func NewHealthTracker(window time.Duration) *HealthTracker {
if window == 0 {
window = 5 * time.Minute
}
return &HealthTracker{
windows: make(map[string][]HealthEvent),
windowDu: window,
}
}
// Record adds a health event for a provider.
func (h *HealthTracker) Record(provider string, latencyMS int64, err error) {
event := HealthEvent{
Timestamp: time.Now(),
LatencyMS: latencyMS,
IsError: err != nil,
}
if err != nil {
event.ErrorMsg = err.Error()
}
h.mu.Lock()
defer h.mu.Unlock()
h.windows[provider] = append(h.windows[provider], event)
h.prune(provider)
}
// Status returns computed health for all tracked providers.
func (h *HealthTracker) Status() []ProviderHealth {
h.mu.RLock()
defer h.mu.RUnlock()
cutoff := time.Now().Add(-h.windowDu)
var results []ProviderHealth
for provider, events := range h.windows {
var total, errors int
var totalLatency int64
for _, e := range events {
if e.Timestamp.Before(cutoff) {
continue
}
total++
totalLatency += e.LatencyMS
if e.IsError {
errors++
}
}
if total == 0 {
continue
}
errorRate := float64(errors) / float64(total)
status := "healthy"
if errorRate >= 0.5 {
status = "down"
} else if errorRate >= 0.1 {
status = "degraded"
}
results = append(results, ProviderHealth{
Provider: provider,
Status: status,
ErrorRate: errorRate,
AvgLatency: float64(totalLatency) / float64(total),
Total: total,
Errors: errors,
})
}
return results
}
// prune removes events outside the window. Must be called with lock held.
func (h *HealthTracker) prune(provider string) {
cutoff := time.Now().Add(-h.windowDu)
events := h.windows[provider]
i := 0
for i < len(events) && events[i].Timestamp.Before(cutoff) {
i++
}
if i > 0 {
h.windows[provider] = events[i:]
}
}

View file

@ -70,3 +70,38 @@ func (r *Registry) Lookup(model string) ([]Route, bool) {
func (r *Registry) ModelNames() []string {
return r.order
}
// RouteInfo exposes route details for dashboard display.
type RouteInfo struct {
ProviderName string `json:"provider_name"`
ProviderModel string `json:"provider_model"`
Priority int `json:"priority"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
}
// ModelRouteInfo exposes a model and its routes for dashboard display.
type ModelRouteInfo struct {
Name string `json:"name"`
Routes []RouteInfo `json:"routes"`
}
// AllRoutes returns all models and their routes in config order.
func (r *Registry) AllRoutes() []ModelRouteInfo {
results := make([]ModelRouteInfo, 0, len(r.order))
for _, name := range r.order {
routes := r.routes[name]
info := ModelRouteInfo{Name: name}
for _, rt := range routes {
info.Routes = append(info.Routes, RouteInfo{
ProviderName: rt.Provider.Name(),
ProviderModel: rt.ProviderModel,
Priority: rt.Priority,
InputPrice: rt.InputPrice,
OutputPrice: rt.OutputPrice,
})
}
results = append(results, info)
}
return results
}

View file

@ -41,20 +41,22 @@ func getAPIToken(ctx context.Context) *auth.APIToken {
}
type Handler struct {
registry *provider.Registry
logger *storage.AsyncLogger
cache *cache.Cache
metrics *metrics.Metrics
cfg *config.Config
registry *provider.Registry
logger *storage.AsyncLogger
cache *cache.Cache
metrics *metrics.Metrics
cfg *config.Config
healthTracker *provider.HealthTracker
}
func NewHandler(registry *provider.Registry, logger *storage.AsyncLogger, c *cache.Cache, m *metrics.Metrics, cfg *config.Config) *Handler {
func NewHandler(registry *provider.Registry, logger *storage.AsyncLogger, c *cache.Cache, m *metrics.Metrics, cfg *config.Config, ht *provider.HealthTracker) *Handler {
return &Handler{
registry: registry,
logger: logger,
cache: c,
metrics: m,
cfg: cfg,
registry: registry,
logger: logger,
cache: c,
metrics: m,
cfg: cfg,
healthTracker: ht,
}
}
@ -117,6 +119,9 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, r *http.Request, req *p
// Client error — don't retry
h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "error", latency, 0, 0, 0)
h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, latency, "error", err.Error(), false, false)
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, err)
}
writeErrorRaw(w, pe.StatusCode, pe.Body)
return
}
@ -124,9 +129,16 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, r *http.Request, req *p
log.Printf("Provider %s failed for %s: %v", route.Provider.Name(), req.Model, err)
h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "error", latency, 0, 0, 0)
h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, latency, "error", err.Error(), false, false)
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, err)
}
continue
}
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, nil)
}
// Compute cost
inputTokens, outputTokens := 0, 0
if resp.Usage != nil {

View file

@ -31,12 +31,19 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *prov
latency := time.Since(start).Milliseconds()
h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "error", latency, 0, 0, 0)
h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, latency, "error", err.Error(), true, false)
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, err)
}
writeErrorRaw(w, pe.StatusCode, pe.Body)
return
}
lastErr = err
latency := time.Since(start).Milliseconds()
log.Printf("Provider %s stream failed for %s: %v", route.Provider.Name(), req.Model, err)
h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, time.Since(start).Milliseconds(), "error", err.Error(), true, false)
h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, latency, "error", err.Error(), true, false)
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, err)
}
continue
}
@ -84,6 +91,9 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *prov
cost := computeCost(inputTokens, outputTokens, route.InputPrice, route.OutputPrice)
h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "success", latency, inputTokens, outputTokens, cost)
h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, inputTokens, outputTokens, cost, latency, "success", "", true, false)
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, nil)
}
return
}

View file

@ -101,3 +101,27 @@ func (db *DB) TodaySpend(tokenName string) (float64, error) {
}
return total.Float64, nil
}
// TodaySpendAll returns today's spend for all tokens as a map.
func (db *DB) TodaySpendAll() (map[string]float64, error) {
startOfDay := time.Now().Truncate(24 * time.Hour).Unix()
rows, err := db.Query(
"SELECT token_name, SUM(cost_usd) FROM request_logs WHERE timestamp >= ? GROUP BY token_name",
startOfDay,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]float64)
for rows.Next() {
var name string
var total float64
if err := rows.Scan(&name, &total); err != nil {
continue
}
result[name] = total
}
return result, nil
}