mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 02:30:51 +08:00
feat(logs): redesign LogsPage with structured log parsing and virtual scrolling
- Add log line parser to extract timestamp, level, status code, latency, IP, HTTP method, and path - Implement virtual scrolling with load-more on scroll-up to handle large log files efficiently - Replace monolithic pre block with structured grid layout for better readability - Add visual badges for log levels and HTTP status codes with color-coded severity - Add IconRefreshCw icon component - Update ToggleSwitch to accept ReactNode as label - Fix fetchConfig calls to use default parameters consistently - Add request deduplication in useConfigStore to prevent duplicate /config API calls - Add i18n keys for load_more_hint and hidden_lines
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label?: string;
|
||||
label?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,15 @@ export function IconInfo({ size = 20, ...props }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function IconRefreshCw({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconDownload({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
@@ -257,4 +266,3 @@ export function IconDollarSign({ size = 20, ...props }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -568,6 +568,8 @@
|
||||
"auto_refresh": "Auto Refresh",
|
||||
"auto_refresh_enabled": "Auto refresh enabled",
|
||||
"auto_refresh_disabled": "Auto refresh disabled",
|
||||
"load_more_hint": "Scroll up to load more",
|
||||
"hidden_lines": "Hidden: {{count}} lines",
|
||||
"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.",
|
||||
|
||||
@@ -568,6 +568,8 @@
|
||||
"auto_refresh": "自动刷新",
|
||||
"auto_refresh_enabled": "自动刷新已开启",
|
||||
"auto_refresh_disabled": "自动刷新已关闭",
|
||||
"load_more_hint": "向上滚动加载更多",
|
||||
"hidden_lines": "已隐藏 {{count}} 行",
|
||||
"search_placeholder": "搜索日志内容或关键字",
|
||||
"search_empty_title": "未找到匹配的日志",
|
||||
"search_empty_desc": "尝试更换关键字或清空搜索条件。",
|
||||
|
||||
@@ -268,7 +268,7 @@ export function AiProvidersPage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await fetchConfig(undefined, true);
|
||||
const data = await fetchConfig();
|
||||
setGeminiKeys(data?.geminiApiKeys || []);
|
||||
setCodexConfigs(data?.codexApiKeys || []);
|
||||
setClaudeConfigs(data?.claudeApiKeys || []);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function ApiKeysPage() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadApiKeys(true);
|
||||
loadApiKeys();
|
||||
}, [loadApiKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -17,67 +17,239 @@
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.controls {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include mobile {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.logViewer {
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: $spacing-lg;
|
||||
border-radius: $radius-lg;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
.actionButton {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
.buttonContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
margin-bottom: $spacing-md;
|
||||
.switchLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: $spacing-2xl;
|
||||
.logPanel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
max-height: 620px;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loadMoreBanner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 48px;
|
||||
margin-bottom: $spacing-md;
|
||||
opacity: 0.5;
|
||||
.loadMoreCount {
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logRow {
|
||||
display: grid;
|
||||
grid-template-columns: 170px 1fr;
|
||||
gap: $spacing-md;
|
||||
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',
|
||||
monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 $spacing-sm 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.rowWarn {
|
||||
border-left-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.rowError {
|
||||
border-left-color: var(--error-color);
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
padding-top: 2px;
|
||||
|
||||
@include mobile {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.rowMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source {
|
||||
color: var(--text-secondary);
|
||||
max-width: 240px;
|
||||
@include text-ellipsis;
|
||||
|
||||
@include mobile {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.statusSuccess {
|
||||
color: var(--success-badge-text);
|
||||
background: var(--success-badge-bg);
|
||||
border-color: var(--success-badge-border);
|
||||
}
|
||||
|
||||
.statusInfo {
|
||||
color: var(--info-color);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.statusWarn {
|
||||
color: var(--warning-color);
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
border-color: rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
|
||||
.statusError {
|
||||
color: var(--failure-badge-text);
|
||||
background: var(--failure-badge-bg);
|
||||
border-color: var(--failure-badge-border);
|
||||
}
|
||||
|
||||
.levelInfo {
|
||||
color: var(--info-color);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.levelWarn {
|
||||
color: var(--warning-color);
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
border-color: rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
|
||||
.levelError {
|
||||
color: var(--error-color);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.levelDebug,
|
||||
.levelTrace {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(107, 114, 128, 0.12);
|
||||
border-color: rgba(107, 114, 128, 0.25);
|
||||
}
|
||||
|
||||
.methodBadge {
|
||||
color: var(--text-primary);
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: rgba(59, 130, 246, 0.22);
|
||||
}
|
||||
|
||||
.path {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
max-width: 520px;
|
||||
@include text-ellipsis;
|
||||
|
||||
@include mobile {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { 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 { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconDownload, IconRefreshCw, IconTimer, IconTrash2 } from '@/components/ui/icons';
|
||||
import { useNotificationStore, useAuthStore } from '@/stores';
|
||||
import { logsApi } from '@/services/api/logs';
|
||||
import styles from './LogsPage.module.scss';
|
||||
@@ -13,30 +15,219 @@ interface ErrorLogItem {
|
||||
modified?: number;
|
||||
}
|
||||
|
||||
// 限制显示的最大日志行数,防止渲染过多导致卡死
|
||||
const MAX_DISPLAY_LINES = 500;
|
||||
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
||||
|
||||
type LogState = {
|
||||
buffer: string[];
|
||||
visibleFrom: number;
|
||||
};
|
||||
|
||||
// 初始只渲染最近 100 行,滚动到顶部再逐步加载更多(避免一次性渲染过多导致卡顿)
|
||||
const INITIAL_DISPLAY_LINES = 100;
|
||||
const LOAD_MORE_LINES = 200;
|
||||
const MAX_BUFFER_LINES = 10000;
|
||||
const LOAD_MORE_THRESHOLD_PX = 72;
|
||||
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
|
||||
type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
|
||||
|
||||
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
|
||||
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\]?\b/i;
|
||||
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
|
||||
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
|
||||
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
||||
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
|
||||
|
||||
type ParsedLogLine = {
|
||||
raw: string;
|
||||
timestamp?: string;
|
||||
level?: LogLevel;
|
||||
source?: string;
|
||||
statusCode?: number;
|
||||
latency?: string;
|
||||
ip?: string;
|
||||
method?: HttpMethod;
|
||||
path?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const extractLogLevel = (value: string): LogLevel | undefined => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'warning') return 'warn';
|
||||
if (normalized === 'warn') return 'warn';
|
||||
if (normalized === 'info') return 'info';
|
||||
if (normalized === 'error') return 'error';
|
||||
if (normalized === 'fatal') return 'fatal';
|
||||
if (normalized === 'debug') return 'debug';
|
||||
if (normalized === 'trace') return 'trace';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const inferLogLevel = (line: string): LogLevel | undefined => {
|
||||
const lowered = line.toLowerCase();
|
||||
if (/\bfatal\b/.test(lowered)) return 'fatal';
|
||||
if (/\berror\b/.test(lowered)) return 'error';
|
||||
if (/\bwarn(?:ing)?\b/.test(lowered) || line.includes('警告')) return 'warn';
|
||||
if (/\binfo\b/.test(lowered)) return 'info';
|
||||
if (/\bdebug\b/.test(lowered)) return 'debug';
|
||||
if (/\btrace\b/.test(lowered)) return 'trace';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractHttpMethodAndPath = (text: string): { method?: HttpMethod; path?: string } => {
|
||||
const match = text.match(HTTP_METHOD_REGEX);
|
||||
if (!match) return {};
|
||||
|
||||
const method = match[1] as HttpMethod;
|
||||
const index = match.index ?? 0;
|
||||
const after = text.slice(index + match[0].length).trim();
|
||||
const path = after ? after.split(/\s+/)[0] : undefined;
|
||||
return { method, path };
|
||||
};
|
||||
|
||||
const parseLogLine = (raw: string): ParsedLogLine => {
|
||||
let remaining = raw.trim();
|
||||
|
||||
let timestamp: string | undefined;
|
||||
const tsMatch = remaining.match(LOG_TIMESTAMP_REGEX);
|
||||
if (tsMatch) {
|
||||
timestamp = tsMatch[1];
|
||||
remaining = remaining.slice(tsMatch[0].length).trim();
|
||||
}
|
||||
|
||||
let level: LogLevel | undefined;
|
||||
const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
|
||||
if (lvlMatch) {
|
||||
level = extractLogLevel(lvlMatch[1]);
|
||||
remaining = remaining.slice(lvlMatch[0].length).trim();
|
||||
}
|
||||
|
||||
let source: string | undefined;
|
||||
const sourceMatch = remaining.match(LOG_SOURCE_REGEX);
|
||||
if (sourceMatch) {
|
||||
source = sourceMatch[1];
|
||||
remaining = remaining.slice(sourceMatch[0].length).trim();
|
||||
}
|
||||
|
||||
let statusCode: number | undefined;
|
||||
let latency: string | undefined;
|
||||
let ip: string | undefined;
|
||||
let method: HttpMethod | undefined;
|
||||
let path: string | undefined;
|
||||
let message = remaining;
|
||||
|
||||
if (remaining.includes('|')) {
|
||||
const segments = remaining
|
||||
.split('|')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
const consumed = new Set<number>();
|
||||
|
||||
// status code
|
||||
const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment));
|
||||
if (statusIndex >= 0) {
|
||||
const match = segments[statusIndex].match(/^(\d{3})\b/);
|
||||
if (match) {
|
||||
const code = Number.parseInt(match[1], 10);
|
||||
if (code >= 100 && code <= 599) {
|
||||
statusCode = code;
|
||||
consumed.add(statusIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// latency
|
||||
const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment));
|
||||
if (latencyIndex >= 0) {
|
||||
const match = segments[latencyIndex].match(LOG_LATENCY_REGEX);
|
||||
if (match) {
|
||||
latency = `${match[1]}${match[2]}`;
|
||||
consumed.add(latencyIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// ip
|
||||
const ipIndex = segments.findIndex(
|
||||
(segment) => LOG_IPV4_REGEX.test(segment) || LOG_IPV6_REGEX.test(segment)
|
||||
);
|
||||
if (ipIndex >= 0) {
|
||||
const match = segments[ipIndex].match(LOG_IPV4_REGEX) ?? segments[ipIndex].match(LOG_IPV6_REGEX);
|
||||
if (match) {
|
||||
ip = match[0];
|
||||
consumed.add(ipIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// method + path
|
||||
const methodIndex = segments.findIndex((segment) => {
|
||||
const { method: parsedMethod } = extractHttpMethodAndPath(segment);
|
||||
return Boolean(parsedMethod);
|
||||
});
|
||||
if (methodIndex >= 0) {
|
||||
const parsed = extractHttpMethodAndPath(segments[methodIndex]);
|
||||
method = parsed.method;
|
||||
path = parsed.path;
|
||||
consumed.add(methodIndex);
|
||||
}
|
||||
|
||||
message = segments.filter((_, index) => !consumed.has(index)).join(' | ');
|
||||
} else {
|
||||
const statusMatch = remaining.match(/\b([1-5]\d{2})\b/);
|
||||
if (statusMatch) {
|
||||
const code = Number.parseInt(statusMatch[1], 10);
|
||||
if (code >= 100 && code <= 599) statusCode = code;
|
||||
}
|
||||
|
||||
const latencyMatch = remaining.match(LOG_LATENCY_REGEX);
|
||||
if (latencyMatch) latency = `${latencyMatch[1]}${latencyMatch[2]}`;
|
||||
|
||||
const ipMatch = remaining.match(LOG_IPV4_REGEX) ?? remaining.match(LOG_IPV6_REGEX);
|
||||
if (ipMatch) ip = ipMatch[0];
|
||||
|
||||
const parsed = extractHttpMethodAndPath(remaining);
|
||||
method = parsed.method;
|
||||
path = parsed.path;
|
||||
}
|
||||
|
||||
if (!level) level = inferLogLevel(raw);
|
||||
|
||||
return {
|
||||
raw,
|
||||
timestamp,
|
||||
level,
|
||||
source,
|
||||
statusCode,
|
||||
latency,
|
||||
ip,
|
||||
method,
|
||||
path,
|
||||
message
|
||||
};
|
||||
};
|
||||
|
||||
export function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
||||
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 logViewerRef = useRef<HTMLDivElement | null>(null);
|
||||
const pendingScrollToBottomRef = useRef(false);
|
||||
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||
|
||||
// 保存最新时间戳用于增量获取
|
||||
const latestTimestampRef = useRef<number>(0);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
const isNearBottom = (node: HTMLPreElement | null) => {
|
||||
const isNearBottom = (node: HTMLDivElement | null) => {
|
||||
if (!node) return true;
|
||||
const threshold = 24;
|
||||
return node.scrollHeight - node.scrollTop - node.clientHeight <= threshold;
|
||||
@@ -48,8 +239,6 @@ export function LogsPage() {
|
||||
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);
|
||||
@@ -77,14 +266,26 @@ export function LogsPage() {
|
||||
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);
|
||||
// 增量更新:追加新日志并限制缓冲区大小(避免内存与渲染膨胀)
|
||||
setLogState((prev) => {
|
||||
const prevRenderedCount = prev.buffer.length - prev.visibleFrom;
|
||||
const combined = [...prev.buffer, ...newLines];
|
||||
const dropCount = Math.max(combined.length - MAX_BUFFER_LINES, 0);
|
||||
const buffer = dropCount > 0 ? combined.slice(dropCount) : combined;
|
||||
let visibleFrom = Math.max(prev.visibleFrom - dropCount, 0);
|
||||
|
||||
// 若用户停留在底部(跟随最新日志),则保持“渲染窗口”大小不变,避免无限增长
|
||||
if (pendingScrollToBottomRef.current) {
|
||||
visibleFrom = Math.max(buffer.length - prevRenderedCount, 0);
|
||||
}
|
||||
|
||||
return { buffer, visibleFrom };
|
||||
});
|
||||
} else if (!incremental) {
|
||||
// 全量加载:只取最后 MAX_DISPLAY_LINES 行
|
||||
setLogLines(newLines.slice(-MAX_DISPLAY_LINES));
|
||||
// 全量加载:默认只渲染最后 100 行,向上滚动再展开更多
|
||||
const buffer = newLines.slice(-MAX_BUFFER_LINES);
|
||||
const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0);
|
||||
setLogState({ buffer, visibleFrom });
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load logs:', err);
|
||||
@@ -102,7 +303,7 @@ export function LogsPage() {
|
||||
if (!window.confirm(t('logs.clear_confirm'))) return;
|
||||
try {
|
||||
await logsApi.clearLogs();
|
||||
setLogLines([]);
|
||||
setLogState({ buffer: [], visibleFrom: 0 });
|
||||
latestTimestampRef.current = 0;
|
||||
showNotification(t('logs.clear_success'), 'success');
|
||||
} catch (err: any) {
|
||||
@@ -111,7 +312,7 @@ export function LogsPage() {
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
const text = logLines.join('\n');
|
||||
const text = logState.buffer.join('\n');
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -193,9 +394,41 @@ export function LogsPage() {
|
||||
|
||||
scrollToBottom();
|
||||
pendingScrollToBottomRef.current = false;
|
||||
}, [loading, logLines]);
|
||||
}, [loading, logState.buffer, logState.visibleFrom]);
|
||||
|
||||
const logsText = logLines.join('\n');
|
||||
const visibleLines = useMemo(
|
||||
() => logState.buffer.slice(logState.visibleFrom),
|
||||
[logState.buffer, logState.visibleFrom]
|
||||
);
|
||||
const parsedVisibleLines = useMemo(
|
||||
() => visibleLines.map((line) => parseLogLine(line)),
|
||||
[visibleLines]
|
||||
);
|
||||
const canLoadMore = logState.visibleFrom > 0;
|
||||
|
||||
const handleLogScroll = () => {
|
||||
const node = logViewerRef.current;
|
||||
if (!node) return;
|
||||
if (!canLoadMore) return;
|
||||
if (pendingPrependScrollRef.current) return;
|
||||
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
|
||||
|
||||
pendingPrependScrollRef.current = { scrollHeight: node.scrollHeight, scrollTop: node.scrollTop };
|
||||
setLogState((prev) => ({
|
||||
...prev,
|
||||
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0)
|
||||
}));
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = logViewerRef.current;
|
||||
const pending = pendingPrependScrollRef.current;
|
||||
if (!node || !pending) return;
|
||||
|
||||
const delta = node.scrollHeight - pending.scrollHeight;
|
||||
node.scrollTop = pending.scrollTop + delta;
|
||||
pendingPrependScrollRef.current = null;
|
||||
}, [logState.visibleFrom]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -204,18 +437,53 @@ export function LogsPage() {
|
||||
<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')}
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => loadLogs(false)}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
<IconRefreshCw size={16} />
|
||||
{t('logs.refresh_button')}
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setAutoRefresh((v) => !v)}>
|
||||
{t('logs.auto_refresh')}: {autoRefresh ? t('common.yes') : t('common.no')}
|
||||
<ToggleSwitch
|
||||
checked={autoRefresh}
|
||||
onChange={(value) => setAutoRefresh(value)}
|
||||
disabled={disableControls}
|
||||
label={
|
||||
<span className={styles.switchLabel}>
|
||||
<IconTimer size={16} />
|
||||
{t('logs.auto_refresh')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={downloadLogs}
|
||||
disabled={logState.buffer.length === 0}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
<IconDownload size={16} />
|
||||
{t('logs.download_button')}
|
||||
</span>
|
||||
</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
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={clearLogs}
|
||||
disabled={disableControls}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
<IconTrash2 size={16} />
|
||||
{t('logs.clear_button')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -223,14 +491,88 @@ export function LogsPage() {
|
||||
{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>
|
||||
) : logState.buffer.length > 0 ? (
|
||||
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
|
||||
{canLoadMore && (
|
||||
<div className={styles.loadMoreBanner}>
|
||||
<span>{t('logs.load_more_hint')}</span>
|
||||
<span className={styles.loadMoreCount}>
|
||||
{t('logs.hidden_lines', { count: logState.visibleFrom })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.logList}>
|
||||
{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 (
|
||||
<div key={`${logState.visibleFrom + index}-${line.raw}`} className={rowClassNames.join(' ')}>
|
||||
<div className={styles.timestamp}>{line.timestamp || ''}</div>
|
||||
<div className={styles.rowMain}>
|
||||
<div className={styles.rowMeta}>
|
||||
{line.level && (
|
||||
<span
|
||||
className={[
|
||||
styles.badge,
|
||||
line.level === 'info' ? styles.levelInfo : '',
|
||||
line.level === 'warn' ? styles.levelWarn : '',
|
||||
line.level === 'error' || line.level === 'fatal' ? styles.levelError : '',
|
||||
line.level === 'debug' ? styles.levelDebug : '',
|
||||
line.level === 'trace' ? styles.levelTrace : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{line.level.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{line.source && (
|
||||
<span className={styles.source} title={line.source}>
|
||||
{line.source}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{typeof line.statusCode === 'number' && (
|
||||
<span
|
||||
className={[
|
||||
styles.badge,
|
||||
styles.statusBadge,
|
||||
line.statusCode >= 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}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{line.latency && <span className={styles.pill}>{line.latency}</span>}
|
||||
{line.ip && <span className={styles.pill}>{line.ip}</span>}
|
||||
|
||||
{line.method && (
|
||||
<span className={[styles.badge, styles.methodBadge].join(' ')}>
|
||||
{line.method}
|
||||
</span>
|
||||
)}
|
||||
{line.path && (
|
||||
<span className={styles.path} title={line.path}>
|
||||
{line.path}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{line.message && <div className={styles.message}>{line.message}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
|
||||
)}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SettingsPage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = (await fetchConfig(undefined, true)) as Config;
|
||||
const data = (await fetchConfig()) as Config;
|
||||
setProxyValue(data?.proxyUrl ?? '');
|
||||
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -27,6 +27,9 @@ interface ConfigState {
|
||||
isCacheValid: (section?: RawConfigSection) => boolean;
|
||||
}
|
||||
|
||||
let configRequestToken = 0;
|
||||
let inFlightConfigRequest: { id: number; promise: Promise<Config> } | null = null;
|
||||
|
||||
const SECTION_KEYS: RawConfigSection[] = [
|
||||
'debug',
|
||||
'proxy-url',
|
||||
@@ -102,13 +105,35 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
|
||||
// section 缓存未命中但 full 缓存可用时,直接复用已获取到的配置,避免重复 /config 请求
|
||||
if (!forceRefresh && section && isCacheValid()) {
|
||||
const fullCached = cache.get('__full__');
|
||||
if (fullCached?.data) {
|
||||
return extractSectionValue(fullCached.data as Config, section);
|
||||
}
|
||||
}
|
||||
|
||||
// 同一时刻合并多个 /config 请求(如 StrictMode 或多个页面同时触发)
|
||||
if (inFlightConfigRequest) {
|
||||
const data = await inFlightConfigRequest.promise;
|
||||
return section ? extractSectionValue(data, section) : data;
|
||||
}
|
||||
|
||||
// 获取新数据
|
||||
set({ loading: true, error: null });
|
||||
|
||||
const requestId = (configRequestToken += 1);
|
||||
try {
|
||||
const data = await configApi.getConfig();
|
||||
const requestPromise = configApi.getConfig();
|
||||
inFlightConfigRequest = { id: requestId, promise: requestPromise };
|
||||
const data = await requestPromise;
|
||||
const now = Date.now();
|
||||
|
||||
// 如果在请求过程中连接已被切换/登出,则忽略旧请求的结果,避免覆盖新会话的状态
|
||||
if (requestId !== configRequestToken) {
|
||||
return section ? extractSectionValue(data, section) : data;
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
const newCache = new Map(cache);
|
||||
newCache.set('__full__', { data, timestamp: now });
|
||||
@@ -127,11 +152,17 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
|
||||
return section ? extractSectionValue(data, section) : data;
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to fetch config',
|
||||
loading: false
|
||||
});
|
||||
if (requestId === configRequestToken) {
|
||||
set({
|
||||
error: error.message || 'Failed to fetch config',
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (inFlightConfigRequest?.id === requestId) {
|
||||
inFlightConfigRequest = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -206,11 +237,18 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
newCache.delete(section);
|
||||
// 同时清除完整配置缓存
|
||||
newCache.delete('__full__');
|
||||
|
||||
set({ cache: newCache });
|
||||
return;
|
||||
} else {
|
||||
newCache.clear();
|
||||
}
|
||||
|
||||
set({ cache: newCache });
|
||||
// 清除全部缓存一般代表“切换连接/登出/全量刷新”,需要让 in-flight 的旧请求失效
|
||||
configRequestToken += 1;
|
||||
inFlightConfigRequest = null;
|
||||
|
||||
set({ config: null, cache: newCache, loading: false, error: null });
|
||||
},
|
||||
|
||||
isCacheValid: (section) => {
|
||||
|
||||
Reference in New Issue
Block a user