fix: 修复日志查看器多处问题

- 首屏加载后不贴底:改由 useResizeObserver 把"跟随时保持贴底"维持成不变式,
  覆盖弹窗入场、横向滚动条、字体度量、tab 由隐藏转可见等后续布局变化
- 向上翻页内容重复:文件日志改用首屏 size 作为反向分页锚点,
  不再受跟踪期间写入的新日志影响
- 首屏到建连之间的日志丢失:follow 支持从锚点字节位置续读(tail -c +N),
  重连仍只跟新增以免整段重放
- 断线重连残留的半行与新流拼接会拼出错行
- 点"跳到顶部"被翻页的位置补偿拽回原处
- 实时裁剪未退还翻页游标,导致下次上翻跳过被裁掉的那一段
- 搜索跳转改按相对位置计算,不再连带滚动外层页面
- 首屏日志不足一页时凭空出现"没有更多日志"

性能:
- ws 帧合并到 rAF 统一落盘,繁忙日志下渲染次数从帧数级降到 60/秒级
- 反向读取按字节切分,只把返回的那页转 string;首块按预估行长一次读足
- 日志行 markRaw 并移除小写副本字段

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-08-16 20:01:12 +08:00
parent 335fbf697f
commit 5391911a97
7 changed files with 159 additions and 76 deletions
+4
View File
@@ -29,12 +29,16 @@ type FileTail struct {
Offset int `json:"offset" form:"offset"`
Limit int `json:"limit" form:"limit"`
Cursor string `json:"cursor" form:"cursor"`
// Size 为首屏返回的文件大小,翻页时回传作为反向分页锚点,避免日志持续写入导致错位
Size int64 `json:"size" form:"size"`
}
type FileFollow struct {
Path string `json:"path" form:"path"`
Service string `json:"service" form:"service"`
Container string `json:"container" form:"container"`
// Offset 为首屏锚点字节位置,从该处开始跟踪以衔接首屏与实时流,避免中间写入的日志丢失
Offset int64 `json:"offset" form:"offset"`
}
type FileCreate struct {
+27 -17
View File
@@ -4,6 +4,7 @@ package service
import (
"bufio"
"bytes"
"cmp"
"crypto/sha256"
"encoding/base64"
@@ -130,9 +131,8 @@ func (s *FileService) Tail(w http.ResponseWriter, r *http.Request) {
if req.Limit > 5000 {
req.Limit = 5000
}
if req.Offset < 0 {
req.Offset = 0
}
// 限制回溯深度,三种日志源共用;再深的历史交给下载原始日志
req.Offset = min(max(req.Offset, 0), 10000)
if req.Path == "" && req.Service == "" && req.Container == "" {
Error(w, http.StatusUnprocessableEntity, s.t.Get("path, service or container is required"))
@@ -166,27 +166,36 @@ func (s *FileService) Tail(w http.ResponseWriter, r *http.Request) {
Success(w, chix.M{"lines": []string{}, "has_more": false, "size": size})
return
}
// 以首屏返回的大小为锚点反向分页,否则跟踪期间写入的新行会顶掉偏移量导致翻页重复
anchor := size
if req.Size > 0 {
anchor = min(req.Size, size)
}
// 从尾部反向读取,直到攒够 offset+limit+1 个换行符(多 1 是为了避免读到不完整的首行)
const chunkSize = int64(8192)
pos := size
var data []byte
// 从锚点反向读取,直到攒够 offset+limit+1 个换行符(多 1 是为了避免读到不完整的首行)
// 首块按预估行长一次读足,绝大多数请求一两次系统调用即可完成;maxScan 兜住超长行
const maxScan = int64(64 << 20)
needLines := req.Offset + req.Limit + 1
readSize := min(int64(needLines)*256, maxScan)
pos := anchor
chunks := make([][]byte, 0, 4)
newlineCount := 0
for pos > 0 && newlineCount < needLines {
readSize := min(chunkSize, pos)
for pos > 0 && newlineCount < needLines && anchor-pos < maxScan {
readSize = min(readSize, pos)
pos -= readSize
buf := make([]byte, readSize)
if _, rerr := f.ReadAt(buf, pos); rerr != nil && rerr != stdio.EOF {
Error(w, http.StatusInternalServerError, "%v", rerr)
return
}
data = append(buf, data...)
newlineCount = strings.Count(string(data), "\n")
newlineCount += bytes.Count(buf, []byte{'\n'})
chunks = append(chunks, buf)
}
slices.Reverse(chunks)
data := bytes.Join(chunks, nil)
// 切分行
all := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
// 按字节切分,只把真正返回的那一页转成 string,避免整个扫描窗口再复制一份
all := bytes.Split(bytes.TrimRight(data, "\n"), []byte{'\n'})
totalLoaded := len(all)
// 当 pos > 0 时第一行可能不完整,丢弃以避免半行被显示
@@ -206,9 +215,9 @@ func (s *FileService) Tail(w http.ResponseWriter, r *http.Request) {
hasMore := pos > 0 || startIdx > startBoundary
result := []string{}
if startIdx < endIdx {
result = all[startIdx:endIdx]
result := make([]string, 0, max(endIdx-startIdx, 0))
for _, line := range all[startIdx:endIdx] {
result = append(result, string(line))
}
Success(w, chix.M{
@@ -997,7 +1006,7 @@ func (s *FileService) tailService(w http.ResponseWriter, req *request.FileTail)
lines = append(lines, formatJournalLine(e.Timestamp, e.Hostname, e.Ident, e.Comm, e.PID, e.Message))
}
// next_cursor 是本次结果中最早那条的 cursor,供下一页 --after-cursor + --reverse 使用
// next_cursor 是本次结果中最早那条的 cursor,供下一页 --before-cursor 继续往前翻
nextCursor := ""
if len(entries) > 0 {
nextCursor = entries[0].Cursor
@@ -1041,6 +1050,7 @@ func formatJournalLine(ts, hostname, ident, comm, pid, message string) string {
// tailContainer 反向读取容器末尾日志
func (s *FileService) tailContainer(w http.ResponseWriter, req *request.FileTail) {
// 容器日志只能整段拉取再切片,回溯深度由 Tail 顶部统一钳制
total := req.Offset + req.Limit
out, err := s.containerRepo.Logs(req.Container, total)
if err != nil {
+3
View File
@@ -125,6 +125,9 @@ func (s *WsService) Follow(w http.ResponseWriter, r *http.Request) {
var cmd *exec.Cmd
if req.Service != "" {
cmd = exec.CommandContext(ctx, "journalctl", "--no-pager", "-n", "0", "-f", "-u", req.Service)
} else if req.Offset > 0 {
// 从首屏锚点接着跟踪,补上首屏读取到建立连接之间写入的日志(-c 的字节偏移从 1 开始)
cmd = exec.CommandContext(ctx, "tail", "-c", fmt.Sprintf("+%d", req.Offset+1), "-F", req.Path)
} else {
cmd = exec.CommandContext(ctx, "tail", "-n", "0", "-F", req.Path)
}
+2
View File
@@ -7,6 +7,7 @@ export default {
content: (path: string): any => http.Get('/file/content', { params: { path } }),
// 反向分页读取文件/容器/systemd 日志
// 文件/容器: 用 offset 从末尾跳过 offset 行,读 limit 行
// 文件: 翻页额外传首屏返回的 size 作为锚点,避免期间写入的新日志顶偏移量
// systemd 服务: 首次不传 cursor,翻页传上一页返回的 next_cursor,每次读 limit 行
tail: (params: {
path?: string
@@ -15,6 +16,7 @@ export default {
offset?: number
limit: number
cursor?: string
size?: number
}): any => http.Get('/file/tail', { params }),
// 保存文件
save: (path: string, content: string): any => http.Post('/file/save', { path, content }),
+8 -2
View File
@@ -24,13 +24,19 @@ export default {
ws.onerror = (e) => reject(e)
})
},
// 文件或 systemd 服务实时跟踪
follow: (params: { path?: string; service?: string; container?: string }): Promise<WebSocket> => {
// 文件或 systemd 服务实时跟踪offset 为首屏锚点字节位置
follow: (params: {
path?: string
service?: string
container?: string
offset?: number
}): Promise<WebSocket> => {
return new Promise((resolve, reject) => {
const qs = new URLSearchParams()
if (params.path) qs.set('path', params.path)
if (params.service) qs.set('service', params.service)
if (params.container) qs.set('container', params.container)
if (params.offset) qs.set('offset', String(params.offset))
const ws = new WebSocket(`${base}/follow?${qs.toString()}`)
ws.onopen = () => resolve(ws)
ws.onerror = (e) => reject(e)
+104 -56
View File
@@ -28,7 +28,6 @@ interface LogLine {
id: number
html: string
text: string
lower: string
}
type ConnStatus = 'connecting' | 'connected' | 'error'
@@ -50,6 +49,7 @@ const searchKeyword = ref('')
const matchedLineId = ref<number | null>(null)
const pendingNew = ref(0)
const scrollEl = ref<HTMLElement | null>(null)
const bodyEl = ref<HTMLElement | null>(null)
const shellEl = ref<HTMLElement | null>(null)
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(shellEl)
@@ -65,9 +65,16 @@ const statusText = computed(() =>
// 全屏时弹出层需挂载到全屏元素内部否则不可见
const popoverTo = computed(() => (isFullscreen.value ? (shellEl.value ?? 'body') : 'body'))
const decoder = new TextDecoder()
let nextId = 0
let pendingTail = ''
// 帧内攒下的原始行,由 flushIncoming 统一落盘
let incoming: string[] = []
let flushScheduled = false
let loadedFromEnd = 0
// 首屏时的文件大小,作为反向翻页与实时跟踪的共同锚点
let anchorSize = 0
let nextCursor = ''
let followWs: WebSocket | null = null
let suppressScrollHandler = false
@@ -89,15 +96,13 @@ const titleLabel = computed(() => props.path || props.service || props.container
const supported = computed(() => !!sourceParams.value)
// text 为剥离 ANSI 后的纯文本供搜索/复制/关键词标注使用
const parseLine = (raw: string): LogLine => {
const text = Anser.ansiToText(raw)
return {
// 行创建后不再变更,markRaw 免掉每行一层 Proxy 与逐字段依赖(5000 行量级下省数 MB)
const parseLine = (raw: string): LogLine =>
markRaw({
id: nextId++,
text,
lower: text.toLowerCase(),
text: Anser.ansiToText(raw),
html: Anser.ansiToHtml(Anser.escapeForHtml(raw), { use_classes: true }),
}
}
})
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -109,21 +114,34 @@ const renderLine = (line: LogLine) => {
return Anser.escapeForHtml(line.text).replace(re, (m) => `<mark class="log-mark">${m}</mark>`)
}
const scrollToBottom = () => {
// 程序化滚动的统一入口:期间抑制滚动回调,否则会被误判为用户手动滚动而退出跟随或触发翻页
const setScrollTop = (calc: (el: HTMLElement) => number) => {
const el = scrollEl.value
if (!el) return
suppressScrollHandler = true
el.scrollTop = el.scrollHeight
el.scrollTop = calc(el)
requestAnimationFrame(() => {
suppressScrollHandler = false
})
}
const scrollToBottom = () => setScrollTop((el) => el.scrollHeight)
// 跳到已加载内容的开头;抑制滚动回调也顺带避免了落到顶部立刻触发翻页又被位置补偿拽回来
const scrollToTop = () => {
const el = scrollEl.value
if (el) el.scrollTop = 0
followMode.value = false
setScrollTop(() => 0)
}
// 贴底后布局仍会继续变化:弹窗入场动画、横向滚动条出现、字体度量生效、tab 由隐藏转可见,
// 单次 scrollToBottom 必然落空,故跟随模式下把"保持贴底"作为不变式由观察器统一维持
const stickToBottom = () => {
if (followMode.value) scrollToBottom()
}
// 容器自身高度(全屏切换、窗口缩放)与内容高度(翻页、换行重排)都要观察
useResizeObserver(scrollEl, stickToBottom)
useResizeObserver(bodyEl, stickToBottom)
const scheduleReconnect = () => {
if (isManuallyClosed) return
if (reconnectTimer) clearTimeout(reconnectTimer)
@@ -133,11 +151,39 @@ const scheduleReconnect = () => {
}, 3000)
}
const startFollow = () => {
// 繁忙日志下 ws 帧率可达上千每秒,逐帧改 lines 就是逐帧整表 patch 加一次强制重排;
// 攒到下一帧统一落盘,渲染次数压到 60/秒量级。锚点续读补发积压时这个差距最明显
const flushIncoming = () => {
flushScheduled = false
const total = incoming.length
if (total === 0) return
// 超出上限的部分永远不会被显示,先裁再解析,省掉白做的 ANSI 转换
const batch = total > MAX_LINES ? incoming.slice(-MAX_LINES) : incoming
incoming = []
lines.value.push(...batch.map(parseLine))
// 跟随与暂停都要裁剪;裁掉的历史行要同步退还翻页游标,否则下次上翻会跳过这一段
if (lines.value.length > MAX_LINES) {
const removed = lines.value.length - MAX_LINES
lines.value.splice(0, removed)
loadedFromEnd = Math.max(0, loadedFromEnd - removed)
}
// 稳态下追加与裁剪行数相抵、内容高度不变,观察器不会触发,这里必须自己贴底
if (followMode.value) {
nextTick(scrollToBottom)
} else {
pendingNew.value += total
}
}
// fromAnchor 仅首次连接时为真:从首屏锚点续读补齐空档,
// 重连改用默认的"只跟新增",否则会把锚点之后已显示的内容整段重放
const startFollow = (fromAnchor = false) => {
if (!sourceParams.value) return
isManuallyClosed = false
status.value = 'connecting'
ws.follow(sourceParams.value)
// 上次连接残留的半行与新流拼接会拼出错行
pendingTail = ''
ws.follow({ ...sourceParams.value, offset: fromAnchor ? anchorSize : 0 })
.then((socket) => {
followWs = socket
socket.binaryType = 'arraybuffer'
@@ -145,21 +191,14 @@ const startFollow = () => {
socket.onmessage = (ev) => {
const data: string =
typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(new Uint8Array(ev.data))
const combined = pendingTail + data
const parts = combined.split('\n')
typeof ev.data === 'string' ? ev.data : decoder.decode(new Uint8Array(ev.data))
const parts = (pendingTail + data).split('\n')
pendingTail = parts.pop() ?? ''
if (parts.length > 0) {
lines.value.push(...parts.map(parseLine))
// 跟随与暂停都要裁剪
if (lines.value.length > MAX_LINES) {
lines.value.splice(0, lines.value.length - MAX_LINES)
}
if (followMode.value) {
nextTick(() => scrollToBottom())
} else {
pendingNew.value += parts.length
}
if (parts.length === 0) return
incoming.push(...parts)
if (!flushScheduled) {
flushScheduled = true
requestAnimationFrame(flushIncoming)
}
}
@@ -181,6 +220,10 @@ const startFollow = () => {
})
}
// hasMore 只表达服务端还有没有更早的日志;要不要继续拿是客户端策略,
// 分开后实时裁剪把行数降回上限以下时,向上翻页能自动恢复
const canLoadOlder = computed(() => hasMore.value && lines.value.length < MAX_LINES)
const PAGE_SIZE = 100
const buildTailParams = (initial: boolean) => {
@@ -189,6 +232,8 @@ const buildTailParams = (initial: boolean) => {
if (!initial) base.cursor = nextCursor
} else {
base.offset = initial ? 0 : loadedFromEnd
// 带上锚点,翻页始终相对首屏那一刻的文件末尾,不受期间写入的新日志影响
if (!initial && anchorSize > 0) base.size = anchorSize
}
return base as any
}
@@ -201,12 +246,15 @@ const loadInitial = () => {
const newLines: string[] = data?.lines ?? []
lines.value = newLines.map(parseLine)
loadedFromEnd = newLines.length
anchorSize = data?.size ?? 0
nextCursor = data?.next_cursor ?? ''
hasMore.value = data?.has_more ?? false
// 与日志行同一次渲染中撤下加载占位,否则 nextTick 时 DOM 里还没有行,贴底会落空
initialLoading.value = false
nextTick(() => {
scrollToBottom()
followMode.value = true
startFollow()
startFollow(true)
})
})
.onComplete(() => {
@@ -215,7 +263,7 @@ const loadInitial = () => {
}
const loadOlder = () => {
if (!sourceParams.value || isLoadingMore.value || !hasMore.value) return
if (!sourceParams.value || isLoadingMore.value || !canLoadOlder.value) return
if (props.service && !nextCursor) {
hasMore.value = false
return
@@ -238,16 +286,7 @@ const loadOlder = () => {
nextCursor = data?.next_cursor ?? ''
hasMore.value = data?.has_more ?? false
// 保持视觉位置:scrollTop = 新 scrollHeight - 旧 scrollHeight + 旧 scrollTop
nextTick(() => {
const target = scrollEl.value
if (target) {
suppressScrollHandler = true
target.scrollTop = target.scrollHeight - oldScrollHeight + oldScrollTop
requestAnimationFrame(() => {
suppressScrollHandler = false
})
}
})
nextTick(() => setScrollTop((el) => el.scrollHeight - oldScrollHeight + oldScrollTop))
})
.onComplete(() => {
isLoadingMore.value = false
@@ -260,7 +299,7 @@ const onScroll = () => {
if (!el) return
const { scrollTop, scrollHeight, clientHeight } = el
followMode.value = scrollHeight - scrollTop - clientHeight < 30
if (scrollTop < 60 && hasMore.value && !isLoadingMore.value) {
if (scrollTop < 60 && canLoadOlder.value && !isLoadingMore.value) {
loadOlder()
}
}
@@ -278,9 +317,9 @@ const toggleFollow = () => {
}
}
// 换行重排后的贴底由观察器负责
const toggleWrap = () => {
wrapLines.value = !wrapLines.value
if (followMode.value) nextTick(() => scrollToBottom())
}
const copyAll = () => {
@@ -298,10 +337,12 @@ const decreaseFont = () => {
if (fontSize.value > 10) fontSize.value--
}
// 用不区分大小写的正则匹配,免去为每行常驻一份小写副本
const matches = computed(() => {
const kw = searchKeyword.value.toLowerCase()
const kw = searchKeyword.value
if (!kw) return []
return lines.value.filter((l) => l.lower.includes(kw))
const re = new RegExp(escapeRegExp(kw), 'i')
return lines.value.filter((l) => re.test(l.text))
})
const matchPos = computed(() => matches.value.findIndex((m) => m.id === matchedLineId.value))
@@ -320,11 +361,18 @@ const goToMatch = (step: 1 | -1) => {
const target = ms[next]
if (!target) return
matchedLineId.value = target.id
const el = scrollEl.value.querySelector(`.log-line[data-id="${target.id}"]`)
if (el) {
el.scrollIntoView({ block: 'center' })
followMode.value = false
}
const el = scrollEl.value.querySelector<HTMLElement>(`.log-line[data-id="${target.id}"]`)
if (!el) return
// 不用 scrollIntoView,它会连带滚动外层页面(组件多数内嵌在 tab 里而非弹窗);
// 按两者的相对位置算,不依赖 offsetParent 落在哪一层
followMode.value = false
setScrollTop(
(c) =>
c.scrollTop +
el.getBoundingClientRect().top -
c.getBoundingClientRect().top -
(c.clientHeight - el.offsetHeight) / 2,
)
}
// 关键字变化时重置搜索游标与高亮
@@ -337,11 +385,6 @@ watch(followMode, (on) => {
if (on) pendingNew.value = 0
})
// 全屏切换后容器高度变化重新贴底
watch(isFullscreen, () => {
if (followMode.value) nextTick(() => scrollToBottom())
})
const cleanup = () => {
isManuallyClosed = true
if (reconnectTimer) {
@@ -352,8 +395,10 @@ const cleanup = () => {
followWs = null
lines.value = []
loadedFromEnd = 0
anchorSize = 0
nextCursor = ''
pendingTail = ''
incoming = []
hasMore.value = false
loadedOlder.value = false
status.value = 'connecting'
@@ -474,7 +519,7 @@ defineExpose({ clear })
@scroll="onScroll"
>
<div v-if="initialLoading" class="log-loading"><n-spin :size="18" /></div>
<template v-else>
<div v-else ref="bodyEl">
<div v-if="isLoadingMore || (loadedOlder && !hasMore)" class="log-boundary">
<n-spin v-if="isLoadingMore" :size="12" />
<span v-else>{{ $gettext('No more logs') }}</span>
@@ -487,7 +532,10 @@ defineExpose({ clear })
:data-id="line.id"
v-html="renderLine(line)"
></div>
</template>
<div v-if="lines.length === 0" class="log-boundary">
{{ $gettext('No logs available') }}
</div>
</div>
</div>
<transition name="pill">
+11 -1
View File
@@ -10,6 +10,10 @@ const props = defineProps({
type: String,
required: false,
},
service: {
type: String,
required: false,
},
container: {
type: String,
required: false,
@@ -64,6 +68,12 @@ defineExpose({ clear })
</ConfirmDialog>
</n-flex>
</template>
<realtime-log v-if="show" ref="logRef" :path="props.path" :container="props.container" />
<realtime-log
v-if="show"
ref="logRef"
:path="props.path"
:service="props.service"
:container="props.container"
/>
</n-modal>
</template>