From 8251fad175c6cb95d3be46f6b869acb597015ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A9=99?= Date: Fri, 26 Dec 2025 08:29:25 +0800 Subject: [PATCH 01/10] feat: implement last-event-id handing in log route --- astrbot/dashboard/routes/log.py | 38 ++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/astrbot/dashboard/routes/log.py b/astrbot/dashboard/routes/log.py index 86cc8c6ca..fd5bca999 100644 --- a/astrbot/dashboard/routes/log.py +++ b/astrbot/dashboard/routes/log.py @@ -1,8 +1,9 @@ import asyncio import json +import time from typing import cast -from quart import Response as QuartResponse +from quart import request, Response as QuartResponse from quart import make_response from astrbot.core import LogBroker, logger @@ -22,17 +23,48 @@ def __init__(self, context: RouteContext, log_broker: LogBroker) -> None: ) async def log(self): + last_event_id = request.headers.get("Last-Event-ID") + async def stream(): queue = None try: + + if last_event_id: + try: + last_ts = float(last_event_id) + + cached_logs = list(self.log_broker.log_cache) + + for log_item in cached_logs: + + log_ts = float(log_item.get("time", 0)) + + if log_ts > last_ts: + payload = { + "type": "log", + **log_item, + } + + yield f"id: {log_ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + except ValueError: + + pass + except Exception as e: + logger.error(f"Log SSE 历史错误: {e}") + queue = self.log_broker.register() while True: message = await queue.get() + + current_ts = message.get("time", time.time()) + payload = { "type": "log", - **message, # see astrbot/core/log.py + **message, # see astrbot/core/log.py } - yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + + yield f"id: {current_ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + except asyncio.CancelledError: pass except BaseException as e: From e0380fda25763b34eaf5bf56647e424305a011a4 Mon Sep 17 00:00:00 2001 From: Noctuline Date: Fri, 26 Dec 2025 08:44:19 +0800 Subject: [PATCH 02/10] perf: better log handling --- .../components/shared/ConsoleDisplayer.vue | 154 +++++++++--------- 1 file changed, 79 insertions(+), 75 deletions(-) diff --git a/dashboard/src/components/shared/ConsoleDisplayer.vue b/dashboard/src/components/shared/ConsoleDisplayer.vue index 7d6759dfd..788211ed7 100644 --- a/dashboard/src/components/shared/ConsoleDisplayer.vue +++ b/dashboard/src/components/shared/ConsoleDisplayer.vue @@ -1,6 +1,5 @@ @@ -26,20 +25,19 @@ export default { name: 'ConsoleDisplayer', data() { return { - autoScroll: true, // 默认开启自动滚动 + autoScroll: true, logColorAnsiMap: { - '\u001b[1;34m': 'color: #0000FF; font-weight: bold;', // bold_blue - '\u001b[1;36m': 'color: #00FFFF; font-weight: bold;', // bold_cyan - '\u001b[1;33m': 'color: #FFFF00; font-weight: bold;', // bold_yellow - '\u001b[31m': 'color: #FF0000;', // red - '\u001b[1;31m': 'color: #FF0000; font-weight: bold;', // bold_red - '\u001b[0m': 'color: inherit; font-weight: normal;', // reset - '\u001b[32m': 'color: #00FF00;', // green + '\u001b[1;34m': 'color: #0000FF; font-weight: bold;', + '\u001b[1;36m': 'color: #00FFFF; font-weight: bold;', + '\u001b[1;33m': 'color: #FFFF00; font-weight: bold;', + '\u001b[31m': 'color: #FF0000;', + '\u001b[1;31m': 'color: #FF0000; font-weight: bold;', + '\u001b[0m': 'color: inherit; font-weight: normal;', + '\u001b[32m': 'color: #00FF00;', 'default': 'color: #FFFFFF;' }, - historyNum_: -1, logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], - selectedLevels: [0, 1, 2, 3, 4], // 默认选中所有级别 + selectedLevels: [0, 1, 2, 3, 4], levelColors: { 'DEBUG': 'grey', 'INFO': 'blue-lighten-3', @@ -47,17 +45,15 @@ export default { 'ERROR': 'red', 'CRITICAL': 'purple' }, - lastProcessedTime: 0, // 记录最后处理的日志时间戳 - localLogCache: [], // 本地日志缓存 + localLogCache: [], + eventSource: null, + retryTimer: null, } }, computed: { commonStore() { return useCommonStore(); }, - logCache() { - return this.commonStore.log_cache; - } }, props: { historyNum: { @@ -70,41 +66,6 @@ export default { } }, watch: { - logCache: { - handler(newVal) { - // 基于 timestamp 处理新增的日志 - if (newVal && newVal.length > 0) { - // 确保 DOM 已经准备好 - this.$nextTick(() => { - // 合并到本地缓存并按时间排序 - const newLogs = newVal.filter(log => log.time > this.lastProcessedTime); - - if (newLogs.length > 0) { - this.localLogCache.push(...newLogs); - // 按时间戳排序 - this.localLogCache.sort((a, b) => a.time - b.time); - - // 只保留最新的 log_cache_max_len 条 - if (this.localLogCache.length > this.commonStore.log_cache_max_len) { - this.localLogCache.splice(0, this.localLogCache.length - this.commonStore.log_cache_max_len); - } - - // 显示新日志 - newLogs.forEach(logItem => { - if (this.isLevelSelected(logItem.level)) { - this.printLog(logItem.data); - } - }); - - // 更新最后处理时间 - this.lastProcessedTime = Math.max(...newLogs.map(log => log.time)); - } - }); - } - }, - deep: true, - immediate: false - }, selectedLevels: { handler() { this.refreshDisplay(); @@ -113,30 +74,77 @@ export default { } }, async mounted() { - // 请求历史日志 await this.fetchLogHistory(); - - // 等待 DOM 准备好后,显示历史日志 - this.$nextTick(() => { - if (this.localLogCache.length > 0) { - this.localLogCache.forEach(logItem => { - if (this.isLevelSelected(logItem.level)) { - this.printLog(logItem.data); - } - }); - // 更新最后处理时间 - this.lastProcessedTime = Math.max(...this.localLogCache.map(log => log.time)); - } - }); + this.connectSSE(); + }, + beforeUnmount() { + if (this.eventSource) { + this.eventSource.close(); + } + if (this.retryTimer) { + clearTimeout(this.retryTimer); + } }, methods: { + connectSSE() { + if (this.eventSource) { + this.eventSource.close(); + } + + this.eventSource = new EventSource('/api/live-log'); + + this.eventSource.onmessage = (event) => { + try { + const payload = JSON.parse(event.data); + this.processNewLogs([payload]); + } catch (e) { + console.error('解析日志失败:', e); + } + }; + + this.eventSource.onerror = (err) => { + this.eventSource.close(); + + this.retryTimer = setTimeout(async () => { + await this.fetchLogHistory(); + this.connectSSE(); + }, 1000); + }; + }, + + processNewLogs(newLogs) { + if (!newLogs || newLogs.length === 0) return; + + let hasUpdate = false; + + newLogs.forEach(log => { + const exists = this.localLogCache.some(existing => existing.time === log.time); + + if (!exists) { + this.localLogCache.push(log); + hasUpdate = true; + + if (this.isLevelSelected(log.level)) { + this.printLog(log.data); + } + } + }); + + if (hasUpdate) { + this.localLogCache.sort((a, b) => a.time - b.time); + + const maxSize = this.commonStore.log_cache_max_len || 200; + if (this.localLogCache.length > maxSize) { + this.localLogCache.splice(0, this.localLogCache.length - maxSize); + } + } + }, + async fetchLogHistory() { try { const res = await axios.get('/api/log-history'); if (res.data.data.logs && res.data.data.logs.length > 0) { - this.localLogCache = [...res.data.data.logs]; - // 按时间戳排序 - this.localLogCache.sort((a, b) => a.time - b.time); + this.processNewLogs(res.data.data.logs); } } catch (err) { console.error('Failed to fetch log history:', err); @@ -162,7 +170,6 @@ export default { if (termElement) { termElement.innerHTML = ''; - // 重新显示所有符合筛选条件的日志 if (this.localLogCache && this.localLogCache.length > 0) { this.localLogCache.forEach(logItem => { if (this.isLevelSelected(logItem.level)) { @@ -173,16 +180,13 @@ export default { } }, - toggleAutoScroll() { this.autoScroll = !this.autoScroll; }, printLog(log) { - // append 一个 span 标签到 term,block 的方式 let ele = document.getElementById('term') if (!ele) { - console.warn('term element not found, skipping log print'); return; } @@ -196,11 +200,11 @@ export default { } } - span.style = style + 'display: block; font-size: 12px; font-family: Consolas, monospace; white-space: pre-wrap;' + span.style = style + 'display: block; font-size: 12px; font-family: Consolas, monospace; white-space: pre-wrap; margin-bottom: 2px;' span.classList.add('fade-in') span.innerText = `${log}`; ele.appendChild(span) - if (this.autoScroll ) { + if (this.autoScroll) { ele.scrollTop = ele.scrollHeight } } @@ -230,4 +234,4 @@ export default { opacity: 1; } } - \ No newline at end of file + From 32a50471bc106837a3c1236d493589d3bbf313d9 Mon Sep 17 00:00:00 2001 From: Noctuline Date: Fri, 26 Dec 2025 09:09:44 +0800 Subject: [PATCH 03/10] chore: ruff format --- astrbot/dashboard/routes/log.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/astrbot/dashboard/routes/log.py b/astrbot/dashboard/routes/log.py index fd5bca999..37f6c7a16 100644 --- a/astrbot/dashboard/routes/log.py +++ b/astrbot/dashboard/routes/log.py @@ -28,26 +28,23 @@ async def log(self): async def stream(): queue = None try: - if last_event_id: try: last_ts = float(last_event_id) - + cached_logs = list(self.log_broker.log_cache) - + for log_item in cached_logs: - log_ts = float(log_item.get("time", 0)) - + if log_ts > last_ts: payload = { "type": "log", **log_item, } - + yield f"id: {log_ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" except ValueError: - pass except Exception as e: logger.error(f"Log SSE 历史错误: {e}") @@ -55,16 +52,16 @@ async def stream(): queue = self.log_broker.register() while True: message = await queue.get() - + current_ts = message.get("time", time.time()) - + payload = { "type": "log", - **message, # see astrbot/core/log.py + **message, # see astrbot/core/log.py } - + yield f"id: {current_ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" - + except asyncio.CancelledError: pass except BaseException as e: From 0d349efce4e24e14c878d0ac86728c370c27a950 Mon Sep 17 00:00:00 2001 From: NoctuUFO Date: Fri, 26 Dec 2025 10:37:15 +0800 Subject: [PATCH 04/10] perf: log --- astrbot/dashboard/routes/log.py | 76 ++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/astrbot/dashboard/routes/log.py b/astrbot/dashboard/routes/log.py index 37f6c7a16..5af5b0485 100644 --- a/astrbot/dashboard/routes/log.py +++ b/astrbot/dashboard/routes/log.py @@ -1,16 +1,25 @@ import asyncio import json import time +from collections.abc import AsyncGenerator from typing import cast -from quart import request, Response as QuartResponse -from quart import make_response +from quart import Response as QuartResponse +from quart import make_response, request from astrbot.core import LogBroker, logger - from .route import Response, Route, RouteContext +def _format_log_sse(log: dict, ts: float) -> str: + """辅助函数:格式化 SSE 消息""" + payload = { + "type": "log", + **log, + } + return f"id: {ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + + class LogRoute(Route): def __init__(self, context: RouteContext, log_broker: LogBroker) -> None: super().__init__(context) @@ -22,49 +31,46 @@ def __init__(self, context: RouteContext, log_broker: LogBroker) -> None: methods=["GET"], ) - async def log(self): - last_event_id = request.headers.get("Last-Event-ID") + async def _replay_cached_logs( + self, last_event_id: str | None + ) -> AsyncGenerator[str, None]: + """辅助生成器:重放缓存的日志""" + if not last_event_id: + return - async def stream(): - queue = None - try: - if last_event_id: - try: - last_ts = float(last_event_id) + try: + last_ts = float(last_event_id) + cached_logs = list(self.log_broker.log_cache) - cached_logs = list(self.log_broker.log_cache) + for log_item in cached_logs: + log_ts = float(log_item.get("time", 0)) - for log_item in cached_logs: - log_ts = float(log_item.get("time", 0)) + if log_ts > last_ts: + yield _format_log_sse(log_item, log_ts) - if log_ts > last_ts: - payload = { - "type": "log", - **log_item, - } + except ValueError: + pass + except Exception as e: + logger.error(f"Log SSE 补发历史错误: {e}") - yield f"id: {log_ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" - except ValueError: - pass - except Exception as e: - logger.error(f"Log SSE 历史错误: {e}") + async def log(self) -> QuartResponse: + last_event_id = request.headers.get("Last-Event-ID") + + async def stream(): + queue = None + try: + async for event in self._replay_cached_logs(last_event_id): + yield event queue = self.log_broker.register() while True: message = await queue.get() - current_ts = message.get("time", time.time()) - - payload = { - "type": "log", - **message, # see astrbot/core/log.py - } - - yield f"id: {current_ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + yield _format_log_sse(message, current_ts) except asyncio.CancelledError: pass - except BaseException as e: + except Exception as e: logger.error(f"Log SSE 连接错误: {e}") finally: if queue: @@ -82,7 +88,7 @@ async def stream(): }, ), ) - response.timeout = None + response.timeout = None # type: ignore return response async def log_history(self): @@ -98,6 +104,6 @@ async def log_history(self): ) .__dict__ ) - except BaseException as e: + except Exception as e: logger.error(f"获取日志历史失败: {e}") return Response().error(f"获取日志历史失败: {e}").__dict__ From b142e6debd929ea108d08615f3522dba2ea14e24 Mon Sep 17 00:00:00 2001 From: NoctuUFO Date: Fri, 26 Dec 2025 10:37:52 +0800 Subject: [PATCH 05/10] Update ConsoleDisplayer.vue --- .../components/shared/ConsoleDisplayer.vue | 62 +++++++++++++++++-- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/dashboard/src/components/shared/ConsoleDisplayer.vue b/dashboard/src/components/shared/ConsoleDisplayer.vue index 788211ed7..6ab88c50d 100644 --- a/dashboard/src/components/shared/ConsoleDisplayer.vue +++ b/dashboard/src/components/shared/ConsoleDisplayer.vue @@ -5,7 +5,6 @@ import axios from 'axios';