diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 5eb5c87e..e9c4ba3a 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -77,13 +77,8 @@ 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 - }); - }); - + // 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 }); }); }); diff --git a/src/web-server/usage-disk-cache.ts b/src/web-server/usage-disk-cache.ts new file mode 100644 index 00000000..a1e69e2b --- /dev/null +++ b/src/web-server/usage-disk-cache.ts @@ -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); + } +} diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts index dfc6e794..61329b6e 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -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>(); // Pending requests for coalescing (prevents duplicate concurrent calls) const pendingRequests = new Map>(); +// 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 | undefined; + const monthly = cache.get('monthly') as CacheEntry | undefined; + const session = cache.get('session') as CacheEntry | 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(key: string, ttl: number, loader: () => Promise): Promise { + // Ensure disk cache is loaded on first request + ensureDiskCacheLoaded(); + const cached = cache.get(key) as CacheEntry | undefined; const now = Date.now(); @@ -89,6 +122,8 @@ async function getCachedData(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(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 { */ 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, + loadMonthlyUsageData() as Promise, + loadSessionData() as Promise, + ]); + + // 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; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 02a0f239..c9ab07be 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 ( +
+ + +
+ ); +} function Layout() { return ( @@ -29,7 +50,9 @@ function Layout() { - + }> + +