mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +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