package dashboard import ( "encoding/json" "net/http" "time" "llm-gateway/internal/auth" "llm-gateway/internal/storage" ) // Exported types for template rendering and JSON API. type Period struct { Requests int `json:"requests"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CostUSD float64 `json:"cost_usd"` Errors int `json:"errors"` CachedHits int `json:"cached_hits"` } type SummaryResult struct { Today *Period `json:"today"` Week *Period `json:"week"` Month *Period `json:"month"` } type ModelStats struct { Model string `json:"model"` Requests int `json:"requests"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CostUSD float64 `json:"cost_usd"` AvgLatencyMS float64 `json:"avg_latency_ms"` } type ProviderStats struct { Provider string `json:"provider"` Requests int `json:"requests"` Successes int `json:"successes"` Errors int `json:"errors"` AvgLatencyMS float64 `json:"avg_latency_ms"` CostUSD float64 `json:"cost_usd"` } type TokenUsageStats struct { TokenName string `json:"token_name"` Requests int `json:"requests"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CostUSD float64 `json:"cost_usd"` } type StatsAPI struct { db *storage.DB authStore *auth.Store } func NewStatsAPI(db *storage.DB, authStore *auth.Store) *StatsAPI { return &StatsAPI{db: db, authStore: authStore} } // 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 { if user == nil || user.IsAdmin { return nil } tokens, err := s.authStore.ListAPITokens(user.ID) if err != nil { return []string{"__none__"} } names := make([]string, len(tokens)) for i, t := range tokens { names[i] = t.Name } if len(names) == 0 { return []string{"__none__"} } return names } // tokenNamesForUser returns token names from request context (for HTTP handlers). func (s *StatsAPI) tokenNamesForUser(r *http.Request) []string { user := auth.UserFromContext(r.Context()) return s.TokenNamesForUser(user) } func buildTokenFilter(tokenNames []string) (string, []any) { if tokenNames == nil { return "", nil } placeholders := "" args := make([]any, len(tokenNames)) for i, n := range tokenNames { if i > 0 { placeholders += "," } placeholders += "?" args[i] = n } return " AND token_name IN (" + placeholders + ")", args } // Data-fetching methods (used by both JSON handlers and template handlers). func (s *StatsAPI) GetSummary(tokenNames []string) *SummaryResult { now := time.Now() todayStart := now.Truncate(24 * time.Hour).Unix() weekStart := now.AddDate(0, 0, -7).Unix() monthStart := now.AddDate(0, -1, 0).Unix() tokenFilter, filterArgs := buildTokenFilter(tokenNames) result := &SummaryResult{ Today: &Period{}, Week: &Period{}, Month: &Period{}, } periods := map[string]struct { since int64 period *Period }{ "today": {todayStart, result.Today}, "week": {weekStart, result.Week}, "month": {monthStart, result.Month}, } for _, p := range periods { args := append([]any{p.since}, filterArgs...) row := s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN cached = 1 THEN 1 ELSE 0 END), 0) FROM request_logs WHERE timestamp >= ?`+tokenFilter, args...) row.Scan(&p.period.Requests, &p.period.InputTokens, &p.period.OutputTokens, &p.period.CostUSD, &p.period.Errors, &p.period.CachedHits) } return result } func (s *StatsAPI) GetModels(tokenNames []string) []ModelStats { since := time.Now().AddDate(0, 0, -30).Unix() tokenFilter, filterArgs := buildTokenFilter(tokenNames) args := append([]any{since}, filterArgs...) rows, err := s.db.Query(`SELECT model, COUNT(*) as requests, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens, COALESCE(SUM(cost_usd), 0) as cost, COALESCE(AVG(latency_ms), 0) as avg_latency FROM request_logs WHERE timestamp >= ?`+tokenFilter+` GROUP BY model ORDER BY requests DESC`, args...) if err != nil { return nil } defer rows.Close() var results []ModelStats for rows.Next() { var m ModelStats rows.Scan(&m.Model, &m.Requests, &m.InputTokens, &m.OutputTokens, &m.CostUSD, &m.AvgLatencyMS) results = append(results, m) } return results } func (s *StatsAPI) GetProviders(tokenNames []string) []ProviderStats { since := time.Now().AddDate(0, 0, -30).Unix() tokenFilter, filterArgs := buildTokenFilter(tokenNames) args := append([]any{since}, filterArgs...) rows, err := s.db.Query(`SELECT provider, COUNT(*) as requests, COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) as successes, COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0) as errors, COALESCE(AVG(latency_ms), 0) as avg_latency, COALESCE(SUM(cost_usd), 0) as cost FROM request_logs WHERE timestamp >= ?`+tokenFilter+` GROUP BY provider ORDER BY requests DESC`, args...) if err != nil { return nil } defer rows.Close() var results []ProviderStats for rows.Next() { var p ProviderStats rows.Scan(&p.Provider, &p.Requests, &p.Successes, &p.Errors, &p.AvgLatencyMS, &p.CostUSD) results = append(results, p) } return results } func (s *StatsAPI) GetTokenUsage(tokenNames []string) []TokenUsageStats { since := time.Now().AddDate(0, 0, -30).Unix() tokenFilter, filterArgs := buildTokenFilter(tokenNames) args := append([]any{since}, filterArgs...) rows, err := s.db.Query(`SELECT token_name, COUNT(*) as requests, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens, COALESCE(SUM(cost_usd), 0) as cost FROM request_logs WHERE timestamp >= ?`+tokenFilter+` GROUP BY token_name ORDER BY requests DESC`, args...) if err != nil { return nil } defer rows.Close() var results []TokenUsageStats for rows.Next() { var t TokenUsageStats rows.Scan(&t.TokenName, &t.Requests, &t.InputTokens, &t.OutputTokens, &t.CostUSD) results = append(results, t) } return results } // JSON HTTP handlers (thin wrappers). func (s *StatsAPI) Summary(w http.ResponseWriter, r *http.Request) { tokenNames := s.tokenNamesForUser(r) result := s.GetSummary(tokenNames) writeJSON(w, result) } func (s *StatsAPI) Models(w http.ResponseWriter, r *http.Request) { tokenNames := s.tokenNamesForUser(r) results := s.GetModels(tokenNames) writeJSON(w, results) } func (s *StatsAPI) Providers(w http.ResponseWriter, r *http.Request) { tokenNames := s.tokenNamesForUser(r) results := s.GetProviders(tokenNames) writeJSON(w, results) } func (s *StatsAPI) Tokens(w http.ResponseWriter, r *http.Request) { tokenNames := s.tokenNamesForUser(r) results := s.GetTokenUsage(tokenNames) writeJSON(w, results) } func (s *StatsAPI) Timeseries(w http.ResponseWriter, r *http.Request) { period := r.URL.Query().Get("period") var since int64 var groupFmt string switch period { case "7d": since = time.Now().AddDate(0, 0, -7).Unix() groupFmt = "%Y-%m-%d" case "30d": since = time.Now().AddDate(0, -1, 0).Unix() groupFmt = "%Y-%m-%d" default: since = time.Now().Add(-24 * time.Hour).Unix() groupFmt = "%Y-%m-%d %H:00" } tokenNames := s.tokenNamesForUser(r) tokenFilter, filterArgs := buildTokenFilter(tokenNames) args := append([]any{since}, filterArgs...) rows, err := s.db.Query(`SELECT strftime('`+groupFmt+`', timestamp, 'unixepoch') as bucket, COUNT(*) as requests, COALESCE(SUM(cost_usd), 0) as cost, COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens FROM request_logs WHERE timestamp >= ?`+tokenFilter+` GROUP BY bucket ORDER BY bucket`, args...) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() type point struct { Bucket string `json:"bucket"` Requests int `json:"requests"` CostUSD float64 `json:"cost_usd"` TotalTokens int `json:"total_tokens"` } var results []point for rows.Next() { var p point rows.Scan(&p.Bucket, &p.Requests, &p.CostUSD, &p.TotalTokens) results = append(results, p) } writeJSON(w, results) } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) }