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:
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user