mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
feat(cliproxy): add stats fetcher and OpenAI-compatible model manager
- Add stats-fetcher.ts for CLIProxy analytics integration - Add openai-compat-manager.ts for model discovery and listing - Add CLIProxy stats routes to web-server for real-time metrics - Create cliproxy-stats-card component for analytics dashboard - Add use-cliproxy-stats hook for data fetching - Extend doctor.ts with CLIProxy health checks - Update analytics page with CLIProxy usage metrics
This commit is contained in:
@@ -25,6 +25,13 @@ export const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
/** Internal API key for CCS-managed requests */
|
||||
const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
|
||||
|
||||
/**
|
||||
* Config version - bump when config format changes to trigger regeneration
|
||||
* v1: Initial config (port, auth-dir, api-keys only)
|
||||
* v2: Enhanced config (quota-exceeded, request-retry, usage-statistics)
|
||||
*/
|
||||
export const CLIPROXY_CONFIG_VERSION = 2;
|
||||
|
||||
/** Provider display names (static metadata) */
|
||||
const PROVIDER_DISPLAY_NAMES: Record<CLIProxyProvider, string> = {
|
||||
gemini: 'Gemini',
|
||||
@@ -112,26 +119,34 @@ export function getBinDir(): string {
|
||||
function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories
|
||||
|
||||
// Unified config with all providers
|
||||
const config = `# CLIProxyAPI unified config generated by CCS
|
||||
# Supports: gemini, codex, agy, qwen (concurrent usage)
|
||||
// Unified config with enhanced CLIProxyAPI features
|
||||
const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION}
|
||||
# Supports: gemini, codex, agy, qwen, iflow (concurrent usage)
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# DO NOT EDIT - regenerated by CCS. Use 'ccs doctor' to update.
|
||||
|
||||
port: ${port}
|
||||
debug: false
|
||||
logging-to-file: false
|
||||
usage-statistics-enabled: false
|
||||
|
||||
# Usage statistics for dashboard analytics
|
||||
usage-statistics-enabled: true
|
||||
|
||||
# Auto-retry on transient errors (403, 408, 500, 502, 503, 504)
|
||||
request-retry: 3
|
||||
max-retry-interval: 30
|
||||
|
||||
# Auto-switch accounts on quota exceeded (429) - killer feature for multi-account
|
||||
quota-exceeded:
|
||||
switch-project: true
|
||||
switch-preview-model: true
|
||||
|
||||
# CCS internal authentication
|
||||
api-keys:
|
||||
- "${CCS_INTERNAL_API_KEY}"
|
||||
|
||||
# OAuth tokens stored in auth/ directory
|
||||
# CLIProxyAPI auto-discovers auth files in subdirectories
|
||||
auth-dir: "${authDir.replace(/\\/g, '/')}"
|
||||
|
||||
# All providers configured - routes by model name
|
||||
# No provider-specific sections needed - OAuth auth files provide credentials
|
||||
`;
|
||||
|
||||
return config;
|
||||
@@ -162,6 +177,80 @@ export function generateConfig(
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force regenerate config.yaml with latest settings
|
||||
* Deletes existing config and creates fresh one with current port
|
||||
* @returns Path to new config file
|
||||
*/
|
||||
export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
const configPath = getConfigPath();
|
||||
|
||||
// Read existing port if config exists (preserve user's port choice)
|
||||
let effectivePort = port;
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const portMatch = content.match(/^port:\s*(\d+)/m);
|
||||
if (portMatch) {
|
||||
effectivePort = parseInt(portMatch[1], 10);
|
||||
}
|
||||
} catch {
|
||||
// Use default port if reading fails
|
||||
}
|
||||
// Delete existing config
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
|
||||
// Ensure directories exist
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 });
|
||||
|
||||
// Generate fresh config
|
||||
const configContent = generateUnifiedConfigContent(effectivePort);
|
||||
fs.writeFileSync(configPath, configContent, { mode: 0o600 });
|
||||
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config needs regeneration (version mismatch or missing required fields)
|
||||
* @returns true if config should be regenerated
|
||||
*/
|
||||
export function configNeedsRegeneration(): boolean {
|
||||
const configPath = getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return false; // Will be created on first use
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Check for version marker
|
||||
const versionMatch = content.match(/CCS v(\d+)/);
|
||||
if (!versionMatch) {
|
||||
return true; // No version marker = old config
|
||||
}
|
||||
|
||||
const configVersion = parseInt(versionMatch[1], 10);
|
||||
if (configVersion < CLIPROXY_CONFIG_VERSION) {
|
||||
return true; // Outdated version
|
||||
}
|
||||
|
||||
// Check for required fields (v2 features)
|
||||
const hasQuotaExceeded = content.includes('quota-exceeded:');
|
||||
const hasRequestRetry = content.includes('request-retry:');
|
||||
const hasUsageStats = content.includes('usage-statistics-enabled: true');
|
||||
|
||||
if (!hasQuotaExceeded || !hasRequestRetry || !hasUsageStats) {
|
||||
return true; // Missing required fields
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return true; // Error reading = regenerate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config exists for provider
|
||||
*/
|
||||
|
||||
@@ -48,6 +48,8 @@ export {
|
||||
// Config generation
|
||||
export {
|
||||
generateConfig,
|
||||
regenerateConfig,
|
||||
configNeedsRegeneration,
|
||||
getClaudeEnvVars,
|
||||
getEffectiveEnvVars,
|
||||
getProviderSettingsPath,
|
||||
@@ -62,6 +64,7 @@ export {
|
||||
configExists,
|
||||
deleteConfig,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
CLIPROXY_CONFIG_VERSION,
|
||||
} from './config-generator';
|
||||
|
||||
// Base config loader (for reading config/base-*.settings.json)
|
||||
@@ -98,3 +101,19 @@ export {
|
||||
getProviderTokenDir,
|
||||
displayAuthStatus,
|
||||
} from './auth-handler';
|
||||
|
||||
// Stats fetcher
|
||||
export type { ClipproxyStats } from './stats-fetcher';
|
||||
export { fetchClipproxyStats, isClipproxyRunning } from './stats-fetcher';
|
||||
|
||||
// OpenAI compatibility layer
|
||||
export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager';
|
||||
export {
|
||||
listOpenAICompatProviders,
|
||||
getOpenAICompatProvider,
|
||||
addOpenAICompatProvider,
|
||||
updateOpenAICompatProvider,
|
||||
removeOpenAICompatProvider,
|
||||
OPENROUTER_TEMPLATE,
|
||||
TOGETHER_TEMPLATE,
|
||||
} from './openai-compat-manager';
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* OpenAI Compatibility Layer Manager
|
||||
*
|
||||
* Manages OpenAI-compatible providers (OpenRouter, Together, etc.)
|
||||
* in CLIProxyAPI's config.yaml.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getConfigPath } from './config-generator';
|
||||
|
||||
/** Model alias configuration */
|
||||
export interface OpenAICompatModel {
|
||||
name: string; // Upstream model name
|
||||
alias: string; // Client-visible alias
|
||||
}
|
||||
|
||||
/** OpenAI-compatible provider configuration */
|
||||
export interface OpenAICompatProvider {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
models: OpenAICompatModel[];
|
||||
}
|
||||
|
||||
/** Config.yaml structure for parsing */
|
||||
interface ConfigYaml {
|
||||
port?: number;
|
||||
'api-keys'?: string[];
|
||||
'auth-dir'?: string;
|
||||
'openai-compatibility'?: Array<{
|
||||
name: string;
|
||||
'base-url': string;
|
||||
headers?: Record<string, string>;
|
||||
'api-key-entries': Array<{
|
||||
'api-key': string;
|
||||
'proxy-url'?: string;
|
||||
}>;
|
||||
models?: Array<{
|
||||
name: string;
|
||||
alias: string;
|
||||
}>;
|
||||
}>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current config.yaml
|
||||
*/
|
||||
function loadConfig(): ConfigYaml {
|
||||
const configPath = getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
return (yaml.load(content) as ConfigYaml) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save config.yaml with proper formatting
|
||||
*/
|
||||
function saveConfig(config: ConfigYaml): void {
|
||||
const configPath = getConfigPath();
|
||||
const content = yaml.dump(config, {
|
||||
lineWidth: -1, // Disable line wrapping
|
||||
quotingType: '"',
|
||||
forceQuotes: false,
|
||||
});
|
||||
|
||||
fs.writeFileSync(configPath, content, { mode: 0o600 });
|
||||
}
|
||||
|
||||
/**
|
||||
* List all configured OpenAI-compatible providers
|
||||
*/
|
||||
export function listOpenAICompatProviders(): OpenAICompatProvider[] {
|
||||
const config = loadConfig();
|
||||
const providers = config['openai-compatibility'] || [];
|
||||
|
||||
return providers.map((p) => ({
|
||||
name: p.name,
|
||||
baseUrl: p['base-url'],
|
||||
apiKey: p['api-key-entries']?.[0]?.['api-key'] || '',
|
||||
models: (p.models || []).map((m) => ({
|
||||
name: m.name,
|
||||
alias: m.alias,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific provider by name
|
||||
*/
|
||||
export function getOpenAICompatProvider(name: string): OpenAICompatProvider | null {
|
||||
const providers = listOpenAICompatProviders();
|
||||
return providers.find((p) => p.name === name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new OpenAI-compatible provider
|
||||
* @throws Error if provider with same name already exists
|
||||
*/
|
||||
export function addOpenAICompatProvider(provider: OpenAICompatProvider): void {
|
||||
const config = loadConfig();
|
||||
|
||||
// Initialize array if not exists
|
||||
if (!config['openai-compatibility']) {
|
||||
config['openai-compatibility'] = [];
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = config['openai-compatibility'].find((p) => p.name === provider.name);
|
||||
if (existing) {
|
||||
throw new Error(`Provider '${provider.name}' already exists`);
|
||||
}
|
||||
|
||||
// Add new provider
|
||||
config['openai-compatibility'].push({
|
||||
name: provider.name,
|
||||
'base-url': provider.baseUrl,
|
||||
'api-key-entries': [{ 'api-key': provider.apiKey }],
|
||||
models: provider.models.map((m) => ({
|
||||
name: m.name,
|
||||
alias: m.alias,
|
||||
})),
|
||||
});
|
||||
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing provider
|
||||
* @throws Error if provider doesn't exist
|
||||
*/
|
||||
export function updateOpenAICompatProvider(
|
||||
name: string,
|
||||
updates: Partial<OpenAICompatProvider>
|
||||
): void {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config['openai-compatibility']) {
|
||||
throw new Error(`Provider '${name}' not found`);
|
||||
}
|
||||
|
||||
const index = config['openai-compatibility'].findIndex((p) => p.name === name);
|
||||
if (index === -1) {
|
||||
throw new Error(`Provider '${name}' not found`);
|
||||
}
|
||||
|
||||
const provider = config['openai-compatibility'][index];
|
||||
|
||||
// Apply updates
|
||||
if (updates.baseUrl) {
|
||||
provider['base-url'] = updates.baseUrl;
|
||||
}
|
||||
if (updates.apiKey) {
|
||||
provider['api-key-entries'] = [{ 'api-key': updates.apiKey }];
|
||||
}
|
||||
if (updates.models) {
|
||||
provider.models = updates.models.map((m) => ({
|
||||
name: m.name,
|
||||
alias: m.alias,
|
||||
}));
|
||||
}
|
||||
if (updates.name && updates.name !== name) {
|
||||
provider.name = updates.name;
|
||||
}
|
||||
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a provider
|
||||
* @returns true if removed, false if not found
|
||||
*/
|
||||
export function removeOpenAICompatProvider(name: string): boolean {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config['openai-compatibility']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = config['openai-compatibility'].findIndex((p) => p.name === name);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
config['openai-compatibility'].splice(index, 1);
|
||||
|
||||
// Remove empty array
|
||||
if (config['openai-compatibility'].length === 0) {
|
||||
delete config['openai-compatibility'];
|
||||
}
|
||||
|
||||
saveConfig(config);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pre-configured OpenRouter template */
|
||||
export const OPENROUTER_TEMPLATE: Omit<OpenAICompatProvider, 'apiKey'> = {
|
||||
name: 'openrouter',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
models: [
|
||||
{ name: 'anthropic/claude-3.5-sonnet', alias: 'claude-sonnet' },
|
||||
{ name: 'anthropic/claude-3-opus', alias: 'claude-opus' },
|
||||
{ name: 'google/gemini-pro-1.5', alias: 'gemini-pro' },
|
||||
],
|
||||
};
|
||||
|
||||
/** Pre-configured Together template */
|
||||
export const TOGETHER_TEMPLATE: Omit<OpenAICompatProvider, 'apiKey'> = {
|
||||
name: 'together',
|
||||
baseUrl: 'https://api.together.xyz/v1',
|
||||
models: [
|
||||
{ name: 'meta-llama/Llama-3-70b-chat-hf', alias: 'llama-70b' },
|
||||
{ name: 'mistralai/Mixtral-8x7B-Instruct-v0.1', alias: 'mixtral' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* CLIProxyAPI Stats Fetcher
|
||||
*
|
||||
* Fetches usage statistics from CLIProxyAPI's management API.
|
||||
* Requires usage-statistics-enabled: true in config.yaml.
|
||||
*/
|
||||
|
||||
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
|
||||
/** Usage statistics from CLIProxyAPI */
|
||||
export interface ClipproxyStats {
|
||||
/** Total number of requests processed */
|
||||
totalRequests: number;
|
||||
/** Token counts */
|
||||
tokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
};
|
||||
/** Requests grouped by model */
|
||||
requestsByModel: Record<string, number>;
|
||||
/** Requests grouped by provider */
|
||||
requestsByProvider: Record<string, number>;
|
||||
/** Number of quota exceeded (429) events */
|
||||
quotaExceededCount: number;
|
||||
/** Number of request retries */
|
||||
retryCount: number;
|
||||
/** Timestamp of stats collection */
|
||||
collectedAt: string;
|
||||
}
|
||||
|
||||
/** Stats API response from CLIProxyAPI */
|
||||
interface StatsApiResponse {
|
||||
total_requests?: number;
|
||||
tokens?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
};
|
||||
requests_by_model?: Record<string, number>;
|
||||
requests_by_provider?: Record<string, number>;
|
||||
quota_exceeded_count?: number;
|
||||
retry_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage statistics from CLIProxyAPI management API
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Stats object or null if unavailable
|
||||
*/
|
||||
export async function fetchClipproxyStats(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<ClipproxyStats | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v0/management/stats`, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as StatsApiResponse;
|
||||
|
||||
// Normalize the response to our interface
|
||||
return {
|
||||
totalRequests: data.total_requests ?? 0,
|
||||
tokens: {
|
||||
input: data.tokens?.input ?? 0,
|
||||
output: data.tokens?.output ?? 0,
|
||||
total: (data.tokens?.input ?? 0) + (data.tokens?.output ?? 0),
|
||||
},
|
||||
requestsByModel: data.requests_by_model ?? {},
|
||||
requestsByProvider: data.requests_by_provider ?? {},
|
||||
quotaExceededCount: data.quota_exceeded_count ?? 0,
|
||||
retryCount: data.retry_count ?? 0,
|
||||
collectedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch {
|
||||
// CLIProxyAPI not running or stats endpoint not available
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLIProxyAPI is running and responsive
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns true if proxy is running
|
||||
*/
|
||||
export async function isClipproxyRunning(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
getConfigPath,
|
||||
getInstalledCliproxyVersion,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
configNeedsRegeneration,
|
||||
regenerateConfig,
|
||||
CLIPROXY_CONFIG_VERSION,
|
||||
} from '../cliproxy';
|
||||
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||
import { getEnvironmentDiagnostics } from './environment-diagnostics';
|
||||
@@ -919,17 +922,34 @@ class Doctor {
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Config file exists?
|
||||
// 2. Config file exists and is up-to-date?
|
||||
const configSpinner = ora('Checking CLIProxy config').start();
|
||||
const configPath = getConfigPath();
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
configSpinner.succeed();
|
||||
console.log(` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml`);
|
||||
this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: 'cliproxy/config.yaml',
|
||||
});
|
||||
// Check if config needs regeneration (version mismatch or missing features)
|
||||
if (configNeedsRegeneration()) {
|
||||
configSpinner.warn();
|
||||
console.log(
|
||||
` ${warn('CLIProxy Config'.padEnd(22))} Outdated config, upgrading to v${CLIPROXY_CONFIG_VERSION}...`
|
||||
);
|
||||
|
||||
// Regenerate config with new features
|
||||
regenerateConfig();
|
||||
|
||||
console.log(` ${ok('CLIProxy Config'.padEnd(22))} Upgraded to v${CLIPROXY_CONFIG_VERSION}`);
|
||||
this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: `Upgraded to v${CLIPROXY_CONFIG_VERSION}`,
|
||||
});
|
||||
} else {
|
||||
configSpinner.succeed();
|
||||
console.log(` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`);
|
||||
this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: `cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
configSpinner.info();
|
||||
console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`);
|
||||
|
||||
@@ -12,6 +12,16 @@ import { Config, Settings } from '../types/config';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import { runHealthChecks, fixHealthIssue } from './health-service';
|
||||
import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler';
|
||||
import { fetchClipproxyStats, isClipproxyRunning } from '../cliproxy/stats-fetcher';
|
||||
import {
|
||||
listOpenAICompatProviders,
|
||||
getOpenAICompatProvider,
|
||||
addOpenAICompatProvider,
|
||||
updateOpenAICompatProvider,
|
||||
removeOpenAICompatProvider,
|
||||
OPENROUTER_TEMPLATE,
|
||||
TOGETHER_TEMPLATE,
|
||||
} from '../cliproxy/openai-compat-manager';
|
||||
import {
|
||||
getAllAccountsSummary,
|
||||
getProviderAccounts,
|
||||
@@ -1023,3 +1033,183 @@ apiRoutes.get('/files', (_req: Request, res: Response): void => {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
|
||||
* Returns: ClipproxyStats or error if proxy not running
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if proxy is running first
|
||||
const running = await isClipproxyRunning();
|
||||
if (!running) {
|
||||
res.status(503).json({
|
||||
error: 'CLIProxyAPI not running',
|
||||
message: 'Start a CLIProxy session (gemini, codex, agy) to collect stats',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch stats from management API
|
||||
const stats = await fetchClipproxyStats();
|
||||
if (!stats) {
|
||||
res.status(503).json({
|
||||
error: 'Stats unavailable',
|
||||
message: 'CLIProxyAPI is running but stats endpoint not responding',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/status - Check CLIProxyAPI running status
|
||||
* Returns: { running: boolean }
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const running = await isClipproxyRunning();
|
||||
res.json({ running });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// OpenAI Compatibility Layer Routes
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/openai-compat - List all OpenAI-compatible providers
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/openai-compat', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const providers = listOpenAICompatProviders();
|
||||
// Mask API keys for security
|
||||
const masked = providers.map((p) => ({
|
||||
...p,
|
||||
apiKey: p.apiKey ? `...${p.apiKey.slice(-4)}` : '',
|
||||
}));
|
||||
res.json({ providers: masked });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/openai-compat/templates - Get pre-configured provider templates
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/openai-compat/templates', (_req: Request, res: Response): void => {
|
||||
res.json({
|
||||
templates: [
|
||||
{ ...OPENROUTER_TEMPLATE, description: 'OpenRouter - Access multiple AI models' },
|
||||
{ ...TOGETHER_TEMPLATE, description: 'Together AI - Open source models' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/openai-compat/:name - Get a specific provider
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const provider = getOpenAICompatProvider(req.params.name);
|
||||
if (!provider) {
|
||||
res.status(404).json({ error: `Provider '${req.params.name}' not found` });
|
||||
return;
|
||||
}
|
||||
// Mask API key
|
||||
res.json({
|
||||
...provider,
|
||||
apiKey: provider.apiKey ? `...${provider.apiKey.slice(-4)}` : '',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/openai-compat - Add a new provider
|
||||
* Body: { name, baseUrl, apiKey, models: [{ name, alias }] }
|
||||
*/
|
||||
apiRoutes.post('/cliproxy/openai-compat', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { name, baseUrl, apiKey, models } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name || typeof name !== 'string') {
|
||||
res.status(400).json({ error: 'name is required' });
|
||||
return;
|
||||
}
|
||||
if (!baseUrl || typeof baseUrl !== 'string') {
|
||||
res.status(400).json({ error: 'baseUrl is required' });
|
||||
return;
|
||||
}
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
res.status(400).json({ error: 'apiKey is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
addOpenAICompatProvider({
|
||||
name,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
models: models || [],
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, name });
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
if (message.includes('already exists')) {
|
||||
res.status(409).json({ error: message });
|
||||
} else {
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/cliproxy/openai-compat/:name - Update a provider
|
||||
* Body: { baseUrl?, apiKey?, models?, name? (for rename) }
|
||||
*/
|
||||
apiRoutes.put('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { baseUrl, apiKey, models, name: newName } = req.body;
|
||||
|
||||
updateOpenAICompatProvider(req.params.name, {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
models,
|
||||
name: newName,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
if (message.includes('not found')) {
|
||||
res.status(404).json({ error: message });
|
||||
} else {
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/cliproxy/openai-compat/:name - Remove a provider
|
||||
*/
|
||||
apiRoutes.delete('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const removed = removeOpenAICompatProvider(req.params.name);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: `Provider '${req.params.name}' not found` });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* CLIProxy Stats Card Component
|
||||
*
|
||||
* Displays CLIProxyAPI usage statistics including:
|
||||
* - Proxy status (running/stopped)
|
||||
* - Total requests
|
||||
* - Quota exceeded events
|
||||
* - Request retries
|
||||
* - Requests by provider
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Activity, AlertTriangle, RefreshCw, Server, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useClipproxyStats, useClipproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||
|
||||
interface ClipproxyStatsCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ClipproxyStatsCard({ className }: ClipproxyStatsCardProps) {
|
||||
const { data: status, isLoading: statusLoading } = useClipproxyStatus();
|
||||
const { data: stats, isLoading: statsLoading, error } = useClipproxyStats(status?.running);
|
||||
|
||||
const isLoading = statusLoading || (status?.running && statsLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Proxy not running
|
||||
if (!status?.running) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full border-dashed', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">
|
||||
Offline
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Start a CLIProxy session (gemini, codex, agy) to collect stats.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error fetching stats
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full border-destructive/50', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
<Badge variant="destructive" className="text-[10px] h-5">
|
||||
Error
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1">
|
||||
<p className="text-xs text-destructive">{error.message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Stats available
|
||||
const statItems = [
|
||||
{
|
||||
label: 'Requests',
|
||||
value: stats?.totalRequests ?? 0,
|
||||
icon: Activity,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
|
||||
},
|
||||
{
|
||||
label: 'Quota',
|
||||
value: stats?.quotaExceededCount ?? 0,
|
||||
icon: AlertTriangle,
|
||||
color: stats?.quotaExceededCount ? 'text-amber-600' : 'text-muted-foreground',
|
||||
bgColor:
|
||||
stats?.quotaExceededCount
|
||||
? 'bg-amber-100 dark:bg-amber-900/20'
|
||||
: 'bg-muted',
|
||||
},
|
||||
{
|
||||
label: 'Retries',
|
||||
value: stats?.retryCount ?? 0,
|
||||
icon: RefreshCw,
|
||||
color: stats?.retryCount ? 'text-orange-600' : 'text-muted-foreground',
|
||||
bgColor:
|
||||
stats?.retryCount ? 'bg-orange-100 dark:bg-orange-900/20' : 'bg-muted',
|
||||
},
|
||||
];
|
||||
|
||||
// Provider breakdown
|
||||
const providers = Object.entries(stats?.requestsByProvider ?? {}).sort(
|
||||
(a, b) => b[1] - a[1]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
|
||||
<CardHeader className="px-3 py-2 border-b bg-muted/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
|
||||
>
|
||||
<Zap className="h-3 w-3 mr-0.5" />
|
||||
Running
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 flex-1 min-h-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{statItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex flex-col items-center p-2 rounded-lg bg-muted/50"
|
||||
>
|
||||
<div className={cn('p-1 rounded-md mb-1', item.bgColor)}>
|
||||
<Icon className={cn('h-3 w-3', item.color)} />
|
||||
</div>
|
||||
<span className="text-sm font-bold">{item.value}</span>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Provider breakdown */}
|
||||
{providers.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">
|
||||
By Provider
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{providers.map(([provider, count]) => (
|
||||
<Badge key={provider} variant="outline" className="text-[10px] h-5 px-1.5">
|
||||
{provider}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tokens */}
|
||||
{stats?.tokens && stats.tokens.total > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Tokens: {formatNumber(stats.tokens.input)} in /{' '}
|
||||
{formatNumber(stats.tokens.output)} out
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
@@ -46,20 +46,20 @@ export function UsageTrendChart({
|
||||
}, [data, granularity]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className={cn('h-[300px] w-full', className)} />;
|
||||
return <Skeleton className={cn('h-full w-full', className)} />;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className={cn('h-[300px] 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<div className={cn('w-full h-full', className)}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* React Query hook for CLIProxyAPI stats
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
/** CLIProxy usage statistics */
|
||||
export interface ClipproxyStats {
|
||||
totalRequests: number;
|
||||
tokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
};
|
||||
requestsByModel: Record<string, number>;
|
||||
requestsByProvider: Record<string, number>;
|
||||
quotaExceededCount: number;
|
||||
retryCount: number;
|
||||
collectedAt: string;
|
||||
}
|
||||
|
||||
/** CLIProxy running status */
|
||||
export interface ClipproxyStatus {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CLIProxy stats from API
|
||||
*/
|
||||
async function fetchClipproxyStats(): Promise<ClipproxyStats> {
|
||||
const response = await fetch('/api/cliproxy/stats');
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to fetch stats');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CLIProxy running status
|
||||
*/
|
||||
async function fetchClipproxyStatus(): Promise<ClipproxyStatus> {
|
||||
const response = await fetch('/api/cliproxy/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch status');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get CLIProxy running status
|
||||
*/
|
||||
export function useClipproxyStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-status'],
|
||||
queryFn: fetchClipproxyStatus,
|
||||
refetchInterval: 10000, // Check every 10 seconds
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get CLIProxy usage stats
|
||||
*/
|
||||
export function useClipproxyStats(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-stats'],
|
||||
queryFn: fetchClipproxyStats,
|
||||
enabled,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
retry: 1,
|
||||
staleTime: 10000, // Consider data stale after 10 seconds
|
||||
});
|
||||
}
|
||||
+137
-19
@@ -11,15 +11,16 @@ import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from '@/components/ui/popover';
|
||||
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
||||
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
||||
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
||||
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
||||
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||
import { UsageInsightsCard } from '@/components/analytics/usage-insights-card';
|
||||
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
|
||||
import { ClipproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
|
||||
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight, Lightbulb, Zap, Gauge, Database, CheckCircle2 } from 'lucide-react';
|
||||
import {
|
||||
useUsageSummary,
|
||||
useUsageTrends,
|
||||
@@ -30,7 +31,8 @@ import {
|
||||
useSessions,
|
||||
type ModelUsage,
|
||||
} from '@/hooks/use-usage';
|
||||
import { getModelColor } from '@/lib/utils';
|
||||
import { getModelColor, cn } from '@/lib/utils';
|
||||
import type { AnomalyType } from '@/hooks/use-usage';
|
||||
|
||||
// Format token count to human-readable (K/M/B)
|
||||
function formatTokens(num: number): string {
|
||||
@@ -40,6 +42,42 @@ function formatTokens(num: number): string {
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
// Anomaly type configuration for icons and colors
|
||||
const ANOMALY_CONFIG: Record<
|
||||
AnomalyType,
|
||||
{
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
label: string;
|
||||
}
|
||||
> = {
|
||||
high_input: {
|
||||
icon: Zap,
|
||||
color: 'text-yellow-600 dark:text-yellow-400',
|
||||
bgColor: 'bg-yellow-100 dark:bg-yellow-900/20',
|
||||
label: 'High Input',
|
||||
},
|
||||
high_io_ratio: {
|
||||
icon: Gauge,
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
bgColor: 'bg-orange-100 dark:bg-orange-900/20',
|
||||
label: 'High I/O Ratio',
|
||||
},
|
||||
cost_spike: {
|
||||
icon: DollarSign,
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bgColor: 'bg-red-100 dark:bg-red-900/20',
|
||||
label: 'Cost Spike',
|
||||
},
|
||||
high_cache_read: {
|
||||
icon: Database,
|
||||
color: 'text-cyan-600 dark:text-cyan-400',
|
||||
bgColor: 'bg-cyan-100 dark:bg-cyan-900/20',
|
||||
label: 'Heavy Caching',
|
||||
},
|
||||
};
|
||||
|
||||
export function AnalyticsPage() {
|
||||
// Default to last 30 days
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
||||
@@ -96,7 +134,7 @@ export function AnalyticsPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-5.5rem)] overflow-hidden p-4 gap-3">
|
||||
<div className="flex flex-col h-full overflow-hidden px-4 pt-4 pb-50 gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
@@ -114,6 +152,91 @@ export function AnalyticsPage() {
|
||||
{ label: 'All Time', range: { from: undefined, to: new Date() } },
|
||||
]}
|
||||
/>
|
||||
{/* Usage Insights Dropdown */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 h-8"
|
||||
title="Usage Insights"
|
||||
>
|
||||
<Lightbulb
|
||||
className={`w-3.5 h-3.5 ${
|
||||
insights?.summary?.totalAnomalies ? 'text-amber-500' : 'text-green-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs">Insights</span>
|
||||
{insights?.summary?.totalAnomalies ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="h-4 px-1 text-[10px] font-bold ml-0.5"
|
||||
>
|
||||
{insights.summary.totalAnomalies}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 px-1 text-[10px] font-bold ml-0.5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
|
||||
>
|
||||
OK
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
{isInsightsLoading ? (
|
||||
<div className="p-4 flex items-center justify-center">
|
||||
<div className="animate-pulse flex flex-col items-center gap-2 opacity-50">
|
||||
<div className="h-8 w-8 bg-muted rounded-full" />
|
||||
<div className="h-4 w-32 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
) : insights?.summary?.totalAnomalies ? (
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<div className="divide-y">
|
||||
{insights.anomalies?.map((anomaly, index) => {
|
||||
const config = ANOMALY_CONFIG[anomaly.type];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div key={index} className="p-3 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn('p-2 rounded-lg shrink-0', config.bgColor)}>
|
||||
<Icon className={cn('h-4 w-4', config.color)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium text-sm">{config.label}</p>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{anomaly.date}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{anomaly.message}
|
||||
</p>
|
||||
{anomaly.model && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 font-mono">
|
||||
{anomaly.model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center text-muted-foreground">
|
||||
<div className="w-10 h-10 mx-auto rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<p className="font-medium text-foreground text-sm">No anomalies detected</p>
|
||||
<p className="text-xs mt-1">Usage patterns look normal</p>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{lastUpdatedText && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Updated {lastUpdatedText}
|
||||
@@ -135,22 +258,22 @@ export function AnalyticsPage() {
|
||||
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0 gap-3">
|
||||
<div className="flex-1 flex flex-col min-h-0 gap-4">
|
||||
{/* Usage Trend Chart - Full Width */}
|
||||
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
|
||||
<CardHeader className="px-3 py-2">
|
||||
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
|
||||
<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
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} className="h-full" />
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
|
||||
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-10 gap-3 flex-1 min-h-0">
|
||||
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (2) + CLIProxy Stats (2) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto lg:h-[180px] shrink-0">
|
||||
{/* Cost by Model - 4/10 width with breakdown */}
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
|
||||
<CardHeader className="px-3 py-2">
|
||||
@@ -291,13 +414,8 @@ export function AnalyticsPage() {
|
||||
className="lg:col-span-2"
|
||||
/>
|
||||
|
||||
{/* Usage Insights - 2/10 width */}
|
||||
<UsageInsightsCard
|
||||
anomalies={insights?.anomalies}
|
||||
summary={insights?.summary}
|
||||
isLoading={isInsightsLoading}
|
||||
className="lg:col-span-2"
|
||||
/>
|
||||
{/* CLIProxy Stats - 2/10 width */}
|
||||
<ClipproxyStatsCard className="lg:col-span-2" />
|
||||
</div>
|
||||
|
||||
{/* Model Details Popover - positioned at cursor */}
|
||||
|
||||
Reference in New Issue
Block a user