@hyzyn/dsh-docker 0.13.2 → 0.13.5
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 +98 -345
- package/README.md +98 -299
- package/client-src/index.js +241 -64
- package/client-src/log-stream.js +48 -3
- package/client-src/session-target.js +1 -1
- package/client.js +24 -24
- package/lib/docker.d.ts +24 -5
- package/lib/docker.js +33 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +8 -0
- package/lib/index.js +473 -91
- package/lib/index.js.map +1 -1
- package/package.json +4 -4
- package/scripts/client-smoke.mjs +64 -19
- package/scripts/route-smoke.mjs +36 -2
package/client-src/index.js
CHANGED
|
@@ -62,11 +62,11 @@ const I18N_ZH = {
|
|
|
62
62
|
'status.restarting': '重启中',
|
|
63
63
|
'status.removing': '删除中',
|
|
64
64
|
'status.unknown': '未知',
|
|
65
|
-
'hint.pickMaxLocal': '最多 {max}
|
|
66
|
-
'hint.pickMaxSsh': '最多 {max}
|
|
67
|
-
'hint.pickMany': '
|
|
65
|
+
'hint.pickMaxLocal': '最多 {max} 个容器——再多一次读不清,宿主上也会多起几条 logs 进程',
|
|
66
|
+
'hint.pickMaxSsh': '最多 {max} 个容器——再多一次读不清,SSH 通道也会被占满',
|
|
67
|
+
'hint.pickMany': '已接近上限,再多就看不清了',
|
|
68
68
|
'hint.pickAtLeastTwo': '至少选择 2 个容器',
|
|
69
|
-
'hint.pickBudget': '
|
|
69
|
+
'hint.pickBudget': '聚合日志只占一条 SSE(所有选中容器共用一条连接),活动条的事件流另外只占一条',
|
|
70
70
|
'option.presetAll': '全部可见',
|
|
71
71
|
'status.unhealthy': '不健康',
|
|
72
72
|
'status.attention': '需关注',
|
|
@@ -310,7 +310,7 @@ const I18N_ZH = {
|
|
|
310
310
|
'meta.lines': '- 行数:{count}',
|
|
311
311
|
'meta.exportedAt': '- 导出时间:{time}',
|
|
312
312
|
'msg.listSep': '、',
|
|
313
|
-
'status.aggConnected': '
|
|
313
|
+
'status.aggConnected': '正在跟随 {count} 个容器的日志(docker logs -f,共用一条连接)',
|
|
314
314
|
'status.aggConnecting': '正在连接容器日志流…',
|
|
315
315
|
'status.aggReconnecting': '部分连接中断,正在自动重连…',
|
|
316
316
|
'status.aggPartialError': '部分容器日志流出错',
|
|
@@ -553,11 +553,11 @@ const I18N_EN = {
|
|
|
553
553
|
'status.restarting': 'Restarting',
|
|
554
554
|
'status.removing': 'Removing',
|
|
555
555
|
'status.unknown': 'Unknown',
|
|
556
|
-
'hint.pickMaxLocal': 'At most {max} containers —
|
|
557
|
-
'hint.pickMaxSsh': 'At most {max} containers —
|
|
558
|
-
'hint.pickMany': 'Close to the limit —
|
|
556
|
+
'hint.pickMaxLocal': 'At most {max} containers — beyond that the merged view gets hard to read, and each one adds another logs process on the host',
|
|
557
|
+
'hint.pickMaxSsh': 'At most {max} containers — beyond that the merged view gets hard to read, and the SSH channels fill up',
|
|
558
|
+
'hint.pickMany': 'Close to the limit — more will be hard to read',
|
|
559
559
|
'hint.pickAtLeastTwo': 'Select at least 2 containers',
|
|
560
|
-
'hint.pickBudget': '
|
|
560
|
+
'hint.pickBudget': 'Aggregated logs use a single SSE connection shared by every selected container; the Activity strip\'s event stream is separate',
|
|
561
561
|
'option.presetAll': 'All visible',
|
|
562
562
|
'status.unhealthy': 'Unhealthy',
|
|
563
563
|
'status.attention': 'Needs attention',
|
|
@@ -801,7 +801,7 @@ const I18N_EN = {
|
|
|
801
801
|
'meta.lines': '- Rows: {count}',
|
|
802
802
|
'meta.exportedAt': '- Exported at: {time}',
|
|
803
803
|
'msg.listSep': ', ',
|
|
804
|
-
'status.aggConnected': '
|
|
804
|
+
'status.aggConnected': 'Following {count} container logs (docker logs -f, one shared connection)',
|
|
805
805
|
'status.aggConnecting': 'Connecting to container log streams…',
|
|
806
806
|
'status.aggReconnecting': 'Some connections were lost, reconnecting…',
|
|
807
807
|
'status.aggPartialError': 'Some container log streams errored',
|
|
@@ -1570,29 +1570,36 @@ const ICON_VOLUME =
|
|
|
1570
1570
|
* 实测(真 Chrome + test profile 宿主,页内连打 10 次普通请求、2s 超时):
|
|
1571
1571
|
* 常驻流 4 条 → 6/6 成功、中位 29ms;常驻流 5 条 → **0/6 全部超时**。
|
|
1572
1572
|
* 宿主侧同时刻实测 41ms(进程外请求正常)——**堵在浏览器,不在宿主**。
|
|
1573
|
-
* 所以预算是 4
|
|
1574
|
-
*
|
|
1575
|
-
*
|
|
1576
|
-
* 这个数**不是猜的**,是量出来的;改它之前请重跑那组实验(README「已知限制」有复现步骤)。
|
|
1573
|
+
* 所以预算是 4 条常驻流。这个数**不是猜的**,是量出来的;改它之前请重跑那组实验
|
|
1574
|
+
* (README「已知限制」有复现步骤)。
|
|
1577
1575
|
*/
|
|
1578
1576
|
const SAME_ORIGIN_STREAM_BUDGET = 4
|
|
1579
|
-
/**
|
|
1577
|
+
/** 容器页常开的那条事件流(活动条)。它算在预算里,是面板唯一一条常驻流。 */
|
|
1580
1578
|
const RESERVED_STREAMS = 1
|
|
1581
|
-
/**
|
|
1582
|
-
|
|
1579
|
+
/**
|
|
1580
|
+
* 聚合日志一次最多选几个容器。
|
|
1581
|
+
*
|
|
1582
|
+
* **与浏览器预算解耦了**(D168):聚合日志现在是一条 `/logs/stream?ids=…`,选 2 个还是
|
|
1583
|
+
* 3 个都只占**一条**同源连接,不再「每个容器一条」。所以这个数不是连接悬崖推出来的,
|
|
1584
|
+
* 而是两个产品 / 宿主侧约束里取小:
|
|
1585
|
+
* - 可读性:一屏混三份以上日志,服务名前缀会把内容挤得没法看;
|
|
1586
|
+
* - 宿主侧长流额度:每个被选的容器仍是一条 `docker logs -f`(SSH 上是同一条连接上的
|
|
1587
|
+
* 独立 channel,共享 `MAX_STREAMS_PER_TARGET = 8` 的额度,D150)。
|
|
1588
|
+
*/
|
|
1589
|
+
const PICK_MAX = 3
|
|
1583
1590
|
/**
|
|
1584
1591
|
* 超过这个数就给软提示(仍可聚合)。
|
|
1585
1592
|
*
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1593
|
+
* 与硬上限之间刻意**留一档**:硬上限是「再多就看不清 / 挤占宿主通道」的红线,而用户在
|
|
1594
|
+
* 3 个时已经在消耗大部分可读性——给一句提醒,但不禁用。
|
|
1588
1595
|
*/
|
|
1589
1596
|
const PICK_SOFT_MAX = 2
|
|
1590
1597
|
/**
|
|
1591
1598
|
* SSH 目标为什么不再单独降一档:真正卡住页面的是**浏览器同源预算**(上面那条,与目标
|
|
1592
1599
|
* 类型无关),而不是 SSH 的 `MaxSessions`。旧代码把上限按「SSH 要同时装实时流与短命令」
|
|
1593
1600
|
* 设成 6——那条例由针对的是**宿主侧**通道额度,与浏览器这一层是两笔独立的账,写成同一个
|
|
1594
|
-
* 数字正好把两者都算错(6 >
|
|
1595
|
-
*
|
|
1601
|
+
* 数字正好把两者都算错(6 > 实测安全值)。现在两者各归各:浏览器预算由「一条连接装
|
|
1602
|
+
* N 个容器」直接消掉,`MaxSessions` 由宿主侧的 `MAX_STREAMS_PER_TARGET` 管。
|
|
1596
1603
|
*/
|
|
1597
1604
|
const PICK_MAX_SSH = PICK_MAX
|
|
1598
1605
|
|
|
@@ -1618,6 +1625,42 @@ function isSshTarget(name) {
|
|
|
1618
1625
|
return targets.some((item) => item.name === name && item.kind === 'ssh')
|
|
1619
1626
|
}
|
|
1620
1627
|
|
|
1628
|
+
/**
|
|
1629
|
+
* 宿主是否支持「一条 `/logs/stream` 带多个容器」(`streamProtocol ≥ 3`,D168)。
|
|
1630
|
+
*
|
|
1631
|
+
* 为什么需要协商:聚合日志为了少占浏览器同源连接,胜在一条流里带 `ids=a,b,c`;但**没重启
|
|
1632
|
+
* 的宿主进程仍是旧代码**(模块已在内存里,浏览器却从磁盘读到新 client.js——D166/D167 那次
|
|
1633
|
+
* 开发档错位就是这么来的)。旧宿主只认 `id=`,多源请求会被 400,于是整个聚合视图一句日志
|
|
1634
|
+
* 都没有。所以拿到 config 后按版本分流:≥3 走单连接,更老退回「每容器一条流」,宁可多占
|
|
1635
|
+
* 几条连接也不能让功能凭空消失。
|
|
1636
|
+
*
|
|
1637
|
+
* config 缺失(面板还在加载)按**不支持**处理:先按老形状建流,config 到达后 effect 因
|
|
1638
|
+
* 依赖变化重跑、自动换成单连接。反过来(先乐观地按新协议建流)会在老宿主上 400 一次,
|
|
1639
|
+
* 界面上就是「连接中断」闪一下。
|
|
1640
|
+
*/
|
|
1641
|
+
function supportsMultiLogStream(config) {
|
|
1642
|
+
return config !== null
|
|
1643
|
+
&& typeof config === 'object'
|
|
1644
|
+
&& typeof config.streamProtocol === 'number'
|
|
1645
|
+
&& config.streamProtocol >= 3
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* 容器列表此刻是否真的在屏幕上 —— 活动条的事件流**只在这时**才值得占一条同源连接。
|
|
1650
|
+
*
|
|
1651
|
+
* 详情 / 拉取 / 聚合 / Compose / 网络 / 卷这些视图都会**整段替掉列表**(见渲染里的
|
|
1652
|
+
* `detailView`),活动条随之不可见;此时事件流收不到任何**看得到**的东西,却照旧占着
|
|
1653
|
+
* D165 那 6 条同源连接里的一条。回到列表时 effect 重跑,`hadOpen` 会补一次全量刷新,
|
|
1654
|
+
* 隐藏期间漏掉的事件不影响列表状态的正确性(D93 的语义,与折叠 tab 断流同一套)。
|
|
1655
|
+
*/
|
|
1656
|
+
function shouldStreamEvents(state) {
|
|
1657
|
+
return state.active === true
|
|
1658
|
+
&& state.view === 'containers'
|
|
1659
|
+
&& typeof state.target === 'string'
|
|
1660
|
+
&& state.target !== ''
|
|
1661
|
+
&& state.listVisible === true
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1621
1664
|
/**
|
|
1622
1665
|
* 目标选择器里那一项「+ 连接本机」的哨兵值。
|
|
1623
1666
|
*
|
|
@@ -3035,7 +3078,7 @@ window.__ModuleLoader__.load({
|
|
|
3035
3078
|
return { rows, from, to }
|
|
3036
3079
|
}
|
|
3037
3080
|
|
|
3038
|
-
/** 选中区间的可读描述,用于菜单标题(「6 行 ·
|
|
3081
|
+
/** 选中区间的可读描述,用于菜单标题(「6 行 · app-svc-1」)。 */
|
|
3039
3082
|
function describeSelection(context, range) {
|
|
3040
3083
|
const count = String(range.to - range.from + 1)
|
|
3041
3084
|
if (context.containers.length === 1) return count + t('meta.rowsSuffix') + ' · ' + context.containers[0].name
|
|
@@ -4900,15 +4943,18 @@ window.__ModuleLoader__.load({
|
|
|
4900
4943
|
jsx('div', { className: 'dk_tabs', children: tabs.map(([key, label]) => jsx('button', {
|
|
4901
4944
|
type: 'button', className: 'dk_tab', 'data-on': tab === key ? '1' : '0', onClick: () => setTab(key), children: label,
|
|
4902
4945
|
}, key)) }),
|
|
4903
|
-
jsx('div', { className: 'dk_detailBody', children: tab === 'services' ? servicesTab() : jsx(ComposeLogs, { target: props.target, targetLabel: props.targetLabel, items }) }),
|
|
4946
|
+
jsx('div', { className: 'dk_detailBody', children: tab === 'services' ? servicesTab() : jsx(ComposeLogs, { target: props.target, targetLabel: props.targetLabel, items, multiLog: props.multiLog }) }),
|
|
4904
4947
|
] })
|
|
4905
4948
|
}
|
|
4906
4949
|
|
|
4907
4950
|
/**
|
|
4908
|
-
*
|
|
4909
|
-
*
|
|
4951
|
+
* 项目级聚合日志:默认**所有容器共用一条 `/logs/stream?ids=…`**(D168),帧里带
|
|
4952
|
+
* `id`,客户端按 service 名前缀混流——浏览器同源连接只占一条。混流是到达序,
|
|
4910
4953
|
* 不保证跨容器严格时序——排障要的是「一屏看全这个项目的动静」,不是精确排序。
|
|
4911
|
-
*
|
|
4954
|
+
* 某个容器那条子流失败只标注状态,不影响其余容器。
|
|
4955
|
+
*
|
|
4956
|
+
* 老宿主(`streamProtocol < 3`)不支持 `ids`,此时退回「每个容器各一条流」;
|
|
4957
|
+
* 分流判据在 `supportsMultiLogStream`,由面板把 `multiLog` 传下来。
|
|
4912
4958
|
*/
|
|
4913
4959
|
/**
|
|
4914
4960
|
* docker `logs --timestamps` 前缀(RFC3339)。聚合日志为了**跨容器按时间合并**必须
|
|
@@ -5085,6 +5131,13 @@ window.__ModuleLoader__.load({
|
|
|
5085
5131
|
return next.length > limit ? { entries: next.slice(next.length - limit), dropped: true } : { entries: next, dropped: false }
|
|
5086
5132
|
}
|
|
5087
5133
|
|
|
5134
|
+
/**
|
|
5135
|
+
* 聚合日志共用组件(Compose 项目日志 / 手勾多选都走它)。
|
|
5136
|
+
*
|
|
5137
|
+
* `props.multiLog === true` 时用**一条** `/logs/stream?ids=…` 装下所有容器(D168),
|
|
5138
|
+
* 否则退回旧宿主的「每容器一条流」。判据由面板按 `/config.streamProtocol` 算好传进来,
|
|
5139
|
+
* 组件自己不再读模块级缓存——它要进 effect 依赖,config 迟到时才能自动换路径。
|
|
5140
|
+
*/
|
|
5088
5141
|
function ComposeLogs(props) {
|
|
5089
5142
|
const items = props.items
|
|
5090
5143
|
const [entries, setEntries] = useState([])
|
|
@@ -5228,21 +5281,15 @@ window.__ModuleLoader__.load({
|
|
|
5228
5281
|
clearTimeout(flushTimerRef.current)
|
|
5229
5282
|
flushTimerRef.current = null
|
|
5230
5283
|
}
|
|
5231
|
-
let open = 0
|
|
5232
5284
|
let closed = 0
|
|
5233
|
-
|
|
5234
|
-
*
|
|
5235
|
-
*
|
|
5236
|
-
*
|
|
5285
|
+
/**
|
|
5286
|
+
* 分片 → 完整行:残行按容器 id 存(pendingRef);行对象组出来时即取单调 id
|
|
5287
|
+
* (之后重排/合并只是挪位置,key 不抖动),再交给 enqueue——commit/合帧在
|
|
5288
|
+
* 组件作用域(见上)。单连接多容器与「每容器一条流」共用这一份。
|
|
5237
5289
|
*/
|
|
5238
|
-
const
|
|
5290
|
+
const makePush = (item) => {
|
|
5239
5291
|
const service = item.composeService === null ? item.name : item.composeService
|
|
5240
|
-
|
|
5241
|
-
* 分片 → 完整行:残行按 item 存(pendingRef);行对象组出来时即取单调 id
|
|
5242
|
-
* (之后重排/合并只是挪位置,key 不抖动),再交给 enqueue——commit/合帧
|
|
5243
|
-
* 在组件作用域(见上),N 条流共享同一份。
|
|
5244
|
-
*/
|
|
5245
|
-
const push = (text) => {
|
|
5292
|
+
return (text) => {
|
|
5246
5293
|
const pending = pendingRef.current.get(item.id) ?? ''
|
|
5247
5294
|
const parts = (pending + text).split('\n')
|
|
5248
5295
|
pendingRef.current.set(item.id, parts.pop() ?? '')
|
|
@@ -5258,6 +5305,110 @@ window.__ModuleLoader__.load({
|
|
|
5258
5305
|
}
|
|
5259
5306
|
enqueue(rows)
|
|
5260
5307
|
}
|
|
5308
|
+
}
|
|
5309
|
+
/*
|
|
5310
|
+
* 主机侧背压丢帧(D153):与单容器视图同一份语义,聚合成一句状态行提示;
|
|
5311
|
+
* 计数累加而不是覆盖(D157)——skip 是增量通知,覆盖会少报。两条路径共用。
|
|
5312
|
+
*/
|
|
5313
|
+
const handleSkip = (payload) => {
|
|
5314
|
+
const frames = payload !== null && typeof payload.frames === 'number' ? payload.frames : null
|
|
5315
|
+
if (frames !== null) aggSkippedRef.current += frames
|
|
5316
|
+
setSkipNotice(frames === null
|
|
5317
|
+
? t('hint.logSkippedNoCount')
|
|
5318
|
+
: t('hint.logSkipped', { frames: aggSkippedRef.current }))
|
|
5319
|
+
}
|
|
5320
|
+
const cleanupTimers = () => {
|
|
5321
|
+
if (arrivalTimerRef.current !== null) {
|
|
5322
|
+
clearTimeout(arrivalTimerRef.current)
|
|
5323
|
+
arrivalTimerRef.current = null
|
|
5324
|
+
}
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5327
|
+
/*
|
|
5328
|
+
* 多容器单连接(宿主 streamProtocol ≥ 3,D168)。
|
|
5329
|
+
*
|
|
5330
|
+
* 这是本视图**唯一**的浏览器同源连接:`ids=a,b,c` 由宿主的同一条 SSE 装下,
|
|
5331
|
+
* 帧里带 `id` 再按容器派回各自的残行缓冲。旧实现是「每个容器一条 EventSource」,
|
|
5332
|
+
* 3 个容器就是 3 条同源连接——叠加活动条的事件流正好吃满 4 条常驻流,而浏览器
|
|
5333
|
+
* 每个源只有 6 条 TCP(D165),再多一条普通请求就整页静默排队。
|
|
5334
|
+
*
|
|
5335
|
+
* 为什么不无条件走它:老宿主只认 `id=`,多源请求会 400。开发档里「宿主进程还是
|
|
5336
|
+
* 旧代码、浏览器却读到新 client.js」是真实出现过的组合(D166/D167),所以按宿主
|
|
5337
|
+
* 版本分流;老宿主退回下面「每容器一条流」,宁可多占几条连接也不能让聚合视图空白。
|
|
5338
|
+
*/
|
|
5339
|
+
if (props.multiLog === true) {
|
|
5340
|
+
const byId = new Map(items.map((item) => [item.id, item]))
|
|
5341
|
+
let ended = 0
|
|
5342
|
+
let errored = 0
|
|
5343
|
+
const noteSourceEnd = () => {
|
|
5344
|
+
ended += 1
|
|
5345
|
+
if (errored > 0) setStatus('partial')
|
|
5346
|
+
else if (ended >= items.length) setStatus('closed')
|
|
5347
|
+
}
|
|
5348
|
+
const handle = subscribeLogStream({
|
|
5349
|
+
t,
|
|
5350
|
+
/*
|
|
5351
|
+
* timestamps=1:聚合视图为「按时间合并」与可选显示时间戳固定带上的参数。
|
|
5352
|
+
* 首连带 tail 补历史、重连一律 tail=0(D133):单连接下所有容器同一口径,
|
|
5353
|
+
* 不会出现「某一条重连把历史重放一遍」的重复。
|
|
5354
|
+
*/
|
|
5355
|
+
buildUrl: (tailNow) => streamUrl('/logs/stream', { target: props.target, ids: itemIds, tail: String(tailNow), timestamps: '1' }),
|
|
5356
|
+
tail,
|
|
5357
|
+
onStatus: (next) => {
|
|
5358
|
+
if (next === 'connecting') return
|
|
5359
|
+
if (next === 'open') {
|
|
5360
|
+
// 一条连接 = 一次握手:跳过计数与提示清零(D157,与单容器视图同一语义)
|
|
5361
|
+
aggSkippedRef.current = 0
|
|
5362
|
+
setSkipNotice('')
|
|
5363
|
+
setStatus('open')
|
|
5364
|
+
return
|
|
5365
|
+
}
|
|
5366
|
+
if (next === 'reconnecting') setStatus('reconnecting')
|
|
5367
|
+
},
|
|
5368
|
+
onLine: (text, payload) => {
|
|
5369
|
+
/*
|
|
5370
|
+
* 帧里没有 id(理论上不会:ids 请求必然带)→ 丢掉这一片,**不猜**
|
|
5371
|
+
* 「大概是第一个容器」——猜错就是把 A 的日志写进 B 的行里。
|
|
5372
|
+
*/
|
|
5373
|
+
const item = typeof payload.id === 'string' ? byId.get(payload.id) : undefined
|
|
5374
|
+
if (item !== undefined) makePush(item)(text)
|
|
5375
|
+
},
|
|
5376
|
+
onSkip: handleSkip,
|
|
5377
|
+
// 单个容器停了:整条连接还在推其余容器,只计数、不改连接状态
|
|
5378
|
+
onSourceEnd: noteSourceEnd,
|
|
5379
|
+
onSourceError: () => {
|
|
5380
|
+
// 单个容器失败:其余容器的日志照常收;状态行标注「部分出错」
|
|
5381
|
+
errored += 1
|
|
5382
|
+
setStatus('partial')
|
|
5383
|
+
},
|
|
5384
|
+
onEnd: (payload, controls) => {
|
|
5385
|
+
const reason = payload !== null && typeof payload.reason === 'string' ? payload.reason : 'container-exit'
|
|
5386
|
+
if (reason === 'container-exit') {
|
|
5387
|
+
// 所有容器都到头了:整条连接自然结束
|
|
5388
|
+
controls.close()
|
|
5389
|
+
setStatus(errored > 0 ? 'partial' : 'closed')
|
|
5390
|
+
return
|
|
5391
|
+
}
|
|
5392
|
+
// 服务端停流 / 宿主队列溢出:重连(只补新行)
|
|
5393
|
+
controls.reconnect()
|
|
5394
|
+
},
|
|
5395
|
+
onError: () => {
|
|
5396
|
+
// 服务端 event:error(参数 / 执行失败):整条连接没有可用数据了
|
|
5397
|
+
setStatus('partial')
|
|
5398
|
+
},
|
|
5399
|
+
})
|
|
5400
|
+
return () => {
|
|
5401
|
+
handle.close()
|
|
5402
|
+
cleanupTimers()
|
|
5403
|
+
}
|
|
5404
|
+
}
|
|
5405
|
+
|
|
5406
|
+
/*
|
|
5407
|
+
* 旧宿主(streamProtocol < 3)的退路:**每个容器各一条** /logs/stream。
|
|
5408
|
+
* 首连带 tail 补历史、重连带 tail=0(D133);一条流断了不影响其余流——
|
|
5409
|
+
* 这正是它对同源连接的代价所在(N 个容器 = N 条,D165 的账)。
|
|
5410
|
+
*/
|
|
5411
|
+
const sources = items.map((item) => {
|
|
5261
5412
|
// timestamps=1:聚合视图为「按时间合并」与可选显示时间戳固定带上的参数
|
|
5262
5413
|
const handle = subscribeLogStream({
|
|
5263
5414
|
t,
|
|
@@ -5266,7 +5417,6 @@ window.__ModuleLoader__.load({
|
|
|
5266
5417
|
onStatus: (next) => {
|
|
5267
5418
|
if (next === 'connecting') return
|
|
5268
5419
|
if (next === 'open') {
|
|
5269
|
-
open += 1
|
|
5270
5420
|
// 任一条容器流重连成功:跳过计数与提示清零(D157,与单容器视图同一语义)
|
|
5271
5421
|
aggSkippedRef.current = 0
|
|
5272
5422
|
setSkipNotice('')
|
|
@@ -5275,16 +5425,8 @@ window.__ModuleLoader__.load({
|
|
|
5275
5425
|
}
|
|
5276
5426
|
if (next === 'reconnecting') setStatus('reconnecting')
|
|
5277
5427
|
},
|
|
5278
|
-
onLine:
|
|
5279
|
-
|
|
5280
|
-
// 计数累加而不是覆盖(D157)——skip 是增量通知,覆盖会少报
|
|
5281
|
-
onSkip: (payload) => {
|
|
5282
|
-
const frames = payload !== null && typeof payload.frames === 'number' ? payload.frames : null
|
|
5283
|
-
if (frames !== null) aggSkippedRef.current += frames
|
|
5284
|
-
setSkipNotice(frames === null
|
|
5285
|
-
? t('hint.logSkippedNoCount')
|
|
5286
|
-
: t('hint.logSkipped', { frames: aggSkippedRef.current }))
|
|
5287
|
-
},
|
|
5428
|
+
onLine: makePush(item),
|
|
5429
|
+
onSkip: handleSkip,
|
|
5288
5430
|
onEnd: (payload, controls) => {
|
|
5289
5431
|
const reason = payload !== null && typeof payload.reason === 'string' ? payload.reason : 'container-exit'
|
|
5290
5432
|
if (reason === 'container-exit') {
|
|
@@ -5304,15 +5446,11 @@ window.__ModuleLoader__.load({
|
|
|
5304
5446
|
})
|
|
5305
5447
|
return () => handle.close()
|
|
5306
5448
|
})
|
|
5307
|
-
void open
|
|
5308
5449
|
return () => {
|
|
5309
5450
|
for (const close of sources) close()
|
|
5310
|
-
|
|
5311
|
-
clearTimeout(arrivalTimerRef.current)
|
|
5312
|
-
arrivalTimerRef.current = null
|
|
5313
|
-
}
|
|
5451
|
+
cleanupTimers()
|
|
5314
5452
|
}
|
|
5315
|
-
}, [active, props.target, itemIds, tail])
|
|
5453
|
+
}, [active, props.target, itemIds, tail, props.multiLog])
|
|
5316
5454
|
|
|
5317
5455
|
// 上滚看历史时不拽回底部(D61)/ 贴底时钉到最底:搬进 useLogRows(D152)的 pin——
|
|
5318
5456
|
// 那里是绘制前的 layout effect,垫高与滚动位置同一次提交落定;暂停时不 pin(D61)。
|
|
@@ -5693,7 +5831,7 @@ window.__ModuleLoader__.load({
|
|
|
5693
5831
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
5694
5832
|
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.closePanel'), onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
|
|
5695
5833
|
] }),
|
|
5696
|
-
jsx('div', { className: 'dk_detailBody', children: jsx(ComposeLogs, { target: props.target, targetLabel: props.targetLabel, items: props.items }) }),
|
|
5834
|
+
jsx('div', { className: 'dk_detailBody', children: jsx(ComposeLogs, { target: props.target, targetLabel: props.targetLabel, items: props.items, multiLog: props.multiLog }) }),
|
|
5697
5835
|
] })
|
|
5698
5836
|
}
|
|
5699
5837
|
|
|
@@ -6533,7 +6671,27 @@ window.__ModuleLoader__.load({
|
|
|
6533
6671
|
}
|
|
6534
6672
|
|
|
6535
6673
|
/**
|
|
6536
|
-
*
|
|
6674
|
+
* 列表此刻是否在屏幕上(D168)。
|
|
6675
|
+
*
|
|
6676
|
+
* 详情 / 镜像详情 / 拉取 / Compose / 聚合日志 / 网络 / 卷都会**整段替掉列表**
|
|
6677
|
+
* (见下面渲染里的 `detailView` 分支)——活动条跟着一起消失。此时事件流没有任何
|
|
6678
|
+
* 可见的消费者,却照旧占着浏览器同源连接里的一条(D165:6 条里的一条),正是
|
|
6679
|
+
* 「聚合日志 + 事件流 = 4 条 docker 常驻流」这个危险形状的最后一条。
|
|
6680
|
+
*
|
|
6681
|
+
* 判据放在 effect 外、用 state 直接拼,而不是去读渲染末尾的 `detailView`:hooks
|
|
6682
|
+
* 的依赖数组在渲染时求值,引用后面才声明的 const 会踩 TDZ(本仓踩过,整棵 React
|
|
6683
|
+
* 树被卸载、面板空白)。这里的状态都在本行之前声明。
|
|
6684
|
+
*/
|
|
6685
|
+
const listVisible = detail === null
|
|
6686
|
+
&& imageDetail === null
|
|
6687
|
+
&& pullOpen === false
|
|
6688
|
+
&& composeDetail === null
|
|
6689
|
+
&& aggregateOpen === false
|
|
6690
|
+
&& networkDetail === null
|
|
6691
|
+
&& volumeDetail === null
|
|
6692
|
+
|
|
6693
|
+
/**
|
|
6694
|
+
* 事件流生命周期:只在容器列表页、且选了目标、**且列表真的在场**时开一条。
|
|
6537
6695
|
*
|
|
6538
6696
|
* 两个关键语义:
|
|
6539
6697
|
* 1. **防抖刷新**:一帧事件不刷一次列表,攒到 500ms 静默期再刷(见
|
|
@@ -6541,13 +6699,14 @@ window.__ModuleLoader__.load({
|
|
|
6541
6699
|
* 2. **重连补偿**:EventSource 断线会自动重连,但断线窗口里的事件已经漏了,
|
|
6542
6700
|
* 所以重连成功后先补一次全量列表刷新把状态对齐;首次 open 不补(列表刚加载过)。
|
|
6543
6701
|
*
|
|
6544
|
-
* 切页 / 切 target / 关面板都会走 effect 清理:关流 +
|
|
6702
|
+
* 切页 / 切 target / 关面板都会走 effect 清理:关流 + 取消防抖。进入详情或聚合
|
|
6703
|
+
* 视图同样会关(列表被替掉、活动条不可见,D168),回来时按 `hadOpen` 补一次刷新。
|
|
6545
6704
|
*/
|
|
6546
6705
|
useEffect(() => {
|
|
6547
|
-
// 折叠的 tab 不建流(S3
|
|
6548
|
-
//
|
|
6549
|
-
|
|
6550
|
-
if (
|
|
6706
|
+
// 折叠的 tab 不建流(S3);列表被详情 / 聚合视图替掉时同理(D168)。
|
|
6707
|
+
// 代价是隐藏期间的活动条事件会漏掉——但重连后本来就会补一次全量列表刷新
|
|
6708
|
+
// (见上面的「重连补偿」),列表状态不会因此失真。
|
|
6709
|
+
if (!shouldStreamEvents({ active, view, target, listVisible })) return undefined
|
|
6551
6710
|
if (typeof EventSource !== 'function') {
|
|
6552
6711
|
setEventsStatus('unsupported')
|
|
6553
6712
|
return undefined
|
|
@@ -6674,7 +6833,7 @@ window.__ModuleLoader__.load({
|
|
|
6674
6833
|
close()
|
|
6675
6834
|
debounced.cancel()
|
|
6676
6835
|
}
|
|
6677
|
-
}, [active, view, target, eventsReconnect])
|
|
6836
|
+
}, [active, view, target, eventsReconnect, listVisible])
|
|
6678
6837
|
|
|
6679
6838
|
/*
|
|
6680
6839
|
* 宿主版本协商(2026-10-11):`streamProtocol < 2` 的宿主从不发 `ready`。
|
|
@@ -7172,7 +7331,8 @@ window.__ModuleLoader__.load({
|
|
|
7172
7331
|
* 不算数了,不至于出现「已选 3 个」但只连出 2 条流。
|
|
7173
7332
|
*/
|
|
7174
7333
|
const aggregateItems = pickItems(containers, pickedIds)
|
|
7175
|
-
//
|
|
7334
|
+
// 与目标类型无关了(D168 后浏览器同源连接不再随选择数增长),两个上限同值;
|
|
7335
|
+
// 保留分档只是为了「目标类型 → 预算」这层映射不被悄悄删掉。
|
|
7176
7336
|
const pickTargetIsSsh = isSshTarget(target)
|
|
7177
7337
|
const pickMax = pickTargetIsSsh ? PICK_MAX_SSH : PICK_MAX
|
|
7178
7338
|
const pickInfo = pickDecide(aggregateItems.length, pickTargetIsSsh)
|
|
@@ -7196,6 +7356,12 @@ window.__ModuleLoader__.load({
|
|
|
7196
7356
|
const composeItems = composeDetail === null
|
|
7197
7357
|
? []
|
|
7198
7358
|
: (groupCompose(containers).find((group) => group.project === composeDetail.project)?.items ?? [])
|
|
7359
|
+
/*
|
|
7360
|
+
* 聚合日志的建流方式(D168):宿主 `streamProtocol ≥ 3` 才能用一条流装多个容器,
|
|
7361
|
+
* 老宿主退回「每容器一条」。config 还没到时按老路径走,到达后这个布尔值变化会
|
|
7362
|
+
* 触发 ComposeLogs 的 effect 重跑、自动切到单连接(不需要用户重开视图)。
|
|
7363
|
+
*/
|
|
7364
|
+
const multiLog = supportsMultiLogStream(config)
|
|
7199
7365
|
/*
|
|
7200
7366
|
* 整栏视图互斥:容器详情 > 镜像详情 > 拉取进度 > Compose 项目。
|
|
7201
7367
|
* 它们都占满正文(不叠列表工具条),同一个位置只挂一个。
|
|
@@ -7238,6 +7404,7 @@ window.__ModuleLoader__.load({
|
|
|
7238
7404
|
items: composeItems,
|
|
7239
7405
|
target,
|
|
7240
7406
|
targetLabel: targetLabel(target),
|
|
7407
|
+
multiLog,
|
|
7241
7408
|
onBack: () => setComposeDetail(null),
|
|
7242
7409
|
onClose: requestClose,
|
|
7243
7410
|
docked,
|
|
@@ -7248,6 +7415,7 @@ window.__ModuleLoader__.load({
|
|
|
7248
7415
|
items: aggregateItems,
|
|
7249
7416
|
target,
|
|
7250
7417
|
targetLabel: targetLabel(target),
|
|
7418
|
+
multiLog,
|
|
7251
7419
|
onBack: resetPick,
|
|
7252
7420
|
onClose: requestClose,
|
|
7253
7421
|
docked,
|
|
@@ -8943,6 +9111,15 @@ window.__ModuleLoader__.load({
|
|
|
8943
9111
|
* 这类**接线**错误(路径拼歪了在真机上才暴露)。
|
|
8944
9112
|
*/
|
|
8945
9113
|
exports.__api = api
|
|
9114
|
+
/*
|
|
9115
|
+
* 流判定的测试缝(D168):这两条纯函数决定的都是**浏览器同源连接数**——宿主是否
|
|
9116
|
+
* 支持「一条流装多个容器」、活动条的事件流该不该占一条。连接数没法在离线冒烟里
|
|
9117
|
+
* 数,只能把判定钉住;组件侧的接线由 `client-smoke` 的源码级锚点补。
|
|
9118
|
+
*/
|
|
9119
|
+
exports.__streams = {
|
|
9120
|
+
multiLog: supportsMultiLogStream,
|
|
9121
|
+
shouldStreamEvents,
|
|
9122
|
+
}
|
|
8946
9123
|
/*
|
|
8947
9124
|
* 日志缓冲的测试缝:环形上限 / 残行分片 / 单调 id / 快照切分都是纯逻辑,离线冒烟
|
|
8948
9125
|
* 直接驱动(真实 EventSource 时序进不了 Node 桩)。常量挂出来供用例对齐,避免两边漂移。
|
package/client-src/log-stream.js
CHANGED
|
@@ -26,13 +26,20 @@ export function reconnectTail(initialTail, isReconnect) {
|
|
|
26
26
|
* buildUrl: (tail: number) => string,
|
|
27
27
|
* tail: number,
|
|
28
28
|
* onOpen?: () => void,
|
|
29
|
-
* onLine?: (text: string) => void,
|
|
29
|
+
* onLine?: (text: string, payload: Record<string, unknown>) => void,
|
|
30
30
|
* onSkip?: (payload: { frames?: number, bytes?: number } | null) => void,
|
|
31
|
+
* onSourceEnd?: (payload: Record<string, unknown>) => void,
|
|
32
|
+
* onSourceError?: (payload: Record<string, unknown>) => void,
|
|
31
33
|
* onEnd?: (payload: Record<string, unknown> | null, controls: { reconnect: () => void }) => void,
|
|
32
34
|
* onError?: (message: string, controls: { close: () => void, reconnect: () => void }) => void,
|
|
33
35
|
* onStatus?: (status: 'connecting' | 'open' | 'reconnecting' | 'closed') => void,
|
|
34
36
|
* t?: (key: string) => string,
|
|
35
37
|
* }} options
|
|
38
|
+
*
|
|
39
|
+
* 多容器单连接(宿主 `streamProtocol ≥ 3`):一条 `/logs/stream?ids=…` 里混着多个容器的
|
|
40
|
+
* 分片,`line` 帧因此带 `id`。`onLine` 的第二个参数就是解析后的整帧(老宿主没有 `id`,
|
|
41
|
+
* 调用方按「只有一个容器」处理即可);`source-end` / `source-error` 是**单个容器**的
|
|
42
|
+
* 收尾,与整条连接的 `end` / `error` 分开——一个容器停了,不代表其余容器没日志了。
|
|
36
43
|
* @returns {{ close: () => void, reconnect: () => void }}
|
|
37
44
|
*/
|
|
38
45
|
export function subscribeLogStream(options) {
|
|
@@ -117,8 +124,46 @@ export function subscribeLogStream(options) {
|
|
|
117
124
|
}
|
|
118
125
|
if (payload === null || typeof payload !== 'object') return
|
|
119
126
|
markOpen()
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
/*
|
|
128
|
+
* 第二个参数是整帧:多容器单连接时调用方要按 `payload.id` 把分片派回对应容器
|
|
129
|
+
* (单容器调用方忽略它,行为与旧版一致)。
|
|
130
|
+
*/
|
|
131
|
+
if (typeof payload.d === 'string') options.onLine?.(payload.d, payload)
|
|
132
|
+
else if (typeof payload.e === 'string') options.onLine?.(payload.e, payload)
|
|
133
|
+
})
|
|
134
|
+
/*
|
|
135
|
+
* 单个容器的收尾(多容器单连接专有,`streamProtocol ≥ 3` 才有)。
|
|
136
|
+
*
|
|
137
|
+
* 与 `end` 的区别是**作用域**:`end` 是整条连接结束(所有容器都到头了),
|
|
138
|
+
* 而这里只是某一个容器停了——其余容器的日志还得继续收。所以不 close、不
|
|
139
|
+
* 改连接状态,只把「谁结束了」交给调用方计数。
|
|
140
|
+
*/
|
|
141
|
+
next.addEventListener('source-end', (event) => {
|
|
142
|
+
if (source !== next) return
|
|
143
|
+
let payload = null
|
|
144
|
+
try {
|
|
145
|
+
payload = JSON.parse(event.data)
|
|
146
|
+
} catch {
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (payload === null || typeof payload !== 'object') return
|
|
150
|
+
options.onSourceEnd?.(payload)
|
|
151
|
+
})
|
|
152
|
+
/*
|
|
153
|
+
* 单个容器**这一条**失败(spawn 错 / 远端通道被拒):同样不影响其余容器。
|
|
154
|
+
* 注意它是独立的 SSE 事件名,不走 `error`——`error` 在 EventSource 里还兼着
|
|
155
|
+
* 连接层错误的语义,混在一起会让「一个容器失败」被当成「整条流断了」。
|
|
156
|
+
*/
|
|
157
|
+
next.addEventListener('source-error', (event) => {
|
|
158
|
+
if (source !== next) return
|
|
159
|
+
let payload = null
|
|
160
|
+
try {
|
|
161
|
+
payload = JSON.parse(event.data)
|
|
162
|
+
} catch {
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
if (payload === null || typeof payload !== 'object') return
|
|
166
|
+
options.onSourceError?.(payload)
|
|
122
167
|
})
|
|
123
168
|
next.addEventListener('skip', (event) => {
|
|
124
169
|
if (source !== next) return
|
|
@@ -43,7 +43,7 @@ export function sessionHostPort(spec, liveTarget) {
|
|
|
43
43
|
/**
|
|
44
44
|
* 在已配置目标里按 host:port 找出匹配的那个(`label` 形如 `user@host:port`)。
|
|
45
45
|
*
|
|
46
|
-
* **用户名不参与比较**:同一台主机常用不同账号配多条连接簿条目(root /
|
|
46
|
+
* **用户名不参与比较**:同一台主机常用不同账号配多条连接簿条目(root / operator),
|
|
47
47
|
* 只按账号比会漏配。端口按 22 缺省(label 省略端口时视为 22)。
|
|
48
48
|
*
|
|
49
49
|
* @param rows 宿主 `/targets` 返回的目标行。
|