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