From f2dc615f06c78e996820e23482ef8423d7f120fe Mon Sep 17 00:00:00 2001 From: sharath <29162020+tnfssc@users.noreply.github.com> Date: Wed, 25 Jun 2025 21:26:02 +0000 Subject: [PATCH] feat(usage): add usage logs page and integrate it into personal account settings --- .../(personalAccount)/settings/layout.tsx | 1 + .../settings/usage-logs/page.tsx | 15 + .../src/components/billing/usage-logs.tsx | 468 ++++++++++++++++++ 3 files changed, 484 insertions(+) create mode 100644 frontend/src/app/(dashboard)/(personalAccount)/settings/usage-logs/page.tsx create mode 100644 frontend/src/components/billing/usage-logs.tsx diff --git a/frontend/src/app/(dashboard)/(personalAccount)/settings/layout.tsx b/frontend/src/app/(dashboard)/(personalAccount)/settings/layout.tsx index 52c81f4d..ef0664cf 100644 --- a/frontend/src/app/(dashboard)/(personalAccount)/settings/layout.tsx +++ b/frontend/src/app/(dashboard)/(personalAccount)/settings/layout.tsx @@ -15,6 +15,7 @@ export default function PersonalAccountSettingsPage({ // { name: "Profile", href: "/settings" }, // { name: "Teams", href: "/settings/teams" }, { name: 'Billing', href: '/settings/billing' }, + { name: 'Usage Logs', href: '/settings/usage-logs' }, ]; return ( <> diff --git a/frontend/src/app/(dashboard)/(personalAccount)/settings/usage-logs/page.tsx b/frontend/src/app/(dashboard)/(personalAccount)/settings/usage-logs/page.tsx new file mode 100644 index 00000000..51ec08f7 --- /dev/null +++ b/frontend/src/app/(dashboard)/(personalAccount)/settings/usage-logs/page.tsx @@ -0,0 +1,15 @@ +import { createClient } from '@/lib/supabase/server'; +import UsageLogs from '@/components/billing/usage-logs'; + +export default async function UsageLogsPage() { + const supabaseClient = await createClient(); + const { data: personalAccount } = await supabaseClient.rpc( + 'get_personal_account', + ); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/billing/usage-logs.tsx b/frontend/src/components/billing/usage-logs.tsx new file mode 100644 index 00000000..7ded3c3a --- /dev/null +++ b/frontend/src/components/billing/usage-logs.tsx @@ -0,0 +1,468 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { createClient } from '@/lib/supabase/client'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { ExternalLink, Loader2 } from 'lucide-react'; +import { isLocalMode } from '@/lib/config'; + +interface UsageLogEntry { + message_id: string; + thread_id: string; + created_at: string; + content: { + usage: { + prompt_tokens: number; + completion_tokens: number; + }; + model: string; + }; + total_tokens: number; + estimated_cost: number; + project_id: string; +} + +interface DailyUsage { + date: string; + logs: UsageLogEntry[]; + totalTokens: number; + totalCost: number; + requestCount: number; + models: string[]; +} + +interface Props { + accountId: string; +} + +export default function UsageLogs({ accountId }: Props) { + const [usageLogs, setUsageLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [modelPricing, setModelPricing] = useState< + Record + >({}); + + const ITEMS_PER_PAGE = 1000; + + const fetchModelPricing = async () => { + try { + const response = await fetch('/api/billing/available-models'); + if (!response.ok) throw new Error('Failed to fetch model pricing'); + + const data = await response.json(); + const pricing: Record = {}; + + data.models.forEach((model: any) => { + if ( + model.input_cost_per_million_tokens && + model.output_cost_per_million_tokens + ) { + pricing[model.id] = { + input: model.input_cost_per_million_tokens, + output: model.output_cost_per_million_tokens, + }; + } + }); + + setModelPricing(pricing); + } catch (error) { + console.error('Error fetching model pricing:', error); + } + }; + + const calculateTokenCost = ( + promptTokens: number, + completionTokens: number, + model: string, + ): number => { + // Use fetched pricing data from available-models API + const costs = modelPricing[model]; + + if (costs) { + // Convert from per-million to per-token costs + return ( + (promptTokens / 1000000) * costs.input + + (completionTokens / 1000000) * costs.output + ); + } + + // Fallback to a reasonable average if no pricing data available + const fallbackCost = 0.002; // per 1K tokens + return ((promptTokens + completionTokens) / 1000) * fallbackCost; + }; + + const fetchUsageLogs = async ( + pageNum: number = 0, + append: boolean = false, + ) => { + try { + if (!append) setLoading(true); + else setLoadingMore(true); + + const supabase = createClient(); + + // First, get all thread IDs for this user + const { data: threads, error: threadsError } = await supabase + .from('threads') + .select('thread_id') + .eq('account_id', accountId); + + if (threadsError) throw threadsError; + + if (!threads || threads.length === 0) { + setUsageLogs([]); + setLoading(false); + setLoadingMore(false); + setHasMore(false); + return; + } + + const threadIds = threads.map((t) => t.thread_id); + + // Then fetch usage messages with pagination, including thread project info + const { data: messages, error: messagesError } = await supabase + .from('messages') + .select( + ` + message_id, + thread_id, + created_at, + content, + threads!inner(project_id) + `, + ) + .in('thread_id', threadIds) + .eq('type', 'assistant_response_end') + .order('created_at', { ascending: false }) + .range(pageNum * ITEMS_PER_PAGE, (pageNum + 1) * ITEMS_PER_PAGE - 1); + + if (messagesError) throw messagesError; + + const processedLogs: UsageLogEntry[] = (messages || []).map((message) => { + const usage = message.content?.usage || { + prompt_tokens: 0, + completion_tokens: 0, + }; + const model = message.content?.model || 'unknown'; + const totalTokens = usage.prompt_tokens + usage.completion_tokens; + const estimatedCost = calculateTokenCost( + usage.prompt_tokens, + usage.completion_tokens, + model, + ); + + return { + message_id: message.message_id, + thread_id: message.thread_id, + created_at: message.created_at, + content: { + usage, + model, + }, + total_tokens: totalTokens, + estimated_cost: estimatedCost, + project_id: message.threads?.[0]?.project_id || 'unknown', + }; + }); + + if (append) { + setUsageLogs((prev) => [...prev, ...processedLogs]); + } else { + setUsageLogs(processedLogs); + } + + setHasMore(processedLogs.length === ITEMS_PER_PAGE); + } catch (err) { + console.error('Error fetching usage logs:', err); + setError( + err instanceof Error ? err.message : 'Failed to fetch usage logs', + ); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + useEffect(() => { + fetchModelPricing(); + fetchUsageLogs(0, false); + }, [accountId]); + + const loadMore = () => { + const nextPage = page + 1; + setPage(nextPage); + fetchUsageLogs(nextPage, true); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + const formatCost = (cost: number) => { + return `$${cost.toFixed(4)}`; + }; + + const formatDateOnly = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + const handleThreadClick = (threadId: string, projectId: string) => { + // Navigate to the thread using the correct project_id + const threadUrl = `/projects/${projectId}/thread/${threadId}`; + window.open(threadUrl, '_blank'); + }; + + // Group usage logs by date + const groupLogsByDate = (logs: UsageLogEntry[]): DailyUsage[] => { + const grouped = logs.reduce( + (acc, log) => { + const date = new Date(log.created_at).toDateString(); + + if (!acc[date]) { + acc[date] = { + date, + logs: [], + totalTokens: 0, + totalCost: 0, + requestCount: 0, + models: [], + }; + } + + acc[date].logs.push(log); + acc[date].totalTokens += log.total_tokens; + acc[date].totalCost += log.estimated_cost; + acc[date].requestCount += 1; + + if (!acc[date].models.includes(log.content.model)) { + acc[date].models.push(log.content.model); + } + + return acc; + }, + {} as Record, + ); + + return Object.values(grouped).sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), + ); + }; + + if (isLocalMode()) { + return ( + + + Usage Logs + + +
+

+ Usage logs are not available in local development mode +

+
+
+
+ ); + } + + if (loading) { + return ( + + + Usage Logs + Loading your token usage history... + + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+ ); + } + + if (error) { + return ( + + + Usage Logs + + +
+

Error: {error}

+
+
+
+ ); + } + + const dailyUsage = groupLogsByDate(usageLogs); + const totalUsage = usageLogs.reduce( + (sum, log) => sum + log.estimated_cost, + 0, + ); + + return ( +
+ {/* Usage Logs Accordion */} + + + Daily Usage Logs + + Your token usage organized by day, sorted by most recent + + + + {dailyUsage.length === 0 ? ( +
+

No usage logs found.

+
+ ) : ( + <> + + {dailyUsage.map((day) => ( + + +
+
+
+ {formatDateOnly(day.date)} +
+
+ {day.requestCount} request + {day.requestCount !== 1 ? 's' : ''} •{' '} + {day.models.join(', ')} +
+
+
+
+ {formatCost(day.totalCost)} +
+
+ {day.totalTokens.toLocaleString()} tokens +
+
+
+
+ +
+ + + + Time + Model + + Total + + Cost + + Thread + + + + + {day.logs.map((log) => ( + + + {new Date( + log.created_at, + ).toLocaleTimeString()} + + + + {log.content.model} + + + + {log.total_tokens.toLocaleString()} + + + {formatCost(log.estimated_cost)} + + + + + + ))} + +
+
+
+
+ ))} +
+ + {hasMore && ( +
+ +
+ )} + + )} +
+
+
+ ); +}