refactor(analytics): inline sessions table component

This commit is contained in:
kaitranntt committed 2025-12-09 22:46:10 -05:00
1 parent 1ea67cc13f
commit 9506327343
3 files changed
+90 -446

No files matched your search

@@ -1,269 +0,0 @@
/**
* 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 = useMemo(() => data?.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>
);
}
-16
View File
@@ -175,22 +175,6 @@ export function useModelUsage(options?: UsageQueryOptions) {
});
}
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
+90 -161
View File
@@ -1,14 +1,13 @@
/**
* Analytics Page
*
* Displays Claude Code usage analytics with charts and tables.
* Features daily/monthly views, trend charts, model breakdown, and session history.
* Displays Claude Code usage analytics with charts.
* Features trend charts, model breakdown, and cost analysis.
*/
import { useState, useMemo } from 'react';
import type { DateRange } from 'react-day-picker';
import { startOfMonth, subDays, formatDistanceToNow } 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';
@@ -16,27 +15,22 @@ 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, PieChart, Clock, Calendar, RefreshCw } from 'lucide-react';
import { TrendingUp, PieChart, RefreshCw } from 'lucide-react';
import {
useUsageSummary,
useUsageTrends,
useModelUsage,
useSessions,
useRefreshUsage,
useUsageStatus,
} from '@/hooks/use-usage';
import { getModelColor } from '@/lib/utils';
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
@@ -61,10 +55,6 @@ export function AnalyticsPage() {
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,
});
const { data: status } = useUsageStatus();
// Format "Last updated" text
@@ -90,6 +80,7 @@ export function AnalyticsPage() {
{ 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 && (
@@ -112,163 +103,101 @@ export function AnalyticsPage() {
{/* Summary Cards */}
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Main Content Tabs */}
<Tabs
value={viewMode}
onValueChange={(v) => setViewMode(v as ViewMode)}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="grid w-full grid-cols-3 h-9 mb-4">
<TabsTrigger value="daily" className="text-xs">
Daily
</TabsTrigger>
<TabsTrigger value="monthly" className="text-xs">
Monthly
</TabsTrigger>
<TabsTrigger value="sessions" className="text-xs">
Sessions
</TabsTrigger>
</TabsList>
{/* 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 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Usage Trends
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} className="h-full" />
</CardContent>
</Card>
<div className="flex-1 min-h-0">
{/* Daily View */}
<TabsContent value="daily" className="flex flex-col gap-4 m-0 h-full overflow-hidden">
{/* Usage Trend Chart - Full Width */}
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Usage Trends
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
<UsageTrendChart
data={trends || []}
isLoading={isTrendsLoading}
className="h-full"
/>
</CardContent>
</Card>
{/* Bottom Row - Model Usage & Cost */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0">
{/* Model Distribution */}
<Card className="flex flex-col h-full min-h-0 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<PieChart className="w-4 h-4" />
Model Usage
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
<div className="flex w-full h-full items-center">
<div className="flex-1 h-full min-w-0">
<ModelBreakdownChart
data={models || []}
isLoading={isModelsLoading}
className="h-full"
{/* Bottom Row - Model Usage & Cost */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0">
{/* Model Distribution */}
<Card className="flex flex-col h-full min-h-0 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<PieChart className="w-4 h-4" />
Model Usage
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
<div className="flex w-full h-full items-center">
<div className="flex-1 h-full min-w-0">
<ModelBreakdownChart
data={models || []}
isLoading={isModelsLoading}
className="h-full"
/>
</div>
<div className="w-[220px] shrink-0 pl-4 space-y-2 overflow-y-auto max-h-full">
{models?.slice(0, 8).map((model) => (
<div key={model.model} className="flex items-center gap-2 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex justify-between items-baseline gap-2">
<span className="font-medium truncate" title={model.model}>
{model.model}
</span>
<span className="text-[10px] text-muted-foreground shrink-0">
{model.percentage.toFixed(1)}%
</span>
</div>
</div>
</div>
<div className="w-[220px] shrink-0 pl-4 space-y-2 overflow-y-auto max-h-full">
{models?.slice(0, 8).map((model) => (
<div key={model.model} className="flex items-center gap-2 text-xs">
))}
</div>
</div>
</CardContent>
</Card>
{/* Cost Breakdown */}
<Card className="flex flex-col h-full min-h-0 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium">Cost by Model</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 flex-1 min-h-0 overflow-y-auto">
{isModelsLoading ? (
<Skeleton className="h-full w-full" />
) : (
<div className="space-y-2">
{[...(models || [])]
.sort((a, b) => b.cost - a.cost)
.map((model) => (
<div
key={model.model}
className="flex items-center justify-between text-xs border-b border-border/50 pb-2 last:border-0 last:pb-0"
>
<div className="flex items-center gap-2 min-w-0">
<div
className="w-2 h-2 rounded-full shrink-0"
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex justify-between items-baseline gap-2">
<span className="font-medium truncate" title={model.model}>
{model.model}
</span>
<span className="text-[10px] text-muted-foreground shrink-0">
{model.percentage.toFixed(1)}%
</span>
</div>
</div>
<span className="font-medium" title={model.model}>
{model.model}
</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Cost Breakdown */}
<Card className="flex flex-col h-full min-h-0 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium">Cost by Model</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 flex-1 min-h-0 overflow-y-auto">
{isModelsLoading ? (
<Skeleton className="h-full w-full" />
) : (
<div className="space-y-2">
{[...(models || [])]
.sort((a, b) => b.cost - a.cost)
.map((model) => (
<div
key={model.model}
className="flex items-center justify-between text-xs border-b border-border/50 pb-2 last:border-0 last:pb-0"
>
<div className="flex items-center gap-2 min-w-0">
<div
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<span className="font-medium" title={model.model}>
{model.model}
</span>
</div>
<span className="text-muted-foreground whitespace-nowrap font-mono">
${model.cost.toFixed(4)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Monthly View */}
<TabsContent value="monthly" className="m-0 h-full">
<Card className="h-full flex flex-col">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Calendar className="w-4 h-4" />
Monthly Overview
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<UsageTrendChart
data={trends || []}
isLoading={isTrendsLoading}
granularity="monthly"
className="h-full"
/>
</CardContent>
</Card>
</TabsContent>
{/* Sessions View */}
<TabsContent value="sessions" className="m-0 h-full">
<Card className="h-full flex flex-col">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Clock className="w-4 h-4" />
Session History
</CardTitle>
</CardHeader>
<CardContent className="p-0 flex-1 overflow-hidden">
<div className="h-full overflow-y-auto">
<SessionsTable data={sessions} isLoading={isSessionsLoading} />
<span className="text-muted-foreground whitespace-nowrap font-mono">
${model.cost.toFixed(4)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
)}
</CardContent>
</Card>
</div>
</Tabs>
</div>
</div>
</div>
);