+ {/* Loading State - Only for initial stats load */}
+ {isLoadingStats ? (
+
+
- ) : (
- <>
- {filteredRequests.map(request => (
-
showRequestDetails(request.id)}>
-
-
- {/* Model and Status */}
-
-
- {request.routedModel || request.body?.model ? (
- // Use routedModel if available, otherwise fall back to body.model
- (() => {
- const model = request.routedModel || request.body?.model || '';
- if (model.includes('opus')) return Opus;
- if (model.includes('sonnet')) return Sonnet;
- if (model.includes('haiku')) return Haiku;
- if (model.includes('gpt-4o')) return GPT-4o;
- if (model.includes('gpt')) return GPT;
- return {model.split('-')[0]};
- })()
- ) : API}
-
- {request.routedModel && request.routedModel !== request.originalModel && (
-
-
- routed
-
- )}
- {request.response?.statusCode && (
-
= 200 && request.response.statusCode < 300
- ? 'bg-green-100 text-green-700'
- : request.response.statusCode >= 300 && request.response.statusCode < 400
- ? 'bg-yellow-100 text-yellow-700'
- : 'bg-red-100 text-red-700'
- }`}>
- {request.response.statusCode}
-
- )}
- {request.conversationId && (
-
- Turn {request.turnNumber}
-
- )}
-
-
- {/* Endpoint */}
-
- {getChatCompletionsEndpoint(request.routedModel, request.endpoint)}
-
-
- {/* Metrics Row */}
-
- {request.response?.body?.usage && (
- <>
-
- {((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.output_tokens || 0)).toLocaleString()} tokens
-
- {request.response.body.usage.cache_read_input_tokens && (
-
- {request.response.body.usage.cache_read_input_tokens.toLocaleString()} cached
-
- )}
- >
- )}
-
- {request.response?.responseTime && (
-
- {(request.response.responseTime / 1000).toFixed(2)}s
-
- )}
-
-
-
-
- {new Date(request.timestamp).toLocaleDateString()}
-
-
- {new Date(request.timestamp).toLocaleTimeString()}
-
-
+
+ ) : (
+
+ {/* Stats Dashboard */}
+ {stats &&
}
+
+ {/* Request List */}
+
+
+
+
Requests
+
+
+
+
+
- ))}
- {hasMoreRequests && (
-
-
+
+ {isFetching ? (
+
+
+
Loading requests...
+
+ ) : filteredRequests.length === 0 ? (
+
+
No requests found
+
No requests for this date
+
+ ) : (
+
- {isFetching ? "Loading..." : "Load More"}
-
-
- )}
- >
- )}
-
-
- ) : (
- /* Conversations View */
-
-
-
Conversations
-
-
- {(isFetching && conversationsCurrentPage === 1) || isPending ? (
-
-
-
Loading conversations...
+
+ {requestsVirtualizer.getVirtualItems().map((virtualItem) => {
+ const summary = filteredRequests[virtualItem.index];
+ return (
+
showRequestDetails(summary.requestId)}
+ >
+
+
+
+
+ {summary.model.toLowerCase().includes('opus')
+ ? 'Opus'
+ : summary.model.toLowerCase().includes('sonnet')
+ ? 'Sonnet'
+ : 'Haiku'}
+
+ {summary.statusCode && (
+
+ {summary.statusCode === 200 && '200'}
+
+ )}
+
+
+ {summary.endpoint}
+
+
+ {summary.usage && (
+ <>
+ {(summary.usage.input_tokens || summary.usage.cache_read_input_tokens) && (
+
+
+ {(summary.usage.input_tokens || 0).toLocaleString()}
+ {' '}
+ in
+
+ )}
+ {summary.usage.output_tokens && (
+
+
+ {summary.usage.output_tokens.toLocaleString()}
+ {' '}
+ out
+
+ )}
+ {summary.usage.cache_read_input_tokens && (
+
+ {Math.round(((summary.usage.cache_read_input_tokens || 0) / ((summary.usage.input_tokens || 0) + (summary.usage.cache_read_input_tokens || 0))) * 100)}% cached
+
+ )}
+ >
+ )}
+ {summary.responseTime && (
+
+ {(summary.responseTime / 1000).toFixed(2)}s
+
+ )}
+
+
+
+
+ {new Date(summary.timestamp).toLocaleDateString()}
+
+
+ {new Date(summary.timestamp).toLocaleTimeString()}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
- ) : conversations.length === 0 ? (
-
-
No conversations found
-
Start a conversation to see it appear here
+
+ )}
+
+ )}
+
+ {viewMode === "conversations" && (
+ <>
+
+
+
+
+
+ Total Conversations
+
+
+ {conversations.length}
+
+
- ) : (
- <>
- {conversations.map(conversation => (
-
loadConversationDetails(conversation.id, conversation.projectName)}>
-
-
-
-
- #{conversation.id.slice(-8)}
-
-
- {conversation.requestCount} turns
-
-
- {formatDuration(conversation.duration)}
-
- {conversation.projectName && (
-
- {conversation.projectName}
+
+
+
+ {/* Conversations View */}
+
+
+
Conversations
+
+
+ {(isFetching && conversationsCurrentPage === 1) || isPending ? (
+
+
+
Loading conversations...
+
+ ) : conversations.length === 0 ? (
+
+
No conversations found
+
Start a conversation to see it appear here
+
+ ) : (
+ <>
+ {conversations.map(conversation => (
+
loadConversationDetails(conversation.id, conversation.projectName)}>
+
+
+
+
+ #{conversation.id.slice(-8)}
- )}
-
-
-
-
First Message
-
- {conversation.firstMessage || "No content"}
-
+
+ {conversation.requestCount} turns
+
+
+ {formatDuration(conversation.duration)}
+
+ {conversation.projectName && (
+
+ {conversation.projectName}
+
+ )}
- {conversation.lastMessage && conversation.lastMessage !== conversation.firstMessage && (
-
-
Latest Message
+
+
+
First Message
- {conversation.lastMessage}
+ {conversation.firstMessage || "No content"}
- )}
-
-
-
-
- {new Date(conversation.startTime).toLocaleDateString()}
+ {conversation.lastMessage && conversation.lastMessage !== conversation.firstMessage && (
+
+
Latest Message
+
+ {conversation.lastMessage}
+
+
+ )}
+
-
- {new Date(conversation.startTime).toLocaleTimeString()}
+
+
+ {new Date(conversation.startTime).toLocaleDateString()}
+
+
+ {new Date(conversation.startTime).toLocaleTimeString()}
+
-
- ))}
- {hasMoreConversations && (
-
-
-
- )}
- >
- )}
+ ))}
+ {hasMoreConversations && (
+
+
+
+ )}
+ >
+ )}
+
-
+ >
)}
@@ -853,7 +1159,7 @@ export default function Index() {
- gradeRequest(selectedRequest.id)} />
+
@@ -909,6 +1215,15 @@ export default function Index() {
)}
+
+ {/* Request Compare Modal */}
+ {isCompareModalOpen && selectedForCompare.length === 2 && (
+
+ )}
);
}
diff --git a/web/app/routes/api.requests.$id.tsx b/web/app/routes/api.requests.$id.tsx
new file mode 100644
index 0000000..bf488e2
--- /dev/null
+++ b/web/app/routes/api.requests.$id.tsx
@@ -0,0 +1,23 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+
+const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const { id } = params;
+
+ if (!id) {
+ throw new Response("Request ID is required", { status: 400 });
+ }
+
+ const proxyUrl = `${PROXY_URL}/api/requests/${id}`;
+ const response = await fetch(proxyUrl);
+
+ if (!response.ok) {
+ throw new Response(`Failed to fetch request: ${response.statusText}`, {
+ status: response.status,
+ });
+ }
+
+ return json(await response.json());
+}
diff --git a/web/app/routes/api.requests.summary.tsx b/web/app/routes/api.requests.summary.tsx
new file mode 100644
index 0000000..55e1bc4
--- /dev/null
+++ b/web/app/routes/api.requests.summary.tsx
@@ -0,0 +1,30 @@
+import type { LoaderFunction } from "@remix-run/node";
+import { json } from "@remix-run/node";
+
+const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
+
+export const loader: LoaderFunction = async ({ request }) => {
+ try {
+ const url = new URL(request.url);
+
+ // Forward all known filters (model, start/end, pagination) to the Go backend
+ const backendUrl = new URL(`${PROXY_URL}/api/requests/summary`);
+ url.searchParams.forEach((value, key) => {
+ backendUrl.searchParams.append(key, value);
+ });
+
+ const response = await fetch(backendUrl.toString());
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return json(data);
+ } catch (error) {
+ console.error("Failed to fetch request summaries:", error);
+
+ // Return empty array if backend is not available
+ return json({ requests: [], total: 0 });
+ }
+};
diff --git a/web/app/routes/api.stats.hourly.tsx b/web/app/routes/api.stats.hourly.tsx
new file mode 100644
index 0000000..e6e020a
--- /dev/null
+++ b/web/app/routes/api.stats.hourly.tsx
@@ -0,0 +1,23 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+
+const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const url = new URL(request.url);
+ const date = url.searchParams.get("date");
+
+ if (!date) {
+ throw new Response("date is required", { status: 400 });
+ }
+
+ const params = new URLSearchParams({ date });
+ const proxyUrl = `${PROXY_URL}/api/stats/hourly?${params.toString()}`;
+ const response = await fetch(proxyUrl);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch hourly stats: ${response.statusText}`);
+ }
+
+ return json(await response.json());
+}
diff --git a/web/app/routes/api.stats.models.tsx b/web/app/routes/api.stats.models.tsx
new file mode 100644
index 0000000..62f72c9
--- /dev/null
+++ b/web/app/routes/api.stats.models.tsx
@@ -0,0 +1,23 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+
+const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const url = new URL(request.url);
+ const date = url.searchParams.get("date");
+
+ if (!date) {
+ throw new Response("date is required", { status: 400 });
+ }
+
+ const params = new URLSearchParams({ date });
+ const proxyUrl = `${PROXY_URL}/api/stats/models?${params.toString()}`;
+ const response = await fetch(proxyUrl);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch model stats: ${response.statusText}`);
+ }
+
+ return json(await response.json());
+}
diff --git a/web/app/routes/api.stats.tsx b/web/app/routes/api.stats.tsx
new file mode 100644
index 0000000..64fff0b
--- /dev/null
+++ b/web/app/routes/api.stats.tsx
@@ -0,0 +1,23 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+
+const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const url = new URL(request.url);
+ const start = url.searchParams.get("start");
+ const end = url.searchParams.get("end");
+
+ const params = new URLSearchParams();
+ if (start) params.set("start", start);
+ if (end) params.set("end", end);
+
+ const proxyUrl = `${PROXY_URL}/api/stats${params.toString() ? `?${params}` : ''}`;
+ const response = await fetch(proxyUrl);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch stats: ${response.statusText}`);
+ }
+
+ return json(await response.json());
+}
diff --git a/web/app/utils/formatters.ts b/web/app/utils/formatters.ts
index 4b02e5a..a18779e 100644
--- a/web/app/utils/formatters.ts
+++ b/web/app/utils/formatters.ts
@@ -37,9 +37,12 @@ export function formatJSON(obj: any, maxLength: number = 1000): string {
* Escapes HTML characters to prevent XSS
*/
export function escapeHtml(text: string): string {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
}
/**
@@ -47,39 +50,24 @@ export function escapeHtml(text: string): string {
*/
export function formatLargeText(text: string): string {
if (!text) return '';
-
+
// Escape HTML first
const escaped = escapeHtml(text);
-
- // Format the text with proper spacing and structure
+
+ // Simple, safe formatting - just handle line breaks and basic markdown
return escaped
- // Preserve existing double line breaks
- .replace(/\n\n/g, '
')
- // Convert single line breaks to single
tags
+ // Preserve existing double line breaks as paragraph breaks
+ .replace(/\n\n/g, '
')
+ // Convert single line breaks to
tags
.replace(/\n/g, '
')
- // Format bullet points with modern styling
- .replace(/^(\s*)([-*•])\s+(.+)$/gm, '$1$3')
- // Format numbered lists with modern styling
- .replace(/^(\s*)(\d+)\.\s+(.+)$/gm, '$1$2$3')
- // Format headers with better typography
- .replace(/^([A-Z][^<\n]*:)(
|$)/gm, '
$1
$2')
- // Format code blocks with better styling
- .replace(/\b([A-Z_]{3,})\b/g, '
$1')
- // Format file paths and technical terms
- .replace(/\b([a-zA-Z0-9_-]+\.[a-zA-Z]{2,4})\b/g, '
$1')
- // Format URLs with modern link styling
- .replace(/(https?:\/\/[^\s<]+)/g, '
$1')
- // Format quoted text
- .replace(/^(\s*)([""](.+?)[""])/gm, '$1
$3
')
- // Add proper spacing around paragraphs
- .replace(/(
)/g, '
')
- // Clean up any excessive spacing
- .replace(/(
\s*){3,}/g, '
')
- // Format emphasis patterns
- .replace(/\*\*([^*]+)\*\*/g, '
$1')
- .replace(/\*([^*]+)\*/g, '
$1')
- // Format inline code
- .replace(/`([^`]+)`/g, '
$1');
+ // Format inline code (backticks)
+ .replace(/`([^`]+)`/g, '
$1')
+ // Format bold text
+ .replace(/\*\*([^*]+)\*\*/g, '
$1')
+ // Format italic text
+ .replace(/\*([^*]+)\*/g, '
$1')
+ // Wrap in paragraph
+ .replace(/^(.*)$/, '
$1
');
}
/**
diff --git a/web/package-lock.json b/web/package-lock.json
index 97e7738..3fa5018 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -9,6 +9,7 @@
"@remix-run/node": "^2.16.8",
"@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8",
+ "@tanstack/react-virtual": "^3.13.12",
"isbot": "^4.1.0",
"lucide-react": "^0.522.0",
"react": "^18.2.0",
@@ -2023,6 +2024,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.12",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
+ "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.12",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
+ "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
diff --git a/web/package.json b/web/package.json
index 4388d8f..e3b2a3a 100644
--- a/web/package.json
+++ b/web/package.json
@@ -14,6 +14,7 @@
"@remix-run/node": "^2.16.8",
"@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8",
+ "@tanstack/react-virtual": "^3.13.12",
"isbot": "^4.1.0",
"lucide-react": "^0.522.0",
"react": "^18.2.0",