From d64115f91a7005f2c4ff09a63831da2aac050ba2 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 15:33:22 -0500 Subject: [PATCH] feat(analytics): add 24H hourly chart with caching and UI improvements - Add GitHub link button next to connection status for quick issue reporting - Add 24H button on analytics page with hourly granularity chart - Add /api/usage/hourly endpoint with date range filtering - Add hourly data aggregation and caching (disk + memory) - Fix timezone display: convert UTC hours to local time in chart - Fix CLIProxy Stats card loading state synchronization - Bump disk cache version to 3 (includes hourly data) --- src/cliproxy/auth-handler.ts | 4 +- src/cliproxy/cliproxy-executor.ts | 37 +++- src/cliproxy/config-generator.ts | 4 +- src/management/doctor.ts | 138 +++++++++++--- src/utils/error-manager.ts | 16 +- src/utils/platform-commands.ts | 180 ++++++++++++++++++ src/web-server/data-aggregator.ts | 111 +++++++++++ src/web-server/usage-disk-cache.ts | 9 +- src/web-server/usage-routes.ts | 123 +++++++++++- src/web-server/usage-types.ts | 14 ++ ui/src/App.tsx | 4 +- .../analytics/cliproxy-stats-card.tsx | 8 +- .../analytics/usage-trend-chart.tsx | 61 ++++-- ui/src/components/github-link.tsx | 20 ++ ui/src/components/quick-setup-wizard.tsx | 4 +- ui/src/hooks/use-usage.ts | 25 +++ ui/src/pages/analytics.tsx | 36 +++- 17 files changed, 725 insertions(+), 69 deletions(-) create mode 100644 src/utils/platform-commands.ts create mode 100644 ui/src/components/github-link.tsx diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index 32567662..6a3a5d80 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -20,6 +20,7 @@ import { ok, fail, info, warn } from '../utils/ui'; import { ensureCLIProxyBinary } from './binary-manager'; import { generateConfig, getProviderAuthDir } from './config-generator'; import { CLIProxyProvider } from './types'; +import { getKillCLIProxyCommand } from '../utils/platform-commands'; import { AccountInfo, discoverExistingAccounts, @@ -636,7 +637,8 @@ export async function triggerOAuth( console.error(' Try: ccs qwen --auth --verbose'); } else { console.error(' The OAuth flow may have been cancelled or callback port was in use'); - console.error(` Try: pkill -f cli-proxy-api && ccs ${provider} --auth`); + console.error(` Try: ${getKillCLIProxyCommand()} && ccs ${provider} --auth`); + console.error(' Or run: ccs doctor --fix'); } resolve(null); } diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 744a66a0..a14ac2c8 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -37,6 +37,8 @@ import { renameAccount, getDefaultAccount, } from './account-manager'; +import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands'; +import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -283,7 +285,33 @@ export async function execClaudeWithCLIProxy( const configPath = generateConfig(provider, cfg.port); log(`Config written: ${configPath}`); - // 6. Spawn CLIProxyAPI binary + // 6a. Pre-flight check: ensure port is free (auto-kill zombie CLIProxy) + const portProcess = await getPortProcess(cfg.port); + if (portProcess) { + if (isCLIProxyProcess(portProcess)) { + // Zombie CLIProxy from previous run - auto-kill it + log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); + const killed = killProcessOnPort(cfg.port, verbose); + if (killed) { + console.log(info(`Cleaned up zombie CLIProxy process`)); + // Wait a bit for port to be released + await new Promise((r) => setTimeout(r, 500)); + } + } else { + // Non-CLIProxy process blocking the port - warn user + console.error(''); + console.error( + warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`) + ); + console.error(''); + console.error('To fix this, close the blocking application or run:'); + console.error(` ${getPortCheckCommand(cfg.port)}`); + console.error(''); + throw new Error(`Port ${cfg.port} is in use by another application`); + } + } + + // 6b. Spawn CLIProxyAPI binary const proxyArgs = ['--config', configPath]; log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); @@ -308,7 +336,7 @@ export async function execClaudeWithCLIProxy( console.error(fail(`CLIProxy spawn error: ${error.message}`)); }); - // 5. Wait for proxy readiness via TCP polling + // 7. Wait for proxy readiness via TCP polling const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); readySpinner.start(); @@ -329,9 +357,10 @@ export async function execClaudeWithCLIProxy( console.error(' 3. Invalid configuration'); console.error(''); console.error('Troubleshooting:'); - console.error(` - Check if port ${cfg.port} is in use: lsof -i :${cfg.port}`); + console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`); console.error(' - Run with --verbose for detailed logs'); - console.error(` - Check config: cat ${configPath}`); + console.error(` - View config: ${getCatCommand(configPath)}`); + console.error(' - Try: ccs doctor --fix'); console.error(''); throw new Error(`CLIProxy startup failed: ${err.message}`); diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 92e70e1e..6c0e0a51 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -122,6 +122,8 @@ export function getBinDir(): string { */ function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string { const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories + // Convert Windows backslashes to forward slashes for YAML compatibility + const authDirNormalized = authDir.split(path.sep).join('/'); // Unified config with enhanced CLIProxyAPI features const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} @@ -184,7 +186,7 @@ api-keys: - "${CCS_INTERNAL_API_KEY}" # OAuth tokens directory (auto-discovered by CLIProxyAPI) -auth-dir: "${authDir.replace(/\\\\/g, '/')}" +auth-dir: "${authDirNormalized}" `; return config; diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 1844f8f3..c26c7073 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -24,6 +24,7 @@ import { import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import { getEnvironmentDiagnostics } from './environment-diagnostics'; import { checkAuthCodePorts } from './oauth-port-diagnostics'; +import { killProcessOnPort, getPlatformName } from '../utils/platform-commands'; // Make ora optional (might not be available during npm install postinstall) // ora v9+ is an ES module, need to use .default for CommonJS @@ -1117,46 +1118,135 @@ class Doctor { } /** - * Fix detected issues - * Currently fixes: shared symlinks broken by Claude CLI's atomic writes + * Fix detected issues (--fix flag) + * Fixes: + * 1. Zombie CLIProxy processes blocking ports + * 2. Outdated CLIProxy config files + * 3. Shared symlinks broken by Claude CLI's atomic writes + * 4. OAuth callback ports blocked by CLIProxy */ async fixIssues(): Promise { console.log(''); - console.log(header('ATTEMPTING FIXES')); + console.log(header('AUTO-FIX MODE')); + console.log(''); + console.log(info(`Platform: ${getPlatformName()}`)); console.log(''); let fixed = 0; - // Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.) - const sharedSettings = path.join(this.homedir, '.ccs', 'shared', 'settings.json'); - if (fs.existsSync(sharedSettings)) { + // Fix 1: Kill zombie CLIProxy processes + const zombieSpinner = ora('Checking for zombie CLIProxy processes').start(); + try { + // Check main CLIProxy port + const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); + if (portProcess && isCLIProxyProcess(portProcess)) { + zombieSpinner.text = 'Killing zombie CLIProxy process...'; + const killed = killProcessOnPort(CLIPROXY_DEFAULT_PORT, true); + if (killed) { + zombieSpinner.succeed( + `${ok('Fixed')} Killed zombie CLIProxy on port ${CLIPROXY_DEFAULT_PORT}` + ); + fixed++; + } else { + zombieSpinner.warn(`${warn('Partial')} CLIProxy detected but could not kill`); + } + } else if (portProcess) { + zombieSpinner.info( + `${info('Info')} Port ${CLIPROXY_DEFAULT_PORT} used by ${portProcess.processName} (not CLIProxy)` + ); + } else { + zombieSpinner.succeed(`${ok('OK')} No zombie CLIProxy processes found`); + } + } catch (err) { + zombieSpinner.fail(`${fail('Error')} Could not check processes: ${(err as Error).message}`); + } + + // Fix 2: Kill CLIProxy processes on OAuth callback ports + const oauthPorts = [8085, 1455, 51121]; // Gemini, Codex, Agy + for (const port of oauthPorts) { + const oauthSpinner = ora(`Checking OAuth port ${port}`).start(); try { - const stats = fs.lstatSync(sharedSettings); - if (!stats.isSymbolicLink()) { - const spinner = ora('Fixing shared settings.json symlink').start(); - try { - // Import and use SharedManager to fix symlinks - const SharedManagerModule = await import('./shared-manager'); - const SharedManager = SharedManagerModule.default; - const sharedManager = new SharedManager(); - sharedManager.ensureSharedDirectories(); - spinner.succeed(ok('Fixed') + ' Shared settings.json symlink restored'); + const portProcess = await getPortProcess(port); + if (portProcess && isCLIProxyProcess(portProcess)) { + oauthSpinner.text = `Freeing OAuth port ${port}...`; + const killed = killProcessOnPort(port, true); + if (killed) { + oauthSpinner.succeed(`${ok('Fixed')} Freed OAuth port ${port}`); fixed++; - } catch (err) { - spinner.fail(fail('Failed') + ` Could not fix: ${(err as Error).message}`); + } else { + oauthSpinner.warn(`${warn('Partial')} CLIProxy on port ${port} but could not kill`); } + } else if (portProcess) { + oauthSpinner.info( + `${info('Info')} Port ${port} used by ${portProcess.processName} - please close manually` + ); + } else { + oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} is free`); } } catch (_err) { - // Ignore stat errors + oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} check passed`); } } - if (fixed > 0) { - console.log(''); - console.log(ok(`Fixed ${fixed} issue(s)`)); - } else { - console.log(info('No fixable issues detected')); + // Fix 3: Regenerate outdated CLIProxy config + const configSpinner = ora('Checking CLIProxy config version').start(); + try { + if (configNeedsRegeneration()) { + configSpinner.text = 'Upgrading CLIProxy config...'; + regenerateConfig(); + configSpinner.succeed( + `${ok('Fixed')} Upgraded CLIProxy config to v${CLIPROXY_CONFIG_VERSION}` + ); + fixed++; + } else { + configSpinner.succeed(`${ok('OK')} CLIProxy config is up to date`); + } + } catch (err) { + configSpinner.fail(`${fail('Error')} Could not upgrade config: ${(err as Error).message}`); } + + // Fix 4: Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.) + const symlinkSpinner = ora('Checking shared settings.json symlink').start(); + const sharedSettings = path.join(this.homedir, '.ccs', 'shared', 'settings.json'); + try { + if (fs.existsSync(sharedSettings)) { + const stats = fs.lstatSync(sharedSettings); + if (!stats.isSymbolicLink()) { + symlinkSpinner.text = 'Restoring shared settings.json symlink...'; + const SharedManagerModule = await import('./shared-manager'); + const SharedManager = SharedManagerModule.default; + const sharedManager = new SharedManager(); + sharedManager.ensureSharedDirectories(); + symlinkSpinner.succeed(`${ok('Fixed')} Restored shared settings.json symlink`); + fixed++; + } else { + symlinkSpinner.succeed(`${ok('OK')} Shared settings.json symlink is valid`); + } + } else { + symlinkSpinner.succeed(`${ok('OK')} Shared settings.json not yet created`); + } + } catch (err) { + symlinkSpinner.fail(`${fail('Error')} Could not fix symlink: ${(err as Error).message}`); + } + + // Summary + console.log(''); + if (fixed > 0) { + console.log(ok(`Auto-fix complete: ${fixed} issue(s) resolved`)); + console.log(''); + console.log(info('Try your command again. If issues persist, run:')); + console.log(` ${color('ccs doctor', 'command')} - for full diagnostics`); + } else { + console.log(ok('No issues found that needed fixing')); + console.log(''); + console.log(info('If you still have issues:')); + console.log(` 1. Run ${color('ccs doctor', 'command')} for diagnostics`); + console.log( + ` 2. Try ${color('ccs --auth --verbose', 'command')} for detailed logs` + ); + console.log(` 3. Restart your terminal/computer`); + } + console.log(''); } /** diff --git a/src/utils/error-manager.ts b/src/utils/error-manager.ts index 33380dd7..98bb4acf 100644 --- a/src/utils/error-manager.ts +++ b/src/utils/error-manager.ts @@ -1,5 +1,6 @@ import { initUI, header, color, dim, errorBox } from './ui'; import { ERROR_CODES, getErrorDocUrl, ErrorCode } from './error-codes'; +import { getPortCheckCommand, getKillPidCommand } from './platform-commands'; /** * Error types with structured messages (Legacy - kept for compatibility) @@ -214,6 +215,7 @@ export class ErrorManager { */ static async showPortConflict(port: number): Promise { await initUI(); + const isWindows = process.platform === 'win32'; console.error(''); console.error( @@ -224,13 +226,19 @@ export class ErrorManager { console.error(header('SOLUTIONS')); console.error(''); console.error(' 1. Find process using port:'); - console.error(` ${color(`lsof -i :${port}`, 'command')} (macOS/Linux)`); - console.error(` ${color(`netstat -ano | findstr ${port}`, 'command')} (Windows)`); + console.error(` ${color(getPortCheckCommand(port), 'command')}`); console.error(''); console.error(' 2. Kill the process:'); - console.error(` ${color(`lsof -ti:${port} | xargs kill -9`, 'command')}`); + if (isWindows) { + console.error( + ` ${color(`taskkill /F /PID `, 'command')} (replace with actual ID)` + ); + } else { + console.error(` ${color(getKillPidCommand(12345).replace('12345', ''), 'command')}`); + } console.error(''); - console.error(' 3. Wait and retry (process may exit on its own)'); + console.error(' 3. Auto-fix: Run:'); + console.error(` ${color('ccs doctor --fix', 'command')}`); console.error(''); } diff --git a/src/utils/platform-commands.ts b/src/utils/platform-commands.ts new file mode 100644 index 00000000..98672a64 --- /dev/null +++ b/src/utils/platform-commands.ts @@ -0,0 +1,180 @@ +/** + * Platform-aware command suggestions and utilities + * + * Provides OS-specific commands for troubleshooting messages + * to help non-technical users on Windows, macOS, and Linux. + */ + +import { execSync } from 'child_process'; + +const isWindows = process.platform === 'win32'; +const isMac = process.platform === 'darwin'; + +/** + * Get platform-specific command to check what's using a port + */ +export function getPortCheckCommand(port: number): string { + if (isWindows) { + return `netstat -ano | findstr :${port}`; + } + return `lsof -i :${port}`; +} + +/** + * Get platform-specific command to view file contents + */ +export function getCatCommand(filePath: string): string { + if (isWindows) { + // Use type for CMD, Get-Content for PowerShell + return `type "${filePath}"`; + } + return `cat "${filePath}"`; +} + +/** + * Get platform-specific command to kill CLIProxy processes + */ +export function getKillCLIProxyCommand(): string { + if (isWindows) { + return 'taskkill /F /IM cli-proxy-api.exe'; + } + return 'pkill -f cli-proxy-api'; +} + +/** + * Get platform-specific command to kill a process by PID + */ +export function getKillPidCommand(pid: number): string { + if (isWindows) { + return `taskkill /F /PID ${pid}`; + } + return `kill -9 ${pid}`; +} + +/** + * Get human-friendly platform name + */ +export function getPlatformName(): string { + if (isWindows) return 'Windows'; + if (isMac) return 'macOS'; + return 'Linux'; +} + +/** + * Kill process by PID (cross-platform) + * @returns true if killed successfully, false otherwise + */ +export function killProcessByPid(pid: number, verbose = false): boolean { + try { + if (isWindows) { + execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' }); + } else { + execSync(`kill -9 ${pid}`, { stdio: 'pipe' }); + } + if (verbose) { + console.error(`[cleanup] Killed process ${pid}`); + } + return true; + } catch { + return false; + } +} + +/** + * Kill all CLIProxy processes (cross-platform) + * @returns number of processes killed + */ +export function killAllCLIProxyProcesses(verbose = false): number { + let killed = 0; + + try { + if (isWindows) { + // Windows: taskkill by image name + // Use /T to kill child processes too + execSync('taskkill /F /IM cli-proxy-api.exe /T 2>nul', { stdio: 'pipe' }); + killed++; + } else { + // Unix: pkill with pattern matching + try { + execSync('pkill -9 -f cli-proxy-api', { stdio: 'pipe' }); + killed++; + } catch { + // pkill returns non-zero if no processes matched - that's OK + } + } + } catch { + // No processes to kill or command failed + } + + if (verbose && killed > 0) { + console.error(`[cleanup] Killed ${killed} CLIProxy process(es)`); + } + + return killed; +} + +/** + * Kill process on specific port (cross-platform) + * @returns true if a process was killed, false otherwise + */ +export function killProcessOnPort(port: number, verbose = false): boolean { + try { + if (isWindows) { + // Windows: netstat + taskkill + const result = execSync(`netstat -ano | findstr :${port}`, { + encoding: 'utf-8', + stdio: 'pipe', + }); + const lines = result.trim().split('\n'); + let killed = false; + for (const line of lines) { + const parts = line.trim().split(/\s+/); + const pid = parts[parts.length - 1]; + if (pid && /^\d+$/.test(pid)) { + try { + execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' }); + if (verbose) { + console.error(`[cleanup] Killed process ${pid} on port ${port}`); + } + killed = true; + } catch { + // Process may have already exited + } + } + } + return killed; + } else { + // Unix: lsof + kill + const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: 'pipe' }); + const pids = result + .trim() + .split('\n') + .filter((p) => p); + for (const pid of pids) { + try { + execSync(`kill -9 ${pid}`, { stdio: 'pipe' }); + if (verbose) { + console.error(`[cleanup] Killed process ${pid} on port ${port}`); + } + } catch { + // Process may have already exited + } + } + return pids.length > 0; + } + } catch { + // No process on port or command failed + return false; + } +} + +export default { + getPortCheckCommand, + getCatCommand, + getKillCLIProxyCommand, + getKillPidCommand, + getPlatformName, + killProcessByPid, + killAllCLIProxyProcesses, + killProcessOnPort, +}; diff --git a/src/web-server/data-aggregator.ts b/src/web-server/data-aggregator.ts index 6d558615..0ebd882f 100644 --- a/src/web-server/data-aggregator.ts +++ b/src/web-server/data-aggregator.ts @@ -10,6 +10,7 @@ import { calculateCost } from './model-pricing'; import { type ModelBreakdown, type DailyUsage, + type HourlyUsage, type MonthlyUsage, type SessionUsage, } from './usage-types'; @@ -28,6 +29,13 @@ function extractMonth(timestamp: string): string { return timestamp.slice(0, 7); } +/** Extract YYYY-MM-DD HH:00 from ISO timestamp */ +function extractHour(timestamp: string): string { + const date = timestamp.slice(0, 10); + const hour = timestamp.slice(11, 13) || '00'; + return `${date} ${hour}:00`; +} + /** Create model breakdown from accumulated data */ function createModelBreakdown( modelName: string, @@ -152,6 +160,99 @@ export function aggregateDailyUsage( return dailyUsage; } +// ============================================================================ +// HOURLY AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into hourly usage summaries + * Groups by hour (YYYY-MM-DD HH:00), calculates costs per model + */ +export function aggregateHourlyUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): HourlyUsage[] { + // Group entries by hour + const byHour = new Map(); + + for (const entry of entries) { + const hour = extractHour(entry.timestamp); + const existing = byHour.get(hour) || []; + existing.push(entry); + byHour.set(hour, existing); + } + + // Build hourly summaries + const hourlyUsage: HourlyUsage[] = []; + + for (const [hour, hourEntries] of byHour) { + // Aggregate by model + const modelMap = new Map(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + + for (const entry of hourEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + hourlyUsage.push({ + hour, + source, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + cost: totalCost, + totalCost, + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + }); + } + + // Sort by hour descending (most recent first) + hourlyUsage.sort((a, b) => b.hour.localeCompare(a.hour)); + + return hourlyUsage; +} + // ============================================================================ // MONTHLY AGGREGATION // ============================================================================ @@ -372,6 +473,14 @@ export async function loadDailyUsageData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateHourlyUsage(entries); +} + /** * Load monthly usage data (replaces better-ccusage loadMonthlyUsageData) */ @@ -393,12 +502,14 @@ export async function loadSessionData(options?: ParserOptions): Promise { const entries = await scanProjectsDirectory(options); return { daily: aggregateDailyUsage(entries), + hourly: aggregateHourlyUsage(entries), monthly: aggregateMonthlyUsage(entries), session: aggregateSessionUsage(entries), }; diff --git a/src/web-server/usage-disk-cache.ts b/src/web-server/usage-disk-cache.ts index 8a2a947d..0af47521 100644 --- a/src/web-server/usage-disk-cache.ts +++ b/src/web-server/usage-disk-cache.ts @@ -11,7 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import type { DailyUsage, MonthlyUsage, SessionUsage } from './usage-types'; +import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './usage-types'; import { ok, info, warn } from '../utils/ui'; // Cache configuration @@ -26,13 +26,14 @@ export interface UsageDiskCache { version: number; timestamp: number; daily: DailyUsage[]; + hourly: HourlyUsage[]; monthly: MonthlyUsage[]; session: SessionUsage[]; } // Current cache version - increment to invalidate old caches -// v2: Updated model pricing (Opus 4.5: $5/$25, Gemini 3, GLM, Kimi, etc.) -const CACHE_VERSION = 2; +// v3: Added hourly data to cache +const CACHE_VERSION = 3; /** * Ensure ~/.ccs/cache directory exists @@ -95,6 +96,7 @@ export function isDiskCacheStale(cache: UsageDiskCache | null): boolean { */ export function writeDiskCache( daily: DailyUsage[], + hourly: HourlyUsage[], monthly: MonthlyUsage[], session: SessionUsage[] ): void { @@ -105,6 +107,7 @@ export function writeDiskCache( version: CACHE_VERSION, timestamp: Date.now(), daily, + hourly, monthly, session, }; diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts index 803ba9e0..c6835a90 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -20,9 +20,11 @@ import { loadMonthlyUsageData, loadSessionData, loadAllUsageData, + loadHourlyUsageData, } from './data-aggregator'; import type { DailyUsage, + HourlyUsage, MonthlyUsage, SessionUsage, Anomaly, @@ -78,6 +80,7 @@ function getInstancePaths(): string[] { */ async function loadInstanceData(instancePath: string): Promise<{ daily: DailyUsage[]; + hourly: HourlyUsage[]; monthly: MonthlyUsage[]; session: SessionUsage[]; }> { @@ -89,7 +92,7 @@ async function loadInstanceData(instancePath: string): Promise<{ // Instance may have no usage data - that's OK const instanceName = path.basename(instancePath); console.log(info(`No usage data in instance: ${instanceName}`)); - return { daily: [], monthly: [], session: [] }; + return { daily: [], hourly: [], monthly: [], session: [] }; } } @@ -168,6 +171,52 @@ function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); } +/** + * Merge hourly usage data from multiple sources + * Combines entries with same hour by aggregating tokens + */ +function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { + const hourMap = new Map(); + + for (const source of sources) { + for (const hour of source) { + const existing = hourMap.get(hour.hour); + if (existing) { + existing.inputTokens += hour.inputTokens; + existing.outputTokens += hour.outputTokens; + existing.cacheCreationTokens += hour.cacheCreationTokens; + existing.cacheReadTokens += hour.cacheReadTokens; + existing.totalCost += hour.totalCost; + const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]); + existing.modelsUsed = Array.from(modelSet); + // Merge model breakdowns + for (const breakdown of hour.modelBreakdowns) { + const existingBreakdown = existing.modelBreakdowns.find( + (b) => b.modelName === breakdown.modelName + ); + if (existingBreakdown) { + existingBreakdown.inputTokens += breakdown.inputTokens; + existingBreakdown.outputTokens += breakdown.outputTokens; + existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; + existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; + existingBreakdown.cost += breakdown.cost; + } else { + existing.modelBreakdowns.push({ ...breakdown }); + } + } + } else { + hourMap.set(hour.hour, { + ...hour, + modelsUsed: [...hour.modelsUsed], + modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })), + }); + } + } + } + + return Array.from(hourMap.values()).sort((a, b) => a.hour.localeCompare(b.hour)); +} + /** * Merge session data from multiple sources * Deduplicates by sessionId (same session shouldn't appear in multiple instances) @@ -246,12 +295,13 @@ let diskCacheInitialized = false; */ function persistCacheIfComplete(): void { const daily = cache.get('daily') as CacheEntry | undefined; + const hourly = cache.get('hourly') 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 ?? []); + writeDiskCache(daily.data, hourly?.data ?? [], monthly?.data ?? [], session?.data ?? []); } } @@ -338,6 +388,13 @@ async function getCachedSessionData(): Promise { }); } +/** Cached loader for hourly usage data */ +async function getCachedHourlyData(): Promise { + return getCachedData('hourly', CACHE_TTL.daily, async () => { + return await loadHourlyUsageData(); + }); +} + /** * Clear all cached data (useful for manual refresh) */ @@ -357,6 +414,7 @@ let isRefreshing = false; */ async function refreshFromSource(): Promise<{ daily: DailyUsage[]; + hourly: HourlyUsage[]; monthly: MonthlyUsage[]; session: SessionUsage[]; }> { @@ -367,6 +425,7 @@ async function refreshFromSource(): Promise<{ const instancePaths = getInstancePaths(); const instanceDataResults: Array<{ daily: DailyUsage[]; + hourly: HourlyUsage[]; monthly: MonthlyUsage[]; session: SessionUsage[]; }> = []; @@ -383,11 +442,13 @@ async function refreshFromSource(): Promise<{ // Collect successful instance data const allDailySources: DailyUsage[][] = [defaultData.daily]; + const allHourlySources: HourlyUsage[][] = [defaultData.hourly]; const allMonthlySources: MonthlyUsage[][] = [defaultData.monthly]; const allSessionSources: SessionUsage[][] = [defaultData.session]; for (const result of instanceDataResults) { allDailySources.push(result.daily); + allHourlySources.push(result.hourly); allMonthlySources.push(result.monthly); allSessionSources.push(result.session); } @@ -398,20 +459,22 @@ async function refreshFromSource(): Promise<{ // Merge all data sources const daily = mergeDailyData(allDailySources); + const hourly = mergeHourlyData(allHourlySources); const monthly = mergeMonthlyData(allMonthlySources); const session = mergeSessionData(allSessionSources); // Update in-memory cache const now = Date.now(); cache.set('daily', { data: daily, timestamp: now }); + cache.set('hourly', { data: hourly, 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); + writeDiskCache(daily, hourly, monthly, session); - return { daily, monthly, session }; + return { daily, hourly, monthly, session }; } // ============================================================================ @@ -433,6 +496,7 @@ function ensureDiskCacheLoaded(): void { // 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('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); lastFetchTimestamp = diskCache.timestamp; @@ -463,6 +527,7 @@ export async function prewarmUsageCache(): Promise<{ if (diskCache && isDiskCacheFresh(diskCache)) { const now = Date.now(); cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); + cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); lastFetchTimestamp = diskCache.timestamp; @@ -478,6 +543,7 @@ export async function prewarmUsageCache(): Promise<{ if (diskCache && isDiskCacheStale(diskCache)) { const now = Date.now(); cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); + cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); lastFetchTimestamp = diskCache.timestamp; @@ -753,6 +819,55 @@ usageRoutes.get( } ); +/** + * GET /api/usage/hourly + * + * Returns hourly usage trends for chart visualization. + * Query: ?since=YYYYMMDD&until=YYYYMMDD (defaults to last 24 hours) + */ +usageRoutes.get( + '/hourly', + async (req: Request, res: Response) => { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + + const hourlyData = await getCachedHourlyData(); + + // Filter by date range + const filtered = hourlyData.filter((h) => { + // Extract date from hour format "YYYY-MM-DD HH:00" + const hourDate = h.hour.slice(0, 10).replace(/-/g, ''); + if (since && hourDate < since) return false; + if (until && hourDate > until) return false; + return true; + }); + + // Transform for chart consumption + const trends = filtered.map((hour) => ({ + hour: hour.hour, + tokens: hour.inputTokens + hour.outputTokens, + inputTokens: hour.inputTokens, + outputTokens: hour.outputTokens, + cacheTokens: hour.cacheCreationTokens + hour.cacheReadTokens, + cost: Math.round(hour.totalCost * 100) / 100, + modelsUsed: hour.modelsUsed.length, + requests: hour.modelBreakdowns.length, + })); + + // Sort by hour ascending for chart display + trends.sort((a, b) => a.hour.localeCompare(b.hour)); + + res.json({ + success: true, + data: trends, + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch hourly usage'); + } + } +); + /** * GET /api/usage/models * diff --git a/src/web-server/usage-types.ts b/src/web-server/usage-types.ts index b5ac7288..820bfc53 100644 --- a/src/web-server/usage-types.ts +++ b/src/web-server/usage-types.ts @@ -37,6 +37,20 @@ export interface DailyUsage { modelBreakdowns: ModelBreakdown[]; } +/** Hourly usage aggregation (YYYY-MM-DD HH:00) */ +export interface HourlyUsage { + hour: string; // Format: "YYYY-MM-DD HH:00" + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; +} + /** Monthly usage aggregation (YYYY-MM) */ export interface MonthlyUsage { month: string; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2cb49a79..6db0f20a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { SidebarProvider } from '@/components/ui/sidebar'; import { AppSidebar } from '@/components/app-sidebar'; import { ThemeToggle } from '@/components/theme-toggle'; +import { GitHubLink } from '@/components/github-link'; import { ConnectionIndicator } from '@/components/connection-indicator'; import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; import { Toaster } from 'sonner'; @@ -48,8 +49,9 @@ function Layout() {
-
+
+
diff --git a/ui/src/components/analytics/cliproxy-stats-card.tsx b/ui/src/components/analytics/cliproxy-stats-card.tsx index ba21ec9c..273ab9cc 100644 --- a/ui/src/components/analytics/cliproxy-stats-card.tsx +++ b/ui/src/components/analytics/cliproxy-stats-card.tsx @@ -18,13 +18,17 @@ import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats' interface CliproxyStatsCardProps { className?: string; + isLoading?: boolean; // External loading state from parent (for synchronized loading UI) } -export function CliproxyStatsCard({ className }: CliproxyStatsCardProps) { +export function CliproxyStatsCard({ + className, + isLoading: externalLoading, +}: CliproxyStatsCardProps) { const { data: status, isLoading: statusLoading } = useCliproxyStatus(); const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running); - const isLoading = statusLoading || (status?.running && statsLoading); + const isLoading = externalLoading || statusLoading || (status?.running && statsLoading); if (isLoading) { return ( diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index 69f73400..d4bc7eeb 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -2,7 +2,7 @@ * Usage Trend Chart Component * * Displays usage trends over time with tokens and cost. - * Supports daily and monthly granularity with interactive tooltips. + * Supports daily, hourly, and monthly granularity with interactive tooltips. */ import { useMemo } from 'react'; @@ -17,15 +17,15 @@ import { } from 'recharts'; import { format } from 'date-fns'; import { Skeleton } from '@/components/ui/skeleton'; -import type { DateRange } from 'react-day-picker'; import { cn } from '@/lib/utils'; -import type { DailyUsage } from '@/hooks/use-usage'; +import type { DailyUsage, HourlyUsage } from '@/hooks/use-usage'; + +type ChartData = DailyUsage | HourlyUsage; interface UsageTrendChartProps { - data: DailyUsage[]; + data: ChartData[]; isLoading?: boolean; - dateRange?: DateRange; - granularity?: 'daily' | 'monthly'; + granularity?: 'daily' | 'monthly' | 'hourly'; className?: string; } @@ -34,15 +34,22 @@ export function UsageTrendChart({ isLoading, granularity = 'daily', className, -}: Omit) { +}: UsageTrendChartProps) { const chartData = useMemo(() => { if (!data || data.length === 0) return []; - return [...data].reverse().map((item) => ({ - ...item, - dateFormatted: formatDate(item.date, granularity), - costRounded: Number(item.cost.toFixed(4)), - })); + // For hourly data, already sorted ascending from API + const sortedData = granularity === 'hourly' ? data : [...data].reverse(); + + return sortedData.map((item) => { + // Handle hourly vs daily data format + const timeKey = 'hour' in item ? item.hour : (item as DailyUsage).date; + return { + ...item, + dateFormatted: formatTime(timeKey, granularity), + costRounded: Number(item.cost.toFixed(4)), + }; + }); }, [data, granularity]); if (isLoading) { @@ -52,7 +59,9 @@ export function UsageTrendChart({ if (!data || data.length === 0) { return (
-

No usage data available

+

+ {granularity === 'hourly' ? 'No usage data for today' : 'No usage data available'} +

); } @@ -103,7 +112,7 @@ export function UsageTrendChart({ content={({ active, payload, label }) => { if (!active || !payload || !payload.length) return null; - const data = payload[0].payload; + const tooltipData = payload[0].payload; return (

{label}

@@ -115,7 +124,11 @@ export function UsageTrendChart({ : `$${entry.value}`}

))} -

Requests: {data.requests}

+ {'requests' in tooltipData && ( +

+ Requests: {tooltipData.requests} +

+ )}
); }} @@ -149,14 +162,26 @@ export function UsageTrendChart({ } // Helper functions -function formatDate(dateStr: string, granularity: 'daily' | 'monthly'): string { - const date = new Date(dateStr); +function formatTime(timeStr: string, granularity: 'daily' | 'monthly' | 'hourly'): string { + if (granularity === 'hourly') { + // Format: "YYYY-MM-DD HH:00" -> convert UTC to local time -> "HH:00" + // Parse as UTC and format in local timezone + const [datePart, timePart] = timeStr.split(' '); + if (datePart && timePart) { + // Create date in UTC: "2025-12-12 20:00" -> "2025-12-12T20:00:00Z" + const utcDate = new Date(`${datePart}T${timePart}:00Z`); + return format(utcDate, 'HH:mm'); + } + return timeStr; + } + + const date = new Date(timeStr); if (granularity === 'monthly') { return format(date, 'MMM yyyy'); } - // For daily, show shorter format if range is > 30 days + // For daily, show shorter format return format(date, 'MMM dd'); } diff --git a/ui/src/components/github-link.tsx b/ui/src/components/github-link.tsx new file mode 100644 index 00000000..8ece4cab --- /dev/null +++ b/ui/src/components/github-link.tsx @@ -0,0 +1,20 @@ +/** + * GitHub Link Button + * + * Links to CCS GitHub issues page for bug reports and feature requests. + */ + +import { Github } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const GITHUB_REPO_URL = 'https://github.com/kaitranntt/ccs/issues'; + +export function GitHubLink() { + return ( + + ); +} diff --git a/ui/src/components/quick-setup-wizard.tsx b/ui/src/components/quick-setup-wizard.tsx index ab4e58a5..dfc3d6b4 100644 --- a/ui/src/components/quick-setup-wizard.tsx +++ b/ui/src/components/quick-setup-wizard.tsx @@ -432,9 +432,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
{m.name} {m.description && ( - - - {m.description} - + - {m.description} )}
diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts index 3bcbde88..39e8e393 100644 --- a/ui/src/hooks/use-usage.ts +++ b/ui/src/hooks/use-usage.ts @@ -43,6 +43,17 @@ export interface DailyUsage { modelsUsed: number; } +export interface HourlyUsage { + hour: string; + tokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + cost: number; + modelsUsed: number; + requests: number; +} + export interface ModelUsage { model: string; tokens: number; @@ -147,6 +158,12 @@ export const usageApi = { if (options?.profile) params.append('profile', options.profile); return request(`/usage/daily?${params}`); }, + hourly: (options?: UsageQueryOptions) => { + const params = new URLSearchParams(); + if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); + if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + return request(`/usage/hourly?${params}`); + }, models: (options?: UsageQueryOptions) => { const params = new URLSearchParams(); if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); @@ -224,6 +241,14 @@ export function useUsageTrends(options?: UsageQueryOptions) { }); } +export function useHourlyUsage(options?: UsageQueryOptions) { + return useQuery({ + queryKey: ['usage', 'hourly', options], + queryFn: () => usageApi.hourly(options), + staleTime: 60 * 1000, // 1 minute + }); +} + export function useModelUsage(options?: UsageQueryOptions) { return useQuery({ queryKey: ['usage', 'models', options], diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index aa4a4f0c..3c3e9d64 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -23,6 +23,7 @@ import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucid import { useUsageSummary, useUsageTrends, + useHourlyUsage, useModelUsage, useRefreshUsage, useUsageStatus, @@ -48,6 +49,7 @@ export function AnalyticsPage() { const [isRefreshing, setIsRefreshing] = useState(false); const [selectedModel, setSelectedModel] = useState(null); const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null); + const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily'); const popoverAnchorRef = useRef(null); // Refresh hook @@ -71,10 +73,24 @@ export function AnalyticsPage() { // Fetch data const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions); const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions); + const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions); const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions); const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 }); const { data: status } = useUsageStatus(); + // Handle "24H" preset click + const handleTodayClick = useCallback(() => { + const now = new Date(); + setDateRange({ from: subDays(now, 1), to: now }); + setViewMode('hourly'); + }, []); + + // Handle date range changes from DateRangeFilter + const handleDateRangeChange = useCallback((range: DateRange | undefined) => { + setDateRange(range); + setViewMode('daily'); // Switch back to daily view for multi-day ranges + }, []); + // Format "Last updated" text const lastUpdatedText = useMemo(() => { if (!status?.lastFetch) return null; @@ -102,9 +118,17 @@ export function AnalyticsPage() {

Track usage & insights

+ - Usage Trends + {viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'} - + @@ -291,7 +319,7 @@ export function AnalyticsPage() { /> {/* CLIProxy Stats - 2/10 width */} - +
{/* Model Details Popover - positioned at cursor */}