@hyzyn/dsh-docker 0.7.3 → 0.8.1
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 +31 -5
- package/README.md +41 -13
- package/client-src/docker.css +13 -13
- package/client-src/index.js +466 -246
- package/client-src/log-buffer.js +205 -0
- package/client-src/log-stream.js +143 -0
- package/client.js +37 -36
- package/lib/docker.d.ts +18 -3
- package/lib/docker.js +54 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.js +154 -19
- package/lib/index.js.map +1 -1
- package/package.json +5 -4
- package/scripts/client-smoke.mjs +261 -0
- package/scripts/log-perf.mjs +206 -0
- package/scripts/route-smoke.mjs +21 -0
- package/scripts/smoke.mjs +59 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-docker — 日志环形缓冲(纯模块,浏览器半体与 vitest 共用)。
|
|
4
|
+
*
|
|
5
|
+
* 背景(D63 根治):「日志多 → 网页崩溃」有两个客户端根因——
|
|
6
|
+
* 1) FOLLOW 逐 chunk 全量重渲染(渲染层问题,由 index.js 的 150ms 合帧解决);
|
|
7
|
+
* 2) 缓冲只限行数不限字节 + 残行无上限(内存问题,本模块解决)。
|
|
8
|
+
*
|
|
9
|
+
* 这里把「缓冲」从组件里抽出来:
|
|
10
|
+
* - 行数 + 字节双上限,超限从头丢最旧(dropped 标志,不静默丢——沿用 D57 语义);
|
|
11
|
+
* - 每行一个**单调递增 id**:渲染层用它当 React key,窗口滑动时只挂载/卸载
|
|
12
|
+
* 边界行,不再全量重绘;
|
|
13
|
+
* - 残行(没等到 \n 的尾段)超上限强制落为分片行——「永远不换行的输出」不再能
|
|
14
|
+
* 把内存拖爆(单行再长,内存也有界)。
|
|
15
|
+
*
|
|
16
|
+
* 纯模块、零依赖:被 index.js 打包进 client.js,也被 test/log-buffer.test.ts
|
|
17
|
+
* 直接 import(与 session-target.js / current-session.js 同一模式)。
|
|
18
|
+
* 字节按 UTF-16 code unit 数(text.length)近似:是 UTF-8 字节数的宽松上界,
|
|
19
|
+
* 限流够用,不为精度引入 TextEncoder。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** 行数上限兜底值(调用方应显式传入 index.js 的 FOLLOW_LINE_LIMIT)。 */
|
|
23
|
+
export const DEFAULT_MAX_LINES = 5000
|
|
24
|
+
/** 字节上限兜底值(UTF-16 unit 数)。 */
|
|
25
|
+
export const DEFAULT_MAX_BYTES = 4 * 1024 * 1024
|
|
26
|
+
/** 单条残行上限兜底值。 */
|
|
27
|
+
export const DEFAULT_MAX_PENDING = 1024 * 1024
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 一次性日志文本 → 行(快照路径,POST /logs 的 `logs.text`)。
|
|
31
|
+
*
|
|
32
|
+
* 语义必须与 pushChunk 的切分**逐字一致**:一行 = 一个 `\n` 之前的全部内容,
|
|
33
|
+
* 结尾那个 `\n` 是**终止符**、不产生新行——pushChunk 把 `\n` 之后的尾巴留在
|
|
34
|
+
* pending 里、从不落地空行,快照路径也得给出同一个结论。
|
|
35
|
+
*
|
|
36
|
+
* 旧实现直接 `text.split('\n')`:docker logs 每行都以 `\n` 结尾,于是
|
|
37
|
+
* `--tail 201` 的 201 行(201 个 `\n`)被切成 **202** 段,最后一段是空串——
|
|
38
|
+
* 计数多 1、末尾多一条空行、导出也多一行;同一份日志在快照视图与 FOLLOW 视图
|
|
39
|
+
* 下因此行数不同。
|
|
40
|
+
*
|
|
41
|
+
* 只在结尾是 `\n` 时剥掉**一个**:真正以空行结尾的日志(`a\n\n` = 两行)要留住那个空行。
|
|
42
|
+
*/
|
|
43
|
+
export function splitLogLines(text) {
|
|
44
|
+
if (typeof text !== 'string' || text === '') return []
|
|
45
|
+
const body = text.endsWith('\n') ? text.slice(0, -1) : text
|
|
46
|
+
return body.split('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const positiveIntOr = (value, fallback) =>
|
|
50
|
+
Number.isInteger(value) && value > 0 ? value : fallback
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 环形日志缓冲。两个使用方:
|
|
54
|
+
* - 单容器 FOLLOW:pushChunk 吃原始流分片,内部负责残行拼接与按行切分;
|
|
55
|
+
* - 聚合日志:push 侧自己组好行对象(带 service/ts),用 appendRows 入列、
|
|
56
|
+
* replaceAll 把「时间序重排」的整表结果写回。
|
|
57
|
+
*
|
|
58
|
+
* 淘汰策略:只推 head 指针(逻辑删除),snapshot 时按需物理压缩——突发灌入
|
|
59
|
+
* 上万行的场景下,淘汰是 O(1)/行,不会出现逐行 shift 的 O(n²)。
|
|
60
|
+
* 字节合计随写入/淘汰增量维护,淘汰循环本身也是 O(1)/行。
|
|
61
|
+
*/
|
|
62
|
+
export function createLogBuffer(options = {}) {
|
|
63
|
+
const maxLines = positiveIntOr(options.maxLines, DEFAULT_MAX_LINES)
|
|
64
|
+
const maxBytes = positiveIntOr(options.maxBytes, DEFAULT_MAX_BYTES)
|
|
65
|
+
const maxPending = positiveIntOr(options.maxPendingBytes, DEFAULT_MAX_PENDING)
|
|
66
|
+
|
|
67
|
+
let seq = 0
|
|
68
|
+
let entries = [] // { id, text, bytes, ...extra }
|
|
69
|
+
let head = 0 // 逻辑起点
|
|
70
|
+
let bytes = 0 // 存活行字节合计
|
|
71
|
+
let pending = '' // 残行(只有 pushChunk 会碰)
|
|
72
|
+
let droppedSince = false
|
|
73
|
+
|
|
74
|
+
const count = () => entries.length - head
|
|
75
|
+
|
|
76
|
+
/** 超行数/字节上限时从头丢最旧;至少保留一行(丢光会让视图闪空)。 */
|
|
77
|
+
const evict = () => {
|
|
78
|
+
let evicted = false
|
|
79
|
+
while (count() > maxLines && count() > 1) {
|
|
80
|
+
bytes -= entries[head].bytes
|
|
81
|
+
head += 1
|
|
82
|
+
evicted = true
|
|
83
|
+
}
|
|
84
|
+
while (bytes > maxBytes && count() > 1) {
|
|
85
|
+
bytes -= entries[head].bytes
|
|
86
|
+
head += 1
|
|
87
|
+
evicted = true
|
|
88
|
+
}
|
|
89
|
+
if (evicted) droppedSince = true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** head 过半时物理压缩,避免长期 FOLLOW 下底层数组无界增长。 */
|
|
93
|
+
const compact = () => {
|
|
94
|
+
if (head > 32 && head * 2 > entries.length) {
|
|
95
|
+
entries = entries.slice(head)
|
|
96
|
+
head = 0
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const makeLine = (text) => {
|
|
101
|
+
const lineBytes = text.length
|
|
102
|
+
seq += 1
|
|
103
|
+
return { id: seq, text, bytes: lineBytes }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 读后清零的 dropped 标志:两次读取之间是否发生过淘汰。 */
|
|
107
|
+
const takeDropped = () => {
|
|
108
|
+
const dropped = droppedSince
|
|
109
|
+
droppedSince = false
|
|
110
|
+
return dropped
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function appendLine(text) {
|
|
114
|
+
const line = makeLine(text)
|
|
115
|
+
entries.push(line)
|
|
116
|
+
bytes += line.bytes
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const buffer = {
|
|
120
|
+
/** 单调行 id:两个视图各持一个实例,不跨视图比较。聚合视图组行时先取号。 */
|
|
121
|
+
nextId: () => {
|
|
122
|
+
seq += 1
|
|
123
|
+
return seq
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
count,
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 流式原始分片:残行拼接 → 按 \n 切行(与旧 split 版语义逐字等价);
|
|
130
|
+
* 残行超 maxPending 强制落为分片行。返回 { appended: 新落地完整行数 }——
|
|
131
|
+
* 渲染层只在 appended > 0 时排一次渲染,残行增长不打扰 React。
|
|
132
|
+
*/
|
|
133
|
+
pushChunk(text) {
|
|
134
|
+
if (typeof text !== 'string' || text === '') return { appended: 0 }
|
|
135
|
+
let rest = pending + text
|
|
136
|
+
let appended = 0
|
|
137
|
+
let idx = rest.indexOf('\n')
|
|
138
|
+
while (idx >= 0) {
|
|
139
|
+
appendLine(rest.slice(0, idx))
|
|
140
|
+
appended += 1
|
|
141
|
+
rest = rest.slice(idx + 1)
|
|
142
|
+
idx = rest.indexOf('\n')
|
|
143
|
+
}
|
|
144
|
+
while (rest.length > maxPending) {
|
|
145
|
+
appendLine(rest.slice(0, maxPending))
|
|
146
|
+
appended += 1
|
|
147
|
+
rest = rest.slice(maxPending)
|
|
148
|
+
}
|
|
149
|
+
pending = rest
|
|
150
|
+
evict()
|
|
151
|
+
compact()
|
|
152
|
+
return { appended }
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 行对象批量入列(rows 须自带 id 与 bytes:由 nextId() 取号、text.length
|
|
157
|
+
* 计字节)。返回 { dropped }——本轮是否触发了淘汰。
|
|
158
|
+
*/
|
|
159
|
+
appendRows(rows) {
|
|
160
|
+
if (!Array.isArray(rows) || rows.length === 0) return { dropped: false }
|
|
161
|
+
for (const row of rows) {
|
|
162
|
+
entries.push(row)
|
|
163
|
+
bytes += row.bytes
|
|
164
|
+
}
|
|
165
|
+
evict()
|
|
166
|
+
compact()
|
|
167
|
+
return { dropped: takeDropped() }
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 整表替换(聚合视图「按时间」重排后回写;行集合不变、只有顺序变,字节
|
|
172
|
+
* 合计重建一次——调用频率 ≤ 合帧频率,O(n) 无所谓)。替换后照常裁剪。
|
|
173
|
+
*/
|
|
174
|
+
replaceAll(rows) {
|
|
175
|
+
entries = Array.isArray(rows) ? rows.slice() : []
|
|
176
|
+
head = 0
|
|
177
|
+
bytes = 0
|
|
178
|
+
for (const row of entries) bytes += row.bytes
|
|
179
|
+
evict()
|
|
180
|
+
compact()
|
|
181
|
+
return { dropped: takeDropped() }
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/** 存活行的浅拷贝(新数组身份,供 setState 触发 memo 重算)。 */
|
|
185
|
+
snapshot() {
|
|
186
|
+
compact()
|
|
187
|
+
return entries.slice(head)
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
takeDropped,
|
|
191
|
+
|
|
192
|
+
/** 当前残行长度(诊断/测试用)。 */
|
|
193
|
+
pendingLength: () => pending.length,
|
|
194
|
+
|
|
195
|
+
reset() {
|
|
196
|
+
entries = []
|
|
197
|
+
head = 0
|
|
198
|
+
bytes = 0
|
|
199
|
+
pending = ''
|
|
200
|
+
droppedSince = false
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return buffer
|
|
205
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日志流订阅(D133):把「EventSource 生命周期 + 断线重连」收在一处,两条日志视图
|
|
3
|
+
* 共用(单容器 FOLLOW / Compose 聚合)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么不用 EventSource 的自动重连:它**复用同一个 URL**,而 URL 里的 tail 是首连
|
|
6
|
+
* 用来补历史的。重连时服务端会把最后 tail 行当成新行再推一遍,客户端又无脑 append
|
|
7
|
+
* ——日志里就凭空多出一段重复,还会把真正的历史挤掉。这里改成手动重连:首连带 tail,
|
|
8
|
+
* **重连一律 tail=0**(只补新行,不重复历史)。
|
|
9
|
+
*
|
|
10
|
+
* 另一个好处:宿主队列溢出(背压 8MB 上限)时会先发一条
|
|
11
|
+
* `end{reason:'output-limit'}` 再关流,这里能把「为什么断了」交给调用方显示,
|
|
12
|
+
* 而不是让浏览器把它当成正常结束、静默重连。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 重连退避:1s 起、指数增长、15s 封顶(与 tty 隧道同一套手感)。 */
|
|
16
|
+
export const LOG_RECONNECT_BASE_MS = 1000
|
|
17
|
+
export const LOG_RECONNECT_MAX_MS = 15000
|
|
18
|
+
|
|
19
|
+
/** 首连要补的历史行数、重连一律 0(只补新行)。测试缝断言这两个值。 */
|
|
20
|
+
export function reconnectTail(initialTail, isReconnect) {
|
|
21
|
+
return isReconnect ? 0 : initialTail
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{
|
|
26
|
+
* buildUrl: (tail: number) => string,
|
|
27
|
+
* tail: number,
|
|
28
|
+
* onOpen?: () => void,
|
|
29
|
+
* onLine?: (text: string) => void,
|
|
30
|
+
* onEnd?: (payload: Record<string, unknown> | null, controls: { reconnect: () => void }) => void,
|
|
31
|
+
* onError?: (message: string, controls: { close: () => void, reconnect: () => void }) => void,
|
|
32
|
+
* onStatus?: (status: 'connecting' | 'open' | 'reconnecting' | 'closed') => void,
|
|
33
|
+
* }} options
|
|
34
|
+
* @returns {{ close: () => void, reconnect: () => void }}
|
|
35
|
+
*/
|
|
36
|
+
export function subscribeLogStream(options) {
|
|
37
|
+
const { buildUrl, tail } = options
|
|
38
|
+
let source = null
|
|
39
|
+
let closed = false
|
|
40
|
+
let attempt = 0
|
|
41
|
+
let timer = null
|
|
42
|
+
|
|
43
|
+
const dropSource = () => {
|
|
44
|
+
if (source === null) return
|
|
45
|
+
const stale = source
|
|
46
|
+
source = null
|
|
47
|
+
try {
|
|
48
|
+
stale.close()
|
|
49
|
+
} catch {
|
|
50
|
+
/* 已关闭 */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const cancelTimer = () => {
|
|
55
|
+
if (timer === null) return
|
|
56
|
+
clearTimeout(timer)
|
|
57
|
+
timer = null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const settle = () => {
|
|
61
|
+
closed = true
|
|
62
|
+
cancelTimer()
|
|
63
|
+
dropSource()
|
|
64
|
+
options.onStatus?.('closed')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 手动重连:先关掉当前连接(掐断浏览器的自动重连),退避后再开,且不再补历史。 */
|
|
68
|
+
const reconnect = () => {
|
|
69
|
+
if (closed) return
|
|
70
|
+
dropSource()
|
|
71
|
+
cancelTimer()
|
|
72
|
+
options.onStatus?.('reconnecting')
|
|
73
|
+
const delay = Math.min(LOG_RECONNECT_BASE_MS * 2 ** attempt, LOG_RECONNECT_MAX_MS)
|
|
74
|
+
attempt += 1
|
|
75
|
+
timer = setTimeout(() => {
|
|
76
|
+
timer = null
|
|
77
|
+
// 重连一律不补历史(reconnectTail 是这条规则的唯一出处)
|
|
78
|
+
if (!closed) open(reconnectTail(typeof tail === 'number' ? tail : 0, true))
|
|
79
|
+
}, delay)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {number} tailNow 补的历史行数:首连用调用方给的 tail,重连一律 0。 */
|
|
83
|
+
function open(tailNow) {
|
|
84
|
+
if (closed) return
|
|
85
|
+
options.onStatus?.('connecting')
|
|
86
|
+
const next = new EventSource(buildUrl(tailNow))
|
|
87
|
+
source = next
|
|
88
|
+
next.addEventListener('line', (event) => {
|
|
89
|
+
if (source !== next) return
|
|
90
|
+
let payload = null
|
|
91
|
+
try {
|
|
92
|
+
payload = JSON.parse(event.data)
|
|
93
|
+
} catch {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (payload === null || typeof payload !== 'object') return
|
|
97
|
+
if (typeof payload.d === 'string') options.onLine?.(payload.d)
|
|
98
|
+
else if (typeof payload.e === 'string') options.onLine?.(payload.e)
|
|
99
|
+
})
|
|
100
|
+
next.addEventListener('end', (event) => {
|
|
101
|
+
if (source !== next) return
|
|
102
|
+
let payload = null
|
|
103
|
+
try {
|
|
104
|
+
payload = JSON.parse(event.data)
|
|
105
|
+
} catch {
|
|
106
|
+
/* 畸形载荷按「服务端主动停流」处理 */
|
|
107
|
+
}
|
|
108
|
+
options.onEnd?.(payload !== null && typeof payload === 'object' ? payload : null, {
|
|
109
|
+
reconnect: () => {
|
|
110
|
+
if (source !== next) return
|
|
111
|
+
reconnect()
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
next.addEventListener('error', (event) => {
|
|
116
|
+
if (source !== next) return
|
|
117
|
+
// 服务端 event:error 是带 data 的 MessageEvent;连接层错误是普通 Event
|
|
118
|
+
if (typeof event.data === 'string' && event.data !== '') {
|
|
119
|
+
let message = '日志流异常'
|
|
120
|
+
try {
|
|
121
|
+
const payload = JSON.parse(event.data)
|
|
122
|
+
if (payload !== null && typeof payload.message === 'string') message = payload.message
|
|
123
|
+
} catch {
|
|
124
|
+
/* 用默认文案 */
|
|
125
|
+
}
|
|
126
|
+
// 关流还是重连由调用方决定(单容器视图报错即停;聚合视图只标记部分失败)
|
|
127
|
+
options.onError?.(message, { close: settle, reconnect })
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
// 连接层错误:不再交给浏览器自动重连(它会重放 tail),改走手动重连
|
|
131
|
+
reconnect()
|
|
132
|
+
})
|
|
133
|
+
next.onopen = () => {
|
|
134
|
+
if (source !== next) return
|
|
135
|
+
attempt = 0
|
|
136
|
+
options.onStatus?.('open')
|
|
137
|
+
options.onOpen?.()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
open(reconnectTail(typeof tail === 'number' ? tail : 0, false))
|
|
142
|
+
return { close: settle, reconnect }
|
|
143
|
+
}
|