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') + '
${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提供商统计徽章样式 ===== */
/* 统计信息容器 */