import React, { useEffect, useState, useRef, useMemo } from 'react'; import { listen } from '@tauri-apps/api/event'; import ModalDialog from '../common/ModalDialog'; import { useTranslation } from 'react-i18next'; import { request as invoke } from '../../utils/request'; import { Trash2, Search, X, Copy, CheckCircle, ChevronLeft, ChevronRight, RefreshCw, User } from 'lucide-react'; import { AppConfig } from '../../types/config'; import { formatCompactNumber } from '../../utils/format'; import { useAccountStore } from '../../stores/useAccountStore'; import { isTauri } from '../../utils/env'; import { copyToClipboard } from '../../utils/clipboard'; interface ProxyRequestLog { id: string; timestamp: number; method: string; url: string; status: number; duration: number; model?: string; mapped_model?: string; error?: string; request_body?: string; response_body?: string; input_tokens?: number; output_tokens?: number; account_email?: string; protocol?: string; // "openai" | "anthropic" | "gemini" } interface ProxyStats { total_requests: number; success_count: number; error_count: number; } interface ProxyMonitorProps { className?: string; } // Log Table Component interface LogTableProps { logs: ProxyRequestLog[]; loading: boolean; onLogClick: (log: ProxyRequestLog) => void; t: any; } const LogTable: React.FC = ({ logs, loading, onLogClick, t }) => { return (
{logs.map((log) => ( onLogClick(log)} > ))}
{t('monitor.table.status')} {t('monitor.table.method')} {t('monitor.table.model')} {t('monitor.table.protocol')} {t('monitor.table.account')} {t('monitor.table.path')} {t('monitor.table.usage')} {t('monitor.table.duration')} {t('monitor.table.time')}
= 200 && log.status < 400 ? 'badge-success' : 'badge-error'}`}> {log.status} {log.method} {log.mapped_model && log.model !== log.mapped_model ? `${log.model} => ${log.mapped_model}` : (log.model || '-')} {log.protocol && ( {log.protocol === 'openai' ? 'OpenAI' : log.protocol === 'anthropic' ? 'Claude' : log.protocol === 'gemini' ? 'Gemini' : log.protocol} )} {log.account_email ? log.account_email.replace(/(.{3}).*(@.*)/, '$1***$2') : '-'} {log.url} {log.input_tokens != null &&
I: {formatCompactNumber(log.input_tokens)}
} {log.output_tokens != null &&
O: {formatCompactNumber(log.output_tokens)}
}
{log.duration}ms {new Date(log.timestamp).toLocaleTimeString()}
{/* Loading indicator */} {loading && (
{t('common.loading')}
)} {/* Empty state */} {!loading && logs.length === 0 && (
{t('monitor.table.empty') || '暂无请求记录'}
)}
); }; export const ProxyMonitor: React.FC = ({ className }) => { const { t } = useTranslation(); const [logs, setLogs] = useState([]); const [stats, setStats] = useState({ total_requests: 0, success_count: 0, error_count: 0 }); const [filter, setFilter] = useState(''); const [accountFilter, setAccountFilter] = useState(''); // [FIX] 使用 ref 存储最新的筛选条件,避免 setInterval 闭包问题 const filterRef = useRef(filter); const accountFilterRef = useRef(accountFilter); const currentPageRef = useRef(1); const [selectedLog, setSelectedLog] = useState(null); const [isLoggingEnabled, setIsLoggingEnabled] = useState(false); const [isClearConfirmOpen, setIsClearConfirmOpen] = useState(false); const [copiedRequestId, setCopiedRequestId] = useState(null); const { accounts, fetchAccounts } = useAccountStore(); // Pagination state const PAGE_SIZE_OPTIONS = [50, 100, 200, 500]; const [pageSize, setPageSize] = useState(100); const [currentPage, setCurrentPage] = useState(1); const [totalCount, setTotalCount] = useState(0); const [loading, setLoading] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); const uniqueAccounts = useMemo(() => { const emailSet = new Set(); logs.forEach(log => { if (log.account_email) { emailSet.add(log.account_email); } }); accounts.forEach(acc => { emailSet.add(acc.email); }); return Array.from(emailSet).sort(); }, [logs, accounts]); const loadData = async (page = 1, searchFilter = filter, accountEmailFilter = accountFilter) => { if (loading) return; setLoading(true); try { // Add timeout control (10 seconds) const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), 10000) ); const config = await Promise.race([ invoke('load_config'), timeoutPromise ]) as AppConfig; if (config && config.proxy) { setIsLoggingEnabled(config.proxy.enable_logging); await invoke('set_proxy_monitor_enabled', { enabled: config.proxy.enable_logging }); } const errorsOnly = searchFilter === '__ERROR__'; const baseFilter = errorsOnly ? '' : searchFilter; const actualFilter = accountEmailFilter ? (baseFilter ? `${baseFilter} ${accountEmailFilter}` : accountEmailFilter) : baseFilter; // Get count with filter const count = await Promise.race([ invoke('get_proxy_logs_count_filtered', { filter: actualFilter, errorsOnly: errorsOnly }), timeoutPromise ]) as number; setTotalCount(count); // Use filtered paginated query const offset = (page - 1) * pageSize; const history = await Promise.race([ invoke('get_proxy_logs_filtered', { filter: actualFilter, errorsOnly: errorsOnly, limit: pageSize, offset: offset }), timeoutPromise ]) as ProxyRequestLog[]; if (Array.isArray(history)) { setLogs(history); // Clear pending logs to avoid duplicates (database data is authoritative) pendingLogsRef.current = []; } const currentStats = await Promise.race([ invoke('get_proxy_stats'), timeoutPromise ]) as ProxyStats; if (currentStats) setStats(currentStats); } catch (e: any) { console.error("Failed to load proxy data", e); if (e.message === 'Request timeout') { // Show timeout error to user console.error('Loading monitor data timeout, please try again later'); } } finally { setLoading(false); } }; const totalPages = Math.ceil(totalCount / pageSize); const pageStart = totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1; const pageEnd = totalCount === 0 ? 0 : Math.min(currentPage * pageSize, totalCount); const goToPage = (page: number) => { if (page >= 1 && page <= totalPages && page !== currentPage) { setCurrentPage(page); currentPageRef.current = page; // [FIX] 同步 ref loadData(page, filter, accountFilter); } }; const toggleLogging = async () => { const newState = !isLoggingEnabled; try { const config = await invoke('load_config'); if (config && config.proxy) { config.proxy.enable_logging = newState; await invoke('save_config', { config }); await invoke('set_proxy_monitor_enabled', { enabled: newState }); setIsLoggingEnabled(newState); } } catch (e) { console.error("Failed to toggle logging", e); } }; const pendingLogsRef = useRef([]); const listenerSetupRef = useRef(false); const isMountedRef = useRef(true); useEffect(() => { isMountedRef.current = true; loadData(); fetchAccounts(); let unlistenFn: (() => void) | null = null; let updateTimeout: number | null = null; const setupListener = async () => { if (!isTauri()) return; // Prevent duplicate listener registration (React 18 StrictMode) if (listenerSetupRef.current) { console.debug('[ProxyMonitor] Listener already set up, skipping...'); return; } listenerSetupRef.current = true; console.debug('[ProxyMonitor] Setting up event listener for proxy://request'); unlistenFn = await listen('proxy://request', (event) => { if (!isMountedRef.current) return; const newLog = event.payload; // 移除 body 以减少内存占用 const logSummary = { ...newLog, request_body: undefined, response_body: undefined }; // Check if this log already exists (deduplicate at event level) const alreadyExists = pendingLogsRef.current.some(log => log.id === newLog.id); if (alreadyExists) { console.debug('[ProxyMonitor] Duplicate event ignored:', newLog.id); return; } pendingLogsRef.current.push(logSummary); // 防抖:每 500ms 批量更新一次 if (updateTimeout) clearTimeout(updateTimeout); updateTimeout = setTimeout(async () => { if (!isMountedRef.current) return; const currentPending = pendingLogsRef.current; if (currentPending.length > 0) { setLogs(prev => { // Deduplicate by id const existingIds = new Set(prev.map(log => log.id)); const uniqueNewLogs = currentPending.filter(log => !existingIds.has(log.id)); // Merge and sort by timestamp descending (newest first) const merged = [...uniqueNewLogs, ...prev]; merged.sort((a, b) => b.timestamp - a.timestamp); return merged.slice(0, 100); }); // Fetch stats and total count from backend instead of local calculation try { const [currentStats, count] = await Promise.all([ invoke('get_proxy_stats'), invoke('get_proxy_logs_count_filtered', { filter: '', errorsOnly: false }) ]); if (isMountedRef.current) { if (currentStats) setStats(currentStats); setTotalCount(count); } } catch (e) { console.error('Failed to fetch stats:', e); } pendingLogsRef.current = []; } }, 500); }); }; setupListener(); // Web 模式補強:如果不是 Tauri 環境,則啟用定時輪詢 let pollInterval: number | null = null; if (!isTauri()) { console.debug('[ProxyMonitor] Web mode detected, starting auto-poll (10s)'); pollInterval = window.setInterval(() => { if (isMountedRef.current && !loading) { // [FIX] 使用 ref.current 获取最新的筛选条件 loadData(currentPageRef.current, filterRef.current, accountFilterRef.current); } }, 10000); } return () => { isMountedRef.current = false; listenerSetupRef.current = false; if (unlistenFn) unlistenFn(); if (updateTimeout) clearTimeout(updateTimeout); if (pollInterval) clearInterval(pollInterval); }; }, []); useEffect(() => { setCopiedRequestId(null); }, [selectedLog?.id]); // Reload when pageSize changes useEffect(() => { setCurrentPage(1); loadData(1, filter, accountFilter); }, [pageSize]); // Reload when filter changes (search based on all logs) useEffect(() => { setCurrentPage(1); loadData(1, filter, accountFilter); // [FIX] 同步 ref 值,供 setInterval 使用 filterRef.current = filter; accountFilterRef.current = accountFilter; currentPageRef.current = 1; }, [filter, accountFilter]); // Logs are already filtered and sorted by backend // Apply account filter on frontend const filteredLogs = useMemo(() => { if (!accountFilter) return logs; return logs.filter(log => log.account_email === accountFilter); }, [logs, accountFilter]); const quickFilters = [ { label: t('monitor.filters.all'), value: '' }, { label: t('monitor.filters.error'), value: '__ERROR__' }, { label: t('monitor.filters.chat'), value: 'completions' }, { label: t('monitor.filters.gemini'), value: 'gemini' }, { label: t('monitor.filters.claude'), value: 'claude' }, { label: t('monitor.filters.images'), value: 'images' } ]; const clearLogs = () => { setIsClearConfirmOpen(true); }; const executeClearLogs = async () => { setIsClearConfirmOpen(false); try { await invoke('clear_proxy_logs'); setLogs([]); setStats({ total_requests: 0, success_count: 0, error_count: 0 }); setTotalCount(0); } catch (e) { console.error("Failed to clear logs", e); } }; const formatBody = (body?: string) => { if (!body) return {t('monitor.details.payload_empty')}; try { const obj = JSON.parse(body); return
{JSON.stringify(obj, null, 2)}
; } catch (e) { return
{body}
; } }; const getCopyPayload = (body: string) => { try { const obj = JSON.parse(body); return JSON.stringify(obj, null, 2); } catch (e) { return body; } }; return (
setFilter(e.target.value)} />
{formatCompactNumber(stats.total_requests)} {t('monitor.stats.total')} {formatCompactNumber(stats.success_count)} {t('monitor.stats.ok')} {formatCompactNumber(stats.error_count)} {t('monitor.stats.err')}
{t('monitor.filters.quick_filters')} {quickFilters.map(q => ( ))} {(filter || accountFilter) && }
{ setLoadingDetail(true); try { const detail = await invoke('get_proxy_log_detail', { logId: log.id }); setSelectedLog(detail); } catch (e) { console.error('Failed to load log detail', e); setSelectedLog(log); } finally { setLoadingDetail(false); } }} t={t} /> {/* Pagination Controls */}
{t('common.per_page')}
{currentPage} / {totalPages || 1}
{t('common.pagination_info', { start: pageStart, end: pageEnd, total: totalCount })}
{selectedLog && (
setSelectedLog(null)}>
e.stopPropagation()}> {/* Modal Header */}
{loadingDetail &&
} = 200 && selectedLog.status < 400 ? 'badge-success' : 'badge-error'}`}>{selectedLog.status} {selectedLog.method} {selectedLog.url}
{/* Modal Content */}
{/* Metadata Section */}
{t('monitor.details.time')} {new Date(selectedLog.timestamp).toLocaleString()}
{t('monitor.details.duration')} {selectedLog.duration}ms
{t('monitor.details.tokens')}
In: {formatCompactNumber(selectedLog.input_tokens ?? 0)} Out: {formatCompactNumber(selectedLog.output_tokens ?? 0)}
{selectedLog.protocol && (
{t('monitor.details.protocol')} {selectedLog.protocol}
)}
{t('monitor.details.model')} {selectedLog.model || '-'}
{selectedLog.mapped_model && selectedLog.model !== selectedLog.mapped_model && (
{t('monitor.details.mapped_model')} {selectedLog.mapped_model}
)}
{selectedLog.account_email && (
{t('monitor.details.account_used')} {selectedLog.account_email}
)}
{/* Payloads */}

{t('monitor.details.request_payload')}

{formatBody(selectedLog.request_body)}

{t('monitor.details.response_payload')}

{formatBody(selectedLog.response_body)}
)} setIsClearConfirmOpen(false)} />
); };