@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.
- 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/log-stream.js
CHANGED
|
@@ -27,6 +27,7 @@ export function reconnectTail(initialTail, isReconnect) {
|
|
|
27
27
|
* tail: number,
|
|
28
28
|
* onOpen?: () => void,
|
|
29
29
|
* onLine?: (text: string) => void,
|
|
30
|
+
* onSkip?: (payload: { frames?: number, bytes?: number } | null) => void,
|
|
30
31
|
* onEnd?: (payload: Record<string, unknown> | null, controls: { reconnect: () => void }) => void,
|
|
31
32
|
* onError?: (message: string, controls: { close: () => void, reconnect: () => void }) => void,
|
|
32
33
|
* onStatus?: (status: 'connecting' | 'open' | 'reconnecting' | 'closed') => void,
|
|
@@ -103,6 +104,20 @@ export function subscribeLogStream(options) {
|
|
|
103
104
|
if (typeof payload.d === 'string') options.onLine?.(payload.d)
|
|
104
105
|
else if (typeof payload.e === 'string') options.onLine?.(payload.e)
|
|
105
106
|
})
|
|
107
|
+
next.addEventListener('skip', (event) => {
|
|
108
|
+
if (source !== next) return
|
|
109
|
+
/*
|
|
110
|
+
* 主机侧背压丢帧(D153):`{frames,bytes}` = 被丢掉的帧数与字节数。流**没有断**,
|
|
111
|
+
* 只是中间少了一截——交给调用方提示,别当成断线去重连(那会白丢一次历史)。
|
|
112
|
+
*/
|
|
113
|
+
let payload = null
|
|
114
|
+
try {
|
|
115
|
+
payload = JSON.parse(event.data)
|
|
116
|
+
} catch {
|
|
117
|
+
/* 畸形载荷按「跳过了一截」提示,不带数字 */
|
|
118
|
+
}
|
|
119
|
+
options.onSkip?.(payload !== null && typeof payload === 'object' ? payload : null)
|
|
120
|
+
})
|
|
106
121
|
next.addEventListener('end', (event) => {
|
|
107
122
|
if (source !== next) return
|
|
108
123
|
let payload = null
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-docker — 日志正文的窗口化布局(虚拟滚动)纯逻辑(D152)。
|
|
4
|
+
*
|
|
5
|
+
* 背景(D151 之后的实测):D151 把「一个 chunk 一帧 SSE」的固定开销合掉了,持续洪泛下
|
|
6
|
+
* 剩下的唯一大账是**挂载 5000 行的 DOM churn**——20k 行/秒时每帧要淘汰上千行、插入上千行,
|
|
7
|
+
* `scripts/log-perf.mjs` 量到 25~26 个 >50ms 长帧 / 6s(最大延迟 70~97ms),真机上就是
|
|
8
|
+
* 「日志一快整页就卡」。同轮实测已排除两条更便宜的路(按 id 复用 element ±0;合帧间隔退避
|
|
9
|
+
* 把最大延迟抬到 144ms,见 DEFECTS D151)。这里走 D63 一直留着的那条:**只挂可见的几十行**,
|
|
10
|
+
* DOM 从 ~10000 节点掉到 ~500。
|
|
11
|
+
*
|
|
12
|
+
* 为什么不能直接 `content-visibility`(D91 的结论维持):它给未渲染行兜一个**估算高度**,
|
|
13
|
+
* scrollHeight 从此是估算值,而 FOLLOW 的贴底判定(scrollTop=scrollHeight、上滚 <24px、
|
|
14
|
+
* 「回到底部」)全建立在精确高度上。本模块的做法正相反:
|
|
15
|
+
*
|
|
16
|
+
* - 行高**实测**(渲染出来的行由 index.js 量 `offsetHeight`),实测值进缓存;
|
|
17
|
+
* - 从没渲染过的行用估算值(`estimate`)兜——但它们只在「用户还没滚到那里的历史」里,
|
|
18
|
+
* 而**贴底时窗口锚在列表尾部**,尾部永远是实测的 → 贴底判定依旧精确(这是与 D91 的
|
|
19
|
+
* 本质区别:估算只出现在视口之外,不再参与贴底);
|
|
20
|
+
* - 测量落地后按**可见区顶行**做锚点修正(`reanchor`):它上方任何一行的高度修正都会
|
|
21
|
+
* 让滚动内容整体位移,把差值加回 scrollTop 就抵消了,往上滚历史不会跳。
|
|
22
|
+
*
|
|
23
|
+
* 缓存按「最近测量」FIFO 淘汰:环形缓冲本身就是 FIFO 淘汰,最老的那些行再也不会被渲染。
|
|
24
|
+
*
|
|
25
|
+
* 纯模块、零依赖:被 index.js 打包进 client.js,也被 test/log-window.test.ts 直接驱动
|
|
26
|
+
* (与 log-buffer.js / log-stream.js 同一模式)。所有坐标都是 CSS 像素(px)。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** 从没量过的行的兜底高度:12px 字号 × 1.6 行高 ≈ 19px,取整。 */
|
|
30
|
+
export const LOG_ROW_ESTIMATE_PX = 20
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 视口上下各多挂多少行。**必须 ≥ index.js 的 ASK_CONTEXT_LINES(20)**:右键「问 Agent」
|
|
34
|
+
* 的诊断包带的是选中行前后各 20 行,而它是从**已渲染的 DOM** 里取的(见 logRowElements /
|
|
35
|
+
* buildAskPrompt)——多挂的这几十行正是那份上下文的来源。
|
|
36
|
+
*/
|
|
37
|
+
export const LOG_WINDOW_OVERSCAN = 24
|
|
38
|
+
|
|
39
|
+
/** 行高缓存上限:比缓冲行数(5000)留一点余量,FIFO 淘汰跟着环形缓冲的顺序走。 */
|
|
40
|
+
export const LOG_HEIGHT_CACHE_LIMIT = 6000
|
|
41
|
+
|
|
42
|
+
/** 实测高度与缓存值差多少才算「变了」:亚像素抖动不该触发重渲染。 */
|
|
43
|
+
const HEIGHT_EPSILON = 0.5
|
|
44
|
+
|
|
45
|
+
/** 最后一个 `offsets[i] <= top` 的下标(offsets 严格递增:每行高度恒 > 0)。 */
|
|
46
|
+
function firstVisibleIndex(offsets, count, top) {
|
|
47
|
+
let lo = 0
|
|
48
|
+
let hi = count - 1
|
|
49
|
+
let found = count - 1
|
|
50
|
+
while (lo <= hi) {
|
|
51
|
+
const mid = (lo + hi) >> 1
|
|
52
|
+
if (offsets[mid] <= top) {
|
|
53
|
+
found = mid
|
|
54
|
+
lo = mid + 1
|
|
55
|
+
} else {
|
|
56
|
+
hi = mid - 1
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return found
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 最后一个与 [top, top+viewportHeight) 相交的下标:第一个 `offsets[i+1] >= bottom`。 */
|
|
63
|
+
function lastVisibleIndex(offsets, count, bottom) {
|
|
64
|
+
let lo = 0
|
|
65
|
+
let hi = count - 1
|
|
66
|
+
let found = count - 1
|
|
67
|
+
while (lo <= hi) {
|
|
68
|
+
const mid = (lo + hi) >> 1
|
|
69
|
+
if (offsets[mid + 1] >= bottom) {
|
|
70
|
+
found = mid
|
|
71
|
+
hi = mid - 1
|
|
72
|
+
} else {
|
|
73
|
+
lo = mid + 1
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return found
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const positiveNumberOr = (value, fallback) =>
|
|
80
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 日志窗口布局器。一个视图(单容器日志页 / 聚合日志页)一个实例——行 id 只在**同一个
|
|
84
|
+
* 缓冲实例**内唯一(两个视图的 id 都从 1 起,跨视图会撞)。
|
|
85
|
+
*/
|
|
86
|
+
export function createLogWindow(options = {}) {
|
|
87
|
+
const estimate = positiveNumberOr(options.estimate, LOG_ROW_ESTIMATE_PX)
|
|
88
|
+
const overscan = Number.isInteger(options.overscan) && options.overscan >= 0 ? options.overscan : LOG_WINDOW_OVERSCAN
|
|
89
|
+
const maxEntries = positiveNumberOr(options.maxEntries, LOG_HEIGHT_CACHE_LIMIT)
|
|
90
|
+
|
|
91
|
+
/** id(string) → 实测高度。插入序 = 最近测量序(重测会先删再插,挪到队尾)。 */
|
|
92
|
+
const heights = new Map()
|
|
93
|
+
|
|
94
|
+
const heightOf = (id) => {
|
|
95
|
+
const hit = heights.get(String(id))
|
|
96
|
+
return hit === undefined ? estimate : hit
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const prune = () => {
|
|
100
|
+
while (heights.size > maxEntries) {
|
|
101
|
+
const oldest = heights.keys().next()
|
|
102
|
+
if (oldest.done === true) break
|
|
103
|
+
heights.delete(oldest.value)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 前缀和:`offsets[i]` = 第 i 行之前的总高,`offsets[n]` = 整表总高。 */
|
|
108
|
+
const offsets = (entries) => {
|
|
109
|
+
const list = Array.isArray(entries) ? entries : []
|
|
110
|
+
const out = new Float64Array(list.length + 1)
|
|
111
|
+
let acc = 0
|
|
112
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
113
|
+
out[i] = acc
|
|
114
|
+
acc += heightOf(list[i].id)
|
|
115
|
+
}
|
|
116
|
+
out[list.length] = acc
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
estimate,
|
|
122
|
+
overscan,
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 记一行的实测高度。返回 true = 缓存值真的变了(调用方据此排一次重渲染);
|
|
126
|
+
* 非法值(0 / NaN / 负)与亚像素抖动都返回 false。
|
|
127
|
+
*/
|
|
128
|
+
measure(id, px) {
|
|
129
|
+
if (typeof px !== 'number' || !Number.isFinite(px) || px <= 0) return false
|
|
130
|
+
const key = String(id)
|
|
131
|
+
const prev = heights.get(key)
|
|
132
|
+
if (prev !== undefined && Math.abs(prev - px) < HEIGHT_EPSILON) return false
|
|
133
|
+
if (prev !== undefined) heights.delete(key) // 挪到队尾:FIFO 淘汰按「最近测量」算
|
|
134
|
+
heights.set(key, px)
|
|
135
|
+
prune()
|
|
136
|
+
return true
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
heightOf,
|
|
140
|
+
|
|
141
|
+
offsets,
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 一次布局:定位该挂哪些行、上下垫多高。
|
|
145
|
+
*
|
|
146
|
+
* @param {Array<{id: unknown}>} entries 已经过滤好的行(`matched`)
|
|
147
|
+
* @param {{ scrollTop?: number, viewportHeight?: number, pinned?: boolean }} view
|
|
148
|
+
* `pinned=true`(跟随中且用户贴底):窗口直接锚到**列表尾部**——尾部永远是实测的,
|
|
149
|
+
* 贴底判定(`scrollTop = scrollHeight`)因此精确,不受估算高度影响。
|
|
150
|
+
* @returns {{ start: number, end: number, topPad: number, bottomPad: number, total: number,
|
|
151
|
+
* anchorId: unknown, anchorOffset: number }} `end < start` 表示没有行可挂(空表)。
|
|
152
|
+
*/
|
|
153
|
+
layout(entries, view) {
|
|
154
|
+
const list = Array.isArray(entries) ? entries : []
|
|
155
|
+
const count = list.length
|
|
156
|
+
const off = offsets(list)
|
|
157
|
+
const total = off[count]
|
|
158
|
+
const top = Math.max(0, typeof view?.scrollTop === 'number' ? view.scrollTop : 0)
|
|
159
|
+
const vh = Math.max(0, typeof view?.viewportHeight === 'number' ? view.viewportHeight : 0)
|
|
160
|
+
if (count === 0) {
|
|
161
|
+
return { start: 0, end: -1, topPad: 0, bottomPad: 0, total: 0, anchorId: null, anchorOffset: 0 }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let firstVisible
|
|
165
|
+
let end
|
|
166
|
+
let pinnedWindow = false
|
|
167
|
+
if (view?.pinned === true || vh === 0) {
|
|
168
|
+
// 贴底(或视口还量不出来):从尾部往前凑「视口 + overscan 行」的高度。这里的
|
|
169
|
+
// overscan 已经算进 need,下面的 start **不再**往前多留一段(否则会多挂一屏)。
|
|
170
|
+
let i = count - 1
|
|
171
|
+
let acc = 0
|
|
172
|
+
const need = vh + estimate * overscan
|
|
173
|
+
while (i >= 0 && acc < need) {
|
|
174
|
+
acc += off[i + 1] - off[i]
|
|
175
|
+
i -= 1
|
|
176
|
+
}
|
|
177
|
+
// 退出时 i+1 就是「开始覆盖视口 + overscan」的那一行;i<0 说明整表都填不满
|
|
178
|
+
firstVisible = Math.max(0, i + 1)
|
|
179
|
+
end = count - 1
|
|
180
|
+
pinnedWindow = true
|
|
181
|
+
} else {
|
|
182
|
+
firstVisible = firstVisibleIndex(off, count, top)
|
|
183
|
+
end = lastVisibleIndex(off, count, top + vh)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const start = pinnedWindow ? firstVisible : Math.max(0, firstVisible - overscan)
|
|
187
|
+
end = pinnedWindow ? end : Math.min(count - 1, end + overscan)
|
|
188
|
+
return {
|
|
189
|
+
start,
|
|
190
|
+
end,
|
|
191
|
+
topPad: off[start],
|
|
192
|
+
bottomPad: total - off[end + 1],
|
|
193
|
+
total,
|
|
194
|
+
anchorId: list[firstVisible].id,
|
|
195
|
+
anchorOffset: off[firstVisible],
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 某一行在**当前缓存**下的偏移(它上方所有行的实测/估算总高);行不在表里返回 null。
|
|
201
|
+
* 与 `reanchor` 的区别:reanchor 算的是「相对某次旧布局的位移」,这里算的是绝对位置——
|
|
202
|
+
* index.js 的跨代锚点(D154)每帧用它把「应该钉住的那一行」的当前位置记下来,下一帧
|
|
203
|
+
* 再让 reanchor 相对这份记录补偿。
|
|
204
|
+
*/
|
|
205
|
+
offsetOf(entries, id) {
|
|
206
|
+
if (id === null || id === undefined) return null
|
|
207
|
+
const key = String(id)
|
|
208
|
+
const list = Array.isArray(entries) ? entries : []
|
|
209
|
+
let acc = 0
|
|
210
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
211
|
+
if (String(list[i].id) === key) return acc
|
|
212
|
+
acc += heightOf(list[i].id)
|
|
213
|
+
}
|
|
214
|
+
return null
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 测量落地后的锚点修正:用**当前**高度重算「可见区顶行」的偏移,与上一帧布局时的
|
|
219
|
+
* 偏移相减 —— 差值就是滚动内容整体位移的像素数,加回 scrollTop 即抵消。
|
|
220
|
+
* 锚点行已不在表里(被淘汰 / 过滤掉了)时返回 0。
|
|
221
|
+
*/
|
|
222
|
+
reanchor(entries, anchorId, anchorOffset) {
|
|
223
|
+
if (anchorId === null || anchorId === undefined) return 0
|
|
224
|
+
const key = String(anchorId)
|
|
225
|
+
const list = Array.isArray(entries) ? entries : []
|
|
226
|
+
let acc = 0
|
|
227
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
228
|
+
if (String(list[i].id) === key) return acc - anchorOffset
|
|
229
|
+
acc += heightOf(list[i].id)
|
|
230
|
+
}
|
|
231
|
+
return 0
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/** 换流 / 重建缓冲时清空:行 id 会被复用(都从 1 起),高度必须整代作废。 */
|
|
235
|
+
clear() {
|
|
236
|
+
heights.clear()
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
/** 缓存条目数(测试 / 诊断用)。 */
|
|
240
|
+
size() {
|
|
241
|
+
return heights.size
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
}
|