mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +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;
|
export const CLIPROXY_DEFAULT_PORT = 8317;
|
||||||
|
|
||||||
/** Internal API key for CCS-managed requests */
|
/** 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
|
* 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 for dashboard analytics
|
||||||
usage-statistics-enabled: true
|
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)
|
# Auto-retry on transient errors (403, 408, 500, 502, 503, 504)
|
||||||
request-retry: 3
|
request-retry: 3
|
||||||
max-retry-interval: 30
|
max-retry-interval: 30
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Requires usage-statistics-enabled: true in config.yaml.
|
* 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 */
|
/** Usage statistics from CLIProxyAPI */
|
||||||
export interface ClipproxyStats {
|
export interface ClipproxyStats {
|
||||||
@@ -29,17 +29,29 @@ export interface ClipproxyStats {
|
|||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stats API response from CLIProxyAPI */
|
/** Usage API response from CLIProxyAPI /v0/management/usage endpoint */
|
||||||
interface StatsApiResponse {
|
interface UsageApiResponse {
|
||||||
total_requests?: number;
|
failed_requests?: number;
|
||||||
tokens?: {
|
usage?: {
|
||||||
input?: number;
|
total_requests?: number;
|
||||||
output?: 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 controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
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,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${CCS_INTERNAL_API_KEY}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,20 +80,36 @@ export async function fetchClipproxyStats(
|
|||||||
return null;
|
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
|
// Normalize the response to our interface
|
||||||
return {
|
return {
|
||||||
totalRequests: data.total_requests ?? 0,
|
totalRequests: usage?.total_requests ?? 0,
|
||||||
tokens: {
|
tokens: {
|
||||||
input: data.tokens?.input ?? 0,
|
input: 0, // API doesn't provide input/output breakdown
|
||||||
output: data.tokens?.output ?? 0,
|
output: 0,
|
||||||
total: (data.tokens?.input ?? 0) + (data.tokens?.output ?? 0),
|
total: usage?.total_tokens ?? 0,
|
||||||
},
|
},
|
||||||
requestsByModel: data.requests_by_model ?? {},
|
requestsByModel,
|
||||||
requestsByProvider: data.requests_by_provider ?? {},
|
requestsByProvider,
|
||||||
quotaExceededCount: data.quota_exceeded_count ?? 0,
|
quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0,
|
||||||
retryCount: data.retry_count ?? 0,
|
retryCount: 0, // API doesn't track retries separately
|
||||||
collectedAt: new Date().toISOString(),
|
collectedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -99,7 +128,8 @@ export async function isClipproxyRunning(port: number = CLIPROXY_DEFAULT_PORT):
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
|
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,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* CLIProxy Stats Card Component
|
* CLIProxy Stats Card Component
|
||||||
*
|
*
|
||||||
* Displays CLIProxyAPI usage statistics including:
|
* Displays CLIProxyAPI usage statistics with:
|
||||||
* - Proxy status (running/stopped)
|
* - Status indicator (running/offline)
|
||||||
* - Total requests
|
* - Total requests with success rate ring
|
||||||
* - Quota exceeded events
|
* - Total tokens usage
|
||||||
* - Request retries
|
* - Model breakdown with usage bars
|
||||||
* - Requests by provider
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
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 { cn } from '@/lib/utils';
|
||||||
import { useClipproxyStats, useClipproxyStatus } from '@/hooks/use-cliproxy-stats';
|
import { useClipproxyStats, useClipproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||||
|
|
||||||
@@ -92,39 +91,19 @@ export function ClipproxyStatsCard({ className }: ClipproxyStatsCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats available
|
// Calculate stats
|
||||||
const statItems = [
|
const totalRequests = stats?.totalRequests ?? 0;
|
||||||
{
|
const failedRequests = stats?.quotaExceededCount ?? 0;
|
||||||
label: 'Requests',
|
const successRequests = totalRequests - failedRequests;
|
||||||
value: stats?.totalRequests ?? 0,
|
const successRate = totalRequests > 0 ? Math.round((successRequests / totalRequests) * 100) : 100;
|
||||||
icon: Activity,
|
const totalTokens = stats?.tokens?.total ?? 0;
|
||||||
color: 'text-blue-600',
|
|
||||||
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Quota',
|
|
||||||
value: stats?.quotaExceededCount ?? 0,
|
|
||||||
icon: AlertTriangle,
|
|
||||||
color: stats?.quotaExceededCount ? 'text-amber-600' : 'text-muted-foreground',
|
|
||||||
bgColor:
|
|
||||||
stats?.quotaExceededCount
|
|
||||||
? 'bg-amber-100 dark:bg-amber-900/20'
|
|
||||||
: 'bg-muted',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Retries',
|
|
||||||
value: stats?.retryCount ?? 0,
|
|
||||||
icon: RefreshCw,
|
|
||||||
color: stats?.retryCount ? 'text-orange-600' : 'text-muted-foreground',
|
|
||||||
bgColor:
|
|
||||||
stats?.retryCount ? 'bg-orange-100 dark:bg-orange-900/20' : 'bg-muted',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Provider breakdown
|
// Get model breakdown sorted by usage
|
||||||
const providers = Object.entries(stats?.requestsByProvider ?? {}).sort(
|
const models = Object.entries(stats?.requestsByModel ?? {})
|
||||||
(a, b) => b[1] - a[1]
|
.sort((a, b) => b[1] - a[1])
|
||||||
);
|
.slice(0, 4); // Top 4 models
|
||||||
|
|
||||||
|
const maxModelRequests = models.length > 0 ? models[0][1] : 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
|
<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">
|
<CardContent className="p-0 flex-1 min-h-0">
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="p-3 space-y-3">
|
<div className="p-3 space-y-3">
|
||||||
{/* Stats row */}
|
{/* Key metrics row */}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{statItems.map((item) => {
|
{/* Requests with success ring */}
|
||||||
const Icon = item.icon;
|
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
|
||||||
return (
|
<div className="relative">
|
||||||
<div
|
<svg className="w-10 h-10 -rotate-90" viewBox="0 0 36 36">
|
||||||
key={item.label}
|
<circle
|
||||||
className="flex flex-col items-center p-2 rounded-lg bg-muted/50"
|
cx="18"
|
||||||
>
|
cy="18"
|
||||||
<div className={cn('p-1 rounded-md mb-1', item.bgColor)}>
|
r="14"
|
||||||
<Icon className={cn('h-3 w-3', item.color)} />
|
fill="none"
|
||||||
</div>
|
stroke="currentColor"
|
||||||
<span className="text-sm font-bold">{item.value}</span>
|
strokeWidth="3"
|
||||||
<span className="text-[9px] text-muted-foreground">
|
className="text-muted/30"
|
||||||
{item.label}
|
/>
|
||||||
</span>
|
<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>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tokens */}
|
{/* Tokens */}
|
||||||
{stats?.tokens && stats.tokens.total > 0 && (
|
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="p-1.5 rounded-md bg-purple-100 dark:bg-purple-900/20">
|
||||||
Tokens: {formatNumber(stats.tokens.input)} in /{' '}
|
<Coins className="h-4 w-4 text-purple-600" />
|
||||||
{formatNumber(stats.tokens.output)} out
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -197,6 +216,7 @@ export function ClipproxyStatsCard({ className }: ClipproxyStatsCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format large numbers with K/M suffix */
|
||||||
function formatNumber(num: number): string {
|
function formatNumber(num: number): string {
|
||||||
if (num >= 1000000) {
|
if (num >= 1000000) {
|
||||||
return `${(num / 1000000).toFixed(1)}M`;
|
return `${(num / 1000000).toFixed(1)}M`;
|
||||||
@@ -206,3 +226,27 @@ function formatNumber(num: number): string {
|
|||||||
}
|
}
|
||||||
return num.toLocaleString();
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { CheckCircle2, Zap, Gauge, DollarSign, Database, Lightbulb } from 'lucide-react';
|
import { CheckCircle2, Zap, Gauge, DollarSign, Database, Lightbulb } from 'lucide-react';
|
||||||
@@ -17,33 +17,33 @@ const ANOMALY_CONFIG: Record<
|
|||||||
{
|
{
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
color: string;
|
color: string;
|
||||||
|
bgColor: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
high_input: {
|
high_input: {
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
color: 'text-yellow-600 dark:text-yellow-400',
|
color: 'text-amber-600 dark:text-amber-400',
|
||||||
label: 'High Input',
|
bgColor: 'bg-amber-100 dark:bg-amber-900/20',
|
||||||
description: 'Unusually high input token usage detected.',
|
label: 'High Input Volume',
|
||||||
},
|
},
|
||||||
high_io_ratio: {
|
high_io_ratio: {
|
||||||
icon: Gauge,
|
icon: Gauge,
|
||||||
color: 'text-orange-600 dark:text-orange-400',
|
color: 'text-orange-600 dark:text-orange-400',
|
||||||
|
bgColor: 'bg-orange-100 dark:bg-orange-900/20',
|
||||||
label: 'High I/O Ratio',
|
label: 'High I/O Ratio',
|
||||||
description: 'Output tokens are significantly higher than input tokens.',
|
|
||||||
},
|
},
|
||||||
cost_spike: {
|
cost_spike: {
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
color: 'text-red-600 dark:text-red-400',
|
color: 'text-red-600 dark:text-red-400',
|
||||||
label: 'Cost Spike',
|
bgColor: 'bg-red-100 dark:bg-red-900/20',
|
||||||
description: 'Daily cost is significantly higher than average.',
|
label: 'Cost Spike Detected',
|
||||||
},
|
},
|
||||||
high_cache_read: {
|
high_cache_read: {
|
||||||
icon: Database,
|
icon: Database,
|
||||||
color: 'text-cyan-600 dark:text-cyan-400',
|
color: 'text-cyan-600 dark:text-cyan-400',
|
||||||
label: 'Heavy Caching',
|
bgColor: 'bg-cyan-100 dark:bg-cyan-900/20',
|
||||||
description: 'High volume of cache read operations.',
|
label: 'Heavy Cache Usage',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,114 +55,119 @@ export function UsageInsightsCard({
|
|||||||
}: UsageInsightsCardProps) {
|
}: UsageInsightsCardProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn('flex flex-col h-full', className)}>
|
<Card
|
||||||
<CardHeader className="px-4 py-3 border-b">
|
className={cn('flex flex-col h-full border-none shadow-none bg-transparent', className)}
|
||||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
>
|
||||||
<Lightbulb className="w-4 h-4 text-muted-foreground" />
|
<div className="px-1 py-2">
|
||||||
Usage Insights
|
<div className="flex items-center gap-2 mb-4">
|
||||||
</CardTitle>
|
<SkeletonIcon />
|
||||||
</CardHeader>
|
<div className="h-4 w-24 bg-muted rounded animate-pulse" />
|
||||||
<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" />
|
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAnomalies = summary && summary.totalAnomalies > 0;
|
const hasAnomalies = summary && summary.totalAnomalies > 0;
|
||||||
|
|
||||||
return (
|
if (!hasAnomalies) {
|
||||||
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
|
return (
|
||||||
<CardHeader className="px-4 py-3 border-b bg-muted/5">
|
<Card
|
||||||
<div className="flex items-center justify-between">
|
className={cn('flex flex-col h-full border-none shadow-none bg-transparent', className)}
|
||||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
>
|
||||||
<Lightbulb
|
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center text-muted-foreground min-h-[200px]">
|
||||||
className={cn('w-4 h-4', hasAnomalies ? 'text-amber-500' : 'text-green-500')}
|
<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" />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<h3 className="font-medium text-foreground text-sm">All Systems Nominal</h3>
|
||||||
</CardContent>
|
<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>
|
</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)
|
* 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 Editor from 'react-simple-code-editor';
|
||||||
import { Highlight, themes } from 'prism-react-renderer';
|
import { Highlight, themes } from 'prism-react-renderer';
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
@@ -74,18 +74,6 @@ export function CodeEditor({
|
|||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const [isMasked, setIsMasked] = useState(true);
|
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
|
// Validate on every change for JSON
|
||||||
const validation = useMemo(() => {
|
const validation = useMemo(() => {
|
||||||
@@ -138,11 +126,7 @@ export function CodeEditor({
|
|||||||
// Reset flag on commas or new keys (handled by property check),
|
// Reset flag on commas or new keys (handled by property check),
|
||||||
// but persist through colons and whitespace
|
// but persist through colons and whitespace
|
||||||
else if (token.types.includes('punctuation')) {
|
else if (token.types.includes('punctuation')) {
|
||||||
if (
|
if (token.content !== ':' && token.content !== '[' && token.content !== '{') {
|
||||||
token.content !== ':' &&
|
|
||||||
token.content !== '[' &&
|
|
||||||
token.content !== '{'
|
|
||||||
) {
|
|
||||||
nextValueIsSensitive = false;
|
nextValueIsSensitive = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +169,7 @@ export function CodeEditor({
|
|||||||
value={value}
|
value={value}
|
||||||
onValueChange={readonly ? () => {} : onChange}
|
onValueChange={readonly ? () => {} : onChange}
|
||||||
highlight={highlightCode}
|
highlight={highlightCode}
|
||||||
key={editorKey}
|
key={isDark ? 'dark-editor' : 'light-editor'}
|
||||||
padding={12}
|
padding={12}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
onFocus={() => setIsFocused(true)}
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from '@/components/ui/popover';
|
||||||
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
||||||
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
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 { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||||
import { ClipproxyStatsCard } from '@/components/analytics/cliproxy-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 {
|
import {
|
||||||
useUsageSummary,
|
useUsageSummary,
|
||||||
useUsageTrends,
|
useUsageTrends,
|
||||||
@@ -31,8 +32,7 @@ import {
|
|||||||
useSessions,
|
useSessions,
|
||||||
type ModelUsage,
|
type ModelUsage,
|
||||||
} from '@/hooks/use-usage';
|
} from '@/hooks/use-usage';
|
||||||
import { getModelColor, cn } from '@/lib/utils';
|
import { getModelColor } from '@/lib/utils';
|
||||||
import type { AnomalyType } from '@/hooks/use-usage';
|
|
||||||
|
|
||||||
// Format token count to human-readable (K/M/B)
|
// Format token count to human-readable (K/M/B)
|
||||||
function formatTokens(num: number): string {
|
function formatTokens(num: number): string {
|
||||||
@@ -42,42 +42,6 @@ function formatTokens(num: number): string {
|
|||||||
return num.toString();
|
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() {
|
export function AnalyticsPage() {
|
||||||
// Default to last 30 days
|
// Default to last 30 days
|
||||||
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
||||||
@@ -152,15 +116,11 @@ export function AnalyticsPage() {
|
|||||||
{ label: 'All Time', range: { from: undefined, to: new Date() } },
|
{ label: 'All Time', range: { from: undefined, to: new Date() } },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{/* Usage Insights Dropdown */}
|
|
||||||
|
{/* Usage Insights Card (replaces popover) */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" size="sm" className="gap-1.5 h-8" title="Usage Insights">
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-1.5 h-8"
|
|
||||||
title="Usage Insights"
|
|
||||||
>
|
|
||||||
<Lightbulb
|
<Lightbulb
|
||||||
className={`w-3.5 h-3.5 ${
|
className={`w-3.5 h-3.5 ${
|
||||||
insights?.summary?.totalAnomalies ? 'text-amber-500' : 'text-green-500'
|
insights?.summary?.totalAnomalies ? 'text-amber-500' : 'text-green-500'
|
||||||
@@ -168,10 +128,7 @@ export function AnalyticsPage() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs">Insights</span>
|
<span className="text-xs">Insights</span>
|
||||||
{insights?.summary?.totalAnomalies ? (
|
{insights?.summary?.totalAnomalies ? (
|
||||||
<Badge
|
<Badge variant="destructive" className="h-4 px-1 text-[10px] font-bold ml-0.5">
|
||||||
variant="destructive"
|
|
||||||
className="h-4 px-1 text-[10px] font-bold ml-0.5"
|
|
||||||
>
|
|
||||||
{insights.summary.totalAnomalies}
|
{insights.summary.totalAnomalies}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
@@ -184,59 +141,16 @@ export function AnalyticsPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80 p-0" align="end">
|
<PopoverContent className="w-96 p-0 border-0 shadow-lg" align="end">
|
||||||
{isInsightsLoading ? (
|
<UsageInsightsCard
|
||||||
<div className="p-4 flex items-center justify-center">
|
anomalies={insights?.anomalies}
|
||||||
<div className="animate-pulse flex flex-col items-center gap-2 opacity-50">
|
summary={insights?.summary}
|
||||||
<div className="h-8 w-8 bg-muted rounded-full" />
|
isLoading={isInsightsLoading}
|
||||||
<div className="h-4 w-32 bg-muted rounded" />
|
className="border-0 shadow-none max-h-[400px]"
|
||||||
</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>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{lastUpdatedText && (
|
{lastUpdatedText && (
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
Updated {lastUpdatedText}
|
Updated {lastUpdatedText}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Plus, Check, X, User, ChevronDown, Star, Trash2, Sparkles } from 'lucid
|
|||||||
import { CliproxyTable } from '@/components/cliproxy-table';
|
import { CliproxyTable } from '@/components/cliproxy-table';
|
||||||
import { QuickSetupWizard } from '@/components/quick-setup-wizard';
|
import { QuickSetupWizard } from '@/components/quick-setup-wizard';
|
||||||
import { AddAccountDialog } from '@/components/add-account-dialog';
|
import { AddAccountDialog } from '@/components/add-account-dialog';
|
||||||
|
import { ClipproxyStatsOverview } from '@/components/cliproxy-stats-overview';
|
||||||
import {
|
import {
|
||||||
useCliproxy,
|
useCliproxy,
|
||||||
useCliproxyAuth,
|
useCliproxyAuth,
|
||||||
@@ -186,6 +187,9 @@ export function CliproxyPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Session Statistics */}
|
||||||
|
<ClipproxyStatsOverview />
|
||||||
|
|
||||||
{/* Built-in Profiles with Account Management */}
|
{/* Built-in Profiles with Account Management */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user