dsh-remote-agent 0.9.5 → 0.9.7
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/CHANGELOG.md +161 -0
- package/lib/client.js +162 -10
- package/lib/e2e.js +42 -1
- package/lib/explain-error.js +472 -0
- package/lib/hub/node-registry.js +78 -0
- package/lib/hub/registry-handshake.js +44 -0
- package/lib/hub/ui-routes.js +28 -0
- package/lib/node/index.js +40 -7
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +66 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,167 @@
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## [0.9.7] - 2026-10-20
|
|
13
|
+
|
|
14
|
+
> **0.9.6 的 CI 回归修复。** 0.9.6 在本地全绿,但 GitHub Actions 的
|
|
15
|
+
> `ubuntu-latest / node 24.x` 上 E2E 握手全部超时。
|
|
16
|
+
|
|
17
|
+
### 修复:X25519 私钥重建改用 DER,不再依赖 JWK 的 `x` 校验
|
|
18
|
+
|
|
19
|
+
**现象**(CI run `39384173448`,只有 ubuntu/node24 失败):
|
|
20
|
+
|
|
21
|
+
Error: timeout waiting for e2e session on test-worker
|
|
22
|
+
code: 'ERR_CRYPTO_INVALID_JWK'
|
|
23
|
+
|
|
24
|
+
**根因**:`lib/e2e.js` 用 JWK 重建私钥:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
createPrivateKey({ key: { kty: 'OKP', crv: 'X25519', d: b64, x: '' }, format: 'jwk' })
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
本地 Node 24.18 接受 `x: ''`,但 CI 那个 Node 24 小版本**不接受**
|
|
31
|
+
(空串不是合法 base64url;省略 `x` 同样被拒)。
|
|
32
|
+
|
|
33
|
+
**这个 bug 为什么藏了这么久**:失败表现为
|
|
34
|
+
`timeout waiting for e2e session` —— **真因(ERR_CRYPTO_INVALID_JWK)被
|
|
35
|
+
完全掩盖**,看起来像时序抖动,而本地又复现不了。
|
|
36
|
+
|
|
37
|
+
**修法**:直接用 DER 构造 —— X25519 私钥的 PKCS#8 就是
|
|
38
|
+
固定 16 字节前缀 + 32 字节标量。这条路径**不经过 JWK 校验**,跨 Node
|
|
39
|
+
小版本稳定。
|
|
40
|
+
|
|
41
|
+
实测验证:DER 构造出的私钥导出的公钥 `x` 与原始 JWK 完全一致,
|
|
42
|
+
DH 协商结果与正常生成的密钥对一致。
|
|
43
|
+
|
|
44
|
+
**测试**:`test/e2e.test.js` 新增 2 条 ——
|
|
45
|
+
双方派生会话密钥必须一致(E2E 握手的核心数学)、非法长度必须明确抛错。
|
|
46
|
+
|
|
47
|
+
### 0.9.6 的五项体验提升(本版包含)
|
|
48
|
+
|
|
49
|
+
- **④ 统一错误翻译层** —— errno + 17 个协议码 + HTTP + 文案特征,每条带"下一步"
|
|
50
|
+
- **① 副机连接状态可见** —— 记录每次连接尝试,空态显示原因与修法
|
|
51
|
+
- **② 一键复制修法** —— 体检项旁的「复制修法」按钮(只复制,不代改文件)
|
|
52
|
+
- **③ 首次使用向导** —— 编号步骤 + 每步"应该看到什么"
|
|
53
|
+
- **⑤ 任务进度摘要** —— 任务行直接显示"最近:调用工具 Write · 共 23 步"
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
packaging 4 项
|
|
57
|
+
doc-drift 126/126
|
|
58
|
+
diag:inject 11 项
|
|
59
|
+
tests 1009(1007 pass / 0 fail / 2 skip)
|
|
60
|
+
lint 干净
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## [0.9.6] - 2026-10-20
|
|
64
|
+
|
|
65
|
+
### 用户体验:五项提升("出了问题不知道原因"的正面回应)
|
|
66
|
+
|
|
67
|
+
用户实测反馈的核心痛点不是功能不够,而是**看不到系统在做什么**。
|
|
68
|
+
下面五项都指向同一件事:**让每一步的状态可见、每个错误可行动**。
|
|
69
|
+
|
|
70
|
+
#### ④ 统一错误翻译层(`lib/explain-error.js`)
|
|
71
|
+
|
|
72
|
+
此前原始错误直接抛给用户:
|
|
73
|
+
|
|
74
|
+
connect ECONNREFUSED 100.86.48.54:19387
|
|
75
|
+
handshake timeout: no welcome/accepted within 15000ms
|
|
76
|
+
task t-xxx terminal: timeout after 40000ms
|
|
77
|
+
HTTP 401 {"error":"unauthorized"}
|
|
78
|
+
|
|
79
|
+
项目里原本有一份 `friendlyConnectError`(`lib/setup.js`),但只覆盖 5 类
|
|
80
|
+
网络错误、且没用到协议错误码。现在统一到一处,覆盖:
|
|
81
|
+
|
|
82
|
+
- **errno**(ECONNREFUSED / ETIMEDOUT / EADDRINUSE / EADDRNOTAVAIL / …)
|
|
83
|
+
- **协议错误码**(17 个,全部有专门翻译)
|
|
84
|
+
- **HTTP 状态**(401/403/404/405/409/500/502/503/504)
|
|
85
|
+
- **文案特征**(`timeout after Nms` → 换算成秒、`handshake timeout` → 版本差异)
|
|
86
|
+
|
|
87
|
+
**每条都给"下一步做什么"**,并保留原始信息(`raw`)供排查。
|
|
88
|
+
|
|
89
|
+
设计约束:**绝不抛**、**不猜**(拿不准的如实说"未知,请附原始错误")。
|
|
90
|
+
|
|
91
|
+
#### ① 副机连接状态可见
|
|
92
|
+
|
|
93
|
+
**此前**:副机连不上时主机只显示「0 在线」,**没有任何原因**。
|
|
94
|
+
用户为此排查了两天(重启回引导页、19387 不监听、SMB 密码…)。
|
|
95
|
+
|
|
96
|
+
**现在**:注册表记录**每一次连接尝试**(成功/失败 + 原因 + 时间 + 远端地址,
|
|
97
|
+
环形上限 50 条)。面板节点区空态渲染:
|
|
98
|
+
|
|
99
|
+
✗ newland 连接失败 2 分钟前
|
|
100
|
+
100.72.146.73:19387 连不上(超时)
|
|
101
|
+
→ 检查:① 两台机器能否互 ping;② 主机防火墙;③ 是否同一局域网
|
|
102
|
+
原始:connect ETIMEDOUT
|
|
103
|
+
|
|
104
|
+
并区分三种情形(**排查方向完全不同**):
|
|
105
|
+
|
|
106
|
+
- 有失败记录 → 显示原因与修法
|
|
107
|
+
- 从没尝试过 → "副机还没开始连"
|
|
108
|
+
- 连过但现在不在线 → "副机 DSH 可能没在跑"
|
|
109
|
+
|
|
110
|
+
#### ② 一键复制修法
|
|
111
|
+
|
|
112
|
+
体检项旁边加「复制修法」按钮。修法里常含可直接粘贴的 YAML/命令,
|
|
113
|
+
让用户手抄是纯粹的摩擦。
|
|
114
|
+
|
|
115
|
+
**只做复制,不做"一键修复"** —— 后者会改用户文件,风险 > 收益
|
|
116
|
+
(我们刚因为改 DSH 文件出过事故)。
|
|
117
|
+
|
|
118
|
+
#### ③ 首次使用向导
|
|
119
|
+
|
|
120
|
+
配置完成后的"下一步"从一句话变成**编号步骤 + 每步"你应该看到什么"**:
|
|
121
|
+
|
|
122
|
+
1 重启 DSH 必须
|
|
123
|
+
关掉 DSH 窗口再打开。配置只在启动时读取。
|
|
124
|
+
2 生成配对码
|
|
125
|
+
重启后这个面板会出现「副机接入」按钮 —— 点它,复制那行 dshra1://
|
|
126
|
+
3 在副机上粘贴
|
|
127
|
+
副机也装本插件 → 打开面板 → 粘贴 → 保存 → 重启副机 DSH
|
|
128
|
+
4 等它连上
|
|
129
|
+
「在线节点」会从 0 变成 1。若一直是 0,看「节点」区的失败原因。
|
|
130
|
+
|
|
131
|
+
角色选择页也加了一句判断提示:
|
|
132
|
+
|
|
133
|
+
通常:你现在用的这台(你在这里操作 DSH 的)= 主机;
|
|
134
|
+
另一台要被派活的电脑 = 副机。
|
|
135
|
+
|
|
136
|
+
#### ⑤ 任务进度摘要
|
|
137
|
+
|
|
138
|
+
长任务(agent 跑 10 分钟)原来只有展开详情才能看到「进行步骤」。
|
|
139
|
+
现在**任务行下方**直接显示:
|
|
140
|
+
|
|
141
|
+
运行中 · 已跑 6m12s
|
|
142
|
+
最近:调用工具 Write · 共 23 步
|
|
143
|
+
|
|
144
|
+
只对运行中的任务显示(已完成看结果摘要更有用),只取最后一条有内容的步骤。
|
|
145
|
+
|
|
146
|
+
### 顺带修复
|
|
147
|
+
|
|
148
|
+
- `lib/node/index.js` 的 `friendlyConnectError` import 已无用(被 explain-error
|
|
149
|
+
替代),删除
|
|
150
|
+
- `explainError` 的取值路径逐层 try —— `String(err)` 在 `toString` 抛错时
|
|
151
|
+
会崩,而翻译层的职责是"绝不抛"(测试抓到的真 bug)
|
|
152
|
+
|
|
153
|
+
### 测试
|
|
154
|
+
|
|
155
|
+
- `test/explain-error.test.js` **20 条**:errno / 协议码全覆盖(断言 17 个
|
|
156
|
+
ERROR_CODE 都有专门翻译,不能落到兜底)/ HTTP / 文案特征 / 健壮性
|
|
157
|
+
(含 `toString` 抛错的恶意对象)/ 渲染
|
|
158
|
+
- doc-drift 新增 88(翻译层覆盖面)、89(连接历史链路)、90(复制按钮 +
|
|
159
|
+
编号步骤 + 进度摘要)
|
|
160
|
+
|
|
161
|
+
**写测试时又踩了一次"断言自己的问题"**:健壮性用例的消息里写了
|
|
162
|
+
`String(v)`,而那个用例的 `toString` 是故意抛错的 → 断言在构造消息时
|
|
163
|
+
自己先崩,误报成"实现抛错"。改用下标标识用例。
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
packaging 4 项
|
|
167
|
+
doc-drift 126/126
|
|
168
|
+
diag:inject 11 项
|
|
169
|
+
tests 1007(1005 pass / 0 fail / 2 skip)
|
|
170
|
+
lint 干净
|
|
171
|
+
```
|
|
172
|
+
|
|
12
173
|
## [0.9.5] - 2026-10-20
|
|
13
174
|
|
|
14
175
|
> **紧急修复:0.9.4 装上后 DSH 报激活失败,插件显示"异常"。**
|
package/lib/client.js
CHANGED
|
@@ -743,13 +743,56 @@ window.__ModuleLoader__.load({
|
|
|
743
743
|
h('div', { className: 'dra-kv' }, h('span', { className: 'dra-k' }, '配置文件'), h('span', { className: 'dra-mono dra-ell', title: result.file }, result.file || '')),
|
|
744
744
|
result.backup ? h('div', { className: 'dra-kv' }, h('span', { className: 'dra-k' }, '备份'), h('span', { className: 'dra-mono dra-ell', title: result.backup }, result.backup)) : null,
|
|
745
745
|
h('div', { className: 'dra-sec' }, '下一步'),
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
746
|
+
// ── 编号步骤(2026-10-20,体验提升 ③)─────────────────────
|
|
747
|
+
//
|
|
748
|
+
// 原来只有一句"重启 DSH 后生效",用户不知道接下来分几步、
|
|
749
|
+
// 每步做什么、做完该看到什么。
|
|
750
|
+
//
|
|
751
|
+
// 现在给明确的编号流程,**每步一个动作 + 一个"你应该看到什么"**:
|
|
752
|
+
// 用户卡住时能立刻判断自己停在哪一步。
|
|
753
|
+
h('div', { style: { paddingLeft: '4px' } },
|
|
754
|
+
h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
|
|
755
|
+
h('span', { className: 'dra-badge' }, '1'),
|
|
756
|
+
h('span', { style: { flex: '1' } }, '重启 DSH'),
|
|
757
|
+
h('span', { className: 'dra-dim' }, '必须'),
|
|
758
|
+
),
|
|
759
|
+
h('div', { className: 'dra-dim', style: { paddingLeft: '28px', marginBottom: '6px' } },
|
|
760
|
+
'关掉 DSH 窗口再打开。配置只在启动时读取。'),
|
|
761
|
+
|
|
762
|
+
result.role === 'hub'
|
|
763
|
+
? h('div', null,
|
|
764
|
+
h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
|
|
765
|
+
h('span', { className: 'dra-badge' }, '2'),
|
|
766
|
+
h('span', { style: { flex: '1' } }, '生成配对码'),
|
|
767
|
+
),
|
|
768
|
+
h('div', { className: 'dra-dim', style: { paddingLeft: '28px', marginBottom: '6px' } },
|
|
769
|
+
'重启后这个面板会出现「副机接入」按钮 —— 点它,复制那行 dshra1:// 开头的码。'),
|
|
770
|
+
|
|
771
|
+
h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
|
|
772
|
+
h('span', { className: 'dra-badge' }, '3'),
|
|
773
|
+
h('span', { style: { flex: '1' } }, '在副机上粘贴'),
|
|
774
|
+
),
|
|
775
|
+
h('div', { className: 'dra-dim', style: { paddingLeft: '28px', marginBottom: '6px' } },
|
|
776
|
+
'副机也装本插件 → 打开面板 → 把码粘进「配对码」框 → 保存 → 重启副机 DSH。'),
|
|
777
|
+
|
|
778
|
+
h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
|
|
779
|
+
h('span', { className: 'dra-badge' }, '4'),
|
|
780
|
+
h('span', { style: { flex: '1' } }, '等它连上'),
|
|
781
|
+
),
|
|
782
|
+
h('div', { className: 'dra-dim', style: { paddingLeft: '28px' } },
|
|
783
|
+
'回到这个面板 —— 「在线节点」会从 0 变成 1。',
|
|
784
|
+
'若一直是 0,看「节点」区:那里会显示最后一次连接失败的原因和修法。'),
|
|
785
|
+
)
|
|
786
|
+
: h('div', null,
|
|
787
|
+
h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
|
|
788
|
+
h('span', { className: 'dra-badge' }, '2'),
|
|
789
|
+
h('span', { style: { flex: '1' } }, '确认连上了主机'),
|
|
790
|
+
),
|
|
791
|
+
h('div', { className: 'dra-dim', style: { paddingLeft: '28px' } },
|
|
792
|
+
'重启后打开这个面板 —— 应显示「本机状态」而不是引导页。',
|
|
793
|
+
'若又回到引导页,说明配置被覆盖了:看「最近一次错误」和日志尾巴。'),
|
|
794
|
+
),
|
|
795
|
+
),
|
|
753
796
|
),
|
|
754
797
|
)
|
|
755
798
|
}
|
|
@@ -771,6 +814,13 @@ window.__ModuleLoader__.load({
|
|
|
771
814
|
head,
|
|
772
815
|
h('div', { className: 'dra-body' },
|
|
773
816
|
h('div', { className: 'dra-sec' }, switching ? '改成什么角色?' : '这台电脑要做什么?'),
|
|
817
|
+
// 判断提示(2026-10-20,体验提升 ③):用户第一次打开时不知道选哪个。
|
|
818
|
+
// 实测最常见的困惑是"我要在另一台电脑上跑任务,那我这台算什么?"
|
|
819
|
+
switching
|
|
820
|
+
? null
|
|
821
|
+
: h('div', { className: 'dra-dim', style: { marginBottom: '10px' } },
|
|
822
|
+
'通常:你现在用的这台(你在这里操作 DSH 的)= 主机;',
|
|
823
|
+
'另一台要被派活的电脑 = 副机。'),
|
|
774
824
|
// 提示放在卡片**上方**:用户点完按钮后的反馈必须紧邻按钮,
|
|
775
825
|
// 否则会被滚动位置藏在折叠线以下(实测踩过)。
|
|
776
826
|
note ? h('div', { className: 'dra-err', style: { marginBottom: '8px' } }, note) : null,
|
|
@@ -946,6 +996,8 @@ window.__ModuleLoader__.load({
|
|
|
946
996
|
function Panel() {
|
|
947
997
|
const st = React.useState({
|
|
948
998
|
loading: true, error: '', nodes: [], tasks: [], counts: null, caps: null,
|
|
999
|
+
// 连接尝试历史(体验提升 ①):null = 还没拉到,空对象 = 拉到了但没记录
|
|
1000
|
+
connection: null,
|
|
949
1001
|
// 'hub' = 本机是派活方(有 UI 路由)
|
|
950
1002
|
// 'worker' = 本机是干活方(读 STATUS_PATH)
|
|
951
1003
|
// 'setup' = 本机未配置(读 SETUP_STATE_PATH,显示引导)
|
|
@@ -1336,6 +1388,8 @@ window.__ModuleLoader__.load({
|
|
|
1336
1388
|
nodes: ov.nodes || [],
|
|
1337
1389
|
counts: ov.counts || null,
|
|
1338
1390
|
caps: ov.capabilities || null,
|
|
1391
|
+
// 连接尝试历史(体验提升 ①):副机连不上时面板要能说清原因
|
|
1392
|
+
connection: ov.connection || null,
|
|
1339
1393
|
tasks: tk.tasks || [],
|
|
1340
1394
|
})
|
|
1341
1395
|
})
|
|
@@ -1770,8 +1824,22 @@ window.__ModuleLoader__.load({
|
|
|
1770
1824
|
? h('div', { className: 'dra-dim', style: { paddingLeft: '20px' } }, it.detail)
|
|
1771
1825
|
: null,
|
|
1772
1826
|
!it.ok && it.fix
|
|
1773
|
-
? h('div', {
|
|
1774
|
-
'↳ 怎么修:' + it.fix)
|
|
1827
|
+
? h('div', { style: { paddingLeft: '20px', marginTop: '2px' } },
|
|
1828
|
+
h('div', { className: 'dra-dim' }, '↳ 怎么修:' + it.fix),
|
|
1829
|
+
// 一键复制(2026-10-20,体验提升 ②):
|
|
1830
|
+
// 修法里常含可直接粘贴的命令/YAML 片段,让用户手抄是纯粹的摩擦。
|
|
1831
|
+
// **只做复制,不做"一键修复"** —— 后者会改用户文件,风险 > 收益
|
|
1832
|
+
// (我们刚因为改 DSH 文件出过事故)。
|
|
1833
|
+
h('button', {
|
|
1834
|
+
className: 'dra-btn',
|
|
1835
|
+
style: { marginTop: '4px', padding: '2px 8px', fontSize: '11px' },
|
|
1836
|
+
onClick: function () {
|
|
1837
|
+
copyText(it.fix).then(function (ok) {
|
|
1838
|
+
setNote(ok ? '已复制修法到剪贴板' : '复制失败,请手动选中复制')
|
|
1839
|
+
})
|
|
1840
|
+
},
|
|
1841
|
+
}, '复制修法'),
|
|
1842
|
+
)
|
|
1775
1843
|
: null,
|
|
1776
1844
|
)
|
|
1777
1845
|
}),
|
|
@@ -1873,8 +1941,60 @@ window.__ModuleLoader__.load({
|
|
|
1873
1941
|
)
|
|
1874
1942
|
|
|
1875
1943
|
// ── 节点 ──
|
|
1944
|
+
//
|
|
1945
|
+
// 空态不能只说"还没有节点接入"(2026-10-20,体验提升 ①)。
|
|
1946
|
+
// 实测用户为此排查了两天:副机连不上时主机只显示「0 在线」,
|
|
1947
|
+
// **没有任何原因**。现在把连接尝试历史渲染出来:
|
|
1948
|
+
// · 有过失败 → 显示"最后尝试 N 前 —— 人话原因 → 下一步做什么"
|
|
1949
|
+
// · 从没尝试过 → 说明"副机还没开始连"(方向完全不同!)
|
|
1950
|
+
const conn = state.connection || { everConnected: false, byNode: [], recent: [] }
|
|
1951
|
+
const fmtAgo = function (at) {
|
|
1952
|
+
if (!at) return '未知时间'
|
|
1953
|
+
const sec = Math.max(0, Math.round((Date.now() - at) / 1000))
|
|
1954
|
+
if (sec < 60) return sec + ' 秒前'
|
|
1955
|
+
if (sec < 3600) return Math.round(sec / 60) + ' 分钟前'
|
|
1956
|
+
if (sec < 86400) return Math.round(sec / 3600) + ' 小时前'
|
|
1957
|
+
return Math.round(sec / 86400) + ' 天前'
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
const connDiag = function () {
|
|
1961
|
+
const fails = (conn.byNode || []).filter(function (e) { return e.result === 'fail' })
|
|
1962
|
+
if (fails.length > 0) {
|
|
1963
|
+
// 有失败记录:逐台显示最后一次失败的人话
|
|
1964
|
+
return h('div', null,
|
|
1965
|
+
fails.slice(0, 5).map(function (e) {
|
|
1966
|
+
return h('div', { key: (e.nodeId || '') + (e.remote || ''), style: { marginBottom: '8px' } },
|
|
1967
|
+
h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
|
|
1968
|
+
h('span', { className: 'dra-mono', style: { color: 'var(--dsw-alias-state-error-primary, currentColor)' } }, '✗'),
|
|
1969
|
+
h('span', { style: { flex: '1' } }, (e.nodeId || e.remote || '未知来源') + ' 连接失败'),
|
|
1970
|
+
h('span', { className: 'dra-dim' }, fmtAgo(e.at)),
|
|
1971
|
+
),
|
|
1972
|
+
e.title
|
|
1973
|
+
? h('div', { className: 'dra-dim', style: { paddingLeft: '20px' } }, e.title)
|
|
1974
|
+
: null,
|
|
1975
|
+
e.hint
|
|
1976
|
+
? h('div', { className: 'dra-dim', style: { paddingLeft: '20px' } }, '→ ' + e.hint)
|
|
1977
|
+
: null,
|
|
1978
|
+
e.raw
|
|
1979
|
+
? h('div', { className: 'dra-dim', style: { paddingLeft: '20px', opacity: 0.6 } }, '原始:' + e.raw)
|
|
1980
|
+
: null,
|
|
1981
|
+
)
|
|
1982
|
+
}),
|
|
1983
|
+
conn.everConnected
|
|
1984
|
+
? null
|
|
1985
|
+
: h('div', { className: 'dra-dim', style: { marginTop: '4px' } },
|
|
1986
|
+
'这台副机从未成功连上过 —— 先确认副机上的插件已配置 role: worker 并填了正确的 hubUrl。'),
|
|
1987
|
+
)
|
|
1988
|
+
}
|
|
1989
|
+
// 没有任何失败记录:区分"从没尝试"与"连上过但当前离线"
|
|
1990
|
+
return h('div', { className: 'dra-empty' },
|
|
1991
|
+
conn.everConnected
|
|
1992
|
+
? '副机之前连上过,现在不在线 —— 检查副机 DSH 是否在跑。'
|
|
1993
|
+
: '还没有任何副机尝试连接。在副电脑上装本插件,把 config.role 设为 worker 并填 hubUrl(或用面板的配对码)。')
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1876
1996
|
const nodeRows = state.nodes.length === 0
|
|
1877
|
-
?
|
|
1997
|
+
? connDiag()
|
|
1878
1998
|
: state.nodes.map(function (n) {
|
|
1879
1999
|
// 资源徽章:有遥测才显示(首次采样前 cpuPct 为 null → 不误导)
|
|
1880
2000
|
const r = n.resource
|
|
@@ -2156,6 +2276,37 @@ window.__ModuleLoader__.load({
|
|
|
2156
2276
|
const liveSec = t.status === 'running' && t.startedAt
|
|
2157
2277
|
? Math.max(0, Math.round((Date.now() - t.startedAt) / 1000))
|
|
2158
2278
|
: 0
|
|
2279
|
+
|
|
2280
|
+
// ── 进度摘要(2026-10-20,体验提升 ⑤)─────────────────────────
|
|
2281
|
+
//
|
|
2282
|
+
// 原来只有展开详情才能看到「进行步骤」。但长任务(agent 跑 10 分钟)
|
|
2283
|
+
// 用户最想知道的是"它现在在干什么"—— 为此点开详情是多余的摩擦。
|
|
2284
|
+
//
|
|
2285
|
+
// 这里在**任务行下方**加一行摘要:最后一步 + 总步数。
|
|
2286
|
+
// · 只对运行中的任务显示(已完成的任务看结果摘要更有用)
|
|
2287
|
+
// · 只取最后一条有 detail 的(tool/assistant 事件才有内容)
|
|
2288
|
+
let progressLine = null
|
|
2289
|
+
if (t.status === 'running' && Array.isArray(t.progressTail) && t.progressTail.length > 0) {
|
|
2290
|
+
const tail = t.progressTail
|
|
2291
|
+
// 从后往前找第一条带 detail 的("started"/"agent-start" 这类没有内容)
|
|
2292
|
+
let last = null
|
|
2293
|
+
for (let i = tail.length - 1; i >= 0; i -= 1) {
|
|
2294
|
+
const p = tail[i]
|
|
2295
|
+
if (p && typeof p.detail === 'string' && p.detail.trim() !== '') { last = p; break }
|
|
2296
|
+
}
|
|
2297
|
+
if (last) {
|
|
2298
|
+
const phase = typeof last.phase === 'string' ? last.phase : ''
|
|
2299
|
+
progressLine = h('div', {
|
|
2300
|
+
className: 'dra-dim',
|
|
2301
|
+
style: { paddingLeft: '20px', fontSize: '11px', marginTop: '-2px' },
|
|
2302
|
+
title: last.detail,
|
|
2303
|
+
},
|
|
2304
|
+
'最近:' + (phase === 'tool' ? '调用工具 ' : '') + last.detail.slice(0, 120),
|
|
2305
|
+
' · 共 ' + tail.length + ' 步',
|
|
2306
|
+
)
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2159
2310
|
return h('div', { key: t.taskId },
|
|
2160
2311
|
h('div', {
|
|
2161
2312
|
className: 'dra-row' + (isSel ? ' sel' : ''),
|
|
@@ -2169,6 +2320,7 @@ window.__ModuleLoader__.load({
|
|
|
2169
2320
|
? h('span', { className: 'dra-dim', title: '已运行时长' }, '已跑 ' + fmtDur(liveSec * 1000))
|
|
2170
2321
|
: (t.durationMs ? h('span', { className: 'dra-dim' }, fmtDur(t.durationMs)) : null),
|
|
2171
2322
|
),
|
|
2323
|
+
progressLine,
|
|
2172
2324
|
isSel ? h(TaskDetail, {
|
|
2173
2325
|
task: t, detail: detail, caps: caps, onCancel: cancel, onPull: pull,
|
|
2174
2326
|
onApprove: approve, onReject: reject,
|
package/lib/e2e.js
CHANGED
|
@@ -97,8 +97,49 @@ export function normalizeE2EPub(raw) {
|
|
|
97
97
|
return raw
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* X25519 PKCS#8 私钥的 DER 前缀(16 字节)。
|
|
102
|
+
*
|
|
103
|
+
* 结构:SEQUENCE { INTEGER 0, SEQUENCE { OID 1.3.101.110 (X25519) },
|
|
104
|
+
* OCTET STRING { OCTET STRING(32 字节原始标量) } }
|
|
105
|
+
* 后接 32 字节标量即完整私钥。
|
|
106
|
+
*/
|
|
107
|
+
const X25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex')
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 由 base64url 的 32 字节标量构造 X25519 私钥。
|
|
111
|
+
*
|
|
112
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
113
|
+
* ⚠ 为什么用 DER 而不是 JWK(2026-10-20 CI 抓到的真实缺陷)
|
|
114
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
115
|
+
*
|
|
116
|
+
* 原实现:`createPrivateKey({ key: { kty:'OKP', crv:'X25519', d: b64, x: '' }, format:'jwk' })`
|
|
117
|
+
*
|
|
118
|
+
* 在本地 Node 24.18 上能过,但在 GitHub Actions 的 `ubuntu-latest / node 24.x`
|
|
119
|
+
* 上抛 **`ERR_CRYPTO_INVALID_JWK`** —— 那个版本对 JWK 的 `x` 校验更严
|
|
120
|
+
* (空串不是合法 base64url;省略 `x` 同样被拒)。
|
|
121
|
+
*
|
|
122
|
+
* 后果很隐蔽:E2E 握手失败 → 测试只看到
|
|
123
|
+
* `timeout waiting for e2e session on test-worker`,**真因被完全掩盖**。
|
|
124
|
+
*
|
|
125
|
+
* ── 修法 ────────────────────────────────────────────────────────────────
|
|
126
|
+
*
|
|
127
|
+
* 直接用 **DER** 构造:X25519 私钥的 PKCS#8 就是固定 16 字节前缀 + 32 字节
|
|
128
|
+
* 标量。这条路径**不经过 JWK 校验**,跨 Node 小版本稳定。
|
|
129
|
+
*
|
|
130
|
+
* 实测验证:DER 构造出的私钥导出的公钥 `x` 与原始 JWK 完全一致,
|
|
131
|
+
* 且 DH 协商结果与正常生成的密钥对一致。
|
|
132
|
+
*/
|
|
100
133
|
function x2PrivateKeyFromB64(b64) {
|
|
101
|
-
|
|
134
|
+
const raw = Buffer.from(String(b64), 'base64url')
|
|
135
|
+
if (raw.length !== X2_PUB_BYTES) {
|
|
136
|
+
throw new Error(`bad x25519 private key length: ${raw.length} (expect ${X2_PUB_BYTES})`)
|
|
137
|
+
}
|
|
138
|
+
return createPrivateKey({
|
|
139
|
+
key: Buffer.concat([X25519_PKCS8_PREFIX, raw]),
|
|
140
|
+
format: 'der',
|
|
141
|
+
type: 'pkcs8',
|
|
142
|
+
})
|
|
102
143
|
}
|
|
103
144
|
|
|
104
145
|
function x2PublicKeyFromB64(b64) {
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 错误翻译层(2026-10-20,用户体验提升 ④)。
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 为什么需要它
|
|
6
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* 实测用户反馈:**出了问题不知道原因**。而原始错误长这样:
|
|
9
|
+
*
|
|
10
|
+
* connect timeout after 15000ms
|
|
11
|
+
* ECONNREFUSED 100.86.48.54:19387
|
|
12
|
+
* HTTP 401 {"error":"unauthorized"}
|
|
13
|
+
* task t-mvc2qkfcgo32w terminal: timeout after 40000ms
|
|
14
|
+
* EPERM: operation not permitted, rename 'C:\Users\...\templates.json.tmp-...'
|
|
15
|
+
*
|
|
16
|
+
* 用户看到这些只能猜。而本项目**已经**在 `lib/setup.js` 里有一份
|
|
17
|
+
* `friendlyConnectError` —— 但它只覆盖 5 类网络错误,且没用到协议错误码。
|
|
18
|
+
*
|
|
19
|
+
* 本模块把翻译**统一到一处**:
|
|
20
|
+
* · 网络层(Node errno)
|
|
21
|
+
* · 协议层(ERROR_CODE 的 17 个码)
|
|
22
|
+
* · 面板层(HTTP 状态码)
|
|
23
|
+
* · 文件系统(EPERM/ENOENT 等)
|
|
24
|
+
*
|
|
25
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
26
|
+
* 设计原则
|
|
27
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
28
|
+
*
|
|
29
|
+
* · **每条都带"下一步做什么"** —— 只说"连接失败"等于没说
|
|
30
|
+
* · **保留原始信息**(`raw` 字段)—— 翻译不能丢排查线索
|
|
31
|
+
* · **绝不抛**:任何输入都返回一个结构,最差也回退到原始 message
|
|
32
|
+
* · **不猜**:拿不准的不编造原因,如实说"未知,请附上原始错误"
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { ERROR_CODE } from './protocol.js'
|
|
36
|
+
|
|
37
|
+
/** 统一的翻译结果。 */
|
|
38
|
+
function result({ title, detail = '', hint = '', raw = '', level = 'error' }) {
|
|
39
|
+
return { title, detail, hint, raw, level }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 网络层错误(Node errno)→ 人话。
|
|
44
|
+
*
|
|
45
|
+
* @param {string} code errno,如 ECONNREFUSED
|
|
46
|
+
* @param {object} [o]
|
|
47
|
+
* @param {string} [o.where] 目标地址(host:port)
|
|
48
|
+
* @param {boolean} [o.isHub] 出错方是不是主机侧(影响措辞)
|
|
49
|
+
*/
|
|
50
|
+
function translateErrno(code, { where = '', isHub = false } = {}) {
|
|
51
|
+
const at = where !== '' ? where : (isHub ? '副机' : '主机')
|
|
52
|
+
const other = isHub ? '副机' : '主机'
|
|
53
|
+
|
|
54
|
+
switch (code) {
|
|
55
|
+
case 'ECONNREFUSED':
|
|
56
|
+
return result({
|
|
57
|
+
title: `${at} 上没有服务在监听`,
|
|
58
|
+
detail: '连接被主动拒绝 —— 对端机器在,但那个端口没程序在听。',
|
|
59
|
+
hint: isHub
|
|
60
|
+
? `检查:① 副机的 DSH 是否在跑;② 副机配置的 hubUrl 端口是否与主机的 listenPort 一致(默认 19387);③ 副机是否已设 role: worker。`
|
|
61
|
+
: `检查:① 主机的 DSH 是否在跑;② 主机插件是否成功监听(看面板「环境体检」);③ IP/端口是否写对。`,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
case 'ETIMEDOUT':
|
|
65
|
+
case 'ETIMEDOUT_':
|
|
66
|
+
return result({
|
|
67
|
+
title: `${at} 连不上(超时)`,
|
|
68
|
+
detail: '数据包发出去了但没回应 —— 通常是网络不通或防火墙拦截。',
|
|
69
|
+
hint: '检查:① 两台机器能否互 ping(Tailscale 用 tailscale status 看是否 online);'
|
|
70
|
+
+ '② 主机防火墙是否放行该端口;③ 是否在同一局域网但路由器开了 AP 隔离。',
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
case 'EHOSTUNREACH':
|
|
74
|
+
case 'ENETUNREACH':
|
|
75
|
+
return result({
|
|
76
|
+
title: `到 ${at} 的网络不可达`,
|
|
77
|
+
detail: '本机没有到那个地址的路由 —— 地址可能不在任何已连接的网段。',
|
|
78
|
+
hint: `检查:① Tailscale 是否已连接(tailscale status);② 是否两台机器在同一局域网;`
|
|
79
|
+
+ `③ IP 是否写错(Tailscale IP 形如 100.x.x.x)。`,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
case 'ENOTFOUND':
|
|
83
|
+
case 'EAI_AGAIN':
|
|
84
|
+
return result({
|
|
85
|
+
title: `解析不了 ${at} 的主机名`,
|
|
86
|
+
detail: 'DNS 查不到这个名字。',
|
|
87
|
+
hint: 'hubUrl 里建议直接写 **IP**(如 100.86.48.54 或 192.168.1.10),不要写主机名。',
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
case 'EACCES':
|
|
91
|
+
case 'EPERM':
|
|
92
|
+
return result({
|
|
93
|
+
title: '系统拒绝了这次操作(权限不足)',
|
|
94
|
+
detail: '不是网络问题 —— 是操作系统层面的权限拦截。',
|
|
95
|
+
hint: '如果是文件操作:检查目录权限、文件是否被别的程序占用;'
|
|
96
|
+
+ '如果是端口:换一个端口(<1024 需要管理员)。',
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
case 'ECONNRESET':
|
|
100
|
+
return result({
|
|
101
|
+
title: `${at} 主动断开了连接`,
|
|
102
|
+
detail: '连接建立后又被对方关闭 —— 可能是对端重启、崩溃,或协议不匹配。',
|
|
103
|
+
hint: `检查:① ${other}的 DSH 是否刚重启过(重连通常会自动恢复);`
|
|
104
|
+
+ '② 两端插件版本是否一致(版本差太多会握手失败)。',
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
case 'EPIPE':
|
|
108
|
+
return result({
|
|
109
|
+
title: '连接已断开,写入失败',
|
|
110
|
+
detail: '对端已经关闭,还在往管道里写。',
|
|
111
|
+
hint: '通常会自动重连。若持续出现,检查对端进程是否在崩溃重启。',
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
case 'ENOENT':
|
|
115
|
+
return result({
|
|
116
|
+
title: '找不到这个文件或目录',
|
|
117
|
+
detail: '路径不存在 —— 可能是拼写错误,或那个目录被删了。',
|
|
118
|
+
hint: '检查配置里的路径(尤其 roots / uiPullRoot)是否真实存在。',
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
case 'EADDRINUSE':
|
|
122
|
+
return result({
|
|
123
|
+
title: '端口已被占用',
|
|
124
|
+
detail: '这个端口已经有别的程序在监听了。',
|
|
125
|
+
hint: '换一个端口(配置里改 listenPort),或先关掉占用它的程序。'
|
|
126
|
+
+ '查占用:netstat -ano | findstr :19387',
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
case 'EADDRNOTAVAIL':
|
|
130
|
+
return result({
|
|
131
|
+
title: '这个 IP 不属于本机',
|
|
132
|
+
detail: '想绑的地址在本机网卡上不存在(IP 变了,或写错了)。',
|
|
133
|
+
hint: '检查配置里的 listenHost。留空则自动选择(推荐)—— '
|
|
134
|
+
+ '会自动挑 Tailscale IP 或局域网 IP。',
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
default:
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 协议错误码 → 人话。
|
|
144
|
+
*
|
|
145
|
+
* @param {string} code ERROR_CODE 的值
|
|
146
|
+
* @param {object} [o]
|
|
147
|
+
* @param {boolean} [o.isHub] 保留参数(协议错误码的文案对两端通用,暂未分支)
|
|
148
|
+
*/
|
|
149
|
+
function translateProtocolCode(code, { isHub: _isHub = false } = {}) {
|
|
150
|
+
switch (code) {
|
|
151
|
+
case ERROR_CODE.PROTOCOL_MISMATCH:
|
|
152
|
+
return result({
|
|
153
|
+
title: '两端插件版本不兼容',
|
|
154
|
+
detail: '协议版本号对不上 —— 一端太旧或太新。',
|
|
155
|
+
hint: '把**两台机器**的 dsh-remote-agent 升级到同一版本(npm i -g dsh-remote-agent@latest)。',
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
case ERROR_CODE.UNAUTHORIZED:
|
|
159
|
+
return result({
|
|
160
|
+
title: '鉴权失败(token 不对)',
|
|
161
|
+
detail: '主机拒绝了副机的凭证。',
|
|
162
|
+
hint: '重新生成配对码:主机面板「副机接入」→ 复制新的 dshra1:// 整行 → '
|
|
163
|
+
+ '在副机重新粘贴 → 重启副机 DSH。',
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
case ERROR_CODE.DUPLICATE_NODE:
|
|
167
|
+
return result({
|
|
168
|
+
title: '节点 ID 重复',
|
|
169
|
+
detail: '已经有一台同 ID 的副机在线 —— 两台机器用了同一个 nodeId。',
|
|
170
|
+
hint: '在副机面板「本机标识」里改一个不同的节点 ID(或留空自动用主机名),然后重启。',
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
case ERROR_CODE.HUB_IDENTITY_MISMATCH:
|
|
174
|
+
return result({
|
|
175
|
+
title: '主机身份指纹变了',
|
|
176
|
+
detail: '副机之前钉住过这台主机,但现在的指纹对不上 —— 可能是换了主机,或主机身份文件被重建。',
|
|
177
|
+
hint: '如果确实是同一台主机(身份文件被删过):在副机删掉 '
|
|
178
|
+
+ '~/.dsh-remote-agent/hub-pin.json 后重启。如果是换了主机:重新生成配对码。',
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
case ERROR_CODE.HUB_IDENTITY_PENDING:
|
|
182
|
+
return result({
|
|
183
|
+
title: '主机身份待确认',
|
|
184
|
+
detail: '首次连接,副机在等主机出示身份证明。',
|
|
185
|
+
hint: '正常流程会自动完成。若卡住:检查主机是否是较新版本(旧版没有身份签名)。',
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
case ERROR_CODE.E2E_RESET:
|
|
189
|
+
return result({
|
|
190
|
+
title: '加密会话已重置',
|
|
191
|
+
detail: '主机重启过,之前的密钥失效了。',
|
|
192
|
+
hint: '自动重新握手,通常无需操作。若持续出现,检查两端版本是否一致。',
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
case ERROR_CODE.E2E_REQUIRED:
|
|
196
|
+
return result({
|
|
197
|
+
title: '要求加密但握手没完成',
|
|
198
|
+
detail: '业务帧必须以密文发送,但当前连接还没有加密会话。',
|
|
199
|
+
hint: '通常是中继模式下的临时状态。若持续:重启两端 DSH。',
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
case ERROR_CODE.WORKSPACE_DENIED:
|
|
203
|
+
return result({
|
|
204
|
+
title: '工作区被拒绝(路径越界)',
|
|
205
|
+
detail: '请求的路径不在该副机允许的 roots 范围内。',
|
|
206
|
+
hint: '在副机配置里把该目录加进 roots(必须是绝对路径、必须真实存在),然后重启副机。',
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
case ERROR_CODE.ARTIFACT_DENIED:
|
|
210
|
+
return result({
|
|
211
|
+
title: '产物被拒绝',
|
|
212
|
+
detail: '该文件不在可拉取的产物清单里(可能被判为 intermediate 或 other)。',
|
|
213
|
+
hint: '只有 role:final 的文件能拉。用 remote_manifest 看清单;'
|
|
214
|
+
+ '如需放宽,用 artifactRules 配置调整分类规则。',
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
case ERROR_CODE.ARTIFACT_MISSING:
|
|
218
|
+
return result({
|
|
219
|
+
title: '产物文件不见了',
|
|
220
|
+
detail: '清单里有,但副机上那个文件已经被删或改名。',
|
|
221
|
+
hint: '重新跑一次任务,或让副机确认该文件还在原路径。',
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
case ERROR_CODE.ARTIFACT_TOO_LARGE:
|
|
225
|
+
return result({
|
|
226
|
+
title: '产物超过大小上限',
|
|
227
|
+
detail: '单个文件太大,超出协议允许的单帧/单文件上限。',
|
|
228
|
+
hint: '在副机上压缩后再拉,或直接用面板的「工作区共享(SMB)」映射目录访问。',
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
case ERROR_CODE.HASH_MISMATCH:
|
|
232
|
+
return result({
|
|
233
|
+
title: '文件校验失败(内容不一致)',
|
|
234
|
+
detail: '传输过程中内容变了 —— 通常是文件在传的时候被修改,或断点续传的偏移算错了。',
|
|
235
|
+
hint: '重试一次(会自动从头传)。若持续:确认副机上那个文件没有被其它程序正在写。',
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
case ERROR_CODE.FS_TIMEOUT:
|
|
239
|
+
return result({
|
|
240
|
+
title: '文件操作超时',
|
|
241
|
+
detail: '副机在时限内没返回结果 —— 可能它正忙,或那个目录特别大。',
|
|
242
|
+
hint: '稍后重试。若是 grep 大目录,缩小 path 范围或用更精确的 pattern。',
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
case ERROR_CODE.TASK_UNKNOWN:
|
|
246
|
+
return result({
|
|
247
|
+
title: '找不到这个任务',
|
|
248
|
+
detail: '任务 ID 不存在 —— 可能已被清理,或副机重启后丢了记录。',
|
|
249
|
+
hint: '用 remote_nodes 看当前任务列表。若任务确实该在,检查副机是否重启过。',
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
case ERROR_CODE.TASK_DUPLICATE:
|
|
253
|
+
return result({
|
|
254
|
+
title: '任务 ID 重复',
|
|
255
|
+
detail: '这个 taskId 已经存在了。',
|
|
256
|
+
hint: '通常是重试逻辑的问题 —— 换一个新的 taskId,或先查 remote_status。',
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
case ERROR_CODE.INTERNAL:
|
|
260
|
+
return result({
|
|
261
|
+
title: '内部错误',
|
|
262
|
+
detail: '插件内部出了意外。',
|
|
263
|
+
hint: '这通常是 bug。请附上原始错误信息提 issue:'
|
|
264
|
+
+ 'https://github.com/zbzbhs/dsh-remote-agent/issues',
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
default:
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** HTTP 状态码 → 人话(面板/路由层)。 */
|
|
273
|
+
function translateHttp(status) {
|
|
274
|
+
const n = Number(status)
|
|
275
|
+
if (!Number.isFinite(n)) return null
|
|
276
|
+
switch (n) {
|
|
277
|
+
case 401:
|
|
278
|
+
return result({
|
|
279
|
+
title: '面板鉴权失败',
|
|
280
|
+
detail: 'DSH 的连接围栏拒绝了这次请求(Host/Origin 校验或 cookie 失效)。',
|
|
281
|
+
hint: '刷新页面。若持续:确认你是从 DSH 桌面窗口打开面板,而不是别的浏览器标签。',
|
|
282
|
+
})
|
|
283
|
+
case 403:
|
|
284
|
+
return result({
|
|
285
|
+
title: '没有权限',
|
|
286
|
+
detail: '服务端明确拒绝了这次操作。',
|
|
287
|
+
hint: '检查当前 DSH 会话的权限设置。',
|
|
288
|
+
})
|
|
289
|
+
case 404:
|
|
290
|
+
return result({
|
|
291
|
+
title: '接口不存在',
|
|
292
|
+
detail: '请求的路径没注册 —— 通常是**插件没被加载**,或版本太旧没有这个接口。',
|
|
293
|
+
hint: '检查:① 插件是否已安装并启用;② 版本是否过旧(面板底部有版本号)。',
|
|
294
|
+
})
|
|
295
|
+
case 405:
|
|
296
|
+
return result({
|
|
297
|
+
title: '请求方法不对',
|
|
298
|
+
detail: '这个接口不接受该 HTTP 方法。',
|
|
299
|
+
hint: '通常是面板与插件版本不匹配 —— 升级插件后刷新页面。',
|
|
300
|
+
})
|
|
301
|
+
case 409:
|
|
302
|
+
return result({
|
|
303
|
+
title: '冲突',
|
|
304
|
+
detail: '目标状态已被别人改过(例如同时两个人改配置)。',
|
|
305
|
+
hint: '刷新页面看最新状态,再重试。',
|
|
306
|
+
})
|
|
307
|
+
case 500:
|
|
308
|
+
return result({
|
|
309
|
+
title: '服务端出错',
|
|
310
|
+
detail: '插件内部抛了异常。',
|
|
311
|
+
hint: '看 DSH 日志里的 [remote-agent] 行。这通常是 bug,欢迎提 issue。',
|
|
312
|
+
})
|
|
313
|
+
case 502:
|
|
314
|
+
case 503:
|
|
315
|
+
case 504:
|
|
316
|
+
return result({
|
|
317
|
+
title: '副机暂时不可用',
|
|
318
|
+
detail: '主机连不上副机,或副机正在重启。',
|
|
319
|
+
hint: '稍后重试。检查副机是否在线(面板「节点」区)。',
|
|
320
|
+
})
|
|
321
|
+
default:
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 主入口:把任意错误翻译成人话。
|
|
328
|
+
*
|
|
329
|
+
* 识别顺序:errno 码 → 协议错误码 → HTTP 状态码 → 已知文案特征 → 兜底。
|
|
330
|
+
*
|
|
331
|
+
* @param {any} err 错误对象 / 字符串 / {code, message}
|
|
332
|
+
* @param {object} [o]
|
|
333
|
+
* @param {string} [o.where] 目标地址(host:port),用于措辞
|
|
334
|
+
* @param {boolean} [o.isHub] 出错方是不是主机侧
|
|
335
|
+
* @param {string} [o.context] 上下文("连接副机" / "拉取产物" 等),用于兜底文案
|
|
336
|
+
* @returns {{title:string, detail:string, hint:string, raw:string, level:string}}
|
|
337
|
+
*/
|
|
338
|
+
export function explainError(err, { where = '', isHub = false, context = '' } = {}) {
|
|
339
|
+
// 提取原始信息(尽量不丢线索)
|
|
340
|
+
//
|
|
341
|
+
// ⚠ 这里必须**逐层 try**(2026-10-20 测试抓到的真 bug):
|
|
342
|
+
// `String(err)` 会调用 err.toString(),而它可能是被用户/第三方对象覆盖的
|
|
343
|
+
// 会抛错的实现。原写法 `String(err.message ?? err)` 在 message 为 undefined
|
|
344
|
+
// 时退化成 `String(err)` → 整个翻译层崩掉。
|
|
345
|
+
// 翻译层的职责是"绝不抛",所以每个可能抛的取值都单独兜住。
|
|
346
|
+
let raw = ''
|
|
347
|
+
let code = ''
|
|
348
|
+
let message = ''
|
|
349
|
+
const safeString = (v) => {
|
|
350
|
+
try {
|
|
351
|
+
if (typeof v === 'string') return v
|
|
352
|
+
if (v === null || v === undefined) return ''
|
|
353
|
+
return String(v)
|
|
354
|
+
} catch {
|
|
355
|
+
return '(无法转成字符串)'
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
if (typeof err === 'string') {
|
|
360
|
+
raw = err
|
|
361
|
+
message = err
|
|
362
|
+
} else if (err && typeof err === 'object') {
|
|
363
|
+
// message 优先;取不到再退回整个对象(但用 safeString 包住)
|
|
364
|
+
let m = ''
|
|
365
|
+
try {
|
|
366
|
+
m = typeof err.message === 'string' ? err.message : ''
|
|
367
|
+
} catch {
|
|
368
|
+
/* message getter 抛错 */
|
|
369
|
+
}
|
|
370
|
+
raw = m !== '' ? m : safeString(err)
|
|
371
|
+
message = m
|
|
372
|
+
try {
|
|
373
|
+
code = typeof err.code === 'string' ? err.code : ''
|
|
374
|
+
} catch {
|
|
375
|
+
/* code getter 抛错 */
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
raw = '(无法读取错误对象)'
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 1. errno(Node 网络/文件系统错误)
|
|
383
|
+
if (code !== '') {
|
|
384
|
+
const t = translateErrno(code, { where, isHub })
|
|
385
|
+
if (t) return { ...t, raw }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 2. 协议错误码
|
|
389
|
+
if (code !== '') {
|
|
390
|
+
const t = translateProtocolCode(code, { isHub })
|
|
391
|
+
if (t) return { ...t, raw }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 3. 从 message 里捞协议错误码(很多地方只把 code 拼进文案)
|
|
395
|
+
for (const c of Object.values(ERROR_CODE)) {
|
|
396
|
+
if (typeof c === 'string' && c !== '' && message.includes(c)) {
|
|
397
|
+
const t = translateProtocolCode(c, { isHub })
|
|
398
|
+
if (t) return { ...t, raw }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 4. HTTP 状态码(message 形如 "HTTP 401" 或 "401 Unauthorized")
|
|
403
|
+
const httpMatch = /\b(4\d\d|5\d\d)\b/.exec(message)
|
|
404
|
+
if (httpMatch) {
|
|
405
|
+
const t = translateHttp(httpMatch[1])
|
|
406
|
+
if (t) return { ...t, raw }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 5. 常见文案特征(Node/我们自己的实现里的固定串)
|
|
410
|
+
if (/timeout after \d+ms/i.test(message) || /timed out after \d+ms/i.test(message)) {
|
|
411
|
+
const ms = /after (\d+)ms/i.exec(message)
|
|
412
|
+
const sec = ms ? Math.round(Number(ms[1]) / 1000) : null
|
|
413
|
+
return {
|
|
414
|
+
...result({
|
|
415
|
+
title: '操作超时',
|
|
416
|
+
detail: sec !== null ? `等了 ${sec} 秒没收到回应。` : '等待回应超时。',
|
|
417
|
+
hint: isHub
|
|
418
|
+
? '检查副机是否在线、是否正忙。若是长任务,用更长的 timeoutMs 重派。'
|
|
419
|
+
: '检查主机是否在线、网络是否稳定。',
|
|
420
|
+
}),
|
|
421
|
+
raw,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (/handshake timeout/i.test(message)) {
|
|
426
|
+
return {
|
|
427
|
+
...result({
|
|
428
|
+
title: '握手超时',
|
|
429
|
+
detail: 'TCP 连上了,但 WebSocket/协议握手没在时限内完成。',
|
|
430
|
+
hint: '通常是**两端版本差异太大**,或对端那个端口上跑的不是本插件。'
|
|
431
|
+
+ '确认:① 端口号是否写对(默认 19387);② 两端插件版本是否一致。',
|
|
432
|
+
}),
|
|
433
|
+
raw,
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (/not lossless JSON|invalid output/i.test(message)) {
|
|
438
|
+
return {
|
|
439
|
+
...result({
|
|
440
|
+
title: '数据格式异常',
|
|
441
|
+
detail: '插件返回的数据里有无法序列化的值(这通常是 bug)。',
|
|
442
|
+
hint: '请附上原始错误提 issue。',
|
|
443
|
+
}),
|
|
444
|
+
raw,
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// 6. 兜底:不编造原因,如实说
|
|
449
|
+
return {
|
|
450
|
+
...result({
|
|
451
|
+
title: context !== '' ? `${context}失败` : '出错了',
|
|
452
|
+
detail: message !== '' ? message : '没有更多信息。',
|
|
453
|
+
hint: '如果看不懂这条错误,请把**原始错误**整段附上提 issue:'
|
|
454
|
+
+ 'https://github.com/zbzbhs/dsh-remote-agent/issues',
|
|
455
|
+
level: 'warn',
|
|
456
|
+
}),
|
|
457
|
+
raw,
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 把翻译结果渲染成一行可读文本(日志/CLI 用;面板自己排版)。
|
|
463
|
+
*
|
|
464
|
+
* @param {ReturnType<typeof explainError>} t
|
|
465
|
+
* @returns {string}
|
|
466
|
+
*/
|
|
467
|
+
export function formatExplained(t) {
|
|
468
|
+
const lines = [t.title]
|
|
469
|
+
if (t.detail) lines.push(` ${t.detail}`)
|
|
470
|
+
if (t.hint) lines.push(` → ${t.hint}`)
|
|
471
|
+
return lines.join('\n')
|
|
472
|
+
}
|
package/lib/hub/node-registry.js
CHANGED
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
// 视图投影共享层:registry 给工具用的任务视图与 UI 路由共用同一份白名单。
|
|
36
36
|
import { taskView as taskProjection } from './views.js'
|
|
37
37
|
import { errText } from './hub-util.js'
|
|
38
|
+
// 统一错误翻译层(2026-10-20,体验提升 ①):连接失败时给面板"人话 + 下一步"。
|
|
39
|
+
import { explainError } from '../explain-error.js'
|
|
38
40
|
import { withIngest } from './registry-ingest.js'
|
|
39
41
|
import { withHandshake } from './registry-handshake.js'
|
|
40
42
|
|
|
@@ -78,6 +80,19 @@ class NodeRegistryCore {
|
|
|
78
80
|
this.audit = audit && typeof audit.record === 'function' ? audit : null
|
|
79
81
|
/** nodeId → rec */
|
|
80
82
|
this.nodes = new Map()
|
|
83
|
+
/**
|
|
84
|
+
* 连接尝试历史(2026-10-20,体验提升 ①)。
|
|
85
|
+
*
|
|
86
|
+
* 问题:副机连不上时,主机只显示「0 在线」,**没有任何原因**。
|
|
87
|
+
* 实测用户为此排查了两天(重启回引导页、19387 不监听、SMB 密码…)。
|
|
88
|
+
*
|
|
89
|
+
* 这里记**每一次连接尝试的结果**(成功/失败 + 原因 + 时间 + 远端地址),
|
|
90
|
+
* 面板据此显示"最后尝试 2 分钟前 —— 握手超时 → 检查…"。
|
|
91
|
+
*
|
|
92
|
+
* 上限 50 条(环形):这是给人看的诊断线索,不是审计日志
|
|
93
|
+
* (审计是 audit.jsonl,那是给合规/追溯用的)。
|
|
94
|
+
*/
|
|
95
|
+
this.connectionLog = []
|
|
81
96
|
/** 等 hello 的连接 */
|
|
82
97
|
this.pending = new Set()
|
|
83
98
|
/** taskId → NodeJS.Timeout(两阶段审批的自动拒绝定时器,提案 #2) */
|
|
@@ -972,6 +987,69 @@ class NodeRegistryCore {
|
|
|
972
987
|
}))
|
|
973
988
|
}
|
|
974
989
|
|
|
990
|
+
// ─────────────────────────── 连接尝试历史 ───────────────────────────
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* 记一次连接尝试(2026-10-20,体验提升 ①)。
|
|
994
|
+
*
|
|
995
|
+
* @param {object} o
|
|
996
|
+
* @param {'ok'|'fail'} o.result
|
|
997
|
+
* @param {string} [o.code] 失败码(协议错误码 / errno / 'hello-timeout' 等)
|
|
998
|
+
* @param {string} [o.message] 原始错误文案
|
|
999
|
+
* @param {string} [o.nodeId] 若已知
|
|
1000
|
+
* @param {string} [o.remote] 远端地址(IP:port 或 IP)
|
|
1001
|
+
* @param {string} [o.transport] 'direct' | 'relay'
|
|
1002
|
+
*/
|
|
1003
|
+
noteConnectionAttempt({ result, code = '', message = '', nodeId = '', remote = '', transport = '' } = {}) {
|
|
1004
|
+
try {
|
|
1005
|
+
const entry = {
|
|
1006
|
+
at: Date.now(),
|
|
1007
|
+
result: result === 'ok' ? 'ok' : 'fail',
|
|
1008
|
+
code: typeof code === 'string' ? code : '',
|
|
1009
|
+
message: typeof message === 'string' ? message.slice(0, 300) : '',
|
|
1010
|
+
nodeId: typeof nodeId === 'string' ? nodeId : '',
|
|
1011
|
+
remote: typeof remote === 'string' ? remote : '',
|
|
1012
|
+
transport: typeof transport === 'string' ? transport : '',
|
|
1013
|
+
}
|
|
1014
|
+
// 失败时附上人话解释(面板直接用,不用再翻译一遍)
|
|
1015
|
+
if (entry.result === 'fail') {
|
|
1016
|
+
try {
|
|
1017
|
+
const ex = explainError({ code: entry.code, message: entry.message }, { isHub: true, context: '副机接入' })
|
|
1018
|
+
entry.title = ex.title
|
|
1019
|
+
entry.detail = ex.detail
|
|
1020
|
+
entry.hint = ex.hint
|
|
1021
|
+
} catch {
|
|
1022
|
+
/* 翻译失败不影响记录 */
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
this.connectionLog.push(entry)
|
|
1026
|
+
// 环形上限 50:这是诊断线索,不是审计
|
|
1027
|
+
if (this.connectionLog.length > 50) this.connectionLog.splice(0, this.connectionLog.length - 50)
|
|
1028
|
+
} catch {
|
|
1029
|
+
/* 诊断记录绝不能影响连接流程本身 */
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* 取连接尝试历史(新的在前)+ 每台已知节点的最后状态。
|
|
1035
|
+
*
|
|
1036
|
+
* @param {number} [limit]
|
|
1037
|
+
*/
|
|
1038
|
+
connectionReport(limit = 20) {
|
|
1039
|
+
const recent = this.connectionLog.slice(-Math.max(1, limit)).reverse()
|
|
1040
|
+
// 按 nodeId 归并:每台机器最后一次尝试是什么结果
|
|
1041
|
+
const byNode = new Map()
|
|
1042
|
+
for (const e of this.connectionLog) {
|
|
1043
|
+
if (e.nodeId !== '') byNode.set(e.nodeId, e)
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
recent,
|
|
1047
|
+
byNode: [...byNode.entries()].map(([k, v]) => ({ nodeId: k, ...v })),
|
|
1048
|
+
// 有没有过任何成功的连接 —— 面板据此区分"从没连上过"与"连上又断了"
|
|
1049
|
+
everConnected: this.connectionLog.some((e) => e.result === 'ok'),
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
975
1053
|
// ─────────────────────────── 生命周期 ───────────────────────────
|
|
976
1054
|
|
|
977
1055
|
markOffline(nodeId, reason) {
|
|
@@ -242,6 +242,18 @@ export function withHandshake(Base) {
|
|
|
242
242
|
|
|
243
243
|
if (transport === 'direct') this.sendWelcome(rec)
|
|
244
244
|
else this.sendIdentityFrame(rec) // relay:hub 在握手期对副机静默,身份帧必须在上线后追发
|
|
245
|
+
// 记一次成功的连接尝试(2026-10-20,体验提升 ①)——
|
|
246
|
+
// 面板据此区分"从没连上过"与"连上又断了",这是排查方向的分水岭。
|
|
247
|
+
try {
|
|
248
|
+
this.noteConnectionAttempt({
|
|
249
|
+
result: 'ok',
|
|
250
|
+
nodeId: hello.nodeId,
|
|
251
|
+
remote: remoteOf(conn),
|
|
252
|
+
transport,
|
|
253
|
+
})
|
|
254
|
+
} catch {
|
|
255
|
+
/* 诊断记录绝不能影响上线流程 */
|
|
256
|
+
}
|
|
245
257
|
this.log('info', 'node online', { nodeId: hello.nodeId, os: hello.os, transport })
|
|
246
258
|
return rec
|
|
247
259
|
}
|
|
@@ -388,6 +400,20 @@ export function withHandshake(Base) {
|
|
|
388
400
|
|
|
389
401
|
/** 在指定连接上回 error 帧然后关闭。 */
|
|
390
402
|
rejectConn(conn, code, message, nodeId) {
|
|
403
|
+
// 记一次失败的连接尝试(2026-10-20,体验提升 ①)——
|
|
404
|
+
// 副机连不上时,面板要能说清"为什么",而不是只显示 0 在线。
|
|
405
|
+
try {
|
|
406
|
+
this.noteConnectionAttempt({
|
|
407
|
+
result: 'fail',
|
|
408
|
+
code: typeof code === 'string' ? code : '',
|
|
409
|
+
message: typeof message === 'string' ? message : '',
|
|
410
|
+
nodeId: typeof nodeId === 'string' ? nodeId : '',
|
|
411
|
+
remote: remoteOf(conn),
|
|
412
|
+
transport: 'direct',
|
|
413
|
+
})
|
|
414
|
+
} catch {
|
|
415
|
+
/* 诊断记录绝不能影响拒绝流程本身 */
|
|
416
|
+
}
|
|
391
417
|
try {
|
|
392
418
|
conn.send(encodeFrame(envelope(HUB_TO_NODE.ERROR, { code, message, nodeId })))
|
|
393
419
|
} catch {
|
|
@@ -401,3 +427,21 @@ export function withHandshake(Base) {
|
|
|
401
427
|
}
|
|
402
428
|
}
|
|
403
429
|
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* 从连接对象上尽力取远端地址(用于诊断记录)。
|
|
433
|
+
* WSConnection 不一定暴露 remote,取不到就返回空串。
|
|
434
|
+
*/
|
|
435
|
+
function remoteOf(conn) {
|
|
436
|
+
try {
|
|
437
|
+
const s = conn?.socket
|
|
438
|
+
const addr = s?.remoteAddress
|
|
439
|
+
const port = s?.remotePort
|
|
440
|
+
if (typeof addr === 'string' && addr !== '') {
|
|
441
|
+
return typeof port === 'number' ? `${addr}:${port}` : addr
|
|
442
|
+
}
|
|
443
|
+
} catch {
|
|
444
|
+
/* 取不到就算了 */
|
|
445
|
+
}
|
|
446
|
+
return ''
|
|
447
|
+
}
|
package/lib/hub/ui-routes.js
CHANGED
|
@@ -441,9 +441,37 @@ export function registerUiRoutes({
|
|
|
441
441
|
if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
|
|
442
442
|
const nodes = registry.list().map(nodeView)
|
|
443
443
|
const tasks = registry.listTasks().map((t) => registry.taskView(t))
|
|
444
|
+
// 连接尝试历史(2026-10-20,体验提升 ①):副机连不上时面板要能说清原因。
|
|
445
|
+
// 逐字段投影(不 spread)—— 与其他 UI 响应同一条硬规则。
|
|
446
|
+
const connReport = (() => {
|
|
447
|
+
try {
|
|
448
|
+
const r = registry.connectionReport(20)
|
|
449
|
+
const mapEntry = (e) => ({
|
|
450
|
+
at: Number.isFinite(e.at) ? e.at : 0,
|
|
451
|
+
result: e.result === 'ok' ? 'ok' : 'fail',
|
|
452
|
+
code: typeof e.code === 'string' ? e.code : '',
|
|
453
|
+
nodeId: typeof e.nodeId === 'string' ? e.nodeId : '',
|
|
454
|
+
remote: typeof e.remote === 'string' ? e.remote : '',
|
|
455
|
+
title: typeof e.title === 'string' ? e.title : '',
|
|
456
|
+
detail: typeof e.detail === 'string' ? e.detail : '',
|
|
457
|
+
hint: typeof e.hint === 'string' ? e.hint : '',
|
|
458
|
+
raw: typeof e.message === 'string' ? e.message : '',
|
|
459
|
+
})
|
|
460
|
+
return {
|
|
461
|
+
everConnected: r.everConnected === true,
|
|
462
|
+
byNode: Array.isArray(r.byNode) ? r.byNode.slice(0, 20).map(mapEntry) : [],
|
|
463
|
+
recent: Array.isArray(r.recent) ? r.recent.slice(0, 20).map(mapEntry) : [],
|
|
464
|
+
}
|
|
465
|
+
} catch {
|
|
466
|
+
// 诊断数据取不到不该让整个面板挂掉
|
|
467
|
+
return { everConnected: false, byNode: [], recent: [] }
|
|
468
|
+
}
|
|
469
|
+
})()
|
|
444
470
|
writeJson(res, 200, {
|
|
445
471
|
ok: true,
|
|
446
472
|
nodes,
|
|
473
|
+
// 连接尝试历史:面板据此显示"最后尝试 2 分钟前 —— 握手超时 → …"
|
|
474
|
+
connection: connReport,
|
|
447
475
|
counts: {
|
|
448
476
|
nodes: nodes.length,
|
|
449
477
|
online: nodes.filter((n) => n.online).length,
|
package/lib/node/index.js
CHANGED
|
@@ -45,9 +45,12 @@ import { expandPairConfig } from '../pairing.js'
|
|
|
45
45
|
import { selectRunner } from './runner.js'
|
|
46
46
|
// 资源遥测:CPU(增量)/内存/负载采样,经 node/resource 帧上报 hub。
|
|
47
47
|
import { RESOURCE_INTERVAL_MS, createResourceSampler } from './resource.js'
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
48
|
+
// 统一错误翻译层(2026-10-20,体验提升 ④):覆盖协议错误码(17 个)+ HTTP
|
|
49
|
+
// 状态 + errno,每条都给"下一步做什么"。面板与日志共用同一份口径。
|
|
50
|
+
//
|
|
51
|
+
// 注:原先这里还 import 了 setup.js 的 friendlyConnectError,2026-10-20 起
|
|
52
|
+
// 运行时错误统一走 explain-error.js(覆盖面更广),那个 import 已无用。
|
|
53
|
+
import { explainError, formatExplained } from '../explain-error.js'
|
|
51
54
|
import { scanRepos } from './repos.js'
|
|
52
55
|
import { TaskEngine } from './task-engine.js'
|
|
53
56
|
import { TaskStore } from './task-store.js'
|
|
@@ -655,6 +658,18 @@ function startWorker(ctx, config) {
|
|
|
655
658
|
|
|
656
659
|
/* 6) 发送助手:永不抛、失败即丢帧(断连期间任务照跑) ---------------- */
|
|
657
660
|
let lastError = ''
|
|
661
|
+
/**
|
|
662
|
+
* 最近一次连接失败的结构化详情(2026-10-20,体验提升 ①)。
|
|
663
|
+
*
|
|
664
|
+
* `lastError` 只是原始字符串,面板没法据此给用户"下一步做什么"。
|
|
665
|
+
* 这里存翻译后的三件套:{ at, title, detail, hint, raw, code }。
|
|
666
|
+
*
|
|
667
|
+
* 面板据此显示:
|
|
668
|
+
* ✗ 最后尝试 2 分钟前 —— 主机上没有服务在监听
|
|
669
|
+
* → 检查:① 主机的 DSH 是否在跑;② 端口是否写对…
|
|
670
|
+
* 原始错误:connect ECONNREFUSED 100.86.48.54:19387
|
|
671
|
+
*/
|
|
672
|
+
let lastErrorDetail = null
|
|
658
673
|
const send = (type, data, opts) => {
|
|
659
674
|
try {
|
|
660
675
|
return transport.send(type, data, opts) === true
|
|
@@ -1104,10 +1119,25 @@ function startWorker(ctx, config) {
|
|
|
1104
1119
|
})
|
|
1105
1120
|
on('error', (err) => {
|
|
1106
1121
|
lastError = msgOf(err)
|
|
1107
|
-
// 底层错误码(ECONNREFUSED
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
|
|
1122
|
+
// 底层错误码(ECONNREFUSED 等)对小白是天书。这里用**统一翻译层**
|
|
1123
|
+
// (lib/explain-error.js)给出"哪里没对 + 下一步做什么";原始错误照记
|
|
1124
|
+
// (排查仍需要)。
|
|
1125
|
+
//
|
|
1126
|
+
// 2026-10-20 改:从 setup.js 的 friendlyConnectError 升级到 explain-error.js
|
|
1127
|
+
// —— 后者覆盖协议错误码(17 个)+ HTTP 状态 + 握手超时等,不只是 5 类网络错误。
|
|
1128
|
+
//
|
|
1129
|
+
// ⚠ 结构化保存:面板要显示"最后尝试时间 + 人话 + 原始错误"三件套,
|
|
1130
|
+
// 只存字符串的话面板只能原样打印。
|
|
1131
|
+
const explained = explainError(err, { isHub: false, context: '连接主机' })
|
|
1132
|
+
lastErrorDetail = {
|
|
1133
|
+
at: Date.now(),
|
|
1134
|
+
title: explained.title,
|
|
1135
|
+
detail: explained.detail,
|
|
1136
|
+
hint: explained.hint,
|
|
1137
|
+
raw: explained.raw,
|
|
1138
|
+
code: typeof err?.code === 'string' ? err.code : '',
|
|
1139
|
+
}
|
|
1140
|
+
logger.warn(`连接问题:${formatExplained(explained)}`, { nodeId, error: lastError })
|
|
1111
1141
|
})
|
|
1112
1142
|
// P2-11:hub 身份验证状态。'pending' 期间(relay 宽限窗口内)task/start 会被
|
|
1113
1143
|
// 上面的 guard 拒绝;direct 模式在 welcome 时就已决出,宽限窗口实际只作用于
|
|
@@ -1197,6 +1227,9 @@ function startWorker(ctx, config) {
|
|
|
1197
1227
|
},
|
|
1198
1228
|
reconcile,
|
|
1199
1229
|
lastError: lastError || null,
|
|
1230
|
+
// 结构化的人话错误(面板据此显示"最后尝试 + 原因 + 下一步")。
|
|
1231
|
+
// 旧版副机没有这个字段 → 面板按 null 处理,退回只显示 lastError。
|
|
1232
|
+
lastErrorDetail: lastErrorDetail || null,
|
|
1200
1233
|
updatedAt: Date.now(),
|
|
1201
1234
|
}
|
|
1202
1235
|
}
|
package/package.json
CHANGED
|
@@ -1177,6 +1177,72 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
|
|
|
1177
1177
|
'lib/listen-server.js(拒 0.0.0.0 + Tailscale 优先)+ hub/index.js(测试环境跳过)')
|
|
1178
1178
|
}
|
|
1179
1179
|
|
|
1180
|
+
/* ── 88~90. 用户体验五项(2026-10-20)──
|
|
1181
|
+
*
|
|
1182
|
+
* 实测用户反馈:"出了问题不知道原因"。五个断层:
|
|
1183
|
+
* ① 副机连不上时主机只显示「0 在线」,没有原因
|
|
1184
|
+
* ② 体检能查出来,但修法要手抄 YAML
|
|
1185
|
+
* ③ 首次使用不知道从哪开始
|
|
1186
|
+
* ④ 原始错误(ECONNREFUSED / 401 / timeout after 40000ms)直接抛给用户
|
|
1187
|
+
* ⑤ 长任务只能看到"运行中",不知道在干什么
|
|
1188
|
+
*
|
|
1189
|
+
* 这三条断言钉住"每条都有对应实现",防止后续重构把它们悄悄拿掉。
|
|
1190
|
+
*/
|
|
1191
|
+
{
|
|
1192
|
+
const explainSrc = read('lib/explain-error.js')
|
|
1193
|
+
const regSrc = read('lib/hub/node-registry.js')
|
|
1194
|
+
const clientSrc6 = read('lib/client.js')
|
|
1195
|
+
const uiSrc5 = read('lib/hub/ui-routes.js')
|
|
1196
|
+
|
|
1197
|
+
check('88', '体验④:统一错误翻译层覆盖 errno + 协议码 + HTTP + 文案特征',
|
|
1198
|
+
/export function explainError/.test(explainSrc)
|
|
1199
|
+
// errno 分支
|
|
1200
|
+
&& /ECONNREFUSED/.test(explainSrc)
|
|
1201
|
+
&& /ETIMEDOUT/.test(explainSrc)
|
|
1202
|
+
&& /EADDRINUSE/.test(explainSrc)
|
|
1203
|
+
// 协议码(用 ERROR_CODE 常量,不写死字符串)
|
|
1204
|
+
&& /ERROR_CODE\.UNAUTHORIZED/.test(explainSrc)
|
|
1205
|
+
&& /ERROR_CODE\.PROTOCOL_MISMATCH/.test(explainSrc)
|
|
1206
|
+
// HTTP 状态
|
|
1207
|
+
&& /case 401:/.test(explainSrc)
|
|
1208
|
+
&& /case 404:/.test(explainSrc)
|
|
1209
|
+
// 文案特征
|
|
1210
|
+
&& /handshake timeout/.test(explainSrc)
|
|
1211
|
+
// 每条都有 hint(下一步做什么)
|
|
1212
|
+
&& /hint:/.test(explainSrc),
|
|
1213
|
+
'lib/explain-error.js(errno + 17 个协议码 + HTTP + 文案特征,每条带 hint)')
|
|
1214
|
+
|
|
1215
|
+
check('89', '体验①:连接尝试历史(记录 + 面板空态诊断)',
|
|
1216
|
+
// 注册表记录每次尝试
|
|
1217
|
+
/noteConnectionAttempt/.test(regSrc)
|
|
1218
|
+
&& /connectionLog/.test(regSrc)
|
|
1219
|
+
&& /connectionReport/.test(regSrc)
|
|
1220
|
+
// 拒绝点与成功点都要记
|
|
1221
|
+
&& /noteConnectionAttempt/.test(read('lib/hub/registry-handshake.js'))
|
|
1222
|
+
// 路由透出
|
|
1223
|
+
&& /connection: connReport/.test(uiSrc5)
|
|
1224
|
+
// 面板渲染诊断(区分"从没连过"与"连过又断")
|
|
1225
|
+
&& /connDiag/.test(clientSrc6)
|
|
1226
|
+
&& /everConnected/.test(clientSrc6),
|
|
1227
|
+
'node-registry.js + registry-handshake.js + ui-routes.js + client.js')
|
|
1228
|
+
|
|
1229
|
+
check('90', '体验②③⑤:复制修法按钮 + 编号步骤 + 任务进度摘要',
|
|
1230
|
+
// ② 复制修法
|
|
1231
|
+
/复制修法/.test(clientSrc6)
|
|
1232
|
+
&& /copyText\(it\.fix\)/.test(clientSrc6)
|
|
1233
|
+
// ③ 编号步骤(hub 侧 1-4 步)
|
|
1234
|
+
&& /'重启 DSH'/.test(clientSrc6)
|
|
1235
|
+
&& /'生成配对码'/.test(clientSrc6)
|
|
1236
|
+
&& /'在副机上粘贴'/.test(clientSrc6)
|
|
1237
|
+
&& /'等它连上'/.test(clientSrc6)
|
|
1238
|
+
// ③ 角色选择提示
|
|
1239
|
+
&& /你现在用的这台/.test(clientSrc6)
|
|
1240
|
+
// ⑤ 任务行进度摘要
|
|
1241
|
+
&& /progressLine/.test(clientSrc6)
|
|
1242
|
+
&& /最近:/.test(clientSrc6),
|
|
1243
|
+
'lib/client.js(复制按钮 + 编号步骤 + 角色提示 + 任务进度摘要)')
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1180
1246
|
/* ── 输出 ── */
|
|
1181
1247
|
const failed = results.filter((r) => !r.ok)
|
|
1182
1248
|
if (process.argv.includes('--json')) {
|