perf(analytics): add cache pre-warming and SWR pattern for instant page load

- Add server-side cache pre-warming on startup (non-blocking)
- Implement stale-while-revalidate pattern (1hr stale window)
- Add /api/usage/status endpoint for cache status
- Remove full-page blocking skeleton, use per-component loading
- Add "Updated X ago" timestamp indicator in UI header
- Export AnalyticsSkeleton component for potential future use
This commit is contained in:
kaitranntt
2025-12-08 23:09:01 -05:00
parent 382150f312
commit 69e6a32224
9 changed files with 393 additions and 224 deletions
+13 -4
View File
@@ -440,6 +440,7 @@ src/types/
- Intelligent profile selection algorithms - Intelligent profile selection algorithms
- Cost estimation with typed calculation models - Cost estimation with typed calculation models
- Performance optimization through type-aware caching - Performance optimization through type-aware caching
- Recently implemented significant UI improvements for analytics dashboard, enhancing data presentation.
2. **Enhanced Session Management** 2. **Enhanced Session Management**
- Type-safe session persistence with serialization - Type-safe session persistence with serialization
@@ -584,21 +585,29 @@ src/types/
- **User Experience**: One-command channel switching without data loss - **User Experience**: One-command channel switching without data loss
- **Backward Compatibility**: Zero breaking changes, existing workflows preserved - **Backward Compatibility**: Zero breaking changes, existing workflows preserved
### Version 4.5.1 - UI Layout Improvements ### Version 4.5.1 - UI Quality Gate Fixes & Layout Improvements
**Release Date**: 2025-12-08 **Release Date**: 2025-12-08
#### UI Fixes & Improvements #### UI Fixes & Improvements
-**Auto-formatting**: 31 UI files auto-formatted for consistent styling.
-**Fast Refresh Exports**: Resolved `react-refresh/only-export-components` by extracting `buttonVariants`, `useSidebar`, and `useWebSocketContext` to separate files.
-**React Hooks Issues**: Fixed `react-hooks/purity` (`Math.random()` in `useMemo` for `sidebar.tsx`) and `react-hooks/set-state-in-effect` (`use-theme.ts`, `settings.tsx`).
-**useWebSocket Hook Restructure**: Addressed `react-hooks/immutability` errors and dependency array warnings in `use-websocket.ts`.
-**TypeScript Strict Mode**: Implemented null-check for `document.getElementById('root')` in `src/main.tsx` for strict mode compliance.
-**Duplicate Directory Removal**: Cleaned up extraneous `ui/@/` directory.
-**CLIProxy Card Padding**: Removed excessive padding from CLIProxy cards for better visual integration. -**CLIProxy Card Padding**: Removed excessive padding from CLIProxy cards for better visual integration.
-**CLIProxy Dashboard Layout**: Improved overall layout and styling of the CLIProxy dashboard for enhanced user experience. -**CLIProxy Dashboard Layout**: Improved overall layout and styling of the CLIProxy dashboard.
-**Dropdown Styling**: Refined dropdown component styling for consistency and readability. -**Dropdown Styling**: Refined dropdown component styling.
-**Model Usage Card**: Corrected icon display and refined donut chart styling.
#### Technical Improvements #### Technical Improvements
- **Improved UI Responsiveness**: Adjustments ensure better display across various screen sizes. - **Improved UI Responsiveness**: Adjustments ensure better display across various screen sizes.
- **Enhanced User Experience**: Minor visual tweaks lead to a more polished and intuitive interface. - **Enhanced User Experience**: Minor visual tweaks lead to a more polished and intuitive interface.
#### Validation Results #### Validation Results
- **UI Rendering**: ✅ All UI components render correctly after layout adjustments. - **UI Rendering**: ✅ All UI components render correctly after adjustments and fixes.
- **Functional Impact**: ✅ No regressions introduced, core functionality remains stable. - **Functional Impact**: ✅ No regressions introduced, core functionality remains stable.
- **Code Quality**: ✅ All ESLint and TypeScript quality gates passed after fixes.
--- ---
+7
View File
@@ -77,6 +77,13 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// Start listening // Start listening
return new Promise<ServerInstance>((resolve) => { return new Promise<ServerInstance>((resolve) => {
server.listen(options.port, () => { server.listen(options.port, () => {
// Non-blocking prewarm: load usage cache in background
import('./usage-routes').then(({ prewarmUsageCache }) => {
prewarmUsageCache().catch(() => {
// Error already logged in prewarmUsageCache
});
});
resolve({ server, wss, cleanup }); resolve({ server, wss, cleanup });
}); });
}); });
+76 -3
View File
@@ -50,6 +50,17 @@ const CACHE_TTL = {
session: 60 * 1000, // 1 minute - user may refresh session: 60 * 1000, // 1 minute - user may refresh
}; };
// Stale-while-revalidate: max age for stale data (1 hour)
const STALE_TTL = 60 * 60 * 1000;
// Track when data was last fetched (for UI indicator)
let lastFetchTimestamp: number | null = null;
/** Get timestamp of last successful data fetch */
export function getLastFetchTimestamp(): number | null {
return lastFetchTimestamp;
}
// In-memory cache // In-memory cache
const cache = new Map<string, CacheEntry<unknown>>(); const cache = new Map<string, CacheEntry<unknown>>();
@@ -59,15 +70,38 @@ const pendingRequests = new Map<string, Promise<unknown>>();
/** /**
* Get cached data or fetch from loader with TTL * Get cached data or fetch from loader with TTL
* Also coalesces concurrent requests to prevent duplicate library calls * Also coalesces concurrent requests to prevent duplicate library calls
* Implements stale-while-revalidate pattern for instant responses
*/ */
async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> { async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> {
// Check cache first
const cached = cache.get(key) as CacheEntry<T> | undefined; const cached = cache.get(key) as CacheEntry<T> | undefined;
if (cached && Date.now() - cached.timestamp < ttl) { const now = Date.now();
// Fresh cache - return immediately
if (cached && now - cached.timestamp < ttl) {
return cached.data; return cached.data;
} }
// Check if request is already pending (coalesce) // Stale cache - return immediately, refresh in background (SWR pattern)
if (cached && now - cached.timestamp < STALE_TTL) {
// Fire and forget background refresh if not already pending
if (!pendingRequests.has(key)) {
const promise = loader()
.then((data) => {
cache.set(key, { data, timestamp: Date.now() });
lastFetchTimestamp = Date.now();
})
.catch((err) => {
console.error(`[!] Background refresh failed for ${key}:`, err);
})
.finally(() => {
pendingRequests.delete(key);
});
pendingRequests.set(key, promise);
}
return cached.data;
}
// No usable cache - check if request is already pending (coalesce)
const pending = pendingRequests.get(key) as Promise<T> | undefined; const pending = pendingRequests.get(key) as Promise<T> | undefined;
if (pending) { if (pending) {
return pending; return pending;
@@ -77,6 +111,7 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
const promise = loader() const promise = loader()
.then((data) => { .then((data) => {
cache.set(key, { data, timestamp: Date.now() }); cache.set(key, { data, timestamp: Date.now() });
lastFetchTimestamp = Date.now();
return data; return data;
}) })
.finally(() => { .finally(() => {
@@ -115,6 +150,28 @@ export function clearUsageCache(): void {
cache.clear(); cache.clear();
} }
/**
* Pre-warm usage caches on server startup
* Loads all usage data into cache so first user request is instant
* Returns timestamp when cache was populated
*/
export async function prewarmUsageCache(): Promise<{ timestamp: number; elapsed: number }> {
const start = Date.now();
console.log('[i] Pre-warming usage cache...');
try {
await Promise.all([getCachedDailyData(), getCachedMonthlyData(), getCachedSessionData()]);
const elapsed = Date.now() - start;
lastFetchTimestamp = Date.now();
console.log(`[OK] Usage cache ready (${elapsed}ms)`);
return { timestamp: lastFetchTimestamp, elapsed };
} catch (err) {
console.error('[!] Failed to prewarm usage cache:', err);
throw err;
}
}
// ============================================================================ // ============================================================================
// Validation Helpers // Validation Helpers
// ============================================================================ // ============================================================================
@@ -494,3 +551,19 @@ usageRoutes.post('/refresh', (_req: Request, res: Response) => {
message: 'Usage cache cleared', message: 'Usage cache cleared',
}); });
}); });
/**
* GET /api/usage/status
*
* Returns cache status including last fetch timestamp.
* Used by UI to show "Last updated: X ago" indicator.
*/
usageRoutes.get('/status', (_req: Request, res: Response) => {
res.json({
success: true,
data: {
lastFetch: lastFetchTimestamp,
cacheSize: cache.size,
},
});
});
@@ -6,7 +6,7 @@
*/ */
import { useMemo } from 'react'; import { useMemo } from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import type { ModelUsage } from '@/hooks/use-usage'; import type { ModelUsage } from '@/hooks/use-usage';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -66,24 +66,23 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
const data = payloadArray[0].payload; const data = payloadArray[0].payload;
return ( return (
<div className="rounded-lg border bg-background p-3 shadow-lg"> <div className="rounded-lg border bg-background p-2 shadow-lg text-xs">
<p className="font-medium mb-2">{data.name}</p> <p className="font-medium mb-1">{data.name}</p>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground">
Tokens: {formatNumber(data.value)} ({data.percentage.toFixed(1)}%) {formatNumber(data.value)} ({data.percentage.toFixed(1)}%)
</p> </p>
<p className="text-sm text-muted-foreground">Cost: ${data.cost.toFixed(4)}</p> <p className="text-muted-foreground">${data.cost.toFixed(4)}</p>
<p className="text-sm text-muted-foreground">Requests: {data.requests}</p>
</div> </div>
); );
}; };
const renderLabel = (entry: { percentage: number }) => { const renderLabel = (entry: { percentage: number }) => {
return `${entry.percentage.toFixed(1)}%`; return entry.percentage > 5 ? `${entry.percentage.toFixed(1)}%` : '';
}; };
return ( return (
<div className={cn('w-full', className)}> <div className={cn('w-full', className)}>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={250}>
<PieChart> <PieChart>
<Pie <Pie
data={chartData} data={chartData}
@@ -91,20 +90,17 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
cy="50%" cy="50%"
labelLine={false} labelLine={false}
label={renderLabel} label={renderLabel}
outerRadius={100} innerRadius={60}
fill="#8884d8" outerRadius={80}
paddingAngle={2}
dataKey="value" dataKey="value"
> >
{chartData.map((entry, index) => ( {chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} /> <Cell key={`cell-${index}`} fill={entry.fill} strokeWidth={1} />
))} ))}
</Pie> </Pie>
<Tooltip content={renderTooltip} /> <Tooltip content={renderTooltip} />
<Legend {/* Legend removed from here, moved to AnalyticsPage for better layout control */}
verticalAlign="bottom"
height={36}
formatter={(value) => <span className="text-sm">{value}</span>}
/>
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -33,7 +33,7 @@ export function SessionsTable({ data, isLoading }: SessionsTableProps) {
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
// Get sessions array (stable reference for memoization) // Get sessions array (stable reference for memoization)
const sessions = data?.sessions ?? []; const sessions = useMemo(() => data?.sessions ?? [], [data?.sessions]);
// Filter sessions based on search term // Filter sessions based on search term
const filteredSessions = useMemo(() => { const filteredSessions = useMemo(() => {
@@ -73,19 +73,19 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
]; ];
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{cards.map((card, index) => { {cards.map((card, index) => {
const Icon = card.icon; const Icon = card.icon;
return ( return (
<Card key={index} className="hover:shadow-md transition-shadow"> <Card key={index} className="hover:shadow-md transition-shadow">
<CardContent className="p-6"> <CardContent className="p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between space-x-2">
<div className="space-y-1"> <div className="space-y-1 min-w-0">
<p className="text-sm font-medium text-muted-foreground">{card.title}</p> <p className="text-xs font-medium text-muted-foreground truncate">{card.title}</p>
<p className="text-2xl font-bold">{card.format(card.value)}</p> <p className="text-xl font-bold truncate">{card.format(card.value)}</p>
</div> </div>
<div className={cn('p-2 rounded-lg', card.bgColor)}> <div className={cn('p-2 rounded-lg shrink-0', card.bgColor)}>
<Icon className={cn('h-5 w-5', card.color)} /> <Icon className={cn('h-4 w-4', card.color)} />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -38,7 +38,7 @@ export function UsageTrendChart({
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (!data || data.length === 0) return []; if (!data || data.length === 0) return [];
return data.map((item) => ({ return [...data].reverse().map((item) => ({
...item, ...item,
dateFormatted: formatDate(item.date, granularity), dateFormatted: formatDate(item.date, granularity),
costRounded: Number(item.cost.toFixed(4)), costRounded: Number(item.cost.toFixed(4)),
+20
View File
@@ -65,6 +65,11 @@ export interface UsageQueryOptions {
offset?: number; offset?: number;
} }
export interface UsageStatus {
lastFetch: number | null;
cacheSize: number;
}
// API // API
const BASE_URL = '/api'; const BASE_URL = '/api';
@@ -125,6 +130,8 @@ export const usageApi = {
throw new Error('Failed to refresh usage cache'); throw new Error('Failed to refresh usage cache');
} }
}, },
/** Get cache status including last fetch timestamp */
status: () => request<UsageStatus>('/usage/status'),
}; };
// Helper function to match existing API client pattern // Helper function to match existing API client pattern
@@ -200,3 +207,16 @@ export function useRefreshUsage() {
return refresh; return refresh;
} }
/**
* Hook to get usage cache status
* Returns last fetch timestamp for "Last updated" UI indicator
*/
export function useUsageStatus() {
return useQuery({
queryKey: ['usage', 'status'],
queryFn: () => usageApi.status(),
staleTime: 10 * 1000, // 10 seconds - poll frequently for updates
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
});
}
+254 -190
View File
@@ -5,9 +5,9 @@
* Features daily/monthly views, trend charts, model breakdown, and session history. * Features daily/monthly views, trend charts, model breakdown, and session history.
*/ */
import { useState } from 'react'; import { useState, useMemo } from 'react';
import type { DateRange } from 'react-day-picker'; import type { DateRange } from 'react-day-picker';
import { startOfMonth, subDays } from 'date-fns'; import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -17,13 +17,14 @@ import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart'; import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart'; import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
import { SessionsTable } from '@/components/analytics/sessions-table'; import { SessionsTable } from '@/components/analytics/sessions-table';
import { TrendingUp, BarChart3, Clock, Calendar, Download, RefreshCw } from 'lucide-react'; import { TrendingUp, PieChart, Clock, Calendar, RefreshCw } from 'lucide-react';
import { import {
useUsageSummary, useUsageSummary,
useUsageTrends, useUsageTrends,
useModelUsage, useModelUsage,
useSessions, useSessions,
useRefreshUsage, useRefreshUsage,
useUsageStatus,
} from '@/hooks/use-usage'; } from '@/hooks/use-usage';
type ViewMode = 'daily' | 'monthly' | 'sessions'; type ViewMode = 'daily' | 'monthly' | 'sessions';
@@ -63,164 +64,214 @@ export function AnalyticsPage() {
...apiOptions, ...apiOptions,
limit: 50, limit: 50,
}); });
const { data: status } = useUsageStatus();
// Loading state // Format "Last updated" text
if (isSummaryLoading || isTrendsLoading || isModelsLoading) { const lastUpdatedText = useMemo(() => {
return <AnalyticsSkeleton />; if (!status?.lastFetch) return null;
} return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
}, [status?.lastFetch]);
return ( return (
<div className="p-6 space-y-6"> <div className="flex flex-col h-[calc(100vh-3rem)] overflow-hidden">
{/* Header */} <div className="flex-1 flex flex-col min-h-0 overflow-hidden p-4 pb-14 space-y-4">
<div className="flex items-center justify-between"> {/* Header */}
<div> <div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-semibold">Analytics</h1> <div>
<p className="text-muted-foreground">Track your Claude Code usage and insights</p> <h1 className="text-xl font-semibold">Analytics</h1>
</div> <p className="text-sm text-muted-foreground">Track usage & insights</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-2">
<Download className="w-4 h-4" />
Export
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
</div>
{/* Date Range Filter */}
<DateRangeFilter
value={dateRange}
onChange={setDateRange}
presets={[
{ label: 'Last 7 days', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: 'Last 30 days', range: { from: subDays(new Date(), 30), to: new Date() } },
{ label: 'This month', range: { from: startOfMonth(new Date()), to: new Date() } },
{ label: 'Last 3 months', range: { from: subDays(new Date(), 90), to: new Date() } },
]}
/>
{/* Summary Cards */}
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Main Content Tabs */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="daily" className="gap-2">
<TrendingUp className="w-4 h-4" />
Daily
</TabsTrigger>
<TabsTrigger value="monthly" className="gap-2">
<BarChart3 className="w-4 h-4" />
Monthly
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-2">
<Clock className="w-4 h-4" />
Sessions
</TabsTrigger>
</TabsList>
{/* Daily View */}
<TabsContent value="daily" className="space-y-6">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Usage Trend Chart */}
<Card className="xl:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
Usage Trends
</CardTitle>
</CardHeader>
<CardContent>
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} />
</CardContent>
</Card>
{/* Model Distribution */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
Model Usage
</CardTitle>
</CardHeader>
<CardContent>
<ModelBreakdownChart data={models || []} isLoading={isModelsLoading} />
</CardContent>
</Card>
{/* Cost Breakdown */}
<Card>
<CardHeader>
<CardTitle>Cost by Model</CardTitle>
</CardHeader>
<CardContent>
{isModelsLoading ? (
<Skeleton className="h-[200px]" />
) : (
<div className="space-y-3">
{models?.slice(0, 5).map((model) => (
<div key={model.model} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<span className="text-sm font-medium">{model.model}</span>
</div>
<span className="text-sm text-muted-foreground">
${model.cost.toFixed(4)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div> </div>
</TabsContent> <div className="flex items-center gap-2">
<DateRangeFilter
value={dateRange}
onChange={setDateRange}
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() } },
]}
/>
{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>
{/* Monthly View */} {/* Summary Cards */}
<TabsContent value="monthly"> <UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
Monthly Overview
</CardTitle>
</CardHeader>
<CardContent>
<UsageTrendChart
data={trends || []}
isLoading={isTrendsLoading}
granularity="monthly"
/>
</CardContent>
</Card>
</TabsContent>
{/* Sessions View */} {/* Main Content Tabs */}
<TabsContent value="sessions"> <Tabs
<Card> value={viewMode}
<CardHeader> onValueChange={(v) => setViewMode(v as ViewMode)}
<CardTitle className="flex items-center gap-2"> className="flex-1 flex flex-col min-h-0"
<Clock className="w-5 h-5" /> >
Session History <TabsList className="grid w-full grid-cols-3 h-9 mb-4">
</CardTitle> <TabsTrigger value="daily" className="text-xs">
</CardHeader> Daily
<CardContent> </TabsTrigger>
<SessionsTable data={sessions} isLoading={isSessionsLoading} /> <TabsTrigger value="monthly" className="text-xs">
</CardContent> Monthly
</Card> </TabsTrigger>
</TabsContent> <TabsTrigger value="sessions" className="text-xs">
</Tabs> Sessions
</TabsTrigger>
</TabsList>
<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"
/>
</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>
</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 truncate max-w-[150px]"
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} />
</div>
</CardContent>
</Card>
</TabsContent>
</div>
</Tabs>
</div>
</div> </div>
); );
} }
@@ -248,47 +299,60 @@ function getModelColor(model: string): string {
return colors[Math.abs(hash) % colors.length]; return colors[Math.abs(hash) % colors.length];
} }
// Skeleton loading state export function AnalyticsSkeleton() {
function AnalyticsSkeleton() {
return ( return (
<div className="p-6 space-y-6"> <div className="space-y-4 h-full overflow-hidden">
{/* Header */} {/* Usage Trends Skeleton */}
<div> <Card className="flex flex-col min-h-[300px]">
<Skeleton className="h-8 w-[120px] mb-2" /> <CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-[300px]" /> <Skeleton className="h-4 w-32" />
</div> </CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<Skeleton className="h-full w-full" />
</CardContent>
</Card>
{/* Date Filter */} {/* Bottom Row Skeletons */}
<Skeleton className="h-10 w-[300px]" /> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Model Usage Skeleton */}
{/* Summary Cards */} <Card className="flex flex-col min-h-[250px]">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"> <CardHeader className="p-4 pb-2">
{[1, 2, 3, 4].map((i) => ( <Skeleton className="h-4 w-28" />
<Card key={i}>
<CardContent className="p-6">
<Skeleton className="h-4 w-[100px] mb-2" />
<Skeleton className="h-8 w-[60px]" />
</CardContent>
</Card>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card className="xl:col-span-2">
<CardHeader>
<Skeleton className="h-6 w-[120px]" />
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4 pt-0 flex-1">
<Skeleton className="h-[300px]" /> <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> </CardContent>
</Card> </Card>
<Card>
<CardHeader> {/* Cost Breakdown Skeleton */}
<Skeleton className="h-6 w-[120px]" /> <Card className="flex flex-col min-h-[250px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-28" />
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4 pt-2">
<Skeleton className="h-[200px]" /> <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> </CardContent>
</Card> </Card>
</div> </div>