@hyzyn/dsh-docker 0.10.0 → 0.12.0

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,10 +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} 个容器(SSH 目标上一条连接要同时装实时流与刷新等短命令)',
67
- 'hint.pickMany': '连接数较多,浏览器并发长连接有限制',
65
+ 'hint.pickMaxLocal': '最多 {max} 个容器——再多会占满浏览器的同源连接,整个页面都会卡',
66
+ 'hint.pickMaxSsh': '最多 {max} 个容器——再多会占满浏览器的同源连接,整个页面都会卡',
67
+ 'hint.pickMany': '已接近上限,再选下去整个页面会变卡',
68
68
  'hint.pickAtLeastTwo': '至少选择 2 个容器',
69
+ 'hint.pickBudget': '浏览器每个站点最多同时保持 4 条实时流(含活动条的事件流),再多页面就会卡住',
69
70
  'option.presetAll': '全部可见',
70
71
  'status.unhealthy': '不健康',
71
72
  'status.attention': '需关注',
@@ -361,7 +362,8 @@ const I18N_ZH = {
361
362
  'status.backToListRefresh': ',列表回到 AUTO REFRESH / 手动刷新;活动条上点「重连」可重新订阅',
362
363
  'btn.reconnectEvents': '重连',
363
364
  'hint.reconnectEvents': '重新打开容器事件流(docker events);列表刷新按钮也会一并重连',
364
- 'hint.eventsClosedNoAuto': '事件流断开后不会自己回来(EventSource 只在连接层抖动时自愈;docker events 进程退出属于终止态)——点它重连,或点右上角刷新列表',
365
+ 'hint.eventsClosedNoAuto': '不会自动恢复,点「重连」',
366
+ 'hint.eventsReconnectRefused': '重连被拒:{message}',
365
367
  'hint.conversationHidden': ' · 会话在面板后面:关掉或最小化面板/终端即可看到',
366
368
  'msg.copied': '已复制:',
367
369
  'error.copyManual': '复制失败,请手动执行:',
@@ -481,7 +483,9 @@ const I18N_ZH = {
481
483
  'field.execTimeout': 'exec 超时(秒)',
482
484
  'section.capabilities': '能力开关(默认关闭)',
483
485
  'check.allowMutations': '允许变更操作(容器启停删、镜像拉取 / 删除 / 清理)',
484
- 'check.allowExec': '允许 exec(在容器内执行命令)',
486
+ // 限定到「一次性」通道:旧文案「(在容器内执行命令)」会被读成「exec 整体要授权」,
487
+ // 而卡片「终端」按钮的 docker exec -it 不受它管(D163)。设置卡片是用户第一眼看到的地方。
488
+ 'check.allowExec': '允许一次性 exec(面板命令框与 docker_exec;交互式进容器不由它管)',
485
489
  'hint.socketRoot': 'docker socket 等价于目标主机的 root 权限。开启后,浏览器面板与 agent 都能执行对应操作,请只在可信环境下打开。',
486
490
  'hint.capabilityNotGranted': '⚠ 宿主尚未授权:这两个开关现在打不开,点它会给出一条就地确认的命令(免重启)。关掉它们随时可用。',
487
491
  'badge.notEffective': '未生效:未获宿主授权',
@@ -549,10 +553,11 @@ const I18N_EN = {
549
553
  'status.restarting': 'Restarting',
550
554
  'status.removing': 'Removing',
551
555
  'status.unknown': 'Unknown',
552
- 'hint.pickMaxLocal': 'At most {max} containers; browsers limit concurrent long-lived connections',
553
- 'hint.pickMaxSsh': 'At most {max} containers (a single SSH connection must also carry live streams and short refresh commands)',
554
- 'hint.pickMany': 'Many streams — browsers limit concurrent long-lived connections',
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',
555
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',
556
561
  'option.presetAll': 'All visible',
557
562
  'status.unhealthy': 'Unhealthy',
558
563
  'status.attention': 'Needs attention',
@@ -848,7 +853,8 @@ const I18N_EN = {
848
853
  'status.backToListRefresh': ', the list returns to AUTO REFRESH / manual refresh; hit “Reconnect” on the activity bar to resubscribe',
849
854
  'btn.reconnectEvents': 'Reconnect',
850
855
  'hint.reconnectEvents': 'Reopen the container event stream (docker events); the list refresh button reconnects it too',
851
- 'hint.eventsClosedNoAuto': 'A closed event stream does not come back on its own (EventSource only self-heals connection blips; the docker events process exiting is terminal) — click to reconnect, or hit refresh in the top right',
856
+ 'hint.eventsClosedNoAuto': 'Will not recover on its own — click Reconnect',
857
+ 'hint.eventsReconnectRefused': 'Reconnect refused: {message}',
852
858
  'hint.conversationHidden': ' · the session is behind this panel: close or minimise the panel/terminal to see it',
853
859
  'msg.copied': 'Copied: ',
854
860
  'error.copyManual': 'Copy failed, run it manually: ',
@@ -968,7 +974,7 @@ const I18N_EN = {
968
974
  'field.execTimeout': 'exec timeout (seconds)',
969
975
  'section.capabilities': 'Capability switches (off by default)',
970
976
  'check.allowMutations': 'Allow changes (start/stop/remove containers, pull / remove / prune images)',
971
- 'check.allowExec': 'Allow exec (run commands inside containers)',
977
+ 'check.allowExec': 'Allow one-shot exec (panel command box and docker_exec; interactive container access is not gated by it)',
972
978
  'hint.socketRoot': 'The docker socket is equivalent to root on the target host. Once enabled, both the browser panel and the agent can run these operations — only turn it on in a trusted environment.',
973
979
  'hint.capabilityNotGranted': '⚠ Not granted by the host: these two switches cannot be turned on right now — clicking one gives you a command to confirm in place (no restart). Turning them off always works.',
974
980
  'badge.notEffective': 'Not effective: not granted by the host',
@@ -1553,17 +1559,42 @@ const ICON_VOLUME =
1553
1559
  * 不进 settings,也不新增任何服务端字段,刷新后按 id 对账即可。
1554
1560
  */
1555
1561
 
1556
- /** 超过这个数就给「浏览器并发长连接有限制」的软提示(仍可聚合)。 */
1557
- const PICK_SOFT_MAX = 6
1558
- /** 硬上限:再多就置灰——同源长连接排队后,聚合流反而会「看起来卡住」。 */
1559
- const PICK_MAX = 8
1562
+ /*
1563
+ * 浏览器**同源并发连接**预算(D165,2026-10-08 实测)。
1564
+ *
1565
+ * 为什么这是硬约束而不是「性能建议」:宿主是 **HTTP/1.1**(没有 h2 多路复用),浏览器对
1566
+ * 每个源只放这么多条 TCP;而**每条 EventSource 独占一条连接、且永不主动释放**。吃满之后
1567
+ * 同一个页面里**所有**普通请求(`/api/*`、列表刷新、会话加载……)全部排队——用户看到的是
1568
+ * 「整个 DSH 页面卡住」,不只是 docker 面板。
1569
+ *
1570
+ * 实测(真 Chrome + test profile 宿主,页内连打 10 次普通请求、2s 超时):
1571
+ * 常驻流 4 条 → 6/6 成功、中位 29ms;常驻流 5 条 → **0/6 全部超时**。
1572
+ * 宿主侧同时刻实测 41ms(进程外请求正常)——**堵在浏览器,不在宿主**。
1573
+ * 所以预算是 4 条常驻流,且要**连同事件流一起算**:面板停在容器页时事件流恒开
1574
+ * (见下方 events effect),聚合日志只能用掉剩下的 3 条。
1575
+ *
1576
+ * 这个数**不是猜的**,是量出来的;改它之前请重跑那组实验(README「已知限制」有复现步骤)。
1577
+ */
1578
+ const SAME_ORIGIN_STREAM_BUDGET = 4
1579
+ /** 容器页常开的那条事件流(活动条)。它在预算里,不能被聚合日志挤掉。 */
1580
+ const RESERVED_STREAMS = 1
1581
+ /** 聚合日志可达的硬上限 = 预算 − 事件流。 */
1582
+ const PICK_MAX = SAME_ORIGIN_STREAM_BUDGET - RESERVED_STREAMS
1560
1583
  /**
1561
- * SSH 目标的硬上限:一个目标只维持**一条** TCP 连接,通道额度(OpenSSH `MaxSessions`
1562
- * 默认 10)要同时装下聚合流、统计流、事件流与「刷新列表」这类短命令。留出余量之后
1563
- * 聚合最多 6 条——正好等于软提示线,于是 SSH 上不再有「可点但已偏多」的区间。
1564
- * 本地目标走子进程,没有这个约束,仍是 {@link PICK_MAX}。
1584
+ * 超过这个数就给软提示(仍可聚合)。
1585
+ *
1586
+ * 与硬上限之间刻意**留一档**:硬上限是「再点就连页面都卡」的红线,而用户在 3 条时
1587
+ * 已经在消耗大部分预算——给一句提醒,但不禁用。
1565
1588
  */
1566
- const PICK_MAX_SSH = 6
1589
+ const PICK_SOFT_MAX = 2
1590
+ /**
1591
+ * SSH 目标为什么不再单独降一档:真正卡住页面的是**浏览器同源预算**(上面那条,与目标
1592
+ * 类型无关),而不是 SSH 的 `MaxSessions`。旧代码把上限按「SSH 要同时装实时流与短命令」
1593
+ * 设成 6——那条例由针对的是**宿主侧**通道额度,与浏览器这一层是两笔独立的账,写成同一个
1594
+ * 数字正好把两者都算错(6 > 实测安全值)。现在两者各归各:浏览器预算管上限,
1595
+ * `MaxSessions` 由宿主侧的 `MAX_STREAMS_PER_TARGET` 管。
1596
+ */
1597
+ const PICK_MAX_SSH = PICK_MAX
1567
1598
 
1568
1599
  /**
1569
1600
  * 目标下拉里要不要列「+ 连接本机」那一项。
@@ -1628,8 +1659,8 @@ function pickReconcile(ids, containers) {
1628
1659
 
1629
1660
  /**
1630
1661
  * 「按条件一键选择」的预设(0.15.0)。
1631
- * 为什么需要:跨 30+ 容器里挑 4 个不健康的,手动点既慢又容易漏;而聚合日志的上限是
1632
- * 8 条流,所以条件选择必须能**按上限截断并如实告知略过了几个**。
1662
+ * 为什么需要:跨 30+ 容器里挑几个不健康的,手动点既慢又容易漏;而聚合日志有上限
1663
+ * (见 {@link PICK_MAX}),所以条件选择必须能**按上限截断并如实告知略过了几个**。
1633
1664
  * `needsBase` 的项要先有勾选(拿第一个勾选的容器当基准:同镜像 / 同项目)。
1634
1665
  */
1635
1666
  const PICK_PRESETS = [
@@ -1967,6 +1998,36 @@ function canReconnectEvents(status) {
1967
1998
  return status === 'closed'
1968
1999
  }
1969
2000
 
2001
+ /**
2002
+ * 从事件流的 `error` 事件里解出**服务端给的原因**(D164)。
2003
+ *
2004
+ * 为什么必须解出来:这条流的 `event: error` 带着 data 时(服务端主动拒绝 / 收尾),原文案
2005
+ * 是宿主侧唯一一句可执行的话——例如长流配额满时的「关掉部分实时跟随、把聚合容器数减到 6 个
2006
+ * 以内」。老代码把它整个丢掉、只置 `closed`,于是**点「重连」看起来毫无反应**:拒绝 →
2007
+ * 状态回 `closed` → 界面与点击前逐像素相同,用户只能反复点。统计流一直是对的
2008
+ * (`setStatsError(message)`),这里补齐同一套语义。
2009
+ *
2010
+ * 连接层错误(无 data 的普通 Event)不是「服务端说了什么」,返回空串——那种情况
2011
+ * EventSource 会自己重连,不该冒充成一次被拒绝的重连。
2012
+ *
2013
+ * 抽成纯函数而不是写在组件闭包里:离线冒烟的 React 桩把 `useState` 冻在初值,组件里
2014
+ * 遍历渲染树取不到真实调用结果,判定必须能直接驱动(与 canReconnectEvents 同一个理由)。
2015
+ */
2016
+ function eventsErrorMessage(raw) {
2017
+ if (raw === null || raw === undefined) return ''
2018
+ const data = raw.data
2019
+ if (typeof data !== 'string' || data === '') return ''
2020
+ try {
2021
+ const payload = JSON.parse(data)
2022
+ if (payload !== null && typeof payload === 'object' && typeof payload.message === 'string') {
2023
+ return payload.message
2024
+ }
2025
+ } catch {
2026
+ /* 畸形载荷:按「没有可读原因」处理,不要拿半截 JSON 当文案 */
2027
+ }
2028
+ return data
2029
+ }
2030
+
1970
2031
  /** 新事件放最前(活动条按时间倒序读),超出上限丢最旧。 */
1971
2032
  function pushEvent(list, event, limit) {
1972
2033
  const next = [event, ...list]
@@ -5480,6 +5541,19 @@ window.__ModuleLoader__.load({
5480
5541
  * `unsupported` 刻意不给这个按钮:那个环境根本没有 EventSource,重连必然还是失败。
5481
5542
  */
5482
5543
  const canReconnect = canReconnectEvents(props.status)
5544
+ /*
5545
+ * 断开时右侧那句提示(D164):**被拒过就优先说原因**。「事件流已断开」只说状态,
5546
+ * 用户据此以为点一下就好;而真正拦着重连的是服务端那句话(例如长流配额满),
5547
+ * 不显示出来,「重连」按钮点多少次都像假的。
5548
+ *
5549
+ * 文案要**短**(2026-10-08 用户反馈「啰嗦又生硬」):这里是折叠头右侧的单行小字,
5550
+ * 长句会挤成多行墙。状态行左边已经写着「事件流已断开」,这句不再复述,
5551
+ * 只说「不会自愈 + 点哪」;成因与上限写进 README,不塞进界面。
5552
+ */
5553
+ const refused = typeof props.refused === 'string' ? props.refused : ''
5554
+ const hintText = canReconnect
5555
+ ? (refused === '' ? t('hint.eventsClosedNoAuto') : t('hint.eventsReconnectRefused', { message: refused }))
5556
+ : (events.length === 0 ? t('list.noEvents') : t('list.recentEvents', { recent: recent.length, total: events.length }))
5483
5557
  return jsxs('div', { className: 'dk_activity', 'data-open': open ? '1' : '0', children: [
5484
5558
  /*
5485
5559
  * 折叠按钮与「重连」是**兄弟**,不是嵌套——按钮里嵌按钮既不是合法 HTML,
@@ -5497,10 +5571,8 @@ window.__ModuleLoader__.load({
5497
5571
  jsx('span', { className: 'dk_activityState', 'data-state': props.status ?? '', children: props.statusText ?? '' }),
5498
5572
  jsx('span', { className: 'dk_headerSpacer' }),
5499
5573
  // 断开时右侧那句「暂无事件」是误导(不是「没动静」,是「听不见了」),
5500
- // 换成一句说明为什么需要手动重连
5501
- jsx('span', { className: 'dk_hint', children: canReconnect
5502
- ? t('hint.eventsClosedNoAuto')
5503
- : (events.length === 0 ? t('list.noEvents') : t('list.recentEvents', { recent: recent.length, total: events.length })) }),
5574
+ // 换成一句说明为什么需要手动重连;**被拒过就改成说原因**(D164)
5575
+ jsx('span', { className: 'dk_hint', children: hintText }),
5504
5576
  jsx('span', { className: 'dk_activityChevron', dangerouslySetInnerHTML: { __html: ICON_CHEVRON } }),
5505
5577
  ],
5506
5578
  }, 'head'),
@@ -5570,6 +5642,7 @@ window.__ModuleLoader__.load({
5570
5642
  onClick: props.onClear,
5571
5643
  children: t('btn.clear'),
5572
5644
  }, 'clear') : null,
5645
+ props.budget === undefined ? null : jsx('span', { className: 'dk_hint dk_pickBudget', children: props.budget }),
5573
5646
  props.notice === '' ? null : jsx('span', { className: 'dk_hint dk_pickNotice', children: props.notice }),
5574
5647
  ] }) : null,
5575
5648
  ] })
@@ -5799,6 +5872,12 @@ window.__ModuleLoader__.load({
5799
5872
  /** 面板是否可见(S3):折叠的 tab 不建流;模态 / dock 形态恒为 true。 */
5800
5873
  const active = usePanelActive()
5801
5874
  const [eventsStatus, setEventsStatus] = useState('')
5875
+ /**
5876
+ * 上一次被服务端**拒绝**的原因(D164):配额满这类拒绝在界面上必须看得见,
5877
+ * 否则「点重连 → 被拒 → 回到 closed」在视觉上与「点了没反应」不可区分。
5878
+ * 重连**成功**时清空(那说明这个原因已经过去了)。
5879
+ */
5880
+ const [eventsRefused, setEventsRefused] = useState('')
5802
5881
  /**
5803
5882
  * 手动重连令牌(D160):事件流断开后是**终止态**(EventSource 只在连接层抖动时自愈;
5804
5883
  * `docker events` 进程退出 / 服务端发 `end` 帧都会走 close(),之后没有东西会再开流)。
@@ -6458,6 +6537,9 @@ window.__ModuleLoader__.load({
6458
6537
  */
6459
6538
  let hadOpen = sameTarget && (eventsOpenedRef.current.opened || manualReconnect)
6460
6539
  setEventsStatus('connecting')
6540
+ // 每次重新建流都先把上一条拒绝原因清掉(D164):这是「又试了一次」,不是「一直坏着」;
6541
+ // 若这次仍被拒,onError 会立刻把它写回来。
6542
+ setEventsRefused('')
6461
6543
  const debounced = makeDebounced(EVENTS_REFRESH_DEBOUNCE_MS, () => {
6462
6544
  const load = loadContainersRef.current
6463
6545
  if (load !== null) load()
@@ -6486,8 +6568,15 @@ window.__ModuleLoader__.load({
6486
6568
  close()
6487
6569
  }
6488
6570
  const onError = (raw) => {
6489
- if (typeof raw.data === 'string' && raw.data !== '') {
6571
+ /*
6572
+ * 服务端主动说了原因(带 data 的 error 帧):**显式收尾**,并把那句话留下来
6573
+ * (D164)。老代码把它丢掉、只置 `closed`,于是「重连」点了看起来毫无反应——
6574
+ * 拒绝 → 回 `closed` → 界面与点击前一样。统计流一直是读出 message 的。
6575
+ */
6576
+ const message = eventsErrorMessage(raw)
6577
+ if (message !== '') {
6490
6578
  setEventsStatus('closed')
6579
+ setEventsRefused(message)
6491
6580
  close()
6492
6581
  return
6493
6582
  }
@@ -6499,6 +6588,8 @@ window.__ModuleLoader__.load({
6499
6588
  es.addEventListener('error', onError)
6500
6589
  es.onopen = () => {
6501
6590
  setEventsStatus('open')
6591
+ // 连上了就说明上次那条拒绝原因已经过去(D164):留着会让人以为现在还坏着
6592
+ setEventsRefused('')
6502
6593
  if (hadOpen) {
6503
6594
  // 重连补偿**不走事件驱动的那条防抖**(D93):debounced 是 500ms 尾沿防抖,
6504
6595
  // 每条事件都 schedule() 一次;容器多 + healthcheck 时事件持续 >2 条/秒,
@@ -7340,6 +7431,9 @@ window.__ModuleLoader__.load({
7340
7431
  info: pickInfo,
7341
7432
  presets: pickPresetList,
7342
7433
  max: pickMax,
7434
+ // 上限为什么是 3 得写在脸上(D165):它是量出来的浏览器连接预算,
7435
+ // 不写清楚,看起来就只是一个随手定的数字,用户只会觉得「怎么才 3 个」。
7436
+ budget: t('hint.pickBudget'),
7343
7437
  notice: pickNotice,
7344
7438
  onPreset: applyPreset,
7345
7439
  onClear: () => { setPickedIds([]); setPickNotice('') },
@@ -7386,6 +7480,7 @@ window.__ModuleLoader__.load({
7386
7480
  events,
7387
7481
  status: eventsStatus,
7388
7482
  statusText: eventsStatusText(),
7483
+ refused: eventsRefused,
7389
7484
  open: activityOpen,
7390
7485
  onToggle: () => setActivityOpen((value) => !value),
7391
7486
  // 只重开事件流,不顺带刷列表:列表有自己的 ⟳ 与 AUTO REFRESH,
@@ -8682,8 +8777,11 @@ window.__ModuleLoader__.load({
8682
8777
  }
8683
8778
  exports.__pick = {
8684
8779
  MAX: PICK_MAX,
8685
- /** SSH 目标的上限(= MAX 之外更紧的一档):一条连接要同时装实时流与短命令。 */
8780
+ /** SSH 目标的上限(D165 后与本地同值:卡页面的是浏览器同源预算)。 */
8686
8781
  SSH_MAX: PICK_MAX_SSH,
8782
+ /** 同源常驻流预算与其中留给事件流的名额(上限的推导依据,测试直接钉住)。 */
8783
+ BUDGET: SAME_ORIGIN_STREAM_BUDGET,
8784
+ RESERVED: RESERVED_STREAMS,
8687
8785
  PRESETS: PICK_PRESETS,
8688
8786
  presetCounts: pickPresetCounts,
8689
8787
  apply: pickApply,
@@ -8706,6 +8804,7 @@ window.__ModuleLoader__.load({
8706
8804
  timeText: eventTimeText,
8707
8805
  debounce: makeDebounced,
8708
8806
  canReconnect: canReconnectEvents,
8807
+ errorMessage: eventsErrorMessage,
8709
8808
  }
8710
8809
  /*
8711
8810
  * 同一类测试缝:总览的折叠逻辑与**正文渲染**都是纯函数(正文刻意写成普通函数而不是