mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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('');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user