feat(ui): add cliproxy stats overview and enhance analytics components

- Add cliproxy-stats-overview component for stats display
- Update analytics page with usage insights integration
- Enhance cliproxy stats card and usage insights card
- Update cliproxy page with improved stats integration
- Refactor config-generator and stats-fetcher for better data handling
This commit is contained in:
kaitranntt
2025-12-11 03:31:09 -05:00
parent 8897e711de
commit c3b2d50269
8 changed files with 657 additions and 331 deletions
+7 -1
View File
@@ -23,7 +23,7 @@ interface ProviderSettings {
export const CLIPROXY_DEFAULT_PORT = 8317;
/** Internal API key for CCS-managed requests */
const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
/**
* Config version - bump when config format changes to trigger regeneration
@@ -132,6 +132,12 @@ logging-to-file: false
# Usage statistics for dashboard analytics
usage-statistics-enabled: true
# Management API for CCS dashboard (stats, control panel)
remote-management:
allow-remote: false
secret-key: "${CCS_INTERNAL_API_KEY}"
disable-control-panel: true
# Auto-retry on transient errors (403, 408, 500, 502, 503, 504)
request-retry: 3
max-retry-interval: 30
+52 -22
View File
@@ -5,7 +5,7 @@
* Requires usage-statistics-enabled: true in config.yaml.
*/
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
import { CCS_INTERNAL_API_KEY, CLIPROXY_DEFAULT_PORT } from './config-generator';
/** Usage statistics from CLIProxyAPI */
export interface ClipproxyStats {
@@ -29,17 +29,29 @@ export interface ClipproxyStats {
collectedAt: string;
}
/** Stats API response from CLIProxyAPI */
interface StatsApiResponse {
total_requests?: number;
tokens?: {
input?: number;
output?: number;
/** Usage API response from CLIProxyAPI /v0/management/usage endpoint */
interface UsageApiResponse {
failed_requests?: number;
usage?: {
total_requests?: number;
success_count?: number;
failure_count?: number;
total_tokens?: number;
apis?: Record<
string,
{
total_requests?: number;
total_tokens?: number;
models?: Record<
string,
{
total_requests?: number;
total_tokens?: number;
}
>;
}
>;
};
requests_by_model?: Record<string, number>;
requests_by_provider?: Record<string, number>;
quota_exceeded_count?: number;
retry_count?: number;
}
/**
@@ -54,10 +66,11 @@ export async function fetchClipproxyStats(
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`, {
const response = await fetch(`http://127.0.0.1:${port}/v0/management/usage`, {
signal: controller.signal,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${CCS_INTERNAL_API_KEY}`,
},
});
@@ -67,20 +80,36 @@ export async function fetchClipproxyStats(
return null;
}
const data = (await response.json()) as StatsApiResponse;
const data = (await response.json()) as UsageApiResponse;
const usage = data.usage;
// Extract models and providers from the nested API structure
const requestsByModel: Record<string, number> = {};
const requestsByProvider: Record<string, number> = {};
if (usage?.apis) {
for (const [provider, providerData] of Object.entries(usage.apis)) {
requestsByProvider[provider] = providerData.total_requests ?? 0;
if (providerData.models) {
for (const [model, modelData] of Object.entries(providerData.models)) {
requestsByModel[model] = modelData.total_requests ?? 0;
}
}
}
}
// Normalize the response to our interface
return {
totalRequests: data.total_requests ?? 0,
totalRequests: usage?.total_requests ?? 0,
tokens: {
input: data.tokens?.input ?? 0,
output: data.tokens?.output ?? 0,
total: (data.tokens?.input ?? 0) + (data.tokens?.output ?? 0),
input: 0, // API doesn't provide input/output breakdown
output: 0,
total: usage?.total_tokens ?? 0,
},
requestsByModel: data.requests_by_model ?? {},
requestsByProvider: data.requests_by_provider ?? {},
quotaExceededCount: data.quota_exceeded_count ?? 0,
retryCount: data.retry_count ?? 0,
requestsByModel,
requestsByProvider,
quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0,
retryCount: 0, // API doesn't track retries separately
collectedAt: new Date().toISOString(),
};
} catch {
@@ -99,7 +128,8 @@ export async function isClipproxyRunning(port: number = CLIPROXY_DEFAULT_PORT):
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
const response = await fetch(`http://127.0.0.1:${port}/health`, {
// Use root endpoint - CLIProxyAPI returns server info at /
const response = await fetch(`http://127.0.0.1:${port}/`, {
signal: controller.signal,
});
@@ -1,19 +1,18 @@
/**
* CLIProxy Stats Card Component
*
* Displays CLIProxyAPI usage statistics including:
* - Proxy status (running/stopped)
* - Total requests
* - Quota exceeded events
* - Request retries
* - Requests by provider
* Displays CLIProxyAPI usage statistics with:
* - Status indicator (running/offline)
* - Total requests with success rate ring
* - Total tokens usage
* - Model breakdown with usage bars
*/
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 { Server, Zap, Cpu, Coins } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useClipproxyStats, useClipproxyStatus } from '@/hooks/use-cliproxy-stats';
@@ -92,39 +91,19 @@ export function ClipproxyStatsCard({ className }: ClipproxyStatsCardProps) {
);
}
// 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',
},
];
// Calculate stats
const totalRequests = stats?.totalRequests ?? 0;
const failedRequests = stats?.quotaExceededCount ?? 0;
const successRequests = totalRequests - failedRequests;
const successRate = totalRequests > 0 ? Math.round((successRequests / totalRequests) * 100) : 100;
const totalTokens = stats?.tokens?.total ?? 0;
// Provider breakdown
const providers = Object.entries(stats?.requestsByProvider ?? {}).sort(
(a, b) => b[1] - a[1]
);
// Get model breakdown sorted by usage
const models = Object.entries(stats?.requestsByModel ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, 4); // Top 4 models
const maxModelRequests = models.length > 0 ? models[0][1] : 1;
return (
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
@@ -146,48 +125,88 @@ export function ClipproxyStatsCard({ className }: ClipproxyStatsCardProps) {
<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>
{/* Key metrics row */}
<div className="grid grid-cols-2 gap-2">
{/* Requests with success ring */}
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
<div className="relative">
<svg className="w-10 h-10 -rotate-90" viewBox="0 0 36 36">
<circle
cx="18"
cy="18"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-muted/30"
/>
<circle
cx="18"
cy="18"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeDasharray={`${successRate * 0.88} 88`}
strokeLinecap="round"
className={successRate >= 90 ? 'text-green-500' : 'text-amber-500'}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[8px] font-bold">
{successRate}%
</span>
</div>
<div className="min-w-0">
<div className="text-lg font-bold leading-none">
{formatNumber(totalRequests)}
</div>
<div className="text-[9px] text-muted-foreground mt-0.5">
{failedRequests > 0 ? `${failedRequests} failed` : 'All success'}
</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
{/* Tokens */}
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
<div className="p-1.5 rounded-md bg-purple-100 dark:bg-purple-900/20">
<Coins className="h-4 w-4 text-purple-600" />
</div>
<div className="min-w-0">
<div className="text-lg font-bold leading-none">{formatNumber(totalTokens)}</div>
<div className="text-[9px] text-muted-foreground mt-0.5">Total tokens</div>
</div>
</div>
</div>
{/* Model breakdown */}
{models.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-medium text-muted-foreground">
<Cpu className="h-3 w-3" />
Models Used
</div>
<div className="space-y-1">
{models.map(([model, count]) => {
const percentage = Math.round((count / maxModelRequests) * 100);
const displayName = formatModelName(model);
return (
<div key={model} className="group">
<div className="flex items-center justify-between text-[10px] mb-0.5">
<span className="truncate font-medium" title={model}>
{displayName}
</span>
<span className="text-muted-foreground shrink-0 ml-2">{count}</span>
</div>
<div className="h-1 bg-muted/50 rounded-full overflow-hidden">
<div
className="h-full bg-accent/70 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
@@ -197,6 +216,7 @@ export function ClipproxyStatsCard({ className }: ClipproxyStatsCardProps) {
);
}
/** Format large numbers with K/M suffix */
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
@@ -206,3 +226,27 @@ function formatNumber(num: number): string {
}
return num.toLocaleString();
}
/** Format model names for display (remove prefixes, shorten) */
function formatModelName(model: string): string {
// Remove common prefixes
let name = model
.replace(/^gemini-claude-/, '')
.replace(/^gemini-/, '')
.replace(/^claude-/, '')
.replace(/^anthropic\./, '')
.replace(/-thinking$/, ' Thinking');
// Capitalize first letter of each word
name = name
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
// Shorten long names
if (name.length > 20) {
name = name.slice(0, 18) + '...';
}
return name;
}
@@ -1,4 +1,4 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { CheckCircle2, Zap, Gauge, DollarSign, Database, Lightbulb } from 'lucide-react';
@@ -17,33 +17,33 @@ const ANOMALY_CONFIG: Record<
{
icon: React.ComponentType<{ className?: string }>;
color: string;
bgColor: string;
label: string;
description: string;
}
> = {
high_input: {
icon: Zap,
color: 'text-yellow-600 dark:text-yellow-400',
label: 'High Input',
description: 'Unusually high input token usage detected.',
color: 'text-amber-600 dark:text-amber-400',
bgColor: 'bg-amber-100 dark:bg-amber-900/20',
label: 'High Input Volume',
},
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',
description: 'Output tokens are significantly higher than input tokens.',
},
cost_spike: {
icon: DollarSign,
color: 'text-red-600 dark:text-red-400',
label: 'Cost Spike',
description: 'Daily cost is significantly higher than average.',
bgColor: 'bg-red-100 dark:bg-red-900/20',
label: 'Cost Spike Detected',
},
high_cache_read: {
icon: Database,
color: 'text-cyan-600 dark:text-cyan-400',
label: 'Heavy Caching',
description: 'High volume of cache read operations.',
bgColor: 'bg-cyan-100 dark:bg-cyan-900/20',
label: 'Heavy Cache Usage',
},
};
@@ -55,114 +55,119 @@ export function UsageInsightsCard({
}: UsageInsightsCardProps) {
if (isLoading) {
return (
<Card className={cn('flex flex-col h-full', className)}>
<CardHeader className="px-4 py-3 border-b">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Lightbulb className="w-4 h-4 text-muted-foreground" />
Usage Insights
</CardTitle>
</CardHeader>
<CardContent className="p-4 flex-1 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" />
<Card
className={cn('flex flex-col h-full border-none shadow-none bg-transparent', className)}
>
<div className="px-1 py-2">
<div className="flex items-center gap-2 mb-4">
<SkeletonIcon />
<div className="h-4 w-24 bg-muted rounded animate-pulse" />
</div>
</CardContent>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3">
<div className="w-8 h-8 rounded-lg bg-muted animate-pulse shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-3 w-3/4 bg-muted rounded animate-pulse" />
<div className="h-2 w-1/2 bg-muted rounded animate-pulse" />
</div>
</div>
))}
</div>
</div>
</Card>
);
}
const hasAnomalies = summary && summary.totalAnomalies > 0;
return (
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
<CardHeader className="px-4 py-3 border-b bg-muted/5">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Lightbulb
className={cn('w-4 h-4', hasAnomalies ? 'text-amber-500' : 'text-green-500')}
/>
Usage Insights
</CardTitle>
{hasAnomalies ? (
<Badge
variant="destructive"
className="h-5 px-1.5 text-[10px] uppercase font-bold tracking-wider"
>
Attention Needed
</Badge>
) : (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] uppercase font-bold tracking-wider text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
>
Healthy
</Badge>
)}
</div>
</CardHeader>
<CardContent className="p-0 flex-1 min-h-0 flex flex-col">
{hasAnomalies ? (
<ScrollArea className="flex-1">
<div className="divide-y">
{anomalies.map((anomaly, index) => {
const config = ANOMALY_CONFIG[anomaly.type];
const Icon = config.icon;
return (
<div key={index} className="p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-start gap-3">
<div
className={cn(
'p-2 rounded-lg shrink-0 bg-muted/30',
config.color
.replace('text-', 'bg-')
.replace('600', '100')
.replace('400', '900/20')
)}
>
<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 && (
<div className="pt-1">
<Badge
variant="secondary"
className="text-[10px] px-1 py-0 h-5 font-mono"
>
{anomaly.model}
</Badge>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center text-muted-foreground">
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3">
<CheckCircle2 className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
<p className="font-medium text-foreground">No anomalies detected</p>
<p className="text-xs mt-1 max-w-[200px]">
Your usage patterns look normal for the selected period.
</p>
if (!hasAnomalies) {
return (
<Card
className={cn('flex flex-col h-full border-none shadow-none bg-transparent', className)}
>
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center text-muted-foreground min-h-[200px]">
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3 ring-4 ring-green-50 dark:ring-green-900/10">
<CheckCircle2 className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
)}
</CardContent>
<h3 className="font-medium text-foreground text-sm">All Systems Nominal</h3>
<p className="text-xs mt-1.5 max-w-[200px] leading-relaxed">
Your usage patterns are within normal ranges for the selected period.
</p>
</div>
</Card>
);
}
return (
<Card className={cn('flex flex-col h-full border-none shadow-none bg-transparent', className)}>
<div className="px-1 py-2 flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Lightbulb className="w-4 h-4 text-amber-500" />
<h3 className="font-semibold text-sm">Usage Insights</h3>
</div>
<Badge
variant="outline"
className="h-5 px-2 text-[10px] font-medium border-amber-200 bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-400"
>
{summary.totalAnomalies} {summary.totalAnomalies === 1 ? 'Alert' : 'Alerts'}
</Badge>
</div>
<ScrollArea className="flex-1 -mx-2 px-2">
<div className="space-y-1 pb-2">
{anomalies.map((anomaly, index) => {
const config = ANOMALY_CONFIG[anomaly.type];
const Icon = config.icon;
return (
<div
key={index}
className="group p-3 rounded-lg hover:bg-muted/50 transition-colors border border-transparent hover:border-border/50"
>
<div className="flex items-start gap-3">
<div
className={cn(
'p-2 rounded-md shrink-0 ring-1 ring-inset ring-black/5 dark:ring-white/10',
config.bgColor
)}
>
<Icon className={cn('h-3.5 w-3.5', 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 text-foreground/90">{config.label}</p>
<span className="text-[10px] text-muted-foreground tabular-nums opacity-60 group-hover:opacity-100 transition-opacity">
{anomaly.date}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{anomaly.message}
</p>
{anomaly.model && (
<div className="pt-1.5">
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-5 font-mono bg-muted/50 text-muted-foreground group-hover:bg-background group-hover:text-foreground transition-colors"
>
{anomaly.model}
</Badge>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
</Card>
);
}
function SkeletonIcon() {
return <div className="w-4 h-4 bg-muted rounded-full animate-pulse" />;
}
@@ -0,0 +1,339 @@
/**
* CLIProxy Stats Overview Component
*
* Full-width dashboard section showing comprehensive CLIProxyAPI statistics.
* Features:
* - Status indicator with uptime
* - Request metrics with success rate visualization
* - Token usage with cost estimation
* - Model breakdown with usage distribution
* - Session history
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Server,
Zap,
ZapOff,
Activity,
Coins,
Cpu,
CheckCircle2,
XCircle,
TrendingUp,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useClipproxyStats, useClipproxyStatus } from '@/hooks/use-cliproxy-stats';
interface ClipproxyStatsOverviewProps {
className?: string;
}
export function ClipproxyStatsOverview({ className }: ClipproxyStatsOverviewProps) {
const { data: status, isLoading: statusLoading } = useClipproxyStatus();
const { data: stats, isLoading: statsLoading, error } = useClipproxyStats(status?.running);
const isLoading = statusLoading || (status?.running && statsLoading);
// Loading state
if (isLoading) {
return (
<div className={cn('space-y-4', className)}>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-8 w-24" />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
</div>
);
}
// Offline state
if (!status?.running) {
return (
<div className={cn('space-y-4', className)}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
<Activity className="h-5 w-5" />
Session Statistics
</h2>
<p className="text-sm text-muted-foreground">
Real-time usage metrics from CLIProxyAPI
</p>
</div>
<Badge variant="secondary" className="w-fit gap-1.5">
<ZapOff className="h-3.5 w-3.5" />
Offline
</Badge>
</div>
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Server className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="font-medium mb-1">No Active Session</h3>
<p className="text-sm text-muted-foreground max-w-md">
Start a CLIProxy session using{' '}
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs gemini</code>,{' '}
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs codex</code>,
or <code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs agy</code>{' '}
to view real-time statistics.
</p>
</CardContent>
</Card>
</div>
);
}
// Error state
if (error) {
return (
<div className={cn('space-y-4', className)}>
<Card className="border-destructive/50">
<CardContent className="flex items-center gap-4 py-6">
<XCircle className="h-8 w-8 text-destructive shrink-0" />
<div>
<h3 className="font-medium">Failed to Load Statistics</h3>
<p className="text-sm text-muted-foreground">{error.message}</p>
</div>
</CardContent>
</Card>
</div>
);
}
// Calculate derived stats
const totalRequests = stats?.totalRequests ?? 0;
const failedRequests = stats?.quotaExceededCount ?? 0;
const successRequests = totalRequests - failedRequests;
const successRate = totalRequests > 0 ? Math.round((successRequests / totalRequests) * 100) : 100;
const totalTokens = stats?.tokens?.total ?? 0;
// Get model breakdown sorted by usage
const models = Object.entries(stats?.requestsByModel ?? {}).sort((a, b) => b[1] - a[1]);
const maxModelRequests = models.length > 0 ? models[0][1] : 1;
// Define color palette for models
const modelColors = [
'bg-blue-500',
'bg-purple-500',
'bg-amber-500',
'bg-emerald-500',
'bg-rose-500',
'bg-cyan-500',
];
return (
<div className={cn('space-y-4', className)}>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
<Activity className="h-5 w-5" />
Session Statistics
</h2>
<p className="text-sm text-muted-foreground">Real-time usage metrics from CLIProxyAPI</p>
</div>
<Badge
variant="outline"
className="w-fit gap-1.5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
>
<Zap className="h-3.5 w-3.5" />
Running
</Badge>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Requests Card */}
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Total Requests</p>
<p className="text-2xl font-bold">{formatNumber(totalRequests)}</p>
<div className="flex items-center gap-1.5 text-[10px]">
<CheckCircle2 className="h-3 w-3 text-green-500" />
<span className="text-muted-foreground">{successRequests} success</span>
</div>
</div>
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/20">
<TrendingUp className="h-4 w-4 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
{/* Success Rate Card */}
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<p className="text-xs font-medium text-muted-foreground">Success Rate</p>
<p className="text-2xl font-bold">{successRate}%</p>
<div className="h-1.5 mt-2 bg-muted/50 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
successRate >= 90 ? 'bg-green-500' : 'bg-amber-500'
)}
style={{ width: `${successRate}%` }}
/>
</div>
</div>
<div
className={cn(
'p-2 rounded-lg',
successRate >= 90
? 'bg-green-100 dark:bg-green-900/20'
: 'bg-amber-100 dark:bg-amber-900/20'
)}
>
{successRate >= 90 ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-amber-600" />
)}
</div>
</div>
</CardContent>
</Card>
{/* Tokens Card */}
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Total Tokens</p>
<p className="text-2xl font-bold">{formatNumber(totalTokens)}</p>
<p className="text-[10px] text-muted-foreground">
~${estimateCost(totalTokens).toFixed(2)} estimated
</p>
</div>
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/20">
<Coins className="h-4 w-4 text-purple-600" />
</div>
</div>
</CardContent>
</Card>
{/* Models Card */}
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Models Used</p>
<p className="text-2xl font-bold">{models.length}</p>
<p className="text-[10px] text-muted-foreground">
{models.length > 0 ? formatModelName(models[0][0]) : 'None'}
</p>
</div>
<div className="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-900/20">
<Cpu className="h-4 w-4 text-cyan-600" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Model Breakdown */}
{models.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" />
Model Usage Distribution
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-3">
{models.map(([model, count], index) => {
const percentage = Math.round((count / totalRequests) * 100);
const barPercentage = Math.round((count / maxModelRequests) * 100);
const displayName = formatModelName(model);
const colorClass = modelColors[index % modelColors.length];
return (
<div key={model} className="group">
<div className="flex items-center justify-between text-sm mb-1.5">
<div className="flex items-center gap-2 min-w-0">
<div className={cn('w-2 h-2 rounded-full shrink-0', colorClass)} />
<span className="font-medium truncate" title={model}>
{displayName}
</span>
</div>
<div className="flex items-center gap-3 text-muted-foreground shrink-0">
<span>{count} requests</span>
<span className="text-xs font-medium w-10 text-right">{percentage}%</span>
</div>
</div>
<div className="h-2 bg-muted/50 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
colorClass
)}
style={{ width: `${barPercentage}%` }}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
);
}
/** Format large numbers with K/M suffix */
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();
}
/** Format model names for display */
function formatModelName(model: string): string {
let name = model
.replace(/^gemini-claude-/, '')
.replace(/^gemini-/, '')
.replace(/^claude-/, '')
.replace(/^anthropic\./, '')
.replace(/-thinking$/, ' Thinking');
name = name
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
if (name.length > 25) {
name = name.slice(0, 23) + '...';
}
return name;
}
/** Estimate cost based on token count (rough average) */
function estimateCost(tokens: number): number {
// Average cost per 1M tokens across models (~$3 for input, ~$15 for output)
// Assuming 30% input, 70% output ratio
const avgCostPerMillion = 3 * 0.3 + 15 * 0.7;
return (tokens / 1000000) * avgCostPerMillion;
}
+3 -19
View File
@@ -4,7 +4,7 @@
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
*/
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useState, useCallback, useMemo } from 'react';
import Editor from 'react-simple-code-editor';
import { Highlight, themes } from 'prism-react-renderer';
import { useTheme } from '@/hooks/use-theme';
@@ -74,18 +74,6 @@ export function CodeEditor({
const { isDark } = useTheme();
const [isFocused, setIsFocused] = useState(false);
const [isMasked, setIsMasked] = useState(true);
// Force Editor remount when theme changes (works around react-simple-code-editor caching)
const [editorKey, setEditorKey] = useState(0);
const isFirstRender = useRef(true);
useEffect(() => {
// Skip first render, only trigger on theme changes
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setEditorKey((k) => k + 1);
}, [isDark]);
// Validate on every change for JSON
const validation = useMemo(() => {
@@ -138,11 +126,7 @@ export function CodeEditor({
// Reset flag on commas or new keys (handled by property check),
// but persist through colons and whitespace
else if (token.types.includes('punctuation')) {
if (
token.content !== ':' &&
token.content !== '[' &&
token.content !== '{'
) {
if (token.content !== ':' && token.content !== '[' && token.content !== '{') {
nextValueIsSensitive = false;
}
}
@@ -185,7 +169,7 @@ export function CodeEditor({
value={value}
onValueChange={readonly ? () => {} : onChange}
highlight={highlightCode}
key={editorKey}
key={isDark ? 'dark-editor' : 'light-editor'}
padding={12}
disabled={readonly}
onFocus={() => setIsFocused(true)}
+16 -102
View File
@@ -11,7 +11,6 @@ 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 { 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';
@@ -20,7 +19,9 @@ import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-char
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
import { ClipproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight, Lightbulb, Zap, Gauge, Database, CheckCircle2 } from 'lucide-react';
import { UsageInsightsCard } from '@/components/analytics/usage-insights-card';
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight, Lightbulb } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
useUsageSummary,
useUsageTrends,
@@ -31,8 +32,7 @@ import {
useSessions,
type ModelUsage,
} from '@/hooks/use-usage';
import { getModelColor, cn } from '@/lib/utils';
import type { AnomalyType } from '@/hooks/use-usage';
import { getModelColor } from '@/lib/utils';
// Format token count to human-readable (K/M/B)
function formatTokens(num: number): string {
@@ -42,42 +42,6 @@ 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>({
@@ -152,15 +116,11 @@ export function AnalyticsPage() {
{ label: 'All Time', range: { from: undefined, to: new Date() } },
]}
/>
{/* Usage Insights Dropdown */}
{/* Usage Insights Card (replaces popover) */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5 h-8"
title="Usage Insights"
>
<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'
@@ -168,10 +128,7 @@ export function AnalyticsPage() {
/>
<span className="text-xs">Insights</span>
{insights?.summary?.totalAnomalies ? (
<Badge
variant="destructive"
className="h-4 px-1 text-[10px] font-bold ml-0.5"
>
<Badge variant="destructive" className="h-4 px-1 text-[10px] font-bold ml-0.5">
{insights.summary.totalAnomalies}
</Badge>
) : (
@@ -184,59 +141,16 @@ export function AnalyticsPage() {
)}
</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 className="w-96 p-0 border-0 shadow-lg" align="end">
<UsageInsightsCard
anomalies={insights?.anomalies}
summary={insights?.summary}
isLoading={isInsightsLoading}
className="border-0 shadow-none max-h-[400px]"
/>
</PopoverContent>
</Popover>
{lastUpdatedText && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
Updated {lastUpdatedText}
+4
View File
@@ -18,6 +18,7 @@ import { Plus, Check, X, User, ChevronDown, Star, Trash2, Sparkles } from 'lucid
import { CliproxyTable } from '@/components/cliproxy-table';
import { QuickSetupWizard } from '@/components/quick-setup-wizard';
import { AddAccountDialog } from '@/components/add-account-dialog';
import { ClipproxyStatsOverview } from '@/components/cliproxy-stats-overview';
import {
useCliproxy,
useCliproxyAuth,
@@ -186,6 +187,9 @@ export function CliproxyPage() {
</Button>
</div>
{/* Session Statistics */}
<ClipproxyStatsOverview />
{/* Built-in Profiles with Account Management */}
<div className="space-y-4">
<div>