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)
This commit is contained in:
kaitranntt
2025-12-12 15:33:22 -05:00
parent 5ef3e324f8
commit d64115f91a
17 changed files with 725 additions and 69 deletions
+3 -1
View File
@@ -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);
}
+33 -4
View File
@@ -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}`);
+3 -1
View File
@@ -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;
+114 -24
View File
@@ -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<void> {
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 <provider> --auth --verbose', 'command')} for detailed logs`
);
console.log(` 3. Restart your terminal/computer`);
}
console.log('');
}
/**
+12 -4
View File
@@ -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<void> {
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 <PID>`, 'command')} (replace <PID> with actual ID)`
);
} else {
console.error(` ${color(getKillPidCommand(12345).replace('12345', '<PID>'), '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('');
}
+180
View File
@@ -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,
};
+111
View File
@@ -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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
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<Daily
return aggregateDailyUsage(entries);
}
/**
* Load hourly usage data for today's chart
*/
export async function loadHourlyUsageData(options?: ParserOptions): Promise<HourlyUsage[]> {
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<SessionU
*/
export async function loadAllUsageData(options?: ParserOptions): Promise<{
daily: DailyUsage[];
hourly: HourlyUsage[];
monthly: MonthlyUsage[];
session: SessionUsage[];
}> {
const entries = await scanProjectsDirectory(options);
return {
daily: aggregateDailyUsage(entries),
hourly: aggregateHourlyUsage(entries),
monthly: aggregateMonthlyUsage(entries),
session: aggregateSessionUsage(entries),
};
+6 -3
View File
@@ -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,
};
+119 -4
View File
@@ -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<string, HourlyUsage>();
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<DailyUsage[]> | undefined;
const hourly = cache.get('hourly') as CacheEntry<HourlyUsage[]> | 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 ?? []);
writeDiskCache(daily.data, hourly?.data ?? [], monthly?.data ?? [], session?.data ?? []);
}
}
@@ -338,6 +388,13 @@ async function getCachedSessionData(): Promise<SessionUsage[]> {
});
}
/** Cached loader for hourly usage data */
async function getCachedHourlyData(): Promise<HourlyUsage[]> {
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<object, object, object, UsageQuery>, 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
*
+14
View File
@@ -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;
+3 -1
View File
@@ -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() {
<AppSidebar />
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">
<header className="flex h-12 items-center justify-end px-4 border-b shrink-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<ConnectionIndicator />
<GitHubLink />
<ThemeToggle />
</div>
</header>
@@ -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 (
@@ -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, 'dateRange'>) {
}: 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 (
<div className={cn('h-full flex items-center justify-center', className)}>
<p className="text-muted-foreground">No usage data available</p>
<p className="text-muted-foreground">
{granularity === 'hourly' ? 'No usage data for today' : 'No usage data available'}
</p>
</div>
);
}
@@ -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 (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="font-medium mb-2">{label}</p>
@@ -115,7 +124,11 @@ export function UsageTrendChart({
: `$${entry.value}`}
</p>
))}
<p className="text-sm text-muted-foreground mt-1">Requests: {data.requests}</p>
{'requests' in tooltipData && (
<p className="text-sm text-muted-foreground mt-1">
Requests: {tooltipData.requests}
</p>
)}
</div>
);
}}
@@ -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');
}
+20
View File
@@ -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 (
<Button variant="ghost" size="icon" asChild title="Report an issue on GitHub">
<a href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4" />
</a>
</Button>
);
}
+1 -3
View File
@@ -432,9 +432,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
<div className="flex items-center gap-2">
<span>{m.name}</span>
{m.description && (
<span className="text-xs text-muted-foreground">
- {m.description}
</span>
<span className="text-xs text-muted-foreground">- {m.description}</span>
)}
</div>
</SelectItem>
+25
View File
@@ -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<DailyUsage[]>(`/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<HourlyUsage[]>(`/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],
+32 -4
View File
@@ -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<ModelUsage | null>(null);
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
const popoverAnchorRef = useRef<HTMLDivElement>(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() {
<p className="text-sm text-muted-foreground">Track usage & insights</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'hourly' ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={handleTodayClick}
>
24H
</Button>
<DateRangeFilter
value={dateRange}
onChange={setDateRange}
onChange={handleDateRangeChange}
presets={[
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
@@ -140,11 +164,15 @@ export function AnalyticsPage() {
<CardHeader className="px-3 py-2 shrink-0">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Usage Trends
{viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'}
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} />
<UsageTrendChart
data={viewMode === 'hourly' ? hourlyData || [] : trends || []}
isLoading={viewMode === 'hourly' ? isHourlyLoading : isTrendsLoading}
granularity={viewMode === 'hourly' ? 'hourly' : 'daily'}
/>
</CardContent>
</Card>
@@ -291,7 +319,7 @@ export function AnalyticsPage() {
/>
{/* CLIProxy Stats - 2/10 width */}
<CliproxyStatsCard className="lg:col-span-2" />
<CliproxyStatsCard isLoading={isSummaryLoading} className="lg:col-span-2" />
</div>
{/* Model Details Popover - positioned at cursor */}