feat(usage): move Monitor to Usage tab with per-request token tracking

- Move MonitorTab and TrafficLogTable from proxy/advanced to usage/
- Add Analytics and Monitor tabs to Usage page
- Remove Monitor tab from API Proxy (flatten to 4 tabs)
- Add Model, Input/Output tokens, and Cost columns to traffic log table
- Make log rows clickable with expandable detail panel (JSON body viewer)
- Fix req/min calculation using actual request time span (MIN/MAX timestamps)
- Add word-wrap for large message bodies in expanded view
This commit is contained in:
Gemini Nexus Bot committed 2026-05-02 00:29:32 -03:00
1 parent bc69a2e8b5
commit 99e458f69b
5 files changed
+725 -101

No files matched your search

-39
View File
@@ -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<ProxyAdvancedTabProps> = () => {
const { t } = useTranslation();
return (
<Tabs defaultValue="monitor" className="space-y-5">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="monitor">{t('proxy.advanced.monitor')}</TabsTrigger>
<TabsTrigger value="tools">{t('proxy.advanced.tabs.tools')}</TabsTrigger>
<TabsTrigger value="replay">{t('proxy.advanced.tabs.replay')}</TabsTrigger>
<TabsTrigger value="capabilities">{t('proxy.advanced.tabs.capabilities')}</TabsTrigger>
</TabsList>
<TabsContent value="monitor">
<MonitorTab />
</TabsContent>
<TabsContent value="tools">
<ToolsTab />
</TabsContent>
<TabsContent value="replay">
<ReplayTab />
</TabsContent>
<TabsContent value="capabilities">
<CapabilitiesTab />
</TabsContent>
</Tabs>
);
};
+204
View File
@@ -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<string>('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<typeof Badge>['variant'] => {
if (state === 'closed') return 'default';
if (state === 'open') return 'destructive';
return 'secondary';
};
return (
<div className="space-y-5">
{/* Metrics */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('proxy.advanced.metrics.requestsPerMinute')}
value={metrics ? Math.round(metrics.requestsPerMinute) : 0}
icon={Zap}
isLoading={metricsLoading}
accent="amber"
/>
<StatCard
label={t('proxy.advanced.metrics.avgLatency')}
value={metrics ? Math.round(metrics.avgLatency) : 0}
icon={Clock}
isLoading={metricsLoading}
accent="blue"
/>
<StatCard
label={t('proxy.advanced.metrics.errorRate')}
value={metrics ? Math.round(metrics.errorRate * 100) : 0}
icon={AlertTriangle}
isLoading={metricsLoading}
accent="amber"
/>
<StatCard
label={t('proxy.advanced.metrics.activeConnections')}
value={metrics?.activeConnections ?? 0}
icon={Network}
isLoading={metricsLoading}
accent="green"
/>
<StatCard
label={t('proxy.advanced.metrics.cacheStatus')}
value={metrics?.cacheHitRate ? `${Math.round(metrics.cacheHitRate * 100)}%` : 'N/A'}
icon={HardDrive}
isLoading={metricsLoading}
accent="purple"
/>
</div>
{/* Circuit Breakers */}
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<div className="border-b border-white/[0.06] px-5 py-4">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.circuitBreaker.title')}</h4>
</div>
<div className="p-5">
{cbLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : !circuitBreakers ? (
<p className="text-muted-foreground text-[13px]">No data</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(circuitBreakers.states).map(([api, state]) => (
<div
key={api}
className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"
>
<div className="flex items-center justify-between">
<span className="text-[13px] font-medium">{api}</span>
<Badge variant={getCircuitBadgeVariant(state.state)}>{state.state}</Badge>
</div>
<div className="text-muted-foreground mt-2 text-xs">
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
</div>
{state.lastFailure && (
<div className="text-muted-foreground text-xs">
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
{new Date(state.lastFailure).toLocaleString()}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* Traffic Logs */}
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h3 className="text-[13px] font-semibold">{t('proxy.advanced.trafficLog.title')}</h3>
{stats && <span className="text-muted-foreground text-xs">Total: {stats.total}</span>}
</div>
<div className="flex items-center gap-2">
<Select value={direction} onValueChange={setDirection}>
<SelectTrigger className="h-8 w-[140px] text-xs">
<SelectValue placeholder={t('proxy.advanced.trafficLog.direction')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('proxy.advanced.trafficLog.all')}</SelectItem>
<SelectItem value="inbound">{t('proxy.advanced.trafficLog.inbound')}</SelectItem>
<SelectItem value="outbound">{t('proxy.advanced.trafficLog.outbound')}</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => handleExport('json')}
>
{t('proxy.advanced.trafficLog.exportJson')}
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => handleExport('csv')}
>
{t('proxy.advanced.trafficLog.exportCsv')}
</Button>
</div>
</div>
<TrafficLogTable logs={logs || []} isLoading={logsLoading} />
</div>
</div>
);
};
+388
View File
@@ -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<string, unknown>;
}
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 <span className="ml-1 inline-block w-3" />;
return sortDir === 'asc' ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
);
}
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<string, unknown>;
// Request body: extract first user message
if (Array.isArray(obj.messages)) {
const userMsg = obj.messages.find(
(m: Record<string, unknown>) => 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<string, unknown> | undefined;
const message = firstChoice?.message as Record<string, unknown> | 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<TrafficLogTableProps> = ({ logs, isLoading }) => {
const { t } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>('timestamp');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [expandedId, setExpandedId] = useState<string | null>(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 (
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<div className="border-b border-white/[0.06] px-5 py-4">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.trafficLog.title')}</h4>
</div>
<div className="p-5">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : logs.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-[13px]">
{t('proxy.advanced.trafficLog.noLogs')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b border-white/[0.06] text-left text-[10px] font-medium tracking-wider uppercase">
{/* Expand indicator column */}
<th className="w-6 pb-2" />
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('timestamp')}
>
{t('proxy.advanced.trafficLog.timestamp')}
<SortIcon column="timestamp" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('direction')}
>
{t('proxy.advanced.trafficLog.direction')}
<SortIcon column="direction" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('endpoint')}
>
{t('proxy.advanced.trafficLog.endpoint')}
<SortIcon column="endpoint" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('model')}
>
Model
<SortIcon column="model" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('status')}
>
{t('proxy.advanced.trafficLog.status')}
<SortIcon column="status" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('tokens')}
>
Input / Output
<SortIcon column="tokens" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('duration')}
>
{t('proxy.advanced.trafficLog.duration')}
<SortIcon column="duration" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 font-medium select-none"
onClick={() => toggleSort('cost')}
>
Cost
<SortIcon column="cost" sortKey={sortKey} sortDir={sortDir} />
</th>
</tr>
</thead>
<tbody>
{sortedLogs.map((log, i) => {
const logKey = `${log.requestId}-${i}`;
const isExpanded = expandedId === logKey;
const preview = getBodyPreview(log.body);
return (
<React.Fragment key={logKey}>
<tr
className={`cursor-pointer border-b border-white/[0.04] transition-colors last:border-0 ${
isExpanded
? 'bg-white/[0.03]'
: 'hover:bg-white/[0.02]'
}`}
onClick={() => toggleExpand(logKey)}
>
{/* Expand chevron */}
<td className="py-2 pr-1">
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
)}
</td>
<td className="text-muted-foreground py-2 pr-4 font-mono text-xs">
{new Date(log.timestamp).toLocaleTimeString()}
</td>
<td className="py-2 pr-4">
<Badge
variant="outline"
className={
log.direction === 'inbound'
? 'border-blue-500/30 text-blue-400'
: log.direction === 'outbound'
? 'border-emerald-500/30 text-emerald-400'
: 'border-slate-500/30 text-slate-400'
}
>
{log.direction}
</Badge>
</td>
<td className="py-2 pr-4 font-mono text-xs">{log.endpoint}</td>
<td className="py-2 pr-4 text-xs">{log.model || '—'}</td>
<td className="py-2 pr-4 text-xs">
{log.status != null ? (
<Badge
variant={log.status >= 400 ? 'destructive' : 'secondary'}
className="text-xs"
>
{log.status}
</Badge>
) : (
'—'
)}
</td>
<td className="py-2 pr-4 text-xs">
{log.tokensTotal ? (
`${formatTokens(log.tokensPrompt)} / ${formatTokens(log.tokensCompletion)}`
) : (
'—'
)}
</td>
<td className="py-2 pr-4 text-xs">
{log.durationMs != null ? `${log.durationMs}ms` : '—'}
</td>
<td className="py-2 text-xs text-emerald-400">
{log.tokensTotal && log.model
? `$${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)}`
: '—'}
</td>
</tr>
{/* Expanded detail row */}
{isExpanded && (
<tr className="border-b border-white/[0.04]">
<td colSpan={COL_COUNT} className="p-0">
<div className="border-t border-white/[0.06] bg-white/[0.01] px-6 py-4">
{/* Preview line */}
{preview && (
<div className="mb-3 rounded-lg bg-white/[0.03] px-3 py-2 text-xs text-muted-foreground italic break-words">
"{preview}"
</div>
)}
{/* Summary badges */}
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline" className="font-mono text-[10px]">
ID: {log.requestId.slice(0, 8)}
</Badge>
{log.model && (
<Badge variant="secondary" className="text-[10px]">
{log.model}
</Badge>
)}
{log.tokensPrompt != null && (
<Badge variant="outline" className="border-blue-500/20 text-blue-400 text-[10px]">
Input: {log.tokensPrompt.toLocaleString()} tokens
</Badge>
)}
{log.tokensCompletion != null && (
<Badge variant="outline" className="border-emerald-500/20 text-emerald-400 text-[10px]">
Output: {log.tokensCompletion.toLocaleString()} tokens
</Badge>
)}
{log.tokensTotal != null && log.model && (
<Badge variant="outline" className="border-amber-500/20 text-amber-400 text-[10px]">
Cost: ${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)}
</Badge>
)}
{log.durationMs != null && (
<Badge variant="outline" className="text-[10px]">
{log.durationMs}ms
</Badge>
)}
</div>
{/* Body content */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wider">
{log.direction === 'inbound' ? 'Request Body' : log.direction === 'outbound' ? 'Response Body' : 'Payload'}
</span>
</div>
<pre className="custom-scrollbar max-h-[400px] overflow-auto rounded-lg bg-black/30 p-3 font-mono text-[11px] leading-relaxed text-foreground/80 whitespace-pre-wrap break-all">
{formatBody(log.body)}
</pre>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
+51 -21
View File
@@ -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<string, number>; byEndpoint: Record<string, number>; total: number } {
static getStats(): {
byDirection: Record<string, number>;
byEndpoint: Record<string, number>;
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) {
+82 -41
View File
@@ -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)`;
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="status">Status</TabsTrigger>
<TabsTrigger value="routing">Routing</TabsTrigger>
<TabsTrigger value="tools">Tools</TabsTrigger>
<TabsTrigger value="api-docs">API Docs</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
{/* Status Tab */}
<TabsContent value="status" className="space-y-5">
{/* Service Control Card */}
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<div className="flex items-center justify-between">
<div>
@@ -411,7 +421,11 @@ print(response.choices[0].message.content)`;
<Copy size={14} className="mr-1" />
{t('proxy.copy')}
</Button>
<Button variant="outline" size="sm" onClick={() => setIsRegenerateDialogOpen(true)}>
<Button
variant="outline"
size="sm"
onClick={() => setIsRegenerateDialogOpen(true)}
>
{t('proxy.regenerate')}
</Button>
</div>
@@ -420,7 +434,7 @@ print(response.choices[0].message.content)`;
<Badge variant="destructive">
{proxyConfig.api_key ? 'Weak key' : 'No API key'}
</Badge>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{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)`;
<DialogContent>
<DialogHeader>
<DialogTitle>{t('proxy.regenerateConfirm.title')}</DialogTitle>
<DialogDescription>{t('proxy.regenerateConfirm.description')}</DialogDescription>
<DialogDescription>
{t('proxy.regenerateConfirm.description')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}>
@@ -457,7 +473,9 @@ print(response.choices[0].message.content)`;
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-4">
<div className="space-y-1">
<Label>{t('proxy.config.auto_start')}</Label>
<p className="text-xs text-muted-foreground">{t('proxy.config.auto_start_desc')}</p>
<p className="text-muted-foreground text-xs">
{t('proxy.config.auto_start_desc')}
</p>
</div>
<Switch
checked={proxyConfig.auto_start}
@@ -470,7 +488,7 @@ print(response.choices[0].message.content)`;
</Card>
{/* Account Quota Context */}
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<CardTitle>Account Pool</CardTitle>
<CardDescription>Quota and routing context</CardDescription>
@@ -481,10 +499,14 @@ print(response.choices[0].message.content)`;
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium">Primary Account</p>
<p className="text-muted-foreground font-mono text-[13px]">{accounts[0].email}</p>
<p className="text-muted-foreground font-mono text-[13px]">
{accounts[0].email}
</p>
</div>
{accounts.length > 1 && (
<Badge variant="outline">Pool Mode: Active ({accounts.length} accounts)</Badge>
<Badge variant="outline">
Pool Mode: Active ({accounts.length} accounts)
</Badge>
)}
</div>
<Button variant="outline" size="sm" className="text-xs" asChild>
@@ -506,7 +528,7 @@ print(response.choices[0].message.content)`;
<>
{/* Local Access Info Banner */}
<div className="flex flex-col gap-2 rounded-xl border border-blue-500/20 bg-blue-500/5 p-3 text-[13px] text-blue-200">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<div className="font-semibold">{t('proxy.config.local_access')}</div>
<code className="rounded-md bg-blue-500/10 px-1.5 py-0.5 font-mono text-xs select-all">
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
@@ -516,9 +538,7 @@ print(response.choices[0].message.content)`;
size="icon"
className="h-7 w-7 text-blue-300 hover:bg-blue-500/10"
onClick={() =>
copyToClipboard(
`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`,
)
copyToClipboard(`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`)
}
>
<Copy size={14} />
@@ -572,9 +592,14 @@ print(response.choices[0].message.content)`;
},
];
return items.map((metric) => (
<Card key={metric.label} className="border border-white/[0.06] bg-card shadow-none">
<Card
key={metric.label}
className="bg-card border border-white/[0.06] shadow-none"
>
<CardContent className="pt-6">
<p className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">{metric.label}</p>
<p className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
{metric.label}
</p>
<div className="mt-1 flex items-center gap-2">
<span className="font-mono text-xl font-semibold tabular-nums">
{metric.value}
@@ -592,7 +617,9 @@ print(response.choices[0].message.content)`;
<ServerOff className="text-muted-foreground/50 h-10 w-10" />
<div className="space-y-1">
<p className="text-base font-semibold">Proxy is not running</p>
<p className="text-muted-foreground text-[13px]">Start the proxy to enable local API access</p>
<p className="text-muted-foreground text-[13px]">
Start the proxy to enable local API access
</p>
</div>
<Button
onClick={async () => {
@@ -609,19 +636,19 @@ print(response.choices[0].message.content)`;
{/* Routing Tab */}
<TabsContent value="routing" className="space-y-5">
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<CardTitle>{t('proxy.mapping.title')}</CardTitle>
<CardDescription>{t('proxy.mapping.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
<div className="grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase text-muted-foreground">
<div className="text-muted-foreground grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase">
<div>Requested Model</div>
<div>Resolved Model</div>
<div>Provider</div>
</div>
<div className="grid grid-cols-3 gap-4 items-center p-3 text-[13px]">
<div className="grid grid-cols-3 items-center gap-4 p-3 text-[13px]">
<div className="font-medium">Claude Sonnet 4.6 (Thinking)</div>
<Select
value={resolveAnthropicMappingValue(
@@ -649,7 +676,7 @@ print(response.choices[0].message.content)`;
</Select>
<Badge variant="secondary">Anthropic</Badge>
</div>
<div className="grid grid-cols-3 gap-4 items-center border-t border-white/[0.06] p-3 text-[13px]">
<div className="grid grid-cols-3 items-center gap-4 border-t border-white/[0.06] p-3 text-[13px]">
<div className="font-medium">Claude Opus 4.6 (Thinking)</div>
<Select
value={resolveAnthropicMappingValue(
@@ -696,7 +723,7 @@ print(response.choices[0].message.content)`;
</Card>
{/* Current Mappings */}
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<CardTitle>{t('proxy.routing.currentMappings')}</CardTitle>
<CardDescription>{t('proxy.routing.modelMapping')}</CardDescription>
@@ -706,10 +733,12 @@ print(response.choices[0].message.content)`;
const mappingEntries = Object.entries(proxyConfig.anthropic_mapping || {});
const customEntries = Object.entries(proxyConfig.custom_mapping || {});
return mappingEntries.length === 0 && customEntries.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t('proxy.routing.noMapping')}</p>
<p className="text-muted-foreground text-[13px]">
{t('proxy.routing.noMapping')}
</p>
) : (
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
<div className="grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase text-muted-foreground">
<div className="text-muted-foreground grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase">
<div>{t('proxy.mapping.maps_to')}</div>
<div>{t('settings.modelMapping.targetGemini')}</div>
<div>Provider</div>
@@ -717,7 +746,7 @@ print(response.choices[0].message.content)`;
{mappingEntries.map(([key, value]) => (
<div
key={key}
className="grid grid-cols-3 gap-4 items-center border-b border-white/[0.06] p-3 last:border-0"
className="grid grid-cols-3 items-center gap-4 border-b border-white/[0.06] p-3 last:border-0"
>
<div className="font-mono text-xs">{key}</div>
<div className="font-mono text-xs">{value}</div>
@@ -727,7 +756,7 @@ print(response.choices[0].message.content)`;
{customEntries.map(([key, value]) => (
<div
key={key}
className="grid grid-cols-3 gap-4 items-center border-b border-white/[0.06] p-3 last:border-0"
className="grid grid-cols-3 items-center gap-4 border-b border-white/[0.06] p-3 last:border-0"
>
<div className="font-mono text-xs">{key}</div>
<div className="font-mono text-xs">{value}</div>
@@ -741,22 +770,30 @@ print(response.choices[0].message.content)`;
</Card>
{/* Upstream Proxy Status */}
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<CardTitle>{t('proxy.routing.upstreamProxy')}</CardTitle>
<CardDescription>{t('proxy.routing.upstreamProxy')}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
<span className="text-[13px] text-muted-foreground">{t('proxy.routing.status')}</span>
<span className="text-muted-foreground text-[13px]">
{t('proxy.routing.status')}
</span>
<Badge variant={proxyConfig.upstream_proxy?.enabled ? 'default' : 'secondary'}>
{proxyConfig.upstream_proxy?.enabled ? t('proxy.routing.enabled') : t('proxy.routing.disabled')}
{proxyConfig.upstream_proxy?.enabled
? t('proxy.routing.enabled')
: t('proxy.routing.disabled')}
</Badge>
</div>
{proxyConfig.upstream_proxy?.enabled && (
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
<span className="text-[13px] text-muted-foreground">{t('proxy.routing.url')}</span>
<span className="font-mono text-xs">{proxyConfig.upstream_proxy?.url || '—'}</span>
<span className="text-muted-foreground text-[13px]">
{t('proxy.routing.url')}
</span>
<span className="font-mono text-xs">
{proxyConfig.upstream_proxy?.url || '—'}
</span>
</div>
)}
</CardContent>
@@ -765,7 +802,7 @@ print(response.choices[0].message.content)`;
{/* API Docs Tab */}
<TabsContent value="api-docs" className="space-y-5">
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Code size={20} />
@@ -812,7 +849,11 @@ print(response.choices[0].message.content)`;
{provider}
</SelectLabel>
{models.map((modelId) => (
<SelectItem key={modelId} value={modelId} className="font-mono text-xs">
<SelectItem
key={modelId}
value={modelId}
className="font-mono text-xs"
>
{modelId}
</SelectItem>
))}
@@ -853,7 +894,7 @@ print(response.choices[0].message.content)`;
{/* cURL */}
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
<div
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium hover:bg-white/[0.02] transition-colors"
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium transition-colors hover:bg-white/[0.02]"
onClick={() => setShowCurl(!showCurl)}
>
<span className="flex items-center gap-2">
@@ -876,7 +917,7 @@ print(response.choices[0].message.content)`;
</div>
</div>
{showCurl && (
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
{getCurlExample(activeModelTab)}
</pre>
)}
@@ -885,7 +926,7 @@ print(response.choices[0].message.content)`;
{/* Python */}
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
<div
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium hover:bg-white/[0.02] transition-colors"
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium transition-colors hover:bg-white/[0.02]"
onClick={() => setShowPython(!showPython)}
>
<span className="flex items-center gap-2">
@@ -908,7 +949,7 @@ print(response.choices[0].message.content)`;
</div>
</div>
{showPython && (
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
{getPythonExample(activeModelTab)}
</pre>
)}
@@ -918,9 +959,9 @@ print(response.choices[0].message.content)`;
</Card>
</TabsContent>
{/* Advanced Tab */}
<TabsContent value="advanced">
<ProxyAdvancedTab proxyConfig={proxyConfig} />
{/* Tools Tab */}
<TabsContent value="tools">
<ToolsTab availableModelIds={availableModelIds} />
</TabsContent>
</Tabs>
</div>