mirror of https://github.com/kortix-ai/suna.git
feat(usage): add usage logs page and integrate it into personal account settings
This commit is contained in:
parent
445e19e00b
commit
f2dc615f06
|
@ -15,6 +15,7 @@ export default function PersonalAccountSettingsPage({
|
||||||
// { name: "Profile", href: "/settings" },
|
// { name: "Profile", href: "/settings" },
|
||||||
// { name: "Teams", href: "/settings/teams" },
|
// { name: "Teams", href: "/settings/teams" },
|
||||||
{ name: 'Billing', href: '/settings/billing' },
|
{ name: 'Billing', href: '/settings/billing' },
|
||||||
|
{ name: 'Usage Logs', href: '/settings/usage-logs' },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<UsageLogs accountId={personalAccount.account_id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<UsageLogEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [modelPricing, setModelPricing] = useState<
|
||||||
|
Record<string, { input: number; output: number }>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
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<string, { input: number; output: number }> = {};
|
||||||
|
|
||||||
|
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<string, DailyUsage>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.values(grouped).sort(
|
||||||
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLocalMode()) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Usage Logs</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="p-4 bg-muted/30 border border-border rounded-lg text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Usage logs are not available in local development mode
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Usage Logs</CardTitle>
|
||||||
|
<CardDescription>Loading your token usage history...</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Usage Logs</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||||
|
<p className="text-sm text-destructive">Error: {error}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyUsage = groupLogsByDate(usageLogs);
|
||||||
|
const totalUsage = usageLogs.reduce(
|
||||||
|
(sum, log) => sum + log.estimated_cost,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Usage Logs Accordion */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Daily Usage Logs</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your token usage organized by day, sorted by most recent
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{dailyUsage.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">No usage logs found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
{dailyUsage.map((day) => (
|
||||||
|
<AccordionItem key={day.date} value={day.date}>
|
||||||
|
<AccordionTrigger className="hover:no-underline">
|
||||||
|
<div className="flex justify-between items-center w-full mr-4">
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">
|
||||||
|
{formatDateOnly(day.date)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{day.requestCount} request
|
||||||
|
{day.requestCount !== 1 ? 's' : ''} •{' '}
|
||||||
|
{day.models.join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-mono font-semibold">
|
||||||
|
{formatCost(day.totalCost)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground font-mono">
|
||||||
|
{day.totalTokens.toLocaleString()} tokens
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="rounded-md border mt-4">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Time</TableHead>
|
||||||
|
<TableHead>Model</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
Total
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">Cost</TableHead>
|
||||||
|
<TableHead className="text-center">
|
||||||
|
Thread
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{day.logs.map((log) => (
|
||||||
|
<TableRow key={log.message_id}>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{new Date(
|
||||||
|
log.created_at,
|
||||||
|
).toLocaleTimeString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
|
{log.content.model}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-medium text-sm">
|
||||||
|
{log.total_tokens.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-medium text-sm">
|
||||||
|
{formatCost(log.estimated_cost)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleThreadClick(
|
||||||
|
log.thread_id,
|
||||||
|
log.project_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center pt-6">
|
||||||
|
<Button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{loadingMore ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load More'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue