diff --git a/.gitignore b/.gitignore index 759a730..da58981 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 8623203..7fff594 100644 --- a/proxy/cmd/proxy/main.go +++ b/proxy/cmd/proxy/main.go @@ -65,6 +65,11 @@ 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/{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 4cc3223..179d4ae 100644 --- a/proxy/internal/handler/handlers.go +++ b/proxy/internal/handler/handlers.go @@ -237,6 +237,155 @@ 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) +} + 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 a5d03c0..72aeded 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 84fd0bc..3e7ac60 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,62 +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-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 @@ -264,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 868c616..976f431 100644 --- a/proxy/internal/service/storage.go +++ b/proxy/internal/service/storage.go @@ -15,4 +15,9 @@ 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) } diff --git a/proxy/internal/service/storage_sqlite.go b/proxy/internal/service/storage_sqlite.go index 77a52b4..2d0cc53 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,476 @@ 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 +} + func (s *sqliteStorageService) Close() error { return s.db.Close() } diff --git a/web/app/components/CodeViewer.tsx b/web/app/components/CodeViewer.tsx index f9f4aae..a4b0dcf 100644 --- a/web/app/components/CodeViewer.tsx +++ b/web/app/components/CodeViewer.tsx @@ -82,39 +82,64 @@ 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' }, + // Define token patterns with priorities (first match wins) + // Order matters: strings and comments first to avoid highlighting inside them + const tokenPatterns = [ + { regex: /(["'`])(?:(?=(\\?))\2.)*?\1/, className: 'text-green-400' }, // 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' + ); - return highlighted; + 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 result; }; const handleCopy = async () => { diff --git a/web/app/components/RequestCompareModal.tsx b/web/app/components/RequestCompareModal.tsx new file mode 100644 index 0000000..2d5ad0e --- /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.added} +
+
Added
+
+
+
+ + {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' ? ( +
+
+
Before
+
+ +
+
+
+
After
+
+ +
+
+
+ ) : ( +
+
+ +
+
+ )} +
+ )} +
+ ); +} + +// 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 6b291c1..8117865 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 0000000..4d860b2 --- /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 0000000..9ceb382 --- /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 9908607..22ebf83 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>(new Map()); + const [fullRequestsLoaded, setFullRequestsLoaded] = useState(false); + const [stats, setStats] = useState(null); + const [isLoadingStats, setIsLoadingStats] = useState(false); + const [selectedDate, setSelectedDate] = useState(new Date()); const [conversations, setConversations] = useState([]); const [selectedRequest, setSelectedRequest] = useState(null); const [selectedConversation, setSelectedConversation] = useState(null); @@ -156,49 +204,192 @@ export default function Index() { const [hasMoreConversations, setHasMoreConversations] = useState(true); const itemsPerPage = 50; - const loadRequests = async (filter?: string, loadMore = false) => { + // Compare mode state + const [compareMode, setCompareMode] = useState(false); + const [selectedForCompare, setSelectedForCompare] = useState([]); + const [isCompareModalOpen, setIsCompareModalOpen] = useState(false); + const [currentWeekStart, setCurrentWeekStart] = useState(null); + const [isNavigating, setIsNavigating] = useState(false); + + // Virtualization ref for requests list + const requestsParentRef = useRef(null); + + // Helper to get Sunday-Saturday week boundaries for a given date + const getWeekBoundaries = (date: Date) => { + const weekStart = new Date(date); + weekStart.setHours(0, 0, 0, 0); + const dayOfWeek = weekStart.getDay(); // 0 = Sunday + weekStart.setDate(weekStart.getDate() - dayOfWeek); // Go back to Sunday + + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 6); // Saturday + weekEnd.setHours(23, 59, 59, 999); + + return { weekStart, weekEnd }; + }; + + // Load weekly stats only (for week navigation) + const loadWeeklyStats = async (date?: Date) => { + const targetDate = date || selectedDate; + const { weekStart, weekEnd } = getWeekBoundaries(targetDate); + + const weeklyUrl = new URL('/api/stats', window.location.origin); + weeklyUrl.searchParams.append('start', weekStart.toISOString()); + weeklyUrl.searchParams.append('end', weekEnd.toISOString()); + + const response = await fetch(weeklyUrl.toString()); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + return response.json(); + }; + + // Get UTC timestamps for start and end of local day + const getLocalDayBoundaries = (date: Date) => { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + return { + start: startOfDay.toISOString(), + end: endOfDay.toISOString() + }; + }; + + // Load hourly stats only (for date navigation within same week) + const loadHourlyStats = async (date?: Date) => { + const targetDate = date || selectedDate; + const { start, end } = getLocalDayBoundaries(targetDate); + + const hourlyUrl = new URL('/api/stats/hourly', window.location.origin); + hourlyUrl.searchParams.append('start', start); + hourlyUrl.searchParams.append('end', end); + + const response = await fetch(hourlyUrl.toString()); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + return response.json(); + }; + + // Load model stats only + const loadModelStats = async (date?: Date) => { + const targetDate = date || selectedDate; + const { start, end } = getLocalDayBoundaries(targetDate); + + const modelUrl = new URL('/api/stats/models', window.location.origin); + modelUrl.searchParams.append('start', start); + modelUrl.searchParams.append('end', end); + + const response = await fetch(modelUrl.toString()); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + return response.json(); + }; + + // Load all stats (weekly + hourly + models) + const loadStats = async (date?: Date) => { + setIsLoadingStats(true); + try { + const targetDate = date || selectedDate; + const { weekStart } = getWeekBoundaries(targetDate); + + const [weeklyData, hourlyData, modelData] = await Promise.all([ + loadWeeklyStats(targetDate), + loadHourlyStats(targetDate), + loadModelStats(targetDate) + ]); + + setStats({ + ...weeklyData, + ...hourlyData, + ...modelData + }); + setCurrentWeekStart(weekStart); + } catch (error) { + console.error('Failed to load stats:', error); + } finally { + setIsLoadingStats(false); + } + }; + + // Load lightweight summaries for the list view (fast initial load) + const loadRequests = async (filter?: string, date?: Date) => { setIsFetching(true); - const pageToFetch = loadMore ? requestsCurrentPage + 1 : 1; + setFullRequestsLoaded(false); + setRequestDetailsCache(new Map()); try { const currentModelFilter = filter || modelFilter; - const url = new URL('/api/requests', window.location.origin); - url.searchParams.append("page", pageToFetch.toString()); - url.searchParams.append("limit", itemsPerPage.toString()); + const targetDate = date || selectedDate; + + // Get start and end of day in user's local timezone, then convert to UTC + const startOfDay = new Date(targetDate); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(targetDate); + endOfDay.setHours(23, 59, 59, 999); + + // Use summary endpoint - much faster, minimal data + const url = new URL('/api/requests/summary', window.location.origin); if (currentModelFilter !== "all") { url.searchParams.append("model", currentModelFilter); } + url.searchParams.append("start", startOfDay.toISOString()); + url.searchParams.append("end", endOfDay.toISOString()); + // Load ALL requests - virtualization will handle rendering efficiently const response = await fetch(url.toString()); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - + const data = await response.json(); const requests = data.requests || []; const mappedRequests = requests.map((req: any, index: number) => ({ ...req, - id: req.requestId ? `${req.requestId}_${index}` : `request_${index}` + id: req.requestId || `request_${index}` })); - + startTransition(() => { - if (loadMore) { - setRequests(prev => [...prev, ...mappedRequests]); - } else { - setRequests(mappedRequests); - } - setRequestsCurrentPage(pageToFetch); - setHasMoreRequests(mappedRequests.length === itemsPerPage); + setRequestSummaries(mappedRequests); }); } catch (error) { console.error('Failed to load requests:', error); startTransition(() => { - setRequests([]); + setRequestSummaries([]); }); } finally { setIsFetching(false); } }; + // Get full request details from cache or fetch on demand + const getRequestDetails = async (requestId: string): Promise => { + // Check cache first + if (requestDetailsCache.has(requestId)) { + return requestDetailsCache.get(requestId) || null; + } + + // Fetch single request by ID + try { + const response = await fetch(`/api/requests/${requestId}`); + if (!response.ok) return null; + + const data = await response.json(); + const request = data.request ? { ...data.request, id: data.request.requestId } : null; + + // Cache it + if (request) { + setRequestDetailsCache(prev => new Map(prev).set(requestId, request)); + } + + return request; + } catch (error) { + console.error('Failed to load request details:', error); + return null; + } + }; + const loadConversations = async (modelFilter: string = "all", loadMore = false) => { setIsFetching(true); const pageToFetch = loadMore ? conversationsCurrentPage + 1 : 1; @@ -257,7 +448,8 @@ export default function Index() { }); if (response.ok) { - setRequests([]); + setRequestSummaries([]); + setRequestDetailsCache(new Map()); setConversations([]); setRequestsCurrentPage(1); setHasMoreRequests(true); @@ -266,14 +458,15 @@ export default function Index() { } } catch (error) { console.error('Failed to clear requests:', error); - setRequests([]); + setRequestSummaries([]); + setRequestDetailsCache(new Map()); } }; const filterRequests = (filter: string) => { - if (filter === 'all') return requests; - - return requests.filter(req => { + if (filter === 'all') return requestSummaries; + + return requestSummaries.filter(req => { switch (filter) { case 'messages': return req.endpoint.includes('/messages'); @@ -342,8 +535,8 @@ export default function Index() { return parts.length > 0 ? parts.join(' • ') : '📡 API request'; }; - const showRequestDetails = (requestId: number) => { - const request = requests.find(r => r.id === requestId); + const showRequestDetails = async (requestId: string) => { + const request = await getRequestDetails(requestId); if (request) { setSelectedRequest(request); setIsModalOpen(true); @@ -355,60 +548,40 @@ export default function Index() { setSelectedRequest(null); }; - const getToolStats = () => { - let toolDefinitions = 0; - let toolCalls = 0; - - requests.forEach(req => { - if (req.body) { - // Count tool definitions in system prompts - if (req.body.system) { - req.body.system.forEach(sys => { - if (sys.text && sys.text.includes('')) { - const functionMatches = [...sys.text.matchAll(/([\s\S]*?)<\/function>/g)]; - toolDefinitions += functionMatches.length; - } - }); - } - - // Count actual tool calls in messages - if (req.body.messages) { - req.body.messages.forEach(msg => { - if (msg.content && Array.isArray(msg.content)) { - msg.content.forEach((contentPart: any) => { - if (contentPart.type === 'tool_use') { - toolCalls++; - } - if (contentPart.type === 'text' && contentPart.text && contentPart.text.includes('')) { - const functionMatches = [...contentPart.text.matchAll(/([\s\S]*?)<\/function>/g)]; - toolDefinitions += functionMatches.length; - } - }); - } - }); - } - } - }); - - return `${toolCalls} calls / ${toolDefinitions} tools`; + // Compare mode functions + const toggleCompareMode = () => { + setCompareMode(!compareMode); + setSelectedForCompare([]); }; - const getPromptGradeStats = () => { - let totalGrades = 0; - let gradeCount = 0; - - requests.forEach(req => { - if (req.promptGrade && req.promptGrade.score) { - totalGrades += req.promptGrade.score; - gradeCount++; + const toggleRequestSelection = async (summary: RequestSummary) => { + // Get full request details for compare + const request = await getRequestDetails(summary.requestId); + if (!request) return; + + setSelectedForCompare(prev => { + const isSelected = prev.some(r => r.requestId === request.requestId); + if (isSelected) { + return prev.filter(r => r.requestId !== request.requestId); + } else if (prev.length < 2) { + return [...prev, request]; } + return prev; }); - - if (gradeCount > 0) { - const avgGrade = (totalGrades / gradeCount).toFixed(1); - return `${avgGrade}/5`; + }; + + const isRequestSelected = (summary: RequestSummary) => { + return selectedForCompare.some(r => r.requestId === summary.requestId); + }; + + const openCompareModal = () => { + if (selectedForCompare.length === 2) { + setIsCompareModalOpen(true); } - return '-/5'; + }; + + const closeCompareModal = () => { + setIsCompareModalOpen(false); }; const formatDuration = (milliseconds: number) => { @@ -421,91 +594,109 @@ export default function Index() { } }; - const formatConversationSummary = (conversation: ConversationSummary) => { - const duration = formatDuration(conversation.duration); - return `${conversation.requestCount} requests • ${duration} duration`; + const handleModelFilterChange = (newFilter: string) => { + setModelFilter(newFilter); + // Only reload requests list, not stats (stats always show all models) + loadRequests(newFilter, selectedDate); }; - const canGradeRequest = (request: Request) => { - return request.body && - request.body.messages && - request.body.messages.some(msg => msg.role === 'user') && - request.endpoint.includes('/messages'); - }; + const handleDateChange = async (newDate: Date) => { + // Prevent concurrent navigation + if (isNavigating) { + return; + } - const gradeRequest = async (requestId: number) => { - const request = requests.find(r => r.id === requestId); - if (!request || !canGradeRequest(request)) return; + setIsNavigating(true); try { - const response = await fetch('/api/grade-prompt', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - messages: request.body!.messages, - systemMessages: request.body!.system || [], - requestId: request.timestamp - }) - }); + // Check if we're moving to a different week BEFORE updating state + const { weekStart: newWeekStart } = getWeekBoundaries(newDate); + const needsNewWeek = !currentWeekStart || + newWeekStart.getTime() !== currentWeekStart.getTime(); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + // Update selected date + setSelectedDate(newDate); + + if (needsNewWeek) { + // Load weekly, hourly, and model stats for new week + setCurrentWeekStart(newWeekStart); + await loadStats(newDate); + } else { + // Just load hourly and model stats for the new date (same week) + const [hourlyData, modelData] = await Promise.all([ + loadHourlyStats(newDate), + loadModelStats(newDate) + ]); + + if (hourlyData && modelData) { + setStats(prev => { + if (!prev) return { dailyStats: [], ...hourlyData, ...modelData }; + return { + ...prev, + ...hourlyData, + ...modelData + }; + }); + } } - const promptGrade = await response.json(); - - // Update the request with the new grading - const updatedRequests = requests.map(r => - r.id === requestId ? { ...r, promptGrade } : r - ); - setRequests(updatedRequests); - + // Reload requests for the selected date + if (viewMode === 'requests') { + loadRequests(modelFilter, newDate); + } } catch (error) { - console.error('Failed to grade prompt:', error); - } - }; - - const handleModelFilterChange = (newFilter: string) => { - setModelFilter(newFilter); - if (viewMode === 'requests') { - loadRequests(newFilter); - } else { - loadConversations(newFilter); + console.error('Error in handleDateChange:', error); + } finally { + setIsNavigating(false); } }; useEffect(() => { + // Load stats first (super fast!) - always show all models + loadStats(); + if (viewMode === 'requests') { loadRequests(modelFilter); } else { - loadConversations(modelFilter); + // Conversations don't use model filter + loadConversations("all"); } - }, [viewMode, modelFilter]); + }, [viewMode]); // Handle escape key to close modals useEffect(() => { const handleEscapeKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { - if (isModalOpen) { + if (isCompareModalOpen) { + closeCompareModal(); + } else if (isModalOpen) { closeModal(); } else if (isConversationModalOpen) { setIsConversationModalOpen(false); setSelectedConversation(null); + } else if (compareMode) { + toggleCompareMode(); } } }; window.addEventListener('keydown', handleEscapeKey); - + return () => { window.removeEventListener('keydown', handleEscapeKey); }; - }, [isModalOpen, isConversationModalOpen]); + }, [isModalOpen, isConversationModalOpen, isCompareModalOpen, compareMode]); const filteredRequests = filterRequests(filter); + // Set up virtualizer for requests list with dedicated scroll container + const requestsVirtualizer = useVirtualizer({ + count: filteredRequests.length, + getScrollElement: () => requestsParentRef.current, + estimateSize: () => 120, // Estimated height of each request item + overscan: 10, // Render 10 extra items above and below viewport for smooth scrolling + }); + return (
{/* Header */} @@ -516,6 +707,19 @@ export default function Index() {

Claude Code Monitor

+ {viewMode === "requests" && ( + + )}
- {/* 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 */ -
-
-
-

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 ? ( +
+
+ +

Loading...

- ) : ( - <> - {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 +1159,7 @@ export default function Index() {
- gradeRequest(selectedRequest.id)} /> +
@@ -909,6 +1215,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 0000000..bf488e2 --- /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.summary.tsx b/web/app/routes/api.requests.summary.tsx new file mode 100644 index 0000000..55e1bc4 --- /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 0000000..e6e020a --- /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 0000000..62f72c9 --- /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 0000000..64fff0b --- /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.ts b/web/app/utils/formatters.ts index 4b02e5a..a18779e 100644 --- a/web/app/utils/formatters.ts +++ b/web/app/utils/formatters.ts @@ -37,9 +37,12 @@ export function formatJSON(obj: any, maxLength: number = 1000): string { * Escapes HTML characters to prevent XSS */ export function escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } /** @@ -47,39 +50,24 @@ export function escapeHtml(text: string): string { */ export function formatLargeText(text: string): string { if (!text) return ''; - + // Escape HTML first const escaped = escapeHtml(text); - - // Format the text with proper spacing and structure + + // Simple, safe formatting - just handle line breaks and basic markdown return escaped - // Preserve existing double line breaks - .replace(/\n\n/g, '

') - // Convert single line breaks to single
tags + // Preserve existing double line breaks as paragraph breaks + .replace(/\n\n/g, '

') + // Convert single line breaks to
tags .replace(/\n/g, '
') - // Format bullet points with modern styling - .replace(/^(\s*)([-*•])\s+(.+)$/gm, '$1$3') - // Format numbered lists with modern styling - .replace(/^(\s*)(\d+)\.\s+(.+)$/gm, '$1$2$3') - // Format headers with better typography - .replace(/^([A-Z][^<\n]*:)(
|$)/gm, '

$1
$2') - // Format code blocks with better styling - .replace(/\b([A-Z_]{3,})\b/g, '$1') - // Format file paths and technical terms - .replace(/\b([a-zA-Z0-9_-]+\.[a-zA-Z]{2,4})\b/g, '$1') - // Format URLs with modern link styling - .replace(/(https?:\/\/[^\s<]+)/g, '$1') - // Format quoted text - .replace(/^(\s*)([""](.+?)[""])/gm, '$1
$3
') - // Add proper spacing around paragraphs - .replace(/(

)/g, '
') - // Clean up any excessive spacing - .replace(/(
\s*){3,}/g, '

') - // Format emphasis patterns - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/\*([^*]+)\*/g, '$1') - // Format inline code - .replace(/`([^`]+)`/g, '$1'); + // Format inline code (backticks) + .replace(/`([^`]+)`/g, '$1') + // Format bold text + .replace(/\*\*([^*]+)\*\*/g, '$1') + // Format italic text + .replace(/\*([^*]+)\*/g, '$1') + // Wrap in paragraph + .replace(/^(.*)$/, '

$1

'); } /** diff --git a/web/package-lock.json b/web/package-lock.json index 97e7738..3fa5018 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "@remix-run/node": "^2.16.8", "@remix-run/react": "^2.16.8", "@remix-run/serve": "^2.16.8", + "@tanstack/react-virtual": "^3.13.12", "isbot": "^4.1.0", "lucide-react": "^0.522.0", "react": "^18.2.0", @@ -2023,6 +2024,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", diff --git a/web/package.json b/web/package.json index 4388d8f..e3b2a3a 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "@remix-run/node": "^2.16.8", "@remix-run/react": "^2.16.8", "@remix-run/serve": "^2.16.8", + "@tanstack/react-virtual": "^3.13.12", "isbot": "^4.1.0", "lucide-react": "^0.522.0", "react": "^18.2.0",