mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
refactor(ui): modularize analytics page into directory structure
- split analytics.tsx (420 lines) into modular components - extract analytics-header, charts-grid, cost-by-model-card - add analytics-skeleton for loading states - separate hooks, types, and utils
This commit is contained in:
@@ -1,420 +0,0 @@
|
||||
/**
|
||||
* Analytics Page
|
||||
*
|
||||
* Displays Claude Code usage analytics with charts.
|
||||
* Features trend charts, model breakdown, cost analysis, and anomaly detection.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover';
|
||||
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 { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||
import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
|
||||
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
useUsageSummary,
|
||||
useUsageTrends,
|
||||
useHourlyUsage,
|
||||
useModelUsage,
|
||||
useRefreshUsage,
|
||||
useUsageStatus,
|
||||
useSessions,
|
||||
type ModelUsage,
|
||||
} from '@/hooks/use-usage';
|
||||
import { getModelColor, cn } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
// Format token count to human-readable (K/M/B)
|
||||
function formatTokens(num: number): string {
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
// Default to last 30 days
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
||||
from: subDays(new Date(), 30),
|
||||
to: new Date(),
|
||||
});
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
|
||||
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
|
||||
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 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: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions);
|
||||
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
|
||||
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
|
||||
const { data: status } = useUsageStatus();
|
||||
|
||||
// Handle "24H" preset click
|
||||
const handleTodayClick = useCallback(() => {
|
||||
const now = new Date();
|
||||
setDateRange({ from: subDays(now, 1), to: now });
|
||||
setViewMode('hourly');
|
||||
}, []);
|
||||
|
||||
// Handle date range changes from DateRangeFilter
|
||||
const handleDateRangeChange = useCallback((range: DateRange | undefined) => {
|
||||
setDateRange(range);
|
||||
setViewMode('daily'); // Switch back to daily view for multi-day ranges
|
||||
}, []);
|
||||
|
||||
// Format "Last updated" text
|
||||
const lastUpdatedText = useMemo(() => {
|
||||
if (!status?.lastFetch) return null;
|
||||
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
|
||||
}, [status?.lastFetch]);
|
||||
|
||||
// Handle model click for popover
|
||||
const handleModelClick = useCallback((model: ModelUsage, event: React.MouseEvent) => {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setPopoverPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
|
||||
setSelectedModel(model);
|
||||
}, []);
|
||||
|
||||
const handlePopoverClose = useCallback(() => {
|
||||
setSelectedModel(null);
|
||||
setPopoverPosition(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden px-4 pt-4 pb-50 gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Analytics</h1>
|
||||
<p className="text-sm text-muted-foreground">Track usage & insights</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'hourly' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleTodayClick}
|
||||
>
|
||||
24H
|
||||
</Button>
|
||||
<DateRangeFilter
|
||||
value={dateRange}
|
||||
onChange={handleDateRangeChange}
|
||||
presets={[
|
||||
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
|
||||
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
|
||||
{ label: 'Month', range: { from: startOfMonth(new Date()), to: new Date() } },
|
||||
{ label: 'All Time', range: { from: undefined, to: new Date() } },
|
||||
]}
|
||||
/>
|
||||
|
||||
{lastUpdatedText && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Updated {lastUpdatedText}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 h-8"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0 gap-4">
|
||||
{/* Usage Trend Chart - Full Width */}
|
||||
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
|
||||
<CardHeader className="px-3 py-2 shrink-0">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
{viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
|
||||
<UsageTrendChart
|
||||
data={viewMode === 'hourly' ? hourlyData || [] : trends || []}
|
||||
isLoading={viewMode === 'hourly' ? isHourlyLoading : isTrendsLoading}
|
||||
granularity={viewMode === 'hourly' ? 'hourly' : 'daily'}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (2) + CLIProxy Stats (2) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto lg:h-[180px] shrink-0">
|
||||
{/* Cost by Model - 4/10 width with breakdown */}
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
Cost by Model
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 overflow-y-auto">
|
||||
{isModelsLoading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{[...(models || [])]
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.map((model) => (
|
||||
<button
|
||||
key={model.model}
|
||||
className="group flex items-center text-xs w-full hover:bg-muted/50 rounded px-2 py-1.5 transition-colors cursor-pointer gap-3"
|
||||
onClick={(e) => handleModelClick(model, e)}
|
||||
title="Click for details"
|
||||
>
|
||||
{/* Model name */}
|
||||
<div className="flex items-center gap-2 min-w-0 w-[180px] shrink-0">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: getModelColor(model.model) }}
|
||||
/>
|
||||
<span className="font-medium truncate group-hover:underline underline-offset-2">
|
||||
{model.model}
|
||||
</span>
|
||||
</div>
|
||||
{/* Cost breakdown mini-bar */}
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: '#335c67',
|
||||
width: `${model.cost > 0 ? (model.costBreakdown.input.cost / model.cost) * 100 : 0}%`,
|
||||
}}
|
||||
title={`Input: $${model.costBreakdown.input.cost.toFixed(2)}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: '#fff3b0',
|
||||
width: `${model.cost > 0 ? (model.costBreakdown.output.cost / model.cost) * 100 : 0}%`,
|
||||
}}
|
||||
title={`Output: $${model.costBreakdown.output.cost.toFixed(2)}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: '#e09f3e',
|
||||
width: `${model.cost > 0 ? (model.costBreakdown.cacheCreation.cost / model.cost) * 100 : 0}%`,
|
||||
}}
|
||||
title={`Cache Write: $${model.costBreakdown.cacheCreation.cost.toFixed(2)}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: '#9e2a2b',
|
||||
width: `${model.cost > 0 ? (model.costBreakdown.cacheRead.cost / model.cost) * 100 : 0}%`,
|
||||
}}
|
||||
title={`Cache Read: $${model.costBreakdown.cacheRead.cost.toFixed(2)}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Token count */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] text-muted-foreground w-14 text-right shrink-0',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{formatTokens(model.tokens)}
|
||||
</span>
|
||||
{/* Total cost */}
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono font-medium w-16 text-right shrink-0',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
${model.cost.toFixed(2)}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 opacity-0 group-hover:opacity-50 transition-opacity shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-3 pt-2 px-2 text-[10px] text-muted-foreground border-t mt-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: '#335c67' }}
|
||||
/>
|
||||
Input
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full border border-muted-foreground/30"
|
||||
style={{ backgroundColor: '#fff3b0' }}
|
||||
/>
|
||||
Output
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: '#e09f3e' }}
|
||||
/>
|
||||
Cache Write
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: '#9e2a2b' }}
|
||||
/>
|
||||
Cache Read
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Distribution - 2/10 width */}
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-2">
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<PieChart className="w-4 h-4" />
|
||||
Model Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||
<ModelBreakdownChart
|
||||
data={models || []}
|
||||
isLoading={isModelsLoading}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Session Stats - 2/10 width */}
|
||||
<SessionStatsCard
|
||||
data={sessions}
|
||||
isLoading={isSessionsLoading}
|
||||
className="lg:col-span-2"
|
||||
/>
|
||||
|
||||
{/* CLIProxy Stats - 2/10 width */}
|
||||
<CliproxyStatsCard isLoading={isSummaryLoading} className="lg:col-span-2" />
|
||||
</div>
|
||||
|
||||
{/* Model Details Popover - positioned at cursor */}
|
||||
<Popover open={!!selectedModel} onOpenChange={(open) => !open && handlePopoverClose()}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
ref={popoverAnchorRef}
|
||||
className="fixed pointer-events-none"
|
||||
style={{
|
||||
left: popoverPosition?.x ?? 0,
|
||||
top: popoverPosition?.y ?? 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent className="w-80 p-3" side="top" align="center">
|
||||
{selectedModel && <ModelDetailsContent model={selectedModel} />}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnalyticsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 h-full overflow-hidden">
|
||||
{/* Usage Trends Skeleton */}
|
||||
<Card className="flex flex-col min-h-[300px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom Row Skeletons */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Cost Breakdown Skeleton */}
|
||||
<Card className="flex flex-col min-h-[250px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="w-2.5 h-2.5 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Usage Skeleton */}
|
||||
<Card className="flex flex-col min-h-[250px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
<div className="flex w-full h-full items-center">
|
||||
<div className="flex-1 flex justify-center">
|
||||
<Skeleton className="h-[180px] w-[180px] rounded-full" />
|
||||
</div>
|
||||
<div className="w-[140px] shrink-0 pl-2 space-y-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Skeleton className="w-2 h-2 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Analytics Header Component
|
||||
*
|
||||
* Title, date filter, 24H button, and refresh controls.
|
||||
*/
|
||||
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
import { subDays, startOfMonth } from 'date-fns';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
interface AnalyticsHeaderProps {
|
||||
dateRange: DateRange | undefined;
|
||||
onDateRangeChange: (range: DateRange | undefined) => void;
|
||||
onTodayClick: () => void;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
lastUpdatedText: string | null;
|
||||
viewMode: 'daily' | 'hourly';
|
||||
}
|
||||
|
||||
export function AnalyticsHeader({
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
onTodayClick,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
lastUpdatedText,
|
||||
viewMode,
|
||||
}: AnalyticsHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Analytics</h1>
|
||||
<p className="text-sm text-muted-foreground">Track usage & insights</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'hourly' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onTodayClick}
|
||||
>
|
||||
24H
|
||||
</Button>
|
||||
<DateRangeFilter
|
||||
value={dateRange}
|
||||
onChange={onDateRangeChange}
|
||||
presets={[
|
||||
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
|
||||
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
|
||||
{ label: 'Month', range: { from: startOfMonth(new Date()), to: new Date() } },
|
||||
{ label: 'All Time', range: { from: undefined, to: new Date() } },
|
||||
]}
|
||||
/>
|
||||
|
||||
{lastUpdatedText && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Updated {lastUpdatedText}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 h-8"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Analytics Skeleton Component
|
||||
*
|
||||
* Loading skeleton for the analytics page.
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function AnalyticsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 h-full overflow-hidden">
|
||||
{/* Usage Trends Skeleton */}
|
||||
<Card className="flex flex-col min-h-[300px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom Row Skeletons */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Cost Breakdown Skeleton */}
|
||||
<Card className="flex flex-col min-h-[250px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="w-2.5 h-2.5 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Usage Skeleton */}
|
||||
<Card className="flex flex-col min-h-[250px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
<div className="flex w-full h-full items-center">
|
||||
<div className="flex-1 flex justify-center">
|
||||
<Skeleton className="h-[180px] w-[180px] rounded-full" />
|
||||
</div>
|
||||
<div className="w-[140px] shrink-0 pl-2 space-y-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Skeleton className="w-2 h-2 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Charts Grid Component
|
||||
*
|
||||
* Layout grid for analytics charts and cards.
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
||||
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||
import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
|
||||
import { TrendingUp, PieChart } from 'lucide-react';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { CostByModelCard } from './cost-by-model-card';
|
||||
import type { ModelUsage, PaginatedSessions, DailyUsage, HourlyUsage } from '@/hooks/use-usage';
|
||||
|
||||
interface ChartsGridProps {
|
||||
viewMode: 'daily' | 'hourly';
|
||||
trends: DailyUsage[] | undefined;
|
||||
hourlyData: HourlyUsage[] | undefined;
|
||||
models: ModelUsage[] | undefined;
|
||||
sessions: PaginatedSessions | undefined;
|
||||
isTrendsLoading: boolean;
|
||||
isHourlyLoading: boolean;
|
||||
isModelsLoading: boolean;
|
||||
isSessionsLoading: boolean;
|
||||
isSummaryLoading: boolean;
|
||||
onModelClick: (model: ModelUsage, event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function ChartsGrid({
|
||||
viewMode,
|
||||
trends,
|
||||
hourlyData,
|
||||
models,
|
||||
sessions,
|
||||
isTrendsLoading,
|
||||
isHourlyLoading,
|
||||
isModelsLoading,
|
||||
isSessionsLoading,
|
||||
isSummaryLoading,
|
||||
onModelClick,
|
||||
}: ChartsGridProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 gap-4">
|
||||
{/* Usage Trend Chart - Full Width */}
|
||||
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
|
||||
<CardHeader className="px-3 py-2 shrink-0">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
{viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
|
||||
<UsageTrendChart
|
||||
data={viewMode === 'hourly' ? hourlyData || [] : trends || []}
|
||||
isLoading={viewMode === 'hourly' ? isHourlyLoading : isTrendsLoading}
|
||||
granularity={viewMode === 'hourly' ? 'hourly' : 'daily'}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto lg:h-[180px] shrink-0">
|
||||
{/* Cost by Model */}
|
||||
<CostByModelCard
|
||||
models={models}
|
||||
isLoading={isModelsLoading}
|
||||
onModelClick={onModelClick}
|
||||
privacyMode={privacyMode}
|
||||
/>
|
||||
|
||||
{/* Model Distribution */}
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-2">
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<PieChart className="w-4 h-4" />
|
||||
Model Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||
<ModelBreakdownChart
|
||||
data={models || []}
|
||||
isLoading={isModelsLoading}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Session Stats */}
|
||||
<SessionStatsCard data={sessions} isLoading={isSessionsLoading} className="lg:col-span-2" />
|
||||
|
||||
{/* CLIProxy Stats */}
|
||||
<CliproxyStatsCard isLoading={isSummaryLoading} className="lg:col-span-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Cost By Model Card Component
|
||||
*
|
||||
* Displays a list of models sorted by cost with breakdown bars.
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DollarSign, ChevronRight } from 'lucide-react';
|
||||
import { getModelColor, cn } from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { formatTokens } from '../utils';
|
||||
import type { ModelUsage } from '@/hooks/use-usage';
|
||||
|
||||
interface CostByModelCardProps {
|
||||
models: ModelUsage[] | undefined;
|
||||
isLoading: boolean;
|
||||
onModelClick: (model: ModelUsage, event: React.MouseEvent) => void;
|
||||
privacyMode: boolean;
|
||||
}
|
||||
|
||||
export function CostByModelCard({
|
||||
models,
|
||||
isLoading,
|
||||
onModelClick,
|
||||
privacyMode,
|
||||
}: CostByModelCardProps) {
|
||||
return (
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
Cost by Model
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{[...(models || [])]
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.map((model) => (
|
||||
<button
|
||||
key={model.model}
|
||||
className="group flex items-center text-xs w-full hover:bg-muted/50 rounded px-2 py-1.5 transition-colors cursor-pointer gap-3"
|
||||
onClick={(e) => onModelClick(model, e)}
|
||||
title="Click for details"
|
||||
>
|
||||
{/* Model name */}
|
||||
<div className="flex items-center gap-2 min-w-0 w-[180px] shrink-0">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: getModelColor(model.model) }}
|
||||
/>
|
||||
<span className="font-medium truncate group-hover:underline underline-offset-2">
|
||||
{model.model}
|
||||
</span>
|
||||
</div>
|
||||
{/* Cost breakdown mini-bar */}
|
||||
<CostBreakdownBar model={model} />
|
||||
{/* Token count */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] text-muted-foreground w-14 text-right shrink-0',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{formatTokens(model.tokens)}
|
||||
</span>
|
||||
{/* Total cost */}
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono font-medium w-16 text-right shrink-0',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
${model.cost.toFixed(2)}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 opacity-0 group-hover:opacity-50 transition-opacity shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
{/* Legend */}
|
||||
<CostLegend />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CostBreakdownBar({ model }: { model: ModelUsage }) {
|
||||
const colors = {
|
||||
input: '#335c67',
|
||||
output: '#fff3b0',
|
||||
cacheWrite: '#e09f3e',
|
||||
cacheRead: '#9e2a2b',
|
||||
};
|
||||
|
||||
const getWidth = (cost: number) => (model.cost > 0 ? (cost / model.cost) * 100 : 0);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: colors.input,
|
||||
width: `${getWidth(model.costBreakdown.input.cost)}%`,
|
||||
}}
|
||||
title={`Input: $${model.costBreakdown.input.cost.toFixed(2)}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: colors.output,
|
||||
width: `${getWidth(model.costBreakdown.output.cost)}%`,
|
||||
}}
|
||||
title={`Output: $${model.costBreakdown.output.cost.toFixed(2)}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: colors.cacheWrite,
|
||||
width: `${getWidth(model.costBreakdown.cacheCreation.cost)}%`,
|
||||
}}
|
||||
title={`Cache Write: $${model.costBreakdown.cacheCreation.cost.toFixed(2)}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: colors.cacheRead,
|
||||
width: `${getWidth(model.costBreakdown.cacheRead.cost)}%`,
|
||||
}}
|
||||
title={`Cache Read: $${model.costBreakdown.cacheRead.cost.toFixed(2)}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CostLegend() {
|
||||
const items = [
|
||||
{ color: '#335c67', label: 'Input' },
|
||||
{ color: '#fff3b0', label: 'Output', hasBorder: true },
|
||||
{ color: '#e09f3e', label: 'Cache Write' },
|
||||
{ color: '#9e2a2b', label: 'Cache Read' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 pt-2 px-2 text-[10px] text-muted-foreground border-t mt-2">
|
||||
{items.map(({ color, label, hasBorder }) => (
|
||||
<span key={label} className="flex items-center gap-1">
|
||||
<div
|
||||
className={cn('w-2 h-2 rounded-full', hasBorder && 'border border-muted-foreground/30')}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Analytics Page Hooks
|
||||
*
|
||||
* Composite hook centralizing all data fetching and state for the analytics page.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
import { subDays, formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
useUsageSummary,
|
||||
useUsageTrends,
|
||||
useHourlyUsage,
|
||||
useModelUsage,
|
||||
useRefreshUsage,
|
||||
useUsageStatus,
|
||||
useSessions,
|
||||
type ModelUsage,
|
||||
} from '@/hooks/use-usage';
|
||||
|
||||
export function useAnalyticsPage() {
|
||||
// Default to last 30 days
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
||||
from: subDays(new Date(), 30),
|
||||
to: new Date(),
|
||||
});
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
|
||||
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
|
||||
|
||||
// Refresh hook
|
||||
const refreshUsage = useRefreshUsage();
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await refreshUsage();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [refreshUsage]);
|
||||
|
||||
// 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: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions);
|
||||
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
|
||||
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
|
||||
const { data: status } = useUsageStatus();
|
||||
|
||||
// Handle "24H" preset click
|
||||
const handleTodayClick = useCallback(() => {
|
||||
const now = new Date();
|
||||
setDateRange({ from: subDays(now, 1), to: now });
|
||||
setViewMode('hourly');
|
||||
}, []);
|
||||
|
||||
// Handle date range changes from DateRangeFilter
|
||||
const handleDateRangeChange = useCallback((range: DateRange | undefined) => {
|
||||
setDateRange(range);
|
||||
setViewMode('daily'); // Switch back to daily view for multi-day ranges
|
||||
}, []);
|
||||
|
||||
// Format "Last updated" text
|
||||
const lastUpdatedText = useMemo(() => {
|
||||
if (!status?.lastFetch) return null;
|
||||
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
|
||||
}, [status?.lastFetch]);
|
||||
|
||||
// Handle model click for popover
|
||||
const handleModelClick = useCallback((model: ModelUsage, event: React.MouseEvent) => {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setPopoverPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
|
||||
setSelectedModel(model);
|
||||
}, []);
|
||||
|
||||
const handlePopoverClose = useCallback(() => {
|
||||
setSelectedModel(null);
|
||||
setPopoverPosition(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
dateRange,
|
||||
isRefreshing,
|
||||
viewMode,
|
||||
selectedModel,
|
||||
popoverPosition,
|
||||
// Data
|
||||
summary,
|
||||
trends,
|
||||
hourlyData,
|
||||
models,
|
||||
sessions,
|
||||
status,
|
||||
// Loading states
|
||||
isSummaryLoading,
|
||||
isTrendsLoading,
|
||||
isHourlyLoading,
|
||||
isModelsLoading,
|
||||
isSessionsLoading,
|
||||
// Combined loading
|
||||
isLoading: isSummaryLoading || isTrendsLoading || isModelsLoading || isSessionsLoading,
|
||||
// Handlers
|
||||
handleRefresh,
|
||||
handleTodayClick,
|
||||
handleDateRangeChange,
|
||||
handleModelClick,
|
||||
handlePopoverClose,
|
||||
// Text
|
||||
lastUpdatedText,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Analytics Page
|
||||
*
|
||||
* Displays Claude Code usage analytics with charts.
|
||||
* Features trend charts, model breakdown, cost analysis, and anomaly detection.
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover';
|
||||
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
||||
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||
import { useAnalyticsPage } from './hooks';
|
||||
import { AnalyticsHeader } from './components/analytics-header';
|
||||
import { ChartsGrid } from './components/charts-grid';
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
dateRange,
|
||||
handleDateRangeChange,
|
||||
handleTodayClick,
|
||||
handleRefresh,
|
||||
isRefreshing,
|
||||
lastUpdatedText,
|
||||
viewMode,
|
||||
summary,
|
||||
isSummaryLoading,
|
||||
trends,
|
||||
hourlyData,
|
||||
models,
|
||||
sessions,
|
||||
isTrendsLoading,
|
||||
isHourlyLoading,
|
||||
isModelsLoading,
|
||||
isSessionsLoading,
|
||||
handleModelClick,
|
||||
selectedModel,
|
||||
popoverPosition,
|
||||
handlePopoverClose,
|
||||
} = useAnalyticsPage();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden px-4 pt-4 pb-50 gap-4">
|
||||
{/* Header */}
|
||||
<AnalyticsHeader
|
||||
dateRange={dateRange}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
onTodayClick={handleTodayClick}
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
lastUpdatedText={lastUpdatedText}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
|
||||
|
||||
{/* Charts Grid */}
|
||||
<ChartsGrid
|
||||
viewMode={viewMode}
|
||||
trends={trends}
|
||||
hourlyData={hourlyData}
|
||||
models={models}
|
||||
sessions={sessions}
|
||||
isTrendsLoading={isTrendsLoading}
|
||||
isHourlyLoading={isHourlyLoading}
|
||||
isModelsLoading={isModelsLoading}
|
||||
isSessionsLoading={isSessionsLoading}
|
||||
isSummaryLoading={isSummaryLoading}
|
||||
onModelClick={handleModelClick}
|
||||
/>
|
||||
|
||||
{/* Model Details Popover - positioned at cursor */}
|
||||
<Popover open={!!selectedModel} onOpenChange={(open) => !open && handlePopoverClose()}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
ref={popoverAnchorRef}
|
||||
className="fixed pointer-events-none"
|
||||
style={{
|
||||
left: popoverPosition?.x ?? 0,
|
||||
top: popoverPosition?.y ?? 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent className="w-80 p-3" side="top" align="center">
|
||||
{selectedModel && <ModelDetailsContent model={selectedModel} />}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export skeleton for route-level loading
|
||||
export { AnalyticsSkeleton } from './components/analytics-skeleton';
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Analytics Page Types
|
||||
*/
|
||||
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
import type { ModelUsage } from '@/hooks/use-usage';
|
||||
|
||||
export interface AnalyticsPageState {
|
||||
dateRange: DateRange | undefined;
|
||||
isRefreshing: boolean;
|
||||
selectedModel: ModelUsage | null;
|
||||
popoverPosition: { x: number; y: number } | null;
|
||||
viewMode: 'daily' | 'hourly';
|
||||
}
|
||||
|
||||
export interface DatePreset {
|
||||
label: string;
|
||||
range: DateRange;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Analytics Page Utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format token count to human-readable (K/M/B)
|
||||
*/
|
||||
export function formatTokens(num: number): string {
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user