mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-18 02:16:55 +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 { ensureCLIProxyBinary } from './binary-manager';
|
||||||
import { generateConfig, getProviderAuthDir } from './config-generator';
|
import { generateConfig, getProviderAuthDir } from './config-generator';
|
||||||
import { CLIProxyProvider } from './types';
|
import { CLIProxyProvider } from './types';
|
||||||
|
import { getKillCLIProxyCommand } from '../utils/platform-commands';
|
||||||
import {
|
import {
|
||||||
AccountInfo,
|
AccountInfo,
|
||||||
discoverExistingAccounts,
|
discoverExistingAccounts,
|
||||||
@@ -636,7 +637,8 @@ export async function triggerOAuth(
|
|||||||
console.error(' Try: ccs qwen --auth --verbose');
|
console.error(' Try: ccs qwen --auth --verbose');
|
||||||
} else {
|
} else {
|
||||||
console.error(' The OAuth flow may have been cancelled or callback port was in use');
|
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);
|
resolve(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
renameAccount,
|
renameAccount,
|
||||||
getDefaultAccount,
|
getDefaultAccount,
|
||||||
} from './account-manager';
|
} from './account-manager';
|
||||||
|
import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands';
|
||||||
|
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||||
|
|
||||||
/** Default executor configuration */
|
/** Default executor configuration */
|
||||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||||
@@ -283,7 +285,33 @@ export async function execClaudeWithCLIProxy(
|
|||||||
const configPath = generateConfig(provider, cfg.port);
|
const configPath = generateConfig(provider, cfg.port);
|
||||||
log(`Config written: ${configPath}`);
|
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];
|
const proxyArgs = ['--config', configPath];
|
||||||
|
|
||||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||||
@@ -308,7 +336,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
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}`);
|
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
|
||||||
readySpinner.start();
|
readySpinner.start();
|
||||||
|
|
||||||
@@ -329,9 +357,10 @@ export async function execClaudeWithCLIProxy(
|
|||||||
console.error(' 3. Invalid configuration');
|
console.error(' 3. Invalid configuration');
|
||||||
console.error('');
|
console.error('');
|
||||||
console.error('Troubleshooting:');
|
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(' - 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('');
|
console.error('');
|
||||||
|
|
||||||
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ export function getBinDir(): string {
|
|||||||
*/
|
*/
|
||||||
function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string {
|
function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||||
const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories
|
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
|
// Unified config with enhanced CLIProxyAPI features
|
||||||
const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION}
|
const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION}
|
||||||
@@ -184,7 +186,7 @@ api-keys:
|
|||||||
- "${CCS_INTERNAL_API_KEY}"
|
- "${CCS_INTERNAL_API_KEY}"
|
||||||
|
|
||||||
# OAuth tokens directory (auto-discovered by CLIProxyAPI)
|
# OAuth tokens directory (auto-discovered by CLIProxyAPI)
|
||||||
auth-dir: "${authDir.replace(/\\\\/g, '/')}"
|
auth-dir: "${authDirNormalized}"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
+114
-24
@@ -24,6 +24,7 @@ import {
|
|||||||
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||||
import { getEnvironmentDiagnostics } from './environment-diagnostics';
|
import { getEnvironmentDiagnostics } from './environment-diagnostics';
|
||||||
import { checkAuthCodePorts } from './oauth-port-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)
|
// Make ora optional (might not be available during npm install postinstall)
|
||||||
// ora v9+ is an ES module, need to use .default for CommonJS
|
// ora v9+ is an ES module, need to use .default for CommonJS
|
||||||
@@ -1117,46 +1118,135 @@ class Doctor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fix detected issues
|
* Fix detected issues (--fix flag)
|
||||||
* Currently fixes: shared symlinks broken by Claude CLI's atomic writes
|
* 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> {
|
async fixIssues(): Promise<void> {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(header('ATTEMPTING FIXES'));
|
console.log(header('AUTO-FIX MODE'));
|
||||||
|
console.log('');
|
||||||
|
console.log(info(`Platform: ${getPlatformName()}`));
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
let fixed = 0;
|
let fixed = 0;
|
||||||
|
|
||||||
// Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.)
|
// Fix 1: Kill zombie CLIProxy processes
|
||||||
const sharedSettings = path.join(this.homedir, '.ccs', 'shared', 'settings.json');
|
const zombieSpinner = ora('Checking for zombie CLIProxy processes').start();
|
||||||
if (fs.existsSync(sharedSettings)) {
|
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 {
|
try {
|
||||||
const stats = fs.lstatSync(sharedSettings);
|
const portProcess = await getPortProcess(port);
|
||||||
if (!stats.isSymbolicLink()) {
|
if (portProcess && isCLIProxyProcess(portProcess)) {
|
||||||
const spinner = ora('Fixing shared settings.json symlink').start();
|
oauthSpinner.text = `Freeing OAuth port ${port}...`;
|
||||||
try {
|
const killed = killProcessOnPort(port, true);
|
||||||
// Import and use SharedManager to fix symlinks
|
if (killed) {
|
||||||
const SharedManagerModule = await import('./shared-manager');
|
oauthSpinner.succeed(`${ok('Fixed')} Freed OAuth port ${port}`);
|
||||||
const SharedManager = SharedManagerModule.default;
|
|
||||||
const sharedManager = new SharedManager();
|
|
||||||
sharedManager.ensureSharedDirectories();
|
|
||||||
spinner.succeed(ok('Fixed') + ' Shared settings.json symlink restored');
|
|
||||||
fixed++;
|
fixed++;
|
||||||
} catch (err) {
|
} else {
|
||||||
spinner.fail(fail('Failed') + ` Could not fix: ${(err as Error).message}`);
|
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) {
|
} catch (_err) {
|
||||||
// Ignore stat errors
|
oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} check passed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fixed > 0) {
|
// Fix 3: Regenerate outdated CLIProxy config
|
||||||
console.log('');
|
const configSpinner = ora('Checking CLIProxy config version').start();
|
||||||
console.log(ok(`Fixed ${fixed} issue(s)`));
|
try {
|
||||||
} else {
|
if (configNeedsRegeneration()) {
|
||||||
console.log(info('No fixable issues detected'));
|
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 { initUI, header, color, dim, errorBox } from './ui';
|
||||||
import { ERROR_CODES, getErrorDocUrl, ErrorCode } from './error-codes';
|
import { ERROR_CODES, getErrorDocUrl, ErrorCode } from './error-codes';
|
||||||
|
import { getPortCheckCommand, getKillPidCommand } from './platform-commands';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error types with structured messages (Legacy - kept for compatibility)
|
* Error types with structured messages (Legacy - kept for compatibility)
|
||||||
@@ -214,6 +215,7 @@ export class ErrorManager {
|
|||||||
*/
|
*/
|
||||||
static async showPortConflict(port: number): Promise<void> {
|
static async showPortConflict(port: number): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
console.error('');
|
console.error('');
|
||||||
console.error(
|
console.error(
|
||||||
@@ -224,13 +226,19 @@ export class ErrorManager {
|
|||||||
console.error(header('SOLUTIONS'));
|
console.error(header('SOLUTIONS'));
|
||||||
console.error('');
|
console.error('');
|
||||||
console.error(' 1. Find process using port:');
|
console.error(' 1. Find process using port:');
|
||||||
console.error(` ${color(`lsof -i :${port}`, 'command')} (macOS/Linux)`);
|
console.error(` ${color(getPortCheckCommand(port), 'command')}`);
|
||||||
console.error(` ${color(`netstat -ano | findstr ${port}`, 'command')} (Windows)`);
|
|
||||||
console.error('');
|
console.error('');
|
||||||
console.error(' 2. Kill the process:');
|
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('');
|
||||||
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('');
|
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 {
|
import {
|
||||||
type ModelBreakdown,
|
type ModelBreakdown,
|
||||||
type DailyUsage,
|
type DailyUsage,
|
||||||
|
type HourlyUsage,
|
||||||
type MonthlyUsage,
|
type MonthlyUsage,
|
||||||
type SessionUsage,
|
type SessionUsage,
|
||||||
} from './usage-types';
|
} from './usage-types';
|
||||||
@@ -28,6 +29,13 @@ function extractMonth(timestamp: string): string {
|
|||||||
return timestamp.slice(0, 7);
|
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 */
|
/** Create model breakdown from accumulated data */
|
||||||
function createModelBreakdown(
|
function createModelBreakdown(
|
||||||
modelName: string,
|
modelName: string,
|
||||||
@@ -152,6 +160,99 @@ export function aggregateDailyUsage(
|
|||||||
return dailyUsage;
|
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
|
// MONTHLY AGGREGATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -372,6 +473,14 @@ export async function loadDailyUsageData(options?: ParserOptions): Promise<Daily
|
|||||||
return aggregateDailyUsage(entries);
|
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)
|
* 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<{
|
export async function loadAllUsageData(options?: ParserOptions): Promise<{
|
||||||
daily: DailyUsage[];
|
daily: DailyUsage[];
|
||||||
|
hourly: HourlyUsage[];
|
||||||
monthly: MonthlyUsage[];
|
monthly: MonthlyUsage[];
|
||||||
session: SessionUsage[];
|
session: SessionUsage[];
|
||||||
}> {
|
}> {
|
||||||
const entries = await scanProjectsDirectory(options);
|
const entries = await scanProjectsDirectory(options);
|
||||||
return {
|
return {
|
||||||
daily: aggregateDailyUsage(entries),
|
daily: aggregateDailyUsage(entries),
|
||||||
|
hourly: aggregateHourlyUsage(entries),
|
||||||
monthly: aggregateMonthlyUsage(entries),
|
monthly: aggregateMonthlyUsage(entries),
|
||||||
session: aggregateSessionUsage(entries),
|
session: aggregateSessionUsage(entries),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
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';
|
import { ok, info, warn } from '../utils/ui';
|
||||||
|
|
||||||
// Cache configuration
|
// Cache configuration
|
||||||
@@ -26,13 +26,14 @@ export interface UsageDiskCache {
|
|||||||
version: number;
|
version: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
daily: DailyUsage[];
|
daily: DailyUsage[];
|
||||||
|
hourly: HourlyUsage[];
|
||||||
monthly: MonthlyUsage[];
|
monthly: MonthlyUsage[];
|
||||||
session: SessionUsage[];
|
session: SessionUsage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current cache version - increment to invalidate old caches
|
// Current cache version - increment to invalidate old caches
|
||||||
// v2: Updated model pricing (Opus 4.5: $5/$25, Gemini 3, GLM, Kimi, etc.)
|
// v3: Added hourly data to cache
|
||||||
const CACHE_VERSION = 2;
|
const CACHE_VERSION = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure ~/.ccs/cache directory exists
|
* Ensure ~/.ccs/cache directory exists
|
||||||
@@ -95,6 +96,7 @@ export function isDiskCacheStale(cache: UsageDiskCache | null): boolean {
|
|||||||
*/
|
*/
|
||||||
export function writeDiskCache(
|
export function writeDiskCache(
|
||||||
daily: DailyUsage[],
|
daily: DailyUsage[],
|
||||||
|
hourly: HourlyUsage[],
|
||||||
monthly: MonthlyUsage[],
|
monthly: MonthlyUsage[],
|
||||||
session: SessionUsage[]
|
session: SessionUsage[]
|
||||||
): void {
|
): void {
|
||||||
@@ -105,6 +107,7 @@ export function writeDiskCache(
|
|||||||
version: CACHE_VERSION,
|
version: CACHE_VERSION,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
daily,
|
daily,
|
||||||
|
hourly,
|
||||||
monthly,
|
monthly,
|
||||||
session,
|
session,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import {
|
|||||||
loadMonthlyUsageData,
|
loadMonthlyUsageData,
|
||||||
loadSessionData,
|
loadSessionData,
|
||||||
loadAllUsageData,
|
loadAllUsageData,
|
||||||
|
loadHourlyUsageData,
|
||||||
} from './data-aggregator';
|
} from './data-aggregator';
|
||||||
import type {
|
import type {
|
||||||
DailyUsage,
|
DailyUsage,
|
||||||
|
HourlyUsage,
|
||||||
MonthlyUsage,
|
MonthlyUsage,
|
||||||
SessionUsage,
|
SessionUsage,
|
||||||
Anomaly,
|
Anomaly,
|
||||||
@@ -78,6 +80,7 @@ function getInstancePaths(): string[] {
|
|||||||
*/
|
*/
|
||||||
async function loadInstanceData(instancePath: string): Promise<{
|
async function loadInstanceData(instancePath: string): Promise<{
|
||||||
daily: DailyUsage[];
|
daily: DailyUsage[];
|
||||||
|
hourly: HourlyUsage[];
|
||||||
monthly: MonthlyUsage[];
|
monthly: MonthlyUsage[];
|
||||||
session: SessionUsage[];
|
session: SessionUsage[];
|
||||||
}> {
|
}> {
|
||||||
@@ -89,7 +92,7 @@ async function loadInstanceData(instancePath: string): Promise<{
|
|||||||
// Instance may have no usage data - that's OK
|
// Instance may have no usage data - that's OK
|
||||||
const instanceName = path.basename(instancePath);
|
const instanceName = path.basename(instancePath);
|
||||||
console.log(info(`No usage data in instance: ${instanceName}`));
|
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));
|
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
|
* Merge session data from multiple sources
|
||||||
* Deduplicates by sessionId (same session shouldn't appear in multiple instances)
|
* Deduplicates by sessionId (same session shouldn't appear in multiple instances)
|
||||||
@@ -246,12 +295,13 @@ let diskCacheInitialized = false;
|
|||||||
*/
|
*/
|
||||||
function persistCacheIfComplete(): void {
|
function persistCacheIfComplete(): void {
|
||||||
const daily = cache.get('daily') as CacheEntry<DailyUsage[]> | undefined;
|
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 monthly = cache.get('monthly') as CacheEntry<MonthlyUsage[]> | undefined;
|
||||||
const session = cache.get('session') as CacheEntry<SessionUsage[]> | undefined;
|
const session = cache.get('session') as CacheEntry<SessionUsage[]> | undefined;
|
||||||
|
|
||||||
// Write if we have at least daily data (the most essential)
|
// Write if we have at least daily data (the most essential)
|
||||||
if (daily) {
|
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)
|
* Clear all cached data (useful for manual refresh)
|
||||||
*/
|
*/
|
||||||
@@ -357,6 +414,7 @@ let isRefreshing = false;
|
|||||||
*/
|
*/
|
||||||
async function refreshFromSource(): Promise<{
|
async function refreshFromSource(): Promise<{
|
||||||
daily: DailyUsage[];
|
daily: DailyUsage[];
|
||||||
|
hourly: HourlyUsage[];
|
||||||
monthly: MonthlyUsage[];
|
monthly: MonthlyUsage[];
|
||||||
session: SessionUsage[];
|
session: SessionUsage[];
|
||||||
}> {
|
}> {
|
||||||
@@ -367,6 +425,7 @@ async function refreshFromSource(): Promise<{
|
|||||||
const instancePaths = getInstancePaths();
|
const instancePaths = getInstancePaths();
|
||||||
const instanceDataResults: Array<{
|
const instanceDataResults: Array<{
|
||||||
daily: DailyUsage[];
|
daily: DailyUsage[];
|
||||||
|
hourly: HourlyUsage[];
|
||||||
monthly: MonthlyUsage[];
|
monthly: MonthlyUsage[];
|
||||||
session: SessionUsage[];
|
session: SessionUsage[];
|
||||||
}> = [];
|
}> = [];
|
||||||
@@ -383,11 +442,13 @@ async function refreshFromSource(): Promise<{
|
|||||||
|
|
||||||
// Collect successful instance data
|
// Collect successful instance data
|
||||||
const allDailySources: DailyUsage[][] = [defaultData.daily];
|
const allDailySources: DailyUsage[][] = [defaultData.daily];
|
||||||
|
const allHourlySources: HourlyUsage[][] = [defaultData.hourly];
|
||||||
const allMonthlySources: MonthlyUsage[][] = [defaultData.monthly];
|
const allMonthlySources: MonthlyUsage[][] = [defaultData.monthly];
|
||||||
const allSessionSources: SessionUsage[][] = [defaultData.session];
|
const allSessionSources: SessionUsage[][] = [defaultData.session];
|
||||||
|
|
||||||
for (const result of instanceDataResults) {
|
for (const result of instanceDataResults) {
|
||||||
allDailySources.push(result.daily);
|
allDailySources.push(result.daily);
|
||||||
|
allHourlySources.push(result.hourly);
|
||||||
allMonthlySources.push(result.monthly);
|
allMonthlySources.push(result.monthly);
|
||||||
allSessionSources.push(result.session);
|
allSessionSources.push(result.session);
|
||||||
}
|
}
|
||||||
@@ -398,20 +459,22 @@ async function refreshFromSource(): Promise<{
|
|||||||
|
|
||||||
// Merge all data sources
|
// Merge all data sources
|
||||||
const daily = mergeDailyData(allDailySources);
|
const daily = mergeDailyData(allDailySources);
|
||||||
|
const hourly = mergeHourlyData(allHourlySources);
|
||||||
const monthly = mergeMonthlyData(allMonthlySources);
|
const monthly = mergeMonthlyData(allMonthlySources);
|
||||||
const session = mergeSessionData(allSessionSources);
|
const session = mergeSessionData(allSessionSources);
|
||||||
|
|
||||||
// Update in-memory cache
|
// Update in-memory cache
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
cache.set('daily', { data: daily, timestamp: now });
|
cache.set('daily', { data: daily, timestamp: now });
|
||||||
|
cache.set('hourly', { data: hourly, timestamp: now });
|
||||||
cache.set('monthly', { data: monthly, timestamp: now });
|
cache.set('monthly', { data: monthly, timestamp: now });
|
||||||
cache.set('session', { data: session, timestamp: now });
|
cache.set('session', { data: session, timestamp: now });
|
||||||
lastFetchTimestamp = now;
|
lastFetchTimestamp = now;
|
||||||
|
|
||||||
// Persist to disk
|
// 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)
|
// Load disk cache into memory (regardless of freshness)
|
||||||
// SWR pattern in getCachedData() will handle background refresh
|
// SWR pattern in getCachedData() will handle background refresh
|
||||||
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
|
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('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
|
||||||
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
|
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
|
||||||
lastFetchTimestamp = diskCache.timestamp;
|
lastFetchTimestamp = diskCache.timestamp;
|
||||||
@@ -463,6 +527,7 @@ export async function prewarmUsageCache(): Promise<{
|
|||||||
if (diskCache && isDiskCacheFresh(diskCache)) {
|
if (diskCache && isDiskCacheFresh(diskCache)) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
|
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('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
|
||||||
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
|
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
|
||||||
lastFetchTimestamp = diskCache.timestamp;
|
lastFetchTimestamp = diskCache.timestamp;
|
||||||
@@ -478,6 +543,7 @@ export async function prewarmUsageCache(): Promise<{
|
|||||||
if (diskCache && isDiskCacheStale(diskCache)) {
|
if (diskCache && isDiskCacheStale(diskCache)) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
|
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('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
|
||||||
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
|
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
|
||||||
lastFetchTimestamp = 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
|
* GET /api/usage/models
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ export interface DailyUsage {
|
|||||||
modelBreakdowns: ModelBreakdown[];
|
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) */
|
/** Monthly usage aggregation (YYYY-MM) */
|
||||||
export interface MonthlyUsage {
|
export interface MonthlyUsage {
|
||||||
month: string;
|
month: string;
|
||||||
|
|||||||
+3
-1
@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||||
import { AppSidebar } from '@/components/app-sidebar';
|
import { AppSidebar } from '@/components/app-sidebar';
|
||||||
import { ThemeToggle } from '@/components/theme-toggle';
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
import { GitHubLink } from '@/components/github-link';
|
||||||
import { ConnectionIndicator } from '@/components/connection-indicator';
|
import { ConnectionIndicator } from '@/components/connection-indicator';
|
||||||
import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
|
import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
@@ -48,8 +49,9 @@ function Layout() {
|
|||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<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">
|
<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 />
|
<ConnectionIndicator />
|
||||||
|
<GitHubLink />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats'
|
|||||||
|
|
||||||
interface CliproxyStatsCardProps {
|
interface CliproxyStatsCardProps {
|
||||||
className?: string;
|
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: status, isLoading: statusLoading } = useCliproxyStatus();
|
||||||
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
|
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
|
||||||
|
|
||||||
const isLoading = statusLoading || (status?.running && statsLoading);
|
const isLoading = externalLoading || statusLoading || (status?.running && statsLoading);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Usage Trend Chart Component
|
* Usage Trend Chart Component
|
||||||
*
|
*
|
||||||
* Displays usage trends over time with tokens and cost.
|
* 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';
|
import { useMemo } from 'react';
|
||||||
@@ -17,15 +17,15 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import type { DateRange } from 'react-day-picker';
|
|
||||||
import { cn } from '@/lib/utils';
|
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 {
|
interface UsageTrendChartProps {
|
||||||
data: DailyUsage[];
|
data: ChartData[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
dateRange?: DateRange;
|
granularity?: 'daily' | 'monthly' | 'hourly';
|
||||||
granularity?: 'daily' | 'monthly';
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,15 +34,22 @@ export function UsageTrendChart({
|
|||||||
isLoading,
|
isLoading,
|
||||||
granularity = 'daily',
|
granularity = 'daily',
|
||||||
className,
|
className,
|
||||||
}: Omit<UsageTrendChartProps, 'dateRange'>) {
|
}: UsageTrendChartProps) {
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (!data || data.length === 0) return [];
|
if (!data || data.length === 0) return [];
|
||||||
|
|
||||||
return [...data].reverse().map((item) => ({
|
// For hourly data, already sorted ascending from API
|
||||||
...item,
|
const sortedData = granularity === 'hourly' ? data : [...data].reverse();
|
||||||
dateFormatted: formatDate(item.date, granularity),
|
|
||||||
costRounded: Number(item.cost.toFixed(4)),
|
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]);
|
}, [data, granularity]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -52,7 +59,9 @@ export function UsageTrendChart({
|
|||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('h-full flex items-center justify-center', className)}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,7 +112,7 @@ export function UsageTrendChart({
|
|||||||
content={({ active, payload, label }) => {
|
content={({ active, payload, label }) => {
|
||||||
if (!active || !payload || !payload.length) return null;
|
if (!active || !payload || !payload.length) return null;
|
||||||
|
|
||||||
const data = payload[0].payload;
|
const tooltipData = payload[0].payload;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||||
<p className="font-medium mb-2">{label}</p>
|
<p className="font-medium mb-2">{label}</p>
|
||||||
@@ -115,7 +124,11 @@ export function UsageTrendChart({
|
|||||||
: `$${entry.value}`}
|
: `$${entry.value}`}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -149,14 +162,26 @@ export function UsageTrendChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
function formatDate(dateStr: string, granularity: 'daily' | 'monthly'): string {
|
function formatTime(timeStr: string, granularity: 'daily' | 'monthly' | 'hourly'): string {
|
||||||
const date = new Date(dateStr);
|
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') {
|
if (granularity === 'monthly') {
|
||||||
return format(date, 'MMM yyyy');
|
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');
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span>{m.name}</span>
|
<span>{m.name}</span>
|
||||||
{m.description && (
|
{m.description && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">- {m.description}</span>
|
||||||
- {m.description}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ export interface DailyUsage {
|
|||||||
modelsUsed: number;
|
modelsUsed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HourlyUsage {
|
||||||
|
hour: string;
|
||||||
|
tokens: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheTokens: number;
|
||||||
|
cost: number;
|
||||||
|
modelsUsed: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModelUsage {
|
export interface ModelUsage {
|
||||||
model: string;
|
model: string;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
@@ -147,6 +158,12 @@ export const usageApi = {
|
|||||||
if (options?.profile) params.append('profile', options.profile);
|
if (options?.profile) params.append('profile', options.profile);
|
||||||
return request<DailyUsage[]>(`/usage/daily?${params}`);
|
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) => {
|
models: (options?: UsageQueryOptions) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
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) {
|
export function useModelUsage(options?: UsageQueryOptions) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['usage', 'models', options],
|
queryKey: ['usage', 'models', options],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucid
|
|||||||
import {
|
import {
|
||||||
useUsageSummary,
|
useUsageSummary,
|
||||||
useUsageTrends,
|
useUsageTrends,
|
||||||
|
useHourlyUsage,
|
||||||
useModelUsage,
|
useModelUsage,
|
||||||
useRefreshUsage,
|
useRefreshUsage,
|
||||||
useUsageStatus,
|
useUsageStatus,
|
||||||
@@ -48,6 +49,7 @@ export function AnalyticsPage() {
|
|||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
|
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
|
||||||
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
|
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
|
||||||
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Refresh hook
|
// Refresh hook
|
||||||
@@ -71,10 +73,24 @@ export function AnalyticsPage() {
|
|||||||
// Fetch data
|
// Fetch data
|
||||||
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
|
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
|
||||||
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
|
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
|
||||||
|
const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions);
|
||||||
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
|
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
|
||||||
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
|
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
|
||||||
const { data: status } = useUsageStatus();
|
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
|
// Format "Last updated" text
|
||||||
const lastUpdatedText = useMemo(() => {
|
const lastUpdatedText = useMemo(() => {
|
||||||
if (!status?.lastFetch) return null;
|
if (!status?.lastFetch) return null;
|
||||||
@@ -102,9 +118,17 @@ export function AnalyticsPage() {
|
|||||||
<p className="text-sm text-muted-foreground">Track usage & insights</p>
|
<p className="text-sm text-muted-foreground">Track usage & insights</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'hourly' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={handleTodayClick}
|
||||||
|
>
|
||||||
|
24H
|
||||||
|
</Button>
|
||||||
<DateRangeFilter
|
<DateRangeFilter
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={setDateRange}
|
onChange={handleDateRangeChange}
|
||||||
presets={[
|
presets={[
|
||||||
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
|
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
|
||||||
{ label: '30D', range: { from: subDays(new Date(), 30), 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">
|
<CardHeader className="px-3 py-2 shrink-0">
|
||||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
<TrendingUp className="w-4 h-4" />
|
<TrendingUp className="w-4 h-4" />
|
||||||
Usage Trends
|
{viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -291,7 +319,7 @@ export function AnalyticsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* CLIProxy Stats - 2/10 width */}
|
{/* CLIProxy Stats - 2/10 width */}
|
||||||
<CliproxyStatsCard className="lg:col-span-2" />
|
<CliproxyStatsCard isLoading={isSummaryLoading} className="lg:col-span-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Details Popover - positioned at cursor */}
|
{/* Model Details Popover - positioned at cursor */}
|
||||||
|
|||||||
Reference in New Issue
Block a user