From 7092e2f1294b07059a51345dd16fd17613ef6c1a Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 29 Mar 2026 13:39:25 +0800 Subject: [PATCH] [codex] refine proxy logs debug UI follow-ups (#312) * Refine proxy logs debug page layout * Align proxy logs debug UI with page patterns * Prevent proxy trace detail refresh flicker * Polish proxy trace detail modal --- .../pages/ProxyLogs.server-driven.test.tsx | 125 +++ src/web/pages/ProxyLogs.tsx | 799 ++++++++++-------- 2 files changed, 587 insertions(+), 337 deletions(-) diff --git a/src/web/pages/ProxyLogs.server-driven.test.tsx b/src/web/pages/ProxyLogs.server-driven.test.tsx index 5de90ee..9a8075b 100644 --- a/src/web/pages/ProxyLogs.server-driven.test.tsx +++ b/src/web/pages/ProxyLogs.server-driven.test.tsx @@ -323,6 +323,42 @@ describe('ProxyLogs server-driven page', () => { } }); + it('opens debug trace detail on demand instead of preloading the first trace inline', async () => { + let root!: WebTestRenderer; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + expect(apiMock.getProxyDebugTraceDetail).not.toHaveBeenCalled(); + + const viewDetailButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).trim() === '查看详情' + )); + + await act(async () => { + viewDetailButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.getProxyDebugTraceDetail).toHaveBeenCalledWith(701); + expect(collectText(root.root)).toContain('原始下游请求头'); + expect(collectText(root.root)).toContain('Attempt 记录'); + } finally { + root?.unmount(); + } + }); + it('polls debug traces after tracing is enabled so new results are not hidden behind the settings modal', async () => { vi.useFakeTimers(); apiMock.getRuntimeSettings.mockResolvedValue({ @@ -368,6 +404,95 @@ describe('ProxyLogs server-driven page', () => { } }); + it('keeps debug trace detail visible during polling refresh instead of flashing back to loading', async () => { + vi.useFakeTimers(); + apiMock.getRuntimeSettings.mockResolvedValue({ + proxyDebugTraceEnabled: true, + proxyDebugCaptureHeaders: true, + proxyDebugCaptureBodies: false, + proxyDebugCaptureStreamChunks: false, + proxyDebugTargetSessionId: '', + proxyDebugTargetClientKind: '', + proxyDebugTargetModel: '', + proxyDebugRetentionHours: 24, + proxyDebugMaxBodyBytes: 262144, + }); + + let resolveDetail!: (value: any) => void; + apiMock.getProxyDebugTraceDetail + .mockResolvedValueOnce({ + trace: { + id: 701, + requestedModel: 'gpt-4o', + sessionId: 'sess-debug-1', + requestHeadersJson: '{\"before\":true}', + }, + attempts: [], + }) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveDetail = resolve; + })); + + let root!: WebTestRenderer; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const viewDetailButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).trim() === '查看详情' + )); + + await act(async () => { + viewDetailButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(collectText(root.root)).toContain('原始下游请求头'); + expect(collectText(root.root)).not.toContain('加载追踪详情中...'); + + await act(async () => { + vi.advanceTimersByTime(2100); + }); + await flushMicrotasks(); + + expect(collectText(root.root)).toContain('原始下游请求头'); + expect(collectText(root.root)).not.toContain('加载追踪详情中...'); + + await act(async () => { + resolveDetail({ + trace: { + id: 701, + requestedModel: 'gpt-4o', + sessionId: 'sess-debug-1', + requestHeadersJson: '{\"after\":true}', + }, + attempts: [], + }); + }); + await flushMicrotasks(); + + expect(collectText(root.root)).toContain('原始下游请求头'); + expect(collectText(root.root)).not.toContain('加载追踪详情中...'); + } finally { + await act(async () => { + root?.unmount(); + }); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } + }); + it('keeps the model badge sized to the model name in desktop rows', async () => { let root!: WebTestRenderer; diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index 73251c5..31ebc85 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -63,8 +63,11 @@ type ProxyDebugTraceDetailState = { error?: string; }; +type ProxyDebugTraceAttempt = ProxyDebugTraceDetail['attempts'][number]; + const PAGE_SIZES = [20, 50, 100]; const DEFAULT_PAGE_SIZE = 50; +const TRACE_TABLE_LIMIT = 20; const PROXY_LOG_CLIENT_FAMILY_LABELS: Record = { codex: 'Codex', claude_code: 'Claude Code', @@ -90,7 +93,7 @@ const DEFAULT_PROXY_DEBUG_SETTINGS: ProxyDebugSettingsState = { proxyDebugMaxBodyBytes: 262144, }; const DEBUG_REFRESH_INTERVAL_MS = 2000; -const debugInputStyle: React.CSSProperties = { +const formInputStyle: React.CSSProperties = { width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)', @@ -100,7 +103,7 @@ const debugInputStyle: React.CSSProperties = { background: 'var(--color-bg)', color: 'var(--color-text-primary)', }; -const debugSectionCardStyle: React.CSSProperties = { +const formSectionStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 12, @@ -109,29 +112,22 @@ const debugSectionCardStyle: React.CSSProperties = { borderRadius: 'var(--radius-md)', background: 'var(--color-bg-card)', }; -const debugSectionLabelStyle: React.CSSProperties = { +const formSectionLabelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)', letterSpacing: '0.02em', }; -const debugToggleCardStyle: React.CSSProperties = { +const debugCheckboxRowStyle: React.CSSProperties = { display: 'flex', - alignItems: 'flex-start', - gap: 10, - padding: '12px 14px', - border: '1px solid var(--color-border)', - borderRadius: 'var(--radius-sm)', - background: 'var(--color-bg)', + alignItems: 'center', + gap: 8, color: 'var(--color-text-primary)', }; -const debugSummaryItemStyle: React.CSSProperties = { +const compactSummaryMetricStyle: React.CSSProperties = { display: 'grid', - gap: 6, - padding: 14, - border: '1px solid var(--color-border-light)', - borderRadius: 'var(--radius-md)', - background: 'var(--color-bg-card)', + gap: 4, + minWidth: 112, }; const debugCodeBlockStyle: React.CSSProperties = { whiteSpace: 'pre-wrap', @@ -141,10 +137,53 @@ const debugCodeBlockStyle: React.CSSProperties = { borderRadius: 'var(--radius-sm)', border: '1px solid var(--color-border-light)', background: 'var(--color-bg)', + fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, overflowX: 'auto', }; +const detailInfoGridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', + gap: 12, +}; +const detailInfoItemStyle: React.CSSProperties = { + display: 'grid', + gap: 4, + minWidth: 0, +}; +const detailInfoLabelStyle: React.CSSProperties = { + fontSize: 12, + color: 'var(--color-text-muted)', +}; +const detailInfoValueStyle: React.CSSProperties = { + fontSize: 13, + color: 'var(--color-text-primary)', + fontWeight: 600, + minWidth: 0, + wordBreak: 'break-word', +}; +const detailSectionTitleStyle: React.CSSProperties = { + fontSize: 13, + fontWeight: 600, + color: 'var(--color-text-primary)', +}; +const detailExpandableCardStyle: React.CSSProperties = { + border: '1px solid var(--color-border-light)', + borderRadius: 'var(--radius-sm)', + background: 'var(--color-bg-card)', + overflow: 'hidden', +}; +const detailExpandableSummaryStyle: React.CSSProperties = { + cursor: 'pointer', + listStyle: 'none', + padding: '10px 12px', + fontSize: 13, + fontWeight: 600, + color: 'var(--color-text-primary)', + borderBottom: '1px solid var(--color-border-light)', + background: 'color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg) 14%)', +}; function formatLatency(ms: number) { if (ms >= 1000) { @@ -426,6 +465,21 @@ function formatProxyDebugTargetSummary(settings: ProxyDebugSettingsState) { return parts.length > 0 ? parts.join(',') : '不过滤,记录所有命中的新请求'; } +function CompactSummaryMetric({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( +
+ {label} + {value} +
+ ); +} + export default function ProxyLogs() { const location = useLocation(); const navigate = useNavigate(); @@ -456,12 +510,14 @@ export default function ProxyLogs() { const [debugDraftSettings, setDebugDraftSettings] = useState(DEFAULT_PROXY_DEBUG_SETTINGS); const [debugTraces, setDebugTraces] = useState([]); const [selectedDebugTraceId, setSelectedDebugTraceId] = useState(null); + const [showDebugTraceDetailModal, setShowDebugTraceDetailModal] = useState(false); const [debugDetailById, setDebugDetailById] = useState>({}); const isMobile = useIsMobile(768); const toast = useToast(); const loadSeq = useRef(0); const selectedDebugTraceIdRef = useRef(null); const debugDetailByIdRef = useRef>({}); + const debugDetailInFlightRef = useRef>(new Set()); const fromApiBoundary = toApiTimeBoundary(fromInput); const toApiBoundaryValue = toApiTimeBoundary(toInput); const hasInvalidTimeRange = Boolean( @@ -689,15 +745,20 @@ export default function ProxyLogs() { const loadDebugTraceDetail = useCallback(async ( id: number, - options?: { force?: boolean; suppressToast?: boolean }, + options?: { force?: boolean; suppressToast?: boolean; preserveVisibleData?: boolean }, ) => { const existing = debugDetailByIdRef.current[id]; + if (debugDetailInFlightRef.current.has(id)) return; if (!options?.force && (existing?.loading || existing?.data)) return; - setDebugDetailById((current) => ({ - ...current, - [id]: { loading: true }, - })); + debugDetailInFlightRef.current.add(id); + + if (!options?.preserveVisibleData || !existing?.data) { + setDebugDetailById((current) => ({ + ...current, + [id]: { loading: true }, + })); + } try { const data = await api.getProxyDebugTraceDetail(id); @@ -714,6 +775,8 @@ export default function ProxyLogs() { if (!options?.suppressToast) { toast.error(message); } + } finally { + debugDetailInFlightRef.current.delete(id); } }, [toast]); @@ -726,7 +789,7 @@ export default function ProxyLogs() { const nextSelectedDebugTraceId = ( currentSelectedDebugTraceId && items.some((item) => item.id === currentSelectedDebugTraceId) ? currentSelectedDebugTraceId - : items[0]?.id ?? null + : null ); selectedDebugTraceIdRef.current = nextSelectedDebugTraceId; setSelectedDebugTraceId(nextSelectedDebugTraceId); @@ -734,16 +797,17 @@ export default function ProxyLogs() { await loadDebugTraceDetail(nextSelectedDebugTraceId, { force: true, suppressToast: true, + preserveVisibleData: showDebugTraceDetailModal, }); } - }, [loadDebugTraceDetail]); + }, [loadDebugTraceDetail, showDebugTraceDetailModal]); const loadDebugTraceList = useCallback(async ( options?: { silent?: boolean; refreshSelectedDetail?: boolean; suppressToast?: boolean }, ) => { if (!options?.silent) setDebugPanelLoading(true); try { - const traceResponse = await api.getProxyDebugTraces({ limit: 20 }); + const traceResponse = await api.getProxyDebugTraces({ limit: TRACE_TABLE_LIMIT }); const items = Array.isArray(traceResponse?.items) ? traceResponse.items : []; await syncDebugTraceItems(items, { refreshSelectedDetail: options?.refreshSelectedDetail }); } catch (error: any) { @@ -760,7 +824,7 @@ export default function ProxyLogs() { try { const [runtimeSettings, traceResponse] = await Promise.all([ api.getRuntimeSettings(), - api.getProxyDebugTraces({ limit: 20 }), + api.getProxyDebugTraces({ limit: TRACE_TABLE_LIMIT }), ]); applyLoadedDebugSettings(normalizeProxyDebugSettings(runtimeSettings), { syncDraft: true }); const items = Array.isArray(traceResponse?.items) ? traceResponse.items : []; @@ -777,9 +841,9 @@ export default function ProxyLogs() { }, [loadDebugState]); useEffect(() => { - if (!selectedDebugTraceId) return; + if (!selectedDebugTraceId || !showDebugTraceDetailModal) return; void loadDebugTraceDetail(selectedDebugTraceId); - }, [loadDebugTraceDetail, selectedDebugTraceId]); + }, [loadDebugTraceDetail, selectedDebugTraceId, showDebugTraceDetailModal]); useEffect(() => { if (!debugSettings.proxyDebugTraceEnabled) return; @@ -846,6 +910,170 @@ export default function ProxyLogs() { } }, [expanded, loadDetail]); const selectedDebugTraceDetail = selectedDebugTraceId ? debugDetailById[selectedDebugTraceId] : undefined; + const selectedDebugTraceListItem = selectedDebugTraceId + ? debugTraces.find((trace) => trace.id === selectedDebugTraceId) || null + : null; + const closeDebugTraceDetailModal = useCallback(() => { + setShowDebugTraceDetailModal(false); + }, []); + const openDebugTraceDetailModal = useCallback((traceId: number) => { + selectedDebugTraceIdRef.current = traceId; + setSelectedDebugTraceId(traceId); + setShowDebugTraceDetailModal(true); + }, []); + + function renderTraceStatusBadge(trace: ProxyDebugTraceListItem) { + const failed = trace.finalStatus === 'failed'; + return ( + + {failed ? '失败' : '成功'} + + ); + } + + function renderAttemptDetail(attempt: ProxyDebugTraceAttempt) { + return ( +
+ + #{attempt.attemptIndex + 1} · {attempt.endpoint} · {attempt.responseStatus ?? '-'} · {attempt.requestPath} + +
+
+
+
目标地址
+
{attempt.targetUrl || '-'}
+
+
+
执行器
+
{attempt.runtimeExecutor || '-'}
+
+
+
恢复逻辑
+
{attempt.recoverApplied ? '已应用' : '未应用'}
+
+
+
降级决策
+
{attempt.downgradeDecision ? '已触发' : '未触发'}
+
+
+ {attempt.downgradeReason ? ( +
+ 降级原因:{attempt.downgradeReason} +
+ ) : null} +
+{`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 || '-'}`}
+          
+
+
+ ); + } + + function renderDebugTraceDetailContent() { + if (!selectedDebugTraceId) { + return ( +
+ 暂无追踪详情。请选择一条最近追踪后再查看。 +
+ ); + } + + if (selectedDebugTraceDetail?.loading) { + return
加载追踪详情中...
; + } + + if (selectedDebugTraceDetail?.error) { + return
{selectedDebugTraceDetail.error}
; + } + + if (!selectedDebugTraceDetail?.data) { + return
暂无追踪详情。
; + } + + return ( +
+
+
基础信息
+
+
+
下游路径
+
{selectedDebugTraceDetail.data.trace.downstreamPath || '-'}
+
+
+
Session
+
{selectedDebugTraceDetail.data.trace.sessionId || '-'}
+
+
+
模型
+
{selectedDebugTraceDetail.data.trace.requestedModel || '-'}
+
+
+
最终上游路径
+
{selectedDebugTraceDetail.data.trace.finalUpstreamPath || '-'}
+
+
+
候选 endpoint
+ + {selectedDebugTraceDetail.data.trace.endpointCandidatesJson || '-'} + +
+
+
+ +
+
+ 原始下游请求头 +
+
{selectedDebugTraceDetail.data.trace.requestHeadersJson || '-'}
+
+
+
+ 原始下游请求体 +
+
{selectedDebugTraceDetail.data.trace.requestBodyJson || '-'}
+
+
+
+ 最终响应 +
+
{selectedDebugTraceDetail.data.trace.finalResponseBodyJson || '-'}
+
+
+
+ +
+
Attempt 记录
+ {selectedDebugTraceDetail.data.attempts.length === 0 ? ( +
暂无 attempt 记录
+ ) : ( + selectedDebugTraceDetail.data.attempts.map(renderAttemptDetail) + )} +
+
+ ); + } const filterControls = ( <> @@ -947,20 +1175,6 @@ export default function ProxyLogs() { ); const latestDebugTrace = debugTraces[0] || null; - const debugStatusBadgeStyle: React.CSSProperties = { - display: 'inline-flex', - alignItems: 'center', - gap: 6, - padding: '4px 10px', - borderRadius: 999, - fontSize: 12, - fontWeight: 600, - border: '1px solid var(--color-border-light)', - color: debugSettings.proxyDebugTraceEnabled ? 'var(--color-success)' : 'var(--color-text-secondary)', - background: debugSettings.proxyDebugTraceEnabled - ? 'color-mix(in srgb, var(--color-success) 10%, transparent)' - : 'var(--color-bg-card)', - }; const debugSettingsFooter = (
-
-
-
记录内容
-
- {formatProxyDebugCaptureSummary(debugSettings)} -
-
-
-
过滤范围
-
- {formatProxyDebugTargetSummary(debugSettings)} -
-
-
-
最近追踪数
-
- {debugTraces.length} -
-
-
-
最新一条
-
- {latestDebugTrace ? formatDateTimeLocal(latestDebugTrace.createdAt) : '暂无追踪'} -
-
+
+ + + +
+ +
+
记录内容:{formatProxyDebugCaptureSummary(debugSettings)}
+
过滤范围:{formatProxyDebugTargetSummary(debugSettings)}
-
-
-
-
最近调试追踪
-
- {debugSettings.proxyDebugTraceEnabled ? '开启中,结果会自动刷新' : '尚未开启'} +
+
+
+
最近调试追踪
+
+ 按最新 20 条展示,点击查看详情会打开完整请求与 attempt 链路。
+
+ {debugSettings.proxyDebugTraceEnabled ? '开启中,结果会自动刷新' : '尚未开启'} +
+
- {debugPanelLoading && debugTraces.length === 0 ? ( -
加载调试追踪中...
- ) : debugTraces.length === 0 ? ( -
- {debugSettings.proxyDebugTraceEnabled - ? '暂时还没有新追踪。这里只显示开启后产生的新请求,等下一次代理请求进入就会出现在这里。' - : '调试追踪尚未开启。点击上方“开启调试”或“调试设置”后,新的代理请求会出现在这里。'} -
- ) : ( -
- {debugTraces.map((trace) => ( - - ))} -
- )} -
- -
-
-
追踪详情
-
- 选择左侧一条追踪后查看完整请求与 attempt 链路 -
-
- - {!selectedDebugTraceId ? ( -
- 暂无追踪详情。开启调试后等待新请求,或从左侧选择一条最近追踪。 -
- ) : selectedDebugTraceDetail?.loading ? ( -
加载追踪详情中...
- ) : selectedDebugTraceDetail?.error ? ( -
{selectedDebugTraceDetail.error}
- ) : selectedDebugTraceDetail?.data ? ( -
-
-
基础信息
-
-
下游路径:{selectedDebugTraceDetail.data.trace.downstreamPath || '-'}
-
Session:{selectedDebugTraceDetail.data.trace.sessionId || '-'}
-
模型:{selectedDebugTraceDetail.data.trace.requestedModel || '-'}
-
候选 endpoint:{selectedDebugTraceDetail.data.trace.endpointCandidatesJson || '-'}
-
最终上游路径:{selectedDebugTraceDetail.data.trace.finalUpstreamPath || '-'}
-
-
- -
-
- 原始下游请求头 -
{selectedDebugTraceDetail.data.trace.requestHeadersJson || '-'}
-
-
- 原始下游请求体 -
{selectedDebugTraceDetail.data.trace.requestBodyJson || '-'}
-
-
- 最终响应 -
{selectedDebugTraceDetail.data.trace.finalResponseBodyJson || '-'}
-
-
- -
-
Attempt 记录
- {selectedDebugTraceDetail.data.attempts.length === 0 ? ( -
暂无 attempt 记录
- ) : ( - selectedDebugTraceDetail.data.attempts.map((attempt) => ( -
- - #{attempt.attemptIndex + 1} · {attempt.endpoint} · {attempt.responseStatus ?? '-'} · {attempt.requestPath} - -
-{`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 || '-'}`}
-                      
-
- )) + 查看详情 + )} -
-
- ) : ( -
- 暂无追踪详情。 -
- )} -
+ > + + + + + + ))} +
+ ) : ( + + + + + + + + + + + + + + + {debugTraces.map((trace) => ( + + + + + + + + + + + ))} + +
时间Session模型下游路径上游路径客户端{tr('状态')}操作
+ {formatDateTimeLocal(trace.createdAt)} + {trace.sessionId || `trace-${trace.id}`}{trace.requestedModel || '-'}{trace.downstreamPath || '-'}{trace.finalUpstreamPath || '-'}{trace.clientKind || '-'}{renderTraceStatusBadge(trace)} + +
+ )}
{isMobile ? ( @@ -1452,6 +1552,31 @@ ${attempt.memoryWriteJson || '-'}`} )} + {isMobile ? ( + +
+ {renderDebugTraceDetailContent()} +
+
+ ) : ( + + {renderDebugTraceDetailContent()} + + )} + {hasInvalidTimeRange && (
结束时间必须晚于开始时间