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:
1 parent
bc69a2e8b5
commit
99e458f69b
5 files changed
+725
-101
No files matched your search
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user