feat(cliproxy-stats): Implement detailed stats fetching and integrate into UI

This commit is contained in:
kaitranntt
2025-12-16 05:57:27 -05:00
parent 6e4ee805da
commit 3216a0e847
5 changed files with 131 additions and 37 deletions
+81 -3
View File
@@ -7,10 +7,28 @@
import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator'; import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator';
/** Per-account usage statistics */
export interface AccountUsageStats {
/** Account email or identifier */
source: string;
/** Number of successful requests */
successCount: number;
/** Number of failed requests */
failureCount: number;
/** Total tokens used */
totalTokens: number;
/** Last request timestamp */
lastUsedAt?: string;
}
/** Usage statistics from CLIProxyAPI */ /** Usage statistics from CLIProxyAPI */
export interface CliproxyStats { export interface CliproxyStats {
/** Total number of requests processed */ /** Total number of requests processed */
totalRequests: number; totalRequests: number;
/** Total successful requests */
successCount: number;
/** Total failed requests */
failureCount: number;
/** Token counts */ /** Token counts */
tokens: { tokens: {
input: number; input: number;
@@ -21,6 +39,8 @@ export interface CliproxyStats {
requestsByModel: Record<string, number>; requestsByModel: Record<string, number>;
/** Requests grouped by provider */ /** Requests grouped by provider */
requestsByProvider: Record<string, number>; requestsByProvider: Record<string, number>;
/** Per-account usage breakdown */
accountStats: Record<string, AccountUsageStats>;
/** Number of quota exceeded (429) events */ /** Number of quota exceeded (429) events */
quotaExceededCount: number; quotaExceededCount: number;
/** Number of request retries */ /** Number of request retries */
@@ -29,6 +49,21 @@ export interface CliproxyStats {
collectedAt: string; collectedAt: string;
} }
/** Request detail from CLIProxyAPI */
interface RequestDetail {
timestamp: string;
source: string;
auth_index: number;
tokens: {
input_tokens: number;
output_tokens: number;
reasoning_tokens: number;
cached_tokens: number;
total_tokens: number;
};
failed: boolean;
}
/** Usage API response from CLIProxyAPI /v0/management/usage endpoint */ /** Usage API response from CLIProxyAPI /v0/management/usage endpoint */
interface UsageApiResponse { interface UsageApiResponse {
failed_requests?: number; failed_requests?: number;
@@ -47,6 +82,7 @@ interface UsageApiResponse {
{ {
total_requests?: number; total_requests?: number;
total_tokens?: number; total_tokens?: number;
details?: RequestDetail[];
} }
>; >;
} }
@@ -83,9 +119,14 @@ export async function fetchCliproxyStats(
const data = (await response.json()) as UsageApiResponse; const data = (await response.json()) as UsageApiResponse;
const usage = data.usage; const usage = data.usage;
// Extract models and providers from the nested API structure // Extract models, providers, and per-account stats from the nested API structure
const requestsByModel: Record<string, number> = {}; const requestsByModel: Record<string, number> = {};
const requestsByProvider: Record<string, number> = {}; const requestsByProvider: Record<string, number> = {};
const accountStats: Record<string, AccountUsageStats> = {};
let totalSuccessCount = 0;
let totalFailureCount = 0;
let totalInputTokens = 0;
let totalOutputTokens = 0;
if (usage?.apis) { if (usage?.apis) {
for (const [provider, providerData] of Object.entries(usage.apis)) { for (const [provider, providerData] of Object.entries(usage.apis)) {
@@ -93,6 +134,40 @@ export async function fetchCliproxyStats(
if (providerData.models) { if (providerData.models) {
for (const [model, modelData] of Object.entries(providerData.models)) { for (const [model, modelData] of Object.entries(providerData.models)) {
requestsByModel[model] = modelData.total_requests ?? 0; requestsByModel[model] = modelData.total_requests ?? 0;
// Aggregate per-account stats from request details
if (modelData.details) {
for (const detail of modelData.details) {
const source = detail.source || 'unknown';
// Initialize account stats if not exists
if (!accountStats[source]) {
accountStats[source] = {
source,
successCount: 0,
failureCount: 0,
totalTokens: 0,
};
}
// Update account stats
if (detail.failed) {
accountStats[source].failureCount++;
totalFailureCount++;
} else {
accountStats[source].successCount++;
totalSuccessCount++;
}
const tokens = detail.tokens?.total_tokens ?? 0;
accountStats[source].totalTokens += tokens;
accountStats[source].lastUsedAt = detail.timestamp;
// Aggregate token breakdowns
totalInputTokens += detail.tokens?.input_tokens ?? 0;
totalOutputTokens += detail.tokens?.output_tokens ?? 0;
}
}
} }
} }
} }
@@ -101,13 +176,16 @@ export async function fetchCliproxyStats(
// Normalize the response to our interface // Normalize the response to our interface
return { return {
totalRequests: usage?.total_requests ?? 0, totalRequests: usage?.total_requests ?? 0,
successCount: totalSuccessCount,
failureCount: totalFailureCount,
tokens: { tokens: {
input: 0, // API doesn't provide input/output breakdown input: totalInputTokens,
output: 0, output: totalOutputTokens,
total: usage?.total_tokens ?? 0, total: usage?.total_tokens ?? 0,
}, },
requestsByModel, requestsByModel,
requestsByProvider, requestsByProvider,
accountStats,
quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0, quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0,
retryCount: 0, // API doesn't track retries separately retryCount: 0, // API doesn't track retries separately
collectedAt: new Date().toISOString(), collectedAt: new Date().toISOString(),
+13 -22
View File
@@ -41,34 +41,25 @@ interface ConnectionEvent {
latencyMs?: number; latencyMs?: number;
} }
/** Generate mock connection events based on account data */ /** Generate connection events from real account data */
function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] { function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] {
const events: ConnectionEvent[] = []; const events: ConnectionEvent[] = [];
const now = new Date();
accounts.forEach((account) => { accounts.forEach((account) => {
// Generate events based on success/failure counts // Only show events for accounts that have actual request data
const successEvents = Math.min(account.successCount, 3); const hasActivity = account.successCount > 0 || account.failureCount > 0;
const failEvents = Math.min(account.failureCount, 2); if (!hasActivity) return;
for (let i = 0; i < successEvents; i++) { // Create a single consolidated event per account showing its current status
events.push({ const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date();
id: `${account.id}-s-${i}`, const hasFailures = account.failureCount > 0;
timestamp: new Date(now.getTime() - Math.random() * 3600000), // Last hour
accountEmail: account.email,
status: 'success',
latencyMs: Math.floor(Math.random() * 500) + 50,
});
}
for (let i = 0; i < failEvents; i++) { events.push({
events.push({ id: `${account.id}-status`,
id: `${account.id}-f-${i}`, timestamp: lastUsed,
timestamp: new Date(now.getTime() - Math.random() * 7200000), // Last 2 hours accountEmail: account.email,
accountEmail: account.email, status: hasFailures && account.failureCount > account.successCount ? 'failed' : 'success',
status: 'failed', });
});
}
}); });
// Sort by timestamp descending (most recent first) // Sort by timestamp descending (most recent first)
+16 -6
View File
@@ -6,6 +6,7 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats';
import { cn, STATUS_COLORS } from '@/lib/utils'; import { cn, STATUS_COLORS } from '@/lib/utils';
import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config'; import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -63,9 +64,16 @@ const ACCOUNT_COLORS = [
export function AuthMonitor() { export function AuthMonitor() {
const { data, isLoading, error } = useCliproxyAuth(); const { data, isLoading, error } = useCliproxyAuth();
const { data: statsData, isLoading: statsLoading } = useCliproxyStats();
const [selectedProvider, setSelectedProvider] = useState<string | null>(null); const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null); const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
// Build a map of account email -> usage stats from CLIProxy
const accountStatsMap = useMemo(() => {
if (!statsData?.accountStats) return new Map<string, AccountUsageStats>();
return new Map(Object.entries(statsData.accountStats));
}, [statsData?.accountStats]);
// Transform auth status data into account rows // Transform auth status data into account rows
const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => { const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => {
if (!data?.authStatus) { if (!data?.authStatus) {
@@ -96,9 +104,11 @@ export function AuthMonitor() {
if (!providerData) return; if (!providerData) return;
status.accounts?.forEach((account: OAuthAccount) => { status.accounts?.forEach((account: OAuthAccount) => {
// Mock stats - in production, fetch from CLIProxy /usage endpoint // Get real stats from CLIProxy - try email first, then id
const success = Math.floor(Math.random() * 2000) + 100; const accountEmail = account.email || account.id;
const failure = account.isDefault ? Math.floor(Math.random() * 50) : 0; const realStats = accountStatsMap.get(accountEmail);
const success = realStats?.successCount ?? 0;
const failure = realStats?.failureCount ?? 0;
tSuccess += success; tSuccess += success;
tFailure += failure; tFailure += failure;
providerData.success += success; providerData.success += success;
@@ -112,7 +122,7 @@ export function AuthMonitor() {
isDefault: account.isDefault, isDefault: account.isDefault,
successCount: success, successCount: success,
failureCount: failure, failureCount: failure,
lastUsedAt: account.lastUsedAt, lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length], color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
}; };
accountsList.push(row); accountsList.push(row);
@@ -144,7 +154,7 @@ export function AuthMonitor() {
totalRequests: tSuccess + tFailure, totalRequests: tSuccess + tFailure,
providerStats: providerStatsArr, providerStats: providerStatsArr,
}; };
}, [data?.authStatus]); }, [data?.authStatus, accountStatsMap]);
const overallSuccessRate = const overallSuccessRate =
totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100; totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100;
@@ -154,7 +164,7 @@ export function AuthMonitor() {
? providerStats.find((ps) => ps.provider === selectedProvider) ? providerStats.find((ps) => ps.provider === selectedProvider)
: null; : null;
if (isLoading) { if (isLoading || statsLoading) {
return ( return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm"> <div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
+3 -6
View File
@@ -31,20 +31,17 @@ export function ProfileDeck() {
); );
} }
// Mock data for demonstration // Use real profile data directly
const mockProfiles = profiles.map((profile, index: number) => ({ const normalizedProfiles = profiles.map((profile) => ({
...profile, ...profile,
configured: profile.configured ?? false, // Ensure configured is always boolean configured: profile.configured ?? false, // Ensure configured is always boolean
isActive: index === 0, // First profile is active
lastUsed: index === 0 ? '2 hours ago' : index === 1 ? '1 day ago' : undefined,
model: index === 0 ? 'claude-3.5-sonnet' : index === 1 ? 'claude-3-haiku' : undefined,
})); }));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold">Profiles</h2> <h2 className="text-lg font-semibold">Profiles</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{mockProfiles.map((profile) => ( {normalizedProfiles.map((profile) => (
<ProfileCard <ProfileCard
key={profile.name} key={profile.name}
profile={profile} profile={profile}
+18
View File
@@ -4,9 +4,25 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
/** Per-account usage statistics */
export interface AccountUsageStats {
/** Account email or identifier */
source: string;
/** Number of successful requests */
successCount: number;
/** Number of failed requests */
failureCount: number;
/** Total tokens used */
totalTokens: number;
/** Last request timestamp */
lastUsedAt?: string;
}
/** CLIProxy usage statistics */ /** CLIProxy usage statistics */
export interface CliproxyStats { export interface CliproxyStats {
totalRequests: number; totalRequests: number;
successCount: number;
failureCount: number;
tokens: { tokens: {
input: number; input: number;
output: number; output: number;
@@ -14,6 +30,8 @@ export interface CliproxyStats {
}; };
requestsByModel: Record<string, number>; requestsByModel: Record<string, number>;
requestsByProvider: Record<string, number>; requestsByProvider: Record<string, number>;
/** Per-account usage breakdown */
accountStats: Record<string, AccountUsageStats>;
quotaExceededCount: number; quotaExceededCount: number;
retryCount: number; retryCount: number;
collectedAt: string; collectedAt: string;