perf(logs): 日志事件按窗口合并推送,抽屉改为虚拟滚动

- 后端 LogSink 把逐行事件按 16 KiB / 100 ms 合并成一条 log 事件,文件写入保持即时;后台 tick 与析构 flush 保证低输出和收尾都不丢尾部
- 前端新增 LogViewer:固定行高虚拟滚动、跟随底部/回到最新,增量按 rAF 合并,长日志 DOM 节点数恒定
- 日志抽屉改为行缓冲(lib/logLines)+ 可见窗口计算(lib/logWindow)
- 详情区改 flex 布局,日志面板吃掉剩余高度,回到最新按钮不再被裁到面板外
- 测试:Rust 单元 52→57,Vitest 13→28,Playwright 4→5(1500 行虚拟滚动)
- 文档:README 补充合并推送与虚拟滚动,新增 ADR-0008
This commit is contained in:
2026-10-27 22:38:53 +08:00
parent 14fb3563c5
commit 07ca14ea34
12 changed files with 867 additions and 59 deletions
+60
View File
@@ -264,3 +264,63 @@ test('renders without external network access', async ({ page }) => {
fullPage: true,
})
})
test('keeps a long run log scrollable without rendering every line', async ({
page,
request,
}) => {
const headers = await authHeaders(request)
await request.delete('/api/v1/tasks/e2e_long_log', { headers })
const response = await request.post('/api/v1/tasks', {
headers,
data: {
...taskPayload,
id: 'e2e_long_log',
name: 'Long log task',
timeout_secs: 60,
action: {
...taskPayload.action,
args: ['for /L %i in (1,1,1500) do @echo line-%i'],
},
},
})
expect(response.status()).toBe(201)
await page.goto('/')
await page
.getByRole('row', { name: /Long log task/ })
.getByRole('button', { name: '运行' })
.click()
const drawer = page.getByRole('dialog', { name: 'Long log task' })
await expect(drawer).toBeVisible()
await expect(drawer.locator('.status-succeeded').first()).toBeVisible({
timeout: 60_000,
})
// 自动跟随到底部,最后几行在视口内。
await expect(drawer.getByText('line-1500')).toBeVisible()
// 虚拟滚动:DOM 中只保留视口附近的少量行,而不是 1500 行全量渲染。
const rendered = await drawer.locator('.log-line').count()
expect(rendered).toBeLessThan(200)
expect(rendered).toBeGreaterThan(5)
await drawer.locator('.log-viewer').evaluate((node) => {
node.scrollTop = node.scrollHeight / 2
})
const jump = drawer.getByRole('button', { name: '回到最新 ↓' })
await expect(jump).toBeVisible()
const logBox = await drawer.locator('.log-viewer').boundingBox()
const jumpBox = await jump.boundingBox()
expect(logBox).not.toBeNull()
expect(jumpBox).not.toBeNull()
if (logBox && jumpBox) {
// 按钮必须落在日志面板可视范围内,而不是被面板裁剪到外面。
expect(jumpBox.y + jumpBox.height).toBeLessThanOrEqual(
logBox.y + logBox.height + 1,
)
}
await page.screenshot({
path: 'test-results/dashboard-log-drawer-long.png',
fullPage: true,
})
})
+94 -7
View File
@@ -1160,6 +1160,19 @@ code {
padding: 22px;
}
/* 日志面板吃掉详情区的剩余高度,自己内部滚动,"回到最新"按钮才不会被挤出可视区。 */
.run-detail-panel {
display: flex;
flex-direction: column;
}
.run-detail-panel > .section-heading,
.run-detail-panel > .run-metadata,
.run-detail-panel > .inline-alert,
.run-detail-panel > .empty-inline {
flex: none;
}
.run-history-panel {
border-right: 1px solid var(--border);
background: var(--surface-muted);
@@ -1271,6 +1284,10 @@ code {
}
.log-viewer-wrap {
display: flex;
flex: 1;
flex-direction: column;
min-height: 240px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
@@ -1279,6 +1296,7 @@ code {
.log-viewer-header {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
@@ -1296,18 +1314,73 @@ code {
color: var(--success);
}
.log-viewer-shell {
position: relative;
flex: 1;
min-height: 0;
}
.log-viewer {
min-height: 340px;
max-height: calc(100vh - 410px);
height: 100%;
min-height: 0;
overflow: auto;
margin: 0;
padding: 16px;
padding: 0;
color: var(--text-light);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
line-height: 20px;
overscroll-behavior: contain;
}
.log-viewer:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.log-canvas {
position: relative;
}
.log-window {
position: absolute;
top: 0;
right: 0;
left: 0;
padding: 6px 16px;
will-change: transform;
}
.log-line {
width: max-content;
min-width: 100%;
height: 20px;
white-space: pre;
}
.log-empty {
margin: 0;
padding: 16px;
color: var(--text-tertiary);
}
.log-jump-latest {
position: absolute;
right: 16px;
bottom: 14px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
padding: 6px 12px;
background: rgba(16, 18, 27, 0.88);
color: var(--text-light);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}
.log-jump-latest:hover {
background: rgba(30, 34, 50, 0.95);
}
.log-viewer-wrap .inline-alert {
@@ -1443,6 +1516,19 @@ code {
overflow: visible;
}
.run-detail-panel {
display: block;
}
.log-viewer-wrap {
flex: none;
min-height: 0;
}
.log-viewer-shell {
flex: none;
}
.run-history-panel {
border-right: 0;
border-bottom: 1px solid var(--border);
@@ -1453,8 +1539,9 @@ code {
}
.log-viewer {
height: auto;
min-height: 260px;
max-height: none;
max-height: 60vh;
}
.run-drawer {
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { LogViewer } from './LogViewer'
describe('LogViewer', () => {
it('shows a placeholder before the first chunk arrives', () => {
render(<LogViewer lines={[]} version={0} />)
expect(screen.getByText('等待日志输出...')).toBeInTheDocument()
})
it('only renders the visible window of a long log', () => {
const lines = Array.from({ length: 5000 }, (_, index) => `line ${index}`)
const { container } = render(<LogViewer lines={lines} version={1} />)
const rendered = container.querySelectorAll('.log-line')
expect(rendered.length).toBeGreaterThan(0)
expect(rendered.length).toBeLessThan(100)
expect(screen.getByText('line 0')).toBeInTheDocument()
expect(screen.queryByText('line 4999')).toBeNull()
})
it('keeps the row height fixed so offsets stay computable', () => {
const lines = ['alpha', 'beta'] as const
const { container } = render(<LogViewer lines={lines} version={2} />)
const canvas = container.querySelector('.log-canvas')
expect(canvas).not.toBeNull()
expect((canvas as HTMLElement).style.height).toBe('52px')
})
})
+158
View File
@@ -0,0 +1,158 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { computeLogWindow, isNearBottom } from '../lib/logWindow'
/** 每行固定高度(与 CSS 中的 .log-line 一致),虚拟滚动据此换算位置。 */
export const LOG_ROW_HEIGHT = 20
const LOG_VERTICAL_PADDING = 6
const OVERSCAN_ROWS = 24
const FOLLOW_THRESHOLD_PX = 24
interface LogViewerProps {
/** 已经切好的日志行;内容会被原地追加,靠 version 触发重渲染。 */
lines: readonly string[]
/** 每次内容变化递增,用于在"跟随中"时滚到底部。 */
version: number
emptyLabel?: string
}
/**
* 虚拟滚动的日志面板:只渲染视口附近的行,长日志下 DOM 节点数量恒定。
*
* 行高固定、不自动换行(长行横向滚动),这样滚动位置可以直接由行号换算,
* 不需要逐行测量高度。用户往上翻时停止跟随新日志,并给出"回到最新"按钮。
*/
export function LogViewer({
lines,
version,
emptyLabel = '等待日志输出...',
}: LogViewerProps) {
const viewportRef = useRef<HTMLDivElement>(null)
const frameRef = useRef(0)
const followingRef = useRef(true)
const [following, setFollowing] = useState(true)
const [metrics, setMetrics] = useState({ scrollTop: 0, height: 0 })
useEffect(() => {
const node = viewportRef.current
if (!node) {
return
}
const sync = () => {
setMetrics((previous) =>
previous.height === node.clientHeight
? previous
: { ...previous, height: node.clientHeight },
)
}
sync()
if (typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(sync)
observer.observe(node)
return () => observer.disconnect()
}, [])
useEffect(() => {
return () => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current)
}
}
}, [])
const handleScroll = useCallback(() => {
if (frameRef.current) {
return
}
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0
const node = viewportRef.current
if (!node) {
return
}
const atBottom = isNearBottom(
node.scrollTop,
node.clientHeight,
node.scrollHeight,
FOLLOW_THRESHOLD_PX,
)
followingRef.current = atBottom
setFollowing(atBottom)
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
})
}, [])
// 有新内容时贴住底部;同步更新滚动位置,避免窗口还停在旧位置闪一下。
useLayoutEffect(() => {
if (!followingRef.current) {
return
}
const node = viewportRef.current
if (!node) {
return
}
node.scrollTop = node.scrollHeight
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
}, [version])
const jumpToLatest = useCallback(() => {
const node = viewportRef.current
if (!node) {
return
}
node.scrollTop = node.scrollHeight
followingRef.current = true
setFollowing(true)
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
}, [])
const { start, end } = computeLogWindow(
lines.length,
metrics.scrollTop,
metrics.height,
LOG_ROW_HEIGHT,
OVERSCAN_ROWS,
)
const canvasHeight =
lines.length * LOG_ROW_HEIGHT + LOG_VERTICAL_PADDING * 2
return (
<div className="log-viewer-shell">
<div
className="log-viewer"
ref={viewportRef}
onScroll={handleScroll}
role="log"
aria-label="运行日志"
tabIndex={0}
>
{lines.length === 0 ? (
<p className="log-empty">{emptyLabel}</p>
) : (
<div className="log-canvas" style={{ height: canvasHeight }}>
<div
className="log-window"
style={{ transform: `translateY(${start * LOG_ROW_HEIGHT}px)` }}
>
{lines.slice(start, end).map((line, index) => (
<div className="log-line" key={start + index}>
{line}
</div>
))}
</div>
</div>
)}
</div>
{lines.length > 0 && !following ? (
<button
type="button"
className="log-jump-latest"
onClick={jumpToLatest}
>
回到最新 ↓
</button>
) : null}
</div>
)
}
+54 -19
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
cancelRun,
getRun,
@@ -17,7 +17,9 @@ import {
formatDuration,
isTerminalRunStatus,
} from '../lib/format'
import { appendLogChunk, createLogBuffer, flushLogTail } from '../lib/logLines'
import { useFocusTrap } from '../lib/useFocusTrap'
import { LogViewer } from './LogViewer'
import { RunStatusBadge } from './StatusBadge'
interface RunLogDrawerProps {
@@ -380,15 +382,48 @@ function RunLogOutput({
sseConnected,
runComplete,
}: RunLogOutputProps) {
const [content, setContent] = useState('')
const [version, setVersion] = useState(0)
const [lines, setLines] = useState<readonly string[]>([])
const [pollComplete, setPollComplete] = useState(runComplete)
const [error, setError] = useState<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [reconcileKey, setReconcileKey] = useState(0)
const bufferRef = useRef(createLogBuffer())
const offsetRef = useRef(0)
const outputRef = useRef<HTMLPreElement>(null)
const frameRef = useRef(0)
const complete = runComplete || pollComplete
// 增量先并进缓冲区,一帧最多触发一次重渲染,避免逐行 setState 的 O(n²)。
const scheduleRender = useCallback(() => {
if (frameRef.current) {
return
}
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0
setLines(bufferRef.current.lines)
setVersion((value) => value + 1)
})
}, [])
const ingest = useCallback(
(text: string) => {
if (!text) {
return
}
appendLogChunk(bufferRef.current, text)
scheduleRender()
},
[scheduleRender],
)
useEffect(() => {
return () => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current)
}
}
}, [])
useEffect(() => {
let cancelled = false
const controller = new AbortController()
@@ -404,12 +439,12 @@ function RunLogOutput({
if (offsetRef.current !== startOffset) {
continue
}
if (chunk.content) {
setContent((current) => current + chunk.content)
}
ingest(chunk.content)
offsetRef.current = chunk.next_offset
if (chunk.complete) {
flushLogTail(bufferRef.current)
setPollComplete(true)
scheduleRender()
return
}
await new Promise((resolve) => window.setTimeout(resolve, 600))
@@ -431,7 +466,7 @@ function RunLogOutput({
cancelled = true
controller.abort()
}
}, [reconcileKey, reloadKey, runId])
}, [ingest, reconcileKey, reloadKey, runId, scheduleRender])
useEffect(() => {
const onLog = (event: Event) => {
@@ -443,9 +478,7 @@ function RunLogOutput({
return
}
if (payload.data.offset === offsetRef.current) {
if (payload.data.content) {
setContent((current) => current + payload.data.content)
}
ingest(payload.data.content)
offsetRef.current = payload.data.next_offset
} else if (payload.data.offset > offsetRef.current) {
setReconcileKey((value) => value + 1)
@@ -453,13 +486,15 @@ function RunLogOutput({
}
logEvents.addEventListener('log', onLog)
return () => logEvents.removeEventListener('log', onLog)
}, [logEvents, runId])
}, [ingest, logEvents, runId])
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
if (!complete) {
return
}
}, [content])
flushLogTail(bufferRef.current)
scheduleRender()
}, [complete, scheduleRender])
return (
<div className="log-viewer-wrap">
@@ -482,10 +517,12 @@ function RunLogOutput({
type="button"
className="text-button"
onClick={() => {
setContent('')
bufferRef.current = createLogBuffer()
offsetRef.current = 0
setPollComplete(false)
setError(null)
offsetRef.current = 0
setLines(bufferRef.current.lines)
setVersion((value) => value + 1)
setReloadKey((value) => value + 1)
}}
>
@@ -493,9 +530,7 @@ function RunLogOutput({
</button>
</div>
) : null}
<pre className="log-viewer" ref={outputRef} aria-live="polite">
{content || '等待日志输出...'}
</pre>
<LogViewer lines={lines} version={version} />
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import {
appendLogChunk,
createLogBuffer,
flushLogTail,
logLineCount,
} from './logLines'
describe('appendLogChunk', () => {
it('splits complete lines and keeps the unfinished tail', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, 'first\nsecond\npart')
expect(buffer.lines).toEqual(['first', 'second'])
expect(buffer.tail).toBe('part')
expect(logLineCount(buffer)).toBe(3)
})
it('joins chunks that split a line in the middle', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, 'hel')
appendLogChunk(buffer, 'lo\nnext')
expect(buffer.lines).toEqual(['hello'])
expect(buffer.tail).toBe('next')
})
it('drops the carriage return of Windows line endings', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, 'one\r\ntwo\r\n')
expect(buffer.lines).toEqual(['one', 'two'])
expect(buffer.tail).toBe('')
})
it('ignores empty chunks', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, '')
appendLogChunk(buffer, 'x\n')
expect(buffer.lines).toEqual(['x'])
})
})
describe('flushLogTail', () => {
it('promotes an unterminated last line when the run ends', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, 'line\nno-newline')
flushLogTail(buffer)
expect(buffer.lines).toEqual(['line', 'no-newline'])
expect(buffer.tail).toBe('')
})
it('does nothing when the log ends with a newline', () => {
const buffer = createLogBuffer()
appendLogChunk(buffer, 'line\n')
flushLogTail(buffer)
expect(buffer.lines).toEqual(['line'])
expect(logLineCount(buffer)).toBe(1)
})
})
+44
View File
@@ -0,0 +1,44 @@
/**
* 运行日志的行缓冲。
*
* 日志按块到达(SSE 合并推送或轮询分片),块边界可能落在任意字符之间,
* 所以只把完整行放进 `lines`,剩下的残行留在 `tail` 等下一块拼接。
* 直接改数组而不是每块复制一份,避免长日志上的 O(n²) 拷贝。
*/
export interface LogBuffer {
lines: string[]
tail: string
}
export function createLogBuffer(): LogBuffer {
return { lines: [], tail: '' }
}
/** 把一段增量日志并进缓冲区,只切出完整的行。 */
export function appendLogChunk(buffer: LogBuffer, chunk: string): void {
if (!chunk) {
return
}
const combined = buffer.tail + chunk
const parts = combined.split('\n')
buffer.tail = parts.pop() ?? ''
for (const line of parts) {
// Windows 子进程输出以 \r\n 结尾,去掉行尾的 \r 免得显示成方框。
buffer.lines.push(line.endsWith('\r') ? line.slice(0, -1) : line)
}
}
/** 运行结束:没有以换行符收尾的残行也要作为最后一行显示。 */
export function flushLogTail(buffer: LogBuffer): void {
if (buffer.tail) {
buffer.lines.push(
buffer.tail.endsWith('\r') ? buffer.tail.slice(0, -1) : buffer.tail,
)
buffer.tail = ''
}
}
/** 供界面展示的日志行数(包含尚未换行的最后一行)。 */
export function logLineCount(buffer: LogBuffer): number {
return buffer.lines.length + (buffer.tail ? 1 : 0)
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { computeLogWindow, isNearBottom } from './logWindow'
describe('computeLogWindow', () => {
it('renders a bounded window for a long log', () => {
const window = computeLogWindow(1000, 0, 400, 20, 5)
expect(window.start).toBe(0)
expect(window.end).toBe(25)
})
it('moves the window with the scroll position', () => {
const window = computeLogWindow(1000, 400, 400, 20, 5)
expect(window.start).toBe(15)
expect(window.end).toBe(45)
})
it('clamps the window at the end of the log', () => {
const window = computeLogWindow(1000, 20_000, 400, 20, 5)
expect(window.start).toBe(995)
expect(window.end).toBe(1000)
})
it('returns an empty window for an empty log', () => {
expect(computeLogWindow(0, 0, 400, 20, 5)).toEqual({ start: 0, end: 0 })
})
})
describe('isNearBottom', () => {
it('treats the bottom edge as following', () => {
expect(isNearBottom(1600, 400, 2000, 24)).toBe(true)
})
it('reports scrolling up as not following', () => {
expect(isNearBottom(1200, 400, 2000, 24)).toBe(false)
})
})
+42
View File
@@ -0,0 +1,42 @@
/** 虚拟滚动的可见窗口(左闭右开)。 */
export interface LogWindow {
start: number
end: number
}
/**
* 计算需要渲染的行区间。
*
* `scrollTop` 以像素计,行高固定;`overscan` 是窗口上下额外渲染的行数,
* 用来遮住快速滚动时的空白。
*/
export function computeLogWindow(
total: number,
scrollTop: number,
viewportHeight: number,
rowHeight: number,
overscan: number,
): LogWindow {
if (total <= 0 || rowHeight <= 0) {
return { start: 0, end: 0 }
}
const first = clamp(Math.floor(Math.max(0, scrollTop) / rowHeight), 0, total)
const visible = Math.max(1, Math.ceil(Math.max(0, viewportHeight) / rowHeight))
const start = Math.max(0, first - overscan)
const end = Math.min(total, first + visible + overscan)
return { start, end }
}
/** 视口是否已经贴着底部(留一点像素容差)。 */
export function isNearBottom(
scrollTop: number,
clientHeight: number,
scrollHeight: number,
threshold: number,
): boolean {
return scrollHeight - scrollTop - clientHeight <= threshold
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}