diff --git a/.gitignore b/.gitignore index a4abc5b6..53147312 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ pnpm-debug.log* lerna-debug.log* api.md usage.json +CLAUDE.md +AGENTS.md node_modules dist diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3bb940d3..02bde96d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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." }, diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 257d11bd..43095775 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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 以使用此功能。" }, diff --git a/src/pages/LogsPage.module.scss b/src/pages/LogsPage.module.scss index 32b55118..ddfa9902 100644 --- a/src/pages/LogsPage.module.scss +++ b/src/pages/LogsPage.module.scss @@ -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; diff --git a/src/pages/LogsPage.tsx b/src/pages/LogsPage.tsx index e044fd17..eabdac8d 100644 --- a/src/pages/LogsPage.tsx +++ b/src/pages/LogsPage.tsx @@ -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([]); 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,185 +550,264 @@ 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 (

{t('logs.title')}

- - + + + setAutoRefresh(value)} + disabled={disableControls} + label={ + + + {t('logs.auto_refresh')} + + } + /> + + +
+ } + > + {error &&
{error}
} + +
+
+ setSearchQuery(e.target.value)} + placeholder={t('logs.search_placeholder')} + className={styles.searchInput} + rightElement={ + searchQuery ? ( + + ) : ( + + ) + } + /> +
+ setAutoRefresh(value)} - disabled={disableControls} + checked={hideManagementLogs} + onChange={setHideManagementLogs} label={ - - {t('logs.auto_refresh')} + + {t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })} } /> - - -
- } - > - {error &&
{error}
} - {loading ? ( -
{t('logs.loading')}
- ) : logState.buffer.length > 0 ? ( -
- {canLoadMore && ( -
- {t('logs.load_more_hint')} - - {t('logs.hidden_lines', { count: logState.visibleFrom })} + {removedCount > 0 && ( + + {t('logs.removed')} {removedCount} -
- )} -
- {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); - return ( -
-
{line.timestamp || ''}
-
-
- {line.level && ( - - {line.level.toUpperCase()} - - )} - - {line.source && ( - - {line.source} - - )} - - {typeof line.statusCode === 'number' && ( - = 200 && line.statusCode < 300 - ? styles.statusSuccess - : line.statusCode >= 300 && line.statusCode < 400 - ? styles.statusInfo - : line.statusCode >= 400 && line.statusCode < 500 - ? styles.statusWarn - : styles.statusError - ].join(' ')} - > - {line.statusCode} - - )} - - {line.latency && {line.latency}} - {line.ip && {line.ip}} - - {line.method && ( - - {line.method} - - )} - {line.path && ( - - {line.path} - - )} -
- {line.message &&
{line.message}
} -
-
- ); - })} + )}
- ) : ( - - )} - - - {t('common.refresh')} - - } - > - {errorLogs.length === 0 ? ( -
{t('logs.error_logs_empty')}
- ) : ( -
- {errorLogs.map((item) => ( -
-
-
{item.name}
-
- {item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '} - {item.modified ? formatUnixTimestamp(item.modified) : ''} + {loading ? ( +
{t('logs.loading')}
+ ) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? ( +
+ {canLoadMore && ( +
+ {t('logs.load_more_hint')} + + {t('logs.hidden_lines', { count: logState.visibleFrom })} + +
+ )} +
+ {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); + return ( +
{ + void copyLogLine(line.raw); + }} + title={t('logs.double_click_copy_hint', { + defaultValue: 'Double-click to copy', + })} + > +
{line.timestamp || ''}
+
+
+ {line.level && ( + + {line.level.toUpperCase()} + + )} + + {line.source && ( + + {line.source} + + )} + + {typeof line.statusCode === 'number' && ( + = 200 && line.statusCode < 300 + ? styles.statusSuccess + : line.statusCode >= 300 && line.statusCode < 400 + ? styles.statusInfo + : line.statusCode >= 400 && line.statusCode < 500 + ? styles.statusWarn + : styles.statusError, + ].join(' ')} + > + {line.statusCode} + + )} + + {line.latency && {line.latency}} + {line.ip && {line.ip}} + + {line.method && ( + + {line.method} + + )} + {line.path && ( + + {line.path} + + )} +
+ {line.message &&
{line.message}
} +
+
+ ); + })} +
+
+ ) : logState.buffer.length > 0 ? ( + + ) : ( + + )} + + + + {t('common.refresh')} + + } + > + {errorLogs.length === 0 ? ( +
{t('logs.error_logs_empty')}
+ ) : ( +
+ {errorLogs.map((item) => ( +
+
+
{item.name}
+
+ {item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '} + {item.modified ? formatUnixTimestamp(item.modified) : ''} +
+
+
+
-
- -
-
- ))} -
- )} - + ))} +
+ )} +
); diff --git a/src/services/api/logs.ts b/src/services/api/logs.ts index cf38c937..fc5861aa 100644 --- a/src/services/api/logs.ts +++ b/src/services/api/logs.ts @@ -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 => - apiClient.get('/logs', { params }), + fetchLogs: (params: LogsQuery = {}): Promise => apiClient.get('/logs', { params }), clearLogs: () => apiClient.delete('/logs'), - fetchErrorLogs: () => apiClient.get('/request-error-logs'), + fetchErrorLogs: (): Promise => apiClient.get('/request-error-logs'), downloadErrorLog: (filename: string) => apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, { - responseType: 'blob' - }) + responseType: 'blob', + }), };