@hyzyn/dsh-docker 0.13.3 → 0.13.6

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.
@@ -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': '浏览器每个站点最多同时保持 4 条实时流(含活动条的事件流),再多页面就会卡住',
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': '已连接 {count} 条容器日志流(docker logs -f)',
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 — more would exhaust the browser\'s per-origin connections and stall the whole page',
557
- 'hint.pickMaxSsh': 'At most {max} containers — more would exhaust the browser\'s per-origin connections and stall the whole page',
558
- 'hint.pickMany': 'Close to the limit — selecting more will slow the whole page down',
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': 'A browser keeps at most 4 live streams per origin (including the Activity strip\'s event stream); more will stall the page',
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': 'Connected to {count} container log stream(s) (docker logs -f)',
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
- * (见下方 events effect),聚合日志只能用掉剩下的 3 条。
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
- const PICK_MAX = SAME_ORIGIN_STREAM_BUDGET - RESERVED_STREAMS
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
- * 与硬上限之间刻意**留一档**:硬上限是「再点就连页面都卡」的红线,而用户在 3 条时
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
- * `MaxSessions` 由宿主侧的 `MAX_STREAMS_PER_TARGET` 管。
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
  *
@@ -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
- * 项目级聚合日志:**每个容器各开一条 /logs/stream**,客户端按 service 名前缀
4909
- * 混流(宿主侧 logsStream 本就支持任意容器,无需新接口)。混流是到达序,
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
- * 每条容器流各一个订阅句柄(D133):首连带 tail 补历史,**重连带 tail=0** 只补
5235
- * 新行。聚合视图一条流断了就用 EventSource 自动重连的话,那条流的 tail 会被
5236
- * 整段重放,混进同一条列表里就是「凭空多出一段重复日志」。
5285
+ /**
5286
+ * 分片 → 完整行:残行按容器 id 存(pendingRef);行对象组出来时即取单调 id
5287
+ * (之后重排/合并只是挪位置,key 不抖动),再交给 enqueue——commit/合帧在
5288
+ * 组件作用域(见上)。单连接多容器与「每容器一条流」共用这一份。
5237
5289
  */
5238
- const sources = items.map((item) => {
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: push,
5279
- // 主机侧背压丢帧(D153):与单容器视图同一份语义,聚合成一句状态行提示;
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
- if (arrivalTimerRef.current !== null) {
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
- if (!active) return undefined
6550
- if (view !== 'containers' || target === '') return undefined
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
- // SSH 目标上一条连接要同时装下所有流与短命令,所以聚合上限更紧(见 PICK_MAX_SSH)
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 桩)。常量挂出来供用例对齐,避免两边漂移。
@@ -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
- if (typeof payload.d === 'string') options.onLine?.(payload.d)
121
- else if (typeof payload.e === 'string') options.onLine?.(payload.e)
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