@hyzyn/dsh-docker 0.9.4 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +120 -27
- package/README.md +106 -26
- package/client-src/docker.css +326 -160
- package/client-src/index.js +288 -21
- package/client.js +349 -183
- package/lib/docker.d.ts +32 -3
- package/lib/docker.js +43 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +49 -0
- package/lib/index.js +390 -10
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +9 -0
- package/lib/ssh-exec.js +149 -16
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +3 -3
- package/scripts/client-smoke.mjs +205 -0
- package/scripts/log-perf.mjs +9 -2
- package/scripts/route-smoke.mjs +73 -4
package/client-src/index.js
CHANGED
|
@@ -351,7 +351,10 @@ const I18N_ZH = {
|
|
|
351
351
|
'status.eventsClosed': '事件流已断开',
|
|
352
352
|
'status.eventsStream': '事件流',
|
|
353
353
|
'status.eventsEnded': '事件流已结束',
|
|
354
|
-
'status.backToListRefresh': ',列表回到 AUTO REFRESH /
|
|
354
|
+
'status.backToListRefresh': ',列表回到 AUTO REFRESH / 手动刷新;活动条上点「重连」可重新订阅',
|
|
355
|
+
'btn.reconnectEvents': '重连',
|
|
356
|
+
'hint.reconnectEvents': '重新打开容器事件流(docker events);列表刷新按钮也会一并重连',
|
|
357
|
+
'hint.eventsClosedNoAuto': '事件流断开后不会自己回来(EventSource 只在连接层抖动时自愈;docker events 进程退出属于终止态)——点它重连,或点右上角刷新列表',
|
|
355
358
|
'hint.conversationHidden': ' · 会话在面板后面:关掉或最小化面板/终端即可看到',
|
|
356
359
|
'msg.copied': '已复制:',
|
|
357
360
|
'error.copyManual': '复制失败,请手动执行:',
|
|
@@ -453,6 +456,8 @@ const I18N_ZH = {
|
|
|
453
456
|
'msg.saved': '已保存并热生效',
|
|
454
457
|
'msg.savedDirty': '已保存并热生效(表单在保存期间有新编辑,未覆盖你正在输入的内容)',
|
|
455
458
|
'error.saveFailed': '保存失败:',
|
|
459
|
+
'msg.connectLocalBusy': '正在连接本机…',
|
|
460
|
+
'error.connectLocalFailed': '连接本机失败:',
|
|
456
461
|
'card.desc': '本机与 SSH 主机的容器与镜像:容器 / 镜像 / 网络 / 卷查看,默认只读,变更操作需显式开启。',
|
|
457
462
|
'card.name': 'Docker 容器面板',
|
|
458
463
|
'card.summary': '本机 / SSH 主机上的容器与镜像;默认只读,变更操作需显式开启',
|
|
@@ -508,6 +513,8 @@ const I18N_ZH = {
|
|
|
508
513
|
'hint.keyPath': '支持 ~ 与 ~/ 展开(不支持 ~user);Windows 请写绝对路径',
|
|
509
514
|
'placeholder.passwordSet': '(已设置,留空保持不变)',
|
|
510
515
|
'btn.addTarget': '添加目标',
|
|
516
|
+
'btn.connectLocal': '连接本机',
|
|
517
|
+
'hint.connectLocal': '加一个指向宿主所在机器的本机目标并选中(已有本机目标则直接复用);SSH 与自定义目标原样保留。',
|
|
511
518
|
'hint.addTarget': 'SSH 目标推荐直接选 tty 终端面板的连接簿条目(凭证只需维护一处);手填时密码 / 口令建议写 env:NAME(凭据引用:由官方凭据存储解析,缺失时退回环境变量)。',
|
|
512
519
|
'section.tofu': 'SSH 主机密钥记录(TOFU)',
|
|
513
520
|
'list.noHostKeys': '暂无记录 — 首次 SSH 连接成功后自动记录主机指纹(若 tty 已记录同一主机,会直接复用)。',
|
|
@@ -828,7 +835,10 @@ const I18N_EN = {
|
|
|
828
835
|
'status.eventsClosed': 'Event stream closed',
|
|
829
836
|
'status.eventsStream': 'Event stream',
|
|
830
837
|
'status.eventsEnded': 'Event stream ended',
|
|
831
|
-
'status.backToListRefresh': ', the list returns to AUTO REFRESH / manual refresh',
|
|
838
|
+
'status.backToListRefresh': ', the list returns to AUTO REFRESH / manual refresh; hit “Reconnect” on the activity bar to resubscribe',
|
|
839
|
+
'btn.reconnectEvents': 'Reconnect',
|
|
840
|
+
'hint.reconnectEvents': 'Reopen the container event stream (docker events); the list refresh button reconnects it too',
|
|
841
|
+
'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
842
|
'hint.conversationHidden': ' · the session is behind this panel: close or minimise the panel/terminal to see it',
|
|
833
843
|
'msg.copied': 'Copied: ',
|
|
834
844
|
'error.copyManual': 'Copy failed, run it manually: ',
|
|
@@ -930,6 +940,8 @@ const I18N_EN = {
|
|
|
930
940
|
'msg.saved': 'Saved and applied live',
|
|
931
941
|
'msg.savedDirty': 'Saved and applied live (the form got new edits while saving; what you were typing was not overwritten)',
|
|
932
942
|
'error.saveFailed': 'Save failed: ',
|
|
943
|
+
'msg.connectLocalBusy': 'Connecting to the local Docker…',
|
|
944
|
+
'error.connectLocalFailed': 'Connecting local Docker failed: ',
|
|
933
945
|
'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
946
|
'card.name': 'Docker containers',
|
|
935
947
|
'card.summary': 'Containers and images on this machine / SSH hosts; read-only by default, changes must be enabled explicitly',
|
|
@@ -985,6 +997,8 @@ const I18N_EN = {
|
|
|
985
997
|
'hint.keyPath': 'Supports ~ and ~/ expansion (not ~user); use absolute paths on Windows',
|
|
986
998
|
'placeholder.passwordSet': '(already set, leave empty to keep)',
|
|
987
999
|
'btn.addTarget': 'Add target',
|
|
1000
|
+
'btn.connectLocal': 'Connect local',
|
|
1001
|
+
'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
1002
|
'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
1003
|
'section.tofu': 'SSH host key records (TOFU)',
|
|
990
1004
|
'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 +1103,12 @@ async function request(path, init) {
|
|
|
1089
1103
|
const api = {
|
|
1090
1104
|
config: () => request('/config'),
|
|
1091
1105
|
saveConfig: (patch) => request('/config', { method: 'POST', body: JSON.stringify(patch) }),
|
|
1106
|
+
/**
|
|
1107
|
+
* 一键连接本机(宿主同源证明要求 POST):直接加一个 kind=local 目标并选中它,
|
|
1108
|
+
* 返回 { result: { name, created, saved, reachable, serverVersion?, message }, config }。
|
|
1109
|
+
* 与「设置卡片里手填一个本机目标」是**同一份宿主实现**(/connect-local ↔ connectLocal)。
|
|
1110
|
+
*/
|
|
1111
|
+
connectLocal: () => request('/connect-local', { method: 'POST', body: '{}' }),
|
|
1092
1112
|
targets: () => request('/targets'),
|
|
1093
1113
|
probe: (target) => request('/probe', { method: 'POST', body: JSON.stringify({ target }) }),
|
|
1094
1114
|
containers: (target, all) => request('/containers', { method: 'POST', body: JSON.stringify({ target, all }) }),
|
|
@@ -1532,12 +1552,38 @@ const PICK_MAX = 8
|
|
|
1532
1552
|
*/
|
|
1533
1553
|
const PICK_MAX_SSH = 6
|
|
1534
1554
|
|
|
1555
|
+
/**
|
|
1556
|
+
* 目标下拉里要不要列「+ 连接本机」那一项。
|
|
1557
|
+
*
|
|
1558
|
+
* 入参是**渲染作用域里的目标列表**(与同一个下拉展开的 `<option>` 用同一份数据)——
|
|
1559
|
+
* 刻意不读模块级 configCache:同一个下拉里「列了哪些目标」与「要不要多列这一项」必须同源,
|
|
1560
|
+
* 否则会出现「列表里已经有本机目标、却又多列一项连接本机」这种自相矛盾。
|
|
1561
|
+
*
|
|
1562
|
+
* 两个条件缺一不可:
|
|
1563
|
+
* - `targets.length > 0`:一个目标都没有时,正文空态里已经有一个大按钮(那里才是它最该
|
|
1564
|
+
* 出现的地方),下拉里再列一项就是同一个入口出现两次;
|
|
1565
|
+
* - 没有 `kind=local`:已经有了就不列(选择器里已经有那台本机了)。
|
|
1566
|
+
*/
|
|
1567
|
+
function shouldOfferConnectLocal(targets) {
|
|
1568
|
+
return targets.length > 0 && !targets.some((item) => item.kind === 'local')
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1535
1571
|
/** 目标是不是 SSH —— 决定聚合流的上限。读模块级 configCache,任何组件都能问。 */
|
|
1536
1572
|
function isSshTarget(name) {
|
|
1537
1573
|
const targets = configCache !== null && Array.isArray(configCache.targets) ? configCache.targets : []
|
|
1538
1574
|
return targets.some((item) => item.name === name && item.kind === 'ssh')
|
|
1539
1575
|
}
|
|
1540
1576
|
|
|
1577
|
+
/**
|
|
1578
|
+
* 目标选择器里那一项「+ 连接本机」的哨兵值。
|
|
1579
|
+
*
|
|
1580
|
+
* 为什么不用个普通字符串(如 `local`):目标名是**用户自由填的**(sanitizeTargets 只限长度与
|
|
1581
|
+
* 非空),任何可打印值都可能与真实目标重名——重名就意味着用户点「连接本机」反而切到了别的机器。
|
|
1582
|
+
* 用 NUL 开头则不可能撞上:目标名来自输入框,NUL 进不去;而 `<option value>` 允许任意字符串,
|
|
1583
|
+
* 比较是纯 JS 字符串比较,不经过任何会截断的路径。
|
|
1584
|
+
*/
|
|
1585
|
+
const CONNECT_LOCAL_OPTION = '\u0000connect-local'
|
|
1586
|
+
|
|
1541
1587
|
/** 由勾选数量推导「聚合日志」按钮是否可用 + 操作条提示文案。 */
|
|
1542
1588
|
function pickDecide(count, ssh = false) {
|
|
1543
1589
|
const max = ssh === true ? PICK_MAX_SSH : PICK_MAX
|
|
@@ -1895,6 +1941,19 @@ const EVENT_RECENT = 8
|
|
|
1895
1941
|
*/
|
|
1896
1942
|
const EVENTS_REFRESH_DEBOUNCE_MS = 500
|
|
1897
1943
|
|
|
1944
|
+
/**
|
|
1945
|
+
* 事件流处在「需要人工重连」的终止态吗(D160)。
|
|
1946
|
+
*
|
|
1947
|
+
* 抽成纯函数而不是写在组件里:离线冒烟的 React 桩把 `useState` 冻在初值上,组件里
|
|
1948
|
+
* 那个 `eventsStatus` 永远是 `''`,遍历渲染树根本走不到重连按钮——判定必须能直接驱动。
|
|
1949
|
+
*
|
|
1950
|
+
* **只认 `closed`**:`open` / `connecting` / `reconnecting` 都还在自动恢复的路上;
|
|
1951
|
+
* `unsupported`(环境没有 EventSource)重连必然再失败,给按钮等于骗人。
|
|
1952
|
+
*/
|
|
1953
|
+
function canReconnectEvents(status) {
|
|
1954
|
+
return status === 'closed'
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1898
1957
|
/** 新事件放最前(活动条按时间倒序读),超出上限丢最旧。 */
|
|
1899
1958
|
function pushEvent(list, event, limit) {
|
|
1900
1959
|
const next = [event, ...list]
|
|
@@ -5401,21 +5460,45 @@ window.__ModuleLoader__.load({
|
|
|
5401
5460
|
const open = props.open === true
|
|
5402
5461
|
const events = Array.isArray(props.events) ? props.events : []
|
|
5403
5462
|
const recent = events.slice(0, EVENT_RECENT)
|
|
5463
|
+
/*
|
|
5464
|
+
* 断开时给一个显式的「重连」(D160):这条流进了 closed 就是**终止态**,没有任何
|
|
5465
|
+
* 东西会再开它;而活动条上那句「事件流已断开」看着却像会自愈(EventSource 平时
|
|
5466
|
+
* 确实会自动重连),用户点工具条的 ⟳ 也不见好。
|
|
5467
|
+
* `unsupported` 刻意不给这个按钮:那个环境根本没有 EventSource,重连必然还是失败。
|
|
5468
|
+
*/
|
|
5469
|
+
const canReconnect = canReconnectEvents(props.status)
|
|
5404
5470
|
return jsxs('div', { className: 'dk_activity', 'data-open': open ? '1' : '0', children: [
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5471
|
+
/*
|
|
5472
|
+
* 折叠按钮与「重连」是**兄弟**,不是嵌套——按钮里嵌按钮既不是合法 HTML,
|
|
5473
|
+
* 也会让读屏把「折叠」与「重连」两个动作听成一个。
|
|
5474
|
+
*/
|
|
5475
|
+
jsxs('div', { className: 'dk_activityHeadRow', children: [
|
|
5476
|
+
jsx('button', {
|
|
5477
|
+
type: 'button',
|
|
5478
|
+
className: 'dk_activityHead',
|
|
5479
|
+
'aria-expanded': open,
|
|
5480
|
+
title: t('hint.eventsToggle'),
|
|
5481
|
+
onClick: props.onToggle,
|
|
5482
|
+
children: [
|
|
5483
|
+
jsx('span', { className: 'dk_activityTitle', children: t('panel.activity') }),
|
|
5484
|
+
jsx('span', { className: 'dk_activityState', 'data-state': props.status ?? '', children: props.statusText ?? '' }),
|
|
5485
|
+
jsx('span', { className: 'dk_headerSpacer' }),
|
|
5486
|
+
// 断开时右侧那句「暂无事件」是误导(不是「没动静」,是「听不见了」),
|
|
5487
|
+
// 换成一句说明为什么需要手动重连
|
|
5488
|
+
jsx('span', { className: 'dk_hint', children: canReconnect
|
|
5489
|
+
? t('hint.eventsClosedNoAuto')
|
|
5490
|
+
: (events.length === 0 ? t('list.noEvents') : t('list.recentEvents', { recent: recent.length, total: events.length })) }),
|
|
5491
|
+
jsx('span', { className: 'dk_activityChevron', dangerouslySetInnerHTML: { __html: ICON_CHEVRON } }),
|
|
5492
|
+
],
|
|
5493
|
+
}, 'head'),
|
|
5494
|
+
canReconnect ? jsx('button', {
|
|
5495
|
+
type: 'button',
|
|
5496
|
+
className: 'dk_activityRetry',
|
|
5497
|
+
title: t('hint.reconnectEvents'),
|
|
5498
|
+
onClick: props.onReconnect,
|
|
5499
|
+
children: t('btn.reconnectEvents'),
|
|
5500
|
+
}, 'retry') : null,
|
|
5501
|
+
] }),
|
|
5419
5502
|
open === false ? null : (
|
|
5420
5503
|
recent.length === 0
|
|
5421
5504
|
? jsx('div', { className: 'dk_activityEmpty', children: t('list.noEventsHint') })
|
|
@@ -5687,6 +5770,8 @@ window.__ModuleLoader__.load({
|
|
|
5687
5770
|
/** 已进入聚合视图(items 由 pickItems(containers, pickedIds) 现算)。 */
|
|
5688
5771
|
const [aggregateOpen, setAggregateOpen] = useState(false)
|
|
5689
5772
|
const [loading, setLoading] = useState(false)
|
|
5773
|
+
/** 「连接本机」请求在飞:按钮转圈 + 置灰,避免连点造成一串并发写配置。 */
|
|
5774
|
+
const [connectLocalBusy, setConnectLocalBusy] = useState(false)
|
|
5690
5775
|
const [error, setError] = useState('')
|
|
5691
5776
|
const [notice, setNotice] = useState('')
|
|
5692
5777
|
const [all, setAll] = usePanelState('all', true)
|
|
@@ -5701,11 +5786,30 @@ window.__ModuleLoader__.load({
|
|
|
5701
5786
|
/** 面板是否可见(S3):折叠的 tab 不建流;模态 / dock 形态恒为 true。 */
|
|
5702
5787
|
const active = usePanelActive()
|
|
5703
5788
|
const [eventsStatus, setEventsStatus] = useState('')
|
|
5789
|
+
/**
|
|
5790
|
+
* 手动重连令牌(D160):事件流断开后是**终止态**(EventSource 只在连接层抖动时自愈;
|
|
5791
|
+
* `docker events` 进程退出 / 服务端发 `end` 帧都会走 close(),之后没有东西会再开流)。
|
|
5792
|
+
*
|
|
5793
|
+
* 为什么是计数器而不是布尔:布尔翻回 false 时 effect 看不出「又要求了一次」——
|
|
5794
|
+
* 连点两次重连、或在已经 closed 的状态下再点一次都必须重开。自增的值天然两两不同。
|
|
5795
|
+
*/
|
|
5796
|
+
const [eventsReconnect, setEventsReconnect] = useState(0)
|
|
5704
5797
|
const [activityOpen, setActivityOpen] = usePanelState('activityOpen', true)
|
|
5705
5798
|
/** 建流时用的列表加载器(用 ref 拿最新的,避免 all 一变就重连事件流)。 */
|
|
5706
5799
|
const loadContainersRef = useRef(null)
|
|
5707
5800
|
/** 事件流当前绑定的目标:换目标要清空活动条缓冲,免得混着两台主机的事件。 */
|
|
5708
5801
|
const eventsTargetRef = useRef('')
|
|
5802
|
+
/**
|
|
5803
|
+
* 当前目标上是否**成功开过**事件流(D160):重连补偿的判据。
|
|
5804
|
+
*
|
|
5805
|
+
* 用 ref 而不是 state:它只影响下一次建流时的补偿决定,不该触发重渲染;
|
|
5806
|
+
* 而且必须**跨 effect 运行存活**——`eventsReconnect` 一变 effect 就重跑,
|
|
5807
|
+
* 局部变量会归零,手动重连就会被误判成「首次」而不补偿。
|
|
5808
|
+
* 换目标时重置(见 events effect),新目标的第一次 open 不该补。
|
|
5809
|
+
*/
|
|
5810
|
+
const eventsOpenedRef = useRef({ opened: false })
|
|
5811
|
+
/** 上一次建流时看到的**手动**重连令牌(D160):用来认出「这次 effect 跑是用户点的重连」。 */
|
|
5812
|
+
const eventsReconnectRef = useRef(0)
|
|
5709
5813
|
/** 打开中的容器详情:{ id, tab, item }(item 为快照,列表刷新后优先用新数据)。 */
|
|
5710
5814
|
const [detail, setDetail] = usePanelState('detail', null)
|
|
5711
5815
|
const [refreshToken, setRefreshToken] = useState(0)
|
|
@@ -6167,6 +6271,64 @@ window.__ModuleLoader__.load({
|
|
|
6167
6271
|
props.onTargetChange?.(targetLabel(name))
|
|
6168
6272
|
}
|
|
6169
6273
|
|
|
6274
|
+
/**
|
|
6275
|
+
* 「连接本机」:面板工具条 / 无目标空态那一个按钮的唯一实现。
|
|
6276
|
+
*
|
|
6277
|
+
* 三件事一起做,缺任何一件这个按钮都不成立:
|
|
6278
|
+
* 1. 调宿主的 `POST /connect-local`——它才是**唯一**写配置的地方(复用已有本机目标、
|
|
6279
|
+
* 否则用确定性无冲突名新建;settings 不可用 / 保存失败会带原因返回);
|
|
6280
|
+
* 2. 用响应里的新 config 推送 `publishConfig`(已挂载的面板与连接栏立刻看到新目标)
|
|
6281
|
+
* 并重拉一次 /targets(下拉里的 `name · 本机` label 只有它算得出来);
|
|
6282
|
+
* 3. 立刻把 target 切到那条本机目标(用户点它就是想说「看这台」),并清掉上一台的
|
|
6283
|
+
* 错误 / 勾选 / 执行中标记——与手动换目标同一套清理。
|
|
6284
|
+
*
|
|
6285
|
+
* 探测结果(daemon 不可达 / CLI 缺失 / socket 无权)走 `notice`:**保存成功但连不上**
|
|
6286
|
+
* 既不能被说成失败(目标真的加上了),也不能不提(否则用户只看到空列表)。
|
|
6287
|
+
*/
|
|
6288
|
+
const connectLocal = useCallback(() => {
|
|
6289
|
+
if (connectLocalBusy) return
|
|
6290
|
+
setConnectLocalBusy(true)
|
|
6291
|
+
setNotice(t('msg.connectLocalBusy'))
|
|
6292
|
+
api.connectLocal().then((payload) => {
|
|
6293
|
+
if (!mountedRef.current) return
|
|
6294
|
+
const result = payload.result ?? {}
|
|
6295
|
+
const config_ = payload.config
|
|
6296
|
+
if (config_ !== null && typeof config_ === 'object') {
|
|
6297
|
+
setConfig(config_)
|
|
6298
|
+
primeTargetsCache(config_)
|
|
6299
|
+
publishConfig(config_)
|
|
6300
|
+
}
|
|
6301
|
+
const name = typeof result.name === 'string' ? result.name : ''
|
|
6302
|
+
if (name !== '') {
|
|
6303
|
+
setTarget(name)
|
|
6304
|
+
setSessionScoped(false)
|
|
6305
|
+
setError('')
|
|
6306
|
+
// 「连接本机」= 去看这台主机:容器列表页 + 同一套换目标清理
|
|
6307
|
+
setView('containers')
|
|
6308
|
+
setDetail(null)
|
|
6309
|
+
resetPick()
|
|
6310
|
+
setPending({})
|
|
6311
|
+
props.onTargetChange?.(targetLabel(name))
|
|
6312
|
+
}
|
|
6313
|
+
setNotice(typeof result.message === 'string' && result.message !== '' ? result.message : t('msg.saved'))
|
|
6314
|
+
// 下拉里的 label 只有 /targets 算得出来(config 快照里没有 label 字段)
|
|
6315
|
+
void api.targets().then((targetsPayload) => {
|
|
6316
|
+
if (!mountedRef.current) return
|
|
6317
|
+
const rows = targetsPayload.targets ?? []
|
|
6318
|
+
setTargets(rows)
|
|
6319
|
+
targetsCache = rows
|
|
6320
|
+
}).catch(() => { /* 失败不连坐:上面已按 config 校正过选中项 */ })
|
|
6321
|
+
// 目标增删会影响 tty 连接栏按钮:刷新解析后的目标列表缓存
|
|
6322
|
+
void refreshTargetsCache()
|
|
6323
|
+
}).catch((error_) => {
|
|
6324
|
+
// 设置存储不可用 / 保存失败 / 非 POST 同源拒绝:把宿主给的原因原样带出来
|
|
6325
|
+
if (mountedRef.current) setError(t('error.connectLocalFailed') + error_.message)
|
|
6326
|
+
setNotice('')
|
|
6327
|
+
}).finally(() => {
|
|
6328
|
+
if (mountedRef.current) setConnectLocalBusy(false)
|
|
6329
|
+
})
|
|
6330
|
+
}, [connectLocalBusy])
|
|
6331
|
+
|
|
6170
6332
|
const refresh = useCallback(() => {
|
|
6171
6333
|
// 五个列表各有自己的加载器;容器以外的都变化慢,但都走同一条「切页 / 切目标即刷」
|
|
6172
6334
|
if (view === 'overview') loadOverview()
|
|
@@ -6177,6 +6339,20 @@ window.__ModuleLoader__.load({
|
|
|
6177
6339
|
setRefreshToken((value) => value + 1)
|
|
6178
6340
|
}, [view, loadOverview, loadContainers, loadImages, loadNetworks, loadVolumes])
|
|
6179
6341
|
|
|
6342
|
+
/**
|
|
6343
|
+
* 工具条的 ⟳:**手动**刷新,比 `refresh()` 多做一件事——重开事件流(D160)。
|
|
6344
|
+
*
|
|
6345
|
+
* 为什么与 `refresh()` 分开:AUTO REFRESH 轮询也调 `refresh()`(默认 5 秒一次),
|
|
6346
|
+
* 若把重连塞进去,一条因为 docker events 退出而进入终止态的流会被轮询不断重开——
|
|
6347
|
+
* 既掩盖了「它已经断了」,又让每次轮询都多建一条 SSE。用户点 ⟳ 是**明确的**
|
|
6348
|
+
* 「按现在的情况重新来一遍」,那时重连才对;自动轮询没有这个语义。
|
|
6349
|
+
* `eventsReconnect` 自增即触发事件流 effect 重跑(见那里的依赖数组)。
|
|
6350
|
+
*/
|
|
6351
|
+
const refreshManually = useCallback(() => {
|
|
6352
|
+
refresh()
|
|
6353
|
+
setEventsReconnect((value) => value + 1)
|
|
6354
|
+
}, [refresh])
|
|
6355
|
+
|
|
6180
6356
|
useEffect(() => {
|
|
6181
6357
|
// 总览有自己的取数 effect(它不依赖任何单个 target),这里只服务单目标视图
|
|
6182
6358
|
if (view === 'overview') return undefined
|
|
@@ -6243,16 +6419,36 @@ window.__ModuleLoader__.load({
|
|
|
6243
6419
|
return undefined
|
|
6244
6420
|
}
|
|
6245
6421
|
// 换目标就清空缓冲:活动条里不该混着另一台主机的事件
|
|
6246
|
-
|
|
6422
|
+
const sameTarget = eventsTargetRef.current === target
|
|
6423
|
+
if (!sameTarget) {
|
|
6247
6424
|
eventsTargetRef.current = target
|
|
6248
6425
|
setEvents([])
|
|
6426
|
+
eventsOpenedRef.current.opened = false
|
|
6249
6427
|
}
|
|
6428
|
+
/*
|
|
6429
|
+
* 这一次 effect 跑是不是**用户点了「重连」**(令牌变了就是)。必须在建流前算好
|
|
6430
|
+
* 并记下,否则下一次跑就没得比了。
|
|
6431
|
+
*/
|
|
6432
|
+
const manualReconnect = eventsReconnect !== eventsReconnectRef.current
|
|
6433
|
+
eventsReconnectRef.current = eventsReconnect
|
|
6434
|
+
/*
|
|
6435
|
+
* `hadOpen` 初值决定「这一次 open 要不要补一次全量列表刷新」(重连补偿)。
|
|
6436
|
+
*
|
|
6437
|
+
* 判据是**同一个目标上已经成功开过一次**,而不是「本次 effect 运行内开过」:
|
|
6438
|
+
* 后者会让手动重连(D160)与折叠后展开都退化成「首次」,而这两条路径恰恰
|
|
6439
|
+
* 都是「流断了一段时间、列表可能已经落后」——正是补偿要覆盖的情形。
|
|
6440
|
+
* 换目标仍算首次:那边 loadContainers 的 effect 刚拉过一份新的。
|
|
6441
|
+
*
|
|
6442
|
+
* `manualReconnect` 单独兜一种情形:daemon 全程没起时流**从未 open 过**,
|
|
6443
|
+
* 只按 `opened` 判就会把重连成功后的补偿一起省掉——那时列表还停在「这个目标
|
|
6444
|
+
* 的数据没读到」,活动条却已经绿了,看着像好了其实没好。
|
|
6445
|
+
*/
|
|
6446
|
+
let hadOpen = sameTarget && (eventsOpenedRef.current.opened || manualReconnect)
|
|
6250
6447
|
setEventsStatus('connecting')
|
|
6251
6448
|
const debounced = makeDebounced(EVENTS_REFRESH_DEBOUNCE_MS, () => {
|
|
6252
6449
|
const load = loadContainersRef.current
|
|
6253
6450
|
if (load !== null) load()
|
|
6254
6451
|
})
|
|
6255
|
-
let hadOpen = false
|
|
6256
6452
|
const es = new EventSource(streamUrl('/events/stream', { target }))
|
|
6257
6453
|
let closed = false
|
|
6258
6454
|
const close = () => {
|
|
@@ -6301,12 +6497,13 @@ window.__ModuleLoader__.load({
|
|
|
6301
6497
|
void loadContainersRef.current?.()
|
|
6302
6498
|
}
|
|
6303
6499
|
hadOpen = true
|
|
6500
|
+
eventsOpenedRef.current.opened = true
|
|
6304
6501
|
}
|
|
6305
6502
|
return () => {
|
|
6306
6503
|
close()
|
|
6307
6504
|
debounced.cancel()
|
|
6308
6505
|
}
|
|
6309
|
-
}, [active, view, target])
|
|
6506
|
+
}, [active, view, target, eventsReconnect])
|
|
6310
6507
|
|
|
6311
6508
|
useEffect(() => {
|
|
6312
6509
|
if (notice === '') return undefined
|
|
@@ -6617,6 +6814,28 @@ window.__ModuleLoader__.load({
|
|
|
6617
6814
|
return found.label === undefined ? name : name + ' · ' + found.label
|
|
6618
6815
|
}
|
|
6619
6816
|
|
|
6817
|
+
/**
|
|
6818
|
+
* 渲染「连接本机」按钮——**只出现在「一个目标都没有」的空态里**。
|
|
6819
|
+
*
|
|
6820
|
+
* 为什么不做成常驻的工具条按钮(第一版就是这么写的,用户当场指出多余):它解决的
|
|
6821
|
+
* 唯一问题是「面板全空、只让你去设置卡片手配」,而**已经有任何目标**时,选择器里
|
|
6822
|
+
* 那台就是用户要用的那台——这时再挂一个按钮,既占掉选择器右边的位置,又要靠强调色
|
|
6823
|
+
* 招人点,属于纯噪音。所以:没有任何目标 = 它是最短路径(主按钮);有目标 = 不出现。
|
|
6824
|
+
*
|
|
6825
|
+
* 正因为只在 `target === ''` 的空态里渲染,这里**没有**「已有本机目标」的分支:
|
|
6826
|
+
* 列表非空时 `chooseInitialTarget` 一定会选中一个目标,走不到这个空态;空态 ⇒ 没有
|
|
6827
|
+
* 本机目标可复用 ⇒ 这一下必然是「新建并选中」(宿主侧用 `local` / `local-2`… 的确定性
|
|
6828
|
+
* 名字,并自己处理「万一已经有」的复用)。
|
|
6829
|
+
*/
|
|
6830
|
+
const connectLocalButton = (className) => jsx('button', {
|
|
6831
|
+
type: 'button',
|
|
6832
|
+
className,
|
|
6833
|
+
disabled: connectLocalBusy,
|
|
6834
|
+
title: t('btn.connectLocal') + ' — ' + t('hint.connectLocal'),
|
|
6835
|
+
onClick: connectLocal,
|
|
6836
|
+
children: connectLocalBusy ? t('msg.connectLocalBusy') : t('btn.connectLocal'),
|
|
6837
|
+
}, 'connectLocal')
|
|
6838
|
+
|
|
6620
6839
|
const empty = () => {
|
|
6621
6840
|
if (loading) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: t('list.loading') })] })
|
|
6622
6841
|
if (target === '') {
|
|
@@ -6629,6 +6848,8 @@ window.__ModuleLoader__.load({
|
|
|
6629
6848
|
return jsxs('div', { className: 'dk_empty', children: [
|
|
6630
6849
|
jsx('div', { className: 'dk_emptyTitle', children: t('list.noTargetsConfigured') }),
|
|
6631
6850
|
jsx('div', { className: 'dk_emptyHint', children: t('hint.addTargetEmpty') }),
|
|
6851
|
+
// 一键路径就放在这里:面板全空、「一个目标都没有」正是这个按钮最该出现的地方
|
|
6852
|
+
connectLocalButton('dk_btn dk_btnPrimary'),
|
|
6632
6853
|
] })
|
|
6633
6854
|
}
|
|
6634
6855
|
// 读取失败时列表本来就会被清空(切目标失败尤其如此):此时别把空列表说成
|
|
@@ -6891,7 +7112,7 @@ window.__ModuleLoader__.load({
|
|
|
6891
7112
|
config?.allowMutations === true ? null : jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: t('badge.readOnly') }),
|
|
6892
7113
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
6893
7114
|
// 刷新中让图标自己转(loading 也用于镜像列表)
|
|
6894
|
-
selected !== null ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.refreshList'), 'data-spin': loading ? '1' : undefined, onClick:
|
|
7115
|
+
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
7116
|
jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.closePanel'), onClick: requestClose, children: jsx('span', { dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }),
|
|
6896
7117
|
] }),
|
|
6897
7118
|
/* 工具栏:只在列表视图显示;容器详情是整栏视图,列表筛选在这里没有意义 */
|
|
@@ -6901,6 +7122,15 @@ window.__ModuleLoader__.load({
|
|
|
6901
7122
|
// 总览没有「当前目标」这回事:选择器回落成一条带说明的空值项
|
|
6902
7123
|
value: view === 'overview' ? '' : target,
|
|
6903
7124
|
onChange: (event) => {
|
|
7125
|
+
/*
|
|
7126
|
+
* 下拉里的「+ 连接本机」哨兵项:它不是一个目标,选中它等于「我想加一台本机」。
|
|
7127
|
+
* 必须**在**下面那套换目标逻辑之前拦下——否则 setTarget('\0connect-local') 会把
|
|
7128
|
+
* 当前目标换成一个不存在的名字,面板随即报「未知目标」。
|
|
7129
|
+
*/
|
|
7130
|
+
if (event.target.value === CONNECT_LOCAL_OPTION) {
|
|
7131
|
+
connectLocal()
|
|
7132
|
+
return
|
|
7133
|
+
}
|
|
6904
7134
|
setTarget(event.target.value)
|
|
6905
7135
|
// 用户手动选了目标 = 会话级「未匹配」状态结束(D23):此后 staleList
|
|
6906
7136
|
// 的锁与胶囊照常工作,旧目标卡片不再可点
|
|
@@ -6924,6 +7154,20 @@ window.__ModuleLoader__.load({
|
|
|
6924
7154
|
: (target === '' ? [jsx('option', { value: '', children: t('option.noTargetSelected') }, '__none')] : [])),
|
|
6925
7155
|
...(targets.length === 0 && target !== '' ? [{ name: target, label: undefined }] : targets)
|
|
6926
7156
|
.map((item) => jsx('option', { value: item.name, children: targetLabel(item.name) }, item.name)),
|
|
7157
|
+
/*
|
|
7158
|
+
* 「+ 连接本机」放在列表**末尾**(就是用户找机器时会看的地方):
|
|
7159
|
+
* - 与工具条那版不同,它不占任何常驻位置,只有真的点开下拉才出现;
|
|
7160
|
+
* - `targets.length > 0` 才出现:一个目标都没有时,正文空态里已经有一个
|
|
7161
|
+
* 大按钮(那里才是它最该出现的地方),这里再列一项等于同一入口出现两次;
|
|
7162
|
+
* - 已经有本机目标时也不出现(选择器里已经有它了,再列一项等于重复);
|
|
7163
|
+
* - 总览视图没有「当前目标」,但仍可以加目标,所以不排除 overview。
|
|
7164
|
+
* `disabled` 复用 busy:请求在飞时不给点第二次(与按钮那版防连点一致)。
|
|
7165
|
+
*/
|
|
7166
|
+
shouldOfferConnectLocal(targets) ? jsx('option', {
|
|
7167
|
+
value: CONNECT_LOCAL_OPTION,
|
|
7168
|
+
disabled: connectLocalBusy,
|
|
7169
|
+
children: connectLocalBusy ? t('msg.connectLocalBusy') : '+ ' + t('btn.connectLocal'),
|
|
7170
|
+
}, '__connectLocal') : null,
|
|
6927
7171
|
],
|
|
6928
7172
|
}),
|
|
6929
7173
|
/*
|
|
@@ -7066,7 +7310,7 @@ window.__ModuleLoader__.load({
|
|
|
7066
7310
|
docked ? jsxs('div', { className: 'dk_toolbarEnd', children: [
|
|
7067
7311
|
config?.allowMutations !== true ? jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: t('badge.readOnly') }, 'readonly') : null,
|
|
7068
7312
|
// 刷新中图标自己转,所以这里不再另挂 spinner
|
|
7069
|
-
jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.refreshList'), 'data-spin': loading ? '1' : undefined, onClick:
|
|
7313
|
+
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
7314
|
] }) : null,
|
|
7071
7315
|
] }),
|
|
7072
7316
|
/*
|
|
@@ -7131,6 +7375,9 @@ window.__ModuleLoader__.load({
|
|
|
7131
7375
|
statusText: eventsStatusText(),
|
|
7132
7376
|
open: activityOpen,
|
|
7133
7377
|
onToggle: () => setActivityOpen((value) => !value),
|
|
7378
|
+
// 只重开事件流,不顺带刷列表:列表有自己的 ⟳ 与 AUTO REFRESH,
|
|
7379
|
+
// 而用户点这句「重连」想救的就是那条听不见事件的活动条
|
|
7380
|
+
onReconnect: () => setEventsReconnect((value) => value + 1),
|
|
7134
7381
|
}, 'activity') : null,
|
|
7135
7382
|
body(),
|
|
7136
7383
|
] }),
|
|
@@ -8304,6 +8551,7 @@ window.__ModuleLoader__.load({
|
|
|
8304
8551
|
actionText: eventActionText,
|
|
8305
8552
|
timeText: eventTimeText,
|
|
8306
8553
|
debounce: makeDebounced,
|
|
8554
|
+
canReconnect: canReconnectEvents,
|
|
8307
8555
|
}
|
|
8308
8556
|
/*
|
|
8309
8557
|
* 同一类测试缝:总览的折叠逻辑与**正文渲染**都是纯函数(正文刻意写成普通函数而不是
|
|
@@ -8339,7 +8587,26 @@ window.__ModuleLoader__.load({
|
|
|
8339
8587
|
* 「连接还没建立时靠连接簿 host 兜底」这条路径。
|
|
8340
8588
|
*/
|
|
8341
8589
|
matchTargetForSession,
|
|
8590
|
+
/*
|
|
8591
|
+
* 目标下拉里「+ 连接本机」那一项:哨兵值 + 「要不要列这一项」的判定。两个都挂出来,
|
|
8592
|
+
* 用例才能直接驱动 —— 离线冒烟的 React 桩把 useState 冻在初值上(`targets` 恒为 []),
|
|
8593
|
+
* 遍历渲染树根本走不到那一项,所以判定必须是纯函数。
|
|
8594
|
+
*/
|
|
8595
|
+
CONNECT_LOCAL_OPTION,
|
|
8596
|
+
shouldOfferConnectLocal,
|
|
8342
8597
|
}
|
|
8598
|
+
/*
|
|
8599
|
+
* 模块级 config 缓存的测试缝:`matchTargetForSession` 这类判定读的是它,而它只由
|
|
8600
|
+
* `/config` 的响应 / 设置卡片保存后推进去。离线冒烟没有真往返,直接把一份 config
|
|
8601
|
+
* 写进去是最短的路径(publish 顺带通知订阅者,与生产路径同一份语义)。
|
|
8602
|
+
*/
|
|
8603
|
+
exports.__config = { publishConfig, primeTargetsCache }
|
|
8604
|
+
/*
|
|
8605
|
+
* HTTP 面的测试缝:模块级的 `api` 里的每条都是「路径 + 方法 + body」的直接映射,
|
|
8606
|
+
* 离线冒烟用桩 fetch 调一次就能钉住「一键连接本机打的是 /connect-local 这个 POST」
|
|
8607
|
+
* 这类**接线**错误(路径拼歪了在真机上才暴露)。
|
|
8608
|
+
*/
|
|
8609
|
+
exports.__api = api
|
|
8343
8610
|
/*
|
|
8344
8611
|
* 日志缓冲的测试缝:环形上限 / 残行分片 / 单调 id / 快照切分都是纯逻辑,离线冒烟
|
|
8345
8612
|
* 直接驱动(真实 EventSource 时序进不了 Node 桩)。常量挂出来供用例对齐,避免两边漂移。
|