perf(analytics): instant dashboard loading with disk cache persistence

- Add React lazy loading for heavy pages (Analytics, Settings, etc.)
- Remove blocking prewarmUsageCache() from server startup
- Add disk cache module for persistent usage data storage
- Fix disk cache not being created on first Analytics visit
- Write cache immediately when daily data available (don't wait for all 3 types)

Dashboard now loads in <10ms from disk cache instead of waiting for
better-ccusage library on every visit.
This commit is contained in:
kaitranntt
2025-12-09 01:07:42 -05:00
parent 23e66c2eb9
commit abb156d9f4
4 changed files with 342 additions and 27 deletions
+2 -7
View File
@@ -77,13 +77,8 @@ 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
});
});
// Usage cache loads on-demand when Analytics page is visited
// This keeps server startup instant for users who don't need analytics
resolve({ server, wss, cleanup });
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* Persistent Disk Cache for Usage Data
*
* Caches aggregated usage data to disk to avoid re-parsing 6000+ JSONL files
* on every dashboard startup. Uses TTL-based invalidation with stale-while-revalidate.
*
* Cache location: ~/.ccs/usage-cache.json
* Default TTL: 5 minutes (configurable)
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import type { DailyUsage, MonthlyUsage, SessionUsage } from 'better-ccusage/data-loader';
// Cache configuration
const CCS_DIR = path.join(os.homedir(), '.ccs');
const CACHE_FILE = path.join(CCS_DIR, 'usage-cache.json');
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max age for stale data)
/** Structure of the disk cache file */
export interface UsageDiskCache {
version: number;
timestamp: number;
daily: DailyUsage[];
monthly: MonthlyUsage[];
session: SessionUsage[];
}
// Current cache version - increment to invalidate old caches
const CACHE_VERSION = 1;
/**
* Ensure ~/.ccs directory exists
*/
function ensureCcsDir(): void {
if (!fs.existsSync(CCS_DIR)) {
fs.mkdirSync(CCS_DIR, { recursive: true });
}
}
/**
* Read usage data from disk cache
* Returns null if cache is missing, corrupted, or has incompatible version
* NOTE: Does NOT reject based on age - caller handles staleness via SWR pattern
*/
export function readDiskCache(): UsageDiskCache | null {
try {
if (!fs.existsSync(CACHE_FILE)) {
return null;
}
const data = fs.readFileSync(CACHE_FILE, 'utf-8');
const cache: UsageDiskCache = JSON.parse(data);
// Version check - invalidate if schema changed
if (cache.version !== CACHE_VERSION) {
console.log('[i] Cache version mismatch, will refresh');
return null;
}
// Always return cache regardless of age - SWR pattern handles staleness
return cache;
} catch (err) {
// Cache corrupted or unreadable - treat as miss
console.log('[i] Cache read failed, will refresh:', (err as Error).message);
return null;
}
}
/**
* Check if disk cache is fresh (within TTL)
*/
export function isDiskCacheFresh(cache: UsageDiskCache | null): boolean {
if (!cache) return false;
const age = Date.now() - cache.timestamp;
return age < CACHE_TTL_MS;
}
/**
* Check if disk cache is stale but usable (between TTL and STALE_TTL)
*/
export function isDiskCacheStale(cache: UsageDiskCache | null): boolean {
if (!cache) return false;
const age = Date.now() - cache.timestamp;
return age >= CACHE_TTL_MS && age < STALE_TTL_MS;
}
/**
* Write usage data to disk cache
*/
export function writeDiskCache(
daily: DailyUsage[],
monthly: MonthlyUsage[],
session: SessionUsage[]
): void {
try {
ensureCcsDir();
const cache: UsageDiskCache = {
version: CACHE_VERSION,
timestamp: Date.now(),
daily,
monthly,
session,
};
// Write atomically using temp file + rename
const tempFile = CACHE_FILE + '.tmp';
fs.writeFileSync(tempFile, JSON.stringify(cache), 'utf-8');
fs.renameSync(tempFile, CACHE_FILE);
console.log('[OK] Disk cache updated');
} catch (err) {
// Non-fatal - we can still serve from memory
console.log('[!] Failed to write disk cache:', (err as Error).message);
}
}
/**
* Get cache age in human-readable format
*/
export function getCacheAge(cache: UsageDiskCache | null): string {
if (!cache) return 'never';
const age = Date.now() - cache.timestamp;
const seconds = Math.floor(age / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ${minutes % 60}m ago`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s ago`;
return `${seconds}s ago`;
}
/**
* Delete disk cache (for manual refresh)
*/
export function clearDiskCache(): void {
try {
if (fs.existsSync(CACHE_FILE)) {
fs.unlinkSync(CACHE_FILE);
console.log('[OK] Disk cache cleared');
}
} catch (err) {
console.log('[!] Failed to clear disk cache:', (err as Error).message);
}
}
+157 -9
View File
@@ -5,8 +5,10 @@
* Supports daily, monthly, and session-based usage data aggregation.
*
* Performance optimizations:
* - TTL-based caching to reduce better-ccusage library calls
* - Persistent disk cache to avoid re-parsing JSONL files on startup
* - TTL-based in-memory caching for fast repeated requests
* - Request coalescing to prevent duplicate concurrent requests
* - Non-blocking prewarm with instant stale data serving
*/
import { Router, Request, Response } from 'express';
@@ -18,6 +20,14 @@ import {
type MonthlyUsage,
type SessionUsage,
} from 'better-ccusage/data-loader';
import {
readDiskCache,
writeDiskCache,
isDiskCacheFresh,
isDiskCacheStale,
clearDiskCache,
getCacheAge,
} from './usage-disk-cache';
export const usageRoutes = Router();
@@ -50,8 +60,9 @@ 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;
/// Stale-while-revalidate: max age for stale data (7 days)
// We always show cached data to avoid blocking UI, refresh happens in background
const STALE_TTL = 7 * 24 * 60 * 60 * 1000;
// Track when data was last fetched (for UI indicator)
let lastFetchTimestamp: number | null = null;
@@ -67,12 +78,34 @@ const cache = new Map<string, CacheEntry<unknown>>();
// Pending requests for coalescing (prevents duplicate concurrent calls)
const pendingRequests = new Map<string, Promise<unknown>>();
// Track if disk cache has been loaded into memory
let diskCacheInitialized = false;
/**
* Persist cache to disk when we have enough data to be useful.
* Writes immediately with whatever data is available (empty arrays for missing).
* This ensures disk cache is created after first Analytics page visit.
*/
function persistCacheIfComplete(): void {
const daily = cache.get('daily') as CacheEntry<DailyUsage[]> | undefined;
const monthly = cache.get('monthly') as CacheEntry<MonthlyUsage[]> | undefined;
const session = cache.get('session') as CacheEntry<SessionUsage[]> | undefined;
// Write if we have at least daily data (the most essential)
if (daily) {
writeDiskCache(daily.data, monthly?.data ?? [], session?.data ?? []);
}
}
/**
* 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> {
// Ensure disk cache is loaded on first request
ensureDiskCacheLoaded();
const cached = cache.get(key) as CacheEntry<T> | undefined;
const now = Date.now();
@@ -89,6 +122,8 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
.then((data) => {
cache.set(key, { data, timestamp: Date.now() });
lastFetchTimestamp = Date.now();
// Persist to disk if all data types are cached
persistCacheIfComplete();
})
.catch((err) => {
console.error(`[!] Background refresh failed for ${key}:`, err);
@@ -112,6 +147,8 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
.then((data) => {
cache.set(key, { data, timestamp: Date.now() });
lastFetchTimestamp = Date.now();
// Persist to disk if all data types are cached
persistCacheIfComplete();
return data;
})
.finally(() => {
@@ -148,24 +185,135 @@ async function getCachedSessionData(): Promise<SessionUsage[]> {
*/
export function clearUsageCache(): void {
cache.clear();
clearDiskCache();
// Reset so next API call will try to reload from disk/source
diskCacheInitialized = false;
}
// Track if background refresh is in progress
let isRefreshing = false;
/**
* Load fresh data from better-ccusage and update both memory and disk caches
*/
async function refreshFromSource(): Promise<{
daily: DailyUsage[];
monthly: MonthlyUsage[];
session: SessionUsage[];
}> {
const [daily, monthly, session] = await Promise.all([
loadDailyUsageData() as Promise<DailyUsage[]>,
loadMonthlyUsageData() as Promise<MonthlyUsage[]>,
loadSessionData() as Promise<SessionUsage[]>,
]);
// Update in-memory cache
const now = Date.now();
cache.set('daily', { data: daily, timestamp: now });
cache.set('monthly', { data: monthly, timestamp: now });
cache.set('session', { data: session, timestamp: now });
lastFetchTimestamp = now;
// Persist to disk
writeDiskCache(daily, monthly, session);
return { daily, monthly, session };
}
// ============================================================================
// Module Initialization - Load disk cache immediately for instant API responses
// ============================================================================
/**
* Initialize in-memory cache from disk cache (lazy - called on first API request).
* This ensures first API request gets instant data without calling better-ccusage.
* Background refresh is NOT triggered here - it happens via SWR pattern in getCachedData().
*/
function ensureDiskCacheLoaded(): void {
if (diskCacheInitialized) return;
diskCacheInitialized = true;
const diskCache = readDiskCache();
if (!diskCache) return;
// Load disk cache into memory (regardless of freshness)
// SWR pattern in getCachedData() will handle background refresh
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
lastFetchTimestamp = diskCache.timestamp;
}
/**
* 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
*
* Strategy:
* 1. Check disk cache - if fresh, use it (instant startup)
* 2. If stale, use it immediately but trigger background refresh
* 3. If no cache, return immediately and let first request trigger load
*
* This ensures dashboard opens in <1s regardless of cache state
*/
export async function prewarmUsageCache(): Promise<{ timestamp: number; elapsed: number }> {
export async function prewarmUsageCache(): Promise<{
timestamp: number;
elapsed: number;
source: string;
}> {
const start = Date.now();
console.log('[i] Pre-warming usage cache...');
try {
await Promise.all([getCachedDailyData(), getCachedMonthlyData(), getCachedSessionData()]);
const diskCache = readDiskCache();
// Fresh disk cache - use it directly
if (diskCache && isDiskCacheFresh(diskCache)) {
const now = Date.now();
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
lastFetchTimestamp = diskCache.timestamp;
const elapsed = Date.now() - start;
console.log(
`[OK] Usage cache ready from disk (${elapsed}ms, cached ${getCacheAge(diskCache)})`
);
return { timestamp: now, elapsed, source: 'disk-fresh' };
}
// Stale disk cache - use it immediately, refresh in background
if (diskCache && isDiskCacheStale(diskCache)) {
const now = Date.now();
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
lastFetchTimestamp = diskCache.timestamp;
const elapsed = Date.now() - start;
console.log(
`[OK] Usage cache ready from disk (${elapsed}ms, stale ${getCacheAge(diskCache)}, refreshing...)`
);
// Background refresh
if (!isRefreshing) {
isRefreshing = true;
refreshFromSource()
.then(() => console.log('[OK] Background refresh complete'))
.catch((err) => console.error('[!] Background refresh failed:', err))
.finally(() => {
isRefreshing = false;
});
}
return { timestamp: now, elapsed, source: 'disk-stale' };
}
// No usable disk cache - refresh from source (blocking for first startup only)
console.log('[i] No disk cache, loading from source...');
await refreshFromSource();
const elapsed = Date.now() - start;
lastFetchTimestamp = Date.now();
console.log(`[OK] Usage cache ready (${elapsed}ms)`);
return { timestamp: lastFetchTimestamp, elapsed };
return { timestamp: Date.now(), elapsed, source: 'fresh' };
} catch (err) {
console.error('[!] Failed to prewarm usage cache:', err);
throw err;
+34 -11
View File
@@ -1,3 +1,4 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { SidebarProvider } from '@/components/ui/sidebar';
@@ -7,16 +8,36 @@ import { ConnectionIndicator } from '@/components/connection-indicator';
import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
import { Toaster } from 'sonner';
import { queryClient } from '@/lib/query-client';
import {
HomePage,
ApiPage,
CliproxyPage,
AccountsPage,
SettingsPage,
HealthPage,
SharedPage,
AnalyticsPage,
} from '@/pages';
import { Skeleton } from '@/components/ui/skeleton';
// Eager load: HomePage (initial route)
import { HomePage } from '@/pages';
// Lazy load: heavy pages with charts or complex dependencies
const AnalyticsPage = lazy(() =>
import('@/pages/analytics').then((m) => ({ default: m.AnalyticsPage }))
);
const ApiPage = lazy(() => import('@/pages/api').then((m) => ({ default: m.ApiPage })));
const CliproxyPage = lazy(() =>
import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage }))
);
const AccountsPage = lazy(() =>
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
);
const SettingsPage = lazy(() =>
import('@/pages/settings').then((m) => ({ default: m.SettingsPage }))
);
const HealthPage = lazy(() => import('@/pages/health').then((m) => ({ default: m.HealthPage })));
const SharedPage = lazy(() => import('@/pages/shared').then((m) => ({ default: m.SharedPage })));
// Loading fallback for lazy-loaded pages
function PageLoader() {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
);
}
function Layout() {
return (
@@ -29,7 +50,9 @@ function Layout() {
<ThemeToggle />
</div>
</header>
<Outlet />
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
<LocalhostDisclaimer />
</main>
</SidebarProvider>