@hyzyn/dsh-docker 0.9.6 → 0.11.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.
- package/README.en.md +106 -15
- package/README.md +97 -10
- package/client-src/docker.css +26 -0
- package/client-src/elevation.js +163 -0
- package/client-src/index.js +401 -125
- package/client.js +50 -23
- package/lib/audit.d.ts +106 -0
- package/lib/audit.js +140 -0
- package/lib/audit.js.map +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +94 -16
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.js +27 -10
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +2 -2
- package/scripts/client-smoke.mjs +260 -24
- package/scripts/route-smoke.mjs +143 -1
package/client-src/index.js
CHANGED
|
@@ -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
|
|
|
@@ -55,10 +62,11 @@ const I18N_ZH = {
|
|
|
55
62
|
'status.restarting': '重启中',
|
|
56
63
|
'status.removing': '删除中',
|
|
57
64
|
'status.unknown': '未知',
|
|
58
|
-
'hint.pickMaxLocal': '最多 {max}
|
|
59
|
-
'hint.pickMaxSsh': '最多 {max}
|
|
60
|
-
'hint.pickMany': '
|
|
65
|
+
'hint.pickMaxLocal': '最多 {max} 个容器——再多会占满浏览器的同源连接,整个页面都会卡',
|
|
66
|
+
'hint.pickMaxSsh': '最多 {max} 个容器——再多会占满浏览器的同源连接,整个页面都会卡',
|
|
67
|
+
'hint.pickMany': '已接近上限,再选下去整个页面会变卡',
|
|
61
68
|
'hint.pickAtLeastTwo': '至少选择 2 个容器',
|
|
69
|
+
'hint.pickBudget': '浏览器每个站点最多同时保持 4 条实时流(含活动条的事件流),再多页面就会卡住',
|
|
62
70
|
'option.presetAll': '全部可见',
|
|
63
71
|
'status.unhealthy': '不健康',
|
|
64
72
|
'status.attention': '需关注',
|
|
@@ -354,7 +362,8 @@ const I18N_ZH = {
|
|
|
354
362
|
'status.backToListRefresh': ',列表回到 AUTO REFRESH / 手动刷新;活动条上点「重连」可重新订阅',
|
|
355
363
|
'btn.reconnectEvents': '重连',
|
|
356
364
|
'hint.reconnectEvents': '重新打开容器事件流(docker events);列表刷新按钮也会一并重连',
|
|
357
|
-
'hint.eventsClosedNoAuto': '
|
|
365
|
+
'hint.eventsClosedNoAuto': '不会自动恢复,点「重连」',
|
|
366
|
+
'hint.eventsReconnectRefused': '重连被拒:{message}',
|
|
358
367
|
'hint.conversationHidden': ' · 会话在面板后面:关掉或最小化面板/终端即可看到',
|
|
359
368
|
'msg.copied': '已复制:',
|
|
360
369
|
'error.copyManual': '复制失败,请手动执行:',
|
|
@@ -474,11 +483,16 @@ const I18N_ZH = {
|
|
|
474
483
|
'field.execTimeout': 'exec 超时(秒)',
|
|
475
484
|
'section.capabilities': '能力开关(默认关闭)',
|
|
476
485
|
'check.allowMutations': '允许变更操作(容器启停删、镜像拉取 / 删除 / 清理)',
|
|
477
|
-
|
|
486
|
+
// 限定到「一次性」通道:旧文案「(在容器内执行命令)」会被读成「exec 整体要授权」,
|
|
487
|
+
// 而卡片「终端」按钮的 docker exec -it 不受它管(D163)。设置卡片是用户第一眼看到的地方。
|
|
488
|
+
'check.allowExec': '允许一次性 exec(面板命令框与 docker_exec;交互式进容器不由它管)',
|
|
478
489
|
'hint.socketRoot': 'docker socket 等价于目标主机的 root 权限。开启后,浏览器面板与 agent 都能执行对应操作,请只在可信环境下打开。',
|
|
479
490
|
'hint.capabilityNotGranted': '⚠ 宿主尚未授权:这两个开关现在打不开,点它会给出一条就地确认的命令(免重启)。关掉它们随时可用。',
|
|
480
491
|
'badge.notEffective': '未生效:未获宿主授权',
|
|
481
492
|
'elev.title': '就地授权(免重启)',
|
|
493
|
+
'elev.copyAll': '复制全部({count} 条)',
|
|
494
|
+
'elev.copyAllHint': '每个能力各要一条命令。一次粘贴全部命令,执行后全部开关自动解锁(各自的探测周期内先后生效)。',
|
|
495
|
+
'elev.copyAllCopied': '已复制全部 {count} 条',
|
|
482
496
|
'elev.lockedWhy': '本机任意进程都能发回环请求,所以「打开危险能力」这件事不能由这个页面自己说了算——必须在宿主的文件系统上确认一次。',
|
|
483
497
|
'elev.step': '在宿主的终端里执行下一条命令,开关会自动解锁:',
|
|
484
498
|
'elev.copy': '复制命令',
|
|
@@ -539,10 +553,11 @@ const I18N_EN = {
|
|
|
539
553
|
'status.restarting': 'Restarting',
|
|
540
554
|
'status.removing': 'Removing',
|
|
541
555
|
'status.unknown': 'Unknown',
|
|
542
|
-
'hint.pickMaxLocal': 'At most {max} containers
|
|
543
|
-
'hint.pickMaxSsh': 'At most {max} containers
|
|
544
|
-
'hint.pickMany': '
|
|
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',
|
|
545
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',
|
|
546
561
|
'option.presetAll': 'All visible',
|
|
547
562
|
'status.unhealthy': 'Unhealthy',
|
|
548
563
|
'status.attention': 'Needs attention',
|
|
@@ -838,7 +853,8 @@ const I18N_EN = {
|
|
|
838
853
|
'status.backToListRefresh': ', the list returns to AUTO REFRESH / manual refresh; hit “Reconnect” on the activity bar to resubscribe',
|
|
839
854
|
'btn.reconnectEvents': 'Reconnect',
|
|
840
855
|
'hint.reconnectEvents': 'Reopen the container event stream (docker events); the list refresh button reconnects it too',
|
|
841
|
-
'hint.eventsClosedNoAuto': '
|
|
856
|
+
'hint.eventsClosedNoAuto': 'Will not recover on its own — click Reconnect',
|
|
857
|
+
'hint.eventsReconnectRefused': 'Reconnect refused: {message}',
|
|
842
858
|
'hint.conversationHidden': ' · the session is behind this panel: close or minimise the panel/terminal to see it',
|
|
843
859
|
'msg.copied': 'Copied: ',
|
|
844
860
|
'error.copyManual': 'Copy failed, run it manually: ',
|
|
@@ -958,11 +974,14 @@ const I18N_EN = {
|
|
|
958
974
|
'field.execTimeout': 'exec timeout (seconds)',
|
|
959
975
|
'section.capabilities': 'Capability switches (off by default)',
|
|
960
976
|
'check.allowMutations': 'Allow changes (start/stop/remove containers, pull / remove / prune images)',
|
|
961
|
-
'check.allowExec': 'Allow exec (
|
|
977
|
+
'check.allowExec': 'Allow one-shot exec (panel command box and docker_exec; interactive container access is not gated by it)',
|
|
962
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.',
|
|
963
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.',
|
|
964
980
|
'badge.notEffective': 'Not effective: not granted by the host',
|
|
965
981
|
'elev.title': 'Grant in place (no restart)',
|
|
982
|
+
'elev.copyAll': 'Copy all ({count})',
|
|
983
|
+
'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).',
|
|
984
|
+
'elev.copyAllCopied': 'Copied all {count}',
|
|
966
985
|
'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.',
|
|
967
986
|
'elev.step': 'Run the command below in a terminal on the host and the switch unlocks by itself:',
|
|
968
987
|
'elev.copy': 'Copy command',
|
|
@@ -1540,17 +1559,42 @@ const ICON_VOLUME =
|
|
|
1540
1559
|
* 不进 settings,也不新增任何服务端字段,刷新后按 id 对账即可。
|
|
1541
1560
|
*/
|
|
1542
1561
|
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
|
1583
|
+
/**
|
|
1584
|
+
* 超过这个数就给软提示(仍可聚合)。
|
|
1585
|
+
*
|
|
1586
|
+
* 与硬上限之间刻意**留一档**:硬上限是「再点就连页面都卡」的红线,而用户在 3 条时
|
|
1587
|
+
* 已经在消耗大部分预算——给一句提醒,但不禁用。
|
|
1588
|
+
*/
|
|
1589
|
+
const PICK_SOFT_MAX = 2
|
|
1547
1590
|
/**
|
|
1548
|
-
* SSH
|
|
1549
|
-
*
|
|
1550
|
-
*
|
|
1551
|
-
*
|
|
1591
|
+
* SSH 目标为什么不再单独降一档:真正卡住页面的是**浏览器同源预算**(上面那条,与目标
|
|
1592
|
+
* 类型无关),而不是 SSH 的 `MaxSessions`。旧代码把上限按「SSH 要同时装实时流与短命令」
|
|
1593
|
+
* 设成 6——那条例由针对的是**宿主侧**通道额度,与浏览器这一层是两笔独立的账,写成同一个
|
|
1594
|
+
* 数字正好把两者都算错(6 > 实测安全值)。现在两者各归各:浏览器预算管上限,
|
|
1595
|
+
* `MaxSessions` 由宿主侧的 `MAX_STREAMS_PER_TARGET` 管。
|
|
1552
1596
|
*/
|
|
1553
|
-
const PICK_MAX_SSH =
|
|
1597
|
+
const PICK_MAX_SSH = PICK_MAX
|
|
1554
1598
|
|
|
1555
1599
|
/**
|
|
1556
1600
|
* 目标下拉里要不要列「+ 连接本机」那一项。
|
|
@@ -1615,8 +1659,8 @@ function pickReconcile(ids, containers) {
|
|
|
1615
1659
|
|
|
1616
1660
|
/**
|
|
1617
1661
|
* 「按条件一键选择」的预设(0.15.0)。
|
|
1618
|
-
* 为什么需要:跨 30+
|
|
1619
|
-
*
|
|
1662
|
+
* 为什么需要:跨 30+ 容器里挑几个不健康的,手动点既慢又容易漏;而聚合日志有上限
|
|
1663
|
+
* (见 {@link PICK_MAX}),所以条件选择必须能**按上限截断并如实告知略过了几个**。
|
|
1620
1664
|
* `needsBase` 的项要先有勾选(拿第一个勾选的容器当基准:同镜像 / 同项目)。
|
|
1621
1665
|
*/
|
|
1622
1666
|
const PICK_PRESETS = [
|
|
@@ -1954,6 +1998,36 @@ function canReconnectEvents(status) {
|
|
|
1954
1998
|
return status === 'closed'
|
|
1955
1999
|
}
|
|
1956
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
|
+
|
|
1957
2031
|
/** 新事件放最前(活动条按时间倒序读),超出上限丢最旧。 */
|
|
1958
2032
|
function pushEvent(list, event, limit) {
|
|
1959
2033
|
const next = [event, ...list]
|
|
@@ -5467,6 +5541,19 @@ window.__ModuleLoader__.load({
|
|
|
5467
5541
|
* `unsupported` 刻意不给这个按钮:那个环境根本没有 EventSource,重连必然还是失败。
|
|
5468
5542
|
*/
|
|
5469
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 }))
|
|
5470
5557
|
return jsxs('div', { className: 'dk_activity', 'data-open': open ? '1' : '0', children: [
|
|
5471
5558
|
/*
|
|
5472
5559
|
* 折叠按钮与「重连」是**兄弟**,不是嵌套——按钮里嵌按钮既不是合法 HTML,
|
|
@@ -5484,10 +5571,8 @@ window.__ModuleLoader__.load({
|
|
|
5484
5571
|
jsx('span', { className: 'dk_activityState', 'data-state': props.status ?? '', children: props.statusText ?? '' }),
|
|
5485
5572
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
5486
5573
|
// 断开时右侧那句「暂无事件」是误导(不是「没动静」,是「听不见了」),
|
|
5487
|
-
//
|
|
5488
|
-
jsx('span', { className: 'dk_hint', children:
|
|
5489
|
-
? t('hint.eventsClosedNoAuto')
|
|
5490
|
-
: (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 }),
|
|
5491
5576
|
jsx('span', { className: 'dk_activityChevron', dangerouslySetInnerHTML: { __html: ICON_CHEVRON } }),
|
|
5492
5577
|
],
|
|
5493
5578
|
}, 'head'),
|
|
@@ -5557,6 +5642,7 @@ window.__ModuleLoader__.load({
|
|
|
5557
5642
|
onClick: props.onClear,
|
|
5558
5643
|
children: t('btn.clear'),
|
|
5559
5644
|
}, 'clear') : null,
|
|
5645
|
+
props.budget === undefined ? null : jsx('span', { className: 'dk_hint dk_pickBudget', children: props.budget }),
|
|
5560
5646
|
props.notice === '' ? null : jsx('span', { className: 'dk_hint dk_pickNotice', children: props.notice }),
|
|
5561
5647
|
] }) : null,
|
|
5562
5648
|
] })
|
|
@@ -5786,6 +5872,12 @@ window.__ModuleLoader__.load({
|
|
|
5786
5872
|
/** 面板是否可见(S3):折叠的 tab 不建流;模态 / dock 形态恒为 true。 */
|
|
5787
5873
|
const active = usePanelActive()
|
|
5788
5874
|
const [eventsStatus, setEventsStatus] = useState('')
|
|
5875
|
+
/**
|
|
5876
|
+
* 上一次被服务端**拒绝**的原因(D164):配额满这类拒绝在界面上必须看得见,
|
|
5877
|
+
* 否则「点重连 → 被拒 → 回到 closed」在视觉上与「点了没反应」不可区分。
|
|
5878
|
+
* 重连**成功**时清空(那说明这个原因已经过去了)。
|
|
5879
|
+
*/
|
|
5880
|
+
const [eventsRefused, setEventsRefused] = useState('')
|
|
5789
5881
|
/**
|
|
5790
5882
|
* 手动重连令牌(D160):事件流断开后是**终止态**(EventSource 只在连接层抖动时自愈;
|
|
5791
5883
|
* `docker events` 进程退出 / 服务端发 `end` 帧都会走 close(),之后没有东西会再开流)。
|
|
@@ -6445,6 +6537,9 @@ window.__ModuleLoader__.load({
|
|
|
6445
6537
|
*/
|
|
6446
6538
|
let hadOpen = sameTarget && (eventsOpenedRef.current.opened || manualReconnect)
|
|
6447
6539
|
setEventsStatus('connecting')
|
|
6540
|
+
// 每次重新建流都先把上一条拒绝原因清掉(D164):这是「又试了一次」,不是「一直坏着」;
|
|
6541
|
+
// 若这次仍被拒,onError 会立刻把它写回来。
|
|
6542
|
+
setEventsRefused('')
|
|
6448
6543
|
const debounced = makeDebounced(EVENTS_REFRESH_DEBOUNCE_MS, () => {
|
|
6449
6544
|
const load = loadContainersRef.current
|
|
6450
6545
|
if (load !== null) load()
|
|
@@ -6473,8 +6568,15 @@ window.__ModuleLoader__.load({
|
|
|
6473
6568
|
close()
|
|
6474
6569
|
}
|
|
6475
6570
|
const onError = (raw) => {
|
|
6476
|
-
|
|
6571
|
+
/*
|
|
6572
|
+
* 服务端主动说了原因(带 data 的 error 帧):**显式收尾**,并把那句话留下来
|
|
6573
|
+
* (D164)。老代码把它丢掉、只置 `closed`,于是「重连」点了看起来毫无反应——
|
|
6574
|
+
* 拒绝 → 回 `closed` → 界面与点击前一样。统计流一直是读出 message 的。
|
|
6575
|
+
*/
|
|
6576
|
+
const message = eventsErrorMessage(raw)
|
|
6577
|
+
if (message !== '') {
|
|
6477
6578
|
setEventsStatus('closed')
|
|
6579
|
+
setEventsRefused(message)
|
|
6478
6580
|
close()
|
|
6479
6581
|
return
|
|
6480
6582
|
}
|
|
@@ -6486,6 +6588,8 @@ window.__ModuleLoader__.load({
|
|
|
6486
6588
|
es.addEventListener('error', onError)
|
|
6487
6589
|
es.onopen = () => {
|
|
6488
6590
|
setEventsStatus('open')
|
|
6591
|
+
// 连上了就说明上次那条拒绝原因已经过去(D164):留着会让人以为现在还坏着
|
|
6592
|
+
setEventsRefused('')
|
|
6489
6593
|
if (hadOpen) {
|
|
6490
6594
|
// 重连补偿**不走事件驱动的那条防抖**(D93):debounced 是 500ms 尾沿防抖,
|
|
6491
6595
|
// 每条事件都 schedule() 一次;容器多 + healthcheck 时事件持续 >2 条/秒,
|
|
@@ -7327,6 +7431,9 @@ window.__ModuleLoader__.load({
|
|
|
7327
7431
|
info: pickInfo,
|
|
7328
7432
|
presets: pickPresetList,
|
|
7329
7433
|
max: pickMax,
|
|
7434
|
+
// 上限为什么是 3 得写在脸上(D165):它是量出来的浏览器连接预算,
|
|
7435
|
+
// 不写清楚,看起来就只是一个随手定的数字,用户只会觉得「怎么才 3 个」。
|
|
7436
|
+
budget: t('hint.pickBudget'),
|
|
7330
7437
|
notice: pickNotice,
|
|
7331
7438
|
onPreset: applyPreset,
|
|
7332
7439
|
onClear: () => { setPickedIds([]); setPickNotice('') },
|
|
@@ -7373,6 +7480,7 @@ window.__ModuleLoader__.load({
|
|
|
7373
7480
|
events,
|
|
7374
7481
|
status: eventsStatus,
|
|
7375
7482
|
statusText: eventsStatusText(),
|
|
7483
|
+
refused: eventsRefused,
|
|
7376
7484
|
open: activityOpen,
|
|
7377
7485
|
onToggle: () => setActivityOpen((value) => !value),
|
|
7378
7486
|
// 只重开事件流,不顺带刷列表:列表有自己的 ⟳ 与 AUTO REFRESH,
|
|
@@ -7534,6 +7642,118 @@ window.__ModuleLoader__.load({
|
|
|
7534
7642
|
* 设置卡片
|
|
7535
7643
|
* ------------------------------------------------------------------ */
|
|
7536
7644
|
|
|
7645
|
+
/** 能力开关名 → 宿主侧环境变量名(「另一种方式」那一栏要写出具体变量名)。 */
|
|
7646
|
+
const CAPABILITY_ENV = {
|
|
7647
|
+
allowMutations: 'DSH_DOCKER_ALLOW_MUTATIONS',
|
|
7648
|
+
allowExec: 'DSH_DOCKER_ALLOW_EXEC',
|
|
7649
|
+
}
|
|
7650
|
+
|
|
7651
|
+
/** 能力名 → 开关文案(面板标题靠它)。 */
|
|
7652
|
+
function capabilityLabelOf(capability) {
|
|
7653
|
+
return capability === 'allowMutations' ? t('check.allowMutations') : t('check.allowExec')
|
|
7654
|
+
}
|
|
7655
|
+
|
|
7656
|
+
/**
|
|
7657
|
+
* 就地提权面板组——**纯渲染函数**(入参是纯数据 + 一组回调,返回 jsx 树),刻意不做成组件:
|
|
7658
|
+
* 离线冒烟的 React 桩把 `useState` 冻在初值上、`useEffect` 是空实现,「两个能力同时 pending」
|
|
7659
|
+
* 这件事在组件里根本走不到,而它正是本次改造要保证的行为。做成普通函数后,用例可以真的调用它、
|
|
7660
|
+
* 遍历返回的 jsx 树、并**驱动合并按钮的 onClick** 断言被复制下去的字节(见 scripts/client-smoke.mjs)。
|
|
7661
|
+
*
|
|
7662
|
+
* 结构:合并复制行(≥2 个能力 pending 时才有)→ 逐能力堆叠的面板(已授权的留痕不渲染)。
|
|
7663
|
+
*/
|
|
7664
|
+
function elevationPanelsView(records, options) {
|
|
7665
|
+
const list = Array.isArray(records) ? records : []
|
|
7666
|
+
const now = options.now === undefined ? Date.now() : options.now
|
|
7667
|
+
const pending = list.filter((record) => record !== null && record !== undefined && record.granted !== true)
|
|
7668
|
+
if (pending.length === 0) return null
|
|
7669
|
+
const merged = mergeElevationCommands(list, now)
|
|
7670
|
+
const panel = (record) => {
|
|
7671
|
+
const capability = String(record.capability)
|
|
7672
|
+
const seconds = Number.isFinite(record.expiresAt)
|
|
7673
|
+
? Math.max(0, Math.ceil((Number(record.expiresAt) - now) / 1000))
|
|
7674
|
+
: 0
|
|
7675
|
+
return jsxs('div', { className: 'dk_elevPanel', children: [
|
|
7676
|
+
jsxs('div', { className: 'dk_row', children: [
|
|
7677
|
+
jsx('span', { className: 'dk_label', children: t('elev.title') + ' · ' + capabilityLabelOf(capability) }),
|
|
7678
|
+
jsx('button', {
|
|
7679
|
+
type: 'button',
|
|
7680
|
+
className: 'dk_btn',
|
|
7681
|
+
onClick: () => options.onClose(capability),
|
|
7682
|
+
children: t('elev.close'),
|
|
7683
|
+
}, 'close'),
|
|
7684
|
+
] }),
|
|
7685
|
+
jsx('span', { className: 'dk_hint', children: t('elev.lockedWhy') }),
|
|
7686
|
+
record.error === undefined
|
|
7687
|
+
? null
|
|
7688
|
+
: jsx('span', { className: 'dk_hint dk_hintWarn', children: t('elev.error') + String(record.error) }),
|
|
7689
|
+
record.command === undefined
|
|
7690
|
+
? null
|
|
7691
|
+
: jsxs('div', { className: 'dk_elevSteps', children: [
|
|
7692
|
+
jsx('span', { className: 'dk_hint', children: t('elev.step') }),
|
|
7693
|
+
jsx('code', { className: 'dk_elevCommand', children: String(record.command) }),
|
|
7694
|
+
/*
|
|
7695
|
+
* 动作行:复制 / 重新生成 / 倒计时**并排一行、紧贴命令**。
|
|
7696
|
+
*
|
|
7697
|
+
* 早先这三样各自占一条栅格行,而且「重新生成」被「282 秒后失效」「等待确认…」两行
|
|
7698
|
+
* 说明文字隔在下面——2026-09-27 的真机报告(Windows)里那块看上去就是散的:
|
|
7699
|
+
* 按钮离自己的命令越远,越像「不知道点了会发生什么」。命令与它的按钮是一件事(docker D149)。
|
|
7700
|
+
*/
|
|
7701
|
+
jsxs('div', { className: 'dk_elevActions', children: [
|
|
7702
|
+
jsx('button', {
|
|
7703
|
+
type: 'button',
|
|
7704
|
+
className: 'dk_btn dk_btnPrimary',
|
|
7705
|
+
// 复制反馈**逐能力**隔离:这个按钮只认自己能力的键
|
|
7706
|
+
onClick: () => options.onCopy(capability, String(record.command)),
|
|
7707
|
+
children: options.copiedKey === capability ? t('elev.copied') : t('elev.copy'),
|
|
7708
|
+
}, 'copy'),
|
|
7709
|
+
jsx('button', {
|
|
7710
|
+
type: 'button',
|
|
7711
|
+
className: 'dk_btn',
|
|
7712
|
+
onClick: () => options.onRegenerate(capability),
|
|
7713
|
+
children: t('elev.regenerate'),
|
|
7714
|
+
}, 'regen'),
|
|
7715
|
+
jsx('span', {
|
|
7716
|
+
className: seconds > 0 ? 'dk_hint' : 'dk_hint dk_hintWarn',
|
|
7717
|
+
children: seconds > 0 ? t('elev.expiresIn', { sec: seconds }) : t('elev.expired'),
|
|
7718
|
+
}),
|
|
7719
|
+
] }),
|
|
7720
|
+
jsx('span', { className: 'dk_hint', children: t('elev.waiting') }),
|
|
7721
|
+
jsxs('details', { className: 'dk_elevOther', children: [
|
|
7722
|
+
jsx('summary', { children: t('elev.otherWay') }),
|
|
7723
|
+
jsx('span', {
|
|
7724
|
+
className: 'dk_hint',
|
|
7725
|
+
children: t('elev.envHow', { env: CAPABILITY_ENV[capability] ?? '' }),
|
|
7726
|
+
}),
|
|
7727
|
+
] }),
|
|
7728
|
+
] }),
|
|
7729
|
+
options.configured(capability) === true
|
|
7730
|
+
? jsx('button', {
|
|
7731
|
+
type: 'button',
|
|
7732
|
+
className: 'dk_btn',
|
|
7733
|
+
onClick: () => options.onDisableFirst(capability),
|
|
7734
|
+
children: t('elev.disableFirst'),
|
|
7735
|
+
}, 'disable')
|
|
7736
|
+
: null,
|
|
7737
|
+
] })
|
|
7738
|
+
}
|
|
7739
|
+
return jsxs('div', { className: 'dk_elevPanels', children: [
|
|
7740
|
+
shouldOfferMerge(list, now)
|
|
7741
|
+
? jsxs('div', { className: 'dk_elevMerge', children: [
|
|
7742
|
+
jsx('button', {
|
|
7743
|
+
type: 'button',
|
|
7744
|
+
className: 'dk_btn dk_btnPrimary',
|
|
7745
|
+
onClick: () => options.onCopyAll(joinElevationCommands(merged), merged.length),
|
|
7746
|
+
children: options.copiedKey === ELEVATION_COPY_ALL
|
|
7747
|
+
? t('elev.copyAllCopied', { count: merged.length })
|
|
7748
|
+
: t('elev.copyAll', { count: merged.length }),
|
|
7749
|
+
}, 'copyAll'),
|
|
7750
|
+
jsx('span', { className: 'dk_hint', children: t('elev.copyAllHint') }),
|
|
7751
|
+
] })
|
|
7752
|
+
: null,
|
|
7753
|
+
...pending.map((record) => panel(record)),
|
|
7754
|
+
] })
|
|
7755
|
+
}
|
|
7756
|
+
|
|
7537
7757
|
function DockerSettingsCard(props) {
|
|
7538
7758
|
// DSH ≥0.1.6 的插件配置页把同一条目按 view 渲染两次:summary 一句话摘要、page 完整表单。
|
|
7539
7759
|
// 旧版(≤0.1.5)的 settings.plugin.item 卡片不带 view,走原有可折叠卡片分支。
|
|
@@ -7565,10 +7785,26 @@ window.__ModuleLoader__.load({
|
|
|
7565
7785
|
* 就地提权(见 kit 的 elevation.ts):点开关 → 面板给出一条「在宿主上落地一个随机名文件」的
|
|
7566
7786
|
* 命令 → 宿主发现文件即授权,免重启。状态放在卡片里而不是子组件里:离线冒烟的 React 桩不执行
|
|
7567
7787
|
* 函数组件体(见文件里其它地方的说明),状态留在卡片里才测得动。
|
|
7788
|
+
*
|
|
7789
|
+
* **逐能力**记录(数组,按发起顺序)而不是单槽:面板开着时再点另一个开关,单槽会把前一个
|
|
7790
|
+
* 能力的面板**替换**掉——它的命令从界面消失(宿主侧 challenge 仍 pending),用户于是要在宿主
|
|
7791
|
+
* 终端粘贴两次(2026-09-26 截图实测:两次授权差 14 秒)。逐能力记录让两块面板同时在场,
|
|
7792
|
+
* 顶部出一个「复制全部」。纯逻辑在 `client-src/elevation.js`(冒烟能直接驱动)。
|
|
7793
|
+
*
|
|
7794
|
+
* 为什么不做「kit 侧 begin 返回合并命令」(备选方案,已否):`finishElevation` 只处理**被轮询
|
|
7795
|
+
* 到的那个**能力,合并命令会让另一个能力停在「宿主已授权、配置开关没开」的半状态;还要处理
|
|
7796
|
+
* 两条独立探测定时器之间的授权竞态。客户端合并零 API 变更、零新安全面——kit 的
|
|
7797
|
+
* begin / status / revoke 契约一字不动(nonce 仍只出现在 begin 响应里)。
|
|
7568
7798
|
*/
|
|
7569
|
-
const [
|
|
7799
|
+
const [elevations, setElevations] = useState([])
|
|
7570
7800
|
const [elevationNotice, setElevationNotice] = useState('')
|
|
7571
|
-
|
|
7801
|
+
/**
|
|
7802
|
+
* 复制反馈的归属键(**不是**布尔):单条按钮传自己的能力名、合并按钮传哨兵
|
|
7803
|
+
* `ELEVATION_COPY_ALL`,渲染时各自判自己的键——于是「复制了 A」不会让 B 的按钮也显示
|
|
7804
|
+
* 「已复制」,而「复制全部」也不会让每一块面板都亮起来(改前是一个全局 `copied` 布尔,
|
|
7805
|
+
* 两个按钮会一起变成已复制)。同一时刻只留一处反馈是刻意的:它说的是「你刚复制的是哪一份」。
|
|
7806
|
+
*/
|
|
7807
|
+
const [copiedKey, setCopiedKey] = useState('')
|
|
7572
7808
|
/** 每秒推进一次的计数:只为了让「还剩 N 秒」自己走(读的是渲染期的时间差)。 */
|
|
7573
7809
|
const [nowTick, setNowTick] = useState(0)
|
|
7574
7810
|
/** 授权到达后要「先把开关写进表单、再自动保存」:保存读的是表单镜像,得等一次提交。 */
|
|
@@ -7691,12 +7927,6 @@ window.__ModuleLoader__.load({
|
|
|
7691
7927
|
|
|
7692
7928
|
/* ---------------- 就地提权(capability elevation) ---------------- */
|
|
7693
7929
|
|
|
7694
|
-
/** 能力开关名 → 宿主侧环境变量名(「另一种方式」那一栏要写出具体变量名)。 */
|
|
7695
|
-
const CAPABILITY_ENV = {
|
|
7696
|
-
allowMutations: 'DSH_DOCKER_ALLOW_MUTATIONS',
|
|
7697
|
-
allowExec: 'DSH_DOCKER_ALLOW_EXEC',
|
|
7698
|
-
}
|
|
7699
|
-
|
|
7700
7930
|
/**
|
|
7701
7931
|
* 快照 → 表单:能力开关在表单里存**配置值**(用户写下的那个),不是快照里的有效值。
|
|
7702
7932
|
*
|
|
@@ -7735,8 +7965,18 @@ window.__ModuleLoader__.load({
|
|
|
7735
7965
|
* 一起写进去。脏的时候只把开关置上并提示「点保存生效」——不替用户做决定。
|
|
7736
7966
|
*/
|
|
7737
7967
|
const finishElevation = (capability) => {
|
|
7738
|
-
|
|
7739
|
-
|
|
7968
|
+
/*
|
|
7969
|
+
* **不删记录**,只把它标成已授权并把命令清掉(面板据此消失)。为什么留着这条痕迹:
|
|
7970
|
+
* 合并按钮的存续跟着「面板组里有过几个能力」,不是跟着当前还剩几条命令——两个能力
|
|
7971
|
+
* 都点过之后,其中一个先解锁,按钮要留着把**剩下那条**交给用户(此时再让他回单条面板
|
|
7972
|
+
* 里找命令,等于把刚刚那次「一次粘贴」的动作拆回去)。两个都解锁后组里没有可复制命令,
|
|
7973
|
+
* 按钮自然消失(见 shouldOfferMerge)。
|
|
7974
|
+
*
|
|
7975
|
+
* 反向也成立:「单个 pending → 无合并按钮」——只动过一个开关时组里只有一条记录,
|
|
7976
|
+
* 那条命令就在它自己的面板里,不需要一个「复制全部(1 条)」的按钮。
|
|
7977
|
+
*/
|
|
7978
|
+
setElevations((current) => markElevationGranted(current, capability))
|
|
7979
|
+
setCopiedKey('')
|
|
7740
7980
|
const clean = JSON.stringify(toPayload(formRef.current)) === cleanPayloadRef.current
|
|
7741
7981
|
setForm((current) => (current === null ? current : { ...current, [capability]: true }))
|
|
7742
7982
|
setElevationNotice(clean ? t('elev.granted') : t('elev.grantedNeedSave'))
|
|
@@ -7746,19 +7986,37 @@ window.__ModuleLoader__.load({
|
|
|
7746
7986
|
|
|
7747
7987
|
const beginElevation = (capability) => {
|
|
7748
7988
|
setElevationNotice('')
|
|
7749
|
-
|
|
7750
|
-
|
|
7989
|
+
setCopiedKey('')
|
|
7990
|
+
// 只增改**这一个**能力的记录,别的 pending 原样留着(面板不再被替换)
|
|
7991
|
+
setElevations((current) => upsertElevationRecord(current, capability, {
|
|
7992
|
+
capability,
|
|
7993
|
+
command: undefined,
|
|
7994
|
+
expiresAt: undefined,
|
|
7995
|
+
error: undefined,
|
|
7996
|
+
// 清掉上一轮的「已授权」痕迹:撤销授权后再点这个开关要能重新出面板
|
|
7997
|
+
granted: false,
|
|
7998
|
+
}))
|
|
7751
7999
|
api.elevateBegin(capability).then((result) => {
|
|
7752
8000
|
if (result !== null && result.status === 'granted') {
|
|
7753
8001
|
finishElevation(capability)
|
|
7754
8002
|
return
|
|
7755
8003
|
}
|
|
7756
8004
|
if (result !== null && result.status === 'pending') {
|
|
7757
|
-
|
|
8005
|
+
setElevations((current) => upsertElevationRecord(current, capability, {
|
|
8006
|
+
capability,
|
|
8007
|
+
command: String(result.command),
|
|
8008
|
+
expiresAt: Number(result.expiresAt),
|
|
8009
|
+
}))
|
|
7758
8010
|
return
|
|
7759
8011
|
}
|
|
7760
|
-
|
|
7761
|
-
|
|
8012
|
+
setElevations((current) => upsertElevationRecord(current, capability, {
|
|
8013
|
+
capability,
|
|
8014
|
+
error: String((result && result.error) ?? ''),
|
|
8015
|
+
}))
|
|
8016
|
+
}).catch((error) => setElevations((current) => upsertElevationRecord(current, capability, {
|
|
8017
|
+
capability,
|
|
8018
|
+
error: String(error.message),
|
|
8019
|
+
})))
|
|
7762
8020
|
}
|
|
7763
8021
|
|
|
7764
8022
|
const revokeElevation = (capability) => {
|
|
@@ -7769,14 +8027,36 @@ window.__ModuleLoader__.load({
|
|
|
7769
8027
|
}).catch((error) => setElevationNotice(t('elev.error') + String(error.message)))
|
|
7770
8028
|
}
|
|
7771
8029
|
|
|
7772
|
-
/**
|
|
7773
|
-
|
|
8030
|
+
/**
|
|
8031
|
+
* 复制命令:拿不到剪贴板(老宿主 / 非安全上下文)时退回「请手动复制」。
|
|
8032
|
+
*
|
|
8033
|
+
* `key` 是反馈的归属:单条按钮传能力名,合并按钮传 `ELEVATION_COPY_ALL`——
|
|
8034
|
+
* 于是「复制了 A」不会让 B 的按钮也显示「已复制」(早先是一个全局 `copied`)。
|
|
8035
|
+
*/
|
|
8036
|
+
const copyCommand = (key, command) => {
|
|
7774
8037
|
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
7775
8038
|
if (clipboard === undefined || typeof clipboard.writeText !== 'function') {
|
|
7776
8039
|
setElevationNotice(t('elev.copyManual'))
|
|
7777
8040
|
return
|
|
7778
8041
|
}
|
|
7779
|
-
clipboard.writeText(command).then(() =>
|
|
8042
|
+
clipboard.writeText(command).then(() => setCopiedKey(key)).catch(() => setElevationNotice(t('elev.copyManual')))
|
|
8043
|
+
}
|
|
8044
|
+
|
|
8045
|
+
/**
|
|
8046
|
+
* 合并复制:把所有**仍 pending**(未过期、已拿到命令)的命令按发起顺序用换行连接后一次复制。
|
|
8047
|
+
*
|
|
8048
|
+
* 命令的取用 / 排序 / 连接全在纯函数里(`client-src/elevation.js` 的 `buildMergedElevationText`
|
|
8049
|
+
* 与 `joinElevationCommands`),这里只负责把它交给剪贴板。连接方式为什么是换行而不是 `&&` / `;`:
|
|
8050
|
+
* bash / zsh / PowerShell / cmd 粘贴多行都逐行执行,平台无关;`&&` 在 PS 5.1 上不认,`;` 在 cmd
|
|
8051
|
+
* 上不认。文案只说「执行后全部开关自动解锁」,不承诺同一时刻:两个 challenge 的 TTL 与探测
|
|
8052
|
+
* 周期各自独立(逐面板倒计时照旧显示)。
|
|
8053
|
+
*/
|
|
8054
|
+
const copyAllElevations = (text) => {
|
|
8055
|
+
if (text === '') {
|
|
8056
|
+
setElevationNotice(t('elev.copyManual'))
|
|
8057
|
+
return
|
|
8058
|
+
}
|
|
8059
|
+
copyCommand(ELEVATION_COPY_ALL, text)
|
|
7780
8060
|
}
|
|
7781
8061
|
|
|
7782
8062
|
/*
|
|
@@ -7793,20 +8073,33 @@ window.__ModuleLoader__.load({
|
|
|
7793
8073
|
/*
|
|
7794
8074
|
* 面板开着时轮询授权状态(1.5s)并顺带推进倒计时。轮询而不是等推送:这条通道本来就是
|
|
7795
8075
|
* 「用户在别的终端里做了一件事」,宿主没有可订阅的事件面。
|
|
8076
|
+
*
|
|
8077
|
+
* **轮询全部 pending 能力**(不再只盯单槽那一个):两个 challenge 是各自独立开始的,
|
|
8078
|
+
* 各自的授权时刻可以差十几秒;只轮询一个会让另一个停在「宿主已授权、开关没开」。
|
|
8079
|
+
* deps 用**能力名的拼接串**而不是数组:数组每次渲染都是新引用,effect 会被反复拆装,
|
|
8080
|
+
* 1.5s 的节拍随之漂成「每渲染一次就重建定时器」。内容不变时它一字不变,effect 就不动。
|
|
7796
8081
|
*/
|
|
7797
|
-
const
|
|
8082
|
+
const elevationPendingKey = elevations
|
|
8083
|
+
// 已授权的那条只是「留痕」(面板已消失),不再轮询——否则每 1.5s 又会走一遍 finishElevation
|
|
8084
|
+
.filter((item) => item.granted !== true)
|
|
8085
|
+
.map((item) => String(item.capability))
|
|
8086
|
+
.sort()
|
|
8087
|
+
.join(',')
|
|
7798
8088
|
useEffect(() => {
|
|
7799
|
-
if (
|
|
8089
|
+
if (elevationPendingKey === '') return undefined
|
|
8090
|
+
const capabilities = elevationPendingKey.split(',')
|
|
7800
8091
|
const timer = setInterval(() => {
|
|
7801
8092
|
setNowTick((value) => value + 1)
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
8093
|
+
for (const capability of capabilities) {
|
|
8094
|
+
api.elevateStatus(capability).then((status) => {
|
|
8095
|
+
if (status !== null && status.status === 'granted') finishElevation(capability)
|
|
8096
|
+
}).catch(() => {
|
|
8097
|
+
/* 轮询失败静默:宿主可能在重启,下一次再试 */
|
|
8098
|
+
})
|
|
8099
|
+
}
|
|
7807
8100
|
}, 1500)
|
|
7808
8101
|
return () => clearInterval(timer)
|
|
7809
|
-
}, [
|
|
8102
|
+
}, [elevationPendingKey])
|
|
7810
8103
|
|
|
7811
8104
|
/* ---------------- 能力开关那一段的渲染 ---------------- */
|
|
7812
8105
|
|
|
@@ -7912,79 +8205,35 @@ window.__ModuleLoader__.load({
|
|
|
7912
8205
|
] })
|
|
7913
8206
|
}
|
|
7914
8207
|
|
|
7915
|
-
/**
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
jsx('span', { className: 'dk_label', children: t('elev.title') + ' · ' + label }),
|
|
7929
|
-
jsx('button', { type: 'button', className: 'dk_btn', onClick: close, children: t('elev.close') }, 'close'),
|
|
7930
|
-
] }),
|
|
7931
|
-
jsx('span', { className: 'dk_hint', children: t('elev.lockedWhy') }),
|
|
7932
|
-
elevation.error === undefined
|
|
7933
|
-
? null
|
|
7934
|
-
: jsx('span', { className: 'dk_hint dk_hintWarn', children: t('elev.error') + String(elevation.error) }),
|
|
7935
|
-
elevation.command === undefined
|
|
7936
|
-
? null
|
|
7937
|
-
: jsxs('div', { className: 'dk_elevSteps', children: [
|
|
7938
|
-
jsx('span', { className: 'dk_hint', children: t('elev.step') }),
|
|
7939
|
-
jsx('code', { className: 'dk_elevCommand', children: String(elevation.command) }),
|
|
7940
|
-
/*
|
|
7941
|
-
* 动作行:复制 / 重新生成 / 倒计时**并排一行、紧贴命令**。
|
|
7942
|
-
*
|
|
7943
|
-
* 早先这三样各自占一条栅格行,而且「重新生成」被「282 秒后失效」「等待确认…」两行
|
|
7944
|
-
* 说明文字隔在下面——2026-09-27 的真机报告(Windows)里那块看上去就是散的:
|
|
7945
|
-
* 按钮离自己的命令越远,越像「不知道点了会发生什么」。命令与它的按钮是一件事(docker D149)。
|
|
7946
|
-
*/
|
|
7947
|
-
jsxs('div', { className: 'dk_elevActions', children: [
|
|
7948
|
-
jsx('button', {
|
|
7949
|
-
type: 'button',
|
|
7950
|
-
className: 'dk_btn dk_btnPrimary',
|
|
7951
|
-
onClick: () => copyCommand(String(elevation.command)),
|
|
7952
|
-
children: copied ? t('elev.copied') : t('elev.copy'),
|
|
7953
|
-
}, 'copy'),
|
|
7954
|
-
jsx('button', {
|
|
7955
|
-
type: 'button',
|
|
7956
|
-
className: 'dk_btn',
|
|
7957
|
-
onClick: () => beginElevation(capability),
|
|
7958
|
-
children: t('elev.regenerate'),
|
|
7959
|
-
}, 'regen'),
|
|
7960
|
-
jsx('span', {
|
|
7961
|
-
className: seconds > 0 ? 'dk_hint' : 'dk_hint dk_hintWarn',
|
|
7962
|
-
children: seconds > 0 ? t('elev.expiresIn', { sec: seconds }) : t('elev.expired'),
|
|
7963
|
-
}),
|
|
7964
|
-
] }),
|
|
7965
|
-
jsx('span', { className: 'dk_hint', children: t('elev.waiting') }),
|
|
7966
|
-
jsxs('details', { className: 'dk_elevOther', children: [
|
|
7967
|
-
jsx('summary', { children: t('elev.otherWay') }),
|
|
7968
|
-
jsx('span', {
|
|
7969
|
-
className: 'dk_hint',
|
|
7970
|
-
children: t('elev.envHow', { env: CAPABILITY_ENV[capability] ?? '' }),
|
|
7971
|
-
}),
|
|
7972
|
-
] }),
|
|
7973
|
-
] }),
|
|
7974
|
-
form[capability] === true
|
|
7975
|
-
? jsx('button', {
|
|
7976
|
-
type: 'button',
|
|
7977
|
-
className: 'dk_btn',
|
|
7978
|
-
onClick: () => {
|
|
7979
|
-
patch({ [capability]: false })
|
|
7980
|
-
close()
|
|
7981
|
-
},
|
|
7982
|
-
children: t('elev.disableFirst'),
|
|
7983
|
-
}, 'disable')
|
|
7984
|
-
: null,
|
|
7985
|
-
] })
|
|
8208
|
+
/**
|
|
8209
|
+
* 一块能力的面板(✕)与整个面板组都渲染自 `elevationPanelsView`(factory 级纯函数,
|
|
8210
|
+
* 见那段注释):组件这边只提供**状态与回调**,界面结构留在纯函数里,冒烟才驱动得动。
|
|
8211
|
+
*
|
|
8212
|
+
* 逐个能力的动作语义:
|
|
8213
|
+
* - ✕ 只收起**这一个**能力的面板;别的 pending 原样留着(也不撤销宿主侧 challenge,
|
|
8214
|
+
* 与改造前 close 的语义一致——challenge 仍在 pending,重新点开关即复用同一条命令)。
|
|
8215
|
+
* - 「重新生成」= 再走一次 `beginElevation`(逐能力幂等,不动别的能力)。
|
|
8216
|
+
* - 「先关掉这个配置开关」= 关配置 + 收起自己那块面板。
|
|
8217
|
+
*/
|
|
8218
|
+
const closeElevationPanel = (capability) => {
|
|
8219
|
+
setElevations((current) => pruneElevationRecords(removeElevationRecord(current, capability)))
|
|
8220
|
+
setCopiedKey('')
|
|
7986
8221
|
}
|
|
7987
8222
|
|
|
8223
|
+
const elevationPanels = () => elevationPanelsView(elevations, {
|
|
8224
|
+
now: Date.now(),
|
|
8225
|
+
copiedKey,
|
|
8226
|
+
configured: (capability) => form[capability] === true,
|
|
8227
|
+
onCopy: (capability, command) => copyCommand(capability, command),
|
|
8228
|
+
onCopyAll: (text) => copyAllElevations(text),
|
|
8229
|
+
onRegenerate: (capability) => beginElevation(capability),
|
|
8230
|
+
onClose: (capability) => closeElevationPanel(capability),
|
|
8231
|
+
onDisableFirst: (capability) => {
|
|
8232
|
+
patch({ [capability]: false })
|
|
8233
|
+
closeElevationPanel(capability)
|
|
8234
|
+
},
|
|
8235
|
+
})
|
|
8236
|
+
|
|
7988
8237
|
const sectionTitle = (text) => jsx('div', { className: 'dk_cardSection', children: text })
|
|
7989
8238
|
|
|
7990
8239
|
const field = (label, control, hint, span) => jsxs('div', {
|
|
@@ -8096,7 +8345,7 @@ window.__ModuleLoader__.load({
|
|
|
8096
8345
|
elevationNotice === ''
|
|
8097
8346
|
? null
|
|
8098
8347
|
: jsx('span', { className: 'dk_hint', children: elevationNotice }),
|
|
8099
|
-
|
|
8348
|
+
elevations.length === 0 ? null : elevationPanels(),
|
|
8100
8349
|
|
|
8101
8350
|
sectionTitle(t('field.target')),
|
|
8102
8351
|
...form.targets.map((item, index) => {
|
|
@@ -8528,8 +8777,11 @@ window.__ModuleLoader__.load({
|
|
|
8528
8777
|
}
|
|
8529
8778
|
exports.__pick = {
|
|
8530
8779
|
MAX: PICK_MAX,
|
|
8531
|
-
/** SSH
|
|
8780
|
+
/** SSH 目标的上限(D165 后与本地同值:卡页面的是浏览器同源预算)。 */
|
|
8532
8781
|
SSH_MAX: PICK_MAX_SSH,
|
|
8782
|
+
/** 同源常驻流预算与其中留给事件流的名额(上限的推导依据,测试直接钉住)。 */
|
|
8783
|
+
BUDGET: SAME_ORIGIN_STREAM_BUDGET,
|
|
8784
|
+
RESERVED: RESERVED_STREAMS,
|
|
8533
8785
|
PRESETS: PICK_PRESETS,
|
|
8534
8786
|
presetCounts: pickPresetCounts,
|
|
8535
8787
|
apply: pickApply,
|
|
@@ -8552,6 +8804,7 @@ window.__ModuleLoader__.load({
|
|
|
8552
8804
|
timeText: eventTimeText,
|
|
8553
8805
|
debounce: makeDebounced,
|
|
8554
8806
|
canReconnect: canReconnectEvents,
|
|
8807
|
+
errorMessage: eventsErrorMessage,
|
|
8555
8808
|
}
|
|
8556
8809
|
/*
|
|
8557
8810
|
* 同一类测试缝:总览的折叠逻辑与**正文渲染**都是纯函数(正文刻意写成普通函数而不是
|
|
@@ -8619,6 +8872,29 @@ window.__ModuleLoader__.load({
|
|
|
8619
8872
|
PENDING_MAX: LOG_PENDING_MAX,
|
|
8620
8873
|
FLUSH_MS: FOLLOW_FLUSH_MS,
|
|
8621
8874
|
}
|
|
8875
|
+
/*
|
|
8876
|
+
* 就地提权「合并复制」的测试缝:逐能力记录的增改 / 收尾、取哪几条命令、顺序、怎么连接,
|
|
8877
|
+
* 全是纯逻辑(`client-src/elevation.js`)。**必须直接驱动**而不是渲染卡片:离线冒烟的
|
|
8878
|
+
* React 桩把 `useState` 冻在初值上、且 `useEffect` 是空实现,两块面板同时在场这件事
|
|
8879
|
+
* 在组件里根本走不到(这正是 2026-09-26 那次「必须粘贴两次」漏掉测试入口的原因)。
|
|
8880
|
+
*/
|
|
8881
|
+
exports.__elevation = {
|
|
8882
|
+
upsert: upsertElevationRecord,
|
|
8883
|
+
remove: removeElevationRecord,
|
|
8884
|
+
markGranted: markElevationGranted,
|
|
8885
|
+
prune: pruneElevationRecords,
|
|
8886
|
+
merge: mergeElevationCommands,
|
|
8887
|
+
join: joinElevationCommands,
|
|
8888
|
+
text: buildMergedElevationText,
|
|
8889
|
+
shouldOfferMerge,
|
|
8890
|
+
COPY_ALL_KEY: ELEVATION_COPY_ALL,
|
|
8891
|
+
/**
|
|
8892
|
+
* 面板组的**渲染**(与 `__overview.body` 同一形态):用例据它断言「几个 pending 才出
|
|
8893
|
+
* 合并按钮」「按钮的 onClick 到底把什么字符串交下去」——只钉纯函数的话,「界面上没有
|
|
8894
|
+
* 那个按钮」与「按钮接错了内容」这两类错都漏得掉。
|
|
8895
|
+
*/
|
|
8896
|
+
view: elevationPanelsView,
|
|
8897
|
+
}
|
|
8622
8898
|
/*
|
|
8623
8899
|
* 窗口化挂载(D152)的测试缝:布局是纯逻辑,离线冒烟直接驱动(真 DOM 里的量高与
|
|
8624
8900
|
* 滚动位置由 test/log-window.test.ts 与性能门禁覆盖)。常量挂出来供用例对齐。
|