From c3b2d50269b5ad515409cc562e204d94ab65dd87 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 11 Dec 2025 03:31:09 -0500 Subject: [PATCH] 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 --- src/cliproxy/config-generator.ts | 8 +- src/cliproxy/stats-fetcher.ts | 74 ++-- .../analytics/cliproxy-stats-card.tsx | 198 ++++++---- .../analytics/usage-insights-card.tsx | 225 ++++++------ ui/src/components/cliproxy-stats-overview.tsx | 339 ++++++++++++++++++ ui/src/components/code-editor.tsx | 22 +- ui/src/pages/analytics.tsx | 118 +----- ui/src/pages/cliproxy.tsx | 4 + 8 files changed, 657 insertions(+), 331 deletions(-) create mode 100644 ui/src/components/cliproxy-stats-overview.tsx diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 22491cc4..45f5756e 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -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 diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index 36c89b0b..2ba44ca5 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -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; - requests_by_provider?: Record; - 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 = {}; + const requestsByProvider: Record = {}; + + 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, }); diff --git a/ui/src/components/analytics/cliproxy-stats-card.tsx b/ui/src/components/analytics/cliproxy-stats-card.tsx index 4b6e7910..1fbf1135 100644 --- a/ui/src/components/analytics/cliproxy-stats-card.tsx +++ b/ui/src/components/analytics/cliproxy-stats-card.tsx @@ -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 ( @@ -146,48 +125,88 @@ export function ClipproxyStatsCard({ className }: ClipproxyStatsCardProps) {
- {/* Stats row */} -
- {statItems.map((item) => { - const Icon = item.icon; - return ( -
-
- -
- {item.value} - - {item.label} - + {/* Key metrics row */} +
+ {/* Requests with success ring */} +
+
+ + + = 90 ? 'text-green-500' : 'text-amber-500'} + /> + + + {successRate}% + +
+
+
+ {formatNumber(totalRequests)} +
+
+ {failedRequests > 0 ? `${failedRequests} failed` : 'All success'}
- ); - })} -
- - {/* Provider breakdown */} - {providers.length > 0 && ( -
-

- By Provider -

-
- {providers.map(([provider, count]) => ( - - {provider}: {count} - - ))}
- )} - {/* Tokens */} - {stats?.tokens && stats.tokens.total > 0 && ( -
- Tokens: {formatNumber(stats.tokens.input)} in /{' '} - {formatNumber(stats.tokens.output)} out + {/* Tokens */} +
+
+ +
+
+
{formatNumber(totalTokens)}
+
Total tokens
+
+
+
+ + {/* Model breakdown */} + {models.length > 0 && ( +
+
+ + Models Used +
+
+ {models.map(([model, count]) => { + const percentage = Math.round((count / maxModelRequests) * 100); + const displayName = formatModelName(model); + return ( +
+
+ + {displayName} + + {count} +
+
+
+
+
+ ); + })} +
)}
@@ -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; +} diff --git a/ui/src/components/analytics/usage-insights-card.tsx b/ui/src/components/analytics/usage-insights-card.tsx index b7b48585..7c11461a 100644 --- a/ui/src/components/analytics/usage-insights-card.tsx +++ b/ui/src/components/analytics/usage-insights-card.tsx @@ -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 ( - - - - - Usage Insights - - - -
-
-
+ +
+
+ +
- +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+
); } const hasAnomalies = summary && summary.totalAnomalies > 0; - return ( - - -
- - - Usage Insights - - {hasAnomalies ? ( - - Attention Needed - - ) : ( - - Healthy - - )} -
-
- - - {hasAnomalies ? ( - -
- {anomalies.map((anomaly, index) => { - const config = ANOMALY_CONFIG[anomaly.type]; - const Icon = config.icon; - - return ( -
-
-
- -
-
-
-

{config.label}

- - {anomaly.date} - -
-

- {anomaly.message} -

- {anomaly.model && ( -
- - {anomaly.model} - -
- )} -
-
-
- ); - })} -
-
- ) : ( -
-
- -
-

No anomalies detected

-

- Your usage patterns look normal for the selected period. -

+ if (!hasAnomalies) { + return ( + +
+
+
- )} - +

All Systems Nominal

+

+ Your usage patterns are within normal ranges for the selected period. +

+
+
+ ); + } + + return ( + +
+
+ +

Usage Insights

+
+ + {summary.totalAnomalies} {summary.totalAnomalies === 1 ? 'Alert' : 'Alerts'} + +
+ + +
+ {anomalies.map((anomaly, index) => { + const config = ANOMALY_CONFIG[anomaly.type]; + const Icon = config.icon; + + return ( +
+
+
+ +
+ +
+
+

{config.label}

+ + {anomaly.date} + +
+ +

+ {anomaly.message} +

+ + {anomaly.model && ( +
+ + {anomaly.model} + +
+ )} +
+
+
+ ); + })} +
+
); } + +function SkeletonIcon() { + return
; +} diff --git a/ui/src/components/cliproxy-stats-overview.tsx b/ui/src/components/cliproxy-stats-overview.tsx new file mode 100644 index 00000000..7d814db7 --- /dev/null +++ b/ui/src/components/cliproxy-stats-overview.tsx @@ -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 ( +
+
+
+ + +
+ +
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+ ); + } + + // Offline state + if (!status?.running) { + return ( +
+
+
+

+ + Session Statistics +

+

+ Real-time usage metrics from CLIProxyAPI +

+
+ + + Offline + +
+ + + +
+ +
+

No Active Session

+

+ Start a CLIProxy session using{' '} + ccs gemini,{' '} + ccs codex, + or ccs agy{' '} + to view real-time statistics. +

+
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+ + + +
+

Failed to Load Statistics

+

{error.message}

+
+
+
+
+ ); + } + + // 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 ( +
+ {/* Header */} +
+
+

+ + Session Statistics +

+

Real-time usage metrics from CLIProxyAPI

+
+ + + Running + +
+ + {/* Stats Grid */} +
+ {/* Requests Card */} + + +
+
+

Total Requests

+

{formatNumber(totalRequests)}

+
+ + {successRequests} success +
+
+
+ +
+
+
+
+ + {/* Success Rate Card */} + + +
+
+

Success Rate

+

{successRate}%

+
+
= 90 ? 'bg-green-500' : 'bg-amber-500' + )} + style={{ width: `${successRate}%` }} + /> +
+
+
= 90 + ? 'bg-green-100 dark:bg-green-900/20' + : 'bg-amber-100 dark:bg-amber-900/20' + )} + > + {successRate >= 90 ? ( + + ) : ( + + )} +
+
+ + + + {/* Tokens Card */} + + +
+
+

Total Tokens

+

{formatNumber(totalTokens)}

+

+ ~${estimateCost(totalTokens).toFixed(2)} estimated +

+
+
+ +
+
+
+
+ + {/* Models Card */} + + +
+
+

Models Used

+

{models.length}

+

+ {models.length > 0 ? formatModelName(models[0][0]) : 'None'} +

+
+
+ +
+
+
+
+
+ + {/* Model Breakdown */} + {models.length > 0 && ( + + + + + Model Usage Distribution + + + +
+ {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 ( +
+
+
+
+ + {displayName} + +
+
+ {count} requests + {percentage}% +
+
+
+
+
+
+ ); + })} +
+ + + )} +
+ ); +} + +/** 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; +} diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx index d379a7c9..47f95edc 100644 --- a/ui/src/components/code-editor.tsx +++ b/ui/src/components/code-editor.tsx @@ -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)} diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index fd3aef0f..e739d5f2 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -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({ @@ -152,15 +116,11 @@ export function AnalyticsPage() { { label: 'All Time', range: { from: undefined, to: new Date() } }, ]} /> - {/* Usage Insights Dropdown */} + + {/* Usage Insights Card (replaces popover) */} - - - {isInsightsLoading ? ( -
-
-
-
-
-
- ) : insights?.summary?.totalAnomalies ? ( -
-
- {insights.anomalies?.map((anomaly, index) => { - const config = ANOMALY_CONFIG[anomaly.type]; - const Icon = config.icon; - return ( -
-
-
- -
-
-
-

{config.label}

- - {anomaly.date} - -
-

- {anomaly.message} -

- {anomaly.model && ( - - {anomaly.model} - - )} -
-
-
- ); - })} -
-
- ) : ( -
-
- -
-

No anomalies detected

-

Usage patterns look normal

-
- )} + + + {lastUpdatedText && ( Updated {lastUpdatedText} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 7fc098de..6b892fad 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -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() {
+ {/* Session Statistics */} + + {/* Built-in Profiles with Account Management */}