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