mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +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
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user