@hyzyn/dsh-docker 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -250,7 +250,7 @@ Inside the panel:
250
250
  **SSE push** (`GET /api/dsh-docker/logs/stream`, where the server runs
251
251
  `docker logs --follow`) — new log lines are appended as they arrive and polling stops; `FOLLOW` and
252
252
  `AUTO REFRESH` are mutually exclusive (opening the stream stops polling and greys out the switch), and closing it
253
- returns to snapshots with an immediate refresh. Streaming logs keep the last **5000 lines / 4MB** (a ring buffer that drops the oldest on either cap; newline-free oversized output is force-split so memory stays bounded). Chunks render at most every **150ms** (no per-chunk re-render on chatty containers) and rows carry stable ids, so sliding the buffer only mounts/unmounts boundary nodes — the view no longer truncates: whatever `LINES` selects is rendered and exported (bounded by the buffer and the host output cap). Auto-scroll to bottom,
253
+ returns to snapshots with an immediate refresh. Streaming logs keep the last **5000 lines / 4MB** (a ring buffer that drops the oldest on either cap; newline-free oversized output is force-split so memory stays bounded). Chunks render at most every **150ms** (no per-chunk re-render on chatty containers) and rows carry stable ids. The body is **windowed (D152)**: only the visible rows plus 24 rows of overscan on each side are mounted (about 230 nodes for 5000 rows, previously ~10000), the rest is represented by top/bottom padding — under a sustained 20k lines/s flood the max event-loop lag drops from 70–97ms to 6ms and >50ms long frames from 25–26 to **0**. Row heights are **measured** (not estimated) and the window anchors to the tail while pinned, so auto-scroll / scroll-up pause / "back to bottom" keep exact geometry; scrolling up through history is compensated by a top-of-viewport anchor recorded **across commits**, so it does not jump even while the ring buffer keeps evicting old rows (or the aggregate view inserts a row by timestamp). **Switching streams / containers / refreshing a snapshot invalidates that whole generation of measured heights and anchors (D155)**: snapshot row ids are positional (`'s'+index`), so the same id is different content after a refresh — keeping them would compute the padding from the wrong generation's heights (measured: `scrollHeight` inflated by 35644px), so the scrollbar, jumps and "which row is at the top" all describe the wrong generation. The view does not truncate: whatever `LINES` selects is scrollable and exported — it is simply not all mounted at once (bounded by the buffer and the host output cap). Auto-scroll to bottom,
254
254
  drops the oldest and hints once); filtering / level colouring share exactly the same rendering as snapshots. It
255
255
  auto-scrolls to the bottom, pauses when the user scrolls up and floats a
256
256
  "back to bottom" button; a status line in the top right shows the connection state, and a stream that ends
@@ -259,8 +259,15 @@ Inside the panel:
259
259
  carries `tail` to backfill history, while every reconnect uses `tail=0` — new lines only, **never replaying
260
260
  history** (auto-reconnect reuses the URL with its `tail`, so the server pushes the last `tail` lines again as
261
261
  if they were new, and the log grows a duplicated block). When the host-side backpressure queue (8MB)
262
- overflows it first sends an `end` frame with `reason: output-limit` and then closes, so the UI says
263
- "host-side backlog" and reconnects.
262
+ **Host-side backpressure (D153)**: each stream buffers at most 8MB; the text tail streams (logs, pulls)
263
+ drop the oldest frames and keep the newest when they overflow, sending a `skip{frames,bytes}` notice so the UI
264
+ can say "a stretch is missing, following continues" — the stream is **not** torn down (the old policy sent
265
+ `end{reason:output-limit}` and closed, which left the panel stuck in "connection lost" while the container kept
266
+ flooding). The skip accounting is **exact**: when more frames are dropped while a queued `skip` notice has not
267
+ been written yet, the server updates that notice **in place** (D156 — no gap goes unreported); the client
268
+ **accumulates** consecutive skips and shows "N batches skipped on this connection", resetting on a successful
269
+ reconnect (D157). Structured streams (stats, events) still close and reconnect, because a missing sample/event is
270
+ semantically wrong.
264
271
  Connecting / switching pages / closing the panel all close the `EventSource`.
265
272
  - **Overview**: `docker inspect`'s authoritative data — state and health, exit code, restart count and policy,
266
273
  port mappings, mounts (including read-only flags), networks and IPs, entrypoint and command, and the latest
@@ -427,6 +434,22 @@ The "Overview" page lays out all targets on one screen: one counter card per tar
427
434
 
428
435
  ![Settings card: target CRUD, capability switches and parameters, saved and applied hot](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-setting.png)
429
436
 
437
+ Where the settings surface **lives** depends on the DSH version, but it is always the **same form**
438
+ (same settings namespace, same write path) — the data and behaviour are identical:
439
+
440
+ | Host | Where the settings are |
441
+ |---|---|
442
+ | `0.2.0-rc.1` and later | **Directly below the description on the plugin detail page** (`plugins.bundle.config`, key = **this package's own** bundle name) — no extra ">" step into a sub-page |
443
+ | `0.1.6` line | Plugins sidebar → the bundle's **row** → the ">" row detail (`plugins.row.config`) |
444
+ | `≤0.1.5` | Settings → Plugins → the plugin's collapsible card (`settings.plugin.item`) |
445
+ | All of the above | Settings → **plugin configuration**, the same entry (`settings.kit.item`, provided by `@hyzyn/dsh-kit-settings`) |
446
+
447
+ On hosts that have the bundle slot, **this package's** row entry is **no longer registered** — two entries
448
+ for one form only makes people think there are two sets of settings; hosts without it (the bundle slot
449
+ missing) fall back to the row detail automatically, with no loss of function. The **aggregate bundle
450
+ `@hyzyn/dsh-all` is the exception**: its `plugins.bundle.config` key is shared by every plugin (a second
451
+ registrant throws), so it has no inline slot and keeps using its row entry.
452
+
430
453
  Configuration lives in the settings namespace `docker`, i.e. the `docker:` section of `~/.dsh/settings.yaml`
431
454
  (`$DSH_HOME/settings.yaml`; DSH's settings file is provided by the host's `dsh-settings-file`). The composition
432
455
  config in the plugin line acts as the schema's `base`, which the settings layer overrides; the HTTP
@@ -616,6 +639,19 @@ The four streams differ only in "executor + end reason":
616
639
  no-cache`, `connection: keep-alive`, with `flushHeaders()` immediately after writing them (the host's gzip
617
640
  explicitly skips `text/event-stream`, so nothing is buffered).
618
641
  - Heartbeat: one `: ping` comment frame every 15s (ignored by clients per the SSE spec).
642
+ - **Chunk coalescing (D151)**: the log and pull streams merge upstream chunks over a 50ms window before pushing
643
+ them (strict ordering within a channel, stdout before stderr, an immediate flush once 256KB has accumulated), so a
644
+ `line` frame's `d` / `e` may carry **several lines**. The client already splits on arbitrary chunks, so the
645
+ semantics are unchanged — what disappears is "one SSE frame per stdout chunk" (with a chatty container that is
646
+ thousands of frames per second, and every frame's fixed cost lands on the browser's main thread). The `end` frame
647
+ always comes after the last batch of `line` frames in the window.
648
+ - **Backpressure and overflow (D153)**: when the client cannot keep up, frames go into an in-memory queue
649
+ (≤ 8MB per stream). The log and pull streams (text tails) drop the oldest whole frames and keep the newest on
650
+ overflow, pushing a `skip{frames,bytes}` notice (`skip` itself is never dropped) — the stream stays alive. When
651
+ more frames are dropped while a queued `skip` has not been written yet, its counters are updated **in place**
652
+ (D156 — reporting only the first gap would undercount). The
653
+ stats and events streams send `end{reason:output-limit}` and reconnect instead. Also, `write()` returning false
654
+ only means "past the high-water mark": a frame is **never written twice** (that duplication was a real bug).
619
655
  - Teardown: the client disconnects → the executor is aborted immediately (locally `SIGTERM`, then `SIGKILL` if it has
620
656
  not exited in 2s; over SSH that exec channel is closed and the pooled connection is kept for reuse), writing no
621
657
  frame at all; the plugin is disabled / the config is hot-updated / it is uninstalled → the server wraps up on its
@@ -876,14 +912,49 @@ ring buffer / action labels / debounce** (pure logic through the `__events` test
876
912
  the entry label when the sidebar is collapsed (`data-sidebar-collapsed`).
877
913
  Verification that needs a real daemon follows the manual checklist below.
878
914
 
879
- `test/logs-stream.test.ts` (27 cases, run by the root `pnpm test`) covers four layers of the live log stream:
915
+ `test/logs-stream.test.ts` (37 cases, run by the root `pnpm test`) covers four layers of the live log stream:
880
916
  `logsStream`'s argv construction and `assertRef` allowlist, the single-line JSON encapsulation of SSE frames
881
917
  (newlines / multi-byte), the local stream lifecycle (fake spawn: multi-byte across chunks, the SIGTERM→SIGKILL
882
918
  ladder, close resolve, spawn error) plus the SSH long stream's busy-count pairing / sweeper skip, and the route
883
919
  layer's event sequence / heartbeat / silent abort when the client disconnects / uniform wrap-up when the plugin is
884
- disabled.
885
-
886
- `test/streams.test.ts` (33 cases) covers the **stats stream / event stream / pull stream / networks and volumes /
920
+ disabled — plus **chunk coalescing** (D151: ten chunks produce a single `line` frame; when the stream ends before the
921
+ window elapses, `end` still comes after those rows) and **exact skip accounting** (D156: frames dropped while a queued
922
+ `skip` has not been written yet must still be reported — a 46-frame ledger where every frame is either delivered or
923
+ accounted for). The coalescer's own rules (window merge / flush on the size cap /
924
+ idempotent flush / dispose stops the timer) live in `test/sse-coalesce.test.ts` (6 cases).
925
+
926
+ `test/log-window.test.ts` (17 cases) covers the **pure windowing logic** (D152, `client-src/log-window.js`):
927
+ measured heights in the cache vs the estimate for rows never measured, window placement and self-consistent
928
+ padding for the non-pinned case, the pinned case anchoring to the tail with zero bottom padding (the premise
929
+ for exact stick-to-bottom), `reanchor` only compensating for height changes above the anchor, `offsetOf` and
930
+ "compensation after head eviction = the evicted height" (the cross-generation anchor math behind D154), cache
931
+ FIFO eviction and empty-list edges. The real-DOM layer (measuring, anchor correction, pinning) is verified by the
932
+ performance gate in real Chrome; offline there is also the `__logWindow` seam in `scripts/client-smoke.mjs`.
933
+
934
+ `pnpm --filter @hyzyn/dsh-docker perf:logs` (`scripts/log-perf.mjs`) is the **browser-side performance gate for the
935
+ log page**: a real Chrome measures four scenarios — the 5000-row first paint (time / DOM size), the 20000-row FOLLOW
936
+ burst (processing time and max event-loop lag), the **event flood** (the same 6.2k lines/s as 1 line per event vs 50
937
+ lines per event — this is the per-event fixed cost D151 removed) and the **sustained flood** (20k lines/s for 6s; once
938
+ the buffer is full every frame evicts and inserts, which is the "the page janks as soon as logs get fast" users
939
+ report). It also checks two things users tend to misread: the snapshot must show the yellow "truncated" banner whenever
940
+ the "Output limit (KB)" byte cap bites (otherwise "LINES = 1000 returned only 985 rows" looks like data loss — that
941
+ 985 is docker's own record granularity), it also asserts that "scroll-up pauses following" reacts to **real gestures only** (wheel / touch /
942
+ scrollbar / keyboard: a gesture must pause and one click must resume, while positional drift with no gesture must
943
+ *not* stop following), and it simulates the **backlog → reconnect** path (D153/D152: after a host-side `output-limit` the body must
944
+ still show rows, following must not silently stop, and after the automatic reconnect the last mounted row must be
945
+ the newest), it **parks mid-history during a flood** (D154: while the ring buffer keeps evicting, the top visible row
946
+ must stay the same row for 1.2s — uncompensated eviction makes reading history drift like an automatic rewind), it checks the **generation switch** (D155: a snapshot refreshed into a generation of taller wrapped rows and then back to normal rows must have its padding computed from the *current* generation — a stale cache inflates `scrollHeight` by 35644px), and **checks the window is
947
+ correct**: the snapshot content is deterministic, so scrolling to the top must show `seq=0` as the first row,
948
+ scrolling to the bottom `seq=4999` as the last, and after the flood the last mounted row must be the newest one
949
+ (stick-to-bottom still following). Over budget means a non-zero exit. Measured after D152: 5000-row snapshot
950
+ 179ms / **229 body nodes** (previously ~10000) with 53–57 rows mounted; 20k-row burst 933ms / max lag **2ms**
951
+ (previously 52–97ms); event flood 10ms vs 7ms; sustained 20k lines/s × 6s max lag **6ms** with **0** frames >50ms
952
+ (previously 70–97ms / 25–26) and 213 nodes. The budget also caps mounted rows at 200 — if windowing breaks, that
953
+ turns red immediately. It depends on the tty preview fixtures
954
+ (generated by `node packages/tty/scripts/preview.mjs`) and **skips with exit 0** when the fixtures or Chrome are
955
+ missing — it is an npm script, not a required vitest entry.
956
+
957
+ `test/streams.test.ts` (35 cases) covers the **stats stream / event stream / pull stream / networks and volumes /
887
958
  generic SSE infrastructure**: `statsStream` without `--no-stream` (the same construction point as the snapshot),
888
959
  `pullStream`'s `assertImageRef` allowlist, `/stats/stream` normalising line-by-line JSON (including half lines
889
960
  across chunks) into `stats` events with the same shape as the `/stats` snapshot, heartbeats, silent abort when the
package/README.md CHANGED
@@ -224,14 +224,28 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
224
224
  `AUTO REFRESH` 互斥(开流自动停轮询、开关置灰),关闭即回到快照并立即刷新。
225
225
  流式日志保留最近 **5000 行 / 4MB**(环形缓冲,行数或字节超限都丢最旧并提示
226
226
  一次;没有换行符的超长输出会被强制分片,内存恒有界);分片到达按 **150ms 合帧**
227
- 渲染(话痨容器也不再逐条重绘),行 key 用单调 id,缓冲滑动只挂载/卸载边界节点。
228
- 显示层**不再另设截断**:`LINES` 选多少就渲染 / 导出多少(真正的闸是缓冲与宿主的
229
- 「输出上限(KB)」)。自动滚动到底部,用户向上滚动时暂停并浮出
227
+ 渲染(话痨容器也不再逐条重绘),行 key 用单调 id。
228
+ **正文按窗口化挂载(D152)**:DOM 里只留「可见 + 上下各 24 行 overscan」的几十行
229
+ (实测 5000 行时约 230 个节点,此前是全量 ~10000),其余用上下垫高占位——持续洪泛
230
+ 20k 行/秒时事件循环最大延迟从 70~97ms 降到 6ms、>50ms 长帧从 25~26 个降到 **0**。
231
+ 行高是**实测**的(不是估算),贴底时窗口锚在列表尾部,所以「自动贴底」「上滚暂停」
232
+ 「回到底部」的几何判定依旧精确;往上滚历史时按可见区顶行做**跨帧记锚**的修正——
233
+ 洪泛里缓冲持续淘汰旧行、或聚合视图按时间插行,都不会跳。**换流 / 换容器 / 快照刷新
234
+ 会整代作废这批实测高度与锚点(D155)**:快照行 id 是位置寻址(`'s'+index`),刷新后
235
+ 同一个 id 就是另一行内容——不换代就会拿另一代的高度算垫高(实测滚动高虚高 35644px),
236
+ 滚动条、跳转、「滚到顶看到第几行」全部按错的一代算。
237
+ 显示层**不截断**:`LINES` 选多少就是多少——全部行都能滚到、都能导出(真正的闸是
238
+ 缓冲与宿主的「输出上限(KB)」),只是不再同时挂在 DOM 里。自动滚动到底部,用户向上滚动时暂停并浮出
230
239
  「回到底部」按钮;右上状态行显示连接状态,容器退出导致流自然结束时自动切回快照刷新。**断线重连由插件自己管**(不依赖
231
240
  EventSource 的自动重连):首连带 tail 补历史,重连一律 tail=0——只补新行、**不
232
241
  重放历史**(自动重连会复用带 tail 的 URL,服务端就会把最后 tail 行当新行重推一遍,
233
- 日志里凭空多出一段重复)。宿主侧背压队列(8MB)溢出时会先发一条 end 帧
234
- (reason = output-limit)再收尾,界面明说「主机侧积压」并自动重连。
242
+ 日志里凭空多出一段重复)。**宿主侧背压(D153)**:单条流的暂存队列上限 8MB;日志与拉取
243
+ 这类**文本尾部流**溢出时丢最旧的帧、留最新,并推一条 `skip{frames,bytes}` 让界面说明
244
+ 「中间断了一截,跟随继续」——**不再掐流**(老策略是补一条 `end{reason:output-limit}` 收尾,
245
+ 客户端只能重连,积压期间面板就是「连接中断」在打转)。skip 的账目是**精确**的:skip 帧还在
246
+ 队列里没落地时又发生丢弃,服务端**原地更新**它的计数(D156,一段缺口都不吞);客户端把
247
+ 多批 skip **累加**显示成「本次连接共跳过 N 批」,重连成功即清零(D157)。统计 / 事件这类结构化流仍走收尾重连
248
+ (少一个采样/事件在语义上是错的,宁可让客户端补一次)。
235
249
  连接 / 切页 / 关面板都会关闭 `EventSource`。
236
250
  - **概览**:`docker inspect` 的权威数据——状态与健康、退出码、重启次数与策略、
237
251
  端口映射、挂载(含只读标记)、网络与 IP、entrypoint 与命令、最近一次健康
@@ -397,6 +411,21 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
397
411
 
398
412
  ![设置卡片:目标 CRUD、能力开关与参数,保存即热生效](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-setting.png)
399
413
 
414
+ 设置面在宿主里的**位置**随 DSH 版本变,但**始终是同一份表单**(同一个 settings 命名空间、
415
+ 同一个写入通道),配置数据与行为逐字节相同:
416
+
417
+ | 宿主 | 设置面在哪 |
418
+ |---|---|
419
+ | `0.2.0-rc.1` 起 | **插件详情页「说明」正下方**(`plugins.bundle.config`,key = **本包自己的** bundle 包名)——设置就在详情页里,不必再点一次「>」进二级页 |
420
+ | `0.1.6` 线 | 侧边栏「插件」→ 该 bundle 的**行** → 「>」行详情(`plugins.row.config`) |
421
+ | `≤0.1.5` | 设置 → 插件 → 该插件的可折叠卡片(`settings.plugin.item`) |
422
+ | 以上各版 | 设置 → **插件配置** 里的同一条目(`settings.kit.item`,由 `@hyzyn/dsh-kit-settings` 提供) |
423
+
424
+ 新宿主上 bundle 槽可用时**不再注册本包那份 row 槽**——同一份表单两个入口只会让人以为有两套设置;
425
+ 旧宿主(bundle 槽不存在)自动回退到行详情,功能一点不减。**聚合包 `@hyzyn/dsh-all` 例外**:它的
426
+ `plugins.bundle.config` key 是所有插件共享的(重复注册会直接抛错),所以挂不了内联位,它在聚合
427
+ 安装下继续走 row 入口。
428
+
400
429
  配置落在 settings 命名空间 `docker`,即 `~/.dsh/settings.yaml` 的 `docker:`
401
430
  段(`$DSH_HOME/settings.yaml`;DSH 的 settings 文件由宿主 `dsh-settings-file`
402
431
  提供)。插件行里的 composition 配置作为 schema `base` 打底,settings 层覆盖
@@ -580,6 +609,16 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
580
609
  no-cache`、`connection: keep-alive`,写头后立即 `flushHeaders()`(宿主 gzip
581
610
  对 `text/event-stream` 显式跳过,不会缓冲)。
582
611
  - 心跳:每 15s 一帧 `: ping` 注释(SSE 规范里客户端忽略)。
612
+ - **背压与溢出(D153)**:客户端消费不过来时帧进内存队列(每条流 ≤ 8MB);日志 / 拉取流溢出
613
+ 时丢最旧的整帧、只留最新,并推一条 `skip{frames,bytes}`(`skip` 帧自身永不丢)——**流不断**;
614
+ skip 排队期间再发生丢弃时**原地更新**这条 skip 的账目(D156,两段缺口只报出一段就是少报)。
615
+ 统计 / 事件流溢出时补 `end{reason:output-limit}` 收尾,由客户端重连。另外 `write()` 返回
616
+ false 只代表「越过高水位」,帧**不会被重写**(老实现的重复帧就是这里来的)。
617
+ - **分片合帧(D151)**:日志流与拉取流把上游分片按 50ms 窗口合并后再推(同一通道严格保序、
618
+ stdout 先于 stderr、攒满 256KB 立刻推),所以 `line` 帧的 `d` / `e` 里可能有**多行**——
619
+ 客户端本来就是按「任意分片」切行的,语义不变,只是「一个 stdout chunk 一帧」没了(话痨
620
+ 容器每秒几千帧时,每帧的固定开销全砸在浏览器主线程上)。`end` 帧一定排在窗口里最后一批
621
+ `line` 之后。
583
622
  - 清理:客户端断开 → 立即中止执行器(本机 `SIGTERM`,2s 未退再 `SIGKILL`;
584
623
  SSH 关闭该 exec channel、连接池连接保留复用),不写任何帧;插件禁用 / 配置
585
624
  热更新 / 卸载 → 服务端主动收尾(abort + `end`)。
@@ -652,6 +691,17 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
652
691
  都是带指向性的提示,而不是 ssh2 的原始文案。若你的 sshd 调过 `MaxSessions`
653
692
  (`sshd -T | grep maxsessions`),当前上限是编译期常量,需要跟着改就提 issue。
654
693
  折叠右侧栏标签会主动断流、把通道还回去。
694
+ (`D150`)这条约束另有两道补救:① **短命令按目标排队** —— 每条连接同时最多 2 条
695
+ 并发(`DSH_DOCKER_SHORT_CHANNELS` 可覆盖,仅测试 / 排障),多出来的等前一条收尾,
696
+ 不再出现「一次点击(概览 inspect + 日志快照 + 统计快照)就去撞 10 的硬上限」;
697
+ ② **额度满会自愈** —— 远端回 `Channel open failure: open failed`(sshd 侧原文
698
+ `error: no more sessions`)时,插件丢掉并**关闭**那条连接、重建一条再重试一次,
699
+ 宿主日志留一行 `通道额度已满:已重建连接重试`。之所以必须重建:这条连接**不会自己
700
+ 恢复**(中止长流时发的 `signal('KILL')` 在部分 sshd 上被拒绝,而 sshd 在子进程仍活着
701
+ 时延迟释放 session 槽)。代价是该连接上正在跟随的流会断开、由面板 SSE 自动重连
702
+ (日志流按 `D133` 用 `tail=0` 续尾)。**根治办法在 sshd 一侧**:把那台主机的
703
+ `MaxSessions` 调大 —— 例如 `/etc/ssh/sshd_config.d/10-dsh-maxsessions.conf` 写一行
704
+ `MaxSessions 50`,再 `sshd -t && systemctl reload ssh`(reload 只影响新连接)。
655
705
  - **docker CLI 版本差异**:解析走 `--format '{{json .}}'`,字段随版本增减,
656
706
  解析器一律降级而不抛异常(例如 `State` 缺失就从 `Status` 推导状态,健康态
657
707
  从 `(healthy)` / `(unhealthy)` 提取);缺字段时对应列可能为空,需要权威
@@ -829,20 +879,48 @@ loopback 403(含三条流路由)、容器名 / 镜像引用注入尝试被
829
879
  卡片 key 等于命名空间 `docker`、找不到宿主侧边栏时安静降级且卸载可重复调用,ttyConnbar 集成的四条路径(连接簿名命中 / host:port 命中 / 未配置主机不加按钮 / tty 未安装静默跳过),FOLLOW 的 SSE 订阅与「回到底部」交互(静态断言),**镜像详情 / 拉取流 / 删除 / prune 入口**、**统计 FOLLOW + sparkline 钩子**、**Compose 分组与聚合日志**、**「活动」条装配 + 事件环形缓冲 / 动作标签 / 防抖**(纯逻辑经 `__events` 测试缝),以及侧边栏折叠态(`data-sidebar-collapsed`)隐藏入口标签的样式规则。
830
880
  需要真 daemon 的验证走下面的手工清单。
831
881
 
832
- `test/logs-stream.test.ts`(30 例,随根 `pnpm test` 跑)覆盖日志实时流的四层:
882
+ `test/logs-stream.test.ts`(37 例,随根 `pnpm test` 跑)覆盖日志实时流的四层:
833
883
  `logsStream` 的 argv 构造与 `assertRef` 白名单、SSE 帧的单行 JSON 封装(换行 /
834
884
  多字节)、本地流生命周期(假 spawn:跨 chunk 多字节、SIGTERM→SIGKILL 阶梯、
835
885
  close resolve、spawn error)与 SSH 长流的 busy 计数配对 / sweeper 跳过、以及
836
886
  路由层的事件序列 / 心跳 / 客户端断开静默中止 / 插件禁用统一收尾,以及
837
- **`tail=0` 只跟随不补历史**与**背压队列溢出补发 `end{reason:output-limit}`**(D133)。
887
+ **`tail=0` 只跟随不补历史**(D133)、**背压不重写已写过的帧**与**积压时丢最旧 + `skip` 通知、
888
+ 不掐流**(D153)、**skip 账目精确**(D156:skip 排队期间再丢弃,两段缺口都要报——
889
+ 46 块逐块对账「每一帧要么送达、要么被 skip 记账」)、
890
+ 以及**分片合帧**(D151:十个分片只出一个 `line` 帧;未到窗口就收尾时 `end` 仍排在
891
+ 这些行之后)。合帧器本身的规则(窗口合并 / 攒满即推 / flush 幂等 / dispose 停表)在
892
+ `test/sse-coalesce.test.ts`(6 例)。
893
+
894
+ `test/log-window.test.ts`(17 例)覆盖**窗口化挂载的纯逻辑**(D152,`client-src/log-window.js`):
895
+ 实测高度进缓存而没量过的行用估算、`layout` 非贴底时的窗口定位与上下垫高自洽、贴底时锚到
896
+ 列表尾部且下垫为 0(贴底判定精确的前提)、`reanchor` 只在锚点上方行高变化时产生等量补偿、
897
+ `offsetOf` 与「淘汰头部后补偿量 = 被淘汰行总高」(D154 跨代锚点的数学)、
898
+ 缓存 FIFO 淘汰与空表边界。真 DOM 那一层(量高、锚点修正、贴底钉住)由性能门禁在真 Chrome
899
+ 里验证,离线侧另有 `scripts/client-smoke.mjs` 的 `__logWindow` 缝。
838
900
 
839
901
  `pnpm --filter @hyzyn/dsh-docker perf:logs`(`scripts/log-perf.mjs`)是**日志页的浏览器侧
840
- 性能门禁**:用真 Chrome 量「5000 行首屏耗时 / DOM 规模」与「FOLLOW 突发 20000 行时的处理
841
- 耗时与事件循环最大延迟」,超预算即非零退出(当前实测约 234ms / 10000 节点、突发 930ms /
842
- 最大延迟 55ms)。它依赖 tty 的预览夹具(`node packages/tty/scripts/preview.mjs` 生成),
843
- 夹具或 Chrome 缺失时**跳过并 exit 0** —— 它是 npm script,不是 vitest 必跑项。
844
-
845
- `test/streams.test.ts`(33 例)覆盖**统计流 / 事件流 / 拉取流 / 网络卷 / 通用 SSE 基建**:
902
+ 性能门禁**:用真 Chrome 量四个场景——「5000 行首屏耗时 / DOM 规模」、「FOLLOW 突发 20000 行
903
+ 的处理耗时与事件循环最大延迟」、「事件洪泛」(同一 6.2k 行/秒下每事件 1 行 vs 每事件 50 行,
904
+ 量的就是 D151 合掉的那笔每事件固定开销)、以及「**持续洪泛** 20k 行/秒 × 6s」的稳态
905
+ (缓冲满了之后每帧都在淘汰 + 插入,这才是用户报的「日志一快整页就卡」),并**校验两件容易被误解的事**:① 快照被「输出上限(KB)」字节闸截断时**必须有**「已截断」黄色横幅
906
+ (否则「LINES 选 1000 只回来 985 行」会被当成丢行——实测那 985 行是 docker 自己的记录粒度,不是丢行);
907
+ ② 「上滚暂停跟随」只认**真手势**(wheel / 触摸 / 滚动条 / 键盘):真手势上滚必须出现
908
+ 「回到底部」且点一下能恢复,没有手势的位置漂移(洪泛里的夹紧/抖动)**必须不停跟随**;
909
+ ③ **洪泛中驻留历史**(D154):跟随流没停、缓冲还在淘汰最旧的行时驻留 1.2s,可见区顶行
910
+ 必须还是同一行(环形缓冲淘汰的位移没补偿的话,读历史会自动「倒带」);**换代**
911
+ (D155):快照先刷成一代会折行的高行并扫位量进缓存、再刷回普通行,垫高必须由**这一代**
912
+ 的高度算出来(不作废缓存则滚动高虚高 35644px);
913
+ ④ **校验窗口算得对**:
914
+ 快照内容是确定的,滚到顶第一行必须是 `seq=0`、滚到底最后一行必须是 `seq=4999`,洪泛结束后
915
+ 最后一行必须是最新行(贴底跟随还生效)。超预算即非零退出。D152 之后的实测:
916
+ 5000 行快照 179ms / **229 个正文节点**(此前 ~10000)、挂 53~57 行;突发 20k 行 933ms /
917
+ 最大延迟 **2ms**(此前 52~97ms);事件洪泛 10ms vs 7ms;持续洪泛 20k 行/秒 × 6s
918
+ 最大延迟 **6ms**、>50ms 长帧 **0 个**(此前 70~97ms / 25~26 个)、213 节点。预算里另有一条
919
+ 「挂载行数 ≤ 200」——虚拟滚动失效会立刻变红。它依赖 tty 的预览夹具
920
+ (`node packages/tty/scripts/preview.mjs` 生成),夹具或 Chrome 缺失时**跳过并 exit 0**
921
+ —— 它是 npm script,不是 vitest 必跑项。
922
+
923
+ `test/streams.test.ts`(35 例)覆盖**统计流 / 事件流 / 拉取流 / 网络卷 / 通用 SSE 基建**:
846
924
  `statsStream` 不带 `--no-stream`(与快照同一构造点)、`pullStream` 的
847
925
  `assertImageRef` 白名单、`/stats/stream` 把逐行 JSON(含跨 chunk 的半行)归一成
848
926
  `stats` 事件且形状与 `/stats` 快照一致、心跳、客户端断开静默中止、
@@ -1114,6 +1114,12 @@
1114
1114
  font-family: var(--dk-mono);
1115
1115
  font-size: 12px;
1116
1116
  line-height: 1.6;
1117
+ /*
1118
+ * 关掉浏览器的滚动锚定(D152):窗口化会把垫高与行**成批地换掉**,Chrome 的锚定
1119
+ * 会挑一个节点把 scrollTop 拽回去,跟「贴底钉住 / 锚点修正」互相打架——表现就是
1120
+ * 位置漂一下、甚至整屏空白。位置上的一切由 useLogRows 负责,不需要浏览器再猜。
1121
+ */
1122
+ overflow-anchor: none;
1117
1123
  }
1118
1124
 
1119
1125
  .dk_logLine {
@@ -1131,9 +1137,11 @@
1131
1137
  *
1132
1138
  * D63 根治(第二轮)走了另一条路:行 key 用单调 id + 缓冲行数/字节双限 +
1133
1139
  * 150ms 合帧(见 index.js 与 log-buffer.js)——渲染频率与 chunk 速率解耦、
1134
- * 内存有界,DOM 按 LINES 全量渲染(显示层不截断,D129),精确 scrollHeight
1135
- * 保住了贴底判定。content-visibility 依旧没有开的理由;再想压 DOM 就做真
1136
- * 虚拟化(DEFECTS.md 待办),别走估算高度的回头路。
1140
+ * 内存有界。D152 起再叠一层**窗口化挂载**:DOM 里只留可见的几十行 + 上下垫高
1141
+ * (`.dk_logPad`),「显示层不截断」落在**数据**上——全部行都能滚到、都能导出,
1142
+ * 只是不再同时挂在 DOM 里。精确 scrollHeight 由实测行高 + 贴底锚定保住(见
1143
+ * log-window.js),所以贴底判定依旧成立;content-visibility 依旧没有开的理由
1144
+ * (D91 的估算高度会把它打穿)。
1137
1145
  */
1138
1146
  }
1139
1147
 
@@ -1143,6 +1151,17 @@
1143
1151
  border-radius: 2px;
1144
1152
  }
1145
1153
 
1154
+ /*
1155
+ * 窗口化挂载的上下垫高(D152):没挂出来的行由它占位,高度 = 那些行的前缀和。
1156
+ * 必须 `flex: none` / 不参与换行——它是纯占位,不该被压缩,也不该被选中或读屏读到
1157
+ * (元素上带 aria-hidden)。`content-visibility` 依旧不要开(D91)。
1158
+ */
1159
+ .dk_logPad {
1160
+ flex: none;
1161
+ pointer-events: none;
1162
+ user-select: none;
1163
+ }
1164
+
1146
1165
  .dk_logLevel {
1147
1166
  margin-right: var(--dk-gap-sm);
1148
1167
  font-weight: 600;