mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user