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
|
metadata_json TEXT
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
rawDb.exec(
|
rawDb.exec(`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`);
|
||||||
`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_direction ON traffic_logs(direction);`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to ensure proxy_request_metrics table', error);
|
logger.error('Failed to ensure proxy_request_metrics table', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -201,17 +197,30 @@ export class ProxyMetricsRepo {
|
|||||||
const reqRow = raw
|
const reqRow = raw
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
|
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
|
||||||
|
MIN(timestamp) as minTimestamp, MAX(timestamp) as maxTimestamp,
|
||||||
CASE WHEN COUNT(*) = 0 THEN 0
|
CASE WHEN COUNT(*) = 0 THEN 0
|
||||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||||
END as errorRate
|
END as errorRate
|
||||||
FROM proxy_request_metrics WHERE timestamp >= ?`,
|
FROM proxy_request_metrics WHERE timestamp >= ?`,
|
||||||
)
|
)
|
||||||
.get(cutoff) as
|
.get(cutoff) as
|
||||||
| { total: number; avgLatency: number | null; errorRate: number }
|
| {
|
||||||
|
total: number;
|
||||||
|
avgLatency: number | null;
|
||||||
|
errorRate: number;
|
||||||
|
minTimestamp: number | null;
|
||||||
|
maxTimestamp: number | null;
|
||||||
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
const totalRequests = reqRow?.total ?? 0;
|
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 requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
|
||||||
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
|
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
|
||||||
const errorRatePercent = reqRow?.errorRate ?? 0;
|
const errorRatePercent = reqRow?.errorRate ?? 0;
|
||||||
@@ -244,7 +253,9 @@ export class ProxyMetricsRepo {
|
|||||||
const { raw } = getProxyMetricsDb();
|
const { raw } = getProxyMetricsDb();
|
||||||
try {
|
try {
|
||||||
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to delete old proxy metrics records', 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;
|
timestamp: number;
|
||||||
direction: string;
|
direction: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -349,10 +363,13 @@ export class TrafficLogsRepo {
|
|||||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||||
durationMs: r.duration_ms ?? undefined,
|
durationMs: r.duration_ms ?? undefined,
|
||||||
error:
|
error: r.error_message
|
||||||
r.error_message
|
? {
|
||||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
message: r.error_message,
|
||||||
: undefined,
|
stack: r.error_stack ?? undefined,
|
||||||
|
status: r.error_status ?? undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} 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();
|
const { raw } = getProxyMetricsDb();
|
||||||
try {
|
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;
|
direction: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
cnt: number;
|
cnt: number;
|
||||||
@@ -400,7 +425,9 @@ export class TrafficLogsRepo {
|
|||||||
}> {
|
}> {
|
||||||
const { raw } = getProxyMetricsDb();
|
const { raw } = getProxyMetricsDb();
|
||||||
try {
|
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;
|
timestamp: number;
|
||||||
direction: string;
|
direction: string;
|
||||||
request_id: string;
|
request_id: string;
|
||||||
@@ -427,10 +454,13 @@ export class TrafficLogsRepo {
|
|||||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||||
durationMs: r.duration_ms ?? undefined,
|
durationMs: r.duration_ms ?? undefined,
|
||||||
error:
|
error: r.error_message
|
||||||
r.error_message
|
? {
|
||||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
message: r.error_message,
|
||||||
: undefined,
|
stack: r.error_stack ?? undefined,
|
||||||
|
status: r.error_status ?? undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+82
-41
@@ -26,7 +26,17 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} 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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -37,7 +47,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
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';
|
import { flatMap, sortBy, uniq } from 'lodash-es';
|
||||||
|
|
||||||
type ProxyProtocol = 'openai' | 'anthropic';
|
type ProxyProtocol = 'openai' | 'anthropic';
|
||||||
@@ -304,14 +314,14 @@ print(response.choices[0].message.content)`;
|
|||||||
<TabsList className="grid w-full grid-cols-4">
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
<TabsTrigger value="status">Status</TabsTrigger>
|
<TabsTrigger value="status">Status</TabsTrigger>
|
||||||
<TabsTrigger value="routing">Routing</TabsTrigger>
|
<TabsTrigger value="routing">Routing</TabsTrigger>
|
||||||
|
<TabsTrigger value="tools">Tools</TabsTrigger>
|
||||||
<TabsTrigger value="api-docs">API Docs</TabsTrigger>
|
<TabsTrigger value="api-docs">API Docs</TabsTrigger>
|
||||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Status Tab */}
|
{/* Status Tab */}
|
||||||
<TabsContent value="status" className="space-y-5">
|
<TabsContent value="status" className="space-y-5">
|
||||||
{/* Service Control Card */}
|
{/* 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>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -411,7 +421,11 @@ print(response.choices[0].message.content)`;
|
|||||||
<Copy size={14} className="mr-1" />
|
<Copy size={14} className="mr-1" />
|
||||||
{t('proxy.copy')}
|
{t('proxy.copy')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => setIsRegenerateDialogOpen(true)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsRegenerateDialogOpen(true)}
|
||||||
|
>
|
||||||
{t('proxy.regenerate')}
|
{t('proxy.regenerate')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,7 +434,7 @@ print(response.choices[0].message.content)`;
|
|||||||
<Badge variant="destructive">
|
<Badge variant="destructive">
|
||||||
{proxyConfig.api_key ? 'Weak key' : 'No API key'}
|
{proxyConfig.api_key ? 'Weak key' : 'No API key'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
{proxyConfig.api_key
|
{proxyConfig.api_key
|
||||||
? 'API key is too short for secure operation'
|
? 'API key is too short for secure operation'
|
||||||
: 'Generate or set an API key to secure the proxy'}
|
: 'Generate or set an API key to secure the proxy'}
|
||||||
@@ -431,7 +445,9 @@ print(response.choices[0].message.content)`;
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('proxy.regenerateConfirm.title')}</DialogTitle>
|
<DialogTitle>{t('proxy.regenerateConfirm.title')}</DialogTitle>
|
||||||
<DialogDescription>{t('proxy.regenerateConfirm.description')}</DialogDescription>
|
<DialogDescription>
|
||||||
|
{t('proxy.regenerateConfirm.description')}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}>
|
<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="flex items-center justify-between rounded-xl border border-white/[0.06] p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('proxy.config.auto_start')}</Label>
|
<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>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={proxyConfig.auto_start}
|
checked={proxyConfig.auto_start}
|
||||||
@@ -470,7 +488,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Account Quota Context */}
|
{/* 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>
|
<CardHeader>
|
||||||
<CardTitle>Account Pool</CardTitle>
|
<CardTitle>Account Pool</CardTitle>
|
||||||
<CardDescription>Quota and routing context</CardDescription>
|
<CardDescription>Quota and routing context</CardDescription>
|
||||||
@@ -481,10 +499,14 @@ print(response.choices[0].message.content)`;
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[13px] font-medium">Primary Account</p>
|
<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>
|
</div>
|
||||||
{accounts.length > 1 && (
|
{accounts.length > 1 && (
|
||||||
<Badge variant="outline">Pool Mode: Active ({accounts.length} accounts)</Badge>
|
<Badge variant="outline">
|
||||||
|
Pool Mode: Active ({accounts.length} accounts)
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" className="text-xs" asChild>
|
<Button variant="outline" size="sm" className="text-xs" asChild>
|
||||||
@@ -506,7 +528,7 @@ print(response.choices[0].message.content)`;
|
|||||||
<>
|
<>
|
||||||
{/* Local Access Info Banner */}
|
{/* 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 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>
|
<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">
|
<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
|
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
|
||||||
@@ -516,9 +538,7 @@ print(response.choices[0].message.content)`;
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-blue-300 hover:bg-blue-500/10"
|
className="h-7 w-7 text-blue-300 hover:bg-blue-500/10"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
copyToClipboard(
|
copyToClipboard(`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`)
|
||||||
`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Copy size={14} />
|
<Copy size={14} />
|
||||||
@@ -572,9 +592,14 @@ print(response.choices[0].message.content)`;
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
return items.map((metric) => (
|
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">
|
<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">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<span className="font-mono text-xl font-semibold tabular-nums">
|
<span className="font-mono text-xl font-semibold tabular-nums">
|
||||||
{metric.value}
|
{metric.value}
|
||||||
@@ -592,7 +617,9 @@ print(response.choices[0].message.content)`;
|
|||||||
<ServerOff className="text-muted-foreground/50 h-10 w-10" />
|
<ServerOff className="text-muted-foreground/50 h-10 w-10" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-base font-semibold">Proxy is not running</p>
|
<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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -609,19 +636,19 @@ print(response.choices[0].message.content)`;
|
|||||||
|
|
||||||
{/* Routing Tab */}
|
{/* Routing Tab */}
|
||||||
<TabsContent value="routing" className="space-y-5">
|
<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>
|
<CardHeader>
|
||||||
<CardTitle>{t('proxy.mapping.title')}</CardTitle>
|
<CardTitle>{t('proxy.mapping.title')}</CardTitle>
|
||||||
<CardDescription>{t('proxy.mapping.description')}</CardDescription>
|
<CardDescription>{t('proxy.mapping.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
<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>Requested Model</div>
|
||||||
<div>Resolved Model</div>
|
<div>Resolved Model</div>
|
||||||
<div>Provider</div>
|
<div>Provider</div>
|
||||||
</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>
|
<div className="font-medium">Claude Sonnet 4.6 (Thinking)</div>
|
||||||
<Select
|
<Select
|
||||||
value={resolveAnthropicMappingValue(
|
value={resolveAnthropicMappingValue(
|
||||||
@@ -649,7 +676,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</Select>
|
</Select>
|
||||||
<Badge variant="secondary">Anthropic</Badge>
|
<Badge variant="secondary">Anthropic</Badge>
|
||||||
</div>
|
</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>
|
<div className="font-medium">Claude Opus 4.6 (Thinking)</div>
|
||||||
<Select
|
<Select
|
||||||
value={resolveAnthropicMappingValue(
|
value={resolveAnthropicMappingValue(
|
||||||
@@ -696,7 +723,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Current Mappings */}
|
{/* 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>
|
<CardHeader>
|
||||||
<CardTitle>{t('proxy.routing.currentMappings')}</CardTitle>
|
<CardTitle>{t('proxy.routing.currentMappings')}</CardTitle>
|
||||||
<CardDescription>{t('proxy.routing.modelMapping')}</CardDescription>
|
<CardDescription>{t('proxy.routing.modelMapping')}</CardDescription>
|
||||||
@@ -706,10 +733,12 @@ print(response.choices[0].message.content)`;
|
|||||||
const mappingEntries = Object.entries(proxyConfig.anthropic_mapping || {});
|
const mappingEntries = Object.entries(proxyConfig.anthropic_mapping || {});
|
||||||
const customEntries = Object.entries(proxyConfig.custom_mapping || {});
|
const customEntries = Object.entries(proxyConfig.custom_mapping || {});
|
||||||
return mappingEntries.length === 0 && customEntries.length === 0 ? (
|
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="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('proxy.mapping.maps_to')}</div>
|
||||||
<div>{t('settings.modelMapping.targetGemini')}</div>
|
<div>{t('settings.modelMapping.targetGemini')}</div>
|
||||||
<div>Provider</div>
|
<div>Provider</div>
|
||||||
@@ -717,7 +746,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{mappingEntries.map(([key, value]) => (
|
{mappingEntries.map(([key, value]) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
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">{key}</div>
|
||||||
<div className="font-mono text-xs">{value}</div>
|
<div className="font-mono text-xs">{value}</div>
|
||||||
@@ -727,7 +756,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{customEntries.map(([key, value]) => (
|
{customEntries.map(([key, value]) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
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">{key}</div>
|
||||||
<div className="font-mono text-xs">{value}</div>
|
<div className="font-mono text-xs">{value}</div>
|
||||||
@@ -741,22 +770,30 @@ print(response.choices[0].message.content)`;
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Upstream Proxy Status */}
|
{/* 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>
|
<CardHeader>
|
||||||
<CardTitle>{t('proxy.routing.upstreamProxy')}</CardTitle>
|
<CardTitle>{t('proxy.routing.upstreamProxy')}</CardTitle>
|
||||||
<CardDescription>{t('proxy.routing.upstreamProxy')}</CardDescription>
|
<CardDescription>{t('proxy.routing.upstreamProxy')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
|
<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'}>
|
<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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{proxyConfig.upstream_proxy?.enabled && (
|
{proxyConfig.upstream_proxy?.enabled && (
|
||||||
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
|
<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="text-muted-foreground text-[13px]">
|
||||||
<span className="font-mono text-xs">{proxyConfig.upstream_proxy?.url || '—'}</span>
|
{t('proxy.routing.url')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{proxyConfig.upstream_proxy?.url || '—'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -765,7 +802,7 @@ print(response.choices[0].message.content)`;
|
|||||||
|
|
||||||
{/* API Docs Tab */}
|
{/* API Docs Tab */}
|
||||||
<TabsContent value="api-docs" className="space-y-5">
|
<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>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Code size={20} />
|
<Code size={20} />
|
||||||
@@ -812,7 +849,11 @@ print(response.choices[0].message.content)`;
|
|||||||
{provider}
|
{provider}
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
{models.map((modelId) => (
|
{models.map((modelId) => (
|
||||||
<SelectItem key={modelId} value={modelId} className="font-mono text-xs">
|
<SelectItem
|
||||||
|
key={modelId}
|
||||||
|
value={modelId}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
{modelId}
|
{modelId}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -853,7 +894,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{/* cURL */}
|
{/* cURL */}
|
||||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div
|
<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)}
|
onClick={() => setShowCurl(!showCurl)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -876,7 +917,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showCurl && (
|
{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)}
|
{getCurlExample(activeModelTab)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -885,7 +926,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{/* Python */}
|
{/* Python */}
|
||||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div
|
<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)}
|
onClick={() => setShowPython(!showPython)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -908,7 +949,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showPython && (
|
{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)}
|
{getPythonExample(activeModelTab)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -918,9 +959,9 @@ print(response.choices[0].message.content)`;
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Advanced Tab */}
|
{/* Tools Tab */}
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="tools">
|
||||||
<ProxyAdvancedTab proxyConfig={proxyConfig} />
|
<ToolsTab availableModelIds={availableModelIds} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user