fix: webui log output incompletely (#4029)
* fix: webui log output incompletely * fix: improve SSE log parsing to handle partial data chunks * fix: enhance log handling by implementing local cache and fetching history * fix: log time handling to use epoch time
This commit is contained in:
+2
-1
@@ -24,6 +24,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from asyncio import Queue
|
||||
from collections import deque
|
||||
|
||||
@@ -148,7 +149,7 @@ class LogQueueHandler(logging.Handler):
|
||||
self.log_broker.publish(
|
||||
{
|
||||
"level": record.levelname,
|
||||
"time": record.asctime,
|
||||
"time": time.time(),
|
||||
"data": log_entry,
|
||||
},
|
||||
)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { useCommonStore } from '@/stores/common';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import axios from 'axios';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,8 +25,6 @@ import { storeToRefs } from 'pinia';
|
||||
export default {
|
||||
name: 'ConsoleDisplayer',
|
||||
data() {
|
||||
const commonStore = useCommonStore();
|
||||
const { log_cache } = storeToRefs(commonStore);
|
||||
return {
|
||||
autoScroll: true, // 默认开启自动滚动
|
||||
logColorAnsiMap: {
|
||||
@@ -38,7 +37,6 @@ export default {
|
||||
'\u001b[32m': 'color: #00FF00;', // green
|
||||
'default': 'color: #FFFFFF;'
|
||||
},
|
||||
logCache: log_cache,
|
||||
historyNum_: -1,
|
||||
logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
||||
selectedLevels: [0, 1, 2, 3, 4], // 默认选中所有级别
|
||||
@@ -48,7 +46,17 @@ export default {
|
||||
'WARNING': 'amber',
|
||||
'ERROR': 'red',
|
||||
'CRITICAL': 'purple'
|
||||
}
|
||||
},
|
||||
lastProcessedTime: 0, // 记录最后处理的日志时间戳
|
||||
localLogCache: [], // 本地日志缓存
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
commonStore() {
|
||||
return useCommonStore();
|
||||
},
|
||||
logCache() {
|
||||
return this.commonStore.log_cache;
|
||||
}
|
||||
},
|
||||
props: {
|
||||
@@ -63,13 +71,39 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
logCache: {
|
||||
handler(val) {
|
||||
const lastLog = val[this.logCache.length - 1];
|
||||
if (lastLog && this.isLevelSelected(lastLog.level)) {
|
||||
this.printLog(lastLog.data);
|
||||
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
|
||||
deep: true,
|
||||
immediate: false
|
||||
},
|
||||
selectedLevels: {
|
||||
handler() {
|
||||
@@ -78,14 +112,37 @@ export default {
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.logCache.length === 0) {
|
||||
this.delayInit()
|
||||
} else {
|
||||
this.init()
|
||||
}
|
||||
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));
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
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);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch log history:', err);
|
||||
}
|
||||
},
|
||||
|
||||
getLevelColor(level) {
|
||||
return this.levelColors[level] || 'grey';
|
||||
},
|
||||
@@ -101,41 +158,22 @@ export default {
|
||||
},
|
||||
|
||||
refreshDisplay() {
|
||||
// 清空现有的显示
|
||||
const termElement = document.getElementById('term');
|
||||
if (termElement) {
|
||||
termElement.innerHTML = '';
|
||||
}
|
||||
|
||||
// 重新显示符合筛选条件的日志
|
||||
this.init();
|
||||
},
|
||||
|
||||
delayInit() {
|
||||
if (this.logCache.length === 0) {
|
||||
setTimeout(() => {
|
||||
this.delayInit()
|
||||
}, 500)
|
||||
} else {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
|
||||
init() {
|
||||
this.historyNum_ = parseInt(this.historyNum)
|
||||
let i = 0
|
||||
for (let log of this.logCache) {
|
||||
if (this.isLevelSelected(log.level)) { // 只显示选中级别的日志
|
||||
if (this.historyNum_ != -1 && i >= this.logCache.length - this.historyNum_) {
|
||||
this.printLog(log.data)
|
||||
++i
|
||||
} else if (this.historyNum_ == -1) {
|
||||
this.printLog(log.data)
|
||||
}
|
||||
|
||||
// 重新显示所有符合筛选条件的日志
|
||||
if (this.localLogCache && this.localLogCache.length > 0) {
|
||||
this.localLogCache.forEach(logItem => {
|
||||
if (this.isLevelSelected(logItem.level)) {
|
||||
this.printLog(logItem.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
toggleAutoScroll() {
|
||||
this.autoScroll = !this.autoScroll;
|
||||
},
|
||||
@@ -143,6 +181,11 @@ export default {
|
||||
printLog(log) {
|
||||
// append 一个 span 标签到 term,block 的方式
|
||||
let ele = document.getElementById('term')
|
||||
if (!ele) {
|
||||
console.warn('term element not found, skipping log print');
|
||||
return;
|
||||
}
|
||||
|
||||
let span = document.createElement('pre')
|
||||
let style = this.logColorAnsiMap['default']
|
||||
for (let key in this.logColorAnsiMap) {
|
||||
|
||||
@@ -16,21 +16,6 @@ export const useCommonStore = defineStore({
|
||||
}),
|
||||
actions: {
|
||||
async createEventSource() {
|
||||
|
||||
const fetchLogHistory = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/log-history');
|
||||
if (res.data.data.logs) {
|
||||
this.log_cache.push(...res.data.data.logs);
|
||||
} else {
|
||||
this.log_cache = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch log history:', err);
|
||||
}
|
||||
};
|
||||
await fetchLogHistory();
|
||||
|
||||
if (this.eventSource) {
|
||||
return
|
||||
}
|
||||
@@ -54,25 +39,9 @@ export const useCommonStore = defineStore({
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let incompleteLine = ""; // 用于存储不完整的行
|
||||
|
||||
const handleIncompleteLine = (line) => {
|
||||
incompleteLine += line;
|
||||
// if can parse as JSON, return it
|
||||
try {
|
||||
const data_json = JSON.parse(incompleteLine);
|
||||
incompleteLine = ""; // 清空不完整行
|
||||
return data_json;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
let bufferedText = '';
|
||||
|
||||
const processStream = ({ done, value }) => {
|
||||
// get bytes length
|
||||
const bytesLength = value ? value.byteLength : 0;
|
||||
console.log(`Received ${bytesLength} bytes from live log`);
|
||||
if (done) {
|
||||
console.log('SSE stream closed');
|
||||
setTimeout(() => {
|
||||
@@ -82,44 +51,41 @@ export const useCommonStore = defineStore({
|
||||
return;
|
||||
}
|
||||
|
||||
const text = decoder.decode(value);
|
||||
const lines = text.split('\n\n');
|
||||
lines.forEach(line => {
|
||||
if (!line.trim()) {
|
||||
// Accumulate partial chunks; SSE data may split JSON across reads.
|
||||
const text = decoder.decode(value, { stream: true });
|
||||
bufferedText += text;
|
||||
|
||||
// Split completed events; keep the trailing partial in buffer.
|
||||
const segments = bufferedText.split('\n\n');
|
||||
bufferedText = segments.pop() || '';
|
||||
|
||||
segments.forEach(segment => {
|
||||
const line = segment.trim();
|
||||
if (!line.startsWith('data: ')) {
|
||||
return;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
const data = line.substring(5).trim();
|
||||
// {"type":"log","data":"[2021-08-01 00:00:00] INFO: Hello, world!"}
|
||||
let data_json = {}
|
||||
try {
|
||||
data_json = JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.warn('Invalid JSON:', data);
|
||||
// 尝试处理不完整的行
|
||||
const parsedData = handleIncompleteLine(data);
|
||||
if (parsedData) {
|
||||
data_json = parsedData;
|
||||
} else {
|
||||
return; // 如果无法解析,跳过当前行
|
||||
}
|
||||
|
||||
const logLine = line.replace('data: ', '').trim();
|
||||
if (!logLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const logObject = JSON.parse(logLine);
|
||||
// give a uuid if not exists
|
||||
if (!logObject.uuid) {
|
||||
logObject.uuid = crypto.randomUUID();
|
||||
}
|
||||
if (data_json.type === 'log') {
|
||||
this.log_cache.push(data_json);
|
||||
if (this.log_cache.length > this.log_cache_max_len) {
|
||||
this.log_cache.shift();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const parsedData = handleIncompleteLine(line);
|
||||
if (parsedData && parsedData.type === 'log') {
|
||||
this.log_cache.push(parsedData);
|
||||
if (this.log_cache.length > this.log_cache_max_len) {
|
||||
this.log_cache.shift();
|
||||
}
|
||||
this.log_cache.push(logObject);
|
||||
// Limit log cache size
|
||||
if (this.log_cache.length > this.log_cache_max_len) {
|
||||
this.log_cache.splice(0, this.log_cache.length - this.log_cache_max_len);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to parse SSE log line, skipping:', err, logLine);
|
||||
}
|
||||
});
|
||||
|
||||
return reader.read().then(processStream);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user