dsh-mesh-ui 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -677,7 +677,7 @@ body.light .dm-edge-label { stroke: rgba(255,255,255,.92); }
677
677
  h('span', null, '⋯')
678
678
  )
679
679
  ),
680
- h('div', { className: 'dm-hint' }, '拓扑 v17 · 内容 ' + Math.round(layout.W) + '×' + Math.round(layout.H) + 'px · 卡 ' + Math.round(layout.cardW) + '×' + Math.round(layout.cardH) + ' · 缩放 ' + Math.round(view.scale * 100) + '% · 滚轮缩放 / 拖拽平移')
680
+ h('div', { className: 'dm-hint' }, '拓扑 v19 · 内容 ' + Math.round(layout.W) + '×' + Math.round(layout.H) + 'px · 卡 ' + Math.round(layout.cardW) + '×' + Math.round(layout.cardH) + ' · 缩放 ' + Math.round(view.scale * 100) + '% · 滚轮缩放 / 拖拽平移')
681
681
  )
682
682
  }
683
683
 
@@ -835,9 +835,11 @@ function sakuraTunnelOf(data, devKey) {
835
835
  function deviceLinkState(data, peer, lanMap) {
836
836
  var dk = peerDeviceKey(peer.name)
837
837
  var t = peer.tunnel || {}
838
- var publicUp = Boolean(peer.up)
838
+ // 2026-11-10:蓝线状态以隧道端口 TCP 探测为准(HTTP /health 对高延迟节点会误判断连)
839
+ var probe = t.probe && typeof t.probe.up === "boolean" ? t.probe : null
840
+ var publicUp = (probe ? probe.up === true : false) || Boolean(peer.up)
839
841
  var lan = lanMap ? lanMap[dk] : (t.kind === 'lan' ? { up: Boolean(peer.up), host: t.host, port: t.port, ip: t.ip || '' } : null)
840
- var lanUp = Boolean(lan && lan.up)
842
+ var lanUp = Boolean(lan && (lan.probe && lan.probe.up === true ? true : lan.up))
841
843
  var st = sakuraTunnelOf(data, dk)
842
844
  var sakuraUp = st ? st.online === true : null
843
845
  var anyUp = publicUp || sakuraUp === true || lanUp
@@ -858,7 +860,7 @@ function deviceLinkState(data, peer, lanMap) {
858
860
  var localKey = peerDeviceKey(data.nodeName)
859
861
  var devs = [{ devKey: localKey, name: nodeDisplay(data.nodeName) + "(本机)", up: true, port: localPort, host: localHost }].concat(peers.filter(function (p) { return !isLanPeer(p) }).map(function (p) {
860
862
  var tu = p.tunnel || {}
861
- return { devKey: peerDeviceKey(p.name), name: nodeDisplay(peerDeviceKey(p.name)), up: Boolean(p.up), port: tu.port || null, host: tu.host || 'relay.example.com' }
863
+ return { devKey: peerDeviceKey(p.name), name: nodeDisplay(peerDeviceKey(p.name)), up: (tu.probe ? Boolean(tu.probe.up) : Boolean(p.up)), port: tu.port || null, host: tu.host || 'relay.example.com' }
862
864
  }))
863
865
  var aliyunRows = devs.filter(function (d) { return d.port }).map(function (d) {
864
866
  return h('div', { key: d.devKey, className: 'dm-device-row' },
@@ -997,7 +999,7 @@ function deviceLinkState(data, peer, lanMap) {
997
999
  var lanMap = {}
998
1000
  filterSelfPeers(data.peers || [], data.nodeName).forEach(function (p) {
999
1001
  var t = p.tunnel || {}
1000
- if (isLanPeer(p)) lanMap[peerDeviceKey(p.name)] = { up: Boolean(p.up), host: t.host, port: t.port, ip: t.ip || '' }
1002
+ if (isLanPeer(p)) lanMap[peerDeviceKey(p.name)] = { up: Boolean(p.up), host: t.host, port: t.port, ip: t.ip || '', probe: t.probe || null }
1001
1003
  })
1002
1004
  var nodes = [{
1003
1005
  key: 'local',
@@ -1254,22 +1256,55 @@ function deviceLinkState(data, peer, lanMap) {
1254
1256
  var p = String(d.path || '').replace(/[\\/]+$/, '')
1255
1257
  return (d.freeGB != null ? d.freeGB : '?') + ' / ' + d.totalGB + ' GB' + (p ? ' (' + p + ')' : '')
1256
1258
  }
1259
+ // 设备类型:按平台/CPU 形态泛化推断(不依赖任何个人设备表)
1260
+ function formFactorOf(h) {
1261
+ if (!h) return '—'
1262
+ if (h.platform === 'linux' && String(h.arch || '').indexOf('arm') === 0) return '手机'
1263
+ var cpu = String(h.cpuModel || '')
1264
+ return cpu ? '主机' : '—'
1265
+ }
1266
+ function memTextOf(h) {
1267
+ return h && h.memTotalGB ? ((h.memFreeGB != null ? h.memFreeGB : '?') + ' / ' + h.memTotalGB + ' GB') : '—'
1268
+ }
1269
+ function cpuTextOf(h) {
1270
+ return h && h.cpuModel ? h.cpuModel + ' · ' + (h.cpuCores || '?') + ' 线程' : '—'
1271
+ }
1272
+ function ipKindOf(peer) {
1273
+ var t = peer.tunnel || {}
1274
+ if (t.kind === 'loopback') return '本机回环'
1275
+ if (t.kind === 'lan') return '局域网直连'
1276
+ if (t.kind === 'frp') return '公网穿透(域名)'
1277
+ if (t.kind === 'wan') return relayHosts[String(t.host || '').toLowerCase()] ? '公网(共享中转)' : '公网 IP 直连'
1278
+ return '—'
1279
+ }
1280
+ function relayKindOf(peer) {
1281
+ var t = peer.tunnel || {}
1282
+ if (t.kind === 'loopback') return '无(本机回环)'
1283
+ if (t.kind === 'lan') return '无(局域网直连)'
1284
+ if (t.kind === 'frp') return 'Sakura / 第三方 frp 穿透'
1285
+ if (t.kind === 'wan') return relayHosts[String(t.host || '').toLowerCase()] ? '自建公网中继(frps)' : '公网 IP 直连'
1286
+ return '—'
1287
+ }
1288
+ function dshVerOf(peer) {
1289
+ var v = peer.version ? 'v' + peer.version : '—'
1290
+ if (peer.plugin) v += ' · 插件 ' + peer.plugin
1291
+ return v
1292
+ }
1257
1293
 
1258
1294
  var devices = [{
1259
1295
  key: 'local',
1260
1296
  name: nodeDisplay(data.nodeName) + '(本机)',
1261
1297
  up: Boolean(endpoint.up),
1262
1298
  rows: [
1299
+ ['设备类型', formFactorOf(hw)],
1263
1300
  ['操作系统', hw.os || '—'],
1264
- ['CPU', cpuText],
1265
- ['内存', memText],
1301
+ ['内存', memTextOf(hw)],
1266
1302
  ['磁盘', diskText(hw.disk)],
1267
- ['DSH 实例', localUp + '/' + instances.length + ' 在线'],
1268
- ['会话 / 运行', sessions + ' / ' + running],
1269
- ['模型', model],
1270
- ...localTunnelRows,
1271
- ['端点', (endpoint.pid != null ? 'pid ' + endpoint.pid : '—') + ' · tools ' + (endpoint.tools != null ? endpoint.tools : '—') + ' · mesh ' + (data.meshVersion || '?')],
1272
- ['检测时间', clockText(data.at)]
1303
+ ['CPU', cpuTextOf(hw)],
1304
+ ['DSH 版本', data.meshVersion ? 'v' + data.meshVersion : '—'],
1305
+ ['IP 类型', '本机回环'],
1306
+ ['端口号', localTunnel.port != null ? String(localTunnel.port) : '—'],
1307
+ ['公网穿透类型', mainSrc === 'self-hosted' ? '自建 frps 中继(主用)' : (mainSrc === 'sakura' ? 'Sakura 穿透(备用)' : '无(仅局域网)')]
1273
1308
  ]
1274
1309
  }].concat(peers.map(function (peer) {
1275
1310
  var t = peer.tunnel || {}
@@ -1279,18 +1314,15 @@ function deviceLinkState(data, peer, lanMap) {
1279
1314
  name: nodeDisplay(peerDeviceKey(peer.name)),
1280
1315
  up: Boolean(peer.up),
1281
1316
  rows: [
1317
+ ['设备类型', formFactorOf(hwp)],
1282
1318
  ['操作系统', hwp.os || peer.os || '—'],
1283
- ['CPU', hwp.cpuModel ? hwp.cpuModel + ' · ' + (hwp.cpuCores || '?') + ' 线程' : '—'],
1284
- ['内存', hwp.memTotalGB ? ((hwp.memFreeGB != null ? hwp.memFreeGB : '?') + ' / ' + hwp.memTotalGB + ' GB') : '—'],
1319
+ ['内存', memTextOf(hwp)],
1285
1320
  ['磁盘', diskText(hwp.disk)],
1286
- ['运行时长', uptimeText(peer.uptimeSec)],
1287
- ['DSH 实例', (peer.instancesCount || 0) + ' 个(' + ((peer.instancesList || []).map(function (x) { return x.port + (x.up ? '✓' : '✗') }).join(' ') || '—') + ')'],
1288
- ['会话 / 运行', (peer.sessions || 0) + ' / ' + (peer.running || 0)],
1289
- ['任务(进行中)', peer.tasksActive == null ? '—' : String(peer.tasksActive)],
1290
- ['入口', t.host ? t.host + (t.port ? ':' + t.port : '') : '—'],
1291
- ['链路', commLabel(peer, relayHosts).text + ' · ' + (peer.up ? (peer.latencyMs == null ? '—' : peer.latencyMs + 'ms') : '离线') + ' · ' + (peer.tls ? 'TLS ✓' : 'TLS ✗') + ' · v' + (peer.version || '?')],
1292
- ['节点 pid / tools', (peer.nodePid == null ? '—' : peer.nodePid) + ' / ' + (peer.nodeTools == null ? '—' : peer.nodeTools)],
1293
- ['检测时间', clockText(peer.detectedAt || data.at)]
1321
+ ['CPU', cpuTextOf(hwp)],
1322
+ ['DSH 版本', dshVerOf(peer)],
1323
+ ['IP 类型', ipKindOf(peer)],
1324
+ ['端口号', t.port ? String(t.port) : '—'],
1325
+ ['公网穿透类型', relayKindOf(peer)]
1294
1326
  ]
1295
1327
  }
1296
1328
  }))
@@ -1388,17 +1420,62 @@ function deviceLinkState(data, peer, lanMap) {
1388
1420
  var s2 = useState(''); var error = s2[0]; var setError = s2[1]
1389
1421
  var s3 = useState(0); var tick = s3[0]; var setTick = s3[1]
1390
1422
  var s4 = useState(''); var copied = s4[0]; var setCopied = s4[1]
1423
+ // 2026-11-10:真刷新 —— 点击后等待后台采集完成再渲染;本地冷却 + 忙锁防止连点刷爆请求
1424
+ var s6 = useState(""); var refreshHint = s6[0]; var setRefreshHint = s6[1]
1425
+ var forceRef = useRef({ at: 0, busy: false })
1391
1426
  var s5 = useState('local'); var selectedKey = s5[0]; var setSelectedKey = s5[1]
1392
1427
 
1393
1428
  function load(force) {
1429
+ if (!force) {
1430
+ setLoading(true)
1431
+ fetchStatus(false)
1432
+ .then(function (json) {
1433
+ setData(json)
1434
+ setError(json && json.ok === false && !json.warming ? json.error || "mesh-health error" : "")
1435
+ })
1436
+ .catch(function (err) { setError(String((err && err.message) || err)) })
1437
+ .finally(function () { setLoading(false) })
1438
+ return
1439
+ }
1440
+ var st = forceRef.current
1441
+ var now = Date.now()
1442
+ if (st.busy) return
1443
+ if (now - st.at < 8000) {
1444
+ var waitSec = Math.ceil((8000 - (now - st.at)) / 1000)
1445
+ setRefreshHint("刚采集过," + waitSec + "s 后可再刷新")
1446
+ setTimeout(function () { setRefreshHint("") }, 2200)
1447
+ return
1448
+ }
1449
+ st.busy = true
1450
+ st.at = now
1394
1451
  setLoading(true)
1395
- fetchStatus(Boolean(force))
1396
- .then(function (json) {
1397
- setData(json)
1398
- setError(json && json.ok === false && !json.warming ? json.error || 'mesh-health error' : '')
1452
+ setRefreshHint("采集中…")
1453
+ var prevAt = data && data.at
1454
+ var attempts = 0
1455
+ function poll() {
1456
+ attempts++
1457
+ fetchStatus(false).then(function (json) {
1458
+ var fresh = json && json.refreshing === false && json.at && json.at !== prevAt
1459
+ if (fresh || attempts >= 15) {
1460
+ setData(json)
1461
+ setError(json && json.ok === false && !json.warming ? json.error || "mesh-health error" : "")
1462
+ setLoading(false)
1463
+ st.busy = false
1464
+ setRefreshHint(fresh ? "已刷新" : "无新数据(采集超时)")
1465
+ setTimeout(function () { setRefreshHint("") }, 2000)
1466
+ return
1467
+ }
1468
+ setTimeout(poll, 800)
1469
+ }).catch(function () {
1470
+ st.busy = false
1471
+ setLoading(false)
1472
+ setRefreshHint("刷新失败")
1473
+ setTimeout(function () { setRefreshHint("") }, 2500)
1399
1474
  })
1400
- .catch(function (err) { setError(String((err && err.message) || err)) })
1401
- .finally(function () { setLoading(false) })
1475
+ }
1476
+ fetchStatus(true)
1477
+ .catch(function () { /* continue polling */ })
1478
+ .then(function () { setTimeout(poll, 500) })
1402
1479
  }
1403
1480
 
1404
1481
  useEffect(function () {
@@ -1407,7 +1484,7 @@ function deviceLinkState(data, peer, lanMap) {
1407
1484
  return function () { clearInterval(timer) }
1408
1485
  }, [])
1409
1486
 
1410
- useEffect(function () { if (tick > 0) load(false) }, [tick])
1487
+ useEffect(function () { if (tick > 0 && !forceRef.current.busy) load(false) }, [tick])
1411
1488
 
1412
1489
  function copyJson() {
1413
1490
  var text = JSON.stringify(data || {}, null, 2)
@@ -1438,7 +1515,8 @@ function deviceLinkState(data, peer, lanMap) {
1438
1515
  h('span', { style: { flex: 1 } }),
1439
1516
  h('a', { className: 'dm-btn', href: API, target: '_blank', rel: 'noreferrer', style: { textDecoration: 'none' } }, '原始 JSON'),
1440
1517
  h('button', { className: 'dm-btn', onClick: copyJson }, copied || '复制诊断'),
1441
- h('button', { className: 'dm-btn primary', onClick: function () { load(true) } }, loading ? '刷新中…' : '刷新')
1518
+ h('button', { className: 'dm-btn primary', onClick: function () { load(true) }, title: '立即采集一次并等待新快照(8s 冷却)' }, loading ? '采集中…' : '刷新'),
1519
+ refreshHint ? h('span', { className: 'dm-sub' }, refreshHint) : null
1442
1520
  ),
1443
1521
  error ? h('div', { className: 'dm-err' }, error) : null,
1444
1522
 
package/lib/collect.js CHANGED
@@ -16,6 +16,7 @@
16
16
  import { execFile } from 'node:child_process'
17
17
  import { existsSync, readdirSync, readFileSync, statfsSync, writeFileSync } from 'node:fs'
18
18
  import { lookup as dnsLookup } from 'node:dns/promises'
19
+ import { connect as netConnect } from 'node:net'
19
20
  import { arch as osArch, cpus as osCpus, freemem as osFreemem, homedir, platform as osPlatform, release as osRelease, totalmem as osTotalmem } from 'node:os'
20
21
  import { join } from 'node:path'
21
22
 
@@ -505,6 +506,36 @@ export async function collect(repo) {
505
506
  } catch { /* 解析失败则保持主机名 */ }
506
507
  }))
507
508
 
509
+ // 2026-11-10:为每条 peer 隧道做 TCP 端口探测(阿里云 701x / LAN 8124),面板连线状态以此为准,
510
+ // 而不是用 peer.up(HTTP /health 探测,高延迟节点偶发超时会造成"隧道通却显示断连"的误判)
511
+ function tcpProbe(host, port, timeoutMs) {
512
+ return new Promise((resolve) => {
513
+ const started = Date.now()
514
+ let done = false
515
+ const socket = netConnect({ host, port })
516
+ const finish = (up, error) => {
517
+ if (done) return
518
+ done = true
519
+ try { socket.destroy() } catch { /* ignore */ }
520
+ resolve({ up, latencyMs: Date.now() - started, error: up ? null : error || "timeout" })
521
+ }
522
+ socket.setTimeout(timeoutMs)
523
+ socket.once("connect", () => finish(true, null))
524
+ socket.once("timeout", () => finish(false, "timeout"))
525
+ socket.once("error", (err) => finish(false, err?.code || "error"))
526
+ })
527
+ }
528
+ await Promise.all(peers.map(async (peer) => {
529
+ const tunnel = peer.tunnel || {}
530
+ if (!tunnel.host || !tunnel.port) return
531
+ tunnel.probe = await tcpProbe(tunnel.host, Number(tunnel.port), 3000)
532
+ if (!tunnel.probe.up) {
533
+ const retry = await tcpProbe(tunnel.host, Number(tunnel.port), 3000)
534
+ if (retry.up) tunnel.probe = retry
535
+ }
536
+ }))
537
+
538
+
508
539
  return {
509
540
  ok: true,
510
541
  generatedAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mesh-ui",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "DSH settings-page panel for dsh-mesh: AI network topology, peer health, and delegation panel. Read-only; data is collected from the local mesh CLI.",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",