@linyanzhi/dsh-mesh 0.1.0 → 0.2.0

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