Files
Cli-Proxy-API-Management-Ce…/src/components/monitor/RequestLogs.tsx
kongkongyo e4850656a5 feat: 增强文档和监控功能
主要更新:
- 完善 README 文档,新增中文详细使用说明与监控中心介绍
- 优化 README.md 文档内容和格式,增加英文和中文文档切换链接
- 新增监控中心模块,支持请求日志、统计分析和模型管理
- 增强 AI 提供商配置页面,添加配置搜索功能
- 更新 .gitignore,移除无效注释和调整条目名称
- 删除 README_CN.md 文件,统一文档结构

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 00:58:48 +08:00

673 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Card } from '@/components/ui/Card';
import { usageApi } from '@/services/api';
import { useDisableModel } from '@/hooks';
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
import { DisableModelModal } from './DisableModelModal';
import { UnsupportedDisableModal } from './UnsupportedDisableModal';
import {
maskSecret,
formatProviderDisplay,
formatTimestamp,
getRateClassName,
getProviderDisplayParts,
type DateRange,
} from '@/utils/monitor';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface RequestLogsProps {
data: UsageData | null;
loading: boolean;
providerMap: Record<string, string>;
providerTypeMap: Record<string, string>;
apiFilter: string;
}
interface LogEntry {
id: string;
timestamp: string;
timestampMs: number;
apiKey: string;
model: string;
source: string;
displayName: string;
providerName: string | null;
providerType: string;
maskedKey: string;
failed: boolean;
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
interface ChannelModelRequest {
failed: boolean;
timestamp: number;
}
// 预计算的统计数据缓存
interface PrecomputedStats {
recentRequests: ChannelModelRequest[];
successRate: string;
totalCount: number;
}
// 虚拟滚动行高
const ROW_HEIGHT = 40;
export function RequestLogs({ data, loading: parentLoading, providerMap, providerTypeMap, apiFilter }: RequestLogsProps) {
const { t } = useTranslation();
const [filterApi, setFilterApi] = useState('');
const [filterModel, setFilterModel] = useState('');
const [filterSource, setFilterSource] = useState('');
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
const [filterProviderType, setFilterProviderType] = useState('');
const [autoRefresh, setAutoRefresh] = useState(10);
const [countdown, setCountdown] = useState(0);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 用 ref 存储 fetchLogData避免作为定时器 useEffect 的依赖
const fetchLogDataRef = useRef<() => Promise<void>>(() => Promise.resolve());
// 虚拟滚动容器 ref
const tableContainerRef = useRef<HTMLDivElement>(null);
// 固定表头容器 ref
const headerRef = useRef<HTMLDivElement>(null);
// 同步表头和内容的水平滚动
const handleScroll = useCallback(() => {
if (tableContainerRef.current && headerRef.current) {
headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft;
}
}, []);
// 时间范围状态
const [timeRange, setTimeRange] = useState<TimeRange>(7);
const [customRange, setCustomRange] = useState<DateRange | undefined>();
// 日志独立数据状态
const [logData, setLogData] = useState<UsageData | null>(null);
const [logLoading, setLogLoading] = useState(false);
const [isFirstLoad, setIsFirstLoad] = useState(true);
// 使用禁用模型 Hook
const {
disableState,
unsupportedState,
disabling,
isModelDisabled,
handleDisableClick,
handleConfirmDisable,
handleCancelDisable,
handleCloseUnsupported,
} = useDisableModel({ providerMap, providerTypeMap });
// 处理时间范围变化
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
setTimeRange(range);
if (custom) {
setCustomRange(custom);
}
}, []);
// 使用日志独立数据或父组件数据
const effectiveData = logData || data;
// 只在首次加载且没有数据时显示 loading 状态
const showLoading = (parentLoading && isFirstLoad && !effectiveData) || (logLoading && !effectiveData);
// 当父组件数据加载完成时,标记首次加载完成
useEffect(() => {
if (!parentLoading && data) {
setIsFirstLoad(false);
}
}, [parentLoading, data]);
// 独立获取日志数据
const fetchLogData = useCallback(async () => {
setLogLoading(true);
try {
const response = await usageApi.getUsage();
const usageData = response?.usage ?? response;
// 应用时间范围过滤
if (usageData?.apis) {
const apis = usageData.apis as UsageData['apis'];
const now = new Date();
let cutoffStart: Date;
let cutoffEnd: Date = new Date(now.getTime());
cutoffEnd.setHours(23, 59, 59, 999);
if (timeRange === 'custom' && customRange) {
cutoffStart = customRange.start;
cutoffEnd = customRange.end;
} else if (typeof timeRange === 'number') {
cutoffStart = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
} else {
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
}
const filtered: UsageData = { apis: {} };
Object.entries(apis).forEach(([apiKey, apiData]) => {
// 如果有 API 过滤器,检查是否匹配
if (apiFilter && !apiKey.toLowerCase().includes(apiFilter.toLowerCase())) {
return;
}
if (!apiData?.models) return;
const filteredModels: Record<string, { details: UsageData['apis'][string]['models'][string]['details'] }> = {};
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
if (!modelData?.details || !Array.isArray(modelData.details)) return;
const filteredDetails = modelData.details.filter((detail) => {
const timestamp = new Date(detail.timestamp);
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
});
if (filteredDetails.length > 0) {
filteredModels[modelName] = { details: filteredDetails };
}
});
if (Object.keys(filteredModels).length > 0) {
filtered.apis[apiKey] = { models: filteredModels };
}
});
setLogData(filtered);
}
} catch (err) {
console.error('日志刷新失败:', err);
} finally {
setLogLoading(false);
}
}, [timeRange, customRange, apiFilter]);
// 同步 fetchLogData 到 ref确保定时器始终调用最新版本
useEffect(() => {
fetchLogDataRef.current = fetchLogData;
}, [fetchLogData]);
// 统一的自动刷新定时器管理
useEffect(() => {
// 清理旧定时器
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
// 禁用自动刷新时
if (autoRefresh <= 0) {
setCountdown(0);
return;
}
// 设置初始倒计时
setCountdown(autoRefresh);
// 创建新定时器
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
// 倒计时结束,触发刷新并重置倒计时
fetchLogDataRef.current();
return autoRefresh;
}
return prev - 1;
});
}, 1000);
// 组件卸载或 autoRefresh 变化时清理
return () => {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
};
}, [autoRefresh]);
// 时间范围变化时立即刷新数据
useEffect(() => {
fetchLogData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRange, customRange]);
// 获取倒计时显示文本
const getCountdownText = () => {
if (logLoading) {
return t('monitor.logs.refreshing');
}
if (autoRefresh === 0) {
return t('monitor.logs.manual_refresh');
}
if (countdown > 0) {
return t('monitor.logs.refresh_in_seconds', { seconds: countdown });
}
return t('monitor.logs.refreshing');
};
// 将数据转换为日志条目
const logEntries = useMemo(() => {
if (!effectiveData?.apis) return [];
const entries: LogEntry[] = [];
let idCounter = 0;
Object.entries(effectiveData.apis).forEach(([apiKey, apiData]) => {
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
modelData.details.forEach((detail) => {
const source = detail.source || 'unknown';
const { provider, masked } = getProviderDisplayParts(source, providerMap);
const displayName = provider ? `${provider} (${masked})` : masked;
const timestampMs = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
// 获取提供商类型
const providerType = providerTypeMap[source] || '--';
entries.push({
id: `${idCounter++}`,
timestamp: detail.timestamp,
timestampMs,
apiKey,
model: modelName,
source,
displayName,
providerName: provider,
providerType,
maskedKey: masked,
failed: detail.failed,
inputTokens: detail.tokens.input_tokens || 0,
outputTokens: detail.tokens.output_tokens || 0,
totalTokens: detail.tokens.total_tokens || 0,
});
});
});
});
// 按时间倒序排序
return entries.sort((a, b) => b.timestampMs - a.timestampMs);
}, [effectiveData, providerMap, providerTypeMap]);
// 预计算所有条目的统计数据(一次性计算,避免渲染时重复计算)
const precomputedStats = useMemo(() => {
const statsMap = new Map<string, PrecomputedStats>();
// 首先按渠道+模型分组,并按时间排序
const channelModelGroups: Record<string, { entry: LogEntry; index: number }[]> = {};
logEntries.forEach((entry, index) => {
const key = `${entry.source}|||${entry.model}`;
if (!channelModelGroups[key]) {
channelModelGroups[key] = [];
}
channelModelGroups[key].push({ entry, index });
});
// 对每个分组按时间正序排序(用于计算累计统计)
Object.values(channelModelGroups).forEach((group) => {
group.sort((a, b) => a.entry.timestampMs - b.entry.timestampMs);
});
// 计算每个条目的统计数据
Object.entries(channelModelGroups).forEach(([, group]) => {
let successCount = 0;
let totalCount = 0;
const recentRequests: ChannelModelRequest[] = [];
group.forEach(({ entry }) => {
totalCount++;
if (!entry.failed) {
successCount++;
}
// 维护最近 10 次请求
recentRequests.push({ failed: entry.failed, timestamp: entry.timestampMs });
if (recentRequests.length > 10) {
recentRequests.shift();
}
// 计算成功率
const successRate = totalCount > 0 ? ((successCount / totalCount) * 100).toFixed(1) : '0.0';
// 存储该条目的统计数据
statsMap.set(entry.id, {
recentRequests: [...recentRequests],
successRate,
totalCount,
});
});
});
return statsMap;
}, [logEntries]);
// 获取筛选选项
const { apis, models, sources, providerTypes } = useMemo(() => {
const apiSet = new Set<string>();
const modelSet = new Set<string>();
const sourceSet = new Set<string>();
const providerTypeSet = new Set<string>();
logEntries.forEach((entry) => {
apiSet.add(entry.apiKey);
modelSet.add(entry.model);
sourceSet.add(entry.source);
if (entry.providerType && entry.providerType !== '--') {
providerTypeSet.add(entry.providerType);
}
});
return {
apis: Array.from(apiSet).sort(),
models: Array.from(modelSet).sort(),
sources: Array.from(sourceSet).sort(),
providerTypes: Array.from(providerTypeSet).sort(),
};
}, [logEntries]);
// 过滤后的数据
const filteredEntries = useMemo(() => {
return logEntries.filter((entry) => {
if (filterApi && entry.apiKey !== filterApi) return false;
if (filterModel && entry.model !== filterModel) return false;
if (filterSource && entry.source !== filterSource) return false;
if (filterStatus === 'success' && entry.failed) return false;
if (filterStatus === 'failed' && !entry.failed) return false;
if (filterProviderType && entry.providerType !== filterProviderType) return false;
return true;
});
}, [logEntries, filterApi, filterModel, filterSource, filterStatus, filterProviderType]);
// 虚拟滚动配置
const rowVirtualizer = useVirtualizer({
count: filteredEntries.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10, // 预渲染上下各 10 行
});
// 格式化数字
const formatNumber = (num: number) => {
return num.toLocaleString('zh-CN');
};
// 获取预计算的统计数据
const getStats = (entry: LogEntry): PrecomputedStats => {
return precomputedStats.get(entry.id) || {
recentRequests: [],
successRate: '0.0',
totalCount: 0,
};
};
// 渲染单行
const renderRow = (entry: LogEntry) => {
const stats = getStats(entry);
const rateValue = parseFloat(stats.successRate);
const disabled = isModelDisabled(entry.source, entry.model);
return (
<>
<td title={entry.apiKey}>
{maskSecret(entry.apiKey)}
</td>
<td>{entry.providerType}</td>
<td title={entry.model}>
{entry.model}
</td>
<td title={entry.source}>
{entry.providerName ? (
<>
<span className={styles.channelName}>{entry.providerName}</span>
<span className={styles.channelSecret}> ({entry.maskedKey})</span>
</>
) : (
entry.maskedKey
)}
</td>
<td>
<span className={`${styles.statusPill} ${entry.failed ? styles.failed : styles.success}`}>
{entry.failed ? t('monitor.logs.failed') : t('monitor.logs.success')}
</span>
</td>
<td>
<div className={styles.statusBars}>
{stats.recentRequests.map((req, idx) => (
<div
key={idx}
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
/>
))}
</div>
</td>
<td className={getRateClassName(rateValue, styles)}>
{stats.successRate}%
</td>
<td>{formatNumber(stats.totalCount)}</td>
<td>{formatNumber(entry.inputTokens)}</td>
<td>{formatNumber(entry.outputTokens)}</td>
<td>{formatNumber(entry.totalTokens)}</td>
<td>{formatTimestamp(entry.timestamp)}</td>
<td>
{entry.source && entry.source !== '-' && entry.source !== 'unknown' ? (
disabled ? (
<span className={styles.disabledLabel}>
{t('monitor.logs.disabled')}
</span>
) : (
<button
className={styles.disableBtn}
title={t('monitor.logs.disable_model')}
onClick={() => handleDisableClick(entry.source, entry.model)}
>
{t('monitor.logs.disable')}
</button>
)
) : (
'-'
)}
</td>
</>
);
};
return (
<>
<Card
title={t('monitor.logs.title')}
subtitle={
<span>
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.logs.total_count', { count: logEntries.length })}
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.logs.scroll_hint')}</span>
</span>
}
extra={
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
customRange={customRange}
/>
}
>
{/* 筛选器 */}
<div className={styles.logFilters}>
<select
className={styles.logSelect}
value={filterApi}
onChange={(e) => setFilterApi(e.target.value)}
>
<option value="">{t('monitor.logs.all_apis')}</option>
{apis.map((api) => (
<option key={api} value={api}>
{maskSecret(api)}
</option>
))}
</select>
<select
className={styles.logSelect}
value={filterProviderType}
onChange={(e) => setFilterProviderType(e.target.value)}
>
<option value="">{t('monitor.logs.all_provider_types')}</option>
{providerTypes.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterModel}
onChange={(e) => setFilterModel(e.target.value)}
>
<option value="">{t('monitor.logs.all_models')}</option>
{models.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterSource}
onChange={(e) => setFilterSource(e.target.value)}
>
<option value="">{t('monitor.logs.all_sources')}</option>
{sources.map((source) => (
<option key={source} value={source}>
{formatProviderDisplay(source, providerMap)}
</option>
))}
</select>
<select
className={styles.logSelect}
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
>
<option value="">{t('monitor.logs.all_status')}</option>
<option value="success">{t('monitor.logs.success')}</option>
<option value="failed">{t('monitor.logs.failed')}</option>
</select>
<span className={styles.logLastUpdate}>
{getCountdownText()}
</span>
<select
className={styles.logSelect}
value={autoRefresh}
onChange={(e) => setAutoRefresh(Number(e.target.value))}
>
<option value="0">{t('monitor.logs.manual_refresh')}</option>
<option value="5">{t('monitor.logs.refresh_5s')}</option>
<option value="10">{t('monitor.logs.refresh_10s')}</option>
<option value="15">{t('monitor.logs.refresh_15s')}</option>
<option value="30">{t('monitor.logs.refresh_30s')}</option>
<option value="60">{t('monitor.logs.refresh_60s')}</option>
</select>
</div>
{/* 虚拟滚动表格 */}
<div className={styles.tableWrapper}>
{showLoading ? (
<div className={styles.emptyState}>{t('common.loading')}</div>
) : filteredEntries.length === 0 ? (
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
) : (
<>
{/* 固定表头 */}
<div ref={headerRef} className={styles.stickyHeader}>
<table className={`${styles.table} ${styles.virtualTable}`}>
<thead>
<tr>
<th>{t('monitor.logs.header_api')}</th>
<th>{t('monitor.logs.header_request_type')}</th>
<th>{t('monitor.logs.header_model')}</th>
<th>{t('monitor.logs.header_source')}</th>
<th>{t('monitor.logs.header_status')}</th>
<th>{t('monitor.logs.header_recent')}</th>
<th>{t('monitor.logs.header_rate')}</th>
<th>{t('monitor.logs.header_count')}</th>
<th>{t('monitor.logs.header_input')}</th>
<th>{t('monitor.logs.header_output')}</th>
<th>{t('monitor.logs.header_total')}</th>
<th>{t('monitor.logs.header_time')}</th>
<th>{t('monitor.logs.header_actions')}</th>
</tr>
</thead>
</table>
</div>
{/* 虚拟滚动容器 */}
<div
ref={tableContainerRef}
className={styles.virtualScrollContainer}
style={{
height: 'calc(100vh - 420px)',
minHeight: '360px',
overflow: 'auto',
}}
onScroll={handleScroll}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
<table className={`${styles.table} ${styles.virtualTable}`}>
<tbody>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const entry = filteredEntries[virtualRow.index];
return (
<tr
key={entry.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'table',
tableLayout: 'fixed',
}}
>
{renderRow(entry)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
{/* 统计信息 */}
{filteredEntries.length > 0 && (
<div style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
{t('monitor.logs.total_count', { count: filteredEntries.length })}
</div>
)}
</Card>
{/* 禁用确认弹窗 */}
<DisableModelModal
disableState={disableState}
disabling={disabling}
onConfirm={handleConfirmDisable}
onCancel={handleCancelDisable}
/>
{/* 不支持自动禁用提示弹窗 */}
<UnsupportedDisableModal
state={unsupportedState}
onClose={handleCloseUnsupported}
/>
</>
);
}