feat(gateway): add provider health tracking and dashboard stats API
This commit is contained in:
parent
98822eadb8
commit
2b6e20544b
14 changed files with 1309 additions and 74 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
114
llm-gateway/internal/cache/cache.go
vendored
114
llm-gateway/internal/cache/cache.go
vendored
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
120
llm-gateway/internal/dashboard/templates/partials/logs.html
Normal file
120
llm-gateway/internal/dashboard/templates/partials/logs.html
Normal 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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
121
llm-gateway/internal/provider/health.go
Normal file
121
llm-gateway/internal/provider/health.go
Normal 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:]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue