Skip to content

Commit 69e6a32

Browse files
committed
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
1 parent 382150f commit 69e6a32

File tree

9 files changed

+387
-218
lines changed

9 files changed

+387
-218
lines changed

docs/project-roadmap.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ src/types/
440440
- Intelligent profile selection algorithms
441441
- Cost estimation with typed calculation models
442442
- Performance optimization through type-aware caching
443+
- Recently implemented significant UI improvements for analytics dashboard, enhancing data presentation.
443444

444445
2. **Enhanced Session Management**
445446
- Type-safe session persistence with serialization
@@ -584,21 +585,29 @@ src/types/
584585
- **User Experience**: One-command channel switching without data loss
585586
- **Backward Compatibility**: Zero breaking changes, existing workflows preserved
586587

587-
### Version 4.5.1 - UI Layout Improvements
588+
### Version 4.5.1 - UI Quality Gate Fixes & Layout Improvements
588589
**Release Date**: 2025-12-08
589590

590591
#### UI Fixes & Improvements
592+
-**Auto-formatting**: 31 UI files auto-formatted for consistent styling.
593+
-**Fast Refresh Exports**: Resolved `react-refresh/only-export-components` by extracting `buttonVariants`, `useSidebar`, and `useWebSocketContext` to separate files.
594+
-**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`).
595+
-**useWebSocket Hook Restructure**: Addressed `react-hooks/immutability` errors and dependency array warnings in `use-websocket.ts`.
596+
-**TypeScript Strict Mode**: Implemented null-check for `document.getElementById('root')` in `src/main.tsx` for strict mode compliance.
597+
-**Duplicate Directory Removal**: Cleaned up extraneous `ui/@/` directory.
591598
-**CLIProxy Card Padding**: Removed excessive padding from CLIProxy cards for better visual integration.
592-
-**CLIProxy Dashboard Layout**: Improved overall layout and styling of the CLIProxy dashboard for enhanced user experience.
593-
-**Dropdown Styling**: Refined dropdown component styling for consistency and readability.
599+
-**CLIProxy Dashboard Layout**: Improved overall layout and styling of the CLIProxy dashboard.
600+
-**Dropdown Styling**: Refined dropdown component styling.
601+
-**Model Usage Card**: Corrected icon display and refined donut chart styling.
594602

595603
#### Technical Improvements
596604
- **Improved UI Responsiveness**: Adjustments ensure better display across various screen sizes.
597605
- **Enhanced User Experience**: Minor visual tweaks lead to a more polished and intuitive interface.
598606

599607
#### Validation Results
600-
- **UI Rendering**: ✅ All UI components render correctly after layout adjustments.
608+
- **UI Rendering**: ✅ All UI components render correctly after adjustments and fixes.
601609
- **Functional Impact**: ✅ No regressions introduced, core functionality remains stable.
610+
- **Code Quality**: ✅ All ESLint and TypeScript quality gates passed after fixes.
602611

603612
---
604613

src/web-server/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
7777
// Start listening
7878
return new Promise<ServerInstance>((resolve) => {
7979
server.listen(options.port, () => {
80+
// Non-blocking prewarm: load usage cache in background
81+
import('./usage-routes').then(({ prewarmUsageCache }) => {
82+
prewarmUsageCache().catch(() => {
83+
// Error already logged in prewarmUsageCache
84+
});
85+
});
86+
8087
resolve({ server, wss, cleanup });
8188
});
8289
});

src/web-server/usage-routes.ts

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ const CACHE_TTL = {
5050
session: 60 * 1000, // 1 minute - user may refresh
5151
};
5252

53+
// Stale-while-revalidate: max age for stale data (1 hour)
54+
const STALE_TTL = 60 * 60 * 1000;
55+
56+
// Track when data was last fetched (for UI indicator)
57+
let lastFetchTimestamp: number | null = null;
58+
59+
/** Get timestamp of last successful data fetch */
60+
export function getLastFetchTimestamp(): number | null {
61+
return lastFetchTimestamp;
62+
}
63+
5364
// In-memory cache
5465
const cache = new Map<string, CacheEntry<unknown>>();
5566

@@ -59,15 +70,38 @@ const pendingRequests = new Map<string, Promise<unknown>>();
5970
/**
6071
* Get cached data or fetch from loader with TTL
6172
* Also coalesces concurrent requests to prevent duplicate library calls
73+
* Implements stale-while-revalidate pattern for instant responses
6274
*/
6375
async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> {
64-
// Check cache first
6576
const cached = cache.get(key) as CacheEntry<T> | undefined;
66-
if (cached && Date.now() - cached.timestamp < ttl) {
77+
const now = Date.now();
78+
79+
// Fresh cache - return immediately
80+
if (cached && now - cached.timestamp < ttl) {
6781
return cached.data;
6882
}
6983

70-
// Check if request is already pending (coalesce)
84+
// Stale cache - return immediately, refresh in background (SWR pattern)
85+
if (cached && now - cached.timestamp < STALE_TTL) {
86+
// Fire and forget background refresh if not already pending
87+
if (!pendingRequests.has(key)) {
88+
const promise = loader()
89+
.then((data) => {
90+
cache.set(key, { data, timestamp: Date.now() });
91+
lastFetchTimestamp = Date.now();
92+
})
93+
.catch((err) => {
94+
console.error(`[!] Background refresh failed for ${key}:`, err);
95+
})
96+
.finally(() => {
97+
pendingRequests.delete(key);
98+
});
99+
pendingRequests.set(key, promise);
100+
}
101+
return cached.data;
102+
}
103+
104+
// No usable cache - check if request is already pending (coalesce)
71105
const pending = pendingRequests.get(key) as Promise<T> | undefined;
72106
if (pending) {
73107
return pending;
@@ -77,6 +111,7 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
77111
const promise = loader()
78112
.then((data) => {
79113
cache.set(key, { data, timestamp: Date.now() });
114+
lastFetchTimestamp = Date.now();
80115
return data;
81116
})
82117
.finally(() => {
@@ -115,6 +150,28 @@ export function clearUsageCache(): void {
115150
cache.clear();
116151
}
117152

153+
/**
154+
* Pre-warm usage caches on server startup
155+
* Loads all usage data into cache so first user request is instant
156+
* Returns timestamp when cache was populated
157+
*/
158+
export async function prewarmUsageCache(): Promise<{ timestamp: number; elapsed: number }> {
159+
const start = Date.now();
160+
console.log('[i] Pre-warming usage cache...');
161+
162+
try {
163+
await Promise.all([getCachedDailyData(), getCachedMonthlyData(), getCachedSessionData()]);
164+
165+
const elapsed = Date.now() - start;
166+
lastFetchTimestamp = Date.now();
167+
console.log(`[OK] Usage cache ready (${elapsed}ms)`);
168+
return { timestamp: lastFetchTimestamp, elapsed };
169+
} catch (err) {
170+
console.error('[!] Failed to prewarm usage cache:', err);
171+
throw err;
172+
}
173+
}
174+
118175
// ============================================================================
119176
// Validation Helpers
120177
// ============================================================================
@@ -494,3 +551,19 @@ usageRoutes.post('/refresh', (_req: Request, res: Response) => {
494551
message: 'Usage cache cleared',
495552
});
496553
});
554+
555+
/**
556+
* GET /api/usage/status
557+
*
558+
* Returns cache status including last fetch timestamp.
559+
* Used by UI to show "Last updated: X ago" indicator.
560+
*/
561+
usageRoutes.get('/status', (_req: Request, res: Response) => {
562+
res.json({
563+
success: true,
564+
data: {
565+
lastFetch: lastFetchTimestamp,
566+
cacheSize: cache.size,
567+
},
568+
});
569+
});

ui/src/components/analytics/model-breakdown-chart.tsx

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import { useMemo } from 'react';
9-
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
9+
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
1010
import { Skeleton } from '@/components/ui/skeleton';
1111
import type { ModelUsage } from '@/hooks/use-usage';
1212
import { cn } from '@/lib/utils';
@@ -66,45 +66,41 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
6666

6767
const data = payloadArray[0].payload;
6868
return (
69-
<div className="rounded-lg border bg-background p-3 shadow-lg">
70-
<p className="font-medium mb-2">{data.name}</p>
71-
<p className="text-sm text-muted-foreground">
72-
Tokens: {formatNumber(data.value)} ({data.percentage.toFixed(1)}%)
69+
<div className="rounded-lg border bg-background p-2 shadow-lg text-xs">
70+
<p className="font-medium mb-1">{data.name}</p>
71+
<p className="text-muted-foreground">
72+
{formatNumber(data.value)} ({data.percentage.toFixed(1)}%)
7373
</p>
74-
<p className="text-sm text-muted-foreground">Cost: ${data.cost.toFixed(4)}</p>
75-
<p className="text-sm text-muted-foreground">Requests: {data.requests}</p>
74+
<p className="text-muted-foreground">${data.cost.toFixed(4)}</p>
7675
</div>
7776
);
7877
};
7978

8079
const renderLabel = (entry: { percentage: number }) => {
81-
return `${entry.percentage.toFixed(1)}%`;
80+
return entry.percentage > 5 ? `${entry.percentage.toFixed(1)}%` : '';
8281
};
8382

8483
return (
8584
<div className={cn('w-full', className)}>
86-
<ResponsiveContainer width="100%" height={300}>
85+
<ResponsiveContainer width="100%" height={250}>
8786
<PieChart>
8887
<Pie
8988
data={chartData}
9089
cx="50%"
9190
cy="50%"
9291
labelLine={false}
9392
label={renderLabel}
94-
outerRadius={100}
95-
fill="#8884d8"
93+
innerRadius={60}
94+
outerRadius={80}
95+
paddingAngle={2}
9696
dataKey="value"
9797
>
9898
{chartData.map((entry, index) => (
99-
<Cell key={`cell-${index}`} fill={entry.fill} />
99+
<Cell key={`cell-${index}`} fill={entry.fill} strokeWidth={1} />
100100
))}
101101
</Pie>
102102
<Tooltip content={renderTooltip} />
103-
<Legend
104-
verticalAlign="bottom"
105-
height={36}
106-
formatter={(value) => <span className="text-sm">{value}</span>}
107-
/>
103+
{/* Legend removed from here, moved to AnalyticsPage for better layout control */}
108104
</PieChart>
109105
</ResponsiveContainer>
110106
</div>

ui/src/components/analytics/sessions-table.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function SessionsTable({ data, isLoading }: SessionsTableProps) {
3333
const [currentPage, setCurrentPage] = useState(0);
3434

3535
// Get sessions array (stable reference for memoization)
36-
const sessions = data?.sessions ?? [];
36+
const sessions = useMemo(() => data?.sessions ?? [], [data?.sessions]);
3737

3838
// Filter sessions based on search term
3939
const filteredSessions = useMemo(() => {

ui/src/components/analytics/usage-summary-cards.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,19 +73,19 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
7373
];
7474

7575
return (
76-
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
76+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
7777
{cards.map((card, index) => {
7878
const Icon = card.icon;
7979
return (
8080
<Card key={index} className="hover:shadow-md transition-shadow">
81-
<CardContent className="p-6">
82-
<div className="flex items-center justify-between">
83-
<div className="space-y-1">
84-
<p className="text-sm font-medium text-muted-foreground">{card.title}</p>
85-
<p className="text-2xl font-bold">{card.format(card.value)}</p>
81+
<CardContent className="p-4">
82+
<div className="flex items-center justify-between space-x-2">
83+
<div className="space-y-1 min-w-0">
84+
<p className="text-xs font-medium text-muted-foreground truncate">{card.title}</p>
85+
<p className="text-xl font-bold truncate">{card.format(card.value)}</p>
8686
</div>
87-
<div className={cn('p-2 rounded-lg', card.bgColor)}>
88-
<Icon className={cn('h-5 w-5', card.color)} />
87+
<div className={cn('p-2 rounded-lg shrink-0', card.bgColor)}>
88+
<Icon className={cn('h-4 w-4', card.color)} />
8989
</div>
9090
</div>
9191
</CardContent>

ui/src/components/analytics/usage-trend-chart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function UsageTrendChart({
3838
const chartData = useMemo(() => {
3939
if (!data || data.length === 0) return [];
4040

41-
return data.map((item) => ({
41+
return [...data].reverse().map((item) => ({
4242
...item,
4343
dateFormatted: formatDate(item.date, granularity),
4444
costRounded: Number(item.cost.toFixed(4)),

ui/src/hooks/use-usage.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ export interface UsageQueryOptions {
6565
offset?: number;
6666
}
6767

68+
export interface UsageStatus {
69+
lastFetch: number | null;
70+
cacheSize: number;
71+
}
72+
6873
// API
6974
const BASE_URL = '/api';
7075

@@ -125,6 +130,8 @@ export const usageApi = {
125130
throw new Error('Failed to refresh usage cache');
126131
}
127132
},
133+
/** Get cache status including last fetch timestamp */
134+
status: () => request<UsageStatus>('/usage/status'),
128135
};
129136

130137
// Helper function to match existing API client pattern
@@ -200,3 +207,16 @@ export function useRefreshUsage() {
200207

201208
return refresh;
202209
}
210+
211+
/**
212+
* Hook to get usage cache status
213+
* Returns last fetch timestamp for "Last updated" UI indicator
214+
*/
215+
export function useUsageStatus() {
216+
return useQuery({
217+
queryKey: ['usage', 'status'],
218+
queryFn: () => usageApi.status(),
219+
staleTime: 10 * 1000, // 10 seconds - poll frequently for updates
220+
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
221+
});
222+
}

0 commit comments

Comments
 (0)