diff --git a/src/components/proxy/ProxyAdvancedTab.tsx b/src/components/proxy/ProxyAdvancedTab.tsx deleted file mode 100644 index b5f1962..0000000 --- a/src/components/proxy/ProxyAdvancedTab.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { MonitorTab } from './advanced/MonitorTab'; -import { ToolsTab } from './advanced/ToolsTab'; -import { ReplayTab } from './advanced/ReplayTab'; -import { CapabilitiesTab } from './advanced/CapabilitiesTab'; -import type { ProxyConfig } from '@/types/config'; - -interface ProxyAdvancedTabProps { - proxyConfig: ProxyConfig; -} - -export const ProxyAdvancedTab: React.FC = () => { - const { t } = useTranslation(); - - return ( - - - {t('proxy.advanced.monitor')} - {t('proxy.advanced.tabs.tools')} - {t('proxy.advanced.tabs.replay')} - {t('proxy.advanced.tabs.capabilities')} - - - - - - - - - - - - - - - ); -}; diff --git a/src/components/usage/MonitorTab.tsx b/src/components/usage/MonitorTab.tsx new file mode 100644 index 0000000..2d89c75 --- /dev/null +++ b/src/components/usage/MonitorTab.tsx @@ -0,0 +1,204 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { ipc } from '@/ipc/manager'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useToast } from '@/components/ui/use-toast'; +import { StatCard } from '@/components/usage/StatCard'; +import { TrafficLogTable } from './TrafficLogTable'; +import { Loader2, Zap, Clock, AlertTriangle, Network, HardDrive } from 'lucide-react'; + +export const MonitorTab: React.FC = () => { + const { t } = useTranslation(); + const { toast } = useToast(); + const [direction, setDirection] = useState('all'); + + const { data: metricsData, isLoading: metricsLoading } = useQuery({ + queryKey: ['proxyAdvanced', 'metrics'], + queryFn: () => ipc.client.proxyAdvanced.getProxyMetrics(), + refetchInterval: 30000, + }); + + const { data: cbData, isLoading: cbLoading } = useQuery({ + queryKey: ['proxyAdvanced', 'circuitBreaker'], + queryFn: () => ipc.client.proxyAdvanced.getCircuitBreakerStatus(), + refetchInterval: 30000, + }); + + const { data: logsData, isLoading: logsLoading } = useQuery({ + queryKey: ['proxyAdvanced', 'logs', direction], + queryFn: () => + ipc.client.proxyAdvanced.getRecentTrafficLogs({ + limit: 50, + direction: direction === 'all' ? undefined : direction, + }), + refetchInterval: 30000, + }); + + const { data: statsData } = useQuery({ + queryKey: ['proxyAdvanced', 'stats'], + queryFn: () => ipc.client.proxyAdvanced.getTrafficLogStats(), + refetchInterval: 30000, + }); + + const metrics = metricsData?.success ? metricsData.data : undefined; + const circuitBreakers = cbData?.success ? cbData.data : undefined; + const logs = logsData?.success + ? (logsData.data || []).map((log) => ({ + ...log, + model: log.metadata?.model as string | undefined, + tokensPrompt: log.metadata?.tokensPrompt as number | undefined, + tokensCompletion: log.metadata?.tokensCompletion as number | undefined, + tokensTotal: + (((log.metadata?.tokensPrompt as number) || 0) + + ((log.metadata?.tokensCompletion as number) || 0)) || + undefined, + })) + : []; + const stats = statsData?.success ? statsData.data : undefined; + + const handleExport = async (format: 'json' | 'csv') => { + const result = await ipc.client.proxyAdvanced.exportTrafficLogs({ format }); + if (result.success && result.data) { + navigator.clipboard.writeText(result.data); + toast({ title: t('proxy.advanced.tools.copyConfig') }); + } else { + toast({ title: t('common.error'), description: result.error }); + } + }; + + const getCircuitBadgeVariant = (state: string): React.ComponentProps['variant'] => { + if (state === 'closed') return 'default'; + if (state === 'open') return 'destructive'; + return 'secondary'; + }; + + return ( +
+ {/* Metrics */} +
+ + + + + +
+ + {/* Circuit Breakers */} +
+
+

{t('proxy.advanced.circuitBreaker.title')}

+
+
+ {cbLoading ? ( +
+ +
+ ) : !circuitBreakers ? ( +

No data

+ ) : ( +
+ {Object.entries(circuitBreakers.states).map(([api, state]) => ( +
+
+ {api} + {state.state} +
+
+ {t('proxy.advanced.circuitBreaker.failures')}: {state.failures} +
+ {state.lastFailure && ( +
+ {t('proxy.advanced.circuitBreaker.lastFailure')}:{' '} + {new Date(state.lastFailure).toLocaleString()} +
+ )} +
+ ))} +
+ )} +
+
+ + {/* Traffic Logs */} +
+
+
+

{t('proxy.advanced.trafficLog.title')}

+ {stats && Total: {stats.total}} +
+
+ + + +
+
+ +
+
+ ); +}; diff --git a/src/components/usage/TrafficLogTable.tsx b/src/components/usage/TrafficLogTable.tsx new file mode 100644 index 0000000..41f0f94 --- /dev/null +++ b/src/components/usage/TrafficLogTable.tsx @@ -0,0 +1,388 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, ArrowUp, ArrowDown, ChevronRight, ChevronDown } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { calculateCost } from '@/constants/pricing'; + +interface TrafficLogEntry { + timestamp: string; + direction: string; + requestId: string; + endpoint: string; + method?: string; + status?: number; + durationMs?: number; + tokensPrompt?: number; + tokensCompletion?: number; + tokensTotal?: number; + model?: string; + body?: unknown; + headers?: Record; +} + +interface TrafficLogTableProps { + logs: TrafficLogEntry[]; + isLoading: boolean; +} + +type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'model' | 'method' | 'status' | 'tokens' | 'cost' | 'duration'; +type SortDir = 'asc' | 'desc'; + +function SortIcon({ + column, + sortKey, + sortDir, +}: { + column: SortKey; + sortKey: SortKey; + sortDir: SortDir; +}) { + if (sortKey !== column) return ; + return sortDir === 'asc' ? ( + + ) : ( + + ); +} + +const formatTokens = (tokens?: number) => { + if (tokens == null) return '—'; + if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`; + if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`; + return tokens.toString(); +}; + +/** + * Safely format a JSON body for display. + * Truncates very large bodies and handles non-object values. + */ +function formatBody(body: unknown, maxLength = 8000): string { + if (body == null) return 'No data'; + try { + const str = typeof body === 'string' ? body : JSON.stringify(body, null, 2); + if (str.length > maxLength) { + return str.slice(0, maxLength) + `\n\n... [truncated ${str.length - maxLength} chars]`; + } + return str; + } catch { + return String(body); + } +} + +/** + * Extract a short preview of the message content from a request/response body. + */ +function getBodyPreview(body: unknown): string | null { + if (!body || typeof body !== 'object') return null; + const obj = body as Record; + + // Request body: extract first user message + if (Array.isArray(obj.messages)) { + const userMsg = obj.messages.find( + (m: Record) => m.role === 'user', + ); + if (userMsg) { + const content = typeof userMsg.content === 'string' + ? userMsg.content + : JSON.stringify(userMsg.content); + return content.length > 120 ? content.slice(0, 120) + '…' : content; + } + } + + // Response body: extract assistant content + if (Array.isArray(obj.choices)) { + const firstChoice = obj.choices[0] as Record | undefined; + const message = firstChoice?.message as Record | undefined; + if (message?.content && typeof message.content === 'string') { + return message.content.length > 120 + ? message.content.slice(0, 120) + '…' + : message.content; + } + } + + return null; +} + +export const TrafficLogTable: React.FC = ({ logs, isLoading }) => { + const { t } = useTranslation(); + const [sortKey, setSortKey] = useState('timestamp'); + const [sortDir, setSortDir] = useState('desc'); + const [expandedId, setExpandedId] = useState(null); + + const toggleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDir('asc'); + } + }; + + const toggleExpand = (logId: string) => { + setExpandedId((prev) => (prev === logId ? null : logId)); + }; + + const sortedLogs = useMemo(() => { + const dir = sortDir === 'asc' ? 1 : -1; + return [...logs].sort((a, b) => { + switch (sortKey) { + case 'timestamp': + return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) * dir; + case 'direction': + return (a.direction || '').localeCompare(b.direction || '') * dir; + case 'endpoint': + return (a.endpoint || '').localeCompare(b.endpoint || '') * dir; + case 'model': + return (a.model || '').localeCompare(b.model || '') * dir; + case 'method': + return (a.method || '').localeCompare(b.method || '') * dir; + case 'status': { + const aStatus = a.status ?? -1; + const bStatus = b.status ?? -1; + return (aStatus - bStatus) * dir; + } + case 'tokens': { + const aTokens = a.tokensTotal ?? -1; + const bTokens = b.tokensTotal ?? -1; + return (aTokens - bTokens) * dir; + } + case 'cost': { + const aCost = calculateCost(a.model || '', a.tokensPrompt || 0, a.tokensCompletion || 0); + const bCost = calculateCost(b.model || '', b.tokensPrompt || 0, b.tokensCompletion || 0); + return (aCost - bCost) * dir; + } + case 'duration': { + const aDur = a.durationMs ?? -1; + const bDur = b.durationMs ?? -1; + return (aDur - bDur) * dir; + } + default: + return 0; + } + }); + }, [logs, sortKey, sortDir]); + + const COL_COUNT = 9; + + return ( +
+
+

{t('proxy.advanced.trafficLog.title')}

+
+
+ {isLoading ? ( +
+ +
+ ) : logs.length === 0 ? ( +
+ {t('proxy.advanced.trafficLog.noLogs')} +
+ ) : ( +
+ + + + {/* Expand indicator column */} + + + + + + + + + + + + {sortedLogs.map((log, i) => { + const logKey = `${log.requestId}-${i}`; + const isExpanded = expandedId === logKey; + const preview = getBodyPreview(log.body); + + return ( + + toggleExpand(logKey)} + > + {/* Expand chevron */} + + + + + + + + + + + + {/* Expanded detail row */} + {isExpanded && ( + + + + )} + + ); + })} + +
+ toggleSort('timestamp')} + > + {t('proxy.advanced.trafficLog.timestamp')} + + toggleSort('direction')} + > + {t('proxy.advanced.trafficLog.direction')} + + toggleSort('endpoint')} + > + {t('proxy.advanced.trafficLog.endpoint')} + + toggleSort('model')} + > + Model + + toggleSort('status')} + > + {t('proxy.advanced.trafficLog.status')} + + toggleSort('tokens')} + > + Input / Output + + toggleSort('duration')} + > + {t('proxy.advanced.trafficLog.duration')} + + toggleSort('cost')} + > + Cost + +
+ {isExpanded ? ( + + ) : ( + + )} + + {new Date(log.timestamp).toLocaleTimeString()} + + + {log.direction} + + {log.endpoint}{log.model || '—'} + {log.status != null ? ( + = 400 ? 'destructive' : 'secondary'} + className="text-xs" + > + {log.status} + + ) : ( + '—' + )} + + {log.tokensTotal ? ( + `${formatTokens(log.tokensPrompt)} / ${formatTokens(log.tokensCompletion)}` + ) : ( + '—' + )} + + {log.durationMs != null ? `${log.durationMs}ms` : '—'} + + {log.tokensTotal && log.model + ? `$${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)}` + : '—'} +
+
+ {/* Preview line */} + {preview && ( +
+ "{preview}" +
+ )} + + {/* Summary badges */} +
+ + ID: {log.requestId.slice(0, 8)} + + {log.model && ( + + {log.model} + + )} + {log.tokensPrompt != null && ( + + Input: {log.tokensPrompt.toLocaleString()} tokens + + )} + {log.tokensCompletion != null && ( + + Output: {log.tokensCompletion.toLocaleString()} tokens + + )} + {log.tokensTotal != null && log.model && ( + + Cost: ${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)} + + )} + {log.durationMs != null && ( + + {log.durationMs}ms + + )} +
+ + {/* Body content */} +
+
+ + {log.direction === 'inbound' ? 'Request Body' : log.direction === 'outbound' ? 'Response Body' : 'Payload'} + +
+
+                                  {formatBody(log.body)}
+                                
+
+
+
+
+ )} +
+
+ ); +}; diff --git a/src/ipc/database/proxyMetricsHandler.ts b/src/ipc/database/proxyMetricsHandler.ts index 580160b..698b129 100644 --- a/src/ipc/database/proxyMetricsHandler.ts +++ b/src/ipc/database/proxyMetricsHandler.ts @@ -89,12 +89,8 @@ export function ensureProxyMetricsTable(rawDb: Database.Database): void { metadata_json TEXT ); `); - rawDb.exec( - `CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`, - ); - rawDb.exec( - `CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`, - ); + rawDb.exec(`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`); + rawDb.exec(`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`); } catch (error) { logger.error('Failed to ensure proxy_request_metrics table', error); throw error; @@ -201,17 +197,30 @@ export class ProxyMetricsRepo { const reqRow = raw .prepare( `SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency, + MIN(timestamp) as minTimestamp, MAX(timestamp) as maxTimestamp, CASE WHEN COUNT(*) = 0 THEN 0 ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*)) END as errorRate FROM proxy_request_metrics WHERE timestamp >= ?`, ) .get(cutoff) as - | { total: number; avgLatency: number | null; errorRate: number } + | { + total: number; + avgLatency: number | null; + errorRate: number; + minTimestamp: number | null; + maxTimestamp: number | null; + } | undefined; const totalRequests = reqRow?.total ?? 0; - const minutes = Math.max(windowMs / 60_000, 1); + let minutes = Math.max(windowMs / 60_000, 1); + + if (reqRow?.minTimestamp && reqRow?.maxTimestamp && totalRequests > 0) { + const spanMs = reqRow.maxTimestamp - reqRow.minTimestamp; + minutes = Math.max(spanMs / 60_000, 1); + } + const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100; const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0; const errorRatePercent = reqRow?.errorRate ?? 0; @@ -244,7 +253,9 @@ export class ProxyMetricsRepo { const { raw } = getProxyMetricsDb(); try { raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp); - raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp); + raw + .prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`) + .run(cutoffTimestamp); return true; } catch (error) { logger.error('Failed to delete old proxy metrics records', error); @@ -298,7 +309,10 @@ export class TrafficLogsRepo { } } - static getRecent(limit: number = 50, direction?: string): Array<{ + static getRecent( + limit: number = 50, + direction?: string, + ): Array<{ timestamp: number; direction: string; requestId: string; @@ -349,10 +363,13 @@ export class TrafficLogsRepo { body: r.body_json ? JSON.parse(r.body_json) : undefined, chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined, durationMs: r.duration_ms ?? undefined, - error: - r.error_message - ? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined } - : undefined, + error: r.error_message + ? { + message: r.error_message, + stack: r.error_stack ?? undefined, + status: r.error_status ?? undefined, + } + : undefined, metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined, })); } catch (error) { @@ -361,10 +378,18 @@ export class TrafficLogsRepo { } } - static getStats(): { byDirection: Record; byEndpoint: Record; total: number } { + static getStats(): { + byDirection: Record; + byEndpoint: Record; + total: number; + } { const { raw } = getProxyMetricsDb(); try { - const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{ + const rows = raw + .prepare( + `SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`, + ) + .all() as Array<{ direction: string; endpoint: string; cnt: number; @@ -400,7 +425,9 @@ export class TrafficLogsRepo { }> { const { raw } = getProxyMetricsDb(); try { - const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{ + const rows = raw + .prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`) + .all() as Array<{ timestamp: number; direction: string; request_id: string; @@ -427,10 +454,13 @@ export class TrafficLogsRepo { body: r.body_json ? JSON.parse(r.body_json) : undefined, chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined, durationMs: r.duration_ms ?? undefined, - error: - r.error_message - ? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined } - : undefined, + error: r.error_message + ? { + message: r.error_message, + stack: r.error_stack ?? undefined, + status: r.error_status ?? undefined, + } + : undefined, metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined, })); } catch (error) { diff --git a/src/routes/proxy.tsx b/src/routes/proxy.tsx index 7604e8c..88c7169 100644 --- a/src/routes/proxy.tsx +++ b/src/routes/proxy.tsx @@ -26,7 +26,17 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Loader2, Copy, Code, Terminal, Eye, EyeOff, ServerOff, ChevronDown, ChevronUp } from 'lucide-react'; +import { + Loader2, + Copy, + Code, + Terminal, + Eye, + EyeOff, + ServerOff, + ChevronDown, + ChevronUp, +} from 'lucide-react'; import { Dialog, DialogContent, @@ -37,7 +47,7 @@ import { } from '@/components/ui/dialog'; import { useCloudAccounts } from '@/hooks/useCloudAccounts'; -import { ProxyAdvancedTab } from '@/components/proxy/ProxyAdvancedTab'; +import { ToolsTab } from '@/components/proxy/advanced/ToolsTab'; import { flatMap, sortBy, uniq } from 'lodash-es'; type ProxyProtocol = 'openai' | 'anthropic'; @@ -304,14 +314,14 @@ print(response.choices[0].message.content)`; Status Routing + Tools API Docs - Advanced {/* Status Tab */} {/* Service Control Card */} - +
@@ -411,7 +421,11 @@ print(response.choices[0].message.content)`; {t('proxy.copy')} -
@@ -420,7 +434,7 @@ print(response.choices[0].message.content)`; {proxyConfig.api_key ? 'Weak key' : 'No API key'} - + {proxyConfig.api_key ? 'API key is too short for secure operation' : 'Generate or set an API key to secure the proxy'} @@ -431,7 +445,9 @@ print(response.choices[0].message.content)`; {t('proxy.regenerateConfirm.title')} - {t('proxy.regenerateConfirm.description')} + + {t('proxy.regenerateConfirm.description')} +