dsh-mesh-ui 0.0.1 → 0.0.3
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/lib/client.js +752 -172
- package/lib/collect.js +512 -457
- package/lib/delegations.js +240 -233
- package/lib/index.js +199 -198
- package/package.json +1 -1
package/lib/collect.js
CHANGED
|
@@ -1,459 +1,514 @@
|
|
|
1
|
-
// dsh-mesh-ui — 数据采集(服务端半,后台异步跑)
|
|
2
|
-
//
|
|
3
|
-
// 目标:给设置页「AI 网络」提供一份**够用但轻**的数据:
|
|
4
|
-
// - mesh-health --json(节点/peer/实例/诊断,权威口径)
|
|
5
|
-
// - 本机实例的模型(解析 <dshN>/profiles/web/cordis.patch.yml 的 agent-default-model)
|
|
6
|
-
// - 每实例活跃会话数 / 运行中数(host status / host_self 的 agents)
|
|
7
|
-
// - 最近委托任务(~/.dsh-mesh/delegations.json)
|
|
8
|
-
// - 告警历史(~/.dsh-mesh/alerts.jsonl 尾部 N 条 + ALERT.json 最新)
|
|
9
|
-
//
|
|
10
|
-
// 为什么不在 HTTP handler 里算:2026-11-06 实测 spawnSync 会堵死 DSH host 事件循环,
|
|
11
|
-
// 而 mesh-health 又要探测同进程的 host bridge(9100/9101)→ 自死锁。所以本模块只在
|
|
12
|
-
// 后台刷新时被调用,handler 只读缓存。
|
|
13
|
-
//
|
|
14
|
-
// 归宿:reusable(dsh-mesh/plugin/dsh-mesh-ui/lib/collect.js)
|
|
15
|
-
|
|
16
|
-
import { execFile } from 'node:child_process'
|
|
17
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return process.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
delete cleanEnv.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return { __diag: '
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (build >=
|
|
158
|
-
return 'Windows (' +
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (platform === '
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
files.
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
return
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
1
|
+
// dsh-mesh-ui — 数据采集(服务端半,后台异步跑)
|
|
2
|
+
//
|
|
3
|
+
// 目标:给设置页「AI 网络」提供一份**够用但轻**的数据:
|
|
4
|
+
// - mesh-health --json(节点/peer/实例/诊断,权威口径)
|
|
5
|
+
// - 本机实例的模型(解析 <dshN>/profiles/web/cordis.patch.yml 的 agent-default-model)
|
|
6
|
+
// - 每实例活跃会话数 / 运行中数(host status / host_self 的 agents)
|
|
7
|
+
// - 最近委托任务(~/.dsh-mesh/delegations.json)
|
|
8
|
+
// - 告警历史(~/.dsh-mesh/alerts.jsonl 尾部 N 条 + ALERT.json 最新)
|
|
9
|
+
//
|
|
10
|
+
// 为什么不在 HTTP handler 里算:2026-11-06 实测 spawnSync 会堵死 DSH host 事件循环,
|
|
11
|
+
// 而 mesh-health 又要探测同进程的 host bridge(9100/9101)→ 自死锁。所以本模块只在
|
|
12
|
+
// 后台刷新时被调用,handler 只读缓存。
|
|
13
|
+
//
|
|
14
|
+
// 归宿:reusable(dsh-mesh/plugin/dsh-mesh-ui/lib/collect.js)
|
|
15
|
+
|
|
16
|
+
import { execFile } from 'node:child_process'
|
|
17
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
18
|
+
import { lookup as dnsLookup } from 'node:dns/promises'
|
|
19
|
+
import { arch as osArch, cpus as osCpus, freemem as osFreemem, homedir, platform as osPlatform, release as osRelease, totalmem as osTotalmem } from 'node:os'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
|
|
22
|
+
const CLI_TIMEOUT_MS = 25_000
|
|
23
|
+
const ALERT_HISTORY_MAX = 12
|
|
24
|
+
const DELEGATION_RECENT_MAX = 8
|
|
25
|
+
|
|
26
|
+
export function meshHome() {
|
|
27
|
+
return process.env.DSH_MESH_HOME || join(homedir(), '.dsh-mesh')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveRepo() {
|
|
31
|
+
const candidates = [process.env.DSH_MESH_REPO, join(homedir(), 'Code', 'dsh-mesh'), join(homedir(), 'Desktop', 'dsh-mesh')].filter(Boolean)
|
|
32
|
+
for (const candidate of candidates) {
|
|
33
|
+
if (existsSync(join(candidate, 'scripts', 'mesh-health.mjs'))) return candidate
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readJson(file, fallback = null) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
41
|
+
} catch {
|
|
42
|
+
return fallback
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readText(file) {
|
|
47
|
+
try {
|
|
48
|
+
return readFileSync(file, 'utf8')
|
|
49
|
+
} catch {
|
|
50
|
+
return ''
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 异步跑子进程(永不阻塞事件循环)。 */
|
|
55
|
+
/**
|
|
56
|
+
* 子进程应使用的 Node 可执行文件。
|
|
57
|
+
* 桌面版 Host 跑在 Electron Node 模式下,process.execPath 是 Electron 本体;
|
|
58
|
+
* 直接 spawn 它会拉起 GUI,必须带 ELECTRON_RUN_AS_NODE=1(见 run())或改用系统 node。
|
|
59
|
+
* 可用 DSH_NODE_EXEC 显式指定。
|
|
60
|
+
*/
|
|
61
|
+
export function nodeExec() {
|
|
62
|
+
if (process.env.DSH_NODE_EXEC) return process.env.DSH_NODE_EXEC
|
|
63
|
+
return process.execPath
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function run(file, args, { cwd, timeout = CLI_TIMEOUT_MS, env } = {}) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
execFile(
|
|
69
|
+
file,
|
|
70
|
+
args,
|
|
71
|
+
{
|
|
72
|
+
cwd,
|
|
73
|
+
timeout,
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
env: (function () {
|
|
78
|
+
var childEnv = Object.assign({}, env || process.env)
|
|
79
|
+
// 桌面版(Electron)里 process.execPath 是 Electron 本体;以 Node 模式运行脚本,否则会拉起 GUI
|
|
80
|
+
if (process.versions.electron && String(file) === String(process.execPath)) childEnv.ELECTRON_RUN_AS_NODE = '1'
|
|
81
|
+
return childEnv
|
|
82
|
+
})()
|
|
83
|
+
},
|
|
84
|
+
(error, stdout, stderr) => resolve({ error: error ? String(error.message || error) : null, stdout: stdout || '', stderr: stderr || '' })
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** mesh-health 是独立脚本(不是 cli 子命令),直接跑脚本拿 --json。 */
|
|
90
|
+
async function meshHealthJson(repo) {
|
|
91
|
+
// DSH host 进程里可能继承 NODE_OPTIONS(--import tsx 等)导致子 node 起不来;子进程清理掉。
|
|
92
|
+
const cleanEnv = { ...process.env }
|
|
93
|
+
delete cleanEnv.NODE_OPTIONS
|
|
94
|
+
delete cleanEnv.NODE_EXTRA_CA_CERTS
|
|
95
|
+
const { error, stdout, stderr } = await run(nodeExec(), [join(repo, 'scripts', 'mesh-health.mjs'), '--json'], { cwd: repo, env: cleanEnv })
|
|
96
|
+
// 注意:mesh-health 在 verdict≠ok 时退出码为 3(有 down 节点),execFile 会把它当 error;
|
|
97
|
+
// 但只要 stdout 是完整 JSON 就必须采用——不能因为退出码丢掉真实数据(2026-11-07 踩到)。
|
|
98
|
+
const start = String(stdout || '').indexOf('{')
|
|
99
|
+
if (start >= 0) {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(stdout.slice(start))
|
|
102
|
+
} catch {
|
|
103
|
+
/* fall through to diag */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (error) return { __diag: 'spawn error: ' + error + ' | stderr: ' + String(stderr || '').slice(-300) }
|
|
107
|
+
return { __diag: 'no JSON; stderr: ' + String(stderr || '').slice(-300) + '; stdout: ' + String(stdout || '').slice(-200) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 跑 dsh-mesh CLI 并解析 JSON(cli 输出偶有前缀噪音 → 从第一个 { 开始解析)。 */
|
|
111
|
+
export async function cliJson(repo, args, options = {}) {
|
|
112
|
+
const { error, stdout } = await run(nodeExec(), [join(repo, 'src', 'cli.js'), ...args], { cwd: repo, ...options })
|
|
113
|
+
if (error) return null
|
|
114
|
+
const start = stdout.indexOf('{')
|
|
115
|
+
if (start < 0) return null
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(stdout.slice(start))
|
|
118
|
+
} catch {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** peer call 结果把正文塞在 result.content[0].text(JSON 字符串)里。 */
|
|
124
|
+
export function unwrapPeerCall(wrapper) {
|
|
125
|
+
const text = wrapper?.result?.content?.[0]?.text
|
|
126
|
+
if (typeof text !== 'string') return null
|
|
127
|
+
try {
|
|
128
|
+
return JSON.parse(text)
|
|
129
|
+
} catch {
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 从 profile patch 里读 agent-default-model(最后一条为准)。 */
|
|
135
|
+
function instanceModel(instanceId) {
|
|
136
|
+
const file = join(homedir(), `.${instanceId}`, 'profiles', 'web', 'cordis.patch.yml')
|
|
137
|
+
const text = readText(file)
|
|
138
|
+
if (!text) return null
|
|
139
|
+
const blocks = text.split(/^- id: /m)
|
|
140
|
+
const block = blocks.reverse().find((item) => item.startsWith('agent-default-model'))
|
|
141
|
+
if (!block) return null
|
|
142
|
+
const grab = (key) => {
|
|
143
|
+
const match = new RegExp(`^\\s*${key}:\\s*(.+)$`, 'm').exec(block)
|
|
144
|
+
return match ? match[1].trim().replace(/^["']|["']$/g, '') : null
|
|
145
|
+
}
|
|
146
|
+
const provider = grab('provider')
|
|
147
|
+
const model = grab('model')
|
|
148
|
+
if (!provider && !model) return null
|
|
149
|
+
return { provider, model, reasoningEffort: grab('reasoningEffort') }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** OS 标签:win32 + 版本号 → 更可读的名字(无法识别就原样拼)。 */
|
|
153
|
+
function osLabel(platform, releaseText) {
|
|
154
|
+
const rel = String(releaseText || '')
|
|
155
|
+
if (platform === 'win32') {
|
|
156
|
+
const build = Number((rel.split('.').pop() || '0'))
|
|
157
|
+
if (build >= 22000) return 'Windows 11 (' + rel + ')'
|
|
158
|
+
if (build >= 10240) return 'Windows 10 (' + rel + ')'
|
|
159
|
+
return 'Windows (' + (rel || '?') + ')'
|
|
160
|
+
}
|
|
161
|
+
if (platform === 'linux') return 'Linux ' + (rel || '')
|
|
162
|
+
if (platform === 'darwin') return 'macOS ' + (rel || '')
|
|
163
|
+
return String(platform || '?') + ' ' + rel
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function gb(bytes) {
|
|
167
|
+
return Math.round((Number(bytes || 0) / 1024 / 1024 / 1024) * 10) / 10
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 本机硬件概况(远端拿不到,需要 host_self 扩展;先只给本机)。 */
|
|
171
|
+
function localHardware() {
|
|
172
|
+
const cpuList = osCpus()
|
|
173
|
+
const first = cpuList[0] || {}
|
|
174
|
+
return {
|
|
175
|
+
os: osLabel(osPlatform(), osRelease()),
|
|
176
|
+
arch: osArch(),
|
|
177
|
+
cpuModel: String(first.model || '').replace(/\s+/g, ' ').trim(),
|
|
178
|
+
cpuCores: cpuList.length,
|
|
179
|
+
memTotalGB: gb(osTotalmem()),
|
|
180
|
+
memFreeGB: gb(osFreemem())
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 判断 peer 链接走的是公网穿透、内网直连还是本机回环。 */
|
|
185
|
+
function tunnelOf(url) {
|
|
186
|
+
try {
|
|
187
|
+
const host = new URL(url).hostname
|
|
188
|
+
const port = new URL(url).port
|
|
189
|
+
if (host === '127.0.0.1' || host === 'localhost') return { kind: 'loopback', host, port, label: '本机回环' }
|
|
190
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
|
191
|
+
const isLan = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)
|
|
192
|
+
return { kind: isLan ? 'lan' : 'wan', host, port, label: isLan ? '内网直连' : '公网 IP' }
|
|
193
|
+
}
|
|
194
|
+
return { kind: 'frp', host, port, label: '公网穿透' }
|
|
195
|
+
} catch {
|
|
196
|
+
return { kind: 'unknown', host: '', port: '', label: '未知' }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 本机 SakuraFrp 隧道公网入口:从服务日志里解析 "使用 >>host:port<<" 行。 */
|
|
201
|
+
function localFrpTunnels() {
|
|
202
|
+
try {
|
|
203
|
+
const dir = 'C:\\ProgramData\\SakuraFrpService\\Logs'
|
|
204
|
+
const files = readdirSync(dir).filter((name) => name.endsWith('.log'))
|
|
205
|
+
if (!files.length) return []
|
|
206
|
+
files.sort()
|
|
207
|
+
const latest = join(dir, files[files.length - 1])
|
|
208
|
+
const text = readText(latest)
|
|
209
|
+
const tail = text.slice(-400000)
|
|
210
|
+
const found = new Map()
|
|
211
|
+
const re = /frpc\[([^\]|]+)[^\]]*\][^\n]*?使用 >>([^\s:]+):(\d+)<</g
|
|
212
|
+
let match
|
|
213
|
+
while ((match = re.exec(tail))) {
|
|
214
|
+
found.set(match[1].trim(), { name: match[1].trim(), host: match[2], port: match[3], source: 'sakura' })
|
|
215
|
+
}
|
|
216
|
+
return Array.from(found.values())
|
|
217
|
+
} catch {
|
|
218
|
+
return []
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 本机自建 frps 隧道入口(主用):读 <repo>/.runtime/frpc/frpc.toml 的 serverAddr + remotePort。
|
|
224
|
+
* 2026-11-09:本机「公网入口」曾显示旧 Sakura 隧道(relay.example.com:29152),实际主用已是自建 frps。
|
|
225
|
+
*/
|
|
226
|
+
function localSelfHostedTunnel(repo) {
|
|
227
|
+
try {
|
|
228
|
+
const file = join(repo || '', '.runtime', 'frpc', 'frpc.toml')
|
|
229
|
+
const text = readText(file)
|
|
230
|
+
if (!text) return null
|
|
231
|
+
const grab = (key) => {
|
|
232
|
+
const m = new RegExp('^\\s*' + key + '\\s*=\\s*"?([^"\\r\\n]+)"?', 'm').exec(text)
|
|
233
|
+
return m ? m[1].trim() : null
|
|
234
|
+
}
|
|
235
|
+
const host = grab('serverAddr')
|
|
236
|
+
const remotePort = grab('remotePort')
|
|
237
|
+
if (!host || !remotePort) return null
|
|
238
|
+
return {
|
|
239
|
+
name: grab('name') || 'self-hosted',
|
|
240
|
+
host,
|
|
241
|
+
port: String(remotePort),
|
|
242
|
+
source: 'self-hosted-frps',
|
|
243
|
+
serverPort: grab('serverPort'),
|
|
244
|
+
localPort: grab('localPort'),
|
|
245
|
+
configPath: file
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 本机 frp 客户端是否在跑(tasklist 很快;失败就当未运行)。 */
|
|
253
|
+
async function frpState() {
|
|
254
|
+
const { stdout } = await run('tasklist', ['/FI', 'IMAGENAME eq frpc.exe', '/NH'], { timeout: 8000 })
|
|
255
|
+
const count = (stdout.match(/frpc\.exe/gi) || []).length
|
|
256
|
+
return { running: count > 0, frpcCount: count }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 从 agents 数组算活跃/运行数。 */
|
|
260
|
+
function agentStats(agents) {
|
|
261
|
+
const list = Array.isArray(agents) ? agents : []
|
|
262
|
+
return { sessions: list.length, running: list.filter((agent) => agent && agent.status === 'running').length }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 最近委托任务 + 统计(读本地台账;台账是 CLI/MCP 发起方写的)。 */
|
|
266
|
+
function delegationSummary() {
|
|
267
|
+
const data = readJson(join(meshHome(), 'delegations.json'), { tasks: {} })
|
|
268
|
+
const tasks = Object.values(data?.tasks || {})
|
|
269
|
+
const recent = tasks
|
|
270
|
+
.slice()
|
|
271
|
+
.sort((a, b) => String(b.updatedAt || b.createdAt || '').localeCompare(String(a.updatedAt || a.createdAt || '')))
|
|
272
|
+
.slice(0, DELEGATION_RECENT_MAX)
|
|
273
|
+
.map((task) => ({
|
|
274
|
+
taskId: task.taskId,
|
|
275
|
+
node: task.node,
|
|
276
|
+
state: task.state,
|
|
277
|
+
policy: task.requestedPolicy || task.policy || '',
|
|
278
|
+
goal: String(task.goal || '').slice(0, 80),
|
|
279
|
+
createdAt: task.createdAt || '',
|
|
280
|
+
updatedAt: task.updatedAt || '',
|
|
281
|
+
durationSec: (() => {
|
|
282
|
+
const start = Date.parse(task.createdAt || '')
|
|
283
|
+
const end = Date.parse(task.updatedAt || '') || Date.now()
|
|
284
|
+
return Number.isFinite(start) ? Math.max(0, Math.round((end - start) / 1000)) : null
|
|
285
|
+
})()
|
|
286
|
+
}))
|
|
287
|
+
const byState = {}
|
|
288
|
+
for (const task of tasks) byState[task.state || 'unknown'] = (byState[task.state || 'unknown'] || 0) + 1
|
|
289
|
+
return { total: tasks.length, byState, recent }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 告警历史:alerts.jsonl 尾部 N 条(旧→新)+ 最新指针。 */
|
|
293
|
+
function alertSummary() {
|
|
294
|
+
const latest = readJson(join(meshHome(), 'ALERT.json'), null)
|
|
295
|
+
const lines = readText(join(meshHome(), 'alerts.jsonl')).split('\n').filter(Boolean)
|
|
296
|
+
const history = []
|
|
297
|
+
for (const line of lines.slice(-ALERT_HISTORY_MAX)) {
|
|
298
|
+
try {
|
|
299
|
+
const item = JSON.parse(line)
|
|
300
|
+
history.push({ at: item.at || '', kind: item.kind || '', summary: item.summary || '', verdict: item.verdict || '' })
|
|
301
|
+
} catch {
|
|
302
|
+
/* 跳过坏行 */
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
latest: latest?.at ? { at: latest.at, kind: latest.kind || '', summary: latest.summary || '', verdict: latest.verdict || '', peersDown: latest.peersDown || [], consecutiveBad: latest.consecutiveBad || 0 } : null,
|
|
307
|
+
history
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 采集一次完整快照。
|
|
313
|
+
* @param {string} repo dsh-mesh 仓库绝对路径
|
|
314
|
+
* @returns {Promise<object>} 仪表盘数据
|
|
315
|
+
*/
|
|
316
|
+
// 2026-11-07 性能:collect 原来串行跑 mesh-health(9s) + overview(21s) + mesh_self(21s) + 每 peer host_self(21s)
|
|
317
|
+
// → 单轮 50s+,超过 30s 刷新周期。现在:
|
|
318
|
+
// 1) mesh-health / overview / 本机硬件 / frp 全部**并发**启动;
|
|
319
|
+
// 2) overview 结果缓存 5 分钟(uptime/任务数不需要 30s 新鲜度),且单次调用超时 8s 就跳过;
|
|
320
|
+
// 3) 本机实例 host status、各 peer host_self 全部并发,单调用超时 6s,慢 peer 不拖垮整轮。
|
|
321
|
+
let overviewCache = { at: 0, byNode: {} }
|
|
322
|
+
|
|
323
|
+
function localPeerName() {
|
|
324
|
+
try {
|
|
325
|
+
const data = readJson(join(meshHome(), 'peers.json'), { items: [] })
|
|
326
|
+
return data?.items?.[0]?.name || ''
|
|
327
|
+
} catch {
|
|
328
|
+
return ''
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function overviewData(repo, force = false) {
|
|
333
|
+
const now = Date.now()
|
|
334
|
+
if (!force && overviewCache.byNode && now - overviewCache.at < 300_000) return overviewCache.byNode
|
|
335
|
+
const calledPeer = localPeerName()
|
|
336
|
+
if (!calledPeer) return overviewCache.byNode || {}
|
|
337
|
+
try {
|
|
338
|
+
const [ovResult, selfResult] = await Promise.all([
|
|
339
|
+
cliJson(repo, ['peer', 'call', 'mesh_overview', '--to', calledPeer, '--args', '{}'], { timeout: 8000 }),
|
|
340
|
+
cliJson(repo, ['peer', 'call', 'mesh_self', '--to', calledPeer, '--args', '{}'], { timeout: 8000 })
|
|
341
|
+
])
|
|
342
|
+
const ov = unwrapPeerCall(ovResult)
|
|
343
|
+
const self = unwrapPeerCall(selfResult)
|
|
344
|
+
if (!ov) return overviewCache.byNode || {}
|
|
345
|
+
const byNode = {}
|
|
346
|
+
const tasksByName = {}
|
|
347
|
+
for (const item of ov?.tasks?.peers || []) tasksByName[item.peer] = item.activeCount ?? null
|
|
348
|
+
byNode[calledPeer] = {
|
|
349
|
+
uptimeSec: self?.host?.uptimeSec ?? null,
|
|
350
|
+
nodePid: null,
|
|
351
|
+
nodeTools: null,
|
|
352
|
+
tasksActive: ov?.tasks?.local?.active ?? null,
|
|
353
|
+
instances: ov?.instances || []
|
|
354
|
+
}
|
|
355
|
+
for (const item of ov?.peers || []) {
|
|
356
|
+
const key = item.name || item.id || ''
|
|
357
|
+
if (!key) continue
|
|
358
|
+
byNode[key] = {
|
|
359
|
+
uptimeSec: item.uptimeSec ?? null,
|
|
360
|
+
nodePid: item.pid ?? null,
|
|
361
|
+
nodeTools: item.tools ?? null,
|
|
362
|
+
tasksActive: tasksByName[key] ?? null,
|
|
363
|
+
instances: item.instances || []
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
overviewCache = { at: now, byNode }
|
|
367
|
+
return byNode
|
|
368
|
+
} catch {
|
|
369
|
+
return overviewCache.byNode || {}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 采集一次完整快照(并发执行,单轮目标 <10s)。
|
|
375
|
+
* @param {string} repo dsh-mesh 仓库绝对路径
|
|
376
|
+
* @returns {Promise<object>} 仪表盘数据
|
|
377
|
+
*/
|
|
378
|
+
export async function collect(repo) {
|
|
379
|
+
const generatedAt = new Date().toISOString()
|
|
380
|
+
if (!repo) return { ok: false, error: 'dsh-mesh repo not found (set DSH_MESH_REPO)', generatedAt }
|
|
381
|
+
|
|
382
|
+
const meshPromise = meshHealthJson(repo)
|
|
383
|
+
const overviewPromise = overviewData(repo)
|
|
384
|
+
const frpPromise = frpState()
|
|
385
|
+
const hardware = localHardware()
|
|
386
|
+
const frpTunnels = localFrpTunnels()
|
|
387
|
+
const selfTunnel = localSelfHostedTunnel(repo)
|
|
388
|
+
const localConfig = readJson(join(meshHome(), 'config.json'), {})?.node || {}
|
|
389
|
+
// 2026-11-09:优先自建 frps(.runtime/frpc/frpc.toml);Sakura 隧道只作备用/历史
|
|
390
|
+
const sakuraTunnel = frpTunnels.find((item) => item.name === (localConfig.name || '')) || frpTunnels[0] || null
|
|
391
|
+
const meshTunnel = selfTunnel || sakuraTunnel
|
|
392
|
+
|
|
393
|
+
const [mesh, ov, frp] = await Promise.all([meshPromise, overviewPromise, frpPromise])
|
|
394
|
+
if (!mesh || mesh.__diag) return { ok: false, error: mesh?.__diag || 'mesh-health returned no JSON', generatedAt }
|
|
395
|
+
const overviewByNode = ov || {}
|
|
396
|
+
|
|
397
|
+
// 本机实例:mesh-health 给 up/pid/tools;host status --json 补 agents(并发)
|
|
398
|
+
const localBase = mesh.local?.instances || []
|
|
399
|
+
const localDetails = await Promise.all(localBase.map(async (instance) => {
|
|
400
|
+
if (!instance.up) return null
|
|
401
|
+
return await cliJson(repo, ['host', 'status', '--instance', instance.id, '--json'], { timeout: 6000 })
|
|
402
|
+
}))
|
|
403
|
+
const localInstances = localBase.map((instance, index) => {
|
|
404
|
+
const detail = localDetails[index]
|
|
405
|
+
const stats = agentStats(detail?.agents)
|
|
406
|
+
return {
|
|
407
|
+
id: instance.id,
|
|
408
|
+
up: Boolean(instance.up),
|
|
409
|
+
pid: instance.pid ?? detail?.node?.pid ?? null,
|
|
410
|
+
tools: instance.tools ?? detail?.tools ?? null,
|
|
411
|
+
model: instanceModel(instance.id),
|
|
412
|
+
sessions: stats.sessions,
|
|
413
|
+
running: stats.running,
|
|
414
|
+
detectedAt: new Date().toISOString(),
|
|
415
|
+
port: 3080 + (Number(String(instance.id || '').replace(/\D/g, '')) || 0),
|
|
416
|
+
hostname: detail?.node?.hostname || '',
|
|
417
|
+
error: instance.error?.code || null
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
// 远端实例端口映射(mesh-health remoteInstances,scope=peer 名)
|
|
422
|
+
const remoteByNode = {}
|
|
423
|
+
for (const inst of mesh.remoteInstances || []) {
|
|
424
|
+
const list = remoteByNode[inst.scope] || (remoteByNode[inst.scope] = [])
|
|
425
|
+
const idx = Number(String(inst.id || '').replace(/\D/g, '')) || 0
|
|
426
|
+
list.push({ id: inst.id, port: 3080 + idx, up: Boolean(inst.up) })
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 远端节点:host_self 并发(单调用超时 6s)
|
|
430
|
+
const meshPeers = mesh.peers || []
|
|
431
|
+
const peerSelfs = await Promise.all(meshPeers.map(async (peer) => {
|
|
432
|
+
if (!peer.up) return null
|
|
433
|
+
return await cliJson(repo, ['peer', 'call', 'host_self', '--to', peer.name, '--args', '{}'], { timeout: 6000 })
|
|
434
|
+
}))
|
|
435
|
+
const peers = meshPeers.map((peer, index) => {
|
|
436
|
+
const self = unwrapPeerCall(peerSelfs[index])
|
|
437
|
+
const stats = agentStats(self?.agents)
|
|
438
|
+
return {
|
|
439
|
+
name: peer.name,
|
|
440
|
+
url: peer.url || '',
|
|
441
|
+
up: Boolean(peer.up),
|
|
442
|
+
latencyMs: peer.latencyMs ?? null,
|
|
443
|
+
version: peer.version || null,
|
|
444
|
+
tls: peer.fingerprintMatch !== false,
|
|
445
|
+
instancesCount: Array.isArray(peer.instances) ? peer.instances.length : Number(peer.instances) || 0,
|
|
446
|
+
instancesList: remoteByNode[peer.name] || [],
|
|
447
|
+
sessions: stats.sessions,
|
|
448
|
+
running: stats.running,
|
|
415
449
|
hostname: self?.node?.hostname || '',
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
450
|
+
hardware: self?.hardware || null,
|
|
451
|
+
release: self?.node?.release || '',
|
|
452
|
+
os: osLabel(self?.node?.platform, self?.node?.release),
|
|
453
|
+
uptimeSec: overviewByNode[peer.name]?.uptimeSec ?? null,
|
|
454
|
+
nodePid: overviewByNode[peer.name]?.nodePid ?? self?.node?.pid ?? null,
|
|
455
|
+
nodeTools: overviewByNode[peer.name]?.nodeTools ?? null,
|
|
456
|
+
tasksActive: overviewByNode[peer.name]?.tasksActive ?? null,
|
|
457
|
+
detectedAt: new Date().toISOString(),
|
|
458
|
+
tunnel: tunnelOf(peer.url || ''),
|
|
459
|
+
plugin: self?.plugin || '',
|
|
460
|
+
error: peer.error?.code || null
|
|
461
|
+
}
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
// 2026-11-10:LAN 链路要显示 IP(不是主机名)→ 对域名型 tunnel.host 做一次 DNS 解析(800ms 超时,失败则回落主机名)
|
|
465
|
+
function isIpLiteral(host) {
|
|
466
|
+
return /^\\d{1,3}(\\.\\d{1,3}){3}$/.test(host) || /^\\[?[0-9a-f:]+\\]?$/i.test(host)
|
|
467
|
+
}
|
|
468
|
+
await Promise.all(peers.map(async (peer) => {
|
|
469
|
+
const tunnel = peer.tunnel || {}
|
|
470
|
+
if (!tunnel.host || isIpLiteral(tunnel.host)) return
|
|
471
|
+
try {
|
|
472
|
+
const resolved = await Promise.race([
|
|
473
|
+
dnsLookup(tunnel.host, { family: 4 }),
|
|
474
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("dns-timeout")), 800))
|
|
475
|
+
])
|
|
476
|
+
if (resolved && resolved.address) tunnel.ip = resolved.address
|
|
477
|
+
} catch { /* 解析失败则保持主机名 */ }
|
|
478
|
+
}))
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
ok: true,
|
|
482
|
+
generatedAt,
|
|
483
|
+
repo,
|
|
484
|
+
nodeName: localConfig.name || '',
|
|
485
|
+
meshVersion: mesh.meshVersion || null,
|
|
486
|
+
verdict: mesh.verdict || 'unknown',
|
|
487
|
+
elapsedMs: mesh.elapsedMs ?? null,
|
|
488
|
+
endpoint: mesh.endpoint || null,
|
|
489
|
+
local: { instances: localInstances },
|
|
490
|
+
hardware,
|
|
491
|
+
frp,
|
|
492
|
+
localTunnel: {
|
|
493
|
+
bind: localConfig.bind || '',
|
|
494
|
+
port: localConfig.port || null,
|
|
495
|
+
tls: Boolean(localConfig.tls?.enabled),
|
|
496
|
+
frpRunning: Boolean(frp.running),
|
|
497
|
+
frpcCount: frp.frpcCount || 0,
|
|
498
|
+
tunnels: frpTunnels,
|
|
499
|
+
sakuraTunnels: frpTunnels,
|
|
500
|
+
selfHosted: selfTunnel,
|
|
501
|
+
mainSource: selfTunnel ? 'self-hosted' : (sakuraTunnel ? 'sakura' : 'none'),
|
|
502
|
+
publicEntry: meshTunnel
|
|
503
|
+
},
|
|
504
|
+
peers,
|
|
505
|
+
remoteInstances: mesh.remoteInstances || [],
|
|
506
|
+
versionMismatch: mesh.versionMismatch || [],
|
|
507
|
+
issues: mesh.issues || [],
|
|
508
|
+
hints: mesh.hints || [],
|
|
509
|
+
frpFlaps: mesh.frpFlaps ?? null,
|
|
510
|
+
tunnels: mesh.tunnels || null,
|
|
511
|
+
delegations: delegationSummary(),
|
|
512
|
+
alerts: alertSummary()
|
|
513
|
+
}
|
|
459
514
|
}
|