mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-05 14:22:12 +00:00
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:
@@ -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();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -11,3 +11,5 @@ export { SettingsPage } from './settings';
|
||||
export { HealthPage } from './health';
|
||||
|
||||
export { SharedPage } from './shared';
|
||||
|
||||
export { AnalyticsPage } from './analytics';
|
||||
|
||||
Reference in New Issue
Block a user