Files
Cli-Proxy-API-Management-Ce…/src/pages/LogsPage.tsx
Supra4E8C 09c17c03b9 feat: unify page layout with consistent page titles and structure
- Add page title (h1) to all main pages for consistent hierarchy
  - Wrap page content in container/content div structure
  - Handle 404 error for unsupported OAuth excluded models API
  - Add cache price input field in usage page model pricing
  - Add upgrade required i18n messages for older CPA versions
  - Import mixins in page-level SCSS modules
2025-12-14 20:05:59 +08:00

274 lines
8.8 KiB
TypeScript

import { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { useNotificationStore, useAuthStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import styles from './LogsPage.module.scss';
interface ErrorLogItem {
name: string;
size?: number;
modified?: number;
}
// 限制显示的最大日志行数,防止渲染过多导致卡死
const MAX_DISPLAY_LINES = 500;
export function LogsPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const [logLines, setLogLines] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [autoRefresh, setAutoRefresh] = useState(false);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
const logViewerRef = useRef<HTMLPreElement | null>(null);
const pendingScrollToBottomRef = useRef(false);
// 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0);
const disableControls = connectionStatus !== 'connected';
const isNearBottom = (node: HTMLPreElement | null) => {
if (!node) return true;
const threshold = 24;
return node.scrollHeight - node.scrollTop - node.clientHeight <= threshold;
};
const scrollToBottom = () => {
const node = logViewerRef.current;
if (!node) return;
node.scrollTop = node.scrollHeight;
};
const isWarningLine = (line: string) => /\bwarn(?:ing)?\b/i.test(line) || line.includes('警告');
const loadLogs = async (incremental = false) => {
if (connectionStatus !== 'connected') {
setLoading(false);
return;
}
if (!incremental) {
setLoading(true);
}
setError('');
try {
pendingScrollToBottomRef.current = !incremental || isNearBottom(logViewerRef.current);
const params = incremental && latestTimestampRef.current > 0
? { after: latestTimestampRef.current }
: {};
const data = await logsApi.fetchLogs(params);
// 更新时间戳
if (data['latest-timestamp']) {
latestTimestampRef.current = data['latest-timestamp'];
}
const newLines = Array.isArray(data.lines) ? data.lines : [];
if (incremental && newLines.length > 0) {
// 增量更新:追加新日志并限制总行数
setLogLines(prev => {
const combined = [...prev, ...newLines];
return combined.slice(-MAX_DISPLAY_LINES);
});
} else if (!incremental) {
// 全量加载:只取最后 MAX_DISPLAY_LINES 行
setLogLines(newLines.slice(-MAX_DISPLAY_LINES));
}
} catch (err: any) {
console.error('Failed to load logs:', err);
if (!incremental) {
setError(err?.message || t('logs.load_error'));
}
} finally {
if (!incremental) {
setLoading(false);
}
}
};
const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return;
try {
await logsApi.clearLogs();
setLogLines([]);
latestTimestampRef.current = 0;
showNotification(t('logs.clear_success'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
}
};
const downloadLogs = () => {
const text = logLines.join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'logs.txt';
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('logs.download_success'), 'success');
};
const loadErrorLogs = async () => {
if (connectionStatus !== 'connected') {
setLoadingErrors(false);
return;
}
setLoadingErrors(true);
try {
const res = await logsApi.fetchErrorLogs();
// API 返回 { files: [...] }
const files = (res as any)?.files;
const list: ErrorLogItem[] = Array.isArray(files)
? files.map((f: any) => ({
name: f.name,
size: f.size,
modified: f.modified
}))
: [];
setErrorLogs(list);
} catch (err: any) {
console.error('Failed to load error logs:', err);
// 静默失败,不影响主日志显示
setErrorLogs([]);
} finally {
setLoadingErrors(false);
}
};
const downloadErrorLog = async (name: string) => {
try {
const response = await logsApi.downloadErrorLog(name);
const blob = new Blob([response.data], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('logs.error_log_download_success'), 'success');
} catch (err: any) {
showNotification(`${t('notification.download_failed')}: ${err?.message || ''}`, 'error');
}
};
useEffect(() => {
if (connectionStatus === 'connected') {
latestTimestampRef.current = 0;
loadLogs(false);
loadErrorLogs();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectionStatus]);
useEffect(() => {
if (!autoRefresh || connectionStatus !== 'connected') {
return;
}
const id = window.setInterval(() => {
loadLogs(true);
}, 8000);
return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoRefresh, connectionStatus]);
useEffect(() => {
if (!pendingScrollToBottomRef.current) return;
if (loading) return;
if (!logViewerRef.current) return;
scrollToBottom();
pendingScrollToBottomRef.current = false;
}, [loading, logLines]);
const logsText = logLines.join('\n');
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.content}>
<Card
title={t('logs.log_content')}
extra={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" size="sm" onClick={() => loadLogs(false)} disabled={loading}>
{t('logs.refresh_button')}
</Button>
<Button variant="secondary" size="sm" onClick={() => setAutoRefresh((v) => !v)}>
{t('logs.auto_refresh')}: {autoRefresh ? t('common.yes') : t('common.no')}
</Button>
<Button variant="secondary" size="sm" onClick={downloadLogs} disabled={logLines.length === 0}>
{t('logs.download_button')}
</Button>
<Button variant="danger" size="sm" onClick={clearLogs} disabled={disableControls}>
{t('logs.clear_button')}
</Button>
</div>
}
>
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logsText ? (
<pre ref={logViewerRef} className="log-viewer log-viewer-lines">
{logLines.map((line, index) => (
<span key={index} className={`log-line${isWarningLine(line) ? ' log-line-warning' : ''}`}>
{line}
</span>
))}
</pre>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</Card>
<Card
title={t('logs.error_logs_modal_title')}
extra={
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
{t('common.refresh')}
</Button>
}
>
{errorLogs.length === 0 ? (
<div className="hint">{t('logs.error_logs_empty')}</div>
) : (
<div className="item-list">
{errorLogs.map((item) => (
<div key={item.name} className="item-row">
<div className="item-meta">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
{item.modified ? new Date(item.modified).toLocaleString() : ''}
</div>
</div>
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => downloadErrorLog(item.name)}>
{t('logs.error_logs_download')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
</div>
</div>
);
}