@hyzyn/dsh-docker 0.9.1 → 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.
- package/README.en.md +78 -7
- package/README.md +91 -13
- package/client-src/docker.css +22 -3
- package/client-src/index.js +448 -56
- package/client-src/log-stream.js +15 -0
- package/client-src/log-window.js +244 -0
- package/client.js +45 -26
- package/lib/index.d.ts +33 -0
- package/lib/index.js +218 -34
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +57 -7
- package/lib/ssh-exec.js +227 -78
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +3 -3
- package/scripts/client-smoke.mjs +126 -10
- package/scripts/log-perf.mjs +327 -10
package/client-src/index.js
CHANGED
|
@@ -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', {
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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 && !
|
|
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
|
-
|
|
4657
|
-
|
|
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
|
-
//
|
|
4754
|
-
|
|
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
|
-
|
|
4835
|
-
|
|
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', {
|
|
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
|
-
|
|
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
|
-
:
|
|
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:
|
|
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
|
-
|
|
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', () =>
|
|
8067
|
-
|
|
8068
|
-
|
|
8069
|
-
|
|
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,卡片走各自原有的可折叠形态。
|