[codex] add proxy debug tracing (#299)
* add proxy debug tracing * fix proxy debug response header typing * extend proxy debug tracing to side paths * Refine proxy debug trace review fixes * Stabilize proxy debug attempt indexing * Tighten proxy debug trace accuracy
This commit is contained in:
@@ -351,6 +351,72 @@ export type ProxyLogsResponse = {
|
||||
summary: ProxyLogsSummary;
|
||||
};
|
||||
|
||||
export type ProxyDebugTraceListItem = {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
downstreamPath: string;
|
||||
clientKind?: string | null;
|
||||
sessionId?: string | null;
|
||||
requestedModel?: string | null;
|
||||
selectedChannelId?: number | null;
|
||||
finalStatus?: string | null;
|
||||
finalHttpStatus?: number | null;
|
||||
finalUpstreamPath?: string | null;
|
||||
};
|
||||
|
||||
export type ProxyDebugTraceDetail = {
|
||||
trace: {
|
||||
id: number;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
downstreamPath?: string | null;
|
||||
clientKind?: string | null;
|
||||
sessionId?: string | null;
|
||||
traceHint?: string | null;
|
||||
requestedModel?: string | null;
|
||||
stickySessionKey?: string | null;
|
||||
stickyHitChannelId?: number | null;
|
||||
selectedChannelId?: number | null;
|
||||
selectedRouteId?: number | null;
|
||||
selectedAccountId?: number | null;
|
||||
selectedSiteId?: number | null;
|
||||
selectedSitePlatform?: string | null;
|
||||
endpointCandidatesJson?: string | null;
|
||||
endpointRuntimeStateJson?: string | null;
|
||||
decisionSummaryJson?: string | null;
|
||||
requestHeadersJson?: string | null;
|
||||
requestBodyJson?: string | null;
|
||||
finalStatus?: string | null;
|
||||
finalHttpStatus?: number | null;
|
||||
finalUpstreamPath?: string | null;
|
||||
finalResponseHeadersJson?: string | null;
|
||||
finalResponseBodyJson?: string | null;
|
||||
};
|
||||
attempts: Array<{
|
||||
id: number;
|
||||
attemptIndex: number;
|
||||
endpoint: string;
|
||||
requestPath: string;
|
||||
targetUrl: string;
|
||||
runtimeExecutor?: string | null;
|
||||
requestHeadersJson?: string | null;
|
||||
requestBodyJson?: string | null;
|
||||
responseStatus?: number | null;
|
||||
responseHeadersJson?: string | null;
|
||||
responseBodyJson?: string | null;
|
||||
rawErrorText?: string | null;
|
||||
recoverApplied?: boolean | null;
|
||||
downgradeDecision?: boolean | null;
|
||||
downgradeReason?: string | null;
|
||||
memoryWriteJson?: string | null;
|
||||
createdAt?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ProxyDebugTracesResponse = {
|
||||
items: ProxyDebugTraceListItem[];
|
||||
};
|
||||
|
||||
export type OAuthProviderInfo = {
|
||||
provider: string;
|
||||
label: string;
|
||||
@@ -546,6 +612,8 @@ export const api = {
|
||||
getDashboard: () => request('/api/stats/dashboard'),
|
||||
getProxyLogs: (params?: ProxyLogsQuery) => request(`/api/stats/proxy-logs${buildQueryString(params)}`) as Promise<ProxyLogsResponse>,
|
||||
getProxyLogDetail: (id: number) => request(`/api/stats/proxy-logs/${id}`) as Promise<ProxyLogDetail>,
|
||||
getProxyDebugTraces: (params?: { limit?: number }) => request(`/api/stats/proxy-debug/traces${buildQueryString(params)}`) as Promise<ProxyDebugTracesResponse>,
|
||||
getProxyDebugTraceDetail: (id: number) => request(`/api/stats/proxy-debug/traces/${id}`) as Promise<ProxyDebugTraceDetail>,
|
||||
checkModels: (accountId: number) => request(`/api/models/check/${accountId}`, { method: 'POST' }),
|
||||
getSiteDistribution: () => request('/api/stats/site-distribution'),
|
||||
getSiteTrend: (days = 7) => request(`/api/stats/site-trend?days=${days}`),
|
||||
|
||||
@@ -9,7 +9,11 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getProxyLogs: vi.fn(),
|
||||
getProxyLogDetail: vi.fn(),
|
||||
getProxyDebugTraces: vi.fn(),
|
||||
getProxyDebugTraceDetail: vi.fn(),
|
||||
getRuntimeSettings: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -97,6 +101,17 @@ describe('ProxyLogs server-driven page', () => {
|
||||
{ id: 9, name: 'main-site', status: 'active' },
|
||||
{ id: 12, name: 'backup-site', status: 'active' },
|
||||
]);
|
||||
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||
proxyDebugTraceEnabled: false,
|
||||
proxyDebugCaptureHeaders: true,
|
||||
proxyDebugCaptureBodies: false,
|
||||
proxyDebugCaptureStreamChunks: false,
|
||||
proxyDebugTargetSessionId: '',
|
||||
proxyDebugTargetClientKind: '',
|
||||
proxyDebugTargetModel: '',
|
||||
proxyDebugRetentionHours: 24,
|
||||
proxyDebugMaxBodyBytes: 262144,
|
||||
});
|
||||
apiMock.getProxyLogs.mockResolvedValue(buildListResponse());
|
||||
apiMock.getProxyLogDetail.mockResolvedValue({
|
||||
id: 101,
|
||||
@@ -151,6 +166,40 @@ describe('ProxyLogs server-driven page', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
apiMock.getProxyDebugTraces.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 701,
|
||||
createdAt: '2026-03-28 18:00:00',
|
||||
requestedModel: 'gpt-4o',
|
||||
downstreamPath: '/v1/responses',
|
||||
finalStatus: 'failed',
|
||||
finalUpstreamPath: '/responses',
|
||||
clientKind: 'codex',
|
||||
sessionId: 'sess-debug-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.getProxyDebugTraceDetail.mockResolvedValue({
|
||||
trace: {
|
||||
id: 701,
|
||||
requestedModel: 'gpt-4o',
|
||||
sessionId: 'sess-debug-1',
|
||||
},
|
||||
attempts: [],
|
||||
});
|
||||
apiMock.updateRuntimeSettings.mockResolvedValue({
|
||||
success: true,
|
||||
proxyDebugTraceEnabled: true,
|
||||
proxyDebugCaptureHeaders: true,
|
||||
proxyDebugCaptureBodies: true,
|
||||
proxyDebugCaptureStreamChunks: false,
|
||||
proxyDebugTargetSessionId: 'sess-debug-1',
|
||||
proxyDebugTargetClientKind: 'codex',
|
||||
proxyDebugTargetModel: 'gpt-4o',
|
||||
proxyDebugRetentionHours: 12,
|
||||
proxyDebugMaxBodyBytes: 131072,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -193,6 +242,85 @@ describe('ProxyLogs server-driven page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('allows toggling proxy debug settings directly from the proxy logs page', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/logs']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const debugButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('调试')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
debugButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getRuntimeSettings).toHaveBeenCalled();
|
||||
expect(apiMock.getProxyDebugTraces).toHaveBeenCalled();
|
||||
expect(collectText(root.root)).toContain('最近调试追踪');
|
||||
expect(collectText(root.root)).toContain('sess-debug-1');
|
||||
|
||||
const traceEnabledToggle = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.type === 'checkbox'
|
||||
&& node.props['data-debug-setting'] === 'trace-enabled'
|
||||
));
|
||||
const captureBodiesToggle = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.type === 'checkbox'
|
||||
&& node.props['data-debug-setting'] === 'capture-bodies'
|
||||
));
|
||||
const sessionInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['data-debug-setting'] === 'target-session-id'
|
||||
));
|
||||
const retentionInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['data-debug-setting'] === 'retention-hours'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
traceEnabledToggle.props.onChange({ target: { checked: true } });
|
||||
captureBodiesToggle.props.onChange({ target: { checked: true } });
|
||||
sessionInput.props.onChange({ target: { value: 'sess-debug-1' } });
|
||||
retentionInput.props.onChange({ target: { value: '12' } });
|
||||
});
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '保存调试设置'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||
proxyDebugTraceEnabled: true,
|
||||
proxyDebugCaptureBodies: true,
|
||||
proxyDebugTargetSessionId: 'sess-debug-1',
|
||||
proxyDebugRetentionHours: 12,
|
||||
}));
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the model badge sized to the model name in desktop rows', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useDeferredValue, useEffect, useMemo, useRef, useState, useCallb
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
api,
|
||||
type ProxyDebugTraceDetail,
|
||||
type ProxyDebugTraceListItem,
|
||||
type ProxyLogBillingDetails,
|
||||
type ProxyLogClientOption,
|
||||
type ProxyLogDetail,
|
||||
@@ -40,6 +42,24 @@ type ProxyLogSiteFilterOption = {
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
type ProxyDebugSettingsState = {
|
||||
proxyDebugTraceEnabled: boolean;
|
||||
proxyDebugCaptureHeaders: boolean;
|
||||
proxyDebugCaptureBodies: boolean;
|
||||
proxyDebugCaptureStreamChunks: boolean;
|
||||
proxyDebugTargetSessionId: string;
|
||||
proxyDebugTargetClientKind: string;
|
||||
proxyDebugTargetModel: string;
|
||||
proxyDebugRetentionHours: number;
|
||||
proxyDebugMaxBodyBytes: number;
|
||||
};
|
||||
|
||||
type ProxyDebugTraceDetailState = {
|
||||
loading: boolean;
|
||||
data?: ProxyDebugTraceDetail;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZES = [20, 50, 100];
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
|
||||
@@ -55,6 +75,17 @@ const EMPTY_SUMMARY: ProxyLogsSummary = {
|
||||
totalCost: 0,
|
||||
totalTokensAll: 0,
|
||||
};
|
||||
const DEFAULT_PROXY_DEBUG_SETTINGS: ProxyDebugSettingsState = {
|
||||
proxyDebugTraceEnabled: false,
|
||||
proxyDebugCaptureHeaders: true,
|
||||
proxyDebugCaptureBodies: false,
|
||||
proxyDebugCaptureStreamChunks: false,
|
||||
proxyDebugTargetSessionId: '',
|
||||
proxyDebugTargetClientKind: '',
|
||||
proxyDebugTargetModel: '',
|
||||
proxyDebugRetentionHours: 24,
|
||||
proxyDebugMaxBodyBytes: 262144,
|
||||
};
|
||||
|
||||
function formatLatency(ms: number) {
|
||||
if (ms >= 1000) {
|
||||
@@ -314,6 +345,13 @@ export default function ProxyLogs() {
|
||||
const [sites, setSites] = useState<Array<{ id: number; name: string; status?: string | null }>>([]);
|
||||
const [clientOptions, setClientOptions] = useState<ProxyLogClientOption[]>([]);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [showDebugPanel, setShowDebugPanel] = useState(false);
|
||||
const [debugPanelLoading, setDebugPanelLoading] = useState(false);
|
||||
const [debugPanelSaving, setDebugPanelSaving] = useState(false);
|
||||
const [debugSettings, setDebugSettings] = useState<ProxyDebugSettingsState>(DEFAULT_PROXY_DEBUG_SETTINGS);
|
||||
const [debugTraces, setDebugTraces] = useState<ProxyDebugTraceListItem[]>([]);
|
||||
const [selectedDebugTraceId, setSelectedDebugTraceId] = useState<number | null>(null);
|
||||
const [debugDetailById, setDebugDetailById] = useState<Record<number, ProxyDebugTraceDetailState>>({});
|
||||
const isMobile = useIsMobile(768);
|
||||
const toast = useToast();
|
||||
const loadSeq = useRef(0);
|
||||
@@ -527,6 +565,106 @@ export default function ProxyLogs() {
|
||||
}
|
||||
}, [detailById, toast]);
|
||||
|
||||
const loadDebugPanel = useCallback(async (silent = false) => {
|
||||
if (!silent) setDebugPanelLoading(true);
|
||||
try {
|
||||
const [runtimeSettings, traceResponse] = await Promise.all([
|
||||
api.getRuntimeSettings(),
|
||||
api.getProxyDebugTraces({ limit: 20 }),
|
||||
]);
|
||||
setDebugSettings({
|
||||
proxyDebugTraceEnabled: !!runtimeSettings?.proxyDebugTraceEnabled,
|
||||
proxyDebugCaptureHeaders: runtimeSettings?.proxyDebugCaptureHeaders !== false,
|
||||
proxyDebugCaptureBodies: !!runtimeSettings?.proxyDebugCaptureBodies,
|
||||
proxyDebugCaptureStreamChunks: !!runtimeSettings?.proxyDebugCaptureStreamChunks,
|
||||
proxyDebugTargetSessionId: String(runtimeSettings?.proxyDebugTargetSessionId || ''),
|
||||
proxyDebugTargetClientKind: String(runtimeSettings?.proxyDebugTargetClientKind || ''),
|
||||
proxyDebugTargetModel: String(runtimeSettings?.proxyDebugTargetModel || ''),
|
||||
proxyDebugRetentionHours: Number(runtimeSettings?.proxyDebugRetentionHours || 24),
|
||||
proxyDebugMaxBodyBytes: Number(runtimeSettings?.proxyDebugMaxBodyBytes || 262144),
|
||||
});
|
||||
const items = Array.isArray(traceResponse?.items) ? traceResponse.items : [];
|
||||
setDebugTraces(items);
|
||||
setSelectedDebugTraceId((current) => {
|
||||
if (current && items.some((item) => item.id === current)) return current;
|
||||
return items[0]?.id ?? null;
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '加载代理调试面板失败');
|
||||
} finally {
|
||||
if (!silent) setDebugPanelLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDebugPanel) return;
|
||||
void loadDebugPanel();
|
||||
}, [loadDebugPanel, showDebugPanel]);
|
||||
|
||||
const loadDebugTraceDetail = useCallback(async (id: number) => {
|
||||
const existing = debugDetailById[id];
|
||||
if (existing?.loading || existing?.data) return;
|
||||
|
||||
setDebugDetailById((current) => ({
|
||||
...current,
|
||||
[id]: { loading: true },
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await api.getProxyDebugTraceDetail(id);
|
||||
setDebugDetailById((current) => ({
|
||||
...current,
|
||||
[id]: { loading: false, data },
|
||||
}));
|
||||
} catch (error: any) {
|
||||
const message = error?.message || '加载调试追踪详情失败';
|
||||
setDebugDetailById((current) => ({
|
||||
...current,
|
||||
[id]: { loading: false, error: message },
|
||||
}));
|
||||
toast.error(message);
|
||||
}
|
||||
}, [debugDetailById, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDebugPanel || !selectedDebugTraceId) return;
|
||||
void loadDebugTraceDetail(selectedDebugTraceId);
|
||||
}, [loadDebugTraceDetail, selectedDebugTraceId, showDebugPanel]);
|
||||
|
||||
const handleSaveDebugSettings = useCallback(async () => {
|
||||
setDebugPanelSaving(true);
|
||||
try {
|
||||
const updated = await api.updateRuntimeSettings({
|
||||
proxyDebugTraceEnabled: debugSettings.proxyDebugTraceEnabled,
|
||||
proxyDebugCaptureHeaders: debugSettings.proxyDebugCaptureHeaders,
|
||||
proxyDebugCaptureBodies: debugSettings.proxyDebugCaptureBodies,
|
||||
proxyDebugCaptureStreamChunks: debugSettings.proxyDebugCaptureStreamChunks,
|
||||
proxyDebugTargetSessionId: debugSettings.proxyDebugTargetSessionId.trim(),
|
||||
proxyDebugTargetClientKind: debugSettings.proxyDebugTargetClientKind.trim(),
|
||||
proxyDebugTargetModel: debugSettings.proxyDebugTargetModel.trim(),
|
||||
proxyDebugRetentionHours: Math.max(1, Math.trunc(Number(debugSettings.proxyDebugRetentionHours || 24))),
|
||||
proxyDebugMaxBodyBytes: Math.max(1024, Math.trunc(Number(debugSettings.proxyDebugMaxBodyBytes || 262144))),
|
||||
});
|
||||
setDebugSettings({
|
||||
proxyDebugTraceEnabled: !!updated?.proxyDebugTraceEnabled,
|
||||
proxyDebugCaptureHeaders: updated?.proxyDebugCaptureHeaders !== false,
|
||||
proxyDebugCaptureBodies: !!updated?.proxyDebugCaptureBodies,
|
||||
proxyDebugCaptureStreamChunks: !!updated?.proxyDebugCaptureStreamChunks,
|
||||
proxyDebugTargetSessionId: String(updated?.proxyDebugTargetSessionId || ''),
|
||||
proxyDebugTargetClientKind: String(updated?.proxyDebugTargetClientKind || ''),
|
||||
proxyDebugTargetModel: String(updated?.proxyDebugTargetModel || ''),
|
||||
proxyDebugRetentionHours: Number(updated?.proxyDebugRetentionHours || 24),
|
||||
proxyDebugMaxBodyBytes: Number(updated?.proxyDebugMaxBodyBytes || 262144),
|
||||
});
|
||||
toast.success('代理调试设置已保存');
|
||||
await loadDebugPanel(true);
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '保存代理调试设置失败');
|
||||
} finally {
|
||||
setDebugPanelSaving(false);
|
||||
}
|
||||
}, [debugSettings, loadDebugPanel, toast]);
|
||||
|
||||
const handleToggleExpand = useCallback((id: number) => {
|
||||
const shouldExpand = expanded !== id;
|
||||
setExpanded(shouldExpand ? id : null);
|
||||
@@ -534,6 +672,7 @@ export default function ProxyLogs() {
|
||||
void loadDetail(id);
|
||||
}
|
||||
}, [expanded, loadDetail]);
|
||||
const selectedDebugTraceDetail = selectedDebugTraceId ? debugDetailById[selectedDebugTraceId] : undefined;
|
||||
|
||||
const filterControls = (
|
||||
<>
|
||||
@@ -650,6 +789,14 @@ export default function ProxyLogs() {
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDebugPanel((current) => !current)}
|
||||
className={`btn btn-ghost${showDebugPanel ? ' btn-ghost-active' : ''}`}
|
||||
style={{ border: '1px solid var(--color-border)', padding: '6px 14px' }}
|
||||
>
|
||||
{showDebugPanel ? '收起调试' : '调试追踪'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
className={`btn btn-ghost${autoRefresh ? ' btn-ghost-active' : ''}`}
|
||||
@@ -684,6 +831,251 @@ export default function ProxyLogs() {
|
||||
)}
|
||||
/>
|
||||
|
||||
{showDebugPanel ? (
|
||||
<div className="card" style={{ marginBottom: 12, display: 'grid', gap: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>代理调试追踪</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
在使用日志页直接开启后,后续请求会记录路由选择、上游 endpoint 尝试和原始请求/响应。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void loadDebugPanel()}
|
||||
disabled={debugPanelLoading}
|
||||
>
|
||||
{debugPanelLoading ? '刷新中...' : '刷新调试面板'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugSettings.proxyDebugTraceEnabled}
|
||||
data-debug-setting="trace-enabled"
|
||||
onChange={(e) => setDebugSettings((current) => ({ ...current, proxyDebugTraceEnabled: !!e.target.checked }))}
|
||||
/>
|
||||
<span>开启调试追踪</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugSettings.proxyDebugCaptureHeaders}
|
||||
data-debug-setting="capture-headers"
|
||||
onChange={(e) => setDebugSettings((current) => ({ ...current, proxyDebugCaptureHeaders: !!e.target.checked }))}
|
||||
/>
|
||||
<span>采集原始请求/响应头</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugSettings.proxyDebugCaptureBodies}
|
||||
data-debug-setting="capture-bodies"
|
||||
onChange={(e) => setDebugSettings((current) => ({ ...current, proxyDebugCaptureBodies: !!e.target.checked }))}
|
||||
/>
|
||||
<span>采集请求体和响应体</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugSettings.proxyDebugCaptureStreamChunks}
|
||||
data-debug-setting="capture-stream-chunks"
|
||||
onChange={(e) => setDebugSettings((current) => ({ ...current, proxyDebugCaptureStreamChunks: !!e.target.checked }))}
|
||||
/>
|
||||
<span>采集流式原始分片</span>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>目标 Session ID</span>
|
||||
<input
|
||||
type="text"
|
||||
value={debugSettings.proxyDebugTargetSessionId}
|
||||
data-debug-setting="target-session-id"
|
||||
onChange={(e) => setDebugSettings((current) => ({ ...current, proxyDebugTargetSessionId: e.target.value }))}
|
||||
placeholder="留空表示不过滤"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>目标客户端</span>
|
||||
<input
|
||||
type="text"
|
||||
value={debugSettings.proxyDebugTargetClientKind}
|
||||
data-debug-setting="target-client-kind"
|
||||
onChange={(e) => setDebugSettings((current) => ({ ...current, proxyDebugTargetClientKind: e.target.value }))}
|
||||
placeholder="如 codex / claude_code"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>目标模型</span>
|
||||
<input
|
||||
type="text"
|
||||
value={debugSettings.proxyDebugTargetModel}
|
||||
data-debug-setting="target-model"
|
||||
onChange={(e) => setDebugSettings((current) => ({ ...current, proxyDebugTargetModel: e.target.value }))}
|
||||
placeholder="如 gpt-4o"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>保留时长(小时)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={debugSettings.proxyDebugRetentionHours}
|
||||
data-debug-setting="retention-hours"
|
||||
onChange={(e) => setDebugSettings((current) => ({ ...current, proxyDebugRetentionHours: Number(e.target.value || 1) }))}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>抓取体积上限(字节)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1024}
|
||||
value={debugSettings.proxyDebugMaxBodyBytes}
|
||||
data-debug-setting="max-body-bytes"
|
||||
onChange={(e) => setDebugSettings((current) => ({ ...current, proxyDebugMaxBodyBytes: Number(e.target.value || 1024) }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void handleSaveDebugSettings()}
|
||||
disabled={debugPanelSaving}
|
||||
>
|
||||
{debugPanelSaving ? '保存中...' : '保存调试设置'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setDebugSettings(DEFAULT_PROXY_DEBUG_SETTINGS)}
|
||||
>
|
||||
重置为默认值
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: isMobile ? '1fr' : 'minmax(280px, 360px) minmax(0, 1fr)' }}>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontWeight: 600 }}>最近调试追踪</div>
|
||||
{debugTraces.length === 0 ? (
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
暂无调试追踪,开启后等待新请求进入即可。
|
||||
</div>
|
||||
) : (
|
||||
debugTraces.map((trace) => (
|
||||
<button
|
||||
key={trace.id}
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
textAlign: 'left',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: selectedDebugTraceId === trace.id ? 'color-mix(in srgb, var(--color-primary) 10%, transparent)' : 'transparent',
|
||||
}}
|
||||
onClick={() => {
|
||||
setSelectedDebugTraceId(trace.id);
|
||||
void loadDebugTraceDetail(trace.id);
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontWeight: 600 }}>{trace.sessionId || `trace-${trace.id}`}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{trace.requestedModel || '-'} · {trace.downstreamPath}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{trace.finalStatus || 'pending'} · {trace.finalUpstreamPath || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ fontWeight: 600 }}>追踪详情</div>
|
||||
{!selectedDebugTraceId ? (
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
选择左侧一条追踪后查看完整的路由选择、请求头和响应体。
|
||||
</div>
|
||||
) : selectedDebugTraceDetail?.loading ? (
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>加载追踪详情中...</div>
|
||||
) : selectedDebugTraceDetail?.error ? (
|
||||
<div style={{ color: 'var(--color-danger)', fontSize: 13 }}>{selectedDebugTraceDetail.error}</div>
|
||||
) : selectedDebugTraceDetail?.data ? (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gap: 4, fontSize: 13 }}>
|
||||
<div>下游路径:{selectedDebugTraceDetail.data.trace.downstreamPath || '-'}</div>
|
||||
<div>Session:{selectedDebugTraceDetail.data.trace.sessionId || '-'}</div>
|
||||
<div>模型:{selectedDebugTraceDetail.data.trace.requestedModel || '-'}</div>
|
||||
<div>候选 endpoint:{selectedDebugTraceDetail.data.trace.endpointCandidatesJson || '-'}</div>
|
||||
<div>最终上游路径:{selectedDebugTraceDetail.data.trace.finalUpstreamPath || '-'}</div>
|
||||
</div>
|
||||
<details open>
|
||||
<summary>原始下游请求头</summary>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{selectedDebugTraceDetail.data.trace.requestHeadersJson || '-'}</pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary>原始下游请求体</summary>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{selectedDebugTraceDetail.data.trace.requestBodyJson || '-'}</pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary>最终响应</summary>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{selectedDebugTraceDetail.data.trace.finalResponseBodyJson || '-'}</pre>
|
||||
</details>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontWeight: 600 }}>Attempt 记录</div>
|
||||
{selectedDebugTraceDetail.data.attempts.length === 0 ? (
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>暂无 attempt 记录</div>
|
||||
) : (
|
||||
selectedDebugTraceDetail.data.attempts.map((attempt) => (
|
||||
<details key={attempt.id}>
|
||||
<summary>
|
||||
#{attempt.attemptIndex + 1} · {attempt.endpoint} · {attempt.responseStatus ?? '-'} · {attempt.requestPath}
|
||||
</summary>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||
{`targetUrl: ${attempt.targetUrl}
|
||||
runtimeExecutor: ${attempt.runtimeExecutor || '-'}
|
||||
recoverApplied: ${attempt.recoverApplied ? 'true' : 'false'}
|
||||
downgradeDecision: ${attempt.downgradeDecision ? 'true' : 'false'}
|
||||
downgradeReason: ${attempt.downgradeReason || '-'}
|
||||
|
||||
requestHeaders:
|
||||
${attempt.requestHeadersJson || '-'}
|
||||
|
||||
requestBody:
|
||||
${attempt.requestBodyJson || '-'}
|
||||
|
||||
responseHeaders:
|
||||
${attempt.responseHeadersJson || '-'}
|
||||
|
||||
responseBody:
|
||||
${attempt.responseBodyJson || '-'}
|
||||
|
||||
rawErrorText:
|
||||
${attempt.rawErrorText || '-'}
|
||||
|
||||
memoryWrite:
|
||||
${attempt.memoryWriteJson || '-'}`}
|
||||
</pre>
|
||||
</details>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
暂无追踪详情。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasInvalidTimeRange && (
|
||||
<div className="alert alert-error" style={{ marginBottom: 12 }}>
|
||||
结束时间必须晚于开始时间
|
||||
|
||||
Reference in New Issue
Block a user