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
- Cost estimation with typed calculation models
- Performance optimization through type-aware caching
- Recently implemented significant UI improvements for analytics dashboard, enhancing data presentation.
2. **Enhanced Session Management**
- Type-safe session persistence with serialization
@@ -584,21 +585,29 @@ src/types/
- **User Experience**: One-command channel switching without data loss
- **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
#### 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 Dashboard Layout**: Improved overall layout and styling of the CLIProxy dashboard for enhanced user experience.
-**Dropdown Styling**: Refined dropdown component styling for consistency and readability.
-**CLIProxy Dashboard Layout**: Improved overall layout and styling of the CLIProxy dashboard.
-**Dropdown Styling**: Refined dropdown component styling.
-**Model Usage Card**: Corrected icon display and refined donut chart styling.
#### Technical Improvements
- **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.
#### 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.
- **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
return new Promise<ServerInstance>((resolve) => {
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 });
});
});
+76 -3
View File
@@ -50,6 +50,17 @@ const CACHE_TTL = {
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
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
* 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> {
// Check cache first
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;
}
// 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;
if (pending) {
return pending;
@@ -77,6 +111,7 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
const promise = loader()
.then((data) => {
cache.set(key, { data, timestamp: Date.now() });
lastFetchTimestamp = Date.now();
return data;
})
.finally(() => {
@@ -115,6 +150,28 @@ export function clearUsageCache(): void {
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
// ============================================================================
@@ -494,3 +551,19 @@ usageRoutes.post('/refresh', (_req: Request, res: Response) => {
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 { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { Skeleton } from '@/components/ui/skeleton';
import type { ModelUsage } from '@/hooks/use-usage';
import { cn } from '@/lib/utils';
@@ -66,24 +66,23 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
const data = payloadArray[0].payload;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="font-medium mb-2">{data.name}</p>
<p className="text-sm text-muted-foreground">
Tokens: {formatNumber(data.value)} ({data.percentage.toFixed(1)}%)
<div className="rounded-lg border bg-background p-2 shadow-lg text-xs">
<p className="font-medium mb-1">{data.name}</p>
<p className="text-muted-foreground">
{formatNumber(data.value)} ({data.percentage.toFixed(1)}%)
</p>
<p className="text-sm text-muted-foreground">Cost: ${data.cost.toFixed(4)}</p>
<p className="text-sm text-muted-foreground">Requests: {data.requests}</p>
<p className="text-muted-foreground">${data.cost.toFixed(4)}</p>
</div>
);
};
const renderLabel = (entry: { percentage: number }) => {
return `${entry.percentage.toFixed(1)}%`;
return entry.percentage > 5 ? `${entry.percentage.toFixed(1)}%` : '';
};
return (
<div className={cn('w-full', className)}>
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={chartData}
@@ -91,20 +90,17 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
cy="50%"
labelLine={false}
label={renderLabel}
outerRadius={100}
fill="#8884d8"
innerRadius={60}
outerRadius={80}
paddingAngle={2}
dataKey="value"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
<Cell key={`cell-${index}`} fill={entry.fill} strokeWidth={1} />
))}
</Pie>
<Tooltip content={renderTooltip} />
<Legend
verticalAlign="bottom"
height={36}
formatter={(value) => <span className="text-sm">{value}</span>}
/>
{/* Legend removed from here, moved to AnalyticsPage for better layout control */}
</PieChart>
</ResponsiveContainer>
</div>
@@ -33,7 +33,7 @@ export function SessionsTable({ data, isLoading }: SessionsTableProps) {
const [currentPage, setCurrentPage] = useState(0);
// Get sessions array (stable reference for memoization)
const sessions = data?.sessions ?? [];
const sessions = useMemo(() => data?.sessions ?? [], [data?.sessions]);
// Filter sessions based on search term
const filteredSessions = useMemo(() => {
@@ -73,19 +73,19 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
];
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) => {
const Icon = card.icon;
return (
<Card key={index} className="hover:shadow-md transition-shadow">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{card.title}</p>
<p className="text-2xl font-bold">{card.format(card.value)}</p>
<CardContent className="p-4">
<div className="flex items-center justify-between space-x-2">
<div className="space-y-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground truncate">{card.title}</p>
<p className="text-xl font-bold truncate">{card.format(card.value)}</p>
</div>
<div className={cn('p-2 rounded-lg', card.bgColor)}>
<Icon className={cn('h-5 w-5', card.color)} />
<div className={cn('p-2 rounded-lg shrink-0', card.bgColor)}>
<Icon className={cn('h-4 w-4', card.color)} />
</div>
</div>
</CardContent>
@@ -38,7 +38,7 @@ export function UsageTrendChart({
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.map((item) => ({
return [...data].reverse().map((item) => ({
...item,
dateFormatted: formatDate(item.date, granularity),
costRounded: Number(item.cost.toFixed(4)),
+20
View File
@@ -65,6 +65,11 @@ export interface UsageQueryOptions {
offset?: number;
}
export interface UsageStatus {
lastFetch: number | null;
cacheSize: number;
}
// API
const BASE_URL = '/api';
@@ -125,6 +130,8 @@ export const usageApi = {
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
@@ -200,3 +207,16 @@ export function useRefreshUsage() {
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.
*/
import { useState } from 'react';
import { useState, useMemo } from 'react';
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
import { SessionsTable } from '@/components/analytics/sessions-table';
import { TrendingUp, BarChart3, Clock, Calendar, Download, RefreshCw } from 'lucide-react';
import { TrendingUp, PieChart, Clock, Calendar, RefreshCw } from 'lucide-react';
import {
useUsageSummary,
useUsageTrends,
useModelUsage,
useSessions,
useRefreshUsage,
useUsageStatus,
} from '@/hooks/use-usage';
type ViewMode = 'daily' | 'monthly' | 'sessions';
@@ -63,164 +64,214 @@ export function AnalyticsPage() {
...apiOptions,
limit: 50,
});
const { data: status } = useUsageStatus();
// Loading state
if (isSummaryLoading || isTrendsLoading || isModelsLoading) {
return <AnalyticsSkeleton />;
}
// Format "Last updated" text
const lastUpdatedText = useMemo(() => {
if (!status?.lastFetch) return null;
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
}, [status?.lastFetch]);
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Analytics</h1>
<p className="text-muted-foreground">Track your Claude Code usage and insights</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-2">
<Download className="w-4 h-4" />
Export
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
</div>
{/* Date Range Filter */}
<DateRangeFilter
value={dateRange}
onChange={setDateRange}
presets={[
{ label: 'Last 7 days', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: 'Last 30 days', range: { from: subDays(new Date(), 30), to: new Date() } },
{ label: 'This month', range: { from: startOfMonth(new Date()), to: new Date() } },
{ label: 'Last 3 months', range: { from: subDays(new Date(), 90), to: new Date() } },
]}
/>
{/* Summary Cards */}
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Main Content Tabs */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="daily" className="gap-2">
<TrendingUp className="w-4 h-4" />
Daily
</TabsTrigger>
<TabsTrigger value="monthly" className="gap-2">
<BarChart3 className="w-4 h-4" />
Monthly
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-2">
<Clock className="w-4 h-4" />
Sessions
</TabsTrigger>
</TabsList>
{/* Daily View */}
<TabsContent value="daily" className="space-y-6">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Usage Trend Chart */}
<Card className="xl:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
Usage Trends
</CardTitle>
</CardHeader>
<CardContent>
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} />
</CardContent>
</Card>
{/* Model Distribution */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
Model Usage
</CardTitle>
</CardHeader>
<CardContent>
<ModelBreakdownChart data={models || []} isLoading={isModelsLoading} />
</CardContent>
</Card>
{/* Cost Breakdown */}
<Card>
<CardHeader>
<CardTitle>Cost by Model</CardTitle>
</CardHeader>
<CardContent>
{isModelsLoading ? (
<Skeleton className="h-[200px]" />
) : (
<div className="space-y-3">
{models?.slice(0, 5).map((model) => (
<div key={model.model} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<span className="text-sm font-medium">{model.model}</span>
</div>
<span className="text-sm text-muted-foreground">
${model.cost.toFixed(4)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="flex flex-col h-[calc(100vh-3rem)] overflow-hidden">
<div className="flex-1 flex flex-col min-h-0 overflow-hidden p-4 pb-14 space-y-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>
</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 */}
<TabsContent value="monthly">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
Monthly Overview
</CardTitle>
</CardHeader>
<CardContent>
<UsageTrendChart
data={trends || []}
isLoading={isTrendsLoading}
granularity="monthly"
/>
</CardContent>
</Card>
</TabsContent>
{/* Summary Cards */}
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Sessions View */}
<TabsContent value="sessions">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
Session History
</CardTitle>
</CardHeader>
<CardContent>
<SessionsTable data={sessions} isLoading={isSessionsLoading} />
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 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>
<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>
);
}
@@ -248,47 +299,60 @@ function getModelColor(model: string): string {
return colors[Math.abs(hash) % colors.length];
}
// Skeleton loading state
function AnalyticsSkeleton() {
export function AnalyticsSkeleton() {
return (
<div className="p-6 space-y-6">
{/* Header */}
<div>
<Skeleton className="h-8 w-[120px] mb-2" />
<Skeleton className="h-4 w-[300px]" />
</div>
<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>
{/* Date Filter */}
<Skeleton className="h-10 w-[300px]" />
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<Skeleton className="h-4 w-[100px] mb-2" />
<Skeleton className="h-8 w-[60px]" />
</CardContent>
</Card>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card className="xl:col-span-2">
<CardHeader>
<Skeleton className="h-6 w-[120px]" />
{/* Bottom Row Skeletons */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 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>
<Skeleton className="h-[300px]" />
<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>
<Card>
<CardHeader>
<Skeleton className="h-6 w-[120px]" />
{/* 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>
<Skeleton className="h-[200px]" />
<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>
</div>