diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 83ec23b6..8611f590 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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. --- diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 6d084f20..5eb5c87e 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -77,6 +77,13 @@ export async function startServer(options: ServerOptions): Promise((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 }); }); }); diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts index 6c8da9cb..dfc6e794 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -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>(); @@ -59,15 +70,38 @@ const pendingRequests = new Map>(); /** * 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(key: string, ttl: number, loader: () => Promise): Promise { - // Check cache first const cached = cache.get(key) as CacheEntry | 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 | undefined; if (pending) { return pending; @@ -77,6 +111,7 @@ async function getCachedData(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, + }, + }); +}); diff --git a/ui/src/components/analytics/model-breakdown-chart.tsx b/ui/src/components/analytics/model-breakdown-chart.tsx index 81fb9576..cfc02294 100644 --- a/ui/src/components/analytics/model-breakdown-chart.tsx +++ b/ui/src/components/analytics/model-breakdown-chart.tsx @@ -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 ( -
-

{data.name}

-

- Tokens: {formatNumber(data.value)} ({data.percentage.toFixed(1)}%) +

+

{data.name}

+

+ {formatNumber(data.value)} ({data.percentage.toFixed(1)}%)

-

Cost: ${data.cost.toFixed(4)}

-

Requests: {data.requests}

+

${data.cost.toFixed(4)}

); }; const renderLabel = (entry: { percentage: number }) => { - return `${entry.percentage.toFixed(1)}%`; + return entry.percentage > 5 ? `${entry.percentage.toFixed(1)}%` : ''; }; return (
- + {chartData.map((entry, index) => ( - + ))} - {value}} - /> + {/* Legend removed from here, moved to AnalyticsPage for better layout control */}
diff --git a/ui/src/components/analytics/sessions-table.tsx b/ui/src/components/analytics/sessions-table.tsx index 7385dab3..956ac4a6 100644 --- a/ui/src/components/analytics/sessions-table.tsx +++ b/ui/src/components/analytics/sessions-table.tsx @@ -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(() => { diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx index d76aad93..9be107c5 100644 --- a/ui/src/components/analytics/usage-summary-cards.tsx +++ b/ui/src/components/analytics/usage-summary-cards.tsx @@ -73,19 +73,19 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { ]; return ( -
+
{cards.map((card, index) => { const Icon = card.icon; return ( - -
-
-

{card.title}

-

{card.format(card.value)}

+ +
+
+

{card.title}

+

{card.format(card.value)}

-
- +
+
diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index 01e8bc7f..7bdb8343 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -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)), diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts index 8486e3d5..2a19800a 100644 --- a/ui/src/hooks/use-usage.ts +++ b/ui/src/hooks/use-usage.ts @@ -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('/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 + }); +} diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index bf98651d..410ae7fa 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -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 ; - } + // Format "Last updated" text + const lastUpdatedText = useMemo(() => { + if (!status?.lastFetch) return null; + return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true }); + }, [status?.lastFetch]); return ( -
- {/* Header */} -
-
-

Analytics

-

Track your Claude Code usage and insights

-
-
- - -
-
- - {/* Date Range Filter */} - - - {/* Summary Cards */} - - - {/* Main Content Tabs */} - setViewMode(v as ViewMode)}> - - - - Daily - - - - Monthly - - - - Sessions - - - - {/* Daily View */} - -
- {/* Usage Trend Chart */} - - - - - Usage Trends - - - - - - - - {/* Model Distribution */} - - - - - Model Usage - - - - - - - - {/* Cost Breakdown */} - - - Cost by Model - - - {isModelsLoading ? ( - - ) : ( -
- {models?.slice(0, 5).map((model) => ( -
-
-
- {model.model} -
- - ${model.cost.toFixed(4)} - -
- ))} -
- )} - - +
+
+ {/* Header */} +
+
+

Analytics

+

Track usage & insights

- +
+ + {lastUpdatedText && ( + + Updated {lastUpdatedText} + + )} + +
+
- {/* Monthly View */} - - - - - - Monthly Overview - - - - - - - + {/* Summary Cards */} + - {/* Sessions View */} - - - - - - Session History - - - - - - - - + {/* Main Content Tabs */} + setViewMode(v as ViewMode)} + className="flex-1 flex flex-col min-h-0" + > + + + Daily + + + Monthly + + + Sessions + + + +
+ {/* Daily View */} + + {/* Usage Trend Chart - Full Width */} + + + + + Usage Trends + + + + + + + + {/* Bottom Row - Model Usage & Cost */} +
+ {/* Model Distribution */} + + + + + Model Usage + + + +
+
+ +
+
+ {models?.slice(0, 8).map((model) => ( +
+
+
+
+ + {model.model} + + + {model.percentage.toFixed(1)}% + +
+
+
+ ))} +
+
+ + + + {/* Cost Breakdown */} + + + Cost by Model + + + {isModelsLoading ? ( + + ) : ( +
+ {[...(models || [])] + .sort((a, b) => b.cost - a.cost) + .map((model) => ( +
+
+
+ + {model.model} + +
+ + ${model.cost.toFixed(4)} + +
+ ))} +
+ )} + + +
+ + + {/* Monthly View */} + + + + + + Monthly Overview + + + + + + + + + {/* Sessions View */} + + + + + + Session History + + + +
+ +
+
+
+
+
+ +
); } @@ -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 ( -
- {/* Header */} -
- - -
+
+ {/* Usage Trends Skeleton */} + + + + + + + + - {/* Date Filter */} - - - {/* Summary Cards */} -
- {[1, 2, 3, 4].map((i) => ( - - - - - - - ))} -
- - {/* Charts */} -
- - - + {/* Bottom Row Skeletons */} +
+ {/* Model Usage Skeleton */} + + + - - + +
+
+ +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ + +
+ ))} +
+
- - - + + {/* Cost Breakdown Skeleton */} + + + - - + +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+ + +
+ +
+ ))} +