This commit is contained in:
Supra4E8C
2025-12-18 12:36:17 +08:00
parent aecd5875d6
commit e44beb541f
6 changed files with 445 additions and 203 deletions

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@ pnpm-debug.log*
lerna-debug.log*
api.md
usage.json
CLAUDE.md
AGENTS.md
node_modules
dist

View File

@@ -571,11 +571,15 @@
"auto_refresh_disabled": "Auto refresh disabled",
"load_more_hint": "Scroll up to load more",
"hidden_lines": "Hidden: {{count}} lines",
"hide_management_logs": "Hide {{prefix}} logs",
"search_placeholder": "Search logs by content or keyword",
"search_empty_title": "No matching logs found",
"search_empty_desc": "Try a different keyword or clear the search filter.",
"search_empty_desc": "Try a different keyword or clear the filters.",
"double_click_copy_hint": "Double-click to copy raw log line",
"copy_success": "Log copied to clipboard",
"copy_failed": "Copy failed",
"lines": "lines",
"removed": "Removed",
"removed": "Filtered",
"upgrade_required_title": "Please Upgrade CLI Proxy API",
"upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature."
},

View File

@@ -571,11 +571,15 @@
"auto_refresh_disabled": "自动刷新已关闭",
"load_more_hint": "向上滚动加载更多",
"hidden_lines": "已隐藏 {{count}} 行",
"hide_management_logs": "屏蔽 {{prefix}} 日志",
"search_placeholder": "搜索日志内容或关键字",
"search_empty_title": "未找到匹配的日志",
"search_empty_desc": "尝试更换关键字或清空搜索条件。",
"search_empty_desc": "尝试更换关键字或清空筛选条件。",
"double_click_copy_hint": "双击复制日志原文",
"copy_success": "已复制日志原文",
"copy_failed": "复制失败",
"lines": "行",
"removed": "已删除",
"removed": "已过滤",
"upgrade_required_title": "需要升级 CLI Proxy API",
"upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。"
},

View File

@@ -28,6 +28,62 @@
}
}
.filters {
display: flex;
align-items: center;
gap: $spacing-md;
flex-wrap: wrap;
margin-bottom: $spacing-md;
:global(.form-group) {
margin: 0;
}
}
.searchWrapper {
flex: 1;
min-width: 220px;
max-width: 420px;
}
.searchInput {
padding-right: 44px !important;
}
.searchIcon {
color: var(--text-tertiary);
pointer-events: none;
}
.searchClear {
@include button-reset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: $radius-full;
color: var(--text-secondary);
&:hover {
background: var(--bg-secondary);
}
}
.filterStats {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.removedCount {
color: var(--text-tertiary);
}
.actionButton {
white-space: nowrap;
}
@@ -93,7 +149,9 @@
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
border-left: 3px solid transparent;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
cursor: copy;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 12.5px;
line-height: 1.45;

View File

@@ -1,12 +1,22 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } 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 { Input } from '@/components/ui/Input';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconDownload, IconRefreshCw, IconTimer, IconTrash2 } from '@/components/ui/icons';
import {
IconDownload,
IconEyeOff,
IconRefreshCw,
IconSearch,
IconTimer,
IconTrash2,
IconX,
} from '@/components/ui/icons';
import { useNotificationStore, useAuthStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format';
import styles from './LogsPage.module.scss';
@@ -46,7 +56,7 @@ const HTTP_STATUS_PATTERNS: RegExp[] = [
/\b([1-5]\d{2})\s*-/,
new RegExp(`\\b(?:${HTTP_METHODS.join('|')})\\s+\\S+\\s+([1-5]\\d{2})\\b`),
/\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i,
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i,
];
const detectHttpStatusCode = (text: string): number | undefined => {
@@ -187,9 +197,7 @@ const parseLogLine = (raw: string): ParsedLogLine => {
}
// ip
const ipIndex = segments.findIndex(
(segment) => Boolean(extractIp(segment))
);
const ipIndex = segments.findIndex((segment) => Boolean(extractIp(segment)));
if (ipIndex >= 0) {
const extracted = extractIp(segments[ipIndex]);
if (extracted) {
@@ -236,10 +244,44 @@ const parseLogLine = (raw: string): ParsedLogLine => {
ip,
method,
path,
message
message,
};
};
const getErrorMessage = (err: unknown): string => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
if (typeof err !== 'object' || err === null) return '';
if (!('message' in err)) return '';
const message = (err as { message?: unknown }).message;
return typeof message === 'string' ? message : '';
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
};
export function LogsPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
@@ -249,6 +291,9 @@ export function LogsPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [autoRefresh, setAutoRefresh] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(false);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
@@ -287,9 +332,8 @@ export function LogsPage() {
try {
pendingScrollToBottomRef.current = !incremental || isNearBottom(logViewerRef.current);
const params = incremental && latestTimestampRef.current > 0
? { after: latestTimestampRef.current }
: {};
const params =
incremental && latestTimestampRef.current > 0 ? { after: latestTimestampRef.current } : {};
const data = await logsApi.fetchLogs(params);
// 更新时间戳
@@ -321,10 +365,10 @@ export function LogsPage() {
const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0);
setLogState({ buffer, visibleFrom });
}
} catch (err: any) {
} catch (err: unknown) {
console.error('Failed to load logs:', err);
if (!incremental) {
setError(err?.message || t('logs.load_error'));
setError(getErrorMessage(err) || t('logs.load_error'));
}
} finally {
if (!incremental) {
@@ -340,8 +384,12 @@ export function LogsPage() {
setLogState({ buffer: [], visibleFrom: 0 });
latestTimestampRef.current = 0;
showNotification(t('logs.clear_success'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
};
@@ -367,16 +415,8 @@ export function LogsPage() {
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) {
setErrorLogs(Array.isArray(res.files) ? res.files : []);
} catch (err: unknown) {
console.error('Failed to load error logs:', err);
// 静默失败,不影响主日志显示
setErrorLogs([]);
@@ -396,8 +436,12 @@ export function LogsPage() {
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');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
};
@@ -434,23 +478,65 @@ export function LogsPage() {
() => logState.buffer.slice(logState.visibleFrom),
[logState.buffer, logState.visibleFrom]
);
const trimmedSearchQuery = deferredSearchQuery.trim();
const isSearching = trimmedSearchQuery.length > 0;
const baseLines = isSearching ? logState.buffer : visibleLines;
const { filteredLines, removedCount } = useMemo(() => {
let working = baseLines;
let removed = 0;
if (hideManagementLogs) {
const next: string[] = [];
for (const line of working) {
if (line.includes(MANAGEMENT_API_PREFIX)) {
removed += 1;
} else {
next.push(line);
}
}
working = next;
}
if (trimmedSearchQuery) {
const queryLowered = trimmedSearchQuery.toLowerCase();
const next: string[] = [];
for (const line of working) {
if (line.toLowerCase().includes(queryLowered)) {
next.push(line);
} else {
removed += 1;
}
}
working = next;
}
return { filteredLines: working, removedCount: removed };
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
const parsedVisibleLines = useMemo(
() => visibleLines.map((line) => parseLogLine(line)),
[visibleLines]
() => filteredLines.map((line) => parseLogLine(line)),
[filteredLines]
);
const canLoadMore = logState.visibleFrom > 0;
const canLoadMore = !isSearching && logState.visibleFrom > 0;
const handleLogScroll = () => {
const node = logViewerRef.current;
if (!node) return;
if (isSearching) return;
if (!canLoadMore) return;
if (pendingPrependScrollRef.current) return;
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
pendingPrependScrollRef.current = { scrollHeight: node.scrollHeight, scrollTop: node.scrollTop };
pendingPrependScrollRef.current = {
scrollHeight: node.scrollHeight,
scrollTop: node.scrollTop,
};
setLogState((prev) => ({
...prev,
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0)
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
}));
};
@@ -464,6 +550,15 @@ export function LogsPage() {
pendingPrependScrollRef.current = null;
}, [logState.visibleFrom]);
const copyLogLine = async (raw: string) => {
const ok = await copyToClipboard(raw);
if (ok) {
showNotification(t('logs.copy_success', { defaultValue: 'Copied to clipboard' }), 'success');
} else {
showNotification(t('logs.copy_failed', { defaultValue: 'Copy failed' }), 'error');
}
};
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
@@ -523,9 +618,58 @@ export function LogsPage() {
}
>
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
<div className={styles.filterStats}>
<span>
{parsedVisibleLines.length} {t('logs.lines')}
</span>
{removedCount > 0 && (
<span className={styles.removedCount}>
{t('logs.removed')} {removedCount}
</span>
)}
</div>
</div>
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 ? (
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
@@ -539,9 +683,19 @@ export function LogsPage() {
{parsedVisibleLines.map((line, index) => {
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal') rowClassNames.push(styles.rowError);
if (line.level === 'error' || line.level === 'fatal')
rowClassNames.push(styles.rowError);
return (
<div key={`${logState.visibleFrom + index}-${line.raw}`} className={rowClassNames.join(' ')}>
<div
key={`${logState.visibleFrom + index}-${line.raw}`}
className={rowClassNames.join(' ')}
onDoubleClick={() => {
void copyLogLine(line.raw);
}}
title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy',
})}
>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
<div className={styles.rowMeta}>
@@ -551,9 +705,11 @@ export function LogsPage() {
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal' ? styles.levelError : '',
line.level === 'error' || line.level === 'fatal'
? styles.levelError
: '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : ''
line.level === 'trace' ? styles.levelTrace : '',
]
.filter(Boolean)
.join(' ')}
@@ -579,7 +735,7 @@ export function LogsPage() {
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError
: styles.statusError,
].join(' ')}
>
{line.statusCode}
@@ -607,6 +763,11 @@ export function LogsPage() {
})}
</div>
</div>
) : logState.buffer.length > 0 ? (
<EmptyState
title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
@@ -634,7 +795,11 @@ export function LogsPage() {
</div>
</div>
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => downloadErrorLog(item.name)}>
<Button
variant="secondary"
size="sm"
onClick={() => downloadErrorLog(item.name)}
>
{t('logs.error_logs_download')}
</Button>
</div>

View File

@@ -14,16 +14,25 @@ export interface LogsResponse {
'latest-timestamp': number;
}
export interface ErrorLogFile {
name: string;
size?: number;
modified?: number;
}
export interface ErrorLogsResponse {
files?: ErrorLogFile[];
}
export const logsApi = {
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
apiClient.get('/logs', { params }),
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> => apiClient.get('/logs', { params }),
clearLogs: () => apiClient.delete('/logs'),
fetchErrorLogs: () => apiClient.get('/request-error-logs'),
fetchErrorLogs: (): Promise<ErrorLogsResponse> => apiClient.get('/request-error-logs'),
downloadErrorLog: (filename: string) =>
apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
responseType: 'blob'
})
responseType: 'blob',
}),
};