diff --git a/.gitignore b/.gitignore
index 759a7302c..da58981ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,8 @@ node_modules/
# Database and logs
requests/*
requests.db
+requests.db-shm
+requests.db-wal
*.log
proxy.log
@@ -39,7 +41,8 @@ coverage/
# Temporary files
tmp/
temp/
+.playwright-mcp/
# Config
-config.yaml
\ No newline at end of file
+config.yaml
diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go
index 8623203b6..b1131b785 100644
--- a/proxy/cmd/proxy/main.go
+++ b/proxy/cmd/proxy/main.go
@@ -65,6 +65,12 @@ func main() {
r.HandleFunc("/", h.UI).Methods("GET")
r.HandleFunc("/ui", h.UI).Methods("GET")
r.HandleFunc("/api/requests", h.GetRequests).Methods("GET")
+ r.HandleFunc("/api/requests/summary", h.GetRequestsSummary).Methods("GET")
+ r.HandleFunc("/api/requests/latest-date", h.GetLatestRequestDate).Methods("GET")
+ r.HandleFunc("/api/requests/{id}", h.GetRequestByID).Methods("GET")
+ r.HandleFunc("/api/stats", h.GetStats).Methods("GET")
+ r.HandleFunc("/api/stats/hourly", h.GetHourlyStats).Methods("GET")
+ r.HandleFunc("/api/stats/models", h.GetModelStats).Methods("GET")
r.HandleFunc("/api/requests", h.DeleteRequests).Methods("DELETE")
r.HandleFunc("/api/conversations", h.GetConversations).Methods("GET")
r.HandleFunc("/api/conversations/{id}", h.GetConversationByID).Methods("GET")
diff --git a/proxy/internal/handler/handlers.go b/proxy/internal/handler/handlers.go
index 4cc322338..8d6535858 100644
--- a/proxy/internal/handler/handlers.go
+++ b/proxy/internal/handler/handlers.go
@@ -237,6 +237,172 @@ func (h *Handler) GetRequests(w http.ResponseWriter, r *http.Request) {
})
}
+// GetRequestsSummary returns lightweight request data for fast list rendering
+func (h *Handler) GetRequestsSummary(w http.ResponseWriter, r *http.Request) {
+ modelFilter := r.URL.Query().Get("model")
+ if modelFilter == "" {
+ modelFilter = "all"
+ }
+
+ // Get start/end time range (UTC ISO 8601 format from browser)
+ startTime := r.URL.Query().Get("start")
+ endTime := r.URL.Query().Get("end")
+
+ // Parse pagination params
+ offset := 0
+ limit := 0 // Default to 0 (no limit - fetch all)
+
+ if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
+ if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
+ offset = parsed
+ }
+ }
+
+ if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+ if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 && parsed <= 100000 {
+ limit = parsed
+ }
+ }
+
+ summaries, total, err := h.storageService.GetRequestsSummaryPaginated(modelFilter, startTime, endTime, offset, limit)
+ if err != nil {
+ log.Printf("Error getting request summaries: %v", err)
+ http.Error(w, "Failed to get requests", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(struct {
+ Requests []*model.RequestSummary `json:"requests"`
+ Total int `json:"total"`
+ Offset int `json:"offset"`
+ Limit int `json:"limit"`
+ }{
+ Requests: summaries,
+ Total: total,
+ Offset: offset,
+ Limit: limit,
+ })
+}
+
+// GetRequestByID returns a single request by its ID
+func (h *Handler) GetRequestByID(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ requestID := vars["id"]
+
+ if requestID == "" {
+ http.Error(w, "Request ID is required", http.StatusBadRequest)
+ return
+ }
+
+ request, fullID, err := h.storageService.GetRequestByShortID(requestID)
+ if err != nil {
+ log.Printf("Error getting request by ID %s: %v", requestID, err)
+ http.Error(w, "Failed to get request", http.StatusInternalServerError)
+ return
+ }
+
+ if request == nil {
+ http.Error(w, "Request not found", http.StatusNotFound)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(struct {
+ Request *model.RequestLog `json:"request"`
+ FullID string `json:"fullId"`
+ }{
+ Request: request,
+ FullID: fullID,
+ })
+}
+
+// GetStats returns aggregated dashboard statistics - lightning fast!
+func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
+ // Get start/end time range (UTC ISO 8601 format from browser)
+ // Browser sends the user's local day boundaries converted to UTC
+ startTime := r.URL.Query().Get("start")
+ endTime := r.URL.Query().Get("end")
+
+ // Fallback to last 7 days if not provided
+ if startTime == "" || endTime == "" {
+ now := time.Now().UTC()
+ endTime = now.Format(time.RFC3339)
+ startTime = now.AddDate(0, 0, -7).Format(time.RFC3339)
+ }
+
+ stats, err := h.storageService.GetStats(startTime, endTime)
+ if err != nil {
+ log.Printf("Error getting stats: %v", err)
+ http.Error(w, "Failed to get stats", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(stats)
+}
+
+// GetHourlyStats returns hourly breakdown for a specific date range
+func (h *Handler) GetHourlyStats(w http.ResponseWriter, r *http.Request) {
+ // Get start/end time range (UTC ISO 8601 format from browser)
+ startTime := r.URL.Query().Get("start")
+ endTime := r.URL.Query().Get("end")
+
+ if startTime == "" || endTime == "" {
+ http.Error(w, "start and end parameters are required", http.StatusBadRequest)
+ return
+ }
+
+ stats, err := h.storageService.GetHourlyStats(startTime, endTime)
+ if err != nil {
+ log.Printf("Error getting hourly stats: %v", err)
+ http.Error(w, "Failed to get hourly stats", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(stats)
+}
+
+// GetModelStats returns model breakdown for a specific date range
+func (h *Handler) GetModelStats(w http.ResponseWriter, r *http.Request) {
+ // Get start/end time range (UTC ISO 8601 format from browser)
+ startTime := r.URL.Query().Get("start")
+ endTime := r.URL.Query().Get("end")
+
+ if startTime == "" || endTime == "" {
+ http.Error(w, "start and end parameters are required", http.StatusBadRequest)
+ return
+ }
+
+ stats, err := h.storageService.GetModelStats(startTime, endTime)
+ if err != nil {
+ log.Printf("Error getting model stats: %v", err)
+ http.Error(w, "Failed to get model stats", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(stats)
+}
+
+// GetLatestRequestDate returns the date of the most recent request
+func (h *Handler) GetLatestRequestDate(w http.ResponseWriter, r *http.Request) {
+ latestDate, err := h.storageService.GetLatestRequestDate()
+ if err != nil {
+ log.Printf("Error getting latest request date: %v", err)
+ http.Error(w, "Failed to get latest request date", http.StatusInternalServerError)
+ return
+ }
+
+ response := map[string]interface{}{
+ "latestDate": latestDate,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+}
+
func (h *Handler) DeleteRequests(w http.ResponseWriter, r *http.Request) {
clearedCount, err := h.storageService.ClearRequests()
diff --git a/proxy/internal/model/models.go b/proxy/internal/model/models.go
index a5d03c058..72aeded87 100644
--- a/proxy/internal/model/models.go
+++ b/proxy/internal/model/models.go
@@ -40,6 +40,20 @@ type RequestLog struct {
Response *ResponseLog `json:"response,omitempty"`
}
+// RequestSummary is a lightweight version of RequestLog for list views
+type RequestSummary struct {
+ RequestID string `json:"requestId"`
+ Timestamp string `json:"timestamp"`
+ Method string `json:"method"`
+ Endpoint string `json:"endpoint"`
+ Model string `json:"model,omitempty"`
+ OriginalModel string `json:"originalModel,omitempty"`
+ RoutedModel string `json:"routedModel,omitempty"`
+ StatusCode int `json:"statusCode,omitempty"`
+ ResponseTime int64 `json:"responseTime,omitempty"`
+ Usage *AnthropicUsage `json:"usage,omitempty"`
+}
+
type ResponseLog struct {
StatusCode int `json:"statusCode"`
Headers map[string][]string `json:"headers"`
@@ -196,3 +210,44 @@ type ContentBlock struct {
Input json.RawMessage `json:"input,omitempty"`
Text string `json:"text,omitempty"`
}
+
+// Dashboard stats structures
+type DashboardStats struct {
+ DailyStats []DailyTokens `json:"dailyStats"`
+}
+
+type HourlyStatsResponse struct {
+ HourlyStats []HourlyTokens `json:"hourlyStats"`
+ TodayTokens int64 `json:"todayTokens"`
+ TodayRequests int `json:"todayRequests"`
+ AvgResponseTime int64 `json:"avgResponseTime"`
+}
+
+type ModelStatsResponse struct {
+ ModelStats []ModelTokens `json:"modelStats"`
+}
+
+type DailyTokens struct {
+ Date string `json:"date"`
+ Tokens int64 `json:"tokens"`
+ Requests int `json:"requests"`
+ Models map[string]ModelStats `json:"models,omitempty"` // Per-model breakdown
+}
+
+type HourlyTokens struct {
+ Hour int `json:"hour"`
+ Tokens int64 `json:"tokens"`
+ Requests int `json:"requests"`
+ Models map[string]ModelStats `json:"models,omitempty"` // Per-model breakdown
+}
+
+type ModelStats struct {
+ Tokens int64 `json:"tokens"`
+ Requests int `json:"requests"`
+}
+
+type ModelTokens struct {
+ Model string `json:"model"`
+ Tokens int64 `json:"tokens"`
+ Requests int `json:"requests"`
+}
diff --git a/proxy/internal/service/model_router.go b/proxy/internal/service/model_router.go
index afc527667..3e7ac6004 100644
--- a/proxy/internal/service/model_router.go
+++ b/proxy/internal/service/model_router.go
@@ -25,7 +25,6 @@ type ModelRouter struct {
providers map[string]provider.Provider
subagentMappings map[string]string // agentName -> targetModel
customAgentPrompts map[string]SubagentDefinition // promptHash -> definition
- modelProviderMap map[string]string // model -> provider mapping
logger *log.Logger
}
@@ -42,7 +41,6 @@ func NewModelRouter(cfg *config.Config, providers map[string]provider.Provider,
providers: providers,
subagentMappings: cfg.Subagents.Mappings,
customAgentPrompts: make(map[string]SubagentDefinition),
- modelProviderMap: initializeModelProviderMap(),
logger: logger,
}
@@ -58,63 +56,6 @@ func NewModelRouter(cfg *config.Config, providers map[string]provider.Provider,
return router
}
-// initializeModelProviderMap creates a mapping of model names to their providers
-func initializeModelProviderMap() map[string]string {
- modelMap := make(map[string]string)
-
- // OpenAI models
- openaiModels := []string{
- // GPT-5 family
- "gpt-5", "gpt-5-mini", "gpt-5-nano",
-
- // GPT-4.1 family
- "gpt-4.1", "gpt-4.1-2025-04-14",
- "gpt-4.1-mini", "gpt-4.1-mini-2025-04-14",
- "gpt-4.1-nano", "gpt-4.1-nano-2025-04-14",
-
- // GPT-4.5
- "gpt-4.5-preview", "gpt-4.5-preview-2025-02-27",
-
- // GPT-4o variants
- "gpt-4o", "gpt-4o-2024-08-06",
- "gpt-4o-mini", "gpt-4o-mini-2024-07-18",
-
- // GPT-3.5 variants
- "gpt-3.5-turbo", "gpt-3.5-turbo-0125", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-instruct",
-
- // O1 series
- "o1", "o1-2024-12-17",
- "o1-pro", "o1-pro-2025-03-19",
- "o1-mini", "o1-mini-2024-09-12",
-
- // O3 series
- "o3-pro", "o3-pro-2025-06-10",
- "o3", "o3-2025-04-16",
- "o3-mini", "o3-mini-2025-01-31",
- }
-
- for _, model := range openaiModels {
- modelMap[model] = "openai"
- }
-
- // Anthropic models
- anthropicModels := []string{
- "claude-opus-4-1-20250805",
- "claude-opus-4-20250514",
- "claude-sonnet-4-20250514",
- "claude-sonnet-4-5-20250929",
- "claude-opus-4-5-20251101",
- "claude-3-7-sonnet-20250219",
- "claude-3-5-haiku-20241022",
- }
-
- for _, model := range anthropicModels {
- modelMap[model] = "anthropic"
- }
-
- return modelMap
-}
-
// extractStaticPrompt extracts the portion before "Notes:" if it exists
func (r *ModelRouter) extractStaticPrompt(systemPrompt string) string {
// Find the "Notes:" section
@@ -265,11 +206,21 @@ func (r *ModelRouter) hashString(s string) string {
}
func (r *ModelRouter) getProviderNameForModel(model string) string {
- if provider, exists := r.modelProviderMap[model]; exists {
- return provider
+ modelLower := strings.ToLower(model)
+
+ // Anthropic models: claude-*
+ if strings.HasPrefix(modelLower, "claude") {
+ return "anthropic"
+ }
+
+ // OpenAI models: gpt-*, o1*, o3*
+ if strings.HasPrefix(modelLower, "gpt") ||
+ strings.HasPrefix(modelLower, "o1") ||
+ strings.HasPrefix(modelLower, "o3") {
+ return "openai"
}
- // Default to anthropic
+ // Default to anthropic for unknown models
r.logger.Printf("⚠️ Model '%s' doesn't match any known patterns, defaulting to anthropic", model)
return "anthropic"
}
diff --git a/proxy/internal/service/storage.go b/proxy/internal/service/storage.go
index 868c616a2..cf0b1c058 100644
--- a/proxy/internal/service/storage.go
+++ b/proxy/internal/service/storage.go
@@ -1,6 +1,8 @@
package service
import (
+ "time"
+
"github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
@@ -15,4 +17,10 @@ type StorageService interface {
GetRequestByShortID(shortID string) (*model.RequestLog, string, error)
GetConfig() *config.StorageConfig
GetAllRequests(modelFilter string) ([]*model.RequestLog, error)
+ GetRequestsSummary(modelFilter string) ([]*model.RequestSummary, error)
+ GetRequestsSummaryPaginated(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error)
+ GetStats(startDate, endDate string) (*model.DashboardStats, error)
+ GetHourlyStats(startTime, endTime string) (*model.HourlyStatsResponse, error)
+ GetModelStats(startTime, endTime string) (*model.ModelStatsResponse, error)
+ GetLatestRequestDate() (*time.Time, error)
}
diff --git a/proxy/internal/service/storage_sqlite.go b/proxy/internal/service/storage_sqlite.go
index 77a52b4e0..1f64d955d 100644
--- a/proxy/internal/service/storage_sqlite.go
+++ b/proxy/internal/service/storage_sqlite.go
@@ -4,7 +4,9 @@ import (
"database/sql"
"encoding/json"
"fmt"
+ "log"
"strings"
+ "time"
_ "github.com/mattn/go-sqlite3"
@@ -18,7 +20,10 @@ type sqliteStorageService struct {
}
func NewSQLiteStorageService(cfg *config.StorageConfig) (StorageService, error) {
- db, err := sql.Open("sqlite3", cfg.DBPath)
+ // Add SQLite-specific connection parameters for better concurrency
+ dbPath := cfg.DBPath + "?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL"
+
+ db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
@@ -342,19 +347,15 @@ func (s *sqliteStorageService) GetAllRequests(modelFilter string) ([]*model.Requ
&req.RoutedModel,
)
if err != nil {
- // Error scanning row - skip
continue
}
- // Unmarshal JSON fields
if err := json.Unmarshal([]byte(headersJSON), &req.Headers); err != nil {
- // Error unmarshaling headers
continue
}
var body interface{}
if err := json.Unmarshal([]byte(bodyJSON), &body); err != nil {
- // Error unmarshaling body
continue
}
req.Body = body
@@ -379,6 +380,495 @@ func (s *sqliteStorageService) GetAllRequests(modelFilter string) ([]*model.Requ
return requests, nil
}
+// GetRequestsSummary returns minimal data for list view - no body/headers, only usage from response
+func (s *sqliteStorageService) GetRequestsSummary(modelFilter string) ([]*model.RequestSummary, error) {
+ query := `
+ SELECT id, timestamp, method, endpoint, model, original_model, routed_model, response
+ FROM requests
+ `
+ args := []interface{}{}
+
+ if modelFilter != "" && modelFilter != "all" {
+ query += " WHERE LOWER(model) LIKE ?"
+ args = append(args, "%"+strings.ToLower(modelFilter)+"%")
+ }
+
+ query += " ORDER BY timestamp DESC"
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query requests: %w", err)
+ }
+ defer rows.Close()
+
+ var summaries []*model.RequestSummary
+ for rows.Next() {
+ var s model.RequestSummary
+ var responseJSON sql.NullString
+
+ err := rows.Scan(
+ &s.RequestID,
+ &s.Timestamp,
+ &s.Method,
+ &s.Endpoint,
+ &s.Model,
+ &s.OriginalModel,
+ &s.RoutedModel,
+ &responseJSON,
+ )
+ if err != nil {
+ continue
+ }
+
+ // Only parse response to extract usage and status
+ if responseJSON.Valid {
+ var resp model.ResponseLog
+ if err := json.Unmarshal([]byte(responseJSON.String), &resp); err == nil {
+ s.StatusCode = resp.StatusCode
+ s.ResponseTime = resp.ResponseTime
+
+ // Extract usage from response body
+ if resp.Body != nil {
+ var respBody struct {
+ Usage *model.AnthropicUsage `json:"usage"`
+ }
+ if err := json.Unmarshal(resp.Body, &respBody); err == nil && respBody.Usage != nil {
+ s.Usage = respBody.Usage
+ }
+ }
+ }
+ }
+
+ summaries = append(summaries, &s)
+ }
+
+ return summaries, nil
+}
+
+// GetRequestsSummaryPaginated returns minimal data for list view with pagination - super fast!
+func (s *sqliteStorageService) GetRequestsSummaryPaginated(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error) {
+ // First get total count
+ countQuery := "SELECT COUNT(*) FROM requests"
+ countArgs := []interface{}{}
+ whereClauses := []string{}
+
+ if modelFilter != "" && modelFilter != "all" {
+ whereClauses = append(whereClauses, "LOWER(model) LIKE ?")
+ countArgs = append(countArgs, "%"+strings.ToLower(modelFilter)+"%")
+ }
+
+ if startTime != "" && endTime != "" {
+ whereClauses = append(whereClauses, "datetime(timestamp) >= datetime(?) AND datetime(timestamp) <= datetime(?)")
+ countArgs = append(countArgs, startTime, endTime)
+ }
+
+ if len(whereClauses) > 0 {
+ countQuery += " WHERE " + strings.Join(whereClauses, " AND ")
+ }
+
+ var total int
+ if err := s.db.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
+ return nil, 0, fmt.Errorf("failed to get total count: %w", err)
+ }
+
+ // Then get the requested page
+ query := `
+ SELECT id, timestamp, method, endpoint, model, original_model, routed_model, response
+ FROM requests
+ `
+ args := []interface{}{}
+ queryWhereClauses := []string{}
+
+ if modelFilter != "" && modelFilter != "all" {
+ queryWhereClauses = append(queryWhereClauses, "LOWER(model) LIKE ?")
+ args = append(args, "%"+strings.ToLower(modelFilter)+"%")
+ }
+
+ if startTime != "" && endTime != "" {
+ queryWhereClauses = append(queryWhereClauses, "datetime(timestamp) >= datetime(?) AND datetime(timestamp) <= datetime(?)")
+ args = append(args, startTime, endTime)
+ }
+
+ if len(queryWhereClauses) > 0 {
+ query += " WHERE " + strings.Join(queryWhereClauses, " AND ")
+ }
+
+ query += " ORDER BY timestamp DESC"
+
+ // Only add LIMIT if specified (0 means no limit)
+ if limit > 0 {
+ query += " LIMIT ? OFFSET ?"
+ args = append(args, limit, offset)
+ } else if offset > 0 {
+ query += " OFFSET ?"
+ args = append(args, offset)
+ }
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, 0, fmt.Errorf("failed to query requests: %w", err)
+ }
+ defer rows.Close()
+
+ var summaries []*model.RequestSummary
+ for rows.Next() {
+ var s model.RequestSummary
+ var responseJSON sql.NullString
+
+ err := rows.Scan(
+ &s.RequestID,
+ &s.Timestamp,
+ &s.Method,
+ &s.Endpoint,
+ &s.Model,
+ &s.OriginalModel,
+ &s.RoutedModel,
+ &responseJSON,
+ )
+ if err != nil {
+ continue
+ }
+
+ // Only parse response to extract usage and status
+ if responseJSON.Valid {
+ var resp model.ResponseLog
+ if err := json.Unmarshal([]byte(responseJSON.String), &resp); err == nil {
+ s.StatusCode = resp.StatusCode
+ s.ResponseTime = resp.ResponseTime
+
+ // Extract usage from response body
+ if resp.Body != nil {
+ var respBody struct {
+ Usage *model.AnthropicUsage `json:"usage"`
+ }
+ if err := json.Unmarshal(resp.Body, &respBody); err == nil && respBody.Usage != nil {
+ s.Usage = respBody.Usage
+ }
+ }
+ }
+ }
+
+ summaries = append(summaries, &s)
+ }
+
+ log.Printf("📊 GetRequestsSummaryPaginated: returned %d requests (total: %d, limit: %d, offset: %d)", len(summaries), total, limit, offset)
+ return summaries, total, nil
+}
+
+// GetStats returns aggregated statistics for the dashboard - lightning fast!
+func (s *sqliteStorageService) GetStats(startDate, endDate string) (*model.DashboardStats, error) {
+ stats := &model.DashboardStats{
+ DailyStats: make([]model.DailyTokens, 0),
+ }
+
+ // Query each request individually to process all responses
+ query := `
+ SELECT timestamp, COALESCE(model, 'unknown') as model, response
+ FROM requests
+ WHERE datetime(timestamp) >= datetime(?) AND datetime(timestamp) <= datetime(?)
+ ORDER BY timestamp
+ `
+
+ rows, err := s.db.Query(query, startDate, endDate)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query stats: %w", err)
+ }
+ defer rows.Close()
+
+ // Aggregate data in memory
+ dailyMap := make(map[string]*model.DailyTokens)
+
+ for rows.Next() {
+ var timestamp, modelName, responseJSON string
+
+ if err := rows.Scan(×tamp, &modelName, &responseJSON); err != nil {
+ continue
+ }
+
+ // Extract date from timestamp (format: 2025-11-28T13:03:29-08:00)
+ date := strings.Split(timestamp, "T")[0]
+
+ // Parse response to get usage
+ var resp model.ResponseLog
+ if err := json.Unmarshal([]byte(responseJSON), &resp); err != nil {
+ continue
+ }
+
+ var usage *model.AnthropicUsage
+ if resp.Body != nil {
+ var respBody struct {
+ Usage *model.AnthropicUsage `json:"usage"`
+ }
+ if err := json.Unmarshal(resp.Body, &respBody); err == nil {
+ usage = respBody.Usage
+ }
+ }
+
+ tokens := int64(0)
+ if usage != nil {
+ tokens = int64(
+ usage.InputTokens +
+ usage.OutputTokens +
+ usage.CacheReadInputTokens +
+ usage.CacheCreationInputTokens)
+ }
+
+ // Daily aggregation
+ if daily, ok := dailyMap[date]; ok {
+ daily.Tokens += tokens
+ daily.Requests++
+ // Update per-model stats
+ if daily.Models == nil {
+ daily.Models = make(map[string]model.ModelStats)
+ }
+ if modelStat, ok := daily.Models[modelName]; ok {
+ modelStat.Tokens += tokens
+ modelStat.Requests++
+ daily.Models[modelName] = modelStat
+ } else {
+ daily.Models[modelName] = model.ModelStats{
+ Tokens: tokens,
+ Requests: 1,
+ }
+ }
+ } else {
+ dailyMap[date] = &model.DailyTokens{
+ Date: date,
+ Tokens: tokens,
+ Requests: 1,
+ Models: map[string]model.ModelStats{
+ modelName: {
+ Tokens: tokens,
+ Requests: 1,
+ },
+ },
+ }
+ }
+
+ }
+
+ // Convert maps to slices
+ for _, v := range dailyMap {
+ stats.DailyStats = append(stats.DailyStats, *v)
+ }
+
+ return stats, nil
+}
+
+// GetHourlyStats returns hourly breakdown for a specific time range
+func (s *sqliteStorageService) GetHourlyStats(startTime, endTime string) (*model.HourlyStatsResponse, error) {
+ query := `
+ SELECT timestamp, COALESCE(model, 'unknown') as model, response
+ FROM requests
+ WHERE datetime(timestamp) >= datetime(?) AND datetime(timestamp) <= datetime(?)
+ ORDER BY timestamp
+ `
+
+ rows, err := s.db.Query(query, startTime, endTime)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query hourly stats: %w", err)
+ }
+ defer rows.Close()
+
+ hourlyMap := make(map[int]*model.HourlyTokens)
+ var totalTokens int64
+ var totalRequests int
+ var totalResponseTime int64
+ var responseCount int
+
+ for rows.Next() {
+ var timestamp, modelName, responseJSON string
+
+ if err := rows.Scan(×tamp, &modelName, &responseJSON); err != nil {
+ continue
+ }
+
+ // Extract hour from timestamp
+ hour := 0
+ if t, err := time.Parse(time.RFC3339, timestamp); err == nil {
+ hour = t.Hour()
+ }
+
+ // Parse response to get usage and response time
+ var resp model.ResponseLog
+ if err := json.Unmarshal([]byte(responseJSON), &resp); err != nil {
+ continue
+ }
+
+ var usage *model.AnthropicUsage
+ if resp.Body != nil {
+ var respBody struct {
+ Usage *model.AnthropicUsage `json:"usage"`
+ }
+ if err := json.Unmarshal(resp.Body, &respBody); err == nil {
+ usage = respBody.Usage
+ }
+ }
+
+ tokens := int64(0)
+ if usage != nil {
+ tokens = int64(
+ usage.InputTokens +
+ usage.OutputTokens +
+ usage.CacheReadInputTokens +
+ usage.CacheCreationInputTokens)
+ }
+
+ totalTokens += tokens
+ totalRequests++
+
+ // Track response time
+ if resp.ResponseTime > 0 {
+ totalResponseTime += resp.ResponseTime
+ responseCount++
+ }
+
+ // Hourly aggregation
+ if hourly, ok := hourlyMap[hour]; ok {
+ hourly.Tokens += tokens
+ hourly.Requests++
+ // Update per-model stats
+ if hourly.Models == nil {
+ hourly.Models = make(map[string]model.ModelStats)
+ }
+ if modelStat, ok := hourly.Models[modelName]; ok {
+ modelStat.Tokens += tokens
+ modelStat.Requests++
+ hourly.Models[modelName] = modelStat
+ } else {
+ hourly.Models[modelName] = model.ModelStats{
+ Tokens: tokens,
+ Requests: 1,
+ }
+ }
+ } else {
+ hourlyMap[hour] = &model.HourlyTokens{
+ Hour: hour,
+ Tokens: tokens,
+ Requests: 1,
+ Models: map[string]model.ModelStats{
+ modelName: {
+ Tokens: tokens,
+ Requests: 1,
+ },
+ },
+ }
+ }
+
+ }
+
+ // Convert map to slice
+ hourlyStats := make([]model.HourlyTokens, 0)
+ for _, v := range hourlyMap {
+ hourlyStats = append(hourlyStats, *v)
+ }
+
+ // Calculate average response time
+ avgResponseTime := int64(0)
+ if responseCount > 0 {
+ avgResponseTime = totalResponseTime / int64(responseCount)
+ }
+
+ return &model.HourlyStatsResponse{
+ HourlyStats: hourlyStats,
+ TodayTokens: totalTokens,
+ TodayRequests: totalRequests,
+ AvgResponseTime: avgResponseTime,
+ }, nil
+}
+
+// GetModelStats returns model breakdown for a specific time range
+func (s *sqliteStorageService) GetModelStats(startTime, endTime string) (*model.ModelStatsResponse, error) {
+ query := `
+ SELECT timestamp, COALESCE(model, 'unknown') as model, response
+ FROM requests
+ WHERE datetime(timestamp) >= datetime(?) AND datetime(timestamp) <= datetime(?)
+ ORDER BY timestamp
+ `
+
+ rows, err := s.db.Query(query, startTime, endTime)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query model stats: %w", err)
+ }
+ defer rows.Close()
+
+ modelMap := make(map[string]*model.ModelTokens)
+
+ for rows.Next() {
+ var timestamp, modelName, responseJSON string
+
+ if err := rows.Scan(×tamp, &modelName, &responseJSON); err != nil {
+ continue
+ }
+
+ // Parse response to get usage
+ var resp model.ResponseLog
+ if err := json.Unmarshal([]byte(responseJSON), &resp); err != nil {
+ continue
+ }
+
+ var usage *model.AnthropicUsage
+ if resp.Body != nil {
+ var respBody struct {
+ Usage *model.AnthropicUsage `json:"usage"`
+ }
+ if err := json.Unmarshal(resp.Body, &respBody); err == nil {
+ usage = respBody.Usage
+ }
+ }
+
+ tokens := int64(0)
+ if usage != nil {
+ tokens = int64(
+ usage.InputTokens +
+ usage.OutputTokens +
+ usage.CacheReadInputTokens +
+ usage.CacheCreationInputTokens)
+ }
+
+ // Model aggregation
+ if modelStat, ok := modelMap[modelName]; ok {
+ modelStat.Tokens += tokens
+ modelStat.Requests++
+ } else {
+ modelMap[modelName] = &model.ModelTokens{
+ Model: modelName,
+ Tokens: tokens,
+ Requests: 1,
+ }
+ }
+ }
+
+ // Convert map to slice
+ modelStats := make([]model.ModelTokens, 0)
+ for _, v := range modelMap {
+ modelStats = append(modelStats, *v)
+ }
+
+ return &model.ModelStatsResponse{
+ ModelStats: modelStats,
+ }, nil
+}
+
+// GetLatestRequestDate returns the timestamp of the most recent request
+func (s *sqliteStorageService) GetLatestRequestDate() (*time.Time, error) {
+ var timestamp string
+ err := s.db.QueryRow("SELECT timestamp FROM requests ORDER BY timestamp DESC LIMIT 1").Scan(×tamp)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to query latest request: %w", err)
+ }
+
+ t, err := time.Parse(time.RFC3339, timestamp)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse timestamp: %w", err)
+ }
+
+ return &t, nil
+}
+
func (s *sqliteStorageService) Close() error {
return s.db.Close()
}
diff --git a/web/app/components/CodeViewer.test.tsx b/web/app/components/CodeViewer.test.tsx
new file mode 100644
index 000000000..be2568ec4
--- /dev/null
+++ b/web/app/components/CodeViewer.test.tsx
@@ -0,0 +1,83 @@
+import { describe, it, expect } from 'vitest';
+
+// Test the escapeHtml and string regex patterns used in CodeViewer
+// We test the logic directly since the component uses internal functions
+
+describe('CodeViewer escapeHtml', () => {
+ // Replicate the escapeHtml function from CodeViewer
+ const escapeHtml = (str: string) => str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+ it('escapes double quotes for attribute safety', () => {
+ expect(escapeHtml('class="foo"')).toBe('class="foo"');
+ });
+
+ it('escapes single quotes for attribute safety', () => {
+ expect(escapeHtml("class='foo'")).toBe("class='foo'");
+ });
+
+ it('escapes HTML tags', () => {
+ expect(escapeHtml('
')).toBe('<div>');
+ });
+
+ it('escapes ampersands', () => {
+ expect(escapeHtml('a && b')).toBe('a && b');
+ });
+});
+
+describe('CodeViewer string regex patterns', () => {
+ // Test the improved string patterns
+ const doubleQuotePattern = /"(?:[^"\\]|\\.)*"/;
+ const singleQuotePattern = /'(?:[^'\\]|\\.)*'/;
+ const backtickPattern = /`(?:[^`\\]|\\.)*`/;
+
+ describe('double-quoted strings', () => {
+ it('matches simple double-quoted strings', () => {
+ expect('"hello"'.match(doubleQuotePattern)?.[0]).toBe('"hello"');
+ });
+
+ it('matches strings with escaped quotes', () => {
+ expect('"He said \\"hello\\""'.match(doubleQuotePattern)?.[0]).toBe('"He said \\"hello\\""');
+ });
+
+ it('matches strings with escaped backslashes', () => {
+ expect('"path\\\\to\\\\file"'.match(doubleQuotePattern)?.[0]).toBe('"path\\\\to\\\\file"');
+ });
+
+ it('matches empty strings', () => {
+ expect('""'.match(doubleQuotePattern)?.[0]).toBe('""');
+ });
+ });
+
+ describe('single-quoted strings', () => {
+ it('matches simple single-quoted strings', () => {
+ expect("'hello'".match(singleQuotePattern)?.[0]).toBe("'hello'");
+ });
+
+ it('matches strings with escaped quotes', () => {
+ expect("'It\\'s fine'".match(singleQuotePattern)?.[0]).toBe("'It\\'s fine'");
+ });
+
+ it('matches empty strings', () => {
+ expect("''".match(singleQuotePattern)?.[0]).toBe("''");
+ });
+ });
+
+ describe('backtick strings', () => {
+ it('matches simple backtick strings', () => {
+ expect('`hello`'.match(backtickPattern)?.[0]).toBe('`hello`');
+ });
+
+ it('matches strings with escaped backticks', () => {
+ expect('`use \\`code\\``'.match(backtickPattern)?.[0]).toBe('`use \\`code\\``');
+ });
+
+ it('matches empty strings', () => {
+ expect('``'.match(backtickPattern)?.[0]).toBe('``');
+ });
+ });
+});
diff --git a/web/app/components/CodeViewer.tsx b/web/app/components/CodeViewer.tsx
index f9f4aae6d..5f9cc4a3c 100644
--- a/web/app/components/CodeViewer.tsx
+++ b/web/app/components/CodeViewer.tsx
@@ -82,39 +82,68 @@ export function CodeViewer({ code, fileName, language }: CodeViewerProps) {
const detectedLanguage = language || getLanguageFromFileName(fileName);
- // Basic syntax highlighting for common tokens
+ // Single-pass syntax highlighting to avoid corrupting HTML class attributes
const highlightCode = (code: string): string => {
- // Escape HTML
- let highlighted = code
+ // Escape HTML helper
+ const escapeHtml = (str: string) => str
.replace(/&/g, '&')
.replace(//g, '>');
-
- // Common patterns for many languages
- const patterns = [
- // Strings
- { regex: /(["'`])(?:(?=(\\?))\2.)*?\1/g, class: 'text-green-400' },
- // Comments
- { regex: /(\/\/.*$)/gm, class: 'text-gray-500 italic' },
- { regex: /(\/\*[\s\S]*?\*\/)/g, class: 'text-gray-500 italic' },
- { regex: /(#.*$)/gm, class: 'text-gray-500 italic' },
- // Numbers
- { regex: /\b(\d+\.?\d*)\b/g, class: 'text-purple-400' },
- // Keywords (common across many languages)
- { regex: /\b(function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default)\b/g, class: 'text-blue-400' },
- // Boolean and null values
- { regex: /\b(true|false|null|undefined|nil|None|True|False)\b/g, class: 'text-orange-400' },
- // Function calls (basic)
- { regex: /(\w+)(?=\s*\()/g, class: 'text-yellow-400' },
- // Types/Classes (PascalCase)
- { regex: /\b([A-Z][a-zA-Z0-9]*)\b/g, class: 'text-cyan-400' },
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+ // Define token patterns with priorities (first match wins)
+ // Order matters: strings and comments first to avoid highlighting inside them
+ const tokenPatterns = [
+ { regex: /"(?:[^"\\]|\\.)*"/, className: 'text-green-400' }, // double-quoted strings
+ { regex: /'(?:[^'\\]|\\.)*'/, className: 'text-green-400' }, // single-quoted strings
+ { regex: /`(?:[^`\\]|\\.)*`/, className: 'text-green-400' }, // backtick strings
+ { regex: /\/\/.*$/, className: 'text-gray-500 italic' }, // single-line comments
+ { regex: /\/\*[\s\S]*?\*\//, className: 'text-gray-500 italic' }, // multi-line comments
+ { regex: /#.*$/, className: 'text-gray-500 italic' }, // hash comments
+ { regex: /\b(function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default|fn|pub|mod|use|mut|match|loop|impl|trait|where|type|readonly|override)\b/, className: 'text-blue-400' }, // keywords
+ { regex: /\b(true|false|null|undefined|nil|None|True|False|NULL)\b/, className: 'text-orange-400' }, // literals
+ { regex: /\b\d+\.?\d*\b/, className: 'text-purple-400' }, // numbers
+ { regex: /\b[A-Z][a-zA-Z0-9]*\b/, className: 'text-cyan-400' }, // PascalCase (types/classes)
];
- patterns.forEach(({ regex, class: className }) => {
- highlighted = highlighted.replace(regex, `
$&`);
- });
+ // Build a combined regex that matches any token
+ const combinedPattern = new RegExp(
+ tokenPatterns.map(p => `(${p.regex.source})`).join('|'),
+ 'gm'
+ );
+
+ let result = '';
+ let lastIndex = 0;
+
+ // Single pass through the string
+ for (const match of code.matchAll(combinedPattern)) {
+ // Add non-matched text before this match (escaped)
+ if (match.index! > lastIndex) {
+ result += escapeHtml(code.slice(lastIndex, match.index));
+ }
+
+ // Find which pattern matched (first non-undefined capture group)
+ const matchedText = match[0];
+ let className = '';
+ for (let i = 0; i < tokenPatterns.length; i++) {
+ if (match[i + 1] !== undefined) {
+ className = tokenPatterns[i].className;
+ break;
+ }
+ }
+
+ // Add the highlighted token (escape the matched text too)
+ result += `
${escapeHtml(matchedText)}`;
+ lastIndex = match.index! + matchedText.length;
+ }
+
+ // Add remaining text after last match
+ if (lastIndex < code.length) {
+ result += escapeHtml(code.slice(lastIndex));
+ }
- return highlighted;
+ return result;
};
const handleCopy = async () => {
diff --git a/web/app/components/RequestCompareModal.tsx b/web/app/components/RequestCompareModal.tsx
new file mode 100644
index 000000000..2d5ad0ec9
--- /dev/null
+++ b/web/app/components/RequestCompareModal.tsx
@@ -0,0 +1,1152 @@
+import { useState, useMemo } from 'react';
+import {
+ X,
+ GitCompare,
+ Plus,
+ Minus,
+ Equal,
+ ChevronDown,
+ ChevronRight,
+ MessageCircle,
+ User,
+ Bot,
+ Settings,
+ Clock,
+ Cpu,
+ Brain,
+ ArrowRight,
+ List,
+ FileText,
+ Download
+} from 'lucide-react';
+import { MessageContent } from './MessageContent';
+
+interface Message {
+ role: string;
+ content: any;
+}
+
+interface Request {
+ id: number;
+ timestamp: string;
+ method: string;
+ endpoint: string;
+ headers: Record
;
+ originalModel?: string;
+ routedModel?: string;
+ body?: {
+ model?: string;
+ messages?: Message[];
+ system?: Array<{
+ text: string;
+ type: string;
+ cache_control?: { type: string };
+ }>;
+ tools?: Array<{
+ name: string;
+ description: string;
+ input_schema?: any;
+ }>;
+ max_tokens?: number;
+ temperature?: number;
+ stream?: boolean;
+ };
+ response?: {
+ statusCode: number;
+ headers: Record;
+ body?: any;
+ bodyText?: string;
+ responseTime: number;
+ streamingChunks?: string[];
+ isStreaming: boolean;
+ completedAt: string;
+ };
+}
+
+interface RequestCompareModalProps {
+ request1: Request;
+ request2: Request;
+ onClose: () => void;
+}
+
+type DiffType = 'added' | 'removed' | 'unchanged' | 'modified';
+
+interface MessageDiff {
+ type: DiffType;
+ index1?: number;
+ index2?: number;
+ message1?: Message;
+ message2?: Message;
+}
+
+// Extract text content from a message for comparison
+function getMessageText(content: any): string {
+ if (typeof content === 'string') {
+ return content;
+ }
+ if (Array.isArray(content)) {
+ return content
+ .map(block => {
+ if (typeof block === 'string') return block;
+ if (block.type === 'text') return block.text || '';
+ if (block.type === 'tool_use') return `[Tool: ${block.name}]`;
+ if (block.type === 'tool_result') return `[Tool Result: ${block.tool_use_id}]`;
+ return JSON.stringify(block);
+ })
+ .join('\n');
+ }
+ return JSON.stringify(content);
+}
+
+// Compare two messages to see if they're similar
+function messagesAreSimilar(msg1: Message, msg2: Message): boolean {
+ if (msg1.role !== msg2.role) return false;
+ const text1 = getMessageText(msg1.content);
+ const text2 = getMessageText(msg2.content);
+ // Consider messages similar if they share >80% of content
+ const shorter = Math.min(text1.length, text2.length);
+ const longer = Math.max(text1.length, text2.length);
+ if (longer === 0) return true;
+ if (shorter / longer < 0.5) return false;
+ // Simple check: if one is a prefix of the other or they're equal
+ return text1 === text2 || text1.startsWith(text2.slice(0, 100)) || text2.startsWith(text1.slice(0, 100));
+}
+
+// Compute diff between two message arrays
+function computeMessageDiff(messages1: Message[], messages2: Message[]): MessageDiff[] {
+ const diffs: MessageDiff[] = [];
+ let i = 0;
+ let j = 0;
+
+ while (i < messages1.length || j < messages2.length) {
+ if (i >= messages1.length) {
+ // All remaining messages in request2 are additions
+ diffs.push({
+ type: 'added',
+ index2: j,
+ message2: messages2[j]
+ });
+ j++;
+ } else if (j >= messages2.length) {
+ // All remaining messages in request1 are removals
+ diffs.push({
+ type: 'removed',
+ index1: i,
+ message1: messages1[i]
+ });
+ i++;
+ } else if (messagesAreSimilar(messages1[i], messages2[j])) {
+ // Messages match
+ const text1 = getMessageText(messages1[i].content);
+ const text2 = getMessageText(messages2[j].content);
+ diffs.push({
+ type: text1 === text2 ? 'unchanged' : 'modified',
+ index1: i,
+ index2: j,
+ message1: messages1[i],
+ message2: messages2[j]
+ });
+ i++;
+ j++;
+ } else {
+ // Look ahead to find a match
+ let foundMatch = false;
+
+ // Check if messages1[i] matches something ahead in messages2
+ for (let k = j + 1; k < Math.min(j + 5, messages2.length); k++) {
+ if (messagesAreSimilar(messages1[i], messages2[k])) {
+ // messages2[j..k-1] are additions
+ for (let l = j; l < k; l++) {
+ diffs.push({
+ type: 'added',
+ index2: l,
+ message2: messages2[l]
+ });
+ }
+ j = k;
+ foundMatch = true;
+ break;
+ }
+ }
+
+ if (!foundMatch) {
+ // Check if messages2[j] matches something ahead in messages1
+ for (let k = i + 1; k < Math.min(i + 5, messages1.length); k++) {
+ if (messagesAreSimilar(messages1[k], messages2[j])) {
+ // messages1[i..k-1] are removals
+ for (let l = i; l < k; l++) {
+ diffs.push({
+ type: 'removed',
+ index1: l,
+ message1: messages1[l]
+ });
+ }
+ i = k;
+ foundMatch = true;
+ break;
+ }
+ }
+ }
+
+ if (!foundMatch) {
+ // No match found, treat as removal then addition
+ diffs.push({
+ type: 'removed',
+ index1: i,
+ message1: messages1[i]
+ });
+ i++;
+ }
+ }
+ }
+
+ return diffs;
+}
+
+export function RequestCompareModal({ request1, request2, onClose }: RequestCompareModalProps) {
+ const [viewMode, setViewMode] = useState<'structured' | 'diff'>('structured');
+ const [expandedSections, setExpandedSections] = useState>({
+ summary: true,
+ messages: true,
+ system: false,
+ tools: false
+ });
+
+ const toggleSection = (section: string) => {
+ setExpandedSections(prev => ({
+ ...prev,
+ [section]: !prev[section]
+ }));
+ };
+
+ const messages1 = request1.body?.messages || [];
+ const messages2 = request2.body?.messages || [];
+
+ const messageDiffs = useMemo(() => computeMessageDiff(messages1, messages2), [messages1, messages2]);
+
+ const diffStats = useMemo(() => {
+ const stats = {
+ added: 0,
+ removed: 0,
+ modified: 0,
+ unchanged: 0
+ };
+ messageDiffs.forEach(diff => {
+ stats[diff.type]++;
+ });
+ return stats;
+ }, [messageDiffs]);
+
+ const getModelDisplay = (request: Request) => {
+ const model = request.routedModel || request.body?.model || 'Unknown';
+ if (model.includes('opus')) return { name: 'Opus', color: 'text-purple-600' };
+ if (model.includes('sonnet')) return { name: 'Sonnet', color: 'text-indigo-600' };
+ if (model.includes('haiku')) return { name: 'Haiku', color: 'text-teal-600' };
+ return { name: model, color: 'text-gray-600' };
+ };
+
+ const model1 = getModelDisplay(request1);
+ const model2 = getModelDisplay(request2);
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
Compare Requests
+
+
{model1.name}
+
+
{model2.name}
+
+
+
+ {/* View mode toggle */}
+
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {viewMode === 'diff' ? (
+
+ ) : (
+ <>
+ {/* Summary Section */}
+
+
toggleSection('summary')}
+ >
+
+
+
+ Comparison Summary
+
+ {expandedSections.summary ? (
+
+ ) : (
+
+ )}
+
+
+ {expandedSections.summary && (
+
+ {/* Stats */}
+
+
+
+
+
+ {diffStats.removed}
+
+
Removed
+
+
+
+
+ {diffStats.modified}
+
+
Modified
+
+
+
+
+ {diffStats.unchanged}
+
+
Unchanged
+
+
+
+ {/* Request comparison */}
+
+
+
+
+
+ )}
+
+
+ {/* Messages Diff Section */}
+
+
toggleSection('messages')}
+ >
+
+
+
+ Message Differences
+
+ {messages1.length} vs {messages2.length} messages
+
+
+ {expandedSections.messages ? (
+
+ ) : (
+
+ )}
+
+
+ {expandedSections.messages && (
+
+ {messageDiffs.length === 0 ? (
+
+
+
No messages to compare
+
+ ) : (
+ messageDiffs.map((diff, index) => (
+
+ ))
+ )}
+
+ )}
+
+
+ {/* System Prompts Comparison */}
+ {(request1.body?.system || request2.body?.system) && (
+
+
toggleSection('system')}
+ >
+
+
+
+ System Prompts
+
+ {request1.body?.system?.length || 0} vs {request2.body?.system?.length || 0}
+
+
+ {expandedSections.system ? (
+
+ ) : (
+
+ )}
+
+
+ {expandedSections.system && (
+
+
+
+
Request #1
+ {request1.body?.system?.map((sys, i) => (
+
+
+ {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''}
+
+
+ )) ||
No system prompt
}
+
+
+
Request #2
+ {request2.body?.system?.map((sys, i) => (
+
+
+ {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''}
+
+
+ )) ||
No system prompt
}
+
+
+
+ )}
+
+ )}
+
+ {/* Tools Comparison */}
+ {(request1.body?.tools || request2.body?.tools) && (
+
+
toggleSection('tools')}
+ >
+
+
+
+ Available Tools
+
+ {request1.body?.tools?.length || 0} vs {request2.body?.tools?.length || 0}
+
+
+ {expandedSections.tools ? (
+
+ ) : (
+
+ )}
+
+
+ {expandedSections.tools && (
+
+
+
+ )}
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
+
+// Convert full request to plain text for diff
+function requestToText(request: Request): string[] {
+ const lines: string[] = [];
+
+ // System prompt
+ if (request.body?.system && request.body.system.length > 0) {
+ lines.push('=== SYSTEM PROMPT ===');
+ request.body.system.forEach((sys, idx) => {
+ lines.push(`--- System Block [${idx + 1}] (${(new Blob([sys.text]).size / 1024).toFixed(1)} KB) ---`);
+ sys.text.split('\n').forEach(line => lines.push(line));
+ lines.push('');
+ });
+ lines.push('');
+ }
+
+ // Tools (just names and sizes, not full definitions)
+ if (request.body?.tools && request.body.tools.length > 0) {
+ lines.push('=== TOOLS ===');
+ const toolsSize = new Blob([JSON.stringify(request.body.tools)]).size;
+ lines.push(`Total: ${request.body.tools.length} tools (${(toolsSize / 1024).toFixed(1)} KB)`);
+ request.body.tools.forEach(tool => {
+ const toolSize = new Blob([JSON.stringify(tool)]).size;
+ lines.push(` - ${tool.name} (${(toolSize / 1024).toFixed(1)} KB)`);
+ });
+ lines.push('');
+ }
+
+ // Messages
+ lines.push('=== MESSAGES ===');
+ const messages = request.body?.messages || [];
+ messages.forEach((msg, idx) => {
+ const roleLabel = msg.role.toUpperCase();
+ const msgSize = new Blob([getMessageText(msg.content)]).size;
+ lines.push(`--- ${roleLabel} [${idx + 1}] (${(msgSize / 1024).toFixed(1)} KB) ---`);
+ const text = getMessageText(msg.content);
+ text.split('\n').forEach(line => lines.push(line));
+ lines.push('');
+ });
+
+ return lines;
+}
+
+// Simple line-based diff algorithm
+function computeLineDiff(lines1: string[], lines2: string[]): Array<{ type: 'same' | 'added' | 'removed'; line: string; lineNum1?: number; lineNum2?: number }> {
+ const result: Array<{ type: 'same' | 'added' | 'removed'; line: string; lineNum1?: number; lineNum2?: number }> = [];
+
+ // Use longest common subsequence approach
+ const m = lines1.length;
+ const n = lines2.length;
+
+ // Build LCS table
+ const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (lines1[i - 1] === lines2[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ // Backtrack to find diff
+ let i = m, j = n;
+ const diffItems: Array<{ type: 'same' | 'added' | 'removed'; line: string; idx1?: number; idx2?: number }> = [];
+
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && lines1[i - 1] === lines2[j - 1]) {
+ diffItems.unshift({ type: 'same', line: lines1[i - 1], idx1: i, idx2: j });
+ i--;
+ j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ diffItems.unshift({ type: 'added', line: lines2[j - 1], idx2: j });
+ j--;
+ } else {
+ diffItems.unshift({ type: 'removed', line: lines1[i - 1], idx1: i });
+ i--;
+ }
+ }
+
+ // Convert to result with line numbers
+ let lineNum1 = 1, lineNum2 = 1;
+ for (const item of diffItems) {
+ if (item.type === 'same') {
+ result.push({ type: 'same', line: item.line, lineNum1: lineNum1++, lineNum2: lineNum2++ });
+ } else if (item.type === 'removed') {
+ result.push({ type: 'removed', line: item.line, lineNum1: lineNum1++ });
+ } else {
+ result.push({ type: 'added', line: item.line, lineNum2: lineNum2++ });
+ }
+ }
+
+ return result;
+}
+
+// Text diff view component
+function TextDiffView({ request1, request2 }: { request1: Request; request2: Request }) {
+ const lines1 = useMemo(() => requestToText(request1), [request1]);
+ const lines2 = useMemo(() => requestToText(request2), [request2]);
+ const diff = useMemo(() => computeLineDiff(lines1, lines2), [lines1, lines2]);
+
+ const stats = useMemo(() => {
+ let added = 0, removed = 0, same = 0;
+ diff.forEach(d => {
+ if (d.type === 'added') added++;
+ else if (d.type === 'removed') removed++;
+ else same++;
+ });
+ return { added, removed, same };
+ }, [diff]);
+
+ // Generate unified diff format
+ const generateUnifiedDiff = () => {
+ const lines: string[] = [];
+ lines.push('--- Request #1');
+ lines.push('+++ Request #2');
+ lines.push('');
+
+ diff.forEach(item => {
+ const prefix = item.type === 'added' ? '+' : item.type === 'removed' ? '-' : ' ';
+ lines.push(`${prefix}${item.line}`);
+ });
+
+ return lines.join('\n');
+ };
+
+ // Generate markdown format
+ const generateMarkdown = () => {
+ const lines: string[] = [];
+ lines.push('# Request Comparison');
+ lines.push('');
+ lines.push(`**Added:** ${stats.added} lines | **Removed:** ${stats.removed} lines | **Unchanged:** ${stats.same} lines`);
+ lines.push('');
+ lines.push('```diff');
+ diff.forEach(item => {
+ const prefix = item.type === 'added' ? '+' : item.type === 'removed' ? '-' : ' ';
+ lines.push(`${prefix}${item.line}`);
+ });
+ lines.push('```');
+ return lines.join('\n');
+ };
+
+ // Generate JSON format
+ const generateJSON = () => {
+ return JSON.stringify({
+ stats,
+ request1: {
+ lines: lines1,
+ timestamp: request1.timestamp,
+ model: request1.routedModel || request1.body?.model
+ },
+ request2: {
+ lines: lines2,
+ timestamp: request2.timestamp,
+ model: request2.routedModel || request2.body?.model
+ },
+ diff: diff.map(d => ({
+ type: d.type,
+ line: d.line,
+ lineNum1: d.lineNum1,
+ lineNum2: d.lineNum2
+ }))
+ }, null, 2);
+ };
+
+ const handleDownload = (format: 'diff' | 'md' | 'json' | 'vscode') => {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+
+ // VS Code: download both files separately
+ if (format === 'vscode') {
+ const file1Content = lines1.join('\n');
+ const file2Content = lines2.join('\n');
+
+ // Download first file
+ const blob1 = new Blob([file1Content], { type: 'text/plain' });
+ const url1 = URL.createObjectURL(blob1);
+ const a1 = document.createElement('a');
+ a1.href = url1;
+ a1.download = `request1-${timestamp}.txt`;
+ document.body.appendChild(a1);
+ a1.click();
+ document.body.removeChild(a1);
+ URL.revokeObjectURL(url1);
+
+ // Small delay then download second file
+ setTimeout(() => {
+ const blob2 = new Blob([file2Content], { type: 'text/plain' });
+ const url2 = URL.createObjectURL(blob2);
+ const a2 = document.createElement('a');
+ a2.href = url2;
+ a2.download = `request2-${timestamp}.txt`;
+ document.body.appendChild(a2);
+ a2.click();
+ document.body.removeChild(a2);
+ URL.revokeObjectURL(url2);
+
+ // Show instruction
+ alert(`Files downloaded!\n\nCompare with your preferred diff tool:\n diff ~/Downloads/request1-${timestamp}.txt ~/Downloads/request2-${timestamp}.txt\n\nOr in VS Code:\n code --diff ~/Downloads/request1-${timestamp}.txt ~/Downloads/request2-${timestamp}.txt`);
+ }, 100);
+
+ return;
+ }
+
+ let content: string;
+ let filename: string;
+ let type: string;
+
+ switch (format) {
+ case 'md':
+ content = generateMarkdown();
+ filename = `diff-${timestamp}.md`;
+ type = 'text/markdown';
+ break;
+ case 'json':
+ content = generateJSON();
+ filename = `diff-${timestamp}.json`;
+ type = 'application/json';
+ break;
+ default:
+ content = generateUnifiedDiff();
+ filename = `diff-${timestamp}.diff`;
+ type = 'text/plain';
+ }
+
+ const blob = new Blob([content], { type });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
+
+
+
+ Text Diff
+
+
+
+{stats.added} added
+
-{stats.removed} removed
+
{stats.same} unchanged
+
+
+
+
+
+
+
+
+
+
+
+ {diff.map((item, idx) => (
+
+ |
+ {item.lineNum1 || ''}
+ |
+
+ {item.lineNum2 || ''}
+ |
+
+ {item.type === 'added' && +}
+ {item.type === 'removed' && -}
+ |
+
+ {item.line || '\u00A0'}
+ |
+
+ ))}
+
+
+
+
+ );
+}
+
+// Calculate size of content in KB
+function getContentSize(content: any): number {
+ if (!content) return 0;
+ const text = typeof content === 'string' ? content : JSON.stringify(content);
+ return new Blob([text]).size;
+}
+
+// Download helper
+function downloadFile(content: string, filename: string, type: string = 'application/json') {
+ const blob = new Blob([content], { type });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+// Request summary card
+function RequestSummaryCard({ request, label }: { request: Request; label: string }) {
+ const model = request.routedModel || request.body?.model || 'Unknown';
+ const tokens = request.response?.body?.usage;
+ const inputTokens = (tokens?.input_tokens || 0) + (tokens?.cache_read_input_tokens || 0);
+ const outputTokens = tokens?.output_tokens || 0;
+ const cacheRead = tokens?.cache_read_input_tokens || 0;
+ const cacheCreation = tokens?.cache_creation_input_tokens || 0;
+
+ // Calculate sizes
+ const systemSize = request.body?.system?.reduce((acc, s) => acc + getContentSize(s.text), 0) || 0;
+ const toolsSize = getContentSize(request.body?.tools);
+ const messagesSize = request.body?.messages?.reduce((acc, m) => acc + getContentSize(m.content), 0) || 0;
+ const totalSize = systemSize + toolsSize + messagesSize;
+
+ const formatSize = (bytes: number) => {
+ if (bytes < 1024) return `${bytes} B`;
+ return `${(bytes / 1024).toFixed(1)} KB`;
+ };
+
+ const handleDownloadJSON = () => {
+ const timestamp = new Date(request.timestamp).toISOString().replace(/[:.]/g, '-');
+ const filename = `request-${timestamp}.json`;
+ downloadFile(JSON.stringify(request, null, 2), filename);
+ };
+
+ const handleDownloadMarkdown = () => {
+ const timestamp = new Date(request.timestamp).toISOString().replace(/[:.]/g, '-');
+ const model = request.routedModel || request.body?.model || 'Unknown';
+
+ let md = `# Request ${timestamp}\n\n`;
+ md += `**Model:** ${model}\n`;
+ md += `**Input Tokens:** ${inputTokens.toLocaleString()}\n`;
+ md += `**Output Tokens:** ${outputTokens.toLocaleString()}\n\n`;
+
+ if (request.body?.system) {
+ md += `## System Prompt\n\n`;
+ request.body.system.forEach((sys, i) => {
+ md += `### Block ${i + 1}\n\n\`\`\`\n${sys.text}\n\`\`\`\n\n`;
+ });
+ }
+
+ if (request.body?.messages) {
+ md += `## Messages\n\n`;
+ request.body.messages.forEach((msg, i) => {
+ md += `### ${msg.role.toUpperCase()} [${i + 1}]\n\n`;
+ const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2);
+ md += `\`\`\`\n${text}\n\`\`\`\n\n`;
+ });
+ }
+
+ downloadFile(md, `request-${timestamp}.md`, 'text/markdown');
+ };
+
+ return (
+
+
+
{label}
+
+
+
+
+
+
+
+ Model:
+ {model.split('-').slice(-1)[0] || model}
+
+
+ Input Tokens:
+ {inputTokens.toLocaleString()}
+
+
+ Output Tokens:
+ {outputTokens.toLocaleString()}
+
+ {cacheRead > 0 && (
+
+ Cache Read:
+ {cacheRead.toLocaleString()}
+
+ )}
+ {cacheCreation > 0 && (
+
+ Cache Creation:
+ {cacheCreation.toLocaleString()}
+
+ )}
+
+
Size Breakdown
+
+ System Prompt:
+ {formatSize(systemSize)}
+
+
+ Tools ({request.body?.tools?.length || 0}):
+ {formatSize(toolsSize)}
+
+
+ Messages ({request.body?.messages?.length || 0}):
+ {formatSize(messagesSize)}
+
+
+ Total:
+ {formatSize(totalSize)}
+
+
+
+ Response Time:
+ {((request.response?.responseTime || 0) / 1000).toFixed(2)}s
+
+
+ Timestamp:
+ {new Date(request.timestamp).toLocaleString()}
+
+
+
+ );
+}
+
+// Get message size in KB
+function getMessageSize(message: Message | undefined): string {
+ if (!message) return '0 KB';
+ const text = getMessageText(message.content);
+ const bytes = new Blob([text]).size;
+ if (bytes < 1024) return `${bytes} B`;
+ return `${(bytes / 1024).toFixed(1)} KB`;
+}
+
+// Message diff row component
+function MessageDiffRow({ diff }: { diff: MessageDiff }) {
+ const [expanded, setExpanded] = useState(diff.type !== 'unchanged');
+
+ const roleIcons = {
+ 'user': User,
+ 'assistant': Bot,
+ 'system': Settings
+ };
+
+ const getDiffStyles = () => {
+ switch (diff.type) {
+ case 'added':
+ return {
+ bg: 'bg-green-50',
+ border: 'border-green-200',
+ icon: ,
+ label: 'Added',
+ labelBg: 'bg-green-100 text-green-700'
+ };
+ case 'removed':
+ return {
+ bg: 'bg-red-50',
+ border: 'border-red-200',
+ icon: ,
+ label: 'Removed',
+ labelBg: 'bg-red-100 text-red-700'
+ };
+ case 'modified':
+ return {
+ bg: 'bg-yellow-50',
+ border: 'border-yellow-200',
+ icon: ,
+ label: 'Modified',
+ labelBg: 'bg-yellow-100 text-yellow-700'
+ };
+ default:
+ return {
+ bg: 'bg-gray-50',
+ border: 'border-gray-200',
+ icon: ,
+ label: 'Unchanged',
+ labelBg: 'bg-gray-100 text-gray-600'
+ };
+ }
+ };
+
+ const styles = getDiffStyles();
+ const message = diff.message1 || diff.message2;
+ const role = message?.role || 'unknown';
+ const Icon = roleIcons[role as keyof typeof roleIcons] || User;
+
+ return (
+
+
setExpanded(!expanded)}
+ >
+
+ {styles.icon}
+
+
+
+
{role}
+
+ {styles.label}
+
+ {diff.index1 !== undefined && (
+
#{diff.index1 + 1}
+ )}
+ {diff.index2 !== undefined && diff.index1 !== diff.index2 && (
+
+ {diff.index1 !== undefined ? ` → #${diff.index2 + 1}` : `#${diff.index2 + 1}`}
+
+ )}
+
+ {getMessageSize(diff.message1 || diff.message2)}
+
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ {expanded && (
+
+ {diff.type === 'modified' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+}
+
+// Tools comparison component
+function ToolsComparison({ tools1, tools2 }: { tools1: any[]; tools2: any[] }) {
+ const toolNames1 = new Set(tools1.map(t => t.name));
+ const toolNames2 = new Set(tools2.map(t => t.name));
+
+ const added = tools2.filter(t => !toolNames1.has(t.name));
+ const removed = tools1.filter(t => !toolNames2.has(t.name));
+ const common = tools1.filter(t => toolNames2.has(t.name));
+
+ return (
+
+ {added.length > 0 && (
+
+
+
+ Added Tools ({added.length})
+
+
+ {added.map((tool, i) => (
+
+ {tool.name}
+
+ ))}
+
+
+ )}
+ {removed.length > 0 && (
+
+
+
+ Removed Tools ({removed.length})
+
+
+ {removed.map((tool, i) => (
+
+ {tool.name}
+
+ ))}
+
+
+ )}
+ {common.length > 0 && (
+
+
+
+ Common Tools ({common.length})
+
+
+ {common.map((tool, i) => (
+
+ {tool.name}
+
+ ))}
+
+
+ )}
+ {tools1.length === 0 && tools2.length === 0 && (
+
+
+
No tools defined in either request
+
+ )}
+
+ );
+}
diff --git a/web/app/components/RequestDetailContent.tsx b/web/app/components/RequestDetailContent.tsx
index 6b291c168..81178657c 100644
--- a/web/app/components/RequestDetailContent.tsx
+++ b/web/app/components/RequestDetailContent.tsx
@@ -77,7 +77,7 @@ interface Request {
interface RequestDetailContentProps {
request: Request;
- onGrade: () => void;
+ onGrade?: () => void;
}
export default function RequestDetailContent({ request, onGrade }: RequestDetailContentProps) {
diff --git a/web/app/components/UsageDashboard.css b/web/app/components/UsageDashboard.css
new file mode 100644
index 000000000..4d860b26b
--- /dev/null
+++ b/web/app/components/UsageDashboard.css
@@ -0,0 +1,605 @@
+.usage-dashboard {
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', system-ui, sans-serif;
+ background: linear-gradient(180deg, #fafafa 0%, #f5f5f7 100%);
+ border-radius: 16px;
+ padding: 20px 24px;
+ margin-bottom: 24px;
+}
+
+.usage-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.usage-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: #86868b;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.usage-period {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ font-weight: 500;
+ color: #1d1d1f;
+}
+
+.usage-period button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 6px;
+ border: none;
+ background: rgba(0,0,0,0.05);
+ color: #86868b;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.usage-period button:hover:not(:disabled) {
+ background: rgba(0,0,0,0.1);
+ color: #1d1d1f;
+}
+
+.usage-period button:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
+.usage-main {
+ display: grid;
+ grid-template-columns: 200px 1fr 180px;
+ gap: 32px;
+ align-items: start;
+}
+
+.usage-total {
+ padding-right: 24px;
+ border-right: 1px solid rgba(0,0,0,0.06);
+}
+
+.usage-total-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: #86868b;
+ margin-bottom: 4px;
+}
+
+.usage-total-value {
+ font-size: 42px;
+ font-weight: 700;
+ color: #1d1d1f;
+ letter-spacing: -1.5px;
+ line-height: 1;
+}
+
+.usage-total-unit {
+ font-size: 18px;
+ font-weight: 500;
+ color: #86868b;
+ margin-left: 4px;
+}
+
+.usage-subtitle {
+ font-size: 12px;
+ color: #86868b;
+ margin-top: 8px;
+}
+
+.usage-charts {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.chart-row {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.chart-label {
+ font-size: 10px;
+ font-weight: 500;
+ color: #86868b;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.chart-with-axis {
+ display: flex;
+ gap: 8px;
+ align-items: stretch;
+ position: relative;
+}
+
+.chart-y-axis {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ align-items: flex-end;
+ min-width: 32px;
+ padding-top: 2px;
+ padding-bottom: 16px;
+}
+
+.y-axis-label {
+ font-size: 9px;
+ font-weight: 500;
+ color: #86868b;
+ line-height: 1;
+}
+
+.weekly-chart {
+ display: flex;
+ align-items: flex-end;
+ gap: 6px;
+ height: 72px;
+ flex: 1;
+ position: relative;
+}
+
+.day-bar {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+ height: 100%;
+ position: relative;
+ cursor: pointer;
+}
+
+.day-bar-fill-container {
+ width: 100%;
+ display: flex;
+ flex-direction: column-reverse;
+ border-radius: 4px;
+ overflow: hidden;
+ min-height: 2px;
+}
+
+.day-bar-segment {
+ width: 100%;
+ transition: height 0.3s ease;
+}
+
+.day-bar-segment.opus {
+ background: linear-gradient(180deg, #9333ea 0%, #7c3aed 100%);
+}
+
+.day-bar-segment.sonnet {
+ background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
+}
+
+.day-bar-segment.haiku {
+ background: linear-gradient(180deg, #10b981 0%, #059669 100%);
+}
+
+.day-bar-label {
+ font-size: 9px;
+ font-weight: 500;
+ color: #86868b;
+ white-space: nowrap;
+ max-width: 100%;
+ text-align: center;
+}
+
+.day-bar-label.is-today {
+ font-weight: 700;
+ color: #1d1d1f;
+}
+
+.hourly-chart-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.hourly-chart {
+ display: flex;
+ align-items: flex-end;
+ gap: 2px;
+ height: 48px;
+ flex: 1;
+ position: relative;
+}
+
+.hour-x-axis {
+ display: flex;
+ justify-content: space-between;
+ padding: 0 2px;
+}
+
+.hour-x-label {
+ font-size: 9px;
+ font-weight: 500;
+ color: #86868b;
+}
+
+.hour-bar-container {
+ flex: 1;
+ display: flex;
+ align-items: flex-end;
+ height: 100%;
+ position: relative;
+ cursor: pointer;
+}
+
+.hour-bar {
+ width: 100%;
+ display: flex;
+ flex-direction: column-reverse;
+ border-radius: 2px;
+ overflow: hidden;
+ transition: height 0.2s ease;
+ min-height: 1px;
+}
+
+.hour-bar-segment {
+ width: 100%;
+ transition: height 0.2s ease;
+}
+
+.hour-bar-segment.opus {
+ background: linear-gradient(180deg, #9333ea 0%, #7c3aed 100%);
+}
+
+.hour-bar-segment.sonnet {
+ background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
+}
+
+.hour-bar-segment.haiku {
+ background: linear-gradient(180deg, #10b981 0%, #059669 100%);
+}
+
+.hour-tooltip {
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: white;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ border-radius: 8px;
+ padding: 8px 10px;
+ margin-bottom: 6px;
+ min-width: 140px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ z-index: 10;
+ pointer-events: none;
+}
+
+.hour-tooltip-time {
+ font-size: 11px;
+ font-weight: 600;
+ color: #1d1d1f;
+ margin-bottom: 6px;
+ padding-bottom: 4px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+}
+
+.hour-tooltip-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 4px 0;
+}
+
+.hour-tooltip-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.hour-tooltip-dot.opus {
+ background: #9333ea;
+}
+
+.hour-tooltip-dot.sonnet {
+ background: #3b82f6;
+}
+
+.hour-tooltip-dot.haiku {
+ background: #10b981;
+}
+
+.hour-tooltip-label {
+ font-size: 10px;
+ color: #86868b;
+ flex: 1;
+}
+
+.hour-tooltip-value {
+ font-size: 10px;
+ font-weight: 600;
+ color: #1d1d1f;
+}
+
+.hour-tooltip-total {
+ display: flex;
+ justify-content: space-between;
+ font-size: 10px;
+ font-weight: 600;
+ color: #1d1d1f;
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
+}
+
+.day-tooltip {
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: white;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ border-radius: 8px;
+ padding: 8px 10px;
+ margin-bottom: 6px;
+ min-width: 140px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ z-index: 10;
+ pointer-events: none;
+}
+
+.day-tooltip-time {
+ font-size: 11px;
+ font-weight: 600;
+ color: #1d1d1f;
+ margin-bottom: 6px;
+ padding-bottom: 4px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+}
+
+.day-tooltip-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 4px 0;
+}
+
+.day-tooltip-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.day-tooltip-dot.opus {
+ background: #9333ea;
+}
+
+.day-tooltip-dot.sonnet {
+ background: #3b82f6;
+}
+
+.day-tooltip-dot.haiku {
+ background: #10b981;
+}
+
+.day-tooltip-label {
+ font-size: 10px;
+ color: #86868b;
+ flex: 1;
+}
+
+.day-tooltip-value {
+ font-size: 10px;
+ font-weight: 600;
+ color: #1d1d1f;
+}
+
+.day-tooltip-total {
+ display: flex;
+ justify-content: space-between;
+ font-size: 10px;
+ font-weight: 600;
+ color: #1d1d1f;
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
+}
+
+.usage-breakdown {
+ padding-left: 24px;
+ border-left: 1px solid rgba(0,0,0,0.06);
+}
+
+.breakdown-title {
+ font-size: 10px;
+ font-weight: 500;
+ color: #86868b;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ margin-bottom: 12px;
+}
+
+.breakdown-item {
+ margin-bottom: 12px;
+}
+
+.breakdown-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 6px;
+}
+
+.breakdown-model {
+ font-size: 12px;
+ font-weight: 600;
+ color: #1d1d1f;
+}
+
+.breakdown-tokens {
+ font-size: 12px;
+ font-weight: 500;
+ color: #86868b;
+}
+
+.breakdown-bar {
+ height: 4px;
+ background: rgba(0,0,0,0.06);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.breakdown-bar-fill {
+ height: 100%;
+ border-radius: 2px;
+ transition: width 0.3s ease;
+}
+
+.model-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.model-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.model-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.model-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.model-name {
+ font-size: 12px;
+ font-weight: 500;
+ color: #1d1d1f;
+}
+
+.model-stats {
+ font-size: 11px;
+ color: #86868b;
+}
+
+.model-bar-container {
+ width: 60px;
+ height: 4px;
+ background: rgba(0,0,0,0.06);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.model-bar {
+ height: 100%;
+ border-radius: 2px;
+ transition: width 0.3s ease;
+}
+
+.quick-stats {
+ display: flex;
+ gap: 24px;
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid rgba(0,0,0,0.06);
+}
+
+.quick-stat {
+ display: flex;
+ flex-direction: column;
+}
+
+.quick-stat-value {
+ font-size: 14px;
+ font-weight: 600;
+ color: #1d1d1f;
+}
+
+.quick-stat-label {
+ font-size: 10px;
+ color: #86868b;
+}
+
+@media (max-width: 900px) {
+ .usage-main {
+ grid-template-columns: 1fr;
+ gap: 20px;
+ }
+
+ .usage-total {
+ border-right: none;
+ padding-right: 0;
+ border-bottom: 1px solid rgba(0,0,0,0.06);
+ padding-bottom: 16px;
+ }
+
+ .usage-breakdown {
+ border-left: none;
+ padding-left: 0;
+ border-top: 1px solid rgba(0,0,0,0.06);
+ padding-top: 16px;
+ }
+}
+
+/* Average line for weekly chart */
+.average-line {
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(to right, transparent 0%, #10b981 1%, #10b981 99%, transparent 100%);
+ border-top: 1px dashed #10b981;
+ opacity: 0.5;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.average-label {
+ position: absolute;
+ right: 8px;
+ top: -10px;
+ font-size: 9px;
+ font-weight: 600;
+ color: #10b981;
+ background: #fafafa;
+ padding: 0 4px;
+ border-radius: 3px;
+}
+
+/* "Now" indicator for hourly chart */
+.now-indicator {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: rgba(0, 0, 0, 0.15);
+ pointer-events: none;
+ z-index: 2;
+}
+
+.now-indicator::before {
+ content: '';
+ position: absolute;
+ top: -2px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 3px solid transparent;
+ border-right: 3px solid transparent;
+ border-top: 4px solid rgba(0, 0, 0, 0.25);
+}
diff --git a/web/app/components/UsageDashboard.tsx b/web/app/components/UsageDashboard.tsx
new file mode 100644
index 000000000..9ceb382b3
--- /dev/null
+++ b/web/app/components/UsageDashboard.tsx
@@ -0,0 +1,451 @@
+import { useMemo, useState } from 'react';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import './UsageDashboard.css';
+
+interface ModelStats {
+ tokens: number;
+ requests: number;
+}
+
+interface DashboardStats {
+ dailyStats: { date: string; tokens: number; requests: number; models?: Record; }[];
+ hourlyStats: { hour: number; tokens: number; requests: number; models?: Record; }[];
+ modelStats: { model: string; tokens: number; requests: number; }[];
+ todayTokens: number;
+ todayRequests: number;
+ avgResponseTime: number;
+}
+
+interface UsageDashboardProps {
+ stats: DashboardStats;
+ selectedDate: Date;
+}
+
+const MODEL_COLORS: Record = {
+ 'claude-opus': '#9333ea',
+ 'claude-sonnet': '#3b82f6',
+ 'claude-haiku': '#10b981',
+};
+
+function getModelDisplayName(model: string): string {
+ if (model.includes('opus')) return 'Opus';
+ if (model.includes('sonnet')) return 'Sonnet';
+ if (model.includes('haiku')) return 'Haiku';
+ return model;
+}
+
+function getModelColor(model: string): string {
+ if (model.includes('opus')) return MODEL_COLORS['claude-opus'];
+ if (model.includes('sonnet')) return MODEL_COLORS['claude-sonnet'];
+ if (model.includes('haiku')) return MODEL_COLORS['claude-haiku'];
+ return '#6b7280';
+}
+
+function formatTokens(tokens: number): string {
+ if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B`;
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`;
+ return tokens.toString();
+}
+
+export function UsageDashboard({ stats, selectedDate = new Date() }: UsageDashboardProps) {
+ const [hoveredHour, setHoveredHour] = useState(null);
+ const [hoveredDay, setHoveredDay] = useState(null);
+
+ const processedStats = useMemo(() => {
+ const selectedDateStr = selectedDate.toISOString().split('T')[0];
+
+ // Build week (Sunday through Saturday) containing the selected date
+ const days = [];
+ const dailyMap = new Map(stats.dailyStats.map(d => [d.date, d]));
+
+ const actualToday = new Date();
+ actualToday.setHours(0, 0, 0, 0);
+ const actualTodayStr = actualToday.toISOString().split('T')[0];
+
+ // Find the Sunday of the week containing selectedDate
+ const weekStart = new Date(selectedDate);
+ weekStart.setHours(0, 0, 0, 0);
+ const dayOfWeek = weekStart.getDay(); // 0 = Sunday, 1 = Monday, etc.
+ weekStart.setDate(weekStart.getDate() - dayOfWeek); // Go back to Sunday
+
+ // Build all 7 days of the week (Sun-Sat)
+ for (let i = 0; i < 7; i++) {
+ const date = new Date(weekStart);
+ date.setDate(date.getDate() + i);
+ const dateStr = date.toISOString().split('T')[0];
+ const dayData = dailyMap.get(dateStr) || { tokens: 0, requests: 0 };
+
+ // Always show short day name (Sun, Mon, Tue, etc.)
+ const dayLabel = date.toLocaleDateString('en-US', { weekday: 'short' });
+
+ days.push({
+ date: dateStr,
+ dayName: dayLabel,
+ tokens: dayData.tokens,
+ requests: dayData.requests,
+ models: dayData.models || {},
+ isToday: dateStr === actualTodayStr, // Mark actual today, not selected date
+ });
+ }
+
+ // Build 24 hours with data from backend
+ const hours = [];
+ const hourMap = new Map(stats.hourlyStats.map(h => [h.hour, h]));
+
+ for (let h = 0; h < 24; h++) {
+ const hourData = hourMap.get(h) || { tokens: 0, requests: 0, models: {} };
+ hours.push({
+ hour: h,
+ tokens: hourData.tokens,
+ requests: hourData.requests,
+ models: hourData.models || {},
+ });
+ }
+
+ // Process model stats
+ const models = stats.modelStats.map(m => ({
+ model: m.model,
+ displayName: getModelDisplayName(m.model),
+ tokens: m.tokens,
+ requests: m.requests,
+ color: getModelColor(m.model),
+ }));
+
+ // Calculate max values for chart scaling
+ const maxDayTokens = Math.max(...days.map(d => d.tokens), 1);
+ const maxHourTokens = Math.max(...hours.map(h => h.tokens), 1);
+ const maxModelTokens = Math.max(...models.map(m => m.tokens), 1);
+
+ // Generate week label (always show date range for clarity)
+ let weekLabel = 'THIS WEEK';
+
+ if (days.length > 0) {
+ // Parse dates as local dates (not UTC) to avoid timezone shifts
+ const parseLocalDate = (dateStr: string) => {
+ const [year, month, day] = dateStr.split('-').map(Number);
+ return new Date(year, month - 1, day);
+ };
+
+ const firstDay = parseLocalDate(days[0].date); // Sunday
+ const lastDay = parseLocalDate(days[6].date); // Saturday
+
+ // Check if this week contains today
+ const containsToday = days.some(day => day.isToday);
+
+ if (containsToday) {
+ weekLabel = 'THIS WEEK';
+ } else {
+ // Format: "Nov 22 - 28" or "Nov 22 - Dec 5" if crossing months
+ const firstMonth = firstDay.toLocaleDateString('en-US', { month: 'short' });
+ const lastMonth = lastDay.toLocaleDateString('en-US', { month: 'short' });
+ const firstDate = firstDay.getDate();
+ const lastDate = lastDay.getDate();
+
+ if (firstMonth === lastMonth) {
+ weekLabel = `${firstMonth} ${firstDate} - ${lastDate}`;
+ } else {
+ weekLabel = `${firstMonth} ${firstDate} - ${lastMonth} ${lastDate}`;
+ }
+ }
+ }
+
+ // Calculate average daily tokens for the week (excluding days with zero tokens)
+ const daysWithData = days.filter(day => day.tokens > 0);
+ const avgDayTokens = daysWithData.length > 0
+ ? Math.round(daysWithData.reduce((sum, day) => sum + day.tokens, 0) / daysWithData.length)
+ : 0;
+
+ // Get current time if viewing today (including minutes for precise positioning)
+ const now = new Date();
+ const isViewingToday = selectedDateStr === now.toISOString().split('T')[0];
+ const currentTimePosition = isViewingToday
+ ? (now.getHours() + now.getMinutes() / 60) / 24 * 100
+ : null;
+
+ return {
+ days,
+ hours,
+ models,
+ maxDayTokens,
+ maxHourTokens,
+ maxModelTokens,
+ todayTokens: stats.todayTokens,
+ todayRequests: stats.todayRequests,
+ avgResponseTime: stats.avgResponseTime,
+ weekLabel,
+ avgDayTokens,
+ currentTimePosition,
+ };
+ }, [stats, selectedDate]);
+
+ const isToday = selectedDate.toDateString() === new Date().toDateString();
+ const dateLabel = isToday ? 'Today' : selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+
+ return (
+
+
+
+
Tokens {dateLabel}
+
+ {formatTokens(processedStats.todayTokens)}
+ {processedStats.todayTokens >= 1000 && tokens}
+
+
+
+ {processedStats.todayRequests}
+ Requests
+
+
+ {(processedStats.avgResponseTime / 1000).toFixed(1)}s
+ Avg Time
+
+
+ {formatTokens(Math.round(processedStats.todayTokens / Math.max(processedStats.todayRequests, 1)))}
+ Avg/Request
+
+
+
+
+
+
+
{processedStats.weekLabel}
+
+
+ {formatTokens(processedStats.maxDayTokens)}
+ {formatTokens(Math.floor(processedStats.maxDayTokens / 2))}
+ 0
+
+
+ {processedStats.days.map((day, i) => {
+ // Calculate stacked heights for each model - check all possible model name variations
+ const modelKeys = Object.keys(day.models || {});
+ const opusTokens = modelKeys.find(k => k.includes('opus'))
+ ? (day.models[modelKeys.find(k => k.includes('opus'))!]?.tokens || 0)
+ : 0;
+ const sonnetTokens = modelKeys.find(k => k.includes('sonnet'))
+ ? (day.models[modelKeys.find(k => k.includes('sonnet'))!]?.tokens || 0)
+ : 0;
+ const haikuTokens = modelKeys.find(k => k.includes('haiku'))
+ ? (day.models[modelKeys.find(k => k.includes('haiku'))!]?.tokens || 0)
+ : 0;
+
+ const totalHeight = Math.max((day.tokens / processedStats.maxDayTokens) * 100, 4);
+ const opusHeight = day.tokens > 0 ? (opusTokens / day.tokens) * totalHeight : 0;
+ const sonnetHeight = day.tokens > 0 ? (sonnetTokens / day.tokens) * totalHeight : 0;
+ const haikuHeight = day.tokens > 0 ? (haikuTokens / day.tokens) * totalHeight : 0;
+
+ return (
+
setHoveredDay(i)}
+ onMouseLeave={() => setHoveredDay(null)}
+ >
+
+ {opusHeight > 0 && (
+
+ )}
+ {sonnetHeight > 0 && (
+
+ )}
+ {haikuHeight > 0 && (
+
+ )}
+
+ {hoveredDay === i && day.tokens > 0 && (
+
+
{day.dayName}
+ {opusTokens > 0 && (
+
+
+
Opus
+
{formatTokens(opusTokens)}
+
+ )}
+ {sonnetTokens > 0 && (
+
+
+
Sonnet
+
{formatTokens(sonnetTokens)}
+
+ )}
+ {haikuTokens > 0 && (
+
+
+
Haiku
+
{formatTokens(haikuTokens)}
+
+ )}
+
+ Total
+ {formatTokens(day.tokens)}
+
+
+ )}
+
{day.dayName}
+
+ );
+ })}
+ {/* Average line */}
+ {processedStats.avgDayTokens > 0 && (
+
+ avg
+
+ )}
+
+
+
+
+
+
Today by Hour
+
+
+ {formatTokens(processedStats.maxHourTokens)}
+ {formatTokens(Math.floor(processedStats.maxHourTokens / 2))}
+ 0
+
+
+
+ {processedStats.hours.map((hour, i) => {
+ // Calculate stacked heights for each model - check all possible model name variations
+ const modelKeys = Object.keys(hour.models || {});
+ const opusTokens = modelKeys.find(k => k.includes('opus'))
+ ? (hour.models[modelKeys.find(k => k.includes('opus'))!]?.tokens || 0)
+ : 0;
+ const sonnetTokens = modelKeys.find(k => k.includes('sonnet'))
+ ? (hour.models[modelKeys.find(k => k.includes('sonnet'))!]?.tokens || 0)
+ : 0;
+ const haikuTokens = modelKeys.find(k => k.includes('haiku'))
+ ? (hour.models[modelKeys.find(k => k.includes('haiku'))!]?.tokens || 0)
+ : 0;
+
+ const totalHeight = Math.max((hour.tokens / processedStats.maxHourTokens) * 100, 3);
+ const opusHeight = hour.tokens > 0 ? (opusTokens / hour.tokens) * totalHeight : 0;
+ const sonnetHeight = hour.tokens > 0 ? (sonnetTokens / hour.tokens) * totalHeight : 0;
+ const haikuHeight = hour.tokens > 0 ? (haikuTokens / hour.tokens) * totalHeight : 0;
+
+ const hourLabel = i === 0 ? '12 AM' : i === 12 ? '12 PM' : i < 12 ? `${i} AM` : `${i - 12} PM`;
+
+ return (
+
setHoveredHour(i)}
+ onMouseLeave={() => setHoveredHour(null)}
+ >
+
+ {opusHeight > 0 && (
+
+ )}
+ {sonnetHeight > 0 && (
+
+ )}
+ {haikuHeight > 0 && (
+
+ )}
+
+ {hoveredHour === i && hour.tokens > 0 && (
+
+
{hourLabel}
+ {opusTokens > 0 && (
+
+
+
Opus
+
{formatTokens(opusTokens)}
+
+ )}
+ {sonnetTokens > 0 && (
+
+
+
Sonnet
+
{formatTokens(sonnetTokens)}
+
+ )}
+ {haikuTokens > 0 && (
+
+
+
Haiku
+
{formatTokens(haikuTokens)}
+
+ )}
+
+ Total
+ {formatTokens(hour.tokens)}
+
+
+ )}
+
+ );
+ })}
+ {/* "Now" indicator line */}
+ {processedStats.currentTimePosition !== null && (
+
+ )}
+
+
+ 12 AM
+ 6 AM
+ 12 PM
+ 6 PM
+
+
+
+
+
+
+
+
Models
+ {processedStats.models.map((model, i) => (
+
+
+ {model.displayName}
+ {formatTokens(model.tokens)}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx
index 99086071e..74ea9b71a 100644
--- a/web/app/routes/_index.tsx
+++ b/web/app/routes/_index.tsx
@@ -1,12 +1,14 @@
import type { MetaFunction } from "@remix-run/node";
-import { useState, useEffect, useTransition } from "react";
-import {
- Activity,
- RefreshCw,
- Trash2,
+import { useState, useEffect, useTransition, useCallback, useRef } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import {
+ Activity,
+ RefreshCw,
+ Trash2,
List,
FileText,
X,
+ ChevronLeft,
ChevronRight,
ChevronDown,
Inbox,
@@ -29,11 +31,16 @@ import {
Check,
Lightbulb,
Loader2,
- ArrowLeftRight
+ ArrowLeftRight,
+ GitCompare,
+ Square,
+ CheckSquare
} from "lucide-react";
import RequestDetailContent from "../components/RequestDetailContent";
import { ConversationThread } from "../components/ConversationThread";
+import { RequestCompareModal } from "../components/RequestCompareModal";
+import { UsageDashboard } from "../components/UsageDashboard";
import { getChatCompletionsEndpoint } from "../utils/models";
export const meta: MetaFunction = () => {
@@ -43,8 +50,30 @@ export const meta: MetaFunction = () => {
];
};
+// Lightweight summary for list view (fast loading)
+interface RequestSummary {
+ id: string;
+ requestId: string;
+ timestamp: string;
+ method: string;
+ endpoint: string;
+ model?: string;
+ originalModel?: string;
+ routedModel?: string;
+ statusCode?: number;
+ responseTime?: number;
+ usage?: {
+ input_tokens?: number;
+ output_tokens?: number;
+ cache_creation_input_tokens?: number;
+ cache_read_input_tokens?: number;
+ };
+}
+
+// Full request details (loaded on demand)
interface Request {
id: number;
+ requestId?: string;
conversationId?: string;
turnNumber?: number;
isRoot?: boolean;
@@ -138,8 +167,27 @@ interface Conversation {
messageCount: number;
}
+interface DashboardStats {
+ dailyStats: { date: string; tokens: number; requests: number; models?: Record; }[];
+ hourlyStats?: { hour: number; tokens: number; requests: number; models?: Record; }[];
+ modelStats?: { model: string; tokens: number; requests: number; }[];
+ todayTokens?: number;
+ todayRequests?: number;
+ avgResponseTime?: number;
+}
+
+interface ModelStats {
+ tokens: number;
+ requests: number;
+}
+
export default function Index() {
- const [requests, setRequests] = useState([]);
+ const [requestSummaries, setRequestSummaries] = useState([]);
+ const [requestDetailsCache, setRequestDetailsCache] = useState
- {/* Filter buttons - only show for requests view */}
- {viewMode === "requests" && (
-
-
-
-
-
-
+ {/* Compare mode banner - sticky below header */}
+ {compareMode && viewMode === "requests" && (
+
+
+
+
+
+
+
+ Compare Mode
+
+
+ Select 2 requests to compare ({selectedForCompare.length}/2 selected)
+
+
+
+
+ {selectedForCompare.length === 2 && (
+
+ )}
+
+
+
)}
{/* Main Content */}
-
- {/* Stats Grid */}
-
-
+
+ {viewMode === "requests" && (
+
+ {/* Date Navigation - Always Visible */}
-
-
- {viewMode === "requests" ? "Total Requests" : "Total Conversations"}
-
-
- {viewMode === "requests" ? requests.length : conversations.length}
-
+
Request History
+
+
+
+ {selectedDate.toDateString() === new Date().toDateString()
+ ? 'Today'
+ : selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
+
+
-
-
- {/* Main Content */}
- {viewMode === "requests" ? (
- /* Request History */
-
-
-
- {(isFetching && requestsCurrentPage === 1) || isPending ? (
-
-
-
Loading requests...
-
- ) : filteredRequests.length === 0 ? (
-
-
No requests found
-
Make sure you have set ANTHROPIC_BASE_URL to point at the proxy
+ {/* Loading State - Only for initial stats load */}
+ {isLoadingStats ? (
+
+
- ) : (
- <>
- {filteredRequests.map(request => (
-
showRequestDetails(request.id)}>
-
-
- {/* Model and Status */}
-
-
- {request.routedModel || request.body?.model ? (
- // Use routedModel if available, otherwise fall back to body.model
- (() => {
- const model = request.routedModel || request.body?.model || '';
- if (model.includes('opus')) return Opus;
- if (model.includes('sonnet')) return Sonnet;
- if (model.includes('haiku')) return Haiku;
- if (model.includes('gpt-4o')) return GPT-4o;
- if (model.includes('gpt')) return GPT;
- return {model.split('-')[0]};
- })()
- ) : API}
-
- {request.routedModel && request.routedModel !== request.originalModel && (
-
-
- routed
-
- )}
- {request.response?.statusCode && (
-
= 200 && request.response.statusCode < 300
- ? 'bg-green-100 text-green-700'
- : request.response.statusCode >= 300 && request.response.statusCode < 400
- ? 'bg-yellow-100 text-yellow-700'
- : 'bg-red-100 text-red-700'
- }`}>
- {request.response.statusCode}
-
- )}
- {request.conversationId && (
-
- Turn {request.turnNumber}
-
- )}
-
-
- {/* Endpoint */}
-
- {getChatCompletionsEndpoint(request.routedModel, request.endpoint)}
-
-
- {/* Metrics Row */}
-
- {request.response?.body?.usage && (
- <>
-
- {((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.output_tokens || 0)).toLocaleString()} tokens
-
- {request.response.body.usage.cache_read_input_tokens && (
-
- {request.response.body.usage.cache_read_input_tokens.toLocaleString()} cached
-
- )}
- >
- )}
-
- {request.response?.responseTime && (
-
- {(request.response.responseTime / 1000).toFixed(2)}s
-
- )}
-
-
-
-
- {new Date(request.timestamp).toLocaleDateString()}
-
-
- {new Date(request.timestamp).toLocaleTimeString()}
-
-
+
+ ) : (
+
+ {/* Stats Dashboard */}
+ {stats &&
}
+
+ {/* Request List */}
+
+
+
+
Requests
+
+
+
+
+
- ))}
- {hasMoreRequests && (
-
-
+
+ {isFetching ? (
+
+
+
Loading requests...
+
+ ) : filteredRequests.length === 0 ? (
+
+
No requests found
+
No requests for this date
+
+ ) : (
+
- {isFetching ? "Loading..." : "Load More"}
-
-
- )}
- >
- )}
-
-
- ) : (
- /* Conversations View */
-
-
-
Conversations
-
-
- {(isFetching && conversationsCurrentPage === 1) || isPending ? (
-
-
-
Loading conversations...
+
+ {requestsVirtualizer.getVirtualItems().map((virtualItem) => {
+ const summary = filteredRequests[virtualItem.index];
+ return (
+
showRequestDetails(summary.requestId)}
+ >
+
+
+
+
+ {summary.model.toLowerCase().includes('opus')
+ ? 'Opus'
+ : summary.model.toLowerCase().includes('sonnet')
+ ? 'Sonnet'
+ : 'Haiku'}
+
+ {summary.statusCode && (
+
+ {summary.statusCode === 200 && '200'}
+
+ )}
+
+
+ {summary.endpoint}
+
+
+ {summary.usage && (
+ <>
+ {(summary.usage.input_tokens || summary.usage.cache_read_input_tokens) && (
+
+
+ {(summary.usage.input_tokens || 0).toLocaleString()}
+ {' '}
+ in
+
+ )}
+ {summary.usage.output_tokens && (
+
+
+ {summary.usage.output_tokens.toLocaleString()}
+ {' '}
+ out
+
+ )}
+ {summary.usage.cache_read_input_tokens && (
+
+ {Math.round(((summary.usage.cache_read_input_tokens || 0) / ((summary.usage.input_tokens || 0) + (summary.usage.cache_read_input_tokens || 0))) * 100)}% cached
+
+ )}
+ >
+ )}
+ {summary.responseTime && (
+
+ {(summary.responseTime / 1000).toFixed(2)}s
+
+ )}
+
+
+
+
+ {new Date(summary.timestamp).toLocaleDateString()}
+
+
+ {new Date(summary.timestamp).toLocaleTimeString()}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
- ) : conversations.length === 0 ? (
-
-
No conversations found
-
Start a conversation to see it appear here
+
+ )}
+
+ )}
+
+ {viewMode === "conversations" && (
+ <>
+
+
+
+
+
+ Total Conversations
+
+
+ {conversations.length}
+
+
- ) : (
- <>
- {conversations.map(conversation => (
-
loadConversationDetails(conversation.id, conversation.projectName)}>
-
-
-
-
- #{conversation.id.slice(-8)}
-
-
- {conversation.requestCount} turns
-
-
- {formatDuration(conversation.duration)}
-
- {conversation.projectName && (
-
- {conversation.projectName}
+
+
+
+ {/* Conversations View */}
+
+
+
Conversations
+
+
+ {(isFetching && conversationsCurrentPage === 1) || isPending ? (
+
+
+
Loading conversations...
+
+ ) : conversations.length === 0 ? (
+
+
No conversations found
+
Start a conversation to see it appear here
+
+ ) : (
+ <>
+ {conversations.map(conversation => (
+
loadConversationDetails(conversation.id, conversation.projectName)}>
+
+
+
+
+ #{conversation.id.slice(-8)}
- )}
-
-
-
-
First Message
-
- {conversation.firstMessage || "No content"}
-
+
+ {conversation.requestCount} turns
+
+
+ {formatDuration(conversation.duration)}
+
+ {conversation.projectName && (
+
+ {conversation.projectName}
+
+ )}
- {conversation.lastMessage && conversation.lastMessage !== conversation.firstMessage && (
-
-
Latest Message
+
+
+
First Message
- {conversation.lastMessage}
+ {conversation.firstMessage || "No content"}
- )}
-
-
-
-
- {new Date(conversation.startTime).toLocaleDateString()}
+ {conversation.lastMessage && conversation.lastMessage !== conversation.firstMessage && (
+
+
Latest Message
+
+ {conversation.lastMessage}
+
+
+ )}
+
-
- {new Date(conversation.startTime).toLocaleTimeString()}
+
+
+ {new Date(conversation.startTime).toLocaleDateString()}
+
+
+ {new Date(conversation.startTime).toLocaleTimeString()}
+
-
- ))}
- {hasMoreConversations && (
-
-
-
- )}
- >
- )}
+ ))}
+ {hasMoreConversations && (
+
+
+
+ )}
+ >
+ )}
+
-
+ >
)}
@@ -853,7 +1181,7 @@ export default function Index() {
- gradeRequest(selectedRequest.id)} />
+
@@ -909,6 +1237,15 @@ export default function Index() {
)}
+
+ {/* Request Compare Modal */}
+ {isCompareModalOpen && selectedForCompare.length === 2 && (
+
+ )}
);
}
diff --git a/web/app/routes/api.requests.$id.tsx b/web/app/routes/api.requests.$id.tsx
new file mode 100644
index 000000000..bf488e226
--- /dev/null
+++ b/web/app/routes/api.requests.$id.tsx
@@ -0,0 +1,23 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+
+const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const { id } = params;
+
+ if (!id) {
+ throw new Response("Request ID is required", { status: 400 });
+ }
+
+ const proxyUrl = `${PROXY_URL}/api/requests/${id}`;
+ const response = await fetch(proxyUrl);
+
+ if (!response.ok) {
+ throw new Response(`Failed to fetch request: ${response.statusText}`, {
+ status: response.status,
+ });
+ }
+
+ return json(await response.json());
+}
diff --git a/web/app/routes/api.requests.latest-date.tsx b/web/app/routes/api.requests.latest-date.tsx
new file mode 100644
index 000000000..0dd6ae312
--- /dev/null
+++ b/web/app/routes/api.requests.latest-date.tsx
@@ -0,0 +1,19 @@
+import type { LoaderFunctionArgs } from "@remix-run/node";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const apiUrl = process.env.API_URL || "http://localhost:3001";
+
+ const response = await fetch(`${apiUrl}/api/requests/latest-date`);
+
+ if (!response.ok) {
+ return new Response(JSON.stringify({ latestDate: null }), {
+ status: response.status,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ const data = await response.json();
+ return new Response(JSON.stringify(data), {
+ headers: { "Content-Type": "application/json" },
+ });
+}
diff --git a/web/app/routes/api.requests.summary.tsx b/web/app/routes/api.requests.summary.tsx
new file mode 100644
index 000000000..55e1bc4ae
--- /dev/null
+++ b/web/app/routes/api.requests.summary.tsx
@@ -0,0 +1,30 @@
+import type { LoaderFunction } from "@remix-run/node";
+import { json } from "@remix-run/node";
+
+const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
+
+export const loader: LoaderFunction = async ({ request }) => {
+ try {
+ const url = new URL(request.url);
+
+ // Forward all known filters (model, start/end, pagination) to the Go backend
+ const backendUrl = new URL(`${PROXY_URL}/api/requests/summary`);
+ url.searchParams.forEach((value, key) => {
+ backendUrl.searchParams.append(key, value);
+ });
+
+ const response = await fetch(backendUrl.toString());
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return json(data);
+ } catch (error) {
+ console.error("Failed to fetch request summaries:", error);
+
+ // Return empty array if backend is not available
+ return json({ requests: [], total: 0 });
+ }
+};
diff --git a/web/app/routes/api.stats.hourly.tsx b/web/app/routes/api.stats.hourly.tsx
new file mode 100644
index 000000000..e6e020a84
--- /dev/null
+++ b/web/app/routes/api.stats.hourly.tsx
@@ -0,0 +1,23 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+
+const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const url = new URL(request.url);
+ const date = url.searchParams.get("date");
+
+ if (!date) {
+ throw new Response("date is required", { status: 400 });
+ }
+
+ const params = new URLSearchParams({ date });
+ const proxyUrl = `${PROXY_URL}/api/stats/hourly?${params.toString()}`;
+ const response = await fetch(proxyUrl);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch hourly stats: ${response.statusText}`);
+ }
+
+ return json(await response.json());
+}
diff --git a/web/app/routes/api.stats.models.tsx b/web/app/routes/api.stats.models.tsx
new file mode 100644
index 000000000..62f72c9af
--- /dev/null
+++ b/web/app/routes/api.stats.models.tsx
@@ -0,0 +1,23 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+
+const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const url = new URL(request.url);
+ const date = url.searchParams.get("date");
+
+ if (!date) {
+ throw new Response("date is required", { status: 400 });
+ }
+
+ const params = new URLSearchParams({ date });
+ const proxyUrl = `${PROXY_URL}/api/stats/models?${params.toString()}`;
+ const response = await fetch(proxyUrl);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch model stats: ${response.statusText}`);
+ }
+
+ return json(await response.json());
+}
diff --git a/web/app/routes/api.stats.tsx b/web/app/routes/api.stats.tsx
new file mode 100644
index 000000000..64fff0be7
--- /dev/null
+++ b/web/app/routes/api.stats.tsx
@@ -0,0 +1,23 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+
+const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const url = new URL(request.url);
+ const start = url.searchParams.get("start");
+ const end = url.searchParams.get("end");
+
+ const params = new URLSearchParams();
+ if (start) params.set("start", start);
+ if (end) params.set("end", end);
+
+ const proxyUrl = `${PROXY_URL}/api/stats${params.toString() ? `?${params}` : ''}`;
+ const response = await fetch(proxyUrl);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch stats: ${response.statusText}`);
+ }
+
+ return json(await response.json());
+}
diff --git a/web/app/utils/formatters.test.ts b/web/app/utils/formatters.test.ts
new file mode 100644
index 000000000..74a468e6d
--- /dev/null
+++ b/web/app/utils/formatters.test.ts
@@ -0,0 +1,76 @@
+import { describe, it, expect } from 'vitest';
+import { escapeHtml, formatLargeText } from './formatters';
+
+describe('escapeHtml', () => {
+ it('escapes ampersands', () => {
+ expect(escapeHtml('a & b')).toBe('a & b');
+ });
+
+ it('escapes less than', () => {
+ expect(escapeHtml('a < b')).toBe('a < b');
+ });
+
+ it('escapes greater than', () => {
+ expect(escapeHtml('a > b')).toBe('a > b');
+ });
+
+ it('escapes double quotes', () => {
+ expect(escapeHtml('He said "hello"')).toBe('He said "hello"');
+ });
+
+ it('escapes single quotes', () => {
+ expect(escapeHtml("It's fine")).toBe("It's fine");
+ });
+
+ it('escapes all special characters together', () => {
+ expect(escapeHtml('')).toBe(
+ '<script>"alert('xss')&"</script>'
+ );
+ });
+});
+
+describe('formatLargeText', () => {
+ it('returns empty string for empty input', () => {
+ expect(formatLargeText('')).toBe('');
+ });
+
+ it('wraps simple text in paragraph tags', () => {
+ expect(formatLargeText('Hello world')).toBe('
Hello world
');
+ });
+
+ it('converts single newlines to br tags', () => {
+ expect(formatLargeText('Line1\nLine2')).toBe('
Line1
Line2
');
+ });
+
+ it('converts double newlines to paragraph breaks with proper nesting', () => {
+ const result = formatLargeText('Line1\n\nLine2');
+ expect(result).toBe('
Line1
Line2
');
+ });
+
+ it('handles multiple paragraph breaks correctly', () => {
+ const result = formatLargeText('Para1\n\nPara2\n\nPara3');
+ expect(result).toBe('
Para1
Para2
Para3
');
+ });
+
+ it('escapes HTML in the input', () => {
+ const result = formatLargeText('');
+ expect(result).toContain('<script>');
+ expect(result).not.toContain('