From 181cba68861f259fa40488fd005052402552042b Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 3 Nov 2025 18:44:50 +0800 Subject: [PATCH] feat(logs): limit lines, incremental updates, and highlighting --- app.js | 308 ++++++++++++++++++++++++++++++++++++++++++++++++----- styles.css | 209 +++++++++++++++++++++++++++++++++++- 2 files changed, 489 insertions(+), 28 deletions(-) diff --git a/app.js b/app.js index aeb9b33..49c6802 100644 --- a/app.js +++ b/app.js @@ -20,6 +20,10 @@ class CLIProxyManager { // 日志自动刷新定时器 this.logsRefreshTimer = null; + // 当前展示的日志行 + this.displayedLogLines = []; + this.maxDisplayLogLines = 10000; + // 日志时间戳(用于增量加载) this.latestLogTimestamp = null; @@ -1786,6 +1790,7 @@ class CLIProxyManager { if (!logsContent) return; if (!lines || lines.length === 0) { + this.displayedLogLines = []; logsContent.innerHTML = '

' + i18n.t('logs.empty_title') + '

' + i18n.t('logs.empty_desc') + '

'; @@ -1795,26 +1800,22 @@ class CLIProxyManager { // 过滤掉 /v0/management/logs 相关的日志 const filteredLines = lines.filter(line => !line.includes('/v0/management/logs')); - // 限制前端显示的最大行数为 10000 行 - const MAX_DISPLAY_LINES = 10000; + // 限制前端显示的最大行数 let displayedLines = filteredLines; - let displayedLineCount = filteredLines.length; - - if (filteredLines.length > MAX_DISPLAY_LINES) { - const linesToRemove = filteredLines.length - MAX_DISPLAY_LINES; + if (filteredLines.length > this.maxDisplayLogLines) { + const linesToRemove = filteredLines.length - this.maxDisplayLogLines; displayedLines = filteredLines.slice(linesToRemove); - displayedLineCount = MAX_DISPLAY_LINES; } - // 将数组转换为文本 - const logsText = displayedLines.join('\n'); - const logHtml = ` + this.displayedLogLines = displayedLines.slice(); + + const displayedLineCount = this.displayedLogLines.length; + logsContent.innerHTML = `
${displayedLineCount} ${i18n.t('logs.lines')}
-
${this.escapeHtml(logsText)}
+
${this.buildLogsHtml(this.displayedLogLines)}
`; - logsContent.innerHTML = logHtml; // 自动滚动到底部 if (scrollToBottom) { @@ -1830,37 +1831,37 @@ class CLIProxyManager { const logsContent = document.getElementById('logs-content'); if (!logsContent) return; - const logsTextElement = logsContent.querySelector('.logs-text'); - const logsInfoElement = logsContent.querySelector('.logs-info'); - - if (!logsTextElement || !newLines || newLines.length === 0) { + if (!newLines || newLines.length === 0) { return; } + const logsTextElement = logsContent.querySelector('.logs-text'); + const logsInfoElement = logsContent.querySelector('.logs-info'); + // 过滤掉 /v0/management/logs 相关的日志 const filteredNewLines = newLines.filter(line => !line.includes('/v0/management/logs')); if (filteredNewLines.length === 0) { return; // 如果过滤后没有新日志,直接返回 } + if (!logsTextElement) { + this.renderLogs(filteredNewLines, totalLineCount || filteredNewLines.length, true); + return; + } + // 检查用户是否正在查看底部(判断是否需要自动滚动) const isAtBottom = logsTextElement.scrollHeight - logsTextElement.scrollTop - logsTextElement.clientHeight < 50; - // 追加新日志文本 - const newLogsText = '\n' + filteredNewLines.join('\n'); - logsTextElement.textContent += newLogsText; - - // 限制前端显示的最大行数为 10000 行 - const MAX_DISPLAY_LINES = 10000; - const allLines = logsTextElement.textContent.split('\n').filter(line => line.trim()); - if (allLines.length > MAX_DISPLAY_LINES) { - const linesToRemove = allLines.length - MAX_DISPLAY_LINES; - logsTextElement.textContent = allLines.slice(linesToRemove).join('\n'); + this.displayedLogLines = this.displayedLogLines.concat(filteredNewLines); + if (this.displayedLogLines.length > this.maxDisplayLogLines) { + this.displayedLogLines = this.displayedLogLines.slice(this.displayedLogLines.length - this.maxDisplayLogLines); } + logsTextElement.innerHTML = this.buildLogsHtml(this.displayedLogLines); + // 更新行数统计(只显示实际显示的行数,最多 10000 行) if (logsInfoElement) { - const displayedLines = logsTextElement.textContent.split('\n').filter(line => line.trim()).length; + const displayedLines = this.displayedLogLines.length; logsInfoElement.innerHTML = ` ${displayedLines} ${i18n.t('logs.lines')}`; } @@ -1870,6 +1871,259 @@ class CLIProxyManager { } } + // 根据日志内容构造高亮 HTML + buildLogsHtml(lines) { + if (!lines || lines.length === 0) { + return ''; + } + return lines.map(line => { + // 先过滤掉 [GIN] 2025/11/03 - 18:32:59 部分 + // 匹配模式:[GIN] 后面跟着日期 - 时间 + let processedLine = line.replace(/\[GIN\]\s+\d{4}\/\d{2}\/\d{2}\s+-\s+\d{2}:\d{2}:\d{2}\s+/g, ''); + + // 创建标记数组来跟踪需要高亮的位置 + const highlights = []; + + // 1. 检测 HTTP 状态码 + const statusInfo = this.detectHttpStatus(line); + if (statusInfo) { + const statusPattern = new RegExp(`\\b${statusInfo.code}\\b`); + const match = statusPattern.exec(processedLine); + if (match) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: `log-status-tag log-status-${statusInfo.bucket}`, + priority: 10 + }); + } + } + + // 2. 时间戳(只匹配标准格式,排除日期和时间被分隔符分开的情况) + // 匹配格式: + // - 2024-01-01 12:00:00 或 2024/01/01 12:00:00(中间只有一个空格) + // - 2024-01-01T12:00:00 + // - [12:00:00] + // 排除:2024/01/01 - 12:00:00(中间有 - 分隔符) + const timestampPattern = /\d{4}[-/]\d{2}[-/]\d{2}[T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?|\[\d{2}:\d{2}:\d{2}\]/g; + let match; + while ((match = timestampPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-timestamp', + priority: 5 + }); + } + + // 2b. 只匹配在方括号内的完整日期时间(如 [2025-11-03 18:23:14]) + const bracketTimestampPattern = /\[\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\]/g; + while ((match = bracketTimestampPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-timestamp', + priority: 5 + }); + } + + // 3. 日志级别(只匹配在方括号内的,避免匹配路径中的关键字) + const levelPattern = /\[(ERROR|ERRO|ERR|FATAL|CRITICAL|CRIT|WARN|WARNING|INFO|DEBUG|TRACE|PANIC)\]/gi; + while ((match = levelPattern.exec(processedLine)) !== null) { + const level = match[1].toUpperCase(); + let className = 'log-level'; + if (['ERROR', 'ERRO', 'ERR', 'FATAL', 'CRITICAL', 'CRIT', 'PANIC'].includes(level)) { + className += ' log-level-error'; + } else if (['WARN', 'WARNING'].includes(level)) { + className += ' log-level-warn'; + } else if (level === 'INFO') { + className += ' log-level-info'; + } else if (['DEBUG', 'TRACE'].includes(level)) { + className += ' log-level-debug'; + } + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: className, + priority: 8 + }); + } + + // 4. HTTP 方法 + const methodPattern = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\b/g; + while ((match = methodPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-http-method', + priority: 6 + }); + } + + // 5. URL(仅匹配 HTTP(S) 完整URL,不匹配路径) + const urlPattern = /(https?:\/\/[^\s<>"]+)/g; + while ((match = urlPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-path', + priority: 4 + }); + } + + // 6. IP 地址 + const ipPattern = /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g; + while ((match = ipPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-ip', + priority: 7 + }); + } + + // 7. 关键字 + const successPattern = /\b(success|successful|succeeded|completed|ok|done|passed)\b/gi; + while ((match = successPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-keyword-success', + priority: 3 + }); + } + + const errorPattern = /\b(failed|failure|error|exception|panic|fatal|critical|aborted|denied|refused|timeout|invalid)\b/gi; + while ((match = errorPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-keyword-error', + priority: 3 + }); + } + + const warnPattern = /\b(warning|warn|deprecated|slow|retry|retrying)\b/gi; + while ((match = warnPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-keyword-warn', + priority: 3 + }); + } + + // 8. 数字和单位 + const numberPattern = /\b(\d+(?:\.\d+)?)(ms|μs|ns|s|KB|MB|GB|TB|B|%)\b/g; + while ((match = numberPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-number-unit', + priority: 2 + }); + } + + // 移除重叠的高亮(保留优先级高的) + highlights.sort((a, b) => { + if (a.start !== b.start) return a.start - b.start; + return b.priority - a.priority; + }); + + const filteredHighlights = []; + for (const h of highlights) { + const overlaps = filteredHighlights.some(existing => + (h.start >= existing.start && h.start < existing.end) || + (h.end > existing.start && h.end <= existing.end) || + (h.start <= existing.start && h.end >= existing.end) + ); + if (!overlaps) { + filteredHighlights.push(h); + } + } + + // 构建最终的 HTML + filteredHighlights.sort((a, b) => a.start - b.start); + let result = ''; + let lastIndex = 0; + + for (const h of filteredHighlights) { + // 添加高亮之前的文本 + if (h.start > lastIndex) { + result += this.escapeHtml(processedLine.substring(lastIndex, h.start)); + } + // 添加高亮的文本 + result += `${this.escapeHtml(processedLine.substring(h.start, h.end))}`; + lastIndex = h.end; + } + + // 添加剩余的文本 + if (lastIndex < processedLine.length) { + result += this.escapeHtml(processedLine.substring(lastIndex)); + } + + return `${result}`; + }).join(''); + } + + // 检测 HTTP 状态码 + detectHttpStatus(line) { + if (!line) return null; + + // 更精确的 HTTP 状态码匹配模式 + // 匹配: + // 1. "| 状态码 |"(如:| 200 | 或 |200|) + // 2. "状态码 -" 或 "状态码-"(如:200 - 45ms 或 200-45ms) + // 3. "HTTP方法 路径 状态码"(如:GET /api 200) + // 4. "状态码 OK/Error/等状态文本"(如:200 OK) + // 5. "status: 状态码" 或 "code: 状态码" + // 排除: + // - 文件路径中的数字(如 .go:396) + // - 端口号(如 :8080) + // - 普通数字序列 + + const patterns = [ + // 匹配 "| 状态码 |" 或 "|状态码|"(日志中常见格式) + /\|\s*([1-5]\d{2})\s*\|/, + // 匹配 "状态码 -" 或 "状态码-" + /\b([1-5]\d{2})\s*-/, + // 匹配 HTTP 方法后的状态码(GET/POST/等 路径 状态码) + /\b(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\s+\S+\s+([1-5]\d{2})\b/, + // 匹配 "status:" 或 "code:" 后的状态码 + /\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i, + // 匹配状态码后跟状态文本(200 OK, 404 Not Found 等) + /\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 + ]; + + for (const pattern of patterns) { + const match = line.match(pattern); + if (match) { + const code = parseInt(match[1], 10); + if (Number.isNaN(code)) { + continue; + } + + if (code >= 500) { + return { code, bucket: '5xx', match: match[1] }; + } + if (code >= 400) { + return { code, bucket: '4xx', match: match[1] }; + } + if (code >= 300) { + return { code, bucket: '3xx', match: match[1] }; + } + if (code >= 200) { + return { code, bucket: '2xx', match: match[1] }; + } + if (code >= 100) { + return { code, bucket: '1xx', match: match[1] }; + } + } + } + + return null; + } + // 下载日志 async downloadLogs() { try { diff --git a/styles.css b/styles.css index 6193e4a..e3a2976 100644 --- a/styles.css +++ b/styles.css @@ -3280,9 +3280,10 @@ input:checked+.slider:before { border: 1px solid var(--border-primary); border-radius: 6px; padding: 16px; - font-size: 12px; + font-size: 13px; line-height: 1.6; color: var(--text-secondary); + font-weight: 600; white-space: pre-wrap; word-wrap: break-word; max-height: calc(100vh - 480px); @@ -3291,6 +3292,133 @@ input:checked+.slider:before { margin: 0; } +.logs-text .log-line { + display: block; + padding: 2px 0; + margin: 0; + color: inherit; +} + +.logs-text .log-line + .log-line { + margin-top: 2px; +} + +.logs-text .log-status-tag { + display: inline-block; + padding: 0 8px; + margin-right: 6px; + border-radius: 6px; + font-weight: 700; + letter-spacing: 0.3px; +} + +.logs-text .log-status-tag.log-status-2xx { + background: rgba(16, 185, 129, 0.18); + color: #047857; +} + +.logs-text .log-status-tag.log-status-3xx { + background: rgba(6, 182, 212, 0.2); + color: #0f766e; +} + +.logs-text .log-status-tag.log-status-4xx { + background: rgba(251, 191, 36, 0.26); + color: #92400e; +} + +.logs-text .log-status-tag.log-status-5xx { + background: rgba(239, 68, 68, 0.22); + color: #b91c1c; +} + +.logs-text .log-status-tag.log-status-1xx { + background: rgba(165, 180, 252, 0.22); + color: #4338ca; +} + +/* 日志高亮样式 */ + +/* 时间戳高亮 */ +.logs-text .log-timestamp { + color: #8b5cf6; + font-weight: 600; +} + +/* 日志级别高亮 */ +.logs-text .log-level { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + font-weight: 700; + font-size: 0.95em; + letter-spacing: 0.5px; +} + +.logs-text .log-level-error { + background: rgba(239, 68, 68, 0.20); + color: #dc2626; +} + +.logs-text .log-level-warn { + background: rgba(251, 191, 36, 0.25); + color: #d97706; +} + +.logs-text .log-level-info { + background: rgba(59, 130, 246, 0.20); + color: #2563eb; +} + +.logs-text .log-level-debug { + background: rgba(107, 114, 128, 0.18); + color: #6b7280; +} + +/* HTTP 方法高亮 */ +.logs-text .log-http-method { + color: #0891b2; + font-weight: 700; +} + +/* 路径/URL 高亮 */ +.logs-text .log-path { + color: #059669; + text-decoration: underline; + text-decoration-style: dotted; + text-decoration-color: rgba(5, 150, 105, 0.3); +} + +/* IP 地址高亮 */ +.logs-text .log-ip { + color: #7c3aed; + font-weight: 600; +} + +/* 关键字高亮 - 成功 */ +.logs-text .log-keyword-success { + color: #059669; + font-weight: 600; +} + +/* 关键字高亮 - 错误 */ +.logs-text .log-keyword-error { + color: #dc2626; + font-weight: 600; +} + +/* 关键字高亮 - 警告 */ +.logs-text .log-keyword-warn { + color: #ea580c; + font-weight: 600; +} + +/* 数字和单位高亮 */ +.logs-text .log-number-unit { + color: #db2777; + font-weight: 600; +} + .logs-text::-webkit-scrollbar { width: 8px; height: 8px; @@ -3424,10 +3552,89 @@ input:checked+.slider:before { color: var(--text-tertiary); } +[data-theme="dark"] .logs-text .log-status-tag.log-status-2xx { + background: rgba(16, 185, 129, 0.32); + color: #6ee7b7; +} + +[data-theme="dark"] .logs-text .log-status-tag.log-status-3xx { + background: rgba(6, 182, 212, 0.34); + color: #5eead4; +} + +[data-theme="dark"] .logs-text .log-status-tag.log-status-4xx { + background: rgba(251, 191, 36, 0.38); + color: #fcd34d; +} + +[data-theme="dark"] .logs-text .log-status-tag.log-status-5xx { + background: rgba(239, 68, 68, 0.38); + color: #fca5a5; +} + +[data-theme="dark"] .logs-text .log-status-tag.log-status-1xx { + background: rgba(165, 180, 252, 0.38); + color: #c7d2fe; +} + [data-theme="dark"] .logs-container { background: rgba(15, 23, 42, 0.3); } +/* 暗色主题 - 日志高亮样式 */ +[data-theme="dark"] .logs-text .log-timestamp { + color: #c4b5fd; +} + +[data-theme="dark"] .logs-text .log-level-error { + background: rgba(239, 68, 68, 0.32); + color: #fca5a5; +} + +[data-theme="dark"] .logs-text .log-level-warn { + background: rgba(251, 191, 36, 0.35); + color: #fbbf24; +} + +[data-theme="dark"] .logs-text .log-level-info { + background: rgba(59, 130, 246, 0.32); + color: #93c5fd; +} + +[data-theme="dark"] .logs-text .log-level-debug { + background: rgba(107, 114, 128, 0.28); + color: #9ca3af; +} + +[data-theme="dark"] .logs-text .log-http-method { + color: #22d3ee; +} + +[data-theme="dark"] .logs-text .log-path { + color: #34d399; + text-decoration-color: rgba(52, 211, 153, 0.3); +} + +[data-theme="dark"] .logs-text .log-ip { + color: #a78bfa; +} + +[data-theme="dark"] .logs-text .log-keyword-success { + color: #34d399; +} + +[data-theme="dark"] .logs-text .log-keyword-error { + color: #f87171; +} + +[data-theme="dark"] .logs-text .log-keyword-warn { + color: #fb923c; +} + +[data-theme="dark"] .logs-text .log-number-unit { + color: #f472b6; +} + /* ===== AI提供商统计徽章样式 ===== */ /* 统计信息容器 */