@hyzyn/dsh-docker 0.9.2 → 0.9.3

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.
@@ -20,6 +20,7 @@ import dockerCss from './docker.css'
20
20
  import { bookSessionHost, pickTargetByHost, sessionHostPort, staleBookRef } from './session-target.js'
21
21
  import { currentSessionIdOf } from './current-session.js'
22
22
  import { createLogBuffer, splitLogLines } from './log-buffer.js'
23
+ import { createLogWindow, LOG_ROW_ESTIMATE_PX, LOG_WINDOW_OVERSCAN, LOG_HEIGHT_CACHE_LIMIT } from './log-window.js'
23
24
  import { subscribeLogStream, reconnectTail, LOG_RECONNECT_BASE_MS, LOG_RECONNECT_MAX_MS } from './log-stream.js'
24
25
 
25
26
  /* ================================ 国际化 ================================ */
@@ -138,6 +139,8 @@ const I18N_ZH = {
138
139
  'meta.exitCodeNote': '(退出码 {code})',
139
140
  'status.streamEndedSnapshot': ',日志流结束,已切回快照',
140
141
  'hint.logBacklog': '主机侧日志积压超出上限(推送速度超过浏览器消费速度),已断开并重连;重连只补新行,不重复历史',
142
+ 'hint.logSkipped': '主机侧积压,已跳过 {frames} 批较早的日志(内容不连续);跟随继续,不用重连',
143
+ 'hint.logSkippedNoCount': '主机侧积压,已跳过一批较早的日志(内容不连续);跟随继续,不用重连',
141
144
  'status.streamStoppedReconnecting': '服务端已停止日志流,正在重连…',
142
145
  'status.statsFollowing': '实时跟随中(docker stats)',
143
146
  'status.statsConnecting': '正在连接统计流…',
@@ -613,6 +616,8 @@ const I18N_EN = {
613
616
  'meta.exitCodeNote': ' (exit code {code})',
614
617
  'status.streamEndedSnapshot': ', log stream ended, back to the snapshot',
615
618
  'hint.logBacklog': 'Host-side log backlog exceeded the limit (the push rate outran the browser); disconnected and reconnecting. A reconnect only appends new lines, history is not replayed',
619
+ 'hint.logSkipped': 'Host-side backlog: {frames} batches of earlier logs were skipped (content is not contiguous); following continues, no reconnect needed',
620
+ 'hint.logSkippedNoCount': 'Host-side backlog: a batch of earlier logs was skipped (content is not contiguous); following continues, no reconnect needed',
616
621
  'status.streamStoppedReconnecting': 'The server stopped the log stream, reconnecting…',
617
622
  'status.statsFollowing': 'Live (docker stats)',
618
623
  'status.statsConnecting': 'Connecting to the stats stream…',
@@ -1946,7 +1951,7 @@ window.__ModuleLoader__.load({
1946
1951
  const { jsx, jsxs } = require('react/jsx-runtime')
1947
1952
  const { createRoot } = require('react-dom/client')
1948
1953
 
1949
- const { useState, useEffect, useRef, useCallback, useMemo } = React
1954
+ const { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } = React
1950
1955
 
1951
1956
  /** 日志高亮:把匹配片段包成 <mark>(React 元素,不走 innerHTML)。 */
1952
1957
  function highlight(text, query, keyPrefix) {
@@ -2448,9 +2453,16 @@ window.__ModuleLoader__.load({
2448
2453
  /**
2449
2454
  * 行 key = 行的单调 id(D63 根治):旧实现用渲染下标当 key,环形缓冲一滑动
2450
2455
  * 每行内容全变 → 每帧全量 reconcile;id 稳定后窗口滑动只挂载/卸载边界行。
2456
+ *
2457
+ * `data-log-row` 是窗口化挂载(D152)用来**量这一行实测高度**的锚点:渲染出来的行
2458
+ * 才有这个属性,量高与锚点修正都只认它(见 useLogRows)。
2451
2459
  */
2452
2460
  function renderLogLine(entry, query) {
2453
- return jsxs('div', { className: 'dk_logLine', children: renderLogParts(entry.text, entry.id, query) }, String(entry.id))
2461
+ return jsxs('div', {
2462
+ className: 'dk_logLine',
2463
+ 'data-log-row': String(entry.id),
2464
+ children: renderLogParts(entry.text, entry.id, query),
2465
+ }, String(entry.id))
2454
2466
  }
2455
2467
 
2456
2468
  /** Compose 聚合日志行:在标准日志行前加一个 `[service]` 前缀。 */
@@ -2460,6 +2472,8 @@ window.__ModuleLoader__.load({
2460
2472
  : null
2461
2473
  return jsxs('div', {
2462
2474
  className: 'dk_logLine',
2475
+ // 窗口化挂载(D152)量高用的锚点,与单容器视图同一个属性
2476
+ 'data-log-row': String(entry.id),
2463
2477
  // 时间戳只在这里能拿到:entry.ts 是 epoch,而 showTs 关着时 DOM 里没有它。
2464
2478
  // 右键「问 Agent」要用它组诊断包的时间窗,所以挂在 dataset 上(一个数字,代价可忽略)。
2465
2479
  'data-log-ts': typeof entry.ts === 'number' && Number.isFinite(entry.ts) ? String(entry.ts) : undefined,
@@ -2471,6 +2485,258 @@ window.__ModuleLoader__.load({
2471
2485
  }, String(entry.id))
2472
2486
  }
2473
2487
 
2488
+ /**
2489
+ * 日志正文的窗口化挂载(D152 虚拟滚动):两个日志视图共用的一份。
2490
+ *
2491
+ * 为什么:D151 之后持续洪泛剩下的唯一大账是**挂载 5000 行的 DOM churn**(实测
2492
+ * 20k 行/秒下 25~26 个 >50ms 长帧 / 6s)。这里只挂「可见 + 上下 overscan」的
2493
+ * 几十行,其余用上下垫高表示——DOM 从 ~10000 节点掉到 ~500。策略与取舍(估算高度
2494
+ * 只出现在视口之外、贴底时窗口锚在列表尾部因而精确、往上滚历史用锚点修正抵消位移)
2495
+ * 全部写在 `client-src/log-window.js` 的头注释里。
2496
+ *
2497
+ * 三件事在这里合并成一次**绘制前**的布局提交,所以看不到抖动:
2498
+ * 1. 量高:量刚渲染出来的行的实测高度(`offsetHeight`);
2499
+ * 2. 修正:贴底就重新钉到底(尾部实测 → `scrollHeight` 精确),否则按可见区顶行
2500
+ * 做锚点修正(`body.scrollTop += delta`);
2501
+ * 3. 重排:高度变了就重算垫高(`setTick`,React 会在同一帧绘制前同步重渲染)。
2502
+ *
2503
+ * @param bodyRef 日志正文容器(滚动容器)
2504
+ * @param entries 已经过滤好的行(`matched`)——窗口只在这份列表上算
2505
+ * @param options `pin=true` 时列表一变就把视口钉到最底(跟随且用户贴底);
2506
+ * `resetKey` 说明这一屏行属于哪一代缓冲(跟随传缓冲对象、快照传 `logs` 响应对象)
2507
+ * ——身份一变就整代作废实测高度与锚点(D155),漏传时退回「有行 → 空」兜底
2508
+ */
2509
+ function useLogRows(bodyRef, entries, options) {
2510
+ const pin = options !== undefined && options.pin === true
2511
+ const resetKey = options === undefined ? undefined : options.resetKey
2512
+ const winRef = useRef(null)
2513
+ if (winRef.current === null) winRef.current = createLogWindow()
2514
+ const [scrollTop, setScrollTop] = useState(0)
2515
+ const [viewH, setViewH] = useState(0)
2516
+ const [atBottom, setAtBottom] = useState(true)
2517
+ const [, setTick] = useState(0)
2518
+ const rafRef = useRef(null)
2519
+ /**
2520
+ * 上一次同步时看到的几何。用来区分两种「scrollTop 变小」:
2521
+ * - 用户真的往上滚(内容没变短)→ 停掉跟随(D61);
2522
+ * - 内容变短 / 面板高度变了导致浏览器**夹紧**位置(banner 一出现就会)→ 不算上滚,
2523
+ * 继续贴底。D152 的第一版没区分,结果「主机侧积压」banner 一弹,跟随就悄悄停了,
2524
+ * 而窗口还按状态里的旧位置渲染 → 正文空白(用户现场截图就是这个)。
2525
+ */
2526
+ const geomRef = useRef({ top: 0, height: 0 })
2527
+ /** `atBottom` 的即时值:layout effect 里要在同一批里读到刚同步出来的锁存。 */
2528
+ const atBottomRef = useRef(true)
2529
+ /**
2530
+ * 最近一次**用户手势**的时间戳(轮盘 / 触摸 / 拖滚动条 / 键盘)。
2531
+ *
2532
+ * 为什么不能只看几何:洪泛稳态下每帧都在淘汰旧行、插入新行,行高还随内容千差万别,
2533
+ * `scrollTop` 会因为浏览器夹紧、我们自己的贴底、锚点修正而变——光看「位置变小了」
2534
+ * 会把主机侧的抖动误判成「用户上滚」,于是跟随悄悄停掉、右下角冒出「回到底部」。
2535
+ * 现在只有**真的有人滚**(1.5s 内有过手势)才允许停掉跟随;没手势就一律继续贴底。
2536
+ */
2537
+ const gestureRef = useRef(0)
2538
+ /**
2539
+ * 跨提交的稳定锚(D154):上一帧「应该钉在原地不动」的那一行(id + 它当时的偏移)。
2540
+ *
2541
+ * 为什么不能只用本次渲染的 `win.anchorOffset`:那两次测量都在**同一次布局**里,
2542
+ * 差值只包含「量高修正」——洪泛稳态下环形缓冲每帧都在淘汰最旧的行,锚点**上方**
2543
+ * 的总高持续变短,整个内容上移,读历史成了自动「倒带」,而这份位移一点都补不到。
2544
+ * 现在每帧把锚(当前可见区顶行)的**绝对位置**记下来,下一帧用 `reanchor` 相对
2545
+ * 这份记录补偿——淘汰、量高修正、聚合视图按时间插行,三类位移全部被同一个差值吸收。
2546
+ * 贴底时置 null(绝对定位钉尾,无需补偿)。
2547
+ *
2548
+ * **它依赖的不变量**(改 id 规则前先看这里):跨代补偿的前提是「id 撞车 = 同一行」,
2549
+ * 而这靠两层保证——① 快照行的 id 是 `'s' + index`(字符串)、跟随行与聚合行的 id 是
2550
+ * 缓冲里的数字,两代**天然不撞**;② 换代时 `resetKeyRef` 一定先作废锚(见下)。
2551
+ * 破了任一条,锚就可能落到另一代同 id 的陌生行上,补偿量会把视口推飞——**快照刷新
2552
+ * 就是这种情形**:快照 id 是位置寻址(`'s'+index`),刷新后同一 id 换成了别的内容,
2553
+ * 同代替换若不换代,跨代补偿就会把视口推到随机位置。
2554
+ */
2555
+ const anchorRef = useRef(null)
2556
+ /**
2557
+ * 换代号(D155):调用方用 `options.resetKey` 说明「这一屏行属于哪一代缓冲」——
2558
+ * 跟随流是缓冲对象身份(重开流就是新对象)、快照是 `logs` 响应对象(每次刷新都是
2559
+ * 新对象)。身份变化 = 行 id 会重来或位移,实测高度与锚点必须整代作废。
2560
+ */
2561
+ const resetKeyRef = useRef(undefined)
2562
+ /**
2563
+ * 上一帧有没有行:兜底的换代信号。调用方漏传 `resetKey` 时,「有行 → 空 → 有行」
2564
+ * (两个视图重开流都会先把行清空)仍能把高度缓存作废(D155,log-window `clear()`
2565
+ * 的契约),否则上一代容器行的实测高度会被套到这一代同 id 的陌生行上。
2566
+ */
2567
+ const hadRowsRef = useRef(false)
2568
+
2569
+ /*
2570
+ * 手势标记走 React 事件(不自己 addEventListener):日志页不是首屏 tab,挂载时
2571
+ * `bodyRef.current` 还是 null,用 effect 挂监听**永远挂不上**——那样「上滚暂停跟随」
2572
+ * 会整个失效(这条是门禁抓出来的:真手势上滚后按钮不出现)。
2573
+ */
2574
+ const markGesture = () => {
2575
+ gestureRef.current = Date.now()
2576
+ }
2577
+ const gestureHandlers = {
2578
+ onWheel: markGesture,
2579
+ onTouchStart: markGesture,
2580
+ onTouchMove: markGesture,
2581
+ onPointerDown: markGesture,
2582
+ onKeyDown: markGesture,
2583
+ }
2584
+
2585
+ /**
2586
+ * 把 DOM 当**唯一真相源**同步回状态:滚动位置、视口高度、贴底锁存。
2587
+ * 浏览器的滚动位置会在我们没参与的时候变(夹紧 / 我们自己的贴底 / 面板高度变化),
2588
+ * 每次提交都同步一次,窗口才不会按一个不存在的旧位置渲染。
2589
+ *
2590
+ * @returns {boolean} 有没有任何一项变了(调用方据此排一次绘制前重排)
2591
+ */
2592
+ const syncFromDom = (body) => {
2593
+ const top = body.scrollTop
2594
+ const height = body.scrollHeight
2595
+ const client = body.clientHeight
2596
+ const last = geomRef.current
2597
+ const movedUp = top < last.top - 4
2598
+ const shrank = height < last.height - 4
2599
+ const gestureRecent = Date.now() - gestureRef.current < 1500
2600
+ geomRef.current = { top, height }
2601
+ setScrollTop(top)
2602
+ setViewH(client)
2603
+ let latchChanged = false
2604
+ if (movedUp && !shrank && gestureRecent) {
2605
+ latchChanged = atBottomRef.current
2606
+ atBottomRef.current = false
2607
+ setAtBottom(false)
2608
+ } else if (height - top - client < 24) {
2609
+ latchChanged = !atBottomRef.current
2610
+ atBottomRef.current = true
2611
+ setAtBottom(true)
2612
+ }
2613
+ return top !== last.top || height !== last.height || client !== viewH || latchChanged
2614
+ }
2615
+
2616
+ /*
2617
+ * 面板拖宽 / 折叠 / 字号变化都会让换行与行高变:只用来催一次重排,具体数值一律
2618
+ * 由 layout effect 从 DOM 读(别再维护第二份 viewH 来源)。
2619
+ */
2620
+ useEffect(() => {
2621
+ const body = bodyRef.current
2622
+ if (body === null || typeof ResizeObserver !== 'function') return undefined
2623
+ const observer = new ResizeObserver(() => setTick((value) => value + 1))
2624
+ observer.observe(body)
2625
+ return () => {
2626
+ observer.disconnect()
2627
+ // 卸载时排队的 rAF 一起取消:回调里的 syncFromDom 会对着已卸载的组件 setState
2628
+ if (rafRef.current !== null) {
2629
+ cancelAnimationFrame(rafRef.current)
2630
+ rafRef.current = null
2631
+ }
2632
+ }
2633
+ }, [bodyRef])
2634
+
2635
+ /** 滚动:rAF 合帧,一帧最多一次同步(滚动是 60Hz 级的事件源)。 */
2636
+ const onScroll = (event) => {
2637
+ const body = event.currentTarget
2638
+ if (rafRef.current !== null) return
2639
+ const run = () => {
2640
+ rafRef.current = null
2641
+ syncFromDom(body)
2642
+ }
2643
+ if (typeof requestAnimationFrame === 'function') rafRef.current = requestAnimationFrame(run)
2644
+ else run()
2645
+ }
2646
+
2647
+ const scrollToBottom = () => {
2648
+ const body = bodyRef.current
2649
+ atBottomRef.current = true
2650
+ setAtBottom(true)
2651
+ if (body === null) return
2652
+ body.scrollTop = body.scrollHeight
2653
+ geomRef.current = { top: body.scrollTop, height: body.scrollHeight }
2654
+ }
2655
+
2656
+ const list = Array.isArray(entries) ? entries : []
2657
+ const win = winRef.current.layout(list, {
2658
+ scrollTop,
2659
+ viewportHeight: viewH,
2660
+ pinned: pin && atBottom,
2661
+ })
2662
+
2663
+ /*
2664
+ * 每次提交后的三件事,全在**绘制前**完成(layout effect),所以看不到抖动:
2665
+ * 1. 同步 DOM 真相(滚动位置 / 视口高度 / 贴底锁存);
2666
+ * 2. 量刚渲染出来的行(实测高度进缓存);
2667
+ * 3. 位置修正:贴底就钉到最底(尾部实测 → scrollHeight 精确),否则按可见区顶行
2668
+ * 补偿(`scrollTop += delta`);任何一项变了就用 setTick 在同帧重排垫高。
2669
+ *
2670
+ * 无依赖数组:滚动驱动的提交、flush 驱动的提交、面板变化都要走同一段逻辑;
2671
+ * 没变化时它会立刻返回(不 setTick),不会自激。
2672
+ */
2673
+ useLayoutEffect(() => {
2674
+ const body = bodyRef.current
2675
+ if (body === null) return
2676
+ const cache = winRef.current
2677
+ const beforeTop = body.scrollTop
2678
+ const beforeHeight = body.scrollHeight
2679
+ // 换代检测(D155):主判据是调用方给的 `resetKey`(这一屏行属于哪一代缓冲)——
2680
+ // 重开流、换容器、**快照刷新**都会换身份;兜底再认「有行 → 空」(漏传 resetKey 时
2681
+ // 仍能自愈;过滤把 matched 清空也会走到这,清了重测无害)。行 id 是位置寻址
2682
+ // (快照 `'s'+index`),不换代就会把上一代第 N 行的高度/锚点套到陌生内容上。
2683
+ if (resetKey !== resetKeyRef.current) {
2684
+ resetKeyRef.current = resetKey
2685
+ cache.clear()
2686
+ anchorRef.current = null
2687
+ }
2688
+ if (list.length === 0 && hadRowsRef.current) {
2689
+ cache.clear()
2690
+ anchorRef.current = null
2691
+ }
2692
+ hadRowsRef.current = list.length > 0
2693
+ const synced = syncFromDom(body)
2694
+ let changed = false
2695
+ for (const el of body.querySelectorAll('[data-log-row]')) {
2696
+ const id = el.getAttribute('data-log-row')
2697
+ if (id === null) continue
2698
+ if (cache.measure(id, el.offsetHeight)) changed = true
2699
+ }
2700
+ if (pin && atBottomRef.current) {
2701
+ body.scrollTop = body.scrollHeight
2702
+ anchorRef.current = null
2703
+ } else {
2704
+ /*
2705
+ * 锚点补偿(D154):相对**上一帧记录的锚**算位移。旧实现只补「量高」引起的
2706
+ * 位移、且以本次渲染自己的 anchorOffset 为基准——环形缓冲淘汰旧行造成的位移
2707
+ * (锚上方总高变短)一分都补不到,洪泛中读历史就持续上飘。
2708
+ */
2709
+ const prev = anchorRef.current
2710
+ if (prev !== null) {
2711
+ const delta = cache.reanchor(list, prev.id, prev.offset)
2712
+ if (delta !== 0) body.scrollTop += delta
2713
+ }
2714
+ // 记下本帧的锚:可见区顶行 + 它在**实测后**缓存下的绝对偏移
2715
+ const anchorId = win.anchorId
2716
+ const anchorOffset = cache.offsetOf(list, anchorId)
2717
+ anchorRef.current = anchorOffset === null ? null : { id: anchorId, offset: anchorOffset }
2718
+ }
2719
+ const afterTop = body.scrollTop
2720
+ const afterHeight = body.scrollHeight
2721
+ if (changed || synced || afterTop !== beforeTop || afterHeight !== beforeHeight) {
2722
+ geomRef.current = { top: afterTop, height: afterHeight }
2723
+ setTick((value) => value + 1)
2724
+ }
2725
+ })
2726
+
2727
+ return {
2728
+ start: win.start,
2729
+ end: win.end,
2730
+ topPad: win.topPad,
2731
+ bottomPad: win.bottomPad,
2732
+ total: win.total,
2733
+ atBottom,
2734
+ onScroll,
2735
+ scrollToBottom,
2736
+ gestureHandlers,
2737
+ }
2738
+ }
2739
+
2474
2740
  /* ------------------------------------------------------------------ *
2475
2741
  * 日志 → 会话桥:右键把选中的错误交给 Agent
2476
2742
  *
@@ -3043,8 +3309,15 @@ window.__ModuleLoader__.load({
3043
3309
  const [followError, setFollowError] = useState('')
3044
3310
  const [followNotice, setFollowNotice] = useState('')
3045
3311
  const [followDropped, setFollowDropped] = useState(false)
3046
- const [followAtBottom, setFollowAtBottom] = useState(true)
3312
+ // 贴底状态(原 followAtBottom)搬到 useLogRows(D152):它与滚动位置、视口高度、
3313
+ // 行高缓存是同一份状态,分散在两边必然漂。
3047
3314
  const followBufRef = useRef(null)
3315
+ /**
3316
+ * 本次连接累计被主机跳过的帧数(D157):skip 通知是**增量**的(服务端每丢一批报
3317
+ * 一批),提示文案要的是**累计**值——只显示最后一批会少报,长洪泛里「跳过了 1 批」
3318
+ * 与实际相去甚远。连接重开(onStatus 'open')时清零,语义是「本次连接共跳过多少」。
3319
+ */
3320
+ const followSkippedRef = useRef(0)
3048
3321
  const followDirtyRef = useRef(false)
3049
3322
  const followTimerRef = useRef(null)
3050
3323
 
@@ -3163,7 +3436,8 @@ window.__ModuleLoader__.load({
3163
3436
  setFollowDropped(false)
3164
3437
  setFollowError('')
3165
3438
  setFollowNotice('')
3166
- setFollowAtBottom(true)
3439
+ // 重开流 = 内容清空重来:贴底状态一并复位(D92),新行到了仍然贴着底
3440
+ logRows.scrollToBottom()
3167
3441
  setFollowStatus('connecting')
3168
3442
 
3169
3443
  /**
@@ -3193,10 +3467,26 @@ window.__ModuleLoader__.load({
3193
3467
  }),
3194
3468
  tail: logOptions.tail,
3195
3469
  onStatus: (status) => {
3196
- if (status === 'open') setFollowNotice('')
3470
+ if (status === 'open') {
3471
+ // 重连成功 = 新的一段连接:跳过计数与提示一并清零(D157)
3472
+ followSkippedRef.current = 0
3473
+ setFollowNotice('')
3474
+ }
3197
3475
  setFollowStatus(status)
3198
3476
  },
3199
3477
  onLine: pushChunk,
3478
+ /*
3479
+ * 主机侧背压丢帧(D153):流没断,只是中间少了一截——用状态行上方的提示
3480
+ * 说明白(不弹错误横幅、也不重连;重连只会白丢一次历史)。计数**累加**
3481
+ * 而不是覆盖(D157):skip 是增量通知,覆盖会少报。
3482
+ */
3483
+ onSkip: (payload) => {
3484
+ const frames = payload !== null && typeof payload.frames === 'number' ? payload.frames : null
3485
+ if (frames !== null) followSkippedRef.current += frames
3486
+ setFollowNotice(frames === null
3487
+ ? t('hint.logSkippedNoCount')
3488
+ : t('hint.logSkipped', { frames: followSkippedRef.current }))
3489
+ },
3200
3490
  onEnd: (payload, controls) => {
3201
3491
  const reason = payload !== null && typeof payload.reason === 'string' ? payload.reason : 'container-exit'
3202
3492
  const code = payload !== null && typeof payload.code === 'number' ? payload.code : null
@@ -3235,13 +3525,8 @@ window.__ModuleLoader__.load({
3235
3525
  // `docker logs -f` 会一直占着共享的 SSH 通道额度——与 S3 的设计意图相反
3236
3526
  }, [active, tab, follow, props.target, item.id, logOptions.tail, logOptions.timestamps, loadLogs])
3237
3527
 
3238
- // FOLLOW 自动贴底;用户往上滚后暂停,显示「回到底部」
3239
- useEffect(() => {
3240
- if (tab !== 'logs' || !follow || !followAtBottom) return
3241
- const body = logBodyRef.current
3242
- if (body === null) return
3243
- body.scrollTop = body.scrollHeight
3244
- }, [active, tab, follow, followAtBottom, followLines])
3528
+ // FOLLOW 自动贴底 / 用户上滚后暂停 / 行高实测与锚点修正:全部搬进 useLogRows(D152)——
3529
+ // 那里是「绘制前一帧」的 layout effect,垫高与滚动位置在同一次提交里落定,不闪。
3245
3530
 
3246
3531
  /** FOLLOW 与 AUTO REFRESH 互斥:开流停轮询;关流立即回快照。 */
3247
3532
  const toggleFollow = () => {
@@ -3279,17 +3564,11 @@ window.__ModuleLoader__.load({
3279
3564
  return t('status.statsStream')
3280
3565
  }
3281
3566
 
3282
- const scrollToBottom = () => {
3283
- const body = logBodyRef.current
3284
- if (body !== null) body.scrollTop = body.scrollHeight
3285
- setFollowAtBottom(true)
3286
- }
3287
-
3288
- const onLogScroll = (event) => {
3289
- if (!follow) return
3290
- const body = event.currentTarget
3291
- setFollowAtBottom(body.scrollHeight - body.scrollTop - body.clientHeight < 24)
3292
- }
3567
+ /*
3568
+ * 贴底 / 滚动状态由 useLogRows 统一维护(D152):滚动位置、视口高度、atBottom 与
3569
+ * 「回到底部」都从那里取——两个日志视图因此是同一套滚动语义。调用点在下面
3570
+ * logStatsValue 之后(窗口要在过滤后的行列表上算)。
3571
+ */
3293
3572
 
3294
3573
  /** 连接状态文案(连接层错误只动这里,不进错误横幅)。 */
3295
3574
  const followStatusText = () => {
@@ -3503,6 +3782,24 @@ window.__ModuleLoader__.load({
3503
3782
  }, [follow, followLines, snapshotEntriesValue, logFilter, levelMin])
3504
3783
  const logStats = () => logStatsValue
3505
3784
 
3785
+ /*
3786
+ * 贴底 / 滚动状态由 useLogRows 统一维护(D152):滚动位置、视口高度、atBottom 与
3787
+ * 「回到底部」都从那里取——两个日志视图因此是同一套滚动语义。
3788
+ *
3789
+ * 位置在这里而不是更早:窗口要在**过滤之后**的行列表上算(`matched`),所以必须
3790
+ * 等 logStatsValue 出来;上面那些 effect 里对 logRows 的引用都在 effect 体内(渲染
3791
+ * 结束后才执行),不会踩 TDZ。
3792
+ */
3793
+ const logRows = useLogRows(logBodyRef, logStatsValue.matched, {
3794
+ pin: follow,
3795
+ /*
3796
+ * 这一屏行属于哪一代(D155):跟随 → 缓冲对象身份(重开流即新对象);快照 →
3797
+ * `logs` 响应对象身份(每次刷新都是新对象,行 id 是位置寻址 `'s'+index`,
3798
+ * 刷新后同一 id 就是另一行内容,不换代高度与锚点都会算错)。
3799
+ */
3800
+ resetKey: follow ? followBufRef.current : logs,
3801
+ })
3802
+
3506
3803
  const logPill = (on, label, onClick, options) => jsx('button', {
3507
3804
  type: 'button',
3508
3805
  className: 'dk_pill' + ((options?.className) ?? ''),
@@ -3673,7 +3970,12 @@ window.__ModuleLoader__.load({
3673
3970
  // 可聚焦(D64):键盘用户 Tab 进来才能滚动日志、用键盘触发「问 Agent」
3674
3971
  tabIndex: 0,
3675
3972
  'aria-label': t('panel.containerLogs'),
3676
- onScroll: onLogScroll,
3973
+ // 行数真值挂在 DOM 上:窗口化之后 DOM 里只有几十行,外部(性能门禁 / 诊断)
3974
+ // 不能再用 `.dk_logLine` 计数来判断「视图里有多少行」(D152)
3975
+ 'data-log-total': String(matched.length),
3976
+ onScroll: logRows.onScroll,
3977
+ // 「用户真的滚了」的手势来源(D152 加固):只有这些事件才允许停掉贴底跟随
3978
+ ...logRows.gestureHandlers,
3677
3979
  // 右键「问 Agent」:单容器视图里上下文就是当前这一条容器
3678
3980
  onContextMenu: (event) => onLogContextMenu(event, logBodyRef.current, {
3679
3981
  target: props.target,
@@ -3688,11 +3990,21 @@ window.__ModuleLoader__.load({
3688
3990
  ? jsx('div', { className: 'dk_logLine', children: t('list.loading') }, 'loading')
3689
3991
  : (matched.length === 0
3690
3992
  ? jsx('div', { className: 'dk_logLine', children: follow ? t('list.waitingLogs') : (needle === '' ? t('list.noLogs') : t('list.noMatchingLogs')) }, 'empty')
3691
- : matched.map((entry) => renderLogLine(entry, needle))),
3993
+ : [
3994
+ // 上下垫高 = 没挂出来的那些行的占位(虚拟滚动,D152)。`aria-hidden`
3995
+ // 让读屏跳过它们;overscan ≥ 右键「问 Agent」要的前后 20 行上下文。
3996
+ logRows.topPad > 0
3997
+ ? jsx('div', { className: 'dk_logPad', style: { height: String(logRows.topPad) + 'px' }, 'aria-hidden': 'true' }, 'padTop')
3998
+ : null,
3999
+ ...matched.slice(logRows.start, logRows.end + 1).map((entry) => renderLogLine(entry, needle)),
4000
+ logRows.bottomPad > 0
4001
+ ? jsx('div', { className: 'dk_logPad', style: { height: String(logRows.bottomPad) + 'px' }, 'aria-hidden': 'true' }, 'padBottom')
4002
+ : null,
4003
+ ]),
3692
4004
  ],
3693
4005
  }),
3694
- follow && !followAtBottom
3695
- ? jsx('button', { type: 'button', className: 'dk_backToBottom', onClick: scrollToBottom, children: t('btn.backToBottom') })
4006
+ follow && !logRows.atBottom
4007
+ ? jsx('button', { type: 'button', className: 'dk_backToBottom', onClick: logRows.scrollToBottom, children: t('btn.backToBottom') })
3696
4008
  : null,
3697
4009
  ] })
3698
4010
  }
@@ -4625,6 +4937,10 @@ window.__ModuleLoader__.load({
4625
4937
  const [paused, setPaused] = useState(false)
4626
4938
  const [bufferedCount, setBufferedCount] = useState(0)
4627
4939
  const [dropped, setDropped] = useState(false)
4940
+ /** 主机侧背压丢帧的提示(D153):流没断,只是中间少了一截——聚合进状态行。 */
4941
+ const [skipNotice, setSkipNotice] = useState('')
4942
+ /** 本次连接累计被跳过的帧数(D157):skip 是增量通知,提示要的是累计值,覆盖会少报。 */
4943
+ const aggSkippedRef = useRef(0)
4628
4944
  /** 显示每行时间戳(默认关:聚合看内容为主,时间戳会占宽度)。 */
4629
4945
  const [showTs, setShowTs] = useState(false)
4630
4946
  /** 排序:'arrival' 到达序(默认,零延迟)/ 'time' 按容器时间戳合并(窗口 350ms)。 */
@@ -4653,17 +4969,8 @@ window.__ModuleLoader__.load({
4653
4969
  const itemIds = items.map((item) => item.id).join(',')
4654
4970
  /** 面板是否可见(S3):聚合视图是**每容器一条流**,最占 SSH 通道,优先掐它。 */
4655
4971
  const active = usePanelActive()
4656
- /** 用户是否贴底(D61):上滚看历史时暂停自动贴底,与单容器视图同一套行为。 */
4657
- const [atBottom, setAtBottom] = useState(true)
4658
- const onBodyScroll = (event) => {
4659
- const body = event.currentTarget
4660
- setAtBottom(body.scrollHeight - body.scrollTop - body.clientHeight < 24)
4661
- }
4662
- const backToBottom = () => {
4663
- const body = bodyRef.current
4664
- if (body !== null) body.scrollTop = body.scrollHeight
4665
- setAtBottom(true)
4666
- }
4972
+ // 贴底 / 滚动位置 / 视口高度由 useLogRows(D152)统一维护:与单容器视图同一套行为,
4973
+ // 调用点在下面 `matched` 算出来之后(窗口要在过滤后的列表上算)。
4667
4974
 
4668
4975
  /** 暂停期间的去处:攒进暂停缓冲,DOM 不动(与旧版 push 里的分支同一语义)。 */
4669
4976
  const stashRows = (rows) => {
@@ -4747,11 +5054,12 @@ window.__ModuleLoader__.load({
4747
5054
  setEntries([])
4748
5055
  setBufferedCount(0)
4749
5056
  setDropped(false)
5057
+ setSkipNotice('')
4750
5058
  // 重建流 = 内容清空重来,贴底状态必须一并复位(D92):不复位时,之前上滚看历史
4751
5059
  // 留下的 atBottom=false 会跨过这次重建继续生效,新日志停在顶部不跟随,而状态行
4752
5060
  // 还写着「已连接 N 条容器日志流」——「回到底部」常驻且看着像开关坏了。
4753
- // 单容器视图在同一个位置就做了 setFollowAtBottom(true),这里与它对齐。
4754
- setAtBottom(true)
5061
+ // 单容器视图在同一个位置也做同一件事(D152 起两边共用 useLogRows.scrollToBottom)。
5062
+ aggRows.scrollToBottom()
4755
5063
  timeBufRef.current = []
4756
5064
  if (flushTimerRef.current !== null) {
4757
5065
  clearTimeout(flushTimerRef.current)
@@ -4796,12 +5104,24 @@ window.__ModuleLoader__.load({
4796
5104
  if (next === 'connecting') return
4797
5105
  if (next === 'open') {
4798
5106
  open += 1
5107
+ // 任一条容器流重连成功:跳过计数与提示清零(D157,与单容器视图同一语义)
5108
+ aggSkippedRef.current = 0
5109
+ setSkipNotice('')
4799
5110
  setStatus('open')
4800
5111
  return
4801
5112
  }
4802
5113
  if (next === 'reconnecting') setStatus('reconnecting')
4803
5114
  },
4804
5115
  onLine: push,
5116
+ // 主机侧背压丢帧(D153):与单容器视图同一份语义,聚合成一句状态行提示;
5117
+ // 计数累加而不是覆盖(D157)——skip 是增量通知,覆盖会少报
5118
+ onSkip: (payload) => {
5119
+ const frames = payload !== null && typeof payload.frames === 'number' ? payload.frames : null
5120
+ if (frames !== null) aggSkippedRef.current += frames
5121
+ setSkipNotice(frames === null
5122
+ ? t('hint.logSkippedNoCount')
5123
+ : t('hint.logSkipped', { frames: aggSkippedRef.current }))
5124
+ },
4805
5125
  onEnd: (payload, controls) => {
4806
5126
  const reason = payload !== null && typeof payload.reason === 'string' ? payload.reason : 'container-exit'
4807
5127
  if (reason === 'container-exit') {
@@ -4831,12 +5151,8 @@ window.__ModuleLoader__.load({
4831
5151
  }
4832
5152
  }, [active, props.target, itemIds, tail])
4833
5153
 
4834
- useEffect(() => {
4835
- // 上滚看历史时不拽回底部(D61):只有贴底时才跟随新行
4836
- if (paused || !atBottom) return
4837
- const body = bodyRef.current
4838
- if (body !== null) body.scrollTop = body.scrollHeight
4839
- }, [paused, entries, atBottom])
5154
+ // 上滚看历史时不拽回底部(D61)/ 贴底时钉到最底:搬进 useLogRows(D152)的 pin——
5155
+ // 那里是绘制前的 layout effect,垫高与滚动位置同一次提交落定;暂停时不 pin(D61)。
4840
5156
 
4841
5157
  /** 暂停 / 恢复:恢复那一刻把缓冲并入(环形上限)并回到底部。 */
4842
5158
  const togglePause = () => {
@@ -4870,6 +5186,18 @@ window.__ModuleLoader__.load({
4870
5186
  // 与单容器视图一致:LINES 选多少就渲染/导出多少,显示层不再截断(缓冲与宿主
4871
5187
  // 输出上限已是闸;再多一道「只显示最近 N 行」只会让选择说了不算)
4872
5188
 
5189
+ /*
5190
+ * 窗口化挂载(D152):DOM 里只挂可见的几十行 + 上下垫高——「显示层不截断」说的是
5191
+ * **数据**(全部行都能滚到、都能导出),不是「全部行都必须同时挂在 DOM 里」。
5192
+ * 暂停时 `entries` 不动,因此窗口也不动(D61 的冻结语义保持)。
5193
+ */
5194
+ const aggRows = useLogRows(bodyRef, matched, {
5195
+ pin: !paused,
5196
+ // 换代信号(D155):聚合行都来自这一份缓冲,缓冲对象一换 = 整代作废
5197
+ resetKey: aggBufRef.current,
5198
+ })
5199
+
5200
+
4873
5201
  /** 切换排序:先把待合并窗口落地,避免切模式时短暂的顺序错乱。 */
4874
5202
  const toggleOrderMode = () => {
4875
5203
  const next = orderMode === 'time' ? 'arrival' : 'time'
@@ -4998,14 +5326,23 @@ window.__ModuleLoader__.load({
4998
5326
  }, 'export'),
4999
5327
  jsx('span', { className: 'dk_filterCount', children: needle === '' && levelMin === 0 ? String(entries.length) + t('meta.rowsSuffix') : String(matched.length) + ' / ' + String(entries.length) + t('meta.rowsSuffix') }),
5000
5328
  ] }),
5001
- jsx('div', { className: 'dk_followState', 'data-state': status === 'open' ? 'open' : (status === 'closed' ? 'closed' : 'connecting'), children: statusText() }),
5329
+ jsx('div', {
5330
+ className: 'dk_followState',
5331
+ 'data-state': status === 'open' ? 'open' : (status === 'closed' ? 'closed' : 'connecting'),
5332
+ // 主机侧丢帧的提示(D153)并进状态行:它表达的是「内容断了一截」,不是错误
5333
+ children: skipNotice === '' ? statusText() : statusText() + ' · ' + skipNotice,
5334
+ }),
5002
5335
  jsx('div', {
5003
5336
  className: 'dk_logBody',
5004
5337
  ref: bodyRef,
5005
5338
  // 可聚焦(D64):键盘用户 Tab 进来才能滚动聚合日志、用键盘触发「问 Agent」
5006
5339
  tabIndex: 0,
5007
5340
  'aria-label': t('panel.aggContainerLogs'),
5008
- onScroll: onBodyScroll,
5341
+ // 行数真值(D152):窗口化之后 DOM 里只有几十行,外部计数一律读它
5342
+ 'data-log-total': String(matched.length),
5343
+ onScroll: aggRows.onScroll,
5344
+ // 同单容器视图:手势是「用户上滚」的唯一凭据(D152 加固)
5345
+ ...aggRows.gestureHandlers,
5009
5346
  // 右键「问 Agent」:聚合视图把本次聚合的容器集合一起交出去,
5010
5347
  // 具体是哪个容器由每行的 [service] 前缀决定
5011
5348
  onContextMenu: (event) => onLogContextMenu(event, bodyRef.current, {
@@ -5017,10 +5354,18 @@ window.__ModuleLoader__.load({
5017
5354
  children: [
5018
5355
  matched.length === 0
5019
5356
  ? jsx('div', { className: 'dk_logLine', children: status === 'open' ? t('list.waitingLogs') : statusText() }, 'empty')
5020
- : matched.map((entry) => renderAggLine(entry, needle, showTs)),
5357
+ : [
5358
+ aggRows.topPad > 0
5359
+ ? jsx('div', { className: 'dk_logPad', style: { height: String(aggRows.topPad) + 'px' }, 'aria-hidden': 'true' }, 'padTop')
5360
+ : null,
5361
+ ...matched.slice(aggRows.start, aggRows.end + 1).map((entry) => renderAggLine(entry, needle, showTs)),
5362
+ aggRows.bottomPad > 0
5363
+ ? jsx('div', { className: 'dk_logPad', style: { height: String(aggRows.bottomPad) + 'px' }, 'aria-hidden': 'true' }, 'padBottom')
5364
+ : null,
5365
+ ],
5021
5366
  ],
5022
5367
  }),
5023
- !paused && !atBottom ? jsx('button', { type: 'button', className: 'dk_backToBottom', onClick: backToBottom, children: t('btn.backToBottom') }) : null,
5368
+ !paused && !aggRows.atBottom ? jsx('button', { type: 'button', className: 'dk_backToBottom', onClick: aggRows.scrollToBottom, children: t('btn.backToBottom') }) : null,
5024
5369
  ] })
5025
5370
  }
5026
5371
 
@@ -8007,6 +8352,16 @@ window.__ModuleLoader__.load({
8007
8352
  PENDING_MAX: LOG_PENDING_MAX,
8008
8353
  FLUSH_MS: FOLLOW_FLUSH_MS,
8009
8354
  }
8355
+ /*
8356
+ * 窗口化挂载(D152)的测试缝:布局是纯逻辑,离线冒烟直接驱动(真 DOM 里的量高与
8357
+ * 滚动位置由 test/log-window.test.ts 与性能门禁覆盖)。常量挂出来供用例对齐。
8358
+ */
8359
+ exports.__logWindow = {
8360
+ create: createLogWindow,
8361
+ ESTIMATE: LOG_ROW_ESTIMATE_PX,
8362
+ OVERSCAN: LOG_WINDOW_OVERSCAN,
8363
+ LIMIT: LOG_HEIGHT_CACHE_LIMIT,
8364
+ }
8010
8365
  /*
8011
8366
  * 断线重连策略的测试缝(D133):真实 EventSource 时序进不了 Node 桩,但「首连带
8012
8367
  * tail、重连带 0」这条规则本身是纯的——用可注入的假 EventSource 驱动一遍,
@@ -8060,13 +8415,50 @@ window.__ModuleLoader__.load({
8060
8415
  },
8061
8416
  }
8062
8417
  setEntryVisible(true)
8063
- // DSH ≥0.1.6-alpha.2:侧边栏「插件」页里该行的配置页。插槽不存在时 inject 不会触发,
8064
- // 因此在旧版上完全无副作用,一份代码同时兼容两代。
8418
+ /*
8419
+ * 设置面:0.2.0-rc.1 起挂 `plugins.bundle.config`——key 是 **bundle 包名**,管理器的插件
8420
+ * 详情页把它渲染在「说明」正下方;`plugins.row.config` 是 per-row 的「>」子页。
8421
+ *
8422
+ * 两条硬约束(第一条踩过坑,见下):
8423
+ * ① `plugins.bundle.config` 的 key 是**共享命名空间**:同一个 key 只能有一个注册者,
8424
+ * 重复注册**直接抛错**(`keyed slot ... already has an entry`),而客户端 `apply`
8425
+ * 抛错 = 整个插件起不来(实测:把 `@hyzyn/dsh-all` 也挂上时,八个插件抢同一个 key,
8426
+ * 启动页直接变「Failed to load plugins」)(D158)。聚合包 `@hyzyn/dsh-all` 是所有插件共用的
8427
+ * bundle,**不能**挂 bundle.config —— 它没有内联位,设置面走它自己的 row 入口。
8428
+ * ② 内联位可用时,**本包自己的** bundle 不再注册 row 槽(同一份表单两个入口会让人以为
8429
+ * 有两套设置);聚合包那份 row 入口**保留**(那是它在聚合安装下的唯一入口)。旧宿主
8430
+ * (bundle 槽不存在,0.1.6 线)回退注册全部 row 槽,功能一点不减。
8431
+ * 就绪顺序不敏感:任一侧先到都收敛到「内联优先」(后到的本包 row 注册会被撤掉)。
8432
+ */
8433
+ const AGGREGATE_BUNDLE = '@hyzyn/dsh-all'
8434
+ const BUNDLE_NAMES = [...new Set(ROW_CONFIG_KEYS.map((key) => key.split('#')[0]))]
8435
+ const OWN_BUNDLE = BUNDLE_NAMES.find((name) => name !== AGGREGATE_BUNDLE)
8436
+ let bundleConfigLive = false
8437
+ const disposeOwnRowConfigs = []
8438
+ if (OWN_BUNDLE !== undefined) {
8439
+ ctx.slots.inject('plugins.bundle.config', () => {
8440
+ bundleConfigLive = true
8441
+ while (disposeOwnRowConfigs.length > 0) {
8442
+ const disposeRow = disposeOwnRowConfigs.pop()
8443
+ if (typeof disposeRow === 'function') disposeRow()
8444
+ }
8445
+ return ctx.slots.register({
8446
+ name: 'plugins.bundle.config',
8447
+ key: OWN_BUNDLE,
8448
+ }, DockerSettingsCard)
8449
+ })
8450
+ }
8065
8451
  for (const key of ROW_CONFIG_KEYS) {
8066
- ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
8067
- name: 'plugins.row.config',
8068
- key,
8069
- }, DockerSettingsCard))
8452
+ ctx.slots.inject('plugins.row.config', () => {
8453
+ const own = key.split('#')[0] === OWN_BUNDLE
8454
+ if (own && bundleConfigLive) return undefined
8455
+ const disposeRow = ctx.slots.register({
8456
+ name: 'plugins.row.config',
8457
+ key,
8458
+ }, DockerSettingsCard)
8459
+ if (own) disposeOwnRowConfigs.push(disposeRow)
8460
+ return disposeRow
8461
+ })
8070
8462
  }
8071
8463
  // DSH ≥0.1.6:设置里与「通用设置」平级的「插件配置」页(子 slot 由
8072
8464
  // @hyzyn/dsh-kit-settings 声明)。不传 view,卡片走各自原有的可折叠形态。