mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
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:
+13
-4
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user