From 96bc431ab8d6a5d6a9960ec9ecf10392240d35ba Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Thu, 27 Nov 2025 13:04:47 -0800 Subject: [PATCH 01/14] Add request comparison feature to web UI - Add Compare button in header to enter compare mode - Allow selecting 2 requests via checkboxes for side-by-side comparison - Create RequestCompareModal component with: - Summary stats (added/removed/modified/unchanged messages) - Side-by-side request metadata comparison - Message diff view with color-coded changes - System prompt comparison - Tools comparison (added/removed/common) - Sticky compare mode banner that persists while scrolling - Button label changes based on state (Compare / Exit Compare) --- web/app/components/RequestCompareModal.tsx | 671 +++++++++++++++++++++ web/app/routes/_index.tsx | 168 +++++- 2 files changed, 825 insertions(+), 14 deletions(-) create mode 100644 web/app/components/RequestCompareModal.tsx diff --git a/web/app/components/RequestCompareModal.tsx b/web/app/components/RequestCompareModal.tsx new file mode 100644 index 00000000..6d907553 --- /dev/null +++ b/web/app/components/RequestCompareModal.tsx @@ -0,0 +1,671 @@ +import { useState, useMemo } from 'react'; +import { + X, + GitCompare, + Plus, + Minus, + Equal, + ChevronDown, + ChevronRight, + MessageCircle, + User, + Bot, + Settings, + Clock, + Cpu, + Brain, + ArrowRight +} 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 [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} +
+
+ +
+
+ + {/* Content */} +
+ {/* 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 && ( +
+ +
+ )} +
+ )} +
+
+
+ ); +} + +// 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 totalTokens = (tokens?.input_tokens || 0) + (tokens?.output_tokens || 0); + + return ( +
+
{label}
+
+
+ Model: + {model.split('-').slice(-1)[0] || model} +
+
+ Messages: + {request.body?.messages?.length || 0} +
+
+ Tokens: + {totalTokens.toLocaleString()} +
+
+ Response Time: + {((request.response?.responseTime || 0) / 1000).toFixed(2)}s +
+
+ Timestamp: + {new Date(request.timestamp).toLocaleString()} +
+
+
+ ); +} + +// 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}`} + + )} +
+ {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/routes/_index.tsx b/web/app/routes/_index.tsx index 99086071..1cc6df26 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -1,9 +1,9 @@ import type { MetaFunction } from "@remix-run/node"; import { useState, useEffect, useTransition } from "react"; -import { - Activity, - RefreshCw, - Trash2, +import { + Activity, + RefreshCw, + Trash2, List, FileText, X, @@ -29,11 +29,15 @@ 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 { getChatCompletionsEndpoint } from "../utils/models"; export const meta: MetaFunction = () => { @@ -156,6 +160,11 @@ export default function Index() { const [hasMoreConversations, setHasMoreConversations] = useState(true); const itemsPerPage = 50; + // Compare mode state + const [compareMode, setCompareMode] = useState(false); + const [selectedForCompare, setSelectedForCompare] = useState([]); + const [isCompareModalOpen, setIsCompareModalOpen] = useState(false); + const loadRequests = async (filter?: string, loadMore = false) => { setIsFetching(true); const pageToFetch = loadMore ? requestsCurrentPage + 1 : 1; @@ -355,6 +364,38 @@ export default function Index() { setSelectedRequest(null); }; + // Compare mode functions + const toggleCompareMode = () => { + setCompareMode(!compareMode); + setSelectedForCompare([]); + }; + + const toggleRequestSelection = (request: Request) => { + setSelectedForCompare(prev => { + const isSelected = prev.some(r => r.id === request.id); + if (isSelected) { + return prev.filter(r => r.id !== request.id); + } else if (prev.length < 2) { + return [...prev, request]; + } + return prev; + }); + }; + + const isRequestSelected = (request: Request) => { + return selectedForCompare.some(r => r.id === request.id); + }; + + const openCompareModal = () => { + if (selectedForCompare.length === 2) { + setIsCompareModalOpen(true); + } + }; + + const closeCompareModal = () => { + setIsCompareModalOpen(false); + }; + const getToolStats = () => { let toolDefinitions = 0; let toolCalls = 0; @@ -488,21 +529,25 @@ export default function Index() { 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); @@ -516,6 +561,19 @@ export default function Index() {

Claude Code Monitor

+ {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 && ( + + )} + +
+
+
+
+ )} + {/* Filter buttons - only show for requests view */} {viewMode === "requests" && (
@@ -653,8 +748,38 @@ export default function Index() { ) : ( <> {filteredRequests.map(request => ( -
showRequestDetails(request.id)}> +
{ + if (compareMode) { + toggleRequestSelection(request); + } else { + showRequestDetails(request.id); + } + }} + >
+ {/* Compare mode checkbox */} + {compareMode && ( +
+ +
+ )}
{/* Model and Status */}
@@ -680,8 +805,8 @@ export default function Index() { )} {request.response?.statusCode && ( = 200 && request.response.statusCode < 300 - ? 'bg-green-100 text-green-700' + 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' @@ -694,13 +819,19 @@ export default function Index() { Turn {request.turnNumber} )} + {/* Selection order indicator in compare mode */} + {compareMode && isRequestSelected(request) && ( + + #{selectedForCompare.findIndex(r => r.id === request.id) + 1} + + )}
- + {/* Endpoint */}
{getChatCompletionsEndpoint(request.routedModel, request.endpoint)}
- + {/* Metrics Row */}
{request.response?.body?.usage && ( @@ -715,7 +846,7 @@ export default function Index() { )} )} - + {request.response?.responseTime && ( {(request.response.responseTime / 1000).toFixed(2)}s @@ -909,6 +1040,15 @@ export default function Index() {
)} + + {/* Request Compare Modal */} + {isCompareModalOpen && selectedForCompare.length === 2 && ( + + )}
); } From e006800113178b0887bc96469f1d72225162933c Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Thu, 27 Nov 2025 13:15:24 -0800 Subject: [PATCH 02/14] Fix code viewer HTML class attribute corruption - Replace sequential regex replacements with single-pass tokenizer in CodeViewer.tsx highlightCode() function - The old approach applied patterns sequentially, causing later patterns to match numbers inside class attributes (e.g., "400" in "text-purple-400") - New approach: build combined regex, iterate matches once, escape HTML on matched tokens only - Also fix escapeHtml in formatters.ts to not use document.createElement (fails during SSR) and simplify formatLargeText to avoid over-formatting --- web/app/components/CodeViewer.tsx | 75 ++++++++++++++++++++----------- web/app/utils/formatters.ts | 52 +++++++++------------ 2 files changed, 70 insertions(+), 57 deletions(-) diff --git a/web/app/components/CodeViewer.tsx b/web/app/components/CodeViewer.tsx index f9f4aae6..a4b0dcfd 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/utils/formatters.ts b/web/app/utils/formatters.ts index 4b02e5a3..a18779e1 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

'); } /** From ba2e49aee645f836e3b9e0d0252d745cf380478c Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Thu, 27 Nov 2025 13:51:12 -0800 Subject: [PATCH 03/14] Show input/output tokens separately in request list - Change combined "X tokens" to separate "X in" / "Y out" display - Makes it clearer how many tokens are uploaded vs generated - Helps users understand conversation growth per turn --- web/app/routes/_index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx index 1cc6df26..c8a26d0c 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -837,7 +837,10 @@ export default function Index() { {request.response?.body?.usage && ( <> - {((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.output_tokens || 0)).toLocaleString()} tokens + {(request.response.body.usage.input_tokens || 0).toLocaleString()} in + + + {(request.response.body.usage.output_tokens || 0).toLocaleString()} out {request.response.body.usage.cache_read_input_tokens && ( From 33a9df56dbe97e1afc80eaa1f4c72984ed7d14a8 Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Thu, 27 Nov 2025 13:53:38 -0800 Subject: [PATCH 04/14] Fix input token display to include cached tokens - Show total input tokens (cached + non-cached) instead of just non-cached - Change cache display from absolute number to percentage - "68,446 in 100% cached" instead of "1 in 153,525 cached" --- web/app/routes/_index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx index c8a26d0c..5b3de566 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -837,14 +837,14 @@ export default function Index() { {request.response?.body?.usage && ( <> - {(request.response.body.usage.input_tokens || 0).toLocaleString()} in + {((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.cache_read_input_tokens || 0)).toLocaleString()} in {(request.response.body.usage.output_tokens || 0).toLocaleString()} out {request.response.body.usage.cache_read_input_tokens && ( - {request.response.body.usage.cache_read_input_tokens.toLocaleString()} cached + {Math.round((request.response.body.usage.cache_read_input_tokens / ((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.cache_read_input_tokens || 0))) * 100)}% cached )} From fcde0a98bd5bb0cf5642f4d148d61ecd728fc0e7 Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Thu, 27 Nov 2025 21:24:06 -0800 Subject: [PATCH 05/14] Enhance compare view with detailed breakdown and export options RequestCompareModal: - Add text diff view with side-by-side line comparison (LCS algorithm) - Show system prompt and tools in diff, not just messages - Add size breakdown: system prompt, tools, messages in KB - Show cache read/creation tokens separately - Add message size (KB) to each message row in structured view - Add download options: .diff, .json, .md formats - Add "Side-by-Side" export for external diff tools - Toggle between Structured and Text Diff views _index.tsx: - Fix cache display to only show when > 0 --- web/app/components/RequestCompareModal.tsx | 507 ++++++++++++++++++++- web/app/routes/_index.tsx | 2 +- 2 files changed, 495 insertions(+), 14 deletions(-) diff --git a/web/app/components/RequestCompareModal.tsx b/web/app/components/RequestCompareModal.tsx index 6d907553..2d5ad0ec 100644 --- a/web/app/components/RequestCompareModal.tsx +++ b/web/app/components/RequestCompareModal.tsx @@ -14,7 +14,10 @@ import { Clock, Cpu, Brain, - ArrowRight + ArrowRight, + List, + FileText, + Download } from 'lucide-react'; import { MessageContent } from './MessageContent'; @@ -201,6 +204,7 @@ function computeMessageDiff(messages1: Message[], messages2: Message[]): Message } export function RequestCompareModal({ request1, request2, onClose }: RequestCompareModalProps) { + const [viewMode, setViewMode] = useState<'structured' | 'diff'>('structured'); const [expandedSections, setExpandedSections] = useState>({ summary: true, messages: true, @@ -259,17 +263,48 @@ export function RequestCompareModal({ request1, request2, onClose }: RequestComp {model2.name}
- +
+ {/* View mode toggle */} +
+ + +
+ +
{/* Content */}
+ {viewMode === 'diff' ? ( + + ) : ( + <> {/* Summary Section */}
)} + + )}
); } +// 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 totalTokens = (tokens?.input_tokens || 0) + (tokens?.output_tokens || 0); + 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}
+
+
{label}
+
+ + +
+
Model: {model.split('-').slice(-1)[0] || model}
- Messages: - {request.body?.messages?.length || 0} + Input Tokens: + {inputTokens.toLocaleString()}
- Tokens: - {totalTokens.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: @@ -492,6 +961,15 @@ function RequestSummaryCard({ request, label }: { request: Request; label: strin ); } +// 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'); @@ -567,6 +1045,9 @@ function MessageDiffRow({ diff }: { diff: MessageDiff }) { {diff.index1 !== undefined ? ` → #${diff.index2 + 1}` : `#${diff.index2 + 1}`} )} + + {getMessageSize(diff.message1 || diff.message2)} +
{expanded ? ( diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx index 5b3de566..98e39aaf 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -842,7 +842,7 @@ export default function Index() { {(request.response.body.usage.output_tokens || 0).toLocaleString()} out - {request.response.body.usage.cache_read_input_tokens && ( + {request.response.body.usage.cache_read_input_tokens > 0 && ( {Math.round((request.response.body.usage.cache_read_input_tokens / ((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.cache_read_input_tokens || 0))) * 100)}% cached From 3c63286824ed39452a185b48f759b11d9df4509a Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Fri, 28 Nov 2025 11:49:33 -0800 Subject: [PATCH 06/14] Add fast request list with TanStack Virtual and summary endpoint Backend: - Add /api/requests/summary endpoint returning lightweight RequestSummary - RequestSummary includes only: id, timestamp, model, status, usage, responseTime - Skip parsing heavy body/headers JSON for faster list loading Frontend: - Replace react-virtuoso with @tanstack/react-virtual for 60fps scrolling - Load summaries first for fast initial render, preload full details in background - Cache full request details in Map for instant row clicks - Use window scrolling instead of inner container scroll --- proxy/cmd/proxy/main.go | 1 + proxy/internal/handler/handlers.go | 24 + proxy/internal/model/models.go | 14 + proxy/internal/service/storage.go | 1 + proxy/internal/service/storage_sqlite.go | 69 ++- web/app/components/RequestDetailContent.tsx | 2 +- web/app/routes/_index.tsx | 518 ++++++++++---------- web/app/routes/api.requests.summary.tsx | 29 ++ web/package-lock.json | 28 ++ web/package.json | 1 + 10 files changed, 418 insertions(+), 269 deletions(-) create mode 100644 web/app/routes/api.requests.summary.tsx diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go index 8623203b..838fa2cc 100644 --- a/proxy/cmd/proxy/main.go +++ b/proxy/cmd/proxy/main.go @@ -65,6 +65,7 @@ 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", 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 4cc32233..15b5196d 100644 --- a/proxy/internal/handler/handlers.go +++ b/proxy/internal/handler/handlers.go @@ -237,6 +237,30 @@ 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" + } + + summaries, err := h.storageService.GetRequestsSummary(modelFilter) + 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"` + }{ + Requests: summaries, + Total: len(summaries), + }) +} + 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 a5d03c05..758e1d59 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"` diff --git a/proxy/internal/service/storage.go b/proxy/internal/service/storage.go index 868c616a..8e8b9b5b 100644 --- a/proxy/internal/service/storage.go +++ b/proxy/internal/service/storage.go @@ -15,4 +15,5 @@ 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) } diff --git a/proxy/internal/service/storage_sqlite.go b/proxy/internal/service/storage_sqlite.go index 77a52b4e..fdd97458 100644 --- a/proxy/internal/service/storage_sqlite.go +++ b/proxy/internal/service/storage_sqlite.go @@ -342,19 +342,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 +375,71 @@ 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 +} + func (s *sqliteStorageService) Close() error { return s.db.Close() } diff --git a/web/app/components/RequestDetailContent.tsx b/web/app/components/RequestDetailContent.tsx index 6b291c16..81178657 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/routes/_index.tsx b/web/app/routes/_index.tsx index 98e39aaf..087c33c4 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -1,5 +1,6 @@ import type { MetaFunction } from "@remix-run/node"; -import { useState, useEffect, useTransition } from "react"; +import { useState, useEffect, useTransition, useCallback, useRef } from "react"; +import { useWindowVirtualizer } from "@tanstack/react-virtual"; import { Activity, RefreshCw, @@ -47,8 +48,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; @@ -143,7 +166,9 @@ interface Conversation { } export default function Index() { - const [requests, setRequests] = useState([]); + const [requestSummaries, setRequestSummaries] = useState([]); + const [requestDetailsCache, setRequestDetailsCache] = useState>(new Map()); + const [fullRequestsLoaded, setFullRequestsLoaded] = useState(false); const [conversations, setConversations] = useState([]); const [selectedRequest, setSelectedRequest] = useState(null); const [selectedConversation, setSelectedConversation] = useState(null); @@ -165,14 +190,15 @@ export default function Index() { const [selectedForCompare, setSelectedForCompare] = useState([]); const [isCompareModalOpen, setIsCompareModalOpen] = useState(false); - const loadRequests = async (filter?: string, loadMore = false) => { + // Load lightweight summaries for the list view (fast initial load) + const loadRequests = async (filter?: string) => { 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()); + // 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); } @@ -181,33 +207,81 @@ export default function Index() { 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); }); + + // Preload full requests in background after summaries are loaded + preloadFullRequests(currentModelFilter); } catch (error) { console.error('Failed to load requests:', error); startTransition(() => { - setRequests([]); + setRequestSummaries([]); }); } finally { setIsFetching(false); } }; + // Preload full request data in background + const preloadFullRequests = async (currentModelFilter: string) => { + try { + const url = new URL('/api/requests', window.location.origin); + url.searchParams.append("limit", "10000"); + if (currentModelFilter !== "all") { + url.searchParams.append("model", currentModelFilter); + } + + const response = await fetch(url.toString()); + if (!response.ok) return; + + const data = await response.json(); + const requests = data.requests || []; + + // Build cache map + const cache = new Map(); + requests.forEach((req: any) => { + if (req.requestId) { + cache.set(req.requestId, { ...req, id: req.requestId }); + } + }); + + setRequestDetailsCache(cache); + setFullRequestsLoaded(true); + } catch (error) { + console.error('Failed to preload full requests:', error); + } + }; + + // 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; + } + + // Fallback to fetch if not in cache yet + try { + const response = await fetch(`/api/requests?limit=10000`); + if (!response.ok) return null; + + const data = await response.json(); + const request = data.requests?.find((r: any) => r.requestId === requestId); + return request ? { ...request, id: request.requestId } : null; + } 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; @@ -266,7 +340,8 @@ export default function Index() { }); if (response.ok) { - setRequests([]); + setRequestSummaries([]); + setRequestDetailsCache(new Map()); setConversations([]); setRequestsCurrentPage(1); setHasMoreRequests(true); @@ -275,14 +350,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'); @@ -351,8 +427,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); @@ -370,11 +446,15 @@ export default function Index() { setSelectedForCompare([]); }; - const toggleRequestSelection = (request: Request) => { + 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.id === request.id); + const isSelected = prev.some(r => r.requestId === request.requestId); if (isSelected) { - return prev.filter(r => r.id !== request.id); + return prev.filter(r => r.requestId !== request.requestId); } else if (prev.length < 2) { return [...prev, request]; } @@ -382,8 +462,8 @@ export default function Index() { }); }; - const isRequestSelected = (request: Request) => { - return selectedForCompare.some(r => r.id === request.id); + const isRequestSelected = (summary: RequestSummary) => { + return selectedForCompare.some(r => r.requestId === summary.requestId); }; const openCompareModal = () => { @@ -396,62 +476,6 @@ export default function Index() { setIsCompareModalOpen(false); }; - 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`; - }; - - const getPromptGradeStats = () => { - let totalGrades = 0; - let gradeCount = 0; - - requests.forEach(req => { - if (req.promptGrade && req.promptGrade.score) { - totalGrades += req.promptGrade.score; - gradeCount++; - } - }); - - if (gradeCount > 0) { - const avgGrade = (totalGrades / gradeCount).toFixed(1); - return `${avgGrade}/5`; - } - return '-/5'; - }; - const formatDuration = (milliseconds: number) => { if (milliseconds < 60000) { return `${Math.round(milliseconds / 1000)}s`; @@ -462,52 +486,6 @@ export default function Index() { } }; - const formatConversationSummary = (conversation: ConversationSummary) => { - const duration = formatDuration(conversation.duration); - return `${conversation.requestCount} requests • ${duration} duration`; - }; - - const canGradeRequest = (request: Request) => { - return request.body && - request.body.messages && - request.body.messages.some(msg => msg.role === 'user') && - request.endpoint.includes('/messages'); - }; - - const gradeRequest = async (requestId: number) => { - const request = requests.find(r => r.id === requestId); - if (!request || !canGradeRequest(request)) return; - - 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 - }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - 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); - - } catch (error) { - console.error('Failed to grade prompt:', error); - } - }; - const handleModelFilterChange = (newFilter: string) => { setModelFilter(newFilter); if (viewMode === 'requests') { @@ -551,6 +529,15 @@ export default function Index() { const filteredRequests = filterRequests(filter); + // TanStack Virtual for smooth window scrolling with thousands of items + const listRef = useRef(null); + const virtualizer = useWindowVirtualizer({ + count: filteredRequests.length, + estimateSize: () => 85, // Estimated row height in pixels + overscan: 10, // Render 10 extra items above/below viewport for smooth scrolling + scrollMargin: listRef.current?.offsetTop ?? 0, + }); + return (
{/* Header */} @@ -718,7 +705,7 @@ export default function Index() { {viewMode === "requests" ? "Total Requests" : "Total Conversations"}

- {viewMode === "requests" ? requests.length : conversations.length} + {viewMode === "requests" ? requestSummaries.length : conversations.length}

@@ -734,8 +721,8 @@ export default function Index() {

Request History

-
- {(isFetching && requestsCurrentPage === 1) || isPending ? ( +
+ {isFetching || isPending ? (

Loading requests...

@@ -746,140 +733,143 @@ export default function Index() {

Make sure you have set ANTHROPIC_BASE_URL to point at the proxy

) : ( - <> - {filteredRequests.map(request => ( -
{ - if (compareMode) { - toggleRequestSelection(request); - } else { - showRequestDetails(request.id); - } - }} - > -
- {/* Compare mode checkbox */} - {compareMode && ( -
- +
)} - -
- )} -
- {/* 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} - - )} - {/* Selection order indicator in compare mode */} - {compareMode && isRequestSelected(request) && ( - - #{selectedForCompare.findIndex(r => r.id === request.id) + 1} - - )} -
+
+ {/* Model and Status */} +
+

+ {summary.routedModel || summary.model ? ( + (() => { + const model = summary.routedModel || summary.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} +

+ {summary.routedModel && summary.routedModel !== summary.originalModel && ( + + + routed + + )} + {summary.statusCode && ( + = 200 && summary.statusCode < 300 + ? 'bg-green-100 text-green-700' + : summary.statusCode >= 300 && summary.statusCode < 400 + ? 'bg-yellow-100 text-yellow-700' + : 'bg-red-100 text-red-700' + }`}> + {summary.statusCode} + + )} + {compareMode && isRequestSelected(summary) && ( + + #{selectedForCompare.findIndex(r => r.requestId === summary.requestId) + 1} + + )} +
- {/* Endpoint */} -
- {getChatCompletionsEndpoint(request.routedModel, request.endpoint)} -
+ {/* Endpoint */} +
+ {getChatCompletionsEndpoint(summary.routedModel, summary.endpoint)} +
- {/* Metrics Row */} -
- {request.response?.body?.usage && ( - <> - - {((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.cache_read_input_tokens || 0)).toLocaleString()} in - - - {(request.response.body.usage.output_tokens || 0).toLocaleString()} out - - {request.response.body.usage.cache_read_input_tokens > 0 && ( - - {Math.round((request.response.body.usage.cache_read_input_tokens / ((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.cache_read_input_tokens || 0))) * 100)}% cached - - )} - - )} + {/* Metrics Row */} +
+ {summary.usage && ( + <> + + {((summary.usage.input_tokens || 0) + (summary.usage.cache_read_input_tokens || 0)).toLocaleString()} in + + + {(summary.usage.output_tokens || 0).toLocaleString()} out + + {(summary.usage.cache_read_input_tokens || 0) > 0 && ( + + {Math.round(((summary.usage.cache_read_input_tokens || 0) / ((summary.usage.input_tokens || 0) + (summary.usage.cache_read_input_tokens || 0))) * 100)}% cached + + )} + + )} - {request.response?.responseTime && ( - - {(request.response.responseTime / 1000).toFixed(2)}s - - )} -
-
-
-
- {new Date(request.timestamp).toLocaleDateString()} -
-
- {new Date(request.timestamp).toLocaleTimeString()} + {summary.responseTime && ( + + {(summary.responseTime / 1000).toFixed(2)}s + + )} +
+
+
+
+ {new Date(summary.timestamp).toLocaleDateString()} +
+
+ {new Date(summary.timestamp).toLocaleTimeString()} +
+
+
-
-
- ))} - {hasMoreRequests && ( -
- -
- )} - + ); + })} + + )} @@ -987,7 +977,7 @@ export default function Index() {
- gradeRequest(selectedRequest.id)} /> +
diff --git a/web/app/routes/api.requests.summary.tsx b/web/app/routes/api.requests.summary.tsx new file mode 100644 index 00000000..984a96cf --- /dev/null +++ b/web/app/routes/api.requests.summary.tsx @@ -0,0 +1,29 @@ +import type { LoaderFunction } from "@remix-run/node"; +import { json } from "@remix-run/node"; + +export const loader: LoaderFunction = async ({ request }) => { + try { + const url = new URL(request.url); + const modelFilter = url.searchParams.get("model"); + + // Forward the request to the Go backend summary endpoint + const backendUrl = new URL('http://localhost:3001/api/requests/summary'); + if (modelFilter) { + backendUrl.searchParams.append('model', modelFilter); + } + + 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/package-lock.json b/web/package-lock.json index 97e7738c..3fa5018f 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 4388d8f2..e3b2a3a1 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", From 64d262a8486fb86ff61d7c62e2127d343100789c Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Fri, 28 Nov 2025 11:59:03 -0800 Subject: [PATCH 07/14] Simplify model-to-provider routing with pattern matching Remove hardcoded modelProviderMap and initializeModelProviderMap function. Use simple prefix matching instead: - claude* -> anthropic - gpt*, o1*, o3* -> openai This automatically handles new model versions without code changes. --- proxy/internal/service/model_router.go | 74 +++++--------------------- 1 file changed, 13 insertions(+), 61 deletions(-) diff --git a/proxy/internal/service/model_router.go b/proxy/internal/service/model_router.go index 84fd0bc2..3e7ac600 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" } From 83fcf10b9638d1c668cd6f6785afc6727d14aeaa Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Fri, 28 Nov 2025 12:58:46 -0800 Subject: [PATCH 08/14] Configure SQLite for concurrent access to fix database lock errors - Enable WAL mode for concurrent reads during writes - Set busy timeout to 5 seconds instead of immediate failure - Use NORMAL synchronous mode for better performance --- .gitignore | 5 ++++- proxy/internal/service/storage_sqlite.go | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 759a7302..da58981e 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/internal/service/storage_sqlite.go b/proxy/internal/service/storage_sqlite.go index fdd97458..0e944fe7 100644 --- a/proxy/internal/service/storage_sqlite.go +++ b/proxy/internal/service/storage_sqlite.go @@ -18,7 +18,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) } From f290922652d421bbedb4c84e904efb126188ea59 Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Fri, 28 Nov 2025 16:41:41 -0800 Subject: [PATCH 09/14] Add usage dashboard with model breakdowns and optimize request handling Dashboard Features: - Add UsageDashboard component with Apple Screen Time-inspired design - Display daily token usage, request count, and average response time - Weekly bar chart showing last 7 days with stacked model breakdown - Hourly bar chart for selected day with 24-hour view - Model usage breakdown with color-coded bars - Opus: purple gradient (#9333ea) - Sonnet: blue gradient (#3b82f6) - Haiku: green gradient (#10b981) - Interactive tooltips showing per-model token counts - Date navigation with previous/next day arrows - Dynamic week labels (e.g., "Nov 21 - 27" or "THIS WEEK") - Model filter tabs integrated into requests box header Backend API Changes: - Add GET /api/stats endpoint for dashboard statistics - Add GET /api/requests/summary endpoint for lightweight request list - Add GET /api/requests/{id} endpoint to fetch individual requests - Add per-model token breakdown to DailyTokens and HourlyTokens - Add ModelStats struct for tracking tokens/requests per model - Track model breakdown in hourly and daily aggregation maps - Switch from date-string filtering to UTC time-range filtering - Accept start/end UTC timestamps instead of date parameters - Remove default 100-request limit, fetch all requests for selected day Timezone Handling: - Browser calculates local day boundaries (12:00 AM to 11:59 PM) - Convert local time boundaries to UTC before sending to backend - Backend queries using exact UTC timestamp ranges - Fix week label parsing to avoid timezone shifts - Stats query fetches 7-day range (selected date - 6 days) - Requests query fetches single day range - Ensures "today" is intuitive to user's local timezone Performance Optimizations: - Fetch individual requests on-demand instead of loading all 10k+ - Cache fetched request details in client state - Use lightweight summary endpoint for initial list view - Only request list refreshes when filter changes, stats remain static - Fast aggregation queries using maps for O(1) lookups Routes: - Add /api/stats Remix route proxying to Go backend - Add /api/requests/{id} Remix route for single request fetch --- proxy/cmd/proxy/main.go | 2 + proxy/internal/handler/handlers.go | 85 ++- proxy/internal/model/models.go | 35 ++ proxy/internal/service/storage.go | 2 + proxy/internal/service/storage_sqlite.go | 292 ++++++++++ web/app/components/UsageDashboard.css | 605 +++++++++++++++++++ web/app/components/UsageDashboard.tsx | 448 ++++++++++++++ web/app/routes/_index.tsx | 708 ++++++++++++----------- web/app/routes/api.requests.$id.tsx | 23 + web/app/routes/api.stats.tsx | 23 + 10 files changed, 1881 insertions(+), 342 deletions(-) create mode 100644 web/app/components/UsageDashboard.css create mode 100644 web/app/components/UsageDashboard.tsx create mode 100644 web/app/routes/api.requests.$id.tsx create mode 100644 web/app/routes/api.stats.tsx diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go index 838fa2cc..051d983f 100644 --- a/proxy/cmd/proxy/main.go +++ b/proxy/cmd/proxy/main.go @@ -66,6 +66,8 @@ func main() { 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/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 15b5196d..8c02fa33 100644 --- a/proxy/internal/handler/handlers.go +++ b/proxy/internal/handler/handlers.go @@ -244,7 +244,27 @@ func (h *Handler) GetRequestsSummary(w http.ResponseWriter, r *http.Request) { modelFilter = "all" } - summaries, err := h.storageService.GetRequestsSummary(modelFilter) + // 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) @@ -255,12 +275,73 @@ func (h *Handler) GetRequestsSummary(w http.ResponseWriter, r *http.Request) { 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: len(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) +} + 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 758e1d59..0044b59f 100644 --- a/proxy/internal/model/models.go +++ b/proxy/internal/model/models.go @@ -210,3 +210,38 @@ type ContentBlock struct { Input json.RawMessage `json:"input,omitempty"` Text string `json:"text,omitempty"` } + +// Dashboard stats structures +type DashboardStats struct { + DailyStats []DailyTokens `json:"dailyStats"` + HourlyStats []HourlyTokens `json:"hourlyStats"` + ModelStats []ModelTokens `json:"modelStats"` + TodayTokens int64 `json:"todayTokens"` + TodayRequests int `json:"todayRequests"` + AvgResponseTime int64 `json:"avgResponseTime"` +} + +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/storage.go b/proxy/internal/service/storage.go index 8e8b9b5b..a2da31e3 100644 --- a/proxy/internal/service/storage.go +++ b/proxy/internal/service/storage.go @@ -16,4 +16,6 @@ type StorageService interface { 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) } diff --git a/proxy/internal/service/storage_sqlite.go b/proxy/internal/service/storage_sqlite.go index 0e944fe7..1072e1e9 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" @@ -443,6 +445,296 @@ func (s *sqliteStorageService) GetRequestsSummary(modelFilter string) ([]*model. 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, "timestamp >= ? AND timestamp <= ?") + 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, "timestamp >= ? AND timestamp <= ?") + 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), + HourlyStats: make([]model.HourlyTokens, 0), + ModelStats: make([]model.ModelTokens, 0), + } + + // Query each request individually to process all responses + query := ` + SELECT timestamp, COALESCE(model, 'unknown') as model, response + FROM requests + WHERE timestamp >= ? AND timestamp < ? + 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) + hourlyMap := make(map[int]*model.HourlyTokens) + modelMap := make(map[string]*model.ModelTokens) + + // Derive the selected date from endDate (endDate is selectedDate + 1 day) + selectedDateTime, err := time.Parse("2006-01-02T15:04:05", endDate) + if err != nil { + selectedDateTime, _ = time.Parse(time.RFC3339, endDate) + } + selectedDate := selectedDateTime.AddDate(0, 0, -1).Format("2006-01-02") + + 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 date and hour from timestamp (format: 2025-11-28T13:03:29-08:00) + date := strings.Split(timestamp, "T")[0] + 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) + } + + // 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, + }, + }, + } + } + + // Hourly aggregation (for the selected date) + if date == selectedDate { + 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, + }, + }, + } + } + + // Track response time for today + if resp.ResponseTime > 0 { + totalResponseTime += resp.ResponseTime + responseCount++ + } + } + + // 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 maps to slices + for _, v := range dailyMap { + stats.DailyStats = append(stats.DailyStats, *v) + } + for _, v := range hourlyMap { + stats.HourlyStats = append(stats.HourlyStats, *v) + } + for _, v := range modelMap { + stats.ModelStats = append(stats.ModelStats, *v) + } + + // Calculate totals for the selected date + if selectedDay, ok := dailyMap[selectedDate]; ok { + stats.TodayTokens = selectedDay.Tokens + stats.TodayRequests = selectedDay.Requests + } + if responseCount > 0 { + stats.AvgResponseTime = totalResponseTime / int64(responseCount) + } + + return stats, nil +} + func (s *sqliteStorageService) Close() error { return s.db.Close() } diff --git a/web/app/components/UsageDashboard.css b/web/app/components/UsageDashboard.css new file mode 100644 index 00000000..4d860b26 --- /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 00000000..6bc58108 --- /dev/null +++ b/web/app/components/UsageDashboard.tsx @@ -0,0 +1,448 @@ +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]); + + return ( +
+
+
+
Tokens Today
+
+ {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 087c33c4..044bfd5b 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -1,6 +1,5 @@ import type { MetaFunction } from "@remix-run/node"; import { useState, useEffect, useTransition, useCallback, useRef } from "react"; -import { useWindowVirtualizer } from "@tanstack/react-virtual"; import { Activity, RefreshCw, @@ -8,6 +7,7 @@ import { List, FileText, X, + ChevronLeft, ChevronRight, ChevronDown, Inbox, @@ -39,6 +39,7 @@ import { 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 = () => { @@ -165,10 +166,22 @@ interface Conversation { messageCount: number; } +interface DashboardStats { + dailyStats: { date: string; tokens: number; requests: number; }[]; + hourlyStats: { hour: number; tokens: number; requests: number; }[]; + modelStats: { model: string; tokens: number; requests: number; }[]; + todayTokens: number; + todayRequests: number; + avgResponseTime: number; +} + export default function Index() { 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); @@ -190,18 +203,58 @@ export default function Index() { const [selectedForCompare, setSelectedForCompare] = useState([]); const [isCompareModalOpen, setIsCompareModalOpen] = useState(false); + // Load dashboard stats (lightning fast!) + const loadStats = async (date?: Date) => { + setIsLoadingStats(true); + try { + const targetDate = date || selectedDate; + + // For stats, we need 7 days of data (target date - 6 days through target date) + const startDay = new Date(targetDate); + startDay.setDate(startDay.getDate() - 6); + startDay.setHours(0, 0, 0, 0); + + const endDay = new Date(targetDate); + endDay.setHours(23, 59, 59, 999); + + const url = new URL('/api/stats', window.location.origin); + url.searchParams.append('start', startDay.toISOString()); + url.searchParams.append('end', endDay.toISOString()); + + const response = await fetch(url.toString()); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + setStats(data); + } 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) => { + const loadRequests = async (filter?: string, date?: Date) => { setIsFetching(true); setFullRequestsLoaded(false); setRequestDetailsCache(new Map()); try { const currentModelFilter = filter || modelFilter; + 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()); const response = await fetch(url.toString()); if (!response.ok) { @@ -215,12 +268,11 @@ export default function Index() { id: req.requestId || `request_${index}` })); + console.log(`Loaded ${mappedRequests.length} requests (total: ${data.total})`); + startTransition(() => { setRequestSummaries(mappedRequests); }); - - // Preload full requests in background after summaries are loaded - preloadFullRequests(currentModelFilter); } catch (error) { console.error('Failed to load requests:', error); startTransition(() => { @@ -231,36 +283,6 @@ export default function Index() { } }; - // Preload full request data in background - const preloadFullRequests = async (currentModelFilter: string) => { - try { - const url = new URL('/api/requests', window.location.origin); - url.searchParams.append("limit", "10000"); - if (currentModelFilter !== "all") { - url.searchParams.append("model", currentModelFilter); - } - - const response = await fetch(url.toString()); - if (!response.ok) return; - - const data = await response.json(); - const requests = data.requests || []; - - // Build cache map - const cache = new Map(); - requests.forEach((req: any) => { - if (req.requestId) { - cache.set(req.requestId, { ...req, id: req.requestId }); - } - }); - - setRequestDetailsCache(cache); - setFullRequestsLoaded(true); - } catch (error) { - console.error('Failed to preload full requests:', error); - } - }; - // Get full request details from cache or fetch on demand const getRequestDetails = async (requestId: string): Promise => { // Check cache first @@ -268,14 +290,20 @@ export default function Index() { return requestDetailsCache.get(requestId) || null; } - // Fallback to fetch if not in cache yet + // Fetch single request by ID try { - const response = await fetch(`/api/requests?limit=10000`); + const response = await fetch(`/api/requests/${requestId}`); if (!response.ok) return null; const data = await response.json(); - const request = data.requests?.find((r: any) => r.requestId === requestId); - return request ? { ...request, id: request.requestId } : null; + 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; @@ -488,20 +516,29 @@ export default function Index() { const handleModelFilterChange = (newFilter: string) => { setModelFilter(newFilter); + // Only reload requests list, not stats (stats always show all models) + loadRequests(newFilter, selectedDate); + }; + + const handleDateChange = (newDate: Date) => { + setSelectedDate(newDate); + loadStats(newDate); if (viewMode === 'requests') { - loadRequests(newFilter); - } else { - loadConversations(newFilter); + loadRequests(modelFilter, newDate); } }; 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(() => { @@ -529,15 +566,6 @@ export default function Index() { const filteredRequests = filterRequests(filter); - // TanStack Virtual for smooth window scrolling with thousands of items - const listRef = useRef(null); - const virtualizer = useWindowVirtualizer({ - count: filteredRequests.length, - estimateSize: () => 85, // Estimated row height in pixels - overscan: 10, // Render 10 extra items above/below viewport for smooth scrolling - scrollMargin: listRef.current?.offsetTop ?? 0, - }); - return (
{/* Header */} @@ -643,318 +671,318 @@ export default function Index() {
)} - {/* Filter buttons - only show for requests view */} - {viewMode === "requests" && ( -
-
- - - - -
-
- )} - {/* Main Content */} -
- {/* Stats Grid */} -
-
+
+ {viewMode === "requests" && ( +
+ {/* Date Navigation - Always Visible */}
-
-

- {viewMode === "requests" ? "Total Requests" : "Total Conversations"} -

-

- {viewMode === "requests" ? requestSummaries.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 || 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...

- ) : ( -
-
- {virtualizer.getVirtualItems().map((virtualRow) => { - const summary = filteredRequests[virtualRow.index]; - return ( -
+ ) : ( +
+ {/* Stats Dashboard */} + {stats && } + + {/* Request List */} +
+
+
+

Requests

+
+ + + + +
+
+
+
+ {isFetching ? ( +
+ +

Loading requests...

+
+ ) : filteredRequests.length === 0 ? ( +
+

No requests found

+

No requests for this date

+
+ ) : ( +
+ {filteredRequests.map((summary) => (
{ - if (compareMode) { - toggleRequestSelection(summary); - } else { - showRequestDetails(summary.requestId); - } - }} + key={summary.requestId} + className="px-4 py-4 hover:bg-gray-50 transition-colors cursor-pointer border-b border-gray-100 last:border-b-0" + onClick={() => loadRequestDetails(summary.requestId)} > -
- {/* Compare mode checkbox */} - {compareMode && ( -
- -
- )} -
- {/* Model and Status */} -
-

- {summary.routedModel || summary.model ? ( - (() => { - const model = summary.routedModel || summary.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} -

- {summary.routedModel && summary.routedModel !== summary.originalModel && ( - - - routed - - )} - {summary.statusCode && ( - = 200 && summary.statusCode < 300 - ? 'bg-green-100 text-green-700' - : summary.statusCode >= 300 && summary.statusCode < 400 - ? 'bg-yellow-100 text-yellow-700' - : 'bg-red-100 text-red-700' - }`}> - {summary.statusCode} - - )} - {compareMode && isRequestSelected(summary) && ( - - #{selectedForCompare.findIndex(r => r.requestId === summary.requestId) + 1} - - )} -
- - {/* Endpoint */} -
- {getChatCompletionsEndpoint(summary.routedModel, summary.endpoint)} -
- - {/* Metrics Row */} -
- {summary.usage && ( - <> - - {((summary.usage.input_tokens || 0) + (summary.usage.cache_read_input_tokens || 0)).toLocaleString()} in - - - {(summary.usage.output_tokens || 0).toLocaleString()} out - - {(summary.usage.cache_read_input_tokens || 0) > 0 && ( - - {Math.round(((summary.usage.cache_read_input_tokens || 0) / ((summary.usage.input_tokens || 0) + (summary.usage.cache_read_input_tokens || 0))) * 100)}% cached +
+
+
+ + {summary.model.toLowerCase().includes('opus') + ? 'Opus' + : summary.model.toLowerCase().includes('sonnet') + ? 'Sonnet' + : 'Haiku'} - )} - - )} - - {summary.responseTime && ( - - {(summary.responseTime / 1000).toFixed(2)}s - - )} -
-
-
-
- {new Date(summary.timestamp).toLocaleDateString()} -
-
- {new Date(summary.timestamp).toLocaleTimeString()} -
-
-
+ {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 View */ -
-
-

Conversations

-
-
- {(isFetching && conversationsCurrentPage === 1) || isPending ? ( -
- -

Loading conversations...

-
- ) : 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 && ( +
+ +
+ )} + + )} +
-
+ )}
diff --git a/web/app/routes/api.requests.$id.tsx b/web/app/routes/api.requests.$id.tsx new file mode 100644 index 00000000..bf488e22 --- /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.stats.tsx b/web/app/routes/api.stats.tsx new file mode 100644 index 00000000..64fff0be --- /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()); +} From cb582f6c77e7ab9a9250674c3e54333afd01c580 Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Fri, 28 Nov 2025 23:25:26 -0800 Subject: [PATCH 10/14] Fix week navigation to only reload stats when changing weeks - Add getWeekBoundaries() helper to calculate Sunday-Saturday week - Track current week start to detect week changes - Only reload stats when navigating to a different week - Always reload requests for hourly chart on date change - Show actual date in navigation (bold when today) instead of 'Today' label - Weekly chart bars now stay stable while navigating within the same week --- web/app/routes/_index.tsx | 77 ++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx index 044bfd5b..d91ff0f7 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -202,29 +202,39 @@ export default function Index() { const [compareMode, setCompareMode] = useState(false); const [selectedForCompare, setSelectedForCompare] = useState([]); const [isCompareModalOpen, setIsCompareModalOpen] = useState(false); + const [currentWeekStart, setCurrentWeekStart] = useState(null); + const [isNavigating, setIsNavigating] = useState(false); + + // 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 dashboard stats (lightning fast!) const loadStats = async (date?: Date) => { setIsLoadingStats(true); try { const targetDate = date || selectedDate; - - // For stats, we need 7 days of data (target date - 6 days through target date) - const startDay = new Date(targetDate); - startDay.setDate(startDay.getDate() - 6); - startDay.setHours(0, 0, 0, 0); - - const endDay = new Date(targetDate); - endDay.setHours(23, 59, 59, 999); + const { weekStart, weekEnd } = getWeekBoundaries(targetDate); const url = new URL('/api/stats', window.location.origin); - url.searchParams.append('start', startDay.toISOString()); - url.searchParams.append('end', endDay.toISOString()); + url.searchParams.append('start', weekStart.toISOString()); + url.searchParams.append('end', weekEnd.toISOString()); const response = await fetch(url.toString()); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); setStats(data); + setCurrentWeekStart(weekStart); } catch (error) { console.error('Failed to load stats:', error); } finally { @@ -520,11 +530,33 @@ export default function Index() { loadRequests(newFilter, selectedDate); }; - const handleDateChange = (newDate: Date) => { - setSelectedDate(newDate); - loadStats(newDate); - if (viewMode === 'requests') { - loadRequests(modelFilter, newDate); + const handleDateChange = async (newDate: Date) => { + // Prevent concurrent navigation + if (isNavigating) return; + + setIsNavigating(true); + + try { + // Check if we're moving to a different week BEFORE updating state + const { weekStart: newWeekStart } = getWeekBoundaries(newDate); + const needsNewWeek = !currentWeekStart || + newWeekStart.getTime() !== currentWeekStart.getTime(); + + // Update selected date + setSelectedDate(newDate); + + // Update currentWeekStart synchronously to prevent race conditions + if (needsNewWeek) { + setCurrentWeekStart(newWeekStart); + await loadStats(newDate); + } + + // Always reload requests for the selected date (for hourly chart) + if (viewMode === 'requests') { + await loadRequests(modelFilter, newDate); + } + } finally { + setIsNavigating(false); } }; @@ -685,14 +717,17 @@ export default function Index() { newDate.setDate(newDate.getDate() - 1); handleDateChange(newDate); }} - className="p-2 rounded-lg hover:bg-gray-100 transition-colors" + disabled={isNavigating} + className="p-2 rounded-lg hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > - - {selectedDate.toDateString() === new Date().toDateString() - ? 'Today' - : selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + + {selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
) : ( -
- {filteredRequests.map((summary) => ( -
loadRequestDetails(summary.requestId)} - > -
-
-
- - {summary.model.toLowerCase().includes('opus') - ? 'Opus' +
+
+ {requestsVirtualizer.getVirtualItems().map((virtualItem) => { + const summary = filteredRequests[virtualItem.index]; + return ( +
loadRequestDetails(summary.requestId)} + > +
+
+
+ + {summary.model.toLowerCase().includes('opus') + ? 'Opus' + : summary.model.toLowerCase().includes('sonnet') + ? 'Sonnet' + : 'Haiku'} + + {summary.statusCode && ( + + {summary.statusCode === 200 && '200'} - {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()} -
+
+ {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()} +
+
+
+
+ ); + })} +
)}
From 376af890673a6329b5ed169b6c0f346c9324c81f Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Sat, 29 Nov 2025 12:55:50 -0800 Subject: [PATCH 14/14] Fix timezone handling to be fully client-side aware Frontend changes: - Replace date string format with full UTC timestamp boundaries - Calculate local day start/end in browser (e.g., Nov 29 PST = 08:00-08:00 UTC) - Send start/end timestamps instead of date strings to API - Backend now receives exact UTC time ranges for client's local day Backend changes: - Update GetHourlyStats to accept start/end timestamps instead of date - Update GetModelStats to accept start/end timestamps instead of date - Remove server-side date parsing and timezone interpretation - Backend is now completely timezone-agnostic This ensures "Today" shows correct data regardless of client timezone. No more date/timezone confusion between client and server. --- proxy/internal/handler/handlers.go | 28 ++++++++------ proxy/internal/service/storage.go | 4 +- proxy/internal/service/storage_sqlite.go | 48 +++++++++++------------- web/app/routes/_index.tsx | 26 ++++++++++--- web/app/routes/api.requests.summary.tsx | 15 ++++---- web/app/routes/api.stats.hourly.tsx | 23 ++++++++++++ web/app/routes/api.stats.models.tsx | 23 ++++++++++++ 7 files changed, 114 insertions(+), 53 deletions(-) create mode 100644 web/app/routes/api.stats.hourly.tsx create mode 100644 web/app/routes/api.stats.models.tsx diff --git a/proxy/internal/handler/handlers.go b/proxy/internal/handler/handlers.go index eb7f10ee..179d4ae8 100644 --- a/proxy/internal/handler/handlers.go +++ b/proxy/internal/handler/handlers.go @@ -342,16 +342,18 @@ func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(stats) } -// GetHourlyStats returns hourly breakdown for a specific date +// GetHourlyStats returns hourly breakdown for a specific date range func (h *Handler) GetHourlyStats(w http.ResponseWriter, r *http.Request) { - // Get date parameter (YYYY-MM-DD format) - date := r.URL.Query().Get("date") - if date == "" { - http.Error(w, "date parameter is required", http.StatusBadRequest) + // 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(date) + 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) @@ -362,16 +364,18 @@ func (h *Handler) GetHourlyStats(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(stats) } -// GetModelStats returns model breakdown for a specific date +// GetModelStats returns model breakdown for a specific date range func (h *Handler) GetModelStats(w http.ResponseWriter, r *http.Request) { - // Get date parameter (YYYY-MM-DD format) - date := r.URL.Query().Get("date") - if date == "" { - http.Error(w, "date parameter is required", http.StatusBadRequest) + // 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(date) + 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) diff --git a/proxy/internal/service/storage.go b/proxy/internal/service/storage.go index eef15a87..976f4317 100644 --- a/proxy/internal/service/storage.go +++ b/proxy/internal/service/storage.go @@ -18,6 +18,6 @@ type StorageService interface { 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(date string) (*model.HourlyStatsResponse, error) - GetModelStats(date string) (*model.ModelStatsResponse, 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 5cfd1458..2d0cc534 100644 --- a/proxy/internal/service/storage_sqlite.go +++ b/proxy/internal/service/storage_sqlite.go @@ -606,7 +606,11 @@ func (s *sqliteStorageService) GetStats(startDate, endDate string) (*model.Dashb tokens := int64(0) if usage != nil { - tokens = int64(usage.InputTokens + usage.OutputTokens + usage.CacheReadInputTokens) + tokens = int64( + usage.InputTokens + + usage.OutputTokens + + usage.CacheReadInputTokens + + usage.CacheCreationInputTokens) } // Daily aggregation @@ -651,17 +655,8 @@ func (s *sqliteStorageService) GetStats(startDate, endDate string) (*model.Dashb return stats, nil } -// GetHourlyStats returns hourly breakdown for a specific date -func (s *sqliteStorageService) GetHourlyStats(date string) (*model.HourlyStatsResponse, error) { - // Parse date to get start and end of day - dateObj, err := time.Parse("2006-01-02", date) - if err != nil { - return nil, fmt.Errorf("invalid date format: %w", err) - } - - startOfDay := dateObj.Format("2006-01-02") + "T00:00:00" - endOfDay := dateObj.Format("2006-01-02") + "T23:59:59" - +// 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 @@ -669,7 +664,7 @@ func (s *sqliteStorageService) GetHourlyStats(date string) (*model.HourlyStatsRe ORDER BY timestamp ` - rows, err := s.db.Query(query, startOfDay, endOfDay) + rows, err := s.db.Query(query, startTime, endTime) if err != nil { return nil, fmt.Errorf("failed to query hourly stats: %w", err) } @@ -712,7 +707,11 @@ func (s *sqliteStorageService) GetHourlyStats(date string) (*model.HourlyStatsRe tokens := int64(0) if usage != nil { - tokens = int64(usage.InputTokens + usage.OutputTokens + usage.CacheReadInputTokens) + tokens = int64( + usage.InputTokens + + usage.OutputTokens + + usage.CacheReadInputTokens + + usage.CacheCreationInputTokens) } totalTokens += tokens @@ -778,17 +777,8 @@ func (s *sqliteStorageService) GetHourlyStats(date string) (*model.HourlyStatsRe }, nil } -// GetModelStats returns model breakdown for a specific date -func (s *sqliteStorageService) GetModelStats(date string) (*model.ModelStatsResponse, error) { - // Parse date to get start and end of day - dateObj, err := time.Parse("2006-01-02", date) - if err != nil { - return nil, fmt.Errorf("invalid date format: %w", err) - } - - startOfDay := dateObj.Format("2006-01-02") + "T00:00:00" - endOfDay := dateObj.Format("2006-01-02") + "T23:59:59" - +// 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 @@ -796,7 +786,7 @@ func (s *sqliteStorageService) GetModelStats(date string) (*model.ModelStatsResp ORDER BY timestamp ` - rows, err := s.db.Query(query, startOfDay, endOfDay) + rows, err := s.db.Query(query, startTime, endTime) if err != nil { return nil, fmt.Errorf("failed to query model stats: %w", err) } @@ -829,7 +819,11 @@ func (s *sqliteStorageService) GetModelStats(date string) (*model.ModelStatsResp tokens := int64(0) if usage != nil { - tokens = int64(usage.InputTokens + usage.OutputTokens + usage.CacheReadInputTokens) + tokens = int64( + usage.InputTokens + + usage.OutputTokens + + usage.CacheReadInputTokens + + usage.CacheCreationInputTokens) } // Model aggregation diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx index 5b4d90ff..22ebf83e 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -243,13 +243,28 @@ export default function Index() { 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 selectedDateStr = targetDate.toISOString().split('T')[0]; + const { start, end } = getLocalDayBoundaries(targetDate); const hourlyUrl = new URL('/api/stats/hourly', window.location.origin); - hourlyUrl.searchParams.append('date', selectedDateStr); + 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}`); @@ -260,10 +275,11 @@ export default function Index() { // Load model stats only const loadModelStats = async (date?: Date) => { const targetDate = date || selectedDate; - const selectedDateStr = targetDate.toISOString().split('T')[0]; + const { start, end } = getLocalDayBoundaries(targetDate); const modelUrl = new URL('/api/stats/models', window.location.origin); - modelUrl.searchParams.append('date', selectedDateStr); + 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}`); @@ -938,7 +954,7 @@ export default function Index() { style={{ transform: `translateY(${virtualItem.start}px)`, }} - onClick={() => loadRequestDetails(summary.requestId)} + onClick={() => showRequestDetails(summary.requestId)} >
diff --git a/web/app/routes/api.requests.summary.tsx b/web/app/routes/api.requests.summary.tsx index 984a96cf..55e1bc4a 100644 --- a/web/app/routes/api.requests.summary.tsx +++ b/web/app/routes/api.requests.summary.tsx @@ -1,16 +1,17 @@ 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); - const modelFilter = url.searchParams.get("model"); - // Forward the request to the Go backend summary endpoint - const backendUrl = new URL('http://localhost:3001/api/requests/summary'); - if (modelFilter) { - backendUrl.searchParams.append('model', modelFilter); - } + // 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()); @@ -21,7 +22,7 @@ export const loader: LoaderFunction = async ({ request }) => { const data = await response.json(); return json(data); } catch (error) { - console.error('Failed to fetch request summaries:', 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 00000000..e6e020a8 --- /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 00000000..62f72c9a --- /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()); +}