feat(analytics): add usage analytics page with caching layer

- Add Analytics page with usage trends, model breakdown, sessions table
- Add server-side caching layer for better-ccusage data (TTL-based)
- Add request coalescing to prevent duplicate concurrent API calls
- Add /api/usage/refresh endpoint to manually clear cache
- Add date-range filter, summary cards, trend charts components
- Fix API parameter mismatch (since/until in YYYYMMDD format)
- Wire up Refresh button with loading state animation
This commit is contained in:
kaitranntt
2025-12-08 21:47:36 -05:00
parent 57032eec5e
commit a721af3cf3
17 changed files with 1961 additions and 2 deletions
+2
View File
@@ -15,6 +15,7 @@ import {
SettingsPage,
HealthPage,
SharedPage,
AnalyticsPage,
} from '@/pages';
function Layout() {
@@ -42,6 +43,7 @@ export default function App() {
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/api" element={<ApiPage />} />
<Route path="/cliproxy" element={<CliproxyPage />} />
<Route path="/accounts" element={<AccountsPage />} />
@@ -0,0 +1,95 @@
/**
* Date Range Filter Component
*
* Provides date range selection with preset options for analytics.
* Uses react-day-picker for date selection UI.
*/
import React from 'react';
import { format } from 'date-fns';
import type { DateRange } from 'react-day-picker';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { CalendarIcon } from 'lucide-react';
interface DateRangeFilterProps {
value?: DateRange;
onChange: (dateRange: DateRange | undefined) => void;
presets?: Array<{
label: string;
range: DateRange;
}>;
className?: string;
}
export function DateRangeFilter({
value,
onChange,
presets = [],
className,
}: DateRangeFilterProps) {
const handlePresetClick = (range: DateRange) => {
onChange(range);
};
const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const from = e.target.value ? new Date(e.target.value) : undefined;
onChange({ from, to: value?.to });
};
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const to = e.target.value ? new Date(e.target.value) : undefined;
onChange({ from: value?.from, to });
};
return (
<div className={cn('flex flex-wrap items-center gap-2', className)}>
{/* Preset Buttons */}
{presets.map((preset, index) => (
<Button
key={index}
variant={isSameRange(value, preset.range) ? 'default' : 'outline'}
size="sm"
onClick={() => handlePresetClick(preset.range)}
>
{preset.label}
</Button>
))}
{/* Custom Date Range Inputs */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<Input
type="date"
value={value?.from ? format(value.from, 'yyyy-MM-dd') : ''}
onChange={handleFromChange}
placeholder="From"
className="w-40"
/>
</div>
<span className="text-muted-foreground">to</span>
<Input
type="date"
value={value?.to ? format(value.to, 'yyyy-MM-dd') : ''}
onChange={handleToChange}
placeholder="To"
className="w-40"
/>
</div>
</div>
);
}
// Helper to compare date ranges
function isSameRange(a?: DateRange, b?: DateRange): boolean {
if (!a || !b) return a === b;
const fromA = a.from?.getTime() ?? 0;
const fromB = b.from?.getTime() ?? 0;
const toA = a.to?.getTime() ?? 0;
const toB = b.to?.getTime() ?? 0;
return fromA === fromB && toA === toB;
}
@@ -0,0 +1,123 @@
/**
* Model Breakdown Chart Component
*
* Displays usage distribution by model using pie chart.
* Shows tokens, cost, and percentage breakdown.
*/
import { useMemo } from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
import { Skeleton } from '@/components/ui/skeleton';
import type { ModelUsage } from '@/hooks/use-usage';
import { cn } from '@/lib/utils';
interface ModelBreakdownChartProps {
data: ModelUsage[];
isLoading?: boolean;
className?: string;
}
const COLORS = [
'#0080FF',
'#00C49F',
'#FFBB28',
'#FF8042',
'#8884D8',
'#82CA9D',
'#FFC658',
'#8DD1E1',
'#D084D0',
'#87D068',
];
export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdownChartProps) {
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.map((item, index) => ({
name: item.model,
value: item.tokens,
cost: item.cost,
requests: item.requests,
percentage: item.percentage,
fill: COLORS[index % COLORS.length],
}));
}, [data]);
if (isLoading) {
return <Skeleton className={cn('h-[300px] w-full', className)} />;
}
if (!data || data.length === 0) {
return (
<div className={cn('h-[300px] flex items-center justify-center', className)}>
<p className="text-muted-foreground">No model data available</p>
</div>
);
}
const renderTooltip = ({ active, payload }: { active?: boolean; payload?: unknown }) => {
if (!active || !payload) return null;
const payloadArray = payload as Array<{
payload: { name: string; value: number; cost: number; requests: number; percentage: number };
}>;
if (!payloadArray.length) return null;
const data = payloadArray[0].payload;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="font-medium mb-2">{data.name}</p>
<p className="text-sm text-muted-foreground">
Tokens: {formatNumber(data.value)} ({data.percentage.toFixed(1)}%)
</p>
<p className="text-sm text-muted-foreground">Cost: ${data.cost.toFixed(4)}</p>
<p className="text-sm text-muted-foreground">Requests: {data.requests}</p>
</div>
);
};
const renderLabel = (entry: { percentage: number }) => {
return `${entry.percentage.toFixed(1)}%`;
};
return (
<div className={cn('w-full', className)}>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={renderLabel}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip content={renderTooltip} />
<Legend
verticalAlign="bottom"
height={36}
formatter={(value) => <span className="text-sm">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
}
// Helper function to format large numbers
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString();
}
@@ -0,0 +1,269 @@
/**
* Sessions Table Component
*
* Displays session history with pagination and filtering.
* Shows session duration, tokens, cost, and metadata.
*/
import { useState, useMemo } from 'react';
import { formatDistanceToNow } from 'date-fns';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ChevronLeft, ChevronRight, Search, Clock, Zap, DollarSign } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PaginatedSessions } from '@/hooks/use-usage';
interface SessionsTableProps {
data?: PaginatedSessions;
isLoading?: boolean;
}
export function SessionsTable({ data, isLoading }: SessionsTableProps) {
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(0);
// Get sessions array (stable reference for memoization)
const sessions = data?.sessions ?? [];
// Filter sessions based on search term
const filteredSessions = useMemo(() => {
if (!searchTerm) return sessions;
const term = searchTerm.toLowerCase();
return sessions.filter(
(session) =>
session.profile.toLowerCase().includes(term) ||
session.model.toLowerCase().includes(term) ||
session.id.toLowerCase().includes(term)
);
}, [sessions, searchTerm]);
// Pagination for filtered data
const pageSize = 10;
const paginatedSessions = useMemo(() => {
if (!filteredSessions) return [];
const start = currentPage * pageSize;
return filteredSessions.slice(start, start + pageSize);
}, [filteredSessions, currentPage]);
const totalPages = Math.ceil((filteredSessions?.length || 0) / pageSize);
if (isLoading) {
return <SessionsTableSkeleton />;
}
if (!data || data.sessions.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-1">No sessions found</h3>
<p className="text-muted-foreground">Start using Claude Code to see session history</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Search Bar */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by profile, model, or session ID..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(0);
}}
className="pl-8"
/>
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Session ID</TableHead>
<TableHead>Profile</TableHead>
<TableHead>Model</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead>Last Used</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedSessions.map((session) => (
<TableRow key={session.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">{session.id.slice(0, 8)}...</TableCell>
<TableCell>
<Badge variant="secondary">{session.profile}</Badge>
</TableCell>
<TableCell className="font-medium">{session.model}</TableCell>
<TableCell>{session.duration ? formatDuration(session.duration) : '-'}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Zap className="h-3 w-3 text-muted-foreground" />
{formatNumber(session.tokens)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<DollarSign className="h-3 w-3 text-muted-foreground" />$
{session.cost.toFixed(4)}
</div>
</TableCell>
<TableCell className="text-right">{session.requests}</TableCell>
<TableCell className="text-muted-foreground">
{formatDistanceToNow(new Date(session.startTime), { addSuffix: true })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {currentPage * pageSize + 1} to{' '}
{Math.min((currentPage + 1) * pageSize, filteredSessions?.length || 0)} of{' '}
{filteredSessions?.length} sessions
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i;
return (
<Button
key={page}
variant={currentPage === page ? 'default' : 'outline'}
size="sm"
className={cn('w-8 h-8 p-0')}
onClick={() => setCurrentPage(page)}
>
{page + 1}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={currentPage === totalPages - 1}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
// Helper functions
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString();
}
// Skeleton loading state
function SessionsTableSkeleton() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="h-10 flex-1" />
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Session ID</TableHead>
<TableHead>Profile</TableHead>
<TableHead>Model</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead>Last Used</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[1, 2, 3, 4, 5].map((i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-[60px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[80px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[60px]" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-[80px]" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-[70px]" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-[60px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[80px]" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -0,0 +1,108 @@
/**
* Usage Summary Cards Component
*
* Displays key metrics in a card grid layout.
* Shows total tokens, cost, requests, and average tokens per request.
*/
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { TrendingUp, DollarSign, Zap, FileText } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { UsageSummary } from '@/hooks/use-usage';
interface UsageSummaryCardsProps {
data?: UsageSummary;
isLoading?: boolean;
}
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-8 w-[80px]" />
</div>
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
const cards = [
{
title: 'Total Tokens',
value: data?.totalTokens ?? 0,
icon: FileText,
format: (v: number) => formatNumber(v),
color: 'text-blue-600',
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
},
{
title: 'Total Cost',
value: data?.totalCost ?? 0,
icon: DollarSign,
format: (v: number) => `$${v.toFixed(2)}`,
color: 'text-green-600',
bgColor: 'bg-green-100 dark:bg-green-900/20',
},
{
title: 'Total Requests',
value: data?.totalRequests ?? 0,
icon: Zap,
format: (v: number) => formatNumber(v),
color: 'text-purple-600',
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
},
{
title: 'Avg Tokens/Request',
value: data?.averageTokensPerRequest ?? 0,
icon: TrendingUp,
format: (v: number) => formatNumber(Math.round(v)),
color: 'text-orange-600',
bgColor: 'bg-orange-100 dark:bg-orange-900/20',
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{cards.map((card, index) => {
const Icon = card.icon;
return (
<Card key={index} className="hover:shadow-md transition-shadow">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{card.title}</p>
<p className="text-2xl font-bold">{card.format(card.value)}</p>
</div>
<div className={cn('p-2 rounded-lg', card.bgColor)}>
<Icon className={cn('h-5 w-5', card.color)} />
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
// Helper to format large numbers
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString();
}
@@ -0,0 +1,171 @@
/**
* Usage Trend Chart Component
*
* Displays usage trends over time with tokens and cost.
* Supports daily and monthly granularity with interactive tooltips.
*/
import { useMemo } from 'react';
import {
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Area,
AreaChart,
} from 'recharts';
import { format } from 'date-fns';
import { Skeleton } from '@/components/ui/skeleton';
import type { DateRange } from 'react-day-picker';
import { cn } from '@/lib/utils';
import type { DailyUsage } from '@/hooks/use-usage';
interface UsageTrendChartProps {
data: DailyUsage[];
isLoading?: boolean;
dateRange?: DateRange;
granularity?: 'daily' | 'monthly';
className?: string;
}
export function UsageTrendChart({
data,
isLoading,
granularity = 'daily',
className,
}: Omit<UsageTrendChartProps, 'dateRange'>) {
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.map((item) => ({
...item,
dateFormatted: formatDate(item.date, granularity),
costRounded: Number(item.cost.toFixed(4)),
}));
}, [data, granularity]);
if (isLoading) {
return <Skeleton className={cn('h-[300px] w-full', className)} />;
}
if (!data || data.length === 0) {
return (
<div className={cn('h-[300px] flex items-center justify-center', className)}>
<p className="text-muted-foreground">No usage data available</p>
</div>
);
}
return (
<div className={cn('w-full', className)}>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<defs>
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0080FF" stopOpacity={0.8} />
<stop offset="95%" stopColor="#0080FF" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#00C49F" stopOpacity={0.8} />
<stop offset="95%" stopColor="#00C49F" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="dateFormatted"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ className: 'stroke-muted' }}
/>
<YAxis
yAxisId="left"
orientation="left"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ className: 'stroke-muted' }}
tickFormatter={(value) => formatNumber(value)}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ className: 'stroke-muted' }}
tickFormatter={(value) => `$${value}`}
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="font-medium mb-2">{label}</p>
{payload.map((entry, index) => (
<p key={index} className="text-sm" style={{ color: entry.color }}>
{entry.name}:{' '}
{entry.name === 'Tokens'
? formatNumber(Number(entry.value) || 0)
: `$${entry.value}`}
</p>
))}
<p className="text-sm text-muted-foreground mt-1">Requests: {data.requests}</p>
</div>
);
}}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="tokens"
stroke="#0080FF"
strokeWidth={2}
fillOpacity={1}
fill="url(#tokenGradient)"
name="Tokens"
/>
<Area
yAxisId="right"
type="monotone"
dataKey="costRounded"
stroke="#00C49F"
strokeWidth={2}
fillOpacity={1}
fill="url(#costGradient)"
name="Cost"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
// Helper functions
function formatDate(dateStr: string, granularity: 'daily' | 'monthly'): string {
const date = new Date(dateStr);
if (granularity === 'monthly') {
return format(date, 'MMM yyyy');
}
// For daily, show shorter format if range is > 30 days
return format(date, 'MMM dd');
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString();
}
+15 -2
View File
@@ -1,5 +1,15 @@
import { Link, useLocation } from 'react-router-dom';
import { Home, Key, Zap, Users, Settings, Activity, FolderOpen, ChevronRight } from 'lucide-react';
import {
Home,
Key,
Zap,
Users,
Settings,
Activity,
FolderOpen,
ChevronRight,
BarChart3,
} from 'lucide-react';
import {
Sidebar,
SidebarContent,
@@ -24,7 +34,10 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
const navGroups = [
{
title: 'General',
items: [{ path: '/', icon: Home, label: 'Home' }],
items: [
{ path: '/', icon: Home, label: 'Home' },
{ path: '/analytics', icon: BarChart3, label: 'Analytics' },
],
},
{
title: 'Identity & Access',
+202
View File
@@ -0,0 +1,202 @@
/**
* React Query hooks for usage analytics
* Phase 01: Analytics Page Implementation
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
// Types
export interface UsageSummary {
totalTokens: number;
totalCost: number;
totalRequests: number;
averageTokensPerRequest: number;
dailyUsage: DailyUsage[];
}
export interface DailyUsage {
date: string;
tokens: number;
cost: number;
requests: number;
}
export interface ModelUsage {
model: string;
tokens: number;
cost: number;
requests: number;
percentage: number;
}
export interface Session {
id: string;
startTime: string;
endTime?: string;
duration?: number;
tokens: number;
cost: number;
requests: number;
profile: string;
model: string;
}
export interface PaginatedSessions {
sessions: Session[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
export interface MonthlyUsage {
month: string;
tokens: number;
cost: number;
requests: number;
}
export interface UsageQueryOptions {
startDate?: Date;
endDate?: Date;
profile?: string;
limit?: number;
offset?: number;
}
// API
const BASE_URL = '/api';
/**
* Convert Date to YYYYMMDD format for API
*/
function formatDateForApi(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
}
export const usageApi = {
summary: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
return request<UsageSummary>(`/usage/summary?${params}`);
},
trends: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
return request<DailyUsage[]>(`/usage/daily?${params}`);
},
models: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
return request<ModelUsage[]>(`/usage/models?${params}`);
},
sessions: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
if (options?.limit) params.append('limit', options.limit.toString());
if (options?.offset) params.append('offset', options.offset.toString());
return request<PaginatedSessions>(`/usage/sessions?${params}`);
},
monthly: (months?: number, profile?: string) => {
const params = new URLSearchParams();
if (months) params.append('months', months.toString());
if (profile) params.append('profile', profile);
return request<MonthlyUsage[]>(`/usage/monthly?${params}`);
},
/** Clear server-side usage cache and force fresh data fetch */
refresh: async (): Promise<void> => {
const res = await fetch(`${BASE_URL}/usage/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
throw new Error('Failed to refresh usage cache');
}
},
};
// Helper function to match existing API client pattern
async function request<T>(url: string): Promise<T> {
const BASE_URL = '/api';
const res = await fetch(`${BASE_URL}${url}`, {
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const error = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || res.statusText);
}
const result = await res.json();
return result.data || result; // Extract data property if it exists
}
// Hooks
export function useUsageSummary(options?: UsageQueryOptions) {
return useQuery({
queryKey: ['usage', 'summary', options],
queryFn: () => usageApi.summary(options),
staleTime: 60 * 1000, // 1 minute
});
}
export function useUsageTrends(options?: UsageQueryOptions) {
return useQuery({
queryKey: ['usage', 'trends', options],
queryFn: () => usageApi.trends(options),
staleTime: 60 * 1000, // 1 minute
});
}
export function useModelUsage(options?: UsageQueryOptions) {
return useQuery({
queryKey: ['usage', 'models', options],
queryFn: () => usageApi.models(options),
staleTime: 60 * 1000, // 1 minute
});
}
export function useSessions(options?: UsageQueryOptions) {
return useQuery({
queryKey: ['usage', 'sessions', options],
queryFn: () => usageApi.sessions(options),
staleTime: 60 * 1000, // 1 minute
});
}
export function useMonthlyUsage(months?: number, profile?: string) {
return useQuery({
queryKey: ['usage', 'monthly', months, profile],
queryFn: () => usageApi.monthly(months, profile),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to refresh all usage data
* Clears server-side cache and invalidates React Query cache
*/
export function useRefreshUsage() {
const queryClient = useQueryClient();
const refresh = useCallback(async () => {
// Clear server-side cache
await usageApi.refresh();
// Invalidate all usage queries in React Query
await queryClient.invalidateQueries({ queryKey: ['usage'] });
}, [queryClient]);
return refresh;
}
+297
View File
@@ -0,0 +1,297 @@
/**
* Analytics Page
*
* Displays Claude Code usage analytics with charts and tables.
* Features daily/monthly views, trend charts, model breakdown, and session history.
*/
import { useState } from 'react';
import type { DateRange } from 'react-day-picker';
import { startOfMonth, subDays } from 'date-fns';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
import { SessionsTable } from '@/components/analytics/sessions-table';
import { TrendingUp, BarChart3, Clock, Calendar, Download, RefreshCw } from 'lucide-react';
import {
useUsageSummary,
useUsageTrends,
useModelUsage,
useSessions,
useRefreshUsage,
} from '@/hooks/use-usage';
type ViewMode = 'daily' | 'monthly' | 'sessions';
export function AnalyticsPage() {
// Default to last 30 days
const [dateRange, setDateRange] = useState<DateRange | undefined>({
from: subDays(new Date(), 30),
to: new Date(),
});
const [viewMode, setViewMode] = useState<ViewMode>('daily');
const [isRefreshing, setIsRefreshing] = useState(false);
// Refresh hook
const refreshUsage = useRefreshUsage();
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await refreshUsage();
} finally {
setIsRefreshing(false);
}
};
// Convert dates to API format
const apiOptions = {
startDate: dateRange?.from,
endDate: dateRange?.to,
};
// Fetch data
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
const { data: sessions, isLoading: isSessionsLoading } = useSessions({
...apiOptions,
limit: 50,
});
// Loading state
if (isSummaryLoading || isTrendsLoading || isModelsLoading) {
return <AnalyticsSkeleton />;
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Analytics</h1>
<p className="text-muted-foreground">Track your Claude Code usage and insights</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-2">
<Download className="w-4 h-4" />
Export
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
</div>
{/* Date Range Filter */}
<DateRangeFilter
value={dateRange}
onChange={setDateRange}
presets={[
{ label: 'Last 7 days', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: 'Last 30 days', range: { from: subDays(new Date(), 30), to: new Date() } },
{ label: 'This month', range: { from: startOfMonth(new Date()), to: new Date() } },
{ label: 'Last 3 months', range: { from: subDays(new Date(), 90), to: new Date() } },
]}
/>
{/* Summary Cards */}
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Main Content Tabs */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="daily" className="gap-2">
<TrendingUp className="w-4 h-4" />
Daily
</TabsTrigger>
<TabsTrigger value="monthly" className="gap-2">
<BarChart3 className="w-4 h-4" />
Monthly
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-2">
<Clock className="w-4 h-4" />
Sessions
</TabsTrigger>
</TabsList>
{/* Daily View */}
<TabsContent value="daily" className="space-y-6">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Usage Trend Chart */}
<Card className="xl:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
Usage Trends
</CardTitle>
</CardHeader>
<CardContent>
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} />
</CardContent>
</Card>
{/* Model Distribution */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
Model Usage
</CardTitle>
</CardHeader>
<CardContent>
<ModelBreakdownChart data={models || []} isLoading={isModelsLoading} />
</CardContent>
</Card>
{/* Cost Breakdown */}
<Card>
<CardHeader>
<CardTitle>Cost by Model</CardTitle>
</CardHeader>
<CardContent>
{isModelsLoading ? (
<Skeleton className="h-[200px]" />
) : (
<div className="space-y-3">
{models?.slice(0, 5).map((model) => (
<div key={model.model} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<span className="text-sm font-medium">{model.model}</span>
</div>
<span className="text-sm text-muted-foreground">
${model.cost.toFixed(4)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Monthly View */}
<TabsContent value="monthly">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
Monthly Overview
</CardTitle>
</CardHeader>
<CardContent>
<UsageTrendChart
data={trends || []}
isLoading={isTrendsLoading}
granularity="monthly"
/>
</CardContent>
</Card>
</TabsContent>
{/* Sessions View */}
<TabsContent value="sessions">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
Session History
</CardTitle>
</CardHeader>
<CardContent>
<SessionsTable data={sessions} isLoading={isSessionsLoading} />
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
// Helper function to generate consistent colors for models
function getModelColor(model: string): string {
const colors = [
'#0080FF',
'#00C49F',
'#FFBB28',
'#FF8042',
'#8884D8',
'#82CA9D',
'#FFC658',
'#8DD1E1',
'#D084D0',
'#87D068',
];
let hash = 0;
for (let i = 0; i < model.length; i++) {
hash = model.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
// Skeleton loading state
function AnalyticsSkeleton() {
return (
<div className="p-6 space-y-6">
{/* Header */}
<div>
<Skeleton className="h-8 w-[120px] mb-2" />
<Skeleton className="h-4 w-[300px]" />
</div>
{/* Date Filter */}
<Skeleton className="h-10 w-[300px]" />
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<Skeleton className="h-4 w-[100px] mb-2" />
<Skeleton className="h-8 w-[60px]" />
</CardContent>
</Card>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card className="xl:col-span-2">
<CardHeader>
<Skeleton className="h-6 w-[120px]" />
</CardHeader>
<CardContent>
<Skeleton className="h-[300px]" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-[120px]" />
</CardHeader>
<CardContent>
<Skeleton className="h-[200px]" />
</CardContent>
</Card>
</div>
</div>
);
}
+2
View File
@@ -11,3 +11,5 @@ export { SettingsPage } from './settings';
export { HealthPage } from './health';
export { SharedPage } from './shared';
export { AnalyticsPage } from './analytics';