From 07ca14ea3426e96d6b2f6ff42b9650523272ce67 Mon Sep 17 00:00:00 2001 From: CNWei Date: Tue, 27 Oct 2026 22:38:53 +0800 Subject: [PATCH] =?UTF-8?q?perf(logs):=20=E6=97=A5=E5=BF=97=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=8C=89=E7=AA=97=E5=8F=A3=E5=90=88=E5=B9=B6=E6=8E=A8?= =?UTF-8?q?=E9=80=81=EF=BC=8C=E6=8A=BD=E5=B1=89=E6=94=B9=E4=B8=BA=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 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 --- README.md | 17 +- backend/src/runs/executor.rs | 244 +++++++++++++++--- .../0008-log-streaming-and-virtualization.md | 46 ++++ frontend/e2e/dashboard.spec.ts | 60 +++++ frontend/src/App.css | 101 +++++++- frontend/src/components/LogViewer.test.tsx | 33 +++ frontend/src/components/LogViewer.tsx | 158 ++++++++++++ frontend/src/components/RunLogDrawer.tsx | 73 ++++-- frontend/src/lib/logLines.test.ts | 69 +++++ frontend/src/lib/logLines.ts | 44 ++++ frontend/src/lib/logWindow.test.ts | 39 +++ frontend/src/lib/logWindow.ts | 42 +++ 12 files changed, 867 insertions(+), 59 deletions(-) create mode 100644 docs/decisions/0008-log-streaming-and-virtualization.md create mode 100644 frontend/src/components/LogViewer.test.tsx create mode 100644 frontend/src/components/LogViewer.tsx create mode 100644 frontend/src/lib/logLines.test.ts create mode 100644 frontend/src/lib/logLines.ts create mode 100644 frontend/src/lib/logWindow.test.ts create mode 100644 frontend/src/lib/logWindow.ts diff --git a/README.md b/README.md index 0ca2ecc..9aa9978 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台 - 单用户、单实例,只监听 `127.0.0.1` - 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程 - SQLite 持久化,重启后任务与历史仍在 -- 运行日志实时推送(SSE),断线自动回退轮询 +- 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示 - 前端零外部依赖:样式与脚本随包自带;界面字体可选,未生成时自动回退系统字体 > 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。 @@ -184,6 +184,11 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled - **单行 256 KiB** 上限:超出部分丢弃、继续读到行尾,避免内存无界增长。 - 保留期(默认 30 天)与总容量(默认 512 MiB)清理;**跳过正在写入的日志**。 - 读取按 64 KiB 分块且对齐 UTF-8 边界;`complete` 只在"运行结束且读到文件末尾"时为真。 +- **合并推送**:子进程输出按行写入文件后,先在内存里按 **100 ms / 16 KiB** 窗口合并, + 每个窗口只发一条 SSE `log` 事件(偏移仍严格连续);低输出时由后台任务按时推, + 运行结束时在 `LogSink` 析构里兜底 flush,不会丢尾部。 +- **虚拟滚动**:日志抽屉只渲染视口附近的行(固定行高 20 px),几万行时 DOM 节点数恒定; + 增量先并进行缓冲、一帧最多重渲染一次。往上翻时停止跟随并显示「回到最新」。长行横向滚动、不折行。 - **输出编码**:默认 `auto` = 逐行先按 UTF-8 解码,失败回退到系统 ANSI 代码页(简体中文为 GBK)。 可用 `TASK_PILOT_LOG_ENCODING=gbk|utf-8|windows-1251` 强制。 - **脱敏**:环境变量/Header 名包含 `SECRET`、`TOKEN`、`PASSWORD`、`PASS`、`API_KEY`、 @@ -422,6 +427,9 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin + | `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 | | `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 | | `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE | +| `components/LogViewer.tsx` | 日志虚拟滚动、跟随底部/回到最新 | +| `lib/logLines.ts` | 增量日志的行缓冲(残行拼接、CRLF 归一) | +| `lib/logWindow.ts` | 可见行窗口计算 | | `api/client.ts` | 类型化客户端、令牌缓存、403 重试 | | `lib/taskForm.ts` | Zod 校验与表单 ↔ API 载荷映射 | @@ -483,10 +491,10 @@ uv run --with fonttools --with brotli tools/build-fonts.py | 层 | 覆盖 | | --- | --- | -| Rust 单元(52) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属)、安全、目录列举 | +| Rust 单元(57) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 | | Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 | -| 前端 Vitest(13) | 仪表盘渲染、表单载荷、虚拟环境状态、目录浏览器(进入/取消/错误)、下次触发时间 | -| Playwright(4) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**(拦截所有外部请求) | +| 前端 Vitest(28) | 仪表盘渲染、表单载荷、虚拟环境状态、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | +| Playwright(5) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动 | | Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 | --- @@ -530,6 +538,7 @@ uv run --with fonttools --with brotli tools/build-fonts.py - [ADR-0005:由后端提供系统文件夹选择](docs/decisions/0005-folder-picker.md) - [ADR-0006:视觉风格对齐主题并自托管字体](docs/decisions/0006-visual-theme-and-offline-fonts.md) - [ADR-0007:运行期可靠性优化](docs/decisions/0007-runtime-reliability.md) +- [ADR-0008:日志合并推送与虚拟滚动](docs/decisions/0008-log-streaming-and-virtualization.md) ## 开发计划 diff --git a/backend/src/runs/executor.rs b/backend/src/runs/executor.rs index 4c6cf83..49647b3 100644 --- a/backend/src/runs/executor.rs +++ b/backend/src/runs/executor.rs @@ -37,6 +37,10 @@ use crate::{ const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024; const MAX_LOG_LINE_BYTES: usize = 256 * 1024; const MAX_HTTP_LOG_BODY_BYTES: usize = 64 * 1024; +/// 合并推送的日志事件上限:攒够这么多字节就立即发一个事件。 +const LOG_EVENT_BATCH_BYTES: usize = 16 * 1024; +/// 合并推送的时间窗口:超过这个时间还没攒满也发出去,保证低输出时也有实时感。 +const LOG_EVENT_BATCH_INTERVAL: Duration = Duration::from_millis(100); const TERMINATION_WAIT_SECS: u64 = 5; const CREATE_NO_WINDOW: u32 = 0x0800_0000; const DEFAULT_RETENTION_DAYS: u64 = 30; @@ -175,13 +179,28 @@ struct RunningHandle { #[derive(Clone)] struct LogSink { - file: Arc>, - size: Arc, - truncated: Arc, + inner: Arc, +} + +struct LogSinkInner { + file: AsyncMutex, + size: AtomicU64, + truncated: AtomicBool, run_id: i64, events: broadcast::Sender, - secrets: Arc>, + secrets: Vec, encoding: OutputEncoding, + /// 已经写进文件、但还没通过 SSE 推送出去的字节。 + pending: Mutex, +} + +/// 等待合并推送的一段日志。`offset` 是这段内容在日志文件中的起始偏移。 +#[derive(Default)] +struct PendingBatch { + offset: u64, + bytes: usize, + opened_at: Option, + text: String, } struct ScriptOutcome { @@ -466,7 +485,7 @@ impl TaskExecutor { let secrets = sensitive_values(&task.action); let sink = match LogSink::create( &log_path, - task, + &task.id, run_id, self.inner.events.clone(), secrets, @@ -631,7 +650,7 @@ impl TaskExecutor { let log_path = self.log_path(&task.id, run_id); let sink = match LogSink::create( &log_path, - task, + &task.id, run_id, self.inner.events.clone(), sensitive_values(&task.action), @@ -812,7 +831,7 @@ impl TaskExecutor { impl LogSink { async fn create( path: &Path, - task: &TaskResponse, + task_id: &str, run_id: i64, events: broadcast::Sender, secrets: Vec, @@ -822,8 +841,7 @@ impl LogSink { tokio::fs::create_dir_all(parent).await?; } let header = format!( - "[system] TaskPilot run {run_id} for task {} started at {}\n", - task.id, + "[system] TaskPilot run {run_id} for task {task_id} started at {}\n", Utc::now().to_rfc3339() ); tokio::fs::write(path, header.as_bytes()).await?; @@ -834,45 +852,59 @@ impl LogSink { let mut secrets = secrets; secrets.sort_by_key(|value| std::cmp::Reverse(value.len())); - Ok(Self { - file: Arc::new(AsyncMutex::new(file)), - size: Arc::new(AtomicU64::new(header.len() as u64)), - truncated: Arc::new(AtomicBool::new(false)), + let inner = Arc::new(LogSinkInner { + file: AsyncMutex::new(file), + size: AtomicU64::new(header.len() as u64), + truncated: AtomicBool::new(false), run_id, events, - secrets: Arc::new(secrets), + secrets, encoding, - }) + pending: Mutex::new(PendingBatch::default()), + }); + spawn_pending_flusher(&inner); + + Ok(Self { inner }) } fn decode_output(&self, bytes: &[u8]) -> String { - self.encoding.decode(bytes) + self.inner.encoding.decode(bytes) } async fn append(&self, text: &str) { - let redacted = redact_text(text, &self.secrets); + let redacted = redact_text(text, &self.inner.secrets); let bytes = redacted.as_bytes(); - let current = self.size.load(Ordering::Relaxed); + let mut file = self.inner.file.lock().await; + let current = self.inner.size.load(Ordering::Relaxed); if current + bytes.len() as u64 > MAX_LOG_BYTES { - if !self.truncated.swap(true, Ordering::Relaxed) { + if !self.inner.truncated.swap(true, Ordering::Relaxed) { let notice = format!("[system] 日志超过 {MAX_LOG_BYTES} 字节,后续输出已丢弃\n"); - let mut file = self.file.lock().await; let _ = file.write_all(notice.as_bytes()).await; } return; } - - let mut file = self.file.lock().await; if file.write_all(bytes).await.is_err() { return; } - let next = self.size.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64; - let _ = self.events.send(ExecutionEvent::Log { - run_id: self.run_id, - offset: current, - next_offset: next, - content: redacted, - }); + let next = current + bytes.len() as u64; + self.inner.size.store(next, Ordering::Relaxed); + + // 文件写入是串行的,所以 pending 也在文件锁内更新,保证推送的偏移连续。 + let mut pending = lock_pending(&self.inner.pending); + if pending.bytes == 0 { + pending.offset = current; + pending.opened_at = Some(Instant::now()); + } + pending.bytes += bytes.len(); + pending.text.push_str(&redacted); + + let batch_full = pending.bytes >= LOG_EVENT_BATCH_BYTES; + let window_elapsed = pending + .opened_at + .is_some_and(|opened| opened.elapsed() >= LOG_EVENT_BATCH_INTERVAL); + if batch_full || window_elapsed { + emit_pending(&self.inner, &mut pending); + } } async fn system(&self, message: &str) { @@ -884,6 +916,57 @@ impl LogSink { } } +impl Drop for LogSinkInner { + fn drop(&mut self) { + let mut pending = lock_pending(&self.pending); + emit_pending(self, &mut pending); + } +} + +fn lock_pending(pending: &Mutex) -> std::sync::MutexGuard<'_, PendingBatch> { + pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// 把待推送的日志合并成一个事件发出去,并清空缓冲区。 +fn emit_pending(inner: &LogSinkInner, pending: &mut PendingBatch) { + if pending.bytes == 0 { + return; + } + let batch = std::mem::take(pending); + let _ = inner.events.send(ExecutionEvent::Log { + run_id: inner.run_id, + offset: batch.offset, + next_offset: batch.offset + batch.bytes as u64, + content: batch.text, + }); +} + +fn flush_pending_if_due(inner: &LogSinkInner) { + let mut pending = lock_pending(&inner.pending); + let due = pending + .opened_at + .is_some_and(|opened| opened.elapsed() >= LOG_EVENT_BATCH_INTERVAL); + if due { + emit_pending(inner, &mut pending); + } +} + +/// 低输出量时不会触发"攒满即发",靠这个后台任务按时间窗口把日志推出去。 +fn spawn_pending_flusher(inner: &Arc) { + let weak = Arc::downgrade(inner); + tokio::spawn(async move { + loop { + sleep(LOG_EVENT_BATCH_INTERVAL / 2).await; + let Some(inner) = weak.upgrade() else { + break; + }; + flush_pending_if_due(&inner); + } + }); +} + fn lock_running( running: &Mutex>, ) -> std::sync::MutexGuard<'_, HashMap> { @@ -1273,4 +1356,107 @@ mod tests { remove_running_if(&running, "task", Some(2)); assert!(!lock_running(&running).contains_key("task")); } + + async fn test_sink() -> ( + LogSink, + broadcast::Receiver, + tempfile::TempDir, + PathBuf, + ) { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("run.log"); + let (events, receiver) = broadcast::channel(32); + let sink = LogSink::create( + &path, + "task", + 7, + events, + Vec::new(), + OutputEncoding::from_env(), + ) + .await + .unwrap(); + (sink, receiver, temp, path) + } + + async fn recv_log(receiver: &mut broadcast::Receiver) -> (u64, u64, String) { + let event = timeout(Duration::from_secs(2), receiver.recv()) + .await + .expect("log event should arrive") + .expect("log channel should stay open"); + match event { + ExecutionEvent::Log { + run_id, + offset, + next_offset, + content, + } => { + assert_eq!(run_id, 7); + (offset, next_offset, content) + } + other => panic!("expected log event, got {other:?}"), + } + } + + #[tokio::test] + async fn coalesces_rapid_output_into_a_single_log_event() { + let (sink, mut receiver, _temp, path) = test_sink().await; + + sink.append("first line\n").await; + sink.append("second line\n").await; + drop(sink); + + let (offset, next_offset, content) = recv_log(&mut receiver).await; + assert_eq!(content, "first line\nsecond line\n"); + assert_eq!(next_offset - offset, content.len() as u64); + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert!(on_disk.ends_with(&content)); + } + + #[tokio::test] + async fn flushes_the_pending_batch_when_the_sink_is_dropped() { + let (sink, mut receiver, _temp, _path) = test_sink().await; + + sink.append("only line\n").await; + drop(sink); + + let (offset, next_offset, content) = recv_log(&mut receiver).await; + assert_eq!(content, "only line\n"); + assert_eq!(next_offset - offset, content.len() as u64); + } + + #[tokio::test] + async fn flushes_a_full_batch_without_waiting_for_the_window() { + let (sink, mut receiver, _temp, _path) = test_sink().await; + + sink.append(&"x".repeat(LOG_EVENT_BATCH_BYTES)).await; + + let (_offset, _next_offset, content) = recv_log(&mut receiver).await; + assert_eq!(content.len(), LOG_EVENT_BATCH_BYTES); + } + + #[tokio::test] + async fn flushes_low_volume_output_within_the_time_window() { + let (sink, mut receiver, _temp, _path) = test_sink().await; + + sink.append("quiet output\n").await; + + let (_offset, _next_offset, content) = recv_log(&mut receiver).await; + assert_eq!(content, "quiet output\n"); + } + + #[tokio::test] + async fn batches_keep_contiguous_offsets_across_flushes() { + let (sink, mut receiver, _temp, _path) = test_sink().await; + + sink.append(&"x".repeat(LOG_EVENT_BATCH_BYTES)).await; + let (first_offset, first_next, first_content) = recv_log(&mut receiver).await; + sink.append("tail\n").await; + let (second_offset, second_next, second_content) = recv_log(&mut receiver).await; + + assert_eq!(first_next - first_offset, first_content.len() as u64); + assert_eq!(second_offset, first_next); + assert_eq!(second_next - second_offset, second_content.len() as u64); + assert_eq!(second_content, "tail\n"); + } } diff --git a/docs/decisions/0008-log-streaming-and-virtualization.md b/docs/decisions/0008-log-streaming-and-virtualization.md new file mode 100644 index 0000000..619ab16 --- /dev/null +++ b/docs/decisions/0008-log-streaming-and-virtualization.md @@ -0,0 +1,46 @@ +# ADR-0008:日志合并推送与虚拟滚动 + +- 状态:已接受 +- 日期:2026-10-27 + +## 背景 + +日志链路原本是"一行一个事件、一行一次渲染": + +1. 子进程每输出一行,`LogSink::append` 就向 512 容量的 broadcast 通道发一条 `log` 事件。 + 输出密集时事件按行堆积,SSE 订阅者很容易 `Lagged`,触发 `lagged` → 前端重新拉取, + 在日志最密集的时刻反而放大数据库与文件读取压力。 +2. 前端 `RunLogOutput` 把每条事件字符串拼到 `content` state,React 每次都要重渲染整个 + `
` 文本;开销随行数线性增长,单次运行 5 MiB 上限意味着最坏情况下数万行 ×
+   数万次更新,是典型的 O(n²)。
+3. 全部日志行都作为 DOM 文本存在,长日志下文本节点、布局与滚动都变得昂贵。
+
+## 决策
+
+1. **后端按窗口合并**:`LogSink` 新增 `PendingBatch`,行仍然**立即写文件**(轮询读到的偏移不受影响),
+   但 SSE 事件按"**攒满 16 KiB** 或 **满 100 ms**"合并成一条 `log` 事件,携带连续的
+   `offset..next_offset`。
+2. **按时兜底**:每个 sink 起一个 50 ms tick 的后台任务,低输出量时也保证 100 ms 内推送;
+   任务只持有 `Weak`,sink 释放后自行退出。
+3. **析构 flush**:`Drop for LogSinkInner` 同步把残留批次发出去,运行结束时不会丢最后几行。
+4. **前端按帧合并**:增量先进行缓冲,`requestAnimationFrame` 内最多触发一次重渲染,
+   并且在同一次渲染里把新行并入。
+5. **虚拟滚动**:`LogViewer` 以固定行高 20 px 换算可见区间,只渲染视口 ± 24 行的窗口;
+   行内不折行(`white-space: pre`),超长行横向滚动,从而避免逐行测量高度。
+6. **跟随策略**:默认贴底跟随;用户上滚超过 24 px 即停止跟随并显示「回到最新」,
+   点击后回到底部并恢复跟随。
+
+## 原因
+
+- 合并推送把事件数量从"每行一条"降到"每 100 ms 最多一条",SSE 背压与前端更新次数同时下降,
+  而实时感没有可感知的损失(100 ms 窗口)。
+- 文件写入保持即时,轮询接口与下载接口的语义完全不变,合并只影响事件粒度。
+- 固定行高让滚动位置与行号一一对应,虚拟滚动不需要动态测量,代码量与出错面都小。
+- 行缓冲原地追加 + 一帧一次渲染,避免了复制整个字符串/数组的二次开销。
+
+## 边界
+
+- 合并意味着**事件粒度不再是行**:依赖"一条事件 = 一行"的消费者需要按 `content` 自行切分。
+- 不折行是显式取舍:超过面板宽度的行需要横向滚动;折行会让行高不定,虚拟滚动需要测量。
+- 单次运行仍受 5 MiB 总上限、单行 256 KiB 上限约束;虚拟滚动只解决渲染,不会保留更多日志。
+- `Drop` 里的 flush 是同步发送,若此时没有订阅者,这批事件不会被补发(轮询仍然能读到文件)。
diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts
index f571452..ee404d3 100644
--- a/frontend/e2e/dashboard.spec.ts
+++ b/frontend/e2e/dashboard.spec.ts
@@ -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,
+  })
+})
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 5797ddb..3bd2beb 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -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 {
diff --git a/frontend/src/components/LogViewer.test.tsx b/frontend/src/components/LogViewer.test.tsx
new file mode 100644
index 0000000..e7dceef
--- /dev/null
+++ b/frontend/src/components/LogViewer.test.tsx
@@ -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()
+
+    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()
+
+    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()
+
+    const canvas = container.querySelector('.log-canvas')
+    expect(canvas).not.toBeNull()
+    expect((canvas as HTMLElement).style.height).toBe('52px')
+  })
+})
diff --git a/frontend/src/components/LogViewer.tsx b/frontend/src/components/LogViewer.tsx
new file mode 100644
index 0000000..30610d6
--- /dev/null
+++ b/frontend/src/components/LogViewer.tsx
@@ -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(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 (
+    
+
+ {lines.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
+
+ {lines.slice(start, end).map((line, index) => ( +
+ {line} +
+ ))} +
+
+ )} +
+ {lines.length > 0 && !following ? ( + + ) : null} +
+ ) +} diff --git a/frontend/src/components/RunLogDrawer.tsx b/frontend/src/components/RunLogDrawer.tsx index 02a75f0..beb0811 100644 --- a/frontend/src/components/RunLogDrawer.tsx +++ b/frontend/src/components/RunLogDrawer.tsx @@ -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([]) const [pollComplete, setPollComplete] = useState(runComplete) const [error, setError] = useState(null) const [reloadKey, setReloadKey] = useState(0) const [reconcileKey, setReconcileKey] = useState(0) + const bufferRef = useRef(createLogBuffer()) const offsetRef = useRef(0) - const outputRef = useRef(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 (
@@ -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({
) : null} -
-        {content || '等待日志输出...'}
-      
+ ) } diff --git a/frontend/src/lib/logLines.test.ts b/frontend/src/lib/logLines.test.ts new file mode 100644 index 0000000..6cc6398 --- /dev/null +++ b/frontend/src/lib/logLines.test.ts @@ -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) + }) +}) diff --git a/frontend/src/lib/logLines.ts b/frontend/src/lib/logLines.ts new file mode 100644 index 0000000..ad92ae5 --- /dev/null +++ b/frontend/src/lib/logLines.ts @@ -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) +} diff --git a/frontend/src/lib/logWindow.test.ts b/frontend/src/lib/logWindow.test.ts new file mode 100644 index 0000000..c035406 --- /dev/null +++ b/frontend/src/lib/logWindow.test.ts @@ -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) + }) +}) diff --git a/frontend/src/lib/logWindow.ts b/frontend/src/lib/logWindow.ts new file mode 100644 index 0000000..2a3820d --- /dev/null +++ b/frontend/src/lib/logWindow.ts @@ -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)) +}