@hyzyn/dsh-docker 0.9.5 → 0.10.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.
@@ -22,6 +22,13 @@ import { currentSessionIdOf } from './current-session.js'
22
22
  import { createLogBuffer, splitLogLines } from './log-buffer.js'
23
23
  import { createLogWindow, LOG_ROW_ESTIMATE_PX, LOG_WINDOW_OVERSCAN, LOG_HEIGHT_CACHE_LIMIT } from './log-window.js'
24
24
  import { subscribeLogStream, reconnectTail, LOG_RECONNECT_BASE_MS, LOG_RECONNECT_MAX_MS } from './log-stream.js'
25
+ import { upsertElevationRecord, removeElevationRecord, markElevationGranted, pruneElevationRecords, buildMergedElevationText, mergeElevationCommands, joinElevationCommands, shouldOfferMerge } from './elevation.js'
26
+
27
+ /**
28
+ * 「复制全部」按钮的反馈归属哨兵键:能力名不会取这个值,所以它与单条按钮的反馈天然不串。
29
+ * (`copiedKey` 逐能力隔离见卡片里那段注释。)
30
+ */
31
+ const ELEVATION_COPY_ALL = '__all__'
25
32
 
26
33
  /* ================================ 国际化 ================================ */
27
34
 
@@ -351,7 +358,10 @@ const I18N_ZH = {
351
358
  'status.eventsClosed': '事件流已断开',
352
359
  'status.eventsStream': '事件流',
353
360
  'status.eventsEnded': '事件流已结束',
354
- 'status.backToListRefresh': ',列表回到 AUTO REFRESH / 手动刷新',
361
+ 'status.backToListRefresh': ',列表回到 AUTO REFRESH / 手动刷新;活动条上点「重连」可重新订阅',
362
+ 'btn.reconnectEvents': '重连',
363
+ 'hint.reconnectEvents': '重新打开容器事件流(docker events);列表刷新按钮也会一并重连',
364
+ 'hint.eventsClosedNoAuto': '事件流断开后不会自己回来(EventSource 只在连接层抖动时自愈;docker events 进程退出属于终止态)——点它重连,或点右上角刷新列表',
355
365
  'hint.conversationHidden': ' · 会话在面板后面:关掉或最小化面板/终端即可看到',
356
366
  'msg.copied': '已复制:',
357
367
  'error.copyManual': '复制失败,请手动执行:',
@@ -453,6 +463,8 @@ const I18N_ZH = {
453
463
  'msg.saved': '已保存并热生效',
454
464
  'msg.savedDirty': '已保存并热生效(表单在保存期间有新编辑,未覆盖你正在输入的内容)',
455
465
  'error.saveFailed': '保存失败:',
466
+ 'msg.connectLocalBusy': '正在连接本机…',
467
+ 'error.connectLocalFailed': '连接本机失败:',
456
468
  'card.desc': '本机与 SSH 主机的容器与镜像:容器 / 镜像 / 网络 / 卷查看,默认只读,变更操作需显式开启。',
457
469
  'card.name': 'Docker 容器面板',
458
470
  'card.summary': '本机 / SSH 主机上的容器与镜像;默认只读,变更操作需显式开启',
@@ -474,6 +486,9 @@ const I18N_ZH = {
474
486
  'hint.capabilityNotGranted': '⚠ 宿主尚未授权:这两个开关现在打不开,点它会给出一条就地确认的命令(免重启)。关掉它们随时可用。',
475
487
  'badge.notEffective': '未生效:未获宿主授权',
476
488
  'elev.title': '就地授权(免重启)',
489
+ 'elev.copyAll': '复制全部({count} 条)',
490
+ 'elev.copyAllHint': '每个能力各要一条命令。一次粘贴全部命令,执行后全部开关自动解锁(各自的探测周期内先后生效)。',
491
+ 'elev.copyAllCopied': '已复制全部 {count} 条',
477
492
  'elev.lockedWhy': '本机任意进程都能发回环请求,所以「打开危险能力」这件事不能由这个页面自己说了算——必须在宿主的文件系统上确认一次。',
478
493
  'elev.step': '在宿主的终端里执行下一条命令,开关会自动解锁:',
479
494
  'elev.copy': '复制命令',
@@ -508,6 +523,8 @@ const I18N_ZH = {
508
523
  'hint.keyPath': '支持 ~ 与 ~/ 展开(不支持 ~user);Windows 请写绝对路径',
509
524
  'placeholder.passwordSet': '(已设置,留空保持不变)',
510
525
  'btn.addTarget': '添加目标',
526
+ 'btn.connectLocal': '连接本机',
527
+ 'hint.connectLocal': '加一个指向宿主所在机器的本机目标并选中(已有本机目标则直接复用);SSH 与自定义目标原样保留。',
511
528
  'hint.addTarget': 'SSH 目标推荐直接选 tty 终端面板的连接簿条目(凭证只需维护一处);手填时密码 / 口令建议写 env:NAME(凭据引用:由官方凭据存储解析,缺失时退回环境变量)。',
512
529
  'section.tofu': 'SSH 主机密钥记录(TOFU)',
513
530
  'list.noHostKeys': '暂无记录 — 首次 SSH 连接成功后自动记录主机指纹(若 tty 已记录同一主机,会直接复用)。',
@@ -828,7 +845,10 @@ const I18N_EN = {
828
845
  'status.eventsClosed': 'Event stream closed',
829
846
  'status.eventsStream': 'Event stream',
830
847
  'status.eventsEnded': 'Event stream ended',
831
- 'status.backToListRefresh': ', the list returns to AUTO REFRESH / manual refresh',
848
+ 'status.backToListRefresh': ', the list returns to AUTO REFRESH / manual refresh; hit “Reconnect” on the activity bar to resubscribe',
849
+ 'btn.reconnectEvents': 'Reconnect',
850
+ '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',
832
852
  'hint.conversationHidden': ' · the session is behind this panel: close or minimise the panel/terminal to see it',
833
853
  'msg.copied': 'Copied: ',
834
854
  'error.copyManual': 'Copy failed, run it manually: ',
@@ -930,6 +950,8 @@ const I18N_EN = {
930
950
  'msg.saved': 'Saved and applied live',
931
951
  'msg.savedDirty': 'Saved and applied live (the form got new edits while saving; what you were typing was not overwritten)',
932
952
  'error.saveFailed': 'Save failed: ',
953
+ 'msg.connectLocalBusy': 'Connecting to the local Docker…',
954
+ 'error.connectLocalFailed': 'Connecting local Docker failed: ',
933
955
  'card.desc': 'Containers and images on this machine and SSH hosts: containers / images / networks / volumes, read-only by default, changes must be enabled explicitly.',
934
956
  'card.name': 'Docker containers',
935
957
  'card.summary': 'Containers and images on this machine / SSH hosts; read-only by default, changes must be enabled explicitly',
@@ -951,6 +973,9 @@ const I18N_EN = {
951
973
  '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.',
952
974
  'badge.notEffective': 'Not effective: not granted by the host',
953
975
  'elev.title': 'Grant in place (no restart)',
976
+ 'elev.copyAll': 'Copy all ({count})',
977
+ 'elev.copyAllHint': 'Each capability needs its own command. Paste both lines at once; every switch unlocks by itself once they run (each on its own probe cycle).',
978
+ 'elev.copyAllCopied': 'Copied all {count}',
954
979
  'elev.lockedWhy': 'Any local process can send loopback requests, so “turn on a dangerous capability” cannot be decided by this page alone — it has to be confirmed on the host’s filesystem once.',
955
980
  'elev.step': 'Run the command below in a terminal on the host and the switch unlocks by itself:',
956
981
  'elev.copy': 'Copy command',
@@ -985,6 +1010,8 @@ const I18N_EN = {
985
1010
  'hint.keyPath': 'Supports ~ and ~/ expansion (not ~user); use absolute paths on Windows',
986
1011
  'placeholder.passwordSet': '(already set, leave empty to keep)',
987
1012
  'btn.addTarget': 'Add target',
1013
+ 'btn.connectLocal': 'Connect local',
1014
+ 'hint.connectLocal': 'Add a local target pointing at the machine running the host and select it (reuses an existing local target); SSH and custom targets stay untouched.',
988
1015
  'hint.addTarget': 'For an SSH target, prefer picking a tty terminal panel bookmark (credentials live in one place); when filling in manually, write the password / passphrase as env:NAME (credential reference: resolved by the official credential store, falling back to the environment variable).',
989
1016
  'section.tofu': 'SSH host key records (TOFU)',
990
1017
  'list.noHostKeys': 'No records yet — the host fingerprint is recorded automatically after the first successful SSH connection (reused directly if tty already recorded the same host).',
@@ -1089,6 +1116,12 @@ async function request(path, init) {
1089
1116
  const api = {
1090
1117
  config: () => request('/config'),
1091
1118
  saveConfig: (patch) => request('/config', { method: 'POST', body: JSON.stringify(patch) }),
1119
+ /**
1120
+ * 一键连接本机(宿主同源证明要求 POST):直接加一个 kind=local 目标并选中它,
1121
+ * 返回 { result: { name, created, saved, reachable, serverVersion?, message }, config }。
1122
+ * 与「设置卡片里手填一个本机目标」是**同一份宿主实现**(/connect-local ↔ connectLocal)。
1123
+ */
1124
+ connectLocal: () => request('/connect-local', { method: 'POST', body: '{}' }),
1092
1125
  targets: () => request('/targets'),
1093
1126
  probe: (target) => request('/probe', { method: 'POST', body: JSON.stringify({ target }) }),
1094
1127
  containers: (target, all) => request('/containers', { method: 'POST', body: JSON.stringify({ target, all }) }),
@@ -1532,12 +1565,38 @@ const PICK_MAX = 8
1532
1565
  */
1533
1566
  const PICK_MAX_SSH = 6
1534
1567
 
1568
+ /**
1569
+ * 目标下拉里要不要列「+ 连接本机」那一项。
1570
+ *
1571
+ * 入参是**渲染作用域里的目标列表**(与同一个下拉展开的 `<option>` 用同一份数据)——
1572
+ * 刻意不读模块级 configCache:同一个下拉里「列了哪些目标」与「要不要多列这一项」必须同源,
1573
+ * 否则会出现「列表里已经有本机目标、却又多列一项连接本机」这种自相矛盾。
1574
+ *
1575
+ * 两个条件缺一不可:
1576
+ * - `targets.length > 0`:一个目标都没有时,正文空态里已经有一个大按钮(那里才是它最该
1577
+ * 出现的地方),下拉里再列一项就是同一个入口出现两次;
1578
+ * - 没有 `kind=local`:已经有了就不列(选择器里已经有那台本机了)。
1579
+ */
1580
+ function shouldOfferConnectLocal(targets) {
1581
+ return targets.length > 0 && !targets.some((item) => item.kind === 'local')
1582
+ }
1583
+
1535
1584
  /** 目标是不是 SSH —— 决定聚合流的上限。读模块级 configCache,任何组件都能问。 */
1536
1585
  function isSshTarget(name) {
1537
1586
  const targets = configCache !== null && Array.isArray(configCache.targets) ? configCache.targets : []
1538
1587
  return targets.some((item) => item.name === name && item.kind === 'ssh')
1539
1588
  }
1540
1589
 
1590
+ /**
1591
+ * 目标选择器里那一项「+ 连接本机」的哨兵值。
1592
+ *
1593
+ * 为什么不用个普通字符串(如 `local`):目标名是**用户自由填的**(sanitizeTargets 只限长度与
1594
+ * 非空),任何可打印值都可能与真实目标重名——重名就意味着用户点「连接本机」反而切到了别的机器。
1595
+ * 用 NUL 开头则不可能撞上:目标名来自输入框,NUL 进不去;而 `<option value>` 允许任意字符串,
1596
+ * 比较是纯 JS 字符串比较,不经过任何会截断的路径。
1597
+ */
1598
+ const CONNECT_LOCAL_OPTION = '\u0000connect-local'
1599
+
1541
1600
  /** 由勾选数量推导「聚合日志」按钮是否可用 + 操作条提示文案。 */
1542
1601
  function pickDecide(count, ssh = false) {
1543
1602
  const max = ssh === true ? PICK_MAX_SSH : PICK_MAX
@@ -1895,6 +1954,19 @@ const EVENT_RECENT = 8
1895
1954
  */
1896
1955
  const EVENTS_REFRESH_DEBOUNCE_MS = 500
1897
1956
 
1957
+ /**
1958
+ * 事件流处在「需要人工重连」的终止态吗(D160)。
1959
+ *
1960
+ * 抽成纯函数而不是写在组件里:离线冒烟的 React 桩把 `useState` 冻在初值上,组件里
1961
+ * 那个 `eventsStatus` 永远是 `''`,遍历渲染树根本走不到重连按钮——判定必须能直接驱动。
1962
+ *
1963
+ * **只认 `closed`**:`open` / `connecting` / `reconnecting` 都还在自动恢复的路上;
1964
+ * `unsupported`(环境没有 EventSource)重连必然再失败,给按钮等于骗人。
1965
+ */
1966
+ function canReconnectEvents(status) {
1967
+ return status === 'closed'
1968
+ }
1969
+
1898
1970
  /** 新事件放最前(活动条按时间倒序读),超出上限丢最旧。 */
1899
1971
  function pushEvent(list, event, limit) {
1900
1972
  const next = [event, ...list]
@@ -5401,21 +5473,45 @@ window.__ModuleLoader__.load({
5401
5473
  const open = props.open === true
5402
5474
  const events = Array.isArray(props.events) ? props.events : []
5403
5475
  const recent = events.slice(0, EVENT_RECENT)
5476
+ /*
5477
+ * 断开时给一个显式的「重连」(D160):这条流进了 closed 就是**终止态**,没有任何
5478
+ * 东西会再开它;而活动条上那句「事件流已断开」看着却像会自愈(EventSource 平时
5479
+ * 确实会自动重连),用户点工具条的 ⟳ 也不见好。
5480
+ * `unsupported` 刻意不给这个按钮:那个环境根本没有 EventSource,重连必然还是失败。
5481
+ */
5482
+ const canReconnect = canReconnectEvents(props.status)
5404
5483
  return jsxs('div', { className: 'dk_activity', 'data-open': open ? '1' : '0', children: [
5405
- jsx('button', {
5406
- type: 'button',
5407
- className: 'dk_activityHead',
5408
- 'aria-expanded': open,
5409
- title: t('hint.eventsToggle'),
5410
- onClick: props.onToggle,
5411
- children: [
5412
- jsx('span', { className: 'dk_activityTitle', children: t('panel.activity') }),
5413
- jsx('span', { className: 'dk_activityState', 'data-state': props.status ?? '', children: props.statusText ?? '' }),
5414
- jsx('span', { className: 'dk_headerSpacer' }),
5415
- jsx('span', { className: 'dk_hint', children: events.length === 0 ? t('list.noEvents') : t('list.recentEvents', { recent: recent.length, total: events.length }) }),
5416
- jsx('span', { className: 'dk_activityChevron', dangerouslySetInnerHTML: { __html: ICON_CHEVRON } }),
5417
- ],
5418
- }),
5484
+ /*
5485
+ * 折叠按钮与「重连」是**兄弟**,不是嵌套——按钮里嵌按钮既不是合法 HTML,
5486
+ * 也会让读屏把「折叠」与「重连」两个动作听成一个。
5487
+ */
5488
+ jsxs('div', { className: 'dk_activityHeadRow', children: [
5489
+ jsx('button', {
5490
+ type: 'button',
5491
+ className: 'dk_activityHead',
5492
+ 'aria-expanded': open,
5493
+ title: t('hint.eventsToggle'),
5494
+ onClick: props.onToggle,
5495
+ children: [
5496
+ jsx('span', { className: 'dk_activityTitle', children: t('panel.activity') }),
5497
+ jsx('span', { className: 'dk_activityState', 'data-state': props.status ?? '', children: props.statusText ?? '' }),
5498
+ jsx('span', { className: 'dk_headerSpacer' }),
5499
+ // 断开时右侧那句「暂无事件」是误导(不是「没动静」,是「听不见了」),
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 })) }),
5504
+ jsx('span', { className: 'dk_activityChevron', dangerouslySetInnerHTML: { __html: ICON_CHEVRON } }),
5505
+ ],
5506
+ }, 'head'),
5507
+ canReconnect ? jsx('button', {
5508
+ type: 'button',
5509
+ className: 'dk_activityRetry',
5510
+ title: t('hint.reconnectEvents'),
5511
+ onClick: props.onReconnect,
5512
+ children: t('btn.reconnectEvents'),
5513
+ }, 'retry') : null,
5514
+ ] }),
5419
5515
  open === false ? null : (
5420
5516
  recent.length === 0
5421
5517
  ? jsx('div', { className: 'dk_activityEmpty', children: t('list.noEventsHint') })
@@ -5687,6 +5783,8 @@ window.__ModuleLoader__.load({
5687
5783
  /** 已进入聚合视图(items 由 pickItems(containers, pickedIds) 现算)。 */
5688
5784
  const [aggregateOpen, setAggregateOpen] = useState(false)
5689
5785
  const [loading, setLoading] = useState(false)
5786
+ /** 「连接本机」请求在飞:按钮转圈 + 置灰,避免连点造成一串并发写配置。 */
5787
+ const [connectLocalBusy, setConnectLocalBusy] = useState(false)
5690
5788
  const [error, setError] = useState('')
5691
5789
  const [notice, setNotice] = useState('')
5692
5790
  const [all, setAll] = usePanelState('all', true)
@@ -5701,11 +5799,30 @@ window.__ModuleLoader__.load({
5701
5799
  /** 面板是否可见(S3):折叠的 tab 不建流;模态 / dock 形态恒为 true。 */
5702
5800
  const active = usePanelActive()
5703
5801
  const [eventsStatus, setEventsStatus] = useState('')
5802
+ /**
5803
+ * 手动重连令牌(D160):事件流断开后是**终止态**(EventSource 只在连接层抖动时自愈;
5804
+ * `docker events` 进程退出 / 服务端发 `end` 帧都会走 close(),之后没有东西会再开流)。
5805
+ *
5806
+ * 为什么是计数器而不是布尔:布尔翻回 false 时 effect 看不出「又要求了一次」——
5807
+ * 连点两次重连、或在已经 closed 的状态下再点一次都必须重开。自增的值天然两两不同。
5808
+ */
5809
+ const [eventsReconnect, setEventsReconnect] = useState(0)
5704
5810
  const [activityOpen, setActivityOpen] = usePanelState('activityOpen', true)
5705
5811
  /** 建流时用的列表加载器(用 ref 拿最新的,避免 all 一变就重连事件流)。 */
5706
5812
  const loadContainersRef = useRef(null)
5707
5813
  /** 事件流当前绑定的目标:换目标要清空活动条缓冲,免得混着两台主机的事件。 */
5708
5814
  const eventsTargetRef = useRef('')
5815
+ /**
5816
+ * 当前目标上是否**成功开过**事件流(D160):重连补偿的判据。
5817
+ *
5818
+ * 用 ref 而不是 state:它只影响下一次建流时的补偿决定,不该触发重渲染;
5819
+ * 而且必须**跨 effect 运行存活**——`eventsReconnect` 一变 effect 就重跑,
5820
+ * 局部变量会归零,手动重连就会被误判成「首次」而不补偿。
5821
+ * 换目标时重置(见 events effect),新目标的第一次 open 不该补。
5822
+ */
5823
+ const eventsOpenedRef = useRef({ opened: false })
5824
+ /** 上一次建流时看到的**手动**重连令牌(D160):用来认出「这次 effect 跑是用户点的重连」。 */
5825
+ const eventsReconnectRef = useRef(0)
5709
5826
  /** 打开中的容器详情:{ id, tab, item }(item 为快照,列表刷新后优先用新数据)。 */
5710
5827
  const [detail, setDetail] = usePanelState('detail', null)
5711
5828
  const [refreshToken, setRefreshToken] = useState(0)
@@ -6167,6 +6284,64 @@ window.__ModuleLoader__.load({
6167
6284
  props.onTargetChange?.(targetLabel(name))
6168
6285
  }
6169
6286
 
6287
+ /**
6288
+ * 「连接本机」:面板工具条 / 无目标空态那一个按钮的唯一实现。
6289
+ *
6290
+ * 三件事一起做,缺任何一件这个按钮都不成立:
6291
+ * 1. 调宿主的 `POST /connect-local`——它才是**唯一**写配置的地方(复用已有本机目标、
6292
+ * 否则用确定性无冲突名新建;settings 不可用 / 保存失败会带原因返回);
6293
+ * 2. 用响应里的新 config 推送 `publishConfig`(已挂载的面板与连接栏立刻看到新目标)
6294
+ * 并重拉一次 /targets(下拉里的 `name · 本机` label 只有它算得出来);
6295
+ * 3. 立刻把 target 切到那条本机目标(用户点它就是想说「看这台」),并清掉上一台的
6296
+ * 错误 / 勾选 / 执行中标记——与手动换目标同一套清理。
6297
+ *
6298
+ * 探测结果(daemon 不可达 / CLI 缺失 / socket 无权)走 `notice`:**保存成功但连不上**
6299
+ * 既不能被说成失败(目标真的加上了),也不能不提(否则用户只看到空列表)。
6300
+ */
6301
+ const connectLocal = useCallback(() => {
6302
+ if (connectLocalBusy) return
6303
+ setConnectLocalBusy(true)
6304
+ setNotice(t('msg.connectLocalBusy'))
6305
+ api.connectLocal().then((payload) => {
6306
+ if (!mountedRef.current) return
6307
+ const result = payload.result ?? {}
6308
+ const config_ = payload.config
6309
+ if (config_ !== null && typeof config_ === 'object') {
6310
+ setConfig(config_)
6311
+ primeTargetsCache(config_)
6312
+ publishConfig(config_)
6313
+ }
6314
+ const name = typeof result.name === 'string' ? result.name : ''
6315
+ if (name !== '') {
6316
+ setTarget(name)
6317
+ setSessionScoped(false)
6318
+ setError('')
6319
+ // 「连接本机」= 去看这台主机:容器列表页 + 同一套换目标清理
6320
+ setView('containers')
6321
+ setDetail(null)
6322
+ resetPick()
6323
+ setPending({})
6324
+ props.onTargetChange?.(targetLabel(name))
6325
+ }
6326
+ setNotice(typeof result.message === 'string' && result.message !== '' ? result.message : t('msg.saved'))
6327
+ // 下拉里的 label 只有 /targets 算得出来(config 快照里没有 label 字段)
6328
+ void api.targets().then((targetsPayload) => {
6329
+ if (!mountedRef.current) return
6330
+ const rows = targetsPayload.targets ?? []
6331
+ setTargets(rows)
6332
+ targetsCache = rows
6333
+ }).catch(() => { /* 失败不连坐:上面已按 config 校正过选中项 */ })
6334
+ // 目标增删会影响 tty 连接栏按钮:刷新解析后的目标列表缓存
6335
+ void refreshTargetsCache()
6336
+ }).catch((error_) => {
6337
+ // 设置存储不可用 / 保存失败 / 非 POST 同源拒绝:把宿主给的原因原样带出来
6338
+ if (mountedRef.current) setError(t('error.connectLocalFailed') + error_.message)
6339
+ setNotice('')
6340
+ }).finally(() => {
6341
+ if (mountedRef.current) setConnectLocalBusy(false)
6342
+ })
6343
+ }, [connectLocalBusy])
6344
+
6170
6345
  const refresh = useCallback(() => {
6171
6346
  // 五个列表各有自己的加载器;容器以外的都变化慢,但都走同一条「切页 / 切目标即刷」
6172
6347
  if (view === 'overview') loadOverview()
@@ -6177,6 +6352,20 @@ window.__ModuleLoader__.load({
6177
6352
  setRefreshToken((value) => value + 1)
6178
6353
  }, [view, loadOverview, loadContainers, loadImages, loadNetworks, loadVolumes])
6179
6354
 
6355
+ /**
6356
+ * 工具条的 ⟳:**手动**刷新,比 `refresh()` 多做一件事——重开事件流(D160)。
6357
+ *
6358
+ * 为什么与 `refresh()` 分开:AUTO REFRESH 轮询也调 `refresh()`(默认 5 秒一次),
6359
+ * 若把重连塞进去,一条因为 docker events 退出而进入终止态的流会被轮询不断重开——
6360
+ * 既掩盖了「它已经断了」,又让每次轮询都多建一条 SSE。用户点 ⟳ 是**明确的**
6361
+ * 「按现在的情况重新来一遍」,那时重连才对;自动轮询没有这个语义。
6362
+ * `eventsReconnect` 自增即触发事件流 effect 重跑(见那里的依赖数组)。
6363
+ */
6364
+ const refreshManually = useCallback(() => {
6365
+ refresh()
6366
+ setEventsReconnect((value) => value + 1)
6367
+ }, [refresh])
6368
+
6180
6369
  useEffect(() => {
6181
6370
  // 总览有自己的取数 effect(它不依赖任何单个 target),这里只服务单目标视图
6182
6371
  if (view === 'overview') return undefined
@@ -6243,16 +6432,36 @@ window.__ModuleLoader__.load({
6243
6432
  return undefined
6244
6433
  }
6245
6434
  // 换目标就清空缓冲:活动条里不该混着另一台主机的事件
6246
- if (eventsTargetRef.current !== target) {
6435
+ const sameTarget = eventsTargetRef.current === target
6436
+ if (!sameTarget) {
6247
6437
  eventsTargetRef.current = target
6248
6438
  setEvents([])
6439
+ eventsOpenedRef.current.opened = false
6249
6440
  }
6441
+ /*
6442
+ * 这一次 effect 跑是不是**用户点了「重连」**(令牌变了就是)。必须在建流前算好
6443
+ * 并记下,否则下一次跑就没得比了。
6444
+ */
6445
+ const manualReconnect = eventsReconnect !== eventsReconnectRef.current
6446
+ eventsReconnectRef.current = eventsReconnect
6447
+ /*
6448
+ * `hadOpen` 初值决定「这一次 open 要不要补一次全量列表刷新」(重连补偿)。
6449
+ *
6450
+ * 判据是**同一个目标上已经成功开过一次**,而不是「本次 effect 运行内开过」:
6451
+ * 后者会让手动重连(D160)与折叠后展开都退化成「首次」,而这两条路径恰恰
6452
+ * 都是「流断了一段时间、列表可能已经落后」——正是补偿要覆盖的情形。
6453
+ * 换目标仍算首次:那边 loadContainers 的 effect 刚拉过一份新的。
6454
+ *
6455
+ * `manualReconnect` 单独兜一种情形:daemon 全程没起时流**从未 open 过**,
6456
+ * 只按 `opened` 判就会把重连成功后的补偿一起省掉——那时列表还停在「这个目标
6457
+ * 的数据没读到」,活动条却已经绿了,看着像好了其实没好。
6458
+ */
6459
+ let hadOpen = sameTarget && (eventsOpenedRef.current.opened || manualReconnect)
6250
6460
  setEventsStatus('connecting')
6251
6461
  const debounced = makeDebounced(EVENTS_REFRESH_DEBOUNCE_MS, () => {
6252
6462
  const load = loadContainersRef.current
6253
6463
  if (load !== null) load()
6254
6464
  })
6255
- let hadOpen = false
6256
6465
  const es = new EventSource(streamUrl('/events/stream', { target }))
6257
6466
  let closed = false
6258
6467
  const close = () => {
@@ -6301,12 +6510,13 @@ window.__ModuleLoader__.load({
6301
6510
  void loadContainersRef.current?.()
6302
6511
  }
6303
6512
  hadOpen = true
6513
+ eventsOpenedRef.current.opened = true
6304
6514
  }
6305
6515
  return () => {
6306
6516
  close()
6307
6517
  debounced.cancel()
6308
6518
  }
6309
- }, [active, view, target])
6519
+ }, [active, view, target, eventsReconnect])
6310
6520
 
6311
6521
  useEffect(() => {
6312
6522
  if (notice === '') return undefined
@@ -6617,6 +6827,28 @@ window.__ModuleLoader__.load({
6617
6827
  return found.label === undefined ? name : name + ' · ' + found.label
6618
6828
  }
6619
6829
 
6830
+ /**
6831
+ * 渲染「连接本机」按钮——**只出现在「一个目标都没有」的空态里**。
6832
+ *
6833
+ * 为什么不做成常驻的工具条按钮(第一版就是这么写的,用户当场指出多余):它解决的
6834
+ * 唯一问题是「面板全空、只让你去设置卡片手配」,而**已经有任何目标**时,选择器里
6835
+ * 那台就是用户要用的那台——这时再挂一个按钮,既占掉选择器右边的位置,又要靠强调色
6836
+ * 招人点,属于纯噪音。所以:没有任何目标 = 它是最短路径(主按钮);有目标 = 不出现。
6837
+ *
6838
+ * 正因为只在 `target === ''` 的空态里渲染,这里**没有**「已有本机目标」的分支:
6839
+ * 列表非空时 `chooseInitialTarget` 一定会选中一个目标,走不到这个空态;空态 ⇒ 没有
6840
+ * 本机目标可复用 ⇒ 这一下必然是「新建并选中」(宿主侧用 `local` / `local-2`… 的确定性
6841
+ * 名字,并自己处理「万一已经有」的复用)。
6842
+ */
6843
+ const connectLocalButton = (className) => jsx('button', {
6844
+ type: 'button',
6845
+ className,
6846
+ disabled: connectLocalBusy,
6847
+ title: t('btn.connectLocal') + ' — ' + t('hint.connectLocal'),
6848
+ onClick: connectLocal,
6849
+ children: connectLocalBusy ? t('msg.connectLocalBusy') : t('btn.connectLocal'),
6850
+ }, 'connectLocal')
6851
+
6620
6852
  const empty = () => {
6621
6853
  if (loading) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: t('list.loading') })] })
6622
6854
  if (target === '') {
@@ -6629,6 +6861,8 @@ window.__ModuleLoader__.load({
6629
6861
  return jsxs('div', { className: 'dk_empty', children: [
6630
6862
  jsx('div', { className: 'dk_emptyTitle', children: t('list.noTargetsConfigured') }),
6631
6863
  jsx('div', { className: 'dk_emptyHint', children: t('hint.addTargetEmpty') }),
6864
+ // 一键路径就放在这里:面板全空、「一个目标都没有」正是这个按钮最该出现的地方
6865
+ connectLocalButton('dk_btn dk_btnPrimary'),
6632
6866
  ] })
6633
6867
  }
6634
6868
  // 读取失败时列表本来就会被清空(切目标失败尤其如此):此时别把空列表说成
@@ -6891,7 +7125,7 @@ window.__ModuleLoader__.load({
6891
7125
  config?.allowMutations === true ? null : jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: t('badge.readOnly') }),
6892
7126
  jsx('span', { className: 'dk_headerSpacer' }),
6893
7127
  // 刷新中让图标自己转(loading 也用于镜像列表)
6894
- selected !== null ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.refreshList'), 'data-spin': loading ? '1' : undefined, onClick: refresh, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }) }),
7128
+ selected !== null ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.refreshList'), 'data-spin': loading ? '1' : undefined, onClick: refreshManually, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }) }),
6895
7129
  jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.closePanel'), onClick: requestClose, children: jsx('span', { dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }),
6896
7130
  ] }),
6897
7131
  /* 工具栏:只在列表视图显示;容器详情是整栏视图,列表筛选在这里没有意义 */
@@ -6901,6 +7135,15 @@ window.__ModuleLoader__.load({
6901
7135
  // 总览没有「当前目标」这回事:选择器回落成一条带说明的空值项
6902
7136
  value: view === 'overview' ? '' : target,
6903
7137
  onChange: (event) => {
7138
+ /*
7139
+ * 下拉里的「+ 连接本机」哨兵项:它不是一个目标,选中它等于「我想加一台本机」。
7140
+ * 必须**在**下面那套换目标逻辑之前拦下——否则 setTarget('\0connect-local') 会把
7141
+ * 当前目标换成一个不存在的名字,面板随即报「未知目标」。
7142
+ */
7143
+ if (event.target.value === CONNECT_LOCAL_OPTION) {
7144
+ connectLocal()
7145
+ return
7146
+ }
6904
7147
  setTarget(event.target.value)
6905
7148
  // 用户手动选了目标 = 会话级「未匹配」状态结束(D23):此后 staleList
6906
7149
  // 的锁与胶囊照常工作,旧目标卡片不再可点
@@ -6924,6 +7167,20 @@ window.__ModuleLoader__.load({
6924
7167
  : (target === '' ? [jsx('option', { value: '', children: t('option.noTargetSelected') }, '__none')] : [])),
6925
7168
  ...(targets.length === 0 && target !== '' ? [{ name: target, label: undefined }] : targets)
6926
7169
  .map((item) => jsx('option', { value: item.name, children: targetLabel(item.name) }, item.name)),
7170
+ /*
7171
+ * 「+ 连接本机」放在列表**末尾**(就是用户找机器时会看的地方):
7172
+ * - 与工具条那版不同,它不占任何常驻位置,只有真的点开下拉才出现;
7173
+ * - `targets.length > 0` 才出现:一个目标都没有时,正文空态里已经有一个
7174
+ * 大按钮(那里才是它最该出现的地方),这里再列一项等于同一入口出现两次;
7175
+ * - 已经有本机目标时也不出现(选择器里已经有它了,再列一项等于重复);
7176
+ * - 总览视图没有「当前目标」,但仍可以加目标,所以不排除 overview。
7177
+ * `disabled` 复用 busy:请求在飞时不给点第二次(与按钮那版防连点一致)。
7178
+ */
7179
+ shouldOfferConnectLocal(targets) ? jsx('option', {
7180
+ value: CONNECT_LOCAL_OPTION,
7181
+ disabled: connectLocalBusy,
7182
+ children: connectLocalBusy ? t('msg.connectLocalBusy') : '+ ' + t('btn.connectLocal'),
7183
+ }, '__connectLocal') : null,
6927
7184
  ],
6928
7185
  }),
6929
7186
  /*
@@ -7066,7 +7323,7 @@ window.__ModuleLoader__.load({
7066
7323
  docked ? jsxs('div', { className: 'dk_toolbarEnd', children: [
7067
7324
  config?.allowMutations !== true ? jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: t('badge.readOnly') }, 'readonly') : null,
7068
7325
  // 刷新中图标自己转,所以这里不再另挂 spinner
7069
- jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.refreshList'), 'data-spin': loading ? '1' : undefined, onClick: refresh, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }) }, 'refresh'),
7326
+ jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.refreshList'), 'data-spin': loading ? '1' : undefined, onClick: refreshManually, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }) }, 'refresh'),
7070
7327
  ] }) : null,
7071
7328
  ] }),
7072
7329
  /*
@@ -7131,6 +7388,9 @@ window.__ModuleLoader__.load({
7131
7388
  statusText: eventsStatusText(),
7132
7389
  open: activityOpen,
7133
7390
  onToggle: () => setActivityOpen((value) => !value),
7391
+ // 只重开事件流,不顺带刷列表:列表有自己的 ⟳ 与 AUTO REFRESH,
7392
+ // 而用户点这句「重连」想救的就是那条听不见事件的活动条
7393
+ onReconnect: () => setEventsReconnect((value) => value + 1),
7134
7394
  }, 'activity') : null,
7135
7395
  body(),
7136
7396
  ] }),
@@ -7287,6 +7547,118 @@ window.__ModuleLoader__.load({
7287
7547
  * 设置卡片
7288
7548
  * ------------------------------------------------------------------ */
7289
7549
 
7550
+ /** 能力开关名 → 宿主侧环境变量名(「另一种方式」那一栏要写出具体变量名)。 */
7551
+ const CAPABILITY_ENV = {
7552
+ allowMutations: 'DSH_DOCKER_ALLOW_MUTATIONS',
7553
+ allowExec: 'DSH_DOCKER_ALLOW_EXEC',
7554
+ }
7555
+
7556
+ /** 能力名 → 开关文案(面板标题靠它)。 */
7557
+ function capabilityLabelOf(capability) {
7558
+ return capability === 'allowMutations' ? t('check.allowMutations') : t('check.allowExec')
7559
+ }
7560
+
7561
+ /**
7562
+ * 就地提权面板组——**纯渲染函数**(入参是纯数据 + 一组回调,返回 jsx 树),刻意不做成组件:
7563
+ * 离线冒烟的 React 桩把 `useState` 冻在初值上、`useEffect` 是空实现,「两个能力同时 pending」
7564
+ * 这件事在组件里根本走不到,而它正是本次改造要保证的行为。做成普通函数后,用例可以真的调用它、
7565
+ * 遍历返回的 jsx 树、并**驱动合并按钮的 onClick** 断言被复制下去的字节(见 scripts/client-smoke.mjs)。
7566
+ *
7567
+ * 结构:合并复制行(≥2 个能力 pending 时才有)→ 逐能力堆叠的面板(已授权的留痕不渲染)。
7568
+ */
7569
+ function elevationPanelsView(records, options) {
7570
+ const list = Array.isArray(records) ? records : []
7571
+ const now = options.now === undefined ? Date.now() : options.now
7572
+ const pending = list.filter((record) => record !== null && record !== undefined && record.granted !== true)
7573
+ if (pending.length === 0) return null
7574
+ const merged = mergeElevationCommands(list, now)
7575
+ const panel = (record) => {
7576
+ const capability = String(record.capability)
7577
+ const seconds = Number.isFinite(record.expiresAt)
7578
+ ? Math.max(0, Math.ceil((Number(record.expiresAt) - now) / 1000))
7579
+ : 0
7580
+ return jsxs('div', { className: 'dk_elevPanel', children: [
7581
+ jsxs('div', { className: 'dk_row', children: [
7582
+ jsx('span', { className: 'dk_label', children: t('elev.title') + ' · ' + capabilityLabelOf(capability) }),
7583
+ jsx('button', {
7584
+ type: 'button',
7585
+ className: 'dk_btn',
7586
+ onClick: () => options.onClose(capability),
7587
+ children: t('elev.close'),
7588
+ }, 'close'),
7589
+ ] }),
7590
+ jsx('span', { className: 'dk_hint', children: t('elev.lockedWhy') }),
7591
+ record.error === undefined
7592
+ ? null
7593
+ : jsx('span', { className: 'dk_hint dk_hintWarn', children: t('elev.error') + String(record.error) }),
7594
+ record.command === undefined
7595
+ ? null
7596
+ : jsxs('div', { className: 'dk_elevSteps', children: [
7597
+ jsx('span', { className: 'dk_hint', children: t('elev.step') }),
7598
+ jsx('code', { className: 'dk_elevCommand', children: String(record.command) }),
7599
+ /*
7600
+ * 动作行:复制 / 重新生成 / 倒计时**并排一行、紧贴命令**。
7601
+ *
7602
+ * 早先这三样各自占一条栅格行,而且「重新生成」被「282 秒后失效」「等待确认…」两行
7603
+ * 说明文字隔在下面——2026-09-27 的真机报告(Windows)里那块看上去就是散的:
7604
+ * 按钮离自己的命令越远,越像「不知道点了会发生什么」。命令与它的按钮是一件事(docker D149)。
7605
+ */
7606
+ jsxs('div', { className: 'dk_elevActions', children: [
7607
+ jsx('button', {
7608
+ type: 'button',
7609
+ className: 'dk_btn dk_btnPrimary',
7610
+ // 复制反馈**逐能力**隔离:这个按钮只认自己能力的键
7611
+ onClick: () => options.onCopy(capability, String(record.command)),
7612
+ children: options.copiedKey === capability ? t('elev.copied') : t('elev.copy'),
7613
+ }, 'copy'),
7614
+ jsx('button', {
7615
+ type: 'button',
7616
+ className: 'dk_btn',
7617
+ onClick: () => options.onRegenerate(capability),
7618
+ children: t('elev.regenerate'),
7619
+ }, 'regen'),
7620
+ jsx('span', {
7621
+ className: seconds > 0 ? 'dk_hint' : 'dk_hint dk_hintWarn',
7622
+ children: seconds > 0 ? t('elev.expiresIn', { sec: seconds }) : t('elev.expired'),
7623
+ }),
7624
+ ] }),
7625
+ jsx('span', { className: 'dk_hint', children: t('elev.waiting') }),
7626
+ jsxs('details', { className: 'dk_elevOther', children: [
7627
+ jsx('summary', { children: t('elev.otherWay') }),
7628
+ jsx('span', {
7629
+ className: 'dk_hint',
7630
+ children: t('elev.envHow', { env: CAPABILITY_ENV[capability] ?? '' }),
7631
+ }),
7632
+ ] }),
7633
+ ] }),
7634
+ options.configured(capability) === true
7635
+ ? jsx('button', {
7636
+ type: 'button',
7637
+ className: 'dk_btn',
7638
+ onClick: () => options.onDisableFirst(capability),
7639
+ children: t('elev.disableFirst'),
7640
+ }, 'disable')
7641
+ : null,
7642
+ ] })
7643
+ }
7644
+ return jsxs('div', { className: 'dk_elevPanels', children: [
7645
+ shouldOfferMerge(list, now)
7646
+ ? jsxs('div', { className: 'dk_elevMerge', children: [
7647
+ jsx('button', {
7648
+ type: 'button',
7649
+ className: 'dk_btn dk_btnPrimary',
7650
+ onClick: () => options.onCopyAll(joinElevationCommands(merged), merged.length),
7651
+ children: options.copiedKey === ELEVATION_COPY_ALL
7652
+ ? t('elev.copyAllCopied', { count: merged.length })
7653
+ : t('elev.copyAll', { count: merged.length }),
7654
+ }, 'copyAll'),
7655
+ jsx('span', { className: 'dk_hint', children: t('elev.copyAllHint') }),
7656
+ ] })
7657
+ : null,
7658
+ ...pending.map((record) => panel(record)),
7659
+ ] })
7660
+ }
7661
+
7290
7662
  function DockerSettingsCard(props) {
7291
7663
  // DSH ≥0.1.6 的插件配置页把同一条目按 view 渲染两次:summary 一句话摘要、page 完整表单。
7292
7664
  // 旧版(≤0.1.5)的 settings.plugin.item 卡片不带 view,走原有可折叠卡片分支。
@@ -7318,10 +7690,26 @@ window.__ModuleLoader__.load({
7318
7690
  * 就地提权(见 kit 的 elevation.ts):点开关 → 面板给出一条「在宿主上落地一个随机名文件」的
7319
7691
  * 命令 → 宿主发现文件即授权,免重启。状态放在卡片里而不是子组件里:离线冒烟的 React 桩不执行
7320
7692
  * 函数组件体(见文件里其它地方的说明),状态留在卡片里才测得动。
7693
+ *
7694
+ * **逐能力**记录(数组,按发起顺序)而不是单槽:面板开着时再点另一个开关,单槽会把前一个
7695
+ * 能力的面板**替换**掉——它的命令从界面消失(宿主侧 challenge 仍 pending),用户于是要在宿主
7696
+ * 终端粘贴两次(2026-09-26 截图实测:两次授权差 14 秒)。逐能力记录让两块面板同时在场,
7697
+ * 顶部出一个「复制全部」。纯逻辑在 `client-src/elevation.js`(冒烟能直接驱动)。
7698
+ *
7699
+ * 为什么不做「kit 侧 begin 返回合并命令」(备选方案,已否):`finishElevation` 只处理**被轮询
7700
+ * 到的那个**能力,合并命令会让另一个能力停在「宿主已授权、配置开关没开」的半状态;还要处理
7701
+ * 两条独立探测定时器之间的授权竞态。客户端合并零 API 变更、零新安全面——kit 的
7702
+ * begin / status / revoke 契约一字不动(nonce 仍只出现在 begin 响应里)。
7321
7703
  */
7322
- const [elevation, setElevation] = useState(null)
7704
+ const [elevations, setElevations] = useState([])
7323
7705
  const [elevationNotice, setElevationNotice] = useState('')
7324
- const [copied, setCopied] = useState(false)
7706
+ /**
7707
+ * 复制反馈的归属键(**不是**布尔):单条按钮传自己的能力名、合并按钮传哨兵
7708
+ * `ELEVATION_COPY_ALL`,渲染时各自判自己的键——于是「复制了 A」不会让 B 的按钮也显示
7709
+ * 「已复制」,而「复制全部」也不会让每一块面板都亮起来(改前是一个全局 `copied` 布尔,
7710
+ * 两个按钮会一起变成已复制)。同一时刻只留一处反馈是刻意的:它说的是「你刚复制的是哪一份」。
7711
+ */
7712
+ const [copiedKey, setCopiedKey] = useState('')
7325
7713
  /** 每秒推进一次的计数:只为了让「还剩 N 秒」自己走(读的是渲染期的时间差)。 */
7326
7714
  const [nowTick, setNowTick] = useState(0)
7327
7715
  /** 授权到达后要「先把开关写进表单、再自动保存」:保存读的是表单镜像,得等一次提交。 */
@@ -7444,12 +7832,6 @@ window.__ModuleLoader__.load({
7444
7832
 
7445
7833
  /* ---------------- 就地提权(capability elevation) ---------------- */
7446
7834
 
7447
- /** 能力开关名 → 宿主侧环境变量名(「另一种方式」那一栏要写出具体变量名)。 */
7448
- const CAPABILITY_ENV = {
7449
- allowMutations: 'DSH_DOCKER_ALLOW_MUTATIONS',
7450
- allowExec: 'DSH_DOCKER_ALLOW_EXEC',
7451
- }
7452
-
7453
7835
  /**
7454
7836
  * 快照 → 表单:能力开关在表单里存**配置值**(用户写下的那个),不是快照里的有效值。
7455
7837
  *
@@ -7488,8 +7870,18 @@ window.__ModuleLoader__.load({
7488
7870
  * 一起写进去。脏的时候只把开关置上并提示「点保存生效」——不替用户做决定。
7489
7871
  */
7490
7872
  const finishElevation = (capability) => {
7491
- setElevation(null)
7492
- setCopied(false)
7873
+ /*
7874
+ * **不删记录**,只把它标成已授权并把命令清掉(面板据此消失)。为什么留着这条痕迹:
7875
+ * 合并按钮的存续跟着「面板组里有过几个能力」,不是跟着当前还剩几条命令——两个能力
7876
+ * 都点过之后,其中一个先解锁,按钮要留着把**剩下那条**交给用户(此时再让他回单条面板
7877
+ * 里找命令,等于把刚刚那次「一次粘贴」的动作拆回去)。两个都解锁后组里没有可复制命令,
7878
+ * 按钮自然消失(见 shouldOfferMerge)。
7879
+ *
7880
+ * 反向也成立:「单个 pending → 无合并按钮」——只动过一个开关时组里只有一条记录,
7881
+ * 那条命令就在它自己的面板里,不需要一个「复制全部(1 条)」的按钮。
7882
+ */
7883
+ setElevations((current) => markElevationGranted(current, capability))
7884
+ setCopiedKey('')
7493
7885
  const clean = JSON.stringify(toPayload(formRef.current)) === cleanPayloadRef.current
7494
7886
  setForm((current) => (current === null ? current : { ...current, [capability]: true }))
7495
7887
  setElevationNotice(clean ? t('elev.granted') : t('elev.grantedNeedSave'))
@@ -7499,19 +7891,37 @@ window.__ModuleLoader__.load({
7499
7891
 
7500
7892
  const beginElevation = (capability) => {
7501
7893
  setElevationNotice('')
7502
- setCopied(false)
7503
- setElevation({ capability })
7894
+ setCopiedKey('')
7895
+ // 只增改**这一个**能力的记录,别的 pending 原样留着(面板不再被替换)
7896
+ setElevations((current) => upsertElevationRecord(current, capability, {
7897
+ capability,
7898
+ command: undefined,
7899
+ expiresAt: undefined,
7900
+ error: undefined,
7901
+ // 清掉上一轮的「已授权」痕迹:撤销授权后再点这个开关要能重新出面板
7902
+ granted: false,
7903
+ }))
7504
7904
  api.elevateBegin(capability).then((result) => {
7505
7905
  if (result !== null && result.status === 'granted') {
7506
7906
  finishElevation(capability)
7507
7907
  return
7508
7908
  }
7509
7909
  if (result !== null && result.status === 'pending') {
7510
- setElevation({ capability, command: String(result.command), expiresAt: Number(result.expiresAt) })
7910
+ setElevations((current) => upsertElevationRecord(current, capability, {
7911
+ capability,
7912
+ command: String(result.command),
7913
+ expiresAt: Number(result.expiresAt),
7914
+ }))
7511
7915
  return
7512
7916
  }
7513
- setElevation({ capability, error: String((result && result.error) ?? '') })
7514
- }).catch((error) => setElevation({ capability, error: String(error.message) }))
7917
+ setElevations((current) => upsertElevationRecord(current, capability, {
7918
+ capability,
7919
+ error: String((result && result.error) ?? ''),
7920
+ }))
7921
+ }).catch((error) => setElevations((current) => upsertElevationRecord(current, capability, {
7922
+ capability,
7923
+ error: String(error.message),
7924
+ })))
7515
7925
  }
7516
7926
 
7517
7927
  const revokeElevation = (capability) => {
@@ -7522,14 +7932,36 @@ window.__ModuleLoader__.load({
7522
7932
  }).catch((error) => setElevationNotice(t('elev.error') + String(error.message)))
7523
7933
  }
7524
7934
 
7525
- /** 复制命令:拿不到剪贴板(老宿主 / 非安全上下文)时退回「请手动复制」。 */
7526
- const copyCommand = (command) => {
7935
+ /**
7936
+ * 复制命令:拿不到剪贴板(老宿主 / 非安全上下文)时退回「请手动复制」。
7937
+ *
7938
+ * `key` 是反馈的归属:单条按钮传能力名,合并按钮传 `ELEVATION_COPY_ALL`——
7939
+ * 于是「复制了 A」不会让 B 的按钮也显示「已复制」(早先是一个全局 `copied`)。
7940
+ */
7941
+ const copyCommand = (key, command) => {
7527
7942
  const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
7528
7943
  if (clipboard === undefined || typeof clipboard.writeText !== 'function') {
7529
7944
  setElevationNotice(t('elev.copyManual'))
7530
7945
  return
7531
7946
  }
7532
- clipboard.writeText(command).then(() => setCopied(true)).catch(() => setElevationNotice(t('elev.copyManual')))
7947
+ clipboard.writeText(command).then(() => setCopiedKey(key)).catch(() => setElevationNotice(t('elev.copyManual')))
7948
+ }
7949
+
7950
+ /**
7951
+ * 合并复制:把所有**仍 pending**(未过期、已拿到命令)的命令按发起顺序用换行连接后一次复制。
7952
+ *
7953
+ * 命令的取用 / 排序 / 连接全在纯函数里(`client-src/elevation.js` 的 `buildMergedElevationText`
7954
+ * 与 `joinElevationCommands`),这里只负责把它交给剪贴板。连接方式为什么是换行而不是 `&&` / `;`:
7955
+ * bash / zsh / PowerShell / cmd 粘贴多行都逐行执行,平台无关;`&&` 在 PS 5.1 上不认,`;` 在 cmd
7956
+ * 上不认。文案只说「执行后全部开关自动解锁」,不承诺同一时刻:两个 challenge 的 TTL 与探测
7957
+ * 周期各自独立(逐面板倒计时照旧显示)。
7958
+ */
7959
+ const copyAllElevations = (text) => {
7960
+ if (text === '') {
7961
+ setElevationNotice(t('elev.copyManual'))
7962
+ return
7963
+ }
7964
+ copyCommand(ELEVATION_COPY_ALL, text)
7533
7965
  }
7534
7966
 
7535
7967
  /*
@@ -7546,20 +7978,33 @@ window.__ModuleLoader__.load({
7546
7978
  /*
7547
7979
  * 面板开着时轮询授权状态(1.5s)并顺带推进倒计时。轮询而不是等推送:这条通道本来就是
7548
7980
  * 「用户在别的终端里做了一件事」,宿主没有可订阅的事件面。
7981
+ *
7982
+ * **轮询全部 pending 能力**(不再只盯单槽那一个):两个 challenge 是各自独立开始的,
7983
+ * 各自的授权时刻可以差十几秒;只轮询一个会让另一个停在「宿主已授权、开关没开」。
7984
+ * deps 用**能力名的拼接串**而不是数组:数组每次渲染都是新引用,effect 会被反复拆装,
7985
+ * 1.5s 的节拍随之漂成「每渲染一次就重建定时器」。内容不变时它一字不变,effect 就不动。
7549
7986
  */
7550
- const elevationCapability = elevation === null ? '' : String(elevation.capability)
7987
+ const elevationPendingKey = elevations
7988
+ // 已授权的那条只是「留痕」(面板已消失),不再轮询——否则每 1.5s 又会走一遍 finishElevation
7989
+ .filter((item) => item.granted !== true)
7990
+ .map((item) => String(item.capability))
7991
+ .sort()
7992
+ .join(',')
7551
7993
  useEffect(() => {
7552
- if (elevationCapability === '') return undefined
7994
+ if (elevationPendingKey === '') return undefined
7995
+ const capabilities = elevationPendingKey.split(',')
7553
7996
  const timer = setInterval(() => {
7554
7997
  setNowTick((value) => value + 1)
7555
- api.elevateStatus(elevationCapability).then((status) => {
7556
- if (status !== null && status.status === 'granted') finishElevation(elevationCapability)
7557
- }).catch(() => {
7558
- /* 轮询失败静默:宿主可能在重启,下一次再试 */
7559
- })
7998
+ for (const capability of capabilities) {
7999
+ api.elevateStatus(capability).then((status) => {
8000
+ if (status !== null && status.status === 'granted') finishElevation(capability)
8001
+ }).catch(() => {
8002
+ /* 轮询失败静默:宿主可能在重启,下一次再试 */
8003
+ })
8004
+ }
7560
8005
  }, 1500)
7561
8006
  return () => clearInterval(timer)
7562
- }, [elevationCapability])
8007
+ }, [elevationPendingKey])
7563
8008
 
7564
8009
  /* ---------------- 能力开关那一段的渲染 ---------------- */
7565
8010
 
@@ -7665,79 +8110,35 @@ window.__ModuleLoader__.load({
7665
8110
  ] })
7666
8111
  }
7667
8112
 
7668
- /** 就地提权面板(内联展开;不用 confirm()——那会在 React 之外同步阻塞渲染)。 */
7669
- const elevationPanel = () => {
7670
- const capability = String(elevation.capability)
7671
- const label = capability === 'allowMutations' ? t('check.allowMutations') : t('check.allowExec')
7672
- const seconds = elevation.expiresAt === undefined
7673
- ? 0
7674
- : Math.max(0, Math.ceil((Number(elevation.expiresAt) - Date.now()) / 1000))
7675
- const close = () => {
7676
- setElevation(null)
7677
- setCopied(false)
7678
- }
7679
- return jsxs('div', { className: 'dk_elevPanel', children: [
7680
- jsxs('div', { className: 'dk_row', children: [
7681
- jsx('span', { className: 'dk_label', children: t('elev.title') + ' · ' + label }),
7682
- jsx('button', { type: 'button', className: 'dk_btn', onClick: close, children: t('elev.close') }, 'close'),
7683
- ] }),
7684
- jsx('span', { className: 'dk_hint', children: t('elev.lockedWhy') }),
7685
- elevation.error === undefined
7686
- ? null
7687
- : jsx('span', { className: 'dk_hint dk_hintWarn', children: t('elev.error') + String(elevation.error) }),
7688
- elevation.command === undefined
7689
- ? null
7690
- : jsxs('div', { className: 'dk_elevSteps', children: [
7691
- jsx('span', { className: 'dk_hint', children: t('elev.step') }),
7692
- jsx('code', { className: 'dk_elevCommand', children: String(elevation.command) }),
7693
- /*
7694
- * 动作行:复制 / 重新生成 / 倒计时**并排一行、紧贴命令**。
7695
- *
7696
- * 早先这三样各自占一条栅格行,而且「重新生成」被「282 秒后失效」「等待确认…」两行
7697
- * 说明文字隔在下面——2026-09-27 的真机报告(Windows)里那块看上去就是散的:
7698
- * 按钮离自己的命令越远,越像「不知道点了会发生什么」。命令与它的按钮是一件事(docker D149)。
7699
- */
7700
- jsxs('div', { className: 'dk_elevActions', children: [
7701
- jsx('button', {
7702
- type: 'button',
7703
- className: 'dk_btn dk_btnPrimary',
7704
- onClick: () => copyCommand(String(elevation.command)),
7705
- children: copied ? t('elev.copied') : t('elev.copy'),
7706
- }, 'copy'),
7707
- jsx('button', {
7708
- type: 'button',
7709
- className: 'dk_btn',
7710
- onClick: () => beginElevation(capability),
7711
- children: t('elev.regenerate'),
7712
- }, 'regen'),
7713
- jsx('span', {
7714
- className: seconds > 0 ? 'dk_hint' : 'dk_hint dk_hintWarn',
7715
- children: seconds > 0 ? t('elev.expiresIn', { sec: seconds }) : t('elev.expired'),
7716
- }),
7717
- ] }),
7718
- jsx('span', { className: 'dk_hint', children: t('elev.waiting') }),
7719
- jsxs('details', { className: 'dk_elevOther', children: [
7720
- jsx('summary', { children: t('elev.otherWay') }),
7721
- jsx('span', {
7722
- className: 'dk_hint',
7723
- children: t('elev.envHow', { env: CAPABILITY_ENV[capability] ?? '' }),
7724
- }),
7725
- ] }),
7726
- ] }),
7727
- form[capability] === true
7728
- ? jsx('button', {
7729
- type: 'button',
7730
- className: 'dk_btn',
7731
- onClick: () => {
7732
- patch({ [capability]: false })
7733
- close()
7734
- },
7735
- children: t('elev.disableFirst'),
7736
- }, 'disable')
7737
- : null,
7738
- ] })
8113
+ /**
8114
+ * 一块能力的面板(✕)与整个面板组都渲染自 `elevationPanelsView`(factory 级纯函数,
8115
+ * 见那段注释):组件这边只提供**状态与回调**,界面结构留在纯函数里,冒烟才驱动得动。
8116
+ *
8117
+ * 逐个能力的动作语义:
8118
+ * - ✕ 只收起**这一个**能力的面板;别的 pending 原样留着(也不撤销宿主侧 challenge,
8119
+ * 与改造前 close 的语义一致——challenge 仍在 pending,重新点开关即复用同一条命令)。
8120
+ * - 「重新生成」= 再走一次 `beginElevation`(逐能力幂等,不动别的能力)。
8121
+ * - 「先关掉这个配置开关」= 关配置 + 收起自己那块面板。
8122
+ */
8123
+ const closeElevationPanel = (capability) => {
8124
+ setElevations((current) => pruneElevationRecords(removeElevationRecord(current, capability)))
8125
+ setCopiedKey('')
7739
8126
  }
7740
8127
 
8128
+ const elevationPanels = () => elevationPanelsView(elevations, {
8129
+ now: Date.now(),
8130
+ copiedKey,
8131
+ configured: (capability) => form[capability] === true,
8132
+ onCopy: (capability, command) => copyCommand(capability, command),
8133
+ onCopyAll: (text) => copyAllElevations(text),
8134
+ onRegenerate: (capability) => beginElevation(capability),
8135
+ onClose: (capability) => closeElevationPanel(capability),
8136
+ onDisableFirst: (capability) => {
8137
+ patch({ [capability]: false })
8138
+ closeElevationPanel(capability)
8139
+ },
8140
+ })
8141
+
7741
8142
  const sectionTitle = (text) => jsx('div', { className: 'dk_cardSection', children: text })
7742
8143
 
7743
8144
  const field = (label, control, hint, span) => jsxs('div', {
@@ -7849,7 +8250,7 @@ window.__ModuleLoader__.load({
7849
8250
  elevationNotice === ''
7850
8251
  ? null
7851
8252
  : jsx('span', { className: 'dk_hint', children: elevationNotice }),
7852
- elevation === null ? null : elevationPanel(),
8253
+ elevations.length === 0 ? null : elevationPanels(),
7853
8254
 
7854
8255
  sectionTitle(t('field.target')),
7855
8256
  ...form.targets.map((item, index) => {
@@ -8304,6 +8705,7 @@ window.__ModuleLoader__.load({
8304
8705
  actionText: eventActionText,
8305
8706
  timeText: eventTimeText,
8306
8707
  debounce: makeDebounced,
8708
+ canReconnect: canReconnectEvents,
8307
8709
  }
8308
8710
  /*
8309
8711
  * 同一类测试缝:总览的折叠逻辑与**正文渲染**都是纯函数(正文刻意写成普通函数而不是
@@ -8339,7 +8741,26 @@ window.__ModuleLoader__.load({
8339
8741
  * 「连接还没建立时靠连接簿 host 兜底」这条路径。
8340
8742
  */
8341
8743
  matchTargetForSession,
8744
+ /*
8745
+ * 目标下拉里「+ 连接本机」那一项:哨兵值 + 「要不要列这一项」的判定。两个都挂出来,
8746
+ * 用例才能直接驱动 —— 离线冒烟的 React 桩把 useState 冻在初值上(`targets` 恒为 []),
8747
+ * 遍历渲染树根本走不到那一项,所以判定必须是纯函数。
8748
+ */
8749
+ CONNECT_LOCAL_OPTION,
8750
+ shouldOfferConnectLocal,
8342
8751
  }
8752
+ /*
8753
+ * 模块级 config 缓存的测试缝:`matchTargetForSession` 这类判定读的是它,而它只由
8754
+ * `/config` 的响应 / 设置卡片保存后推进去。离线冒烟没有真往返,直接把一份 config
8755
+ * 写进去是最短的路径(publish 顺带通知订阅者,与生产路径同一份语义)。
8756
+ */
8757
+ exports.__config = { publishConfig, primeTargetsCache }
8758
+ /*
8759
+ * HTTP 面的测试缝:模块级的 `api` 里的每条都是「路径 + 方法 + body」的直接映射,
8760
+ * 离线冒烟用桩 fetch 调一次就能钉住「一键连接本机打的是 /connect-local 这个 POST」
8761
+ * 这类**接线**错误(路径拼歪了在真机上才暴露)。
8762
+ */
8763
+ exports.__api = api
8343
8764
  /*
8344
8765
  * 日志缓冲的测试缝:环形上限 / 残行分片 / 单调 id / 快照切分都是纯逻辑,离线冒烟
8345
8766
  * 直接驱动(真实 EventSource 时序进不了 Node 桩)。常量挂出来供用例对齐,避免两边漂移。
@@ -8352,6 +8773,29 @@ window.__ModuleLoader__.load({
8352
8773
  PENDING_MAX: LOG_PENDING_MAX,
8353
8774
  FLUSH_MS: FOLLOW_FLUSH_MS,
8354
8775
  }
8776
+ /*
8777
+ * 就地提权「合并复制」的测试缝:逐能力记录的增改 / 收尾、取哪几条命令、顺序、怎么连接,
8778
+ * 全是纯逻辑(`client-src/elevation.js`)。**必须直接驱动**而不是渲染卡片:离线冒烟的
8779
+ * React 桩把 `useState` 冻在初值上、且 `useEffect` 是空实现,两块面板同时在场这件事
8780
+ * 在组件里根本走不到(这正是 2026-09-26 那次「必须粘贴两次」漏掉测试入口的原因)。
8781
+ */
8782
+ exports.__elevation = {
8783
+ upsert: upsertElevationRecord,
8784
+ remove: removeElevationRecord,
8785
+ markGranted: markElevationGranted,
8786
+ prune: pruneElevationRecords,
8787
+ merge: mergeElevationCommands,
8788
+ join: joinElevationCommands,
8789
+ text: buildMergedElevationText,
8790
+ shouldOfferMerge,
8791
+ COPY_ALL_KEY: ELEVATION_COPY_ALL,
8792
+ /**
8793
+ * 面板组的**渲染**(与 `__overview.body` 同一形态):用例据它断言「几个 pending 才出
8794
+ * 合并按钮」「按钮的 onClick 到底把什么字符串交下去」——只钉纯函数的话,「界面上没有
8795
+ * 那个按钮」与「按钮接错了内容」这两类错都漏得掉。
8796
+ */
8797
+ view: elevationPanelsView,
8798
+ }
8355
8799
  /*
8356
8800
  * 窗口化挂载(D152)的测试缝:布局是纯逻辑,离线冒烟直接驱动(真 DOM 里的量高与
8357
8801
  * 滚动位置由 test/log-window.test.ts 与性能门禁覆盖)。常量挂出来供用例对齐。