dsh-mesh-ui 0.0.13 → 0.0.15

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/README.md CHANGED
@@ -1,9 +1,19 @@
1
1
  # dsh-mesh-ui
2
2
 
3
- DSH settings-page panel for `dsh-mesh`: AI network topology, peer health, and delegation panel.
3
+ DSH left-sidebar page for a `dsh-mesh` AI network: topology, peer health, local instances, delegation ledger and alerts. Public addresses (IP/domain) are hidden by default; LAN addresses remain visible.
4
+
5
+ ## Install
4
6
 
5
7
  ```sh
6
8
  dsh plugin --profile web add dsh-mesh-ui
7
9
  ```
8
10
 
11
+ This installs `dsh-mesh-node` as a dependency. The page is read-only; it reads the local `~/.dsh-mesh` mesh state and never uploads or hardcodes peer addresses.
12
+
13
+ To make this DSH instance a mesh node, also install the host bridge:
14
+
15
+ ```sh
16
+ dsh plugin --profile web add dsh-mesh-host
17
+ ```
18
+
9
19
  See https://github.com/LinYanZhi/dsh-mesh
package/cordis.patch.yml CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-mesh-ui: settings-page panel
1
+ # dsh-mesh-ui: left-sidebar AI network page
2
2
  - insert:
3
3
  - id: dsh-mesh-ui
4
4
  name: dsh-mesh-ui
package/lib/client.js CHANGED
@@ -119,6 +119,37 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
119
119
  function clampNum(v, min, max) { return Math.max(min, Math.min(max, v)) }
120
120
  // [layout-helpers-end]
121
121
  // [relay-helpers-begin] 2026-11-09 架构层:识别公网中转站(frps)与通信方式(纯函数,可单测)
122
+ // 2026-11-10 隐私:默认不展示公网 IP/域名;局域网/回环地址保留。
123
+ function isPrivateNetworkHost(raw) {
124
+ var h = String(raw || '').trim().toLowerCase().replace(/^\[|\]$/g, '')
125
+ h = h.replace(/^https?:\/\//, '').split('/')[0].replace(/:\d+$/, '')
126
+ if (!h) return true
127
+ if (h === 'localhost' || h === '::1' || h === '0.0.0.0' || h === '127.0.0.1') return true
128
+ if (/(\.local|\.lan|\.home|\.internal)$/.test(h)) return true
129
+ var m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
130
+ if (m) {
131
+ var a = Number(m[1]); var b = Number(m[2])
132
+ return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31)
133
+ }
134
+ return false
135
+ }
136
+ function maskHost(raw) {
137
+ var s = String(raw || '').trim()
138
+ if (!s) return '—'
139
+ var port = ''
140
+ var m = /^(.*):(\d+)$/.exec(s)
141
+ if (m && m[1].indexOf(']') < 0) { s = m[1]; port = ':' + m[2] }
142
+ return (isPrivateNetworkHost(s) ? s : '公网地址已隐藏') + port
143
+ }
144
+ function maskText(value) {
145
+ if (typeof value !== 'string') return value
146
+ return value
147
+ .replace(/https?:\/\/[^\s"'<>]+/gi, function (u) {
148
+ try { var x = new URL(u); return isPrivateNetworkHost(x.hostname) ? u : (x.protocol + '//公网地址已隐藏' + (x.port ? ':' + x.port : '')) } catch (e) { return u }
149
+ })
150
+ .replace(/\b(?!10\.|192\.168\.|127\.|172\.(?:1[6-9]|2\d|3[01])\.)(?:\d{1,3}\.){3}\d{1,3}\b/g, '公网地址已隐藏')
151
+ .replace(/\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|net|org|io|dev|cn|top|xyz|me|cc|app|cloud)(?::\d+)?\b/gi, '公网地址已隐藏')
152
+ }
122
153
  // 判定:同一公网 host 被 >=2 个 peer 使用 → 视为共享中转站;LAN/loopback 不算。
123
154
  var RELAY_PUBLIC_KINDS = { wan: 1, frp: 1 }
124
155
  function relayGroups(peers) {
@@ -149,12 +180,12 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
149
180
  if (!host) return { text: '—', short: '—' }
150
181
  var relay = relayHosts && relayHosts[host]
151
182
  var port = t.port ? ':' + t.port : ''
152
- if (relay) return { text: (relay.kind === 'frp' ? '域名穿透' : '自建 frps 中转') + ' · ' + relay.host + port, short: '中转' + (t.port ? ' ' + t.port : '') }
183
+ if (relay) return { text: (relay.kind === 'frp' ? '域名穿透' : '自建 frps 中转') + ' · ' + maskHost(relay.host) + port, short: '中转' + (t.port ? ' ' + t.port : '') }
153
184
  if (t.kind === 'loopback') return { text: '本机回环', short: '本机' }
154
- if (t.kind === 'lan') return { text: '局域网直连 · ' + t.host + port, short: 'LAN' }
155
- if (t.kind === 'frp') return { text: '公网穿透 · ' + t.host + port, short: '穿透' }
156
- if (t.kind === 'wan') return { text: '公网直连 · ' + t.host + port, short: '公网' }
157
- return { text: (t.label || t.kind || '未知') + ' · ' + t.host + port, short: t.label || '未知' }
185
+ if (t.kind === 'lan') return { text: '局域网直连 · ' + maskHost(t.host) + port, short: 'LAN' }
186
+ if (t.kind === 'frp') return { text: '公网穿透 · ' + maskHost(t.host) + port, short: '穿透' }
187
+ if (t.kind === 'wan') return { text: '公网直连 · ' + maskHost(t.host) + port, short: '公网' }
188
+ return { text: (t.label || t.kind || '未知') + ' · ' + maskHost(t.host) + port, short: t.label || '未知' }
158
189
  }
159
190
  // [relay-helpers-end]
160
191
  var W = 560
@@ -643,7 +674,7 @@ body.light .dm-edge-label { stroke: rgba(255,255,255,.92); }
643
674
  return h('div', { className: 'dm-topo-wrap' },
644
675
  h('div', { className: 'dm-topbar' },
645
676
  h('div', { className: 'dm-filters' },
646
- [['lan', '局域网'], ['public', '阿里云'], ['sakura', '樱花穿透']].map(function (it) {
677
+ [['lan', '局域网'], ['public', '公网 frps'], ['sakura', '第三方穿透']].map(function (it) {
647
678
  var tr = TRANSPORTS[it[0]]
648
679
  return h('label', { key: it[0], className: 'dm-flt' + (showFilters[it[0]] ? ' on' : ''), style: { '--c': tr.color } },
649
680
  h('input', { type: 'checkbox', checked: showFilters[it[0]], onChange: function () { toggleFilter(it[0]) } }),
@@ -767,7 +798,7 @@ body.light .dm-edge-label { stroke: rgba(255,255,255,.92); }
767
798
  h('div', { className: 'dm-relay-link' }, h('span', { className: 'dm-relay-line' }), h('span', { className: 'dm-relay-dirlabel' }, '出站')),
768
799
  h('div', { className: 'dm-relay-hub' },
769
800
  h('div', { className: 'dm-relay-name' }, '公网中转站 · frps'),
770
- h('div', { className: 'dm-relay-sub dm-mono' }, g.host),
801
+ h('div', { className: 'dm-relay-sub dm-mono' }, maskHost(g.host)),
771
802
  h('div', { className: 'dm-relay-chips' },
772
803
  ports.map(function (p) { return h('span', { className: 'dm-chip', key: p, title: '隧道入口 :' + p }, ':' + p + ' ×' + g.ports[p]) }),
773
804
  h('span', { className: 'dm-chip ' + (g.up === g.peers.length ? 'run' : 'bad') }, '在线 ' + g.up + '/' + g.peers.length)
@@ -788,8 +819,8 @@ body.light .dm-edge-label { stroke: rgba(255,255,255,.92); }
788
819
  h('span', { className: 'dm-k' }, '樱花隧道:'),
789
820
  h('span', { className: 'dm-chip ' + (sakuraAccount.online === sakuraAccount.total ? 'run' : 'bad') }, sakuraAccount.online + '/' + sakuraAccount.total + ' 在线'),
790
821
  (sakuraAccount.tunnels || []).map(function (t) {
791
- return h('span', { className: 'dm-chip ' + (t.online ? 'run' : 'bad'), key: 'st' + t.id, title: (t.nodeName || '') + ' ' + t.addr + (t.online ? '' : '(离线)') },
792
- (t.deviceKey || t.name) + ' ' + t.addr)
822
+ return h('span', { className: 'dm-chip ' + (t.online ? 'run' : 'bad'), key: 'st' + t.id, title: (t.nodeName || '') + ' ' + maskText(t.addr) + (t.online ? '' : '(离线)') },
823
+ (t.deviceKey || t.name) + ' ' + maskText(t.addr))
793
824
  })
794
825
  ) : null,
795
826
  direct.length ? h('div', { className: 'dm-relay-direct' },
@@ -858,22 +889,22 @@ function deviceLinkState(data, peer, lanMap) {
858
889
  var selfHost = lt.selfHosted || {}
859
890
  var entry = lt.publicEntry || selfHost || {}
860
891
  var localPort = entry.port || selfHost.port || null
861
- var localHost = entry.host || selfHost.host || "relay.example.com"
892
+ var localHost = entry.host || selfHost.host || ''
862
893
  var localKey = peerDeviceKey(data.nodeName)
863
894
  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) {
864
895
  var tu = p.tunnel || {}
865
- 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' }
896
+ 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 || '' }
866
897
  }))
867
898
  var aliyunRows = devs.filter(function (d) { return d.port }).map(function (d) {
868
899
  return h('div', { key: d.devKey, className: 'dm-device-row' },
869
900
  h('span', { className: 'dm-device-k' }, d.name),
870
- h('span', { className: 'dm-device-v' }, (d.up ? '✅ ' : '❌ ') + (d.host || 'relay.example.com') + ':' + d.port)
901
+ h('span', { className: 'dm-device-v' }, (d.up ? '✅ ' : '❌ ') + maskHost(d.host) + ':' + d.port)
871
902
  )
872
903
  })
873
904
  var tunnelRows = acc ? (acc.tunnels || []).map(function (x, i) {
874
905
  return h('div', { key: 'st' + i, className: 'dm-device-row' },
875
906
  h('span', { className: 'dm-device-k' }, (x.deviceKey ? nodeDisplay(peerDeviceKey(x.deviceKey)) : (x.name || '未归类'))),
876
- h('span', { className: 'dm-device-v' }, (x.online ? '✅ ' : '❌ ') + (x.addr || ''))
907
+ h('span', { className: 'dm-device-v' }, (x.online ? '✅ ' : '❌ ') + maskText(x.addr || ''))
877
908
  )
878
909
  }) : null
879
910
  if (!aliyunRows.length && !tunnelRows) return null
@@ -881,19 +912,19 @@ function deviceLinkState(data, peer, lanMap) {
881
912
  h('div', { className: 'dm-infra-card' },
882
913
  h('div', { className: 'dm-infra-head' },
883
914
  h('span', { className: 'dm-dot ok', style: { background: TRANSPORTS.public.color } }),
884
- h('span', null, '阿里云服务器 · ECS'),
915
+ h('span', null, '公网 frps 中转'),
885
916
  h('span', { className: 'dm-sub', style: { marginLeft: 'auto' } }, '自建 frps')
886
917
  ),
887
- h('div', { className: 'dm-sub dm-mono', style: { marginBottom: 4 } }, 'relay.example.com · 隧道 ' + (devs.filter(function (d) { return d.port }).map(function (d) { return d.port }).sort().join(' / ') || '—')),
918
+ h('div', { className: 'dm-sub dm-mono', style: { marginBottom: 4 } }, '隧道 ' + (devs.filter(function (d) { return d.port }).map(function (d) { return d.port }).sort().join(' / ') || '—')),
888
919
  aliyunRows
889
920
  ),
890
921
  acc ? h('div', { className: 'dm-infra-card' },
891
922
  h('div', { className: 'dm-infra-head' },
892
923
  h('span', { className: 'dm-dot', style: { background: TRANSPORTS.sakura.color } }),
893
- h('span', null, '樱花穿透服务器'),
924
+ h('span', null, '第三方 frps 穿透'),
894
925
  h('span', { className: 'dm-sub', style: { marginLeft: 'auto' } }, acc.online + '/' + acc.total + ' 在线')
895
926
  ),
896
- h('div', { className: 'dm-sub dm-mono', style: { marginBottom: 4 } }, 'relay.example.com / relay.example.com / relay.example.com'),
927
+ h('div', { className: 'dm-sub dm-mono', style: { marginBottom: 4 } }, '公网地址已隐藏'),
897
928
  tunnelRows
898
929
  ) : null
899
930
  )
@@ -985,7 +1016,7 @@ function deviceLinkState(data, peer, lanMap) {
985
1016
  var src = localTunnel.mainSource || (entry && entry.source === 'self-hosted-frps' ? 'self-hosted' : '')
986
1017
  if (entry && entry.host) {
987
1018
  var tag = src === 'self-hosted' ? '(自建 frps)' : (src === 'sakura' ? '(Sakura 备用)' : '')
988
- return entry.host + ':' + entry.port + tag
1019
+ return maskHost(entry.host) + ':' + entry.port + tag
989
1020
  }
990
1021
  if (localTunnel.frpRunning) return 'SakuraFrp 已开 · ' + (localTunnel.bind || '?') + ':' + localTunnel.port
991
1022
  if (localTunnel.port) return '未开穿透 · ' + (localTunnel.bind || '?') + ':' + localTunnel.port
@@ -994,7 +1025,7 @@ function deviceLinkState(data, peer, lanMap) {
994
1025
  function peerDomain(peer) {
995
1026
  var t = peer.tunnel || {}
996
1027
  if (!t.host) return '—'
997
- return t.host + (t.port ? ':' + t.port : '') + (t.kind === 'lan' ? '(内网)' : '')
1028
+ return maskHost(t.host) + (t.port ? ':' + t.port : '') + (t.kind === 'lan' ? '(内网)' : '')
998
1029
  }
999
1030
 
1000
1031
  // LAN peer 单独收集(dedupe 后可能被更快的 frp 记录覆盖,但 LAN 线仍要画)
@@ -1039,8 +1070,8 @@ function deviceLinkState(data, peer, lanMap) {
1039
1070
  var sakuraAcc = sakuraAccountOf(data)
1040
1071
  var aliyunPorts = peers.map(function (p) { return isLanPeer(p) ? null : (p.tunnel || {}).port }).filter(Boolean)
1041
1072
  var infra = [
1042
- { key: 'server-aliyun', transport: 'public', name: '阿里云服务器 · ECS', host: 'relay.example.com', color: TRANSPORTS.public.color, detail: '隧道 ' + (aliyunPorts.length ? aliyunPorts.sort().join('/') : '—'), up: true },
1043
- { key: 'server-sakura', transport: 'sakura', name: '樱花穿透服务器', host: 'relay.example.com / relay.example.com / relay.example.com', color: TRANSPORTS.sakura.color, detail: sakuraAcc ? (sakuraAcc.online + '/' + sakuraAcc.total + ' 在线') : '未采集', up: Boolean(sakuraAcc) },
1073
+ { key: 'server-aliyun', transport: 'public', name: '公网 frps 中转', host: '公网地址已隐藏', color: TRANSPORTS.public.color, detail: '隧道 ' + (aliyunPorts.length ? aliyunPorts.sort().join('/') : '—'), up: true },
1074
+ { key: 'server-sakura', transport: 'sakura', name: '第三方 frps 穿透', host: '公网地址已隐藏', color: TRANSPORTS.sakura.color, detail: sakuraAcc ? (sakuraAcc.online + '/' + sakuraAcc.total + ' 在线') : '未采集', up: Boolean(sakuraAcc) },
1044
1075
  ]
1045
1076
  // 2026-11-09-v8:服务器默认位置贴住扇形最高一排设备(用户要求缩小服务器↔设备的垂直间隔)
1046
1077
  function infraDefaultPos(idx) {
@@ -1146,13 +1177,13 @@ function deviceLinkState(data, peer, lanMap) {
1146
1177
  var pa = layout.positions[i]
1147
1178
  if (n.local) {
1148
1179
  // 主机自己的两条上级链路:自建 frps(阿里云) + 本机樱花隧道
1149
- if (show.public && localPub) svgEls = svgEls.concat(curve('pub-local', pa, aliyunPos, true, TRANSPORTS.public, localPub.host + ':' + localPub.port, sizeOf('local'), aliyunSize, edgeRelated('local', 'server-aliyun')))
1150
- if (show.sakura && localSak) svgEls = svgEls.concat(curve('sak-local', pa, sakuraPos, localSak.online === true, TRANSPORTS.sakura, localSak.addr || '', sizeOf('local'), sakuraSize, edgeRelated('local', 'server-sakura')))
1180
+ if (show.public && localPub) svgEls = svgEls.concat(curve('pub-local', pa, aliyunPos, true, TRANSPORTS.public, maskHost(localPub.host) + ':' + localPub.port, sizeOf('local'), aliyunSize, edgeRelated('local', 'server-aliyun')))
1181
+ if (show.sakura && localSak) svgEls = svgEls.concat(curve('sak-local', pa, sakuraPos, localSak.online === true, TRANSPORTS.sakura, maskText(localSak.addr || ''), sizeOf('local'), sakuraSize, edgeRelated('local', 'server-sakura')))
1151
1182
  return
1152
1183
  }
1153
1184
  var sizeN = sizeOf(n.key)
1154
- if (show.public) svgEls = svgEls.concat(curve('pub' + i, pa, aliyunPos, n.publicUp, TRANSPORTS.public, 'relay.example.com' + ((n.tunnel && n.tunnel.port) ? ':' + n.tunnel.port : ''), sizeN, aliyunSize, edgeRelated(n.key, 'server-aliyun')))
1155
- if (show.sakura && n.sakuraTunnel) svgEls = svgEls.concat(curve('sak' + i, pa, sakuraPos, n.sakuraUp === true, TRANSPORTS.sakura, n.sakuraTunnel.addr || '', sizeN, sakuraSize, edgeRelated(n.key, 'server-sakura')))
1185
+ if (show.public) svgEls = svgEls.concat(curve('pub' + i, pa, aliyunPos, n.publicUp, TRANSPORTS.public, '公网 frps' + ((n.tunnel && n.tunnel.port) ? ':' + n.tunnel.port : ''), sizeN, aliyunSize, edgeRelated(n.key, 'server-aliyun')))
1186
+ if (show.sakura && n.sakuraTunnel) svgEls = svgEls.concat(curve('sak' + i, pa, sakuraPos, n.sakuraUp === true, TRANSPORTS.sakura, maskText(n.sakuraTunnel.addr || ''), sizeN, sakuraSize, edgeRelated(n.key, 'server-sakura')))
1156
1187
  if (show.lan && n.lanUp) { var lt = n.lanTunnel || {}; var lanAddr = (lt.ip || lt.host || 'LAN') + (lt.port ? ':' + lt.port : ''); svgEls = svgEls.concat(curve('lan' + i, layout.positions[localNodeIdx], pa, n.lanUp, TRANSPORTS.lan, lanAddr, sizeOf('local'), sizeN, edgeRelated('local', n.key))) }
1157
1188
  })
1158
1189
  return h('div', null,
@@ -1239,16 +1270,16 @@ function deviceLinkState(data, peer, lanMap) {
1239
1270
  var localTunnelText = null
1240
1271
  var tunnels = localTunnel.tunnels || []
1241
1272
  var entry = localTunnel.publicEntry || null
1242
- var tunnelNames = tunnels.map(function (x) { return x.name + '→' + x.host + ':' + x.port }).join(' / ')
1273
+ var tunnelNames = tunnels.map(function (x) { return x.name + '→' + maskHost(x.host) + ':' + x.port }).join(' / ')
1243
1274
  var mainSrc = localTunnel.mainSource || (entry && entry.source === 'self-hosted-frps' ? 'self-hosted' : (entry ? 'sakura' : 'none'))
1244
1275
  var entryText = entry && entry.host
1245
- ? entry.host + ':' + entry.port + (mainSrc === 'self-hosted' ? '(自建 frps · 主用)' : (mainSrc === 'sakura' ? '(Sakura · 备用)' : ''))
1276
+ ? maskHost(entry.host) + ':' + entry.port + (mainSrc === 'self-hosted' ? '(自建 frps · 主用)' : (mainSrc === 'sakura' ? '(Sakura · 备用)' : ''))
1246
1277
  : '未配置'
1247
1278
  var localTunnelRows = [
1248
1279
  ['入口', entryText],
1249
1280
  ['监听 / frpc', (localTunnel.bind || '?') + ':' + (localTunnel.port || '?') + (localTunnel.tls ? ' (TLS)' : '') + ' · ' + (localTunnel.frpRunning ? 'frpc ×' + (localTunnel.frpcCount || 1) : 'frpc 未运行')]
1250
1281
  ]
1251
- if (tunnels.length) localTunnelRows.push(['Sakura 备用', tunnels.map(function (x) { return x.host + ':' + x.port }).join(' / ')])
1282
+ if (tunnels.length) localTunnelRows.push(['Sakura 备用', tunnels.map(function (x) { return maskHost(x.host) + ':' + x.port }).join(' / ')])
1252
1283
  if (mainSrc === 'self-hosted' && localTunnel.selfHosted && localTunnel.selfHosted.configPath) localTunnelRows.push(['frpc 配置', localTunnel.selfHosted.configPath])
1253
1284
  var localTunnelAll = tunnelNames
1254
1285
  var memText = hw.memTotalGB ? ((hw.memFreeGB != null ? hw.memFreeGB : '?') + ' / ' + hw.memTotalGB + ' GB') : '—'
@@ -1444,6 +1475,61 @@ function deviceLinkState(data, peer, lanMap) {
1444
1475
  )
1445
1476
  }
1446
1477
 
1478
+ // 2026-11-10:局域网首次接入(扫描 / 生成配对码 / 配对)。
1479
+ function LanOnboarding() {
1480
+ var sNodes = useState(null); var nodes = sNodes[0]; var setNodes = sNodes[1]
1481
+ var sBusy = useState(false); var busy = sBusy[0]; var setBusy = sBusy[1]
1482
+ var sMsg = useState(''); var msg = sMsg[0]; var setMsg = sMsg[1]
1483
+ var sCode = useState(''); var code = sCode[0]; var setCode = sCode[1]
1484
+ var sMine = useState(null); var mine = sMine[0]; var setMine = sMine[1]
1485
+
1486
+ function scan() {
1487
+ setBusy(true); setMsg('')
1488
+ fetch('/api/dsh-mesh/lan/scan').then(function (r) { return r.json() }).then(function (j) {
1489
+ if (!j.ok) { setMsg(j.error || '扫描失败'); return }
1490
+ setNodes(j.nodes || [])
1491
+ setMsg((j.nodes || []).length ? '发现 ' + j.nodes.length + ' 个节点' : '没有发现局域网节点(对方需 bind 0.0.0.0 并放行 8124)')
1492
+ }).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
1493
+ }
1494
+ function myCode() {
1495
+ setBusy(true); setMine(null); setMsg('')
1496
+ fetch('/api/dsh-mesh/lan/code').then(function (r) { return r.json() }).then(function (j) {
1497
+ if (!j.ok) { setMsg(j.error || '生成配对码失败'); return }
1498
+ setMine(j)
1499
+ }).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
1500
+ }
1501
+ function pairWith(node) {
1502
+ var c = String(code || '').trim()
1503
+ if (!c) { setMsg('先填入对方显示的配对码'); return }
1504
+ setBusy(true); setMsg('')
1505
+ fetch('/api/dsh-mesh/lan/pair', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: String(node.url || '').replace(/\/mcp$/, ''), code: c, name: (node.node || 'lan') + '-lan', fingerprint: node.certFingerprint || '' }) })
1506
+ .then(function (r) { return r.json() }).then(function (j) {
1507
+ setMsg(j.ok ? '配对成功:' + (node.node || '') + ',稍后刷新网络' : ('配对失败:' + (j.stderr || j.error || j.stdout || '').slice(0, 300)))
1508
+ }).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
1509
+ }
1510
+ return h('div', null,
1511
+ h('div', { className: 'dm-lan-row' },
1512
+ h('button', { className: 'dm-btn', disabled: busy, onClick: scan }, busy ? '处理中…' : '扫描局域网'),
1513
+ h('button', { className: 'dm-btn', disabled: busy, onClick: myCode }, '生成我的配对码'),
1514
+ h('span', { className: 'dm-sub' }, '对方的 mesh 需 bind 0.0.0.0;第一次可能需放行 8124')
1515
+ ),
1516
+ msg ? h('div', { className: 'dm-sub', style: { marginTop: 6 } }, msg) : null,
1517
+ mine ? h('div', { className: 'dm-lan-code', style: { marginTop: 8 } }, '配对码 ' + mine.pairingCode + ' · ' + (mine.endpoint || '') + (mine.certFingerprint ? ' · 指纹 ' + mine.certFingerprint : '')) : null,
1518
+ nodes && nodes.length ? h('div', { style: { marginTop: 8 } },
1519
+ nodes.map(function (node, i) {
1520
+ return h('div', { className: 'dm-lan-node', key: i },
1521
+ h('span', { className: 'dm-dot ok' }),
1522
+ h('span', null, (node.node || '?') + ' · ' + (node.url || '').replace('/mcp', '')),
1523
+ h('button', { className: 'dm-btn primary', disabled: busy, onClick: function () { pairWith(node) } }, '用此节点配对')
1524
+ )
1525
+ })
1526
+ ) : null,
1527
+ nodes && nodes.length ? h('div', { className: 'dm-lan-row' },
1528
+ h('input', { placeholder: '对方显示的配对码,如 H5K4-9RXV', value: code, onChange: function (e) { setCode(e.target.value) } })
1529
+ ) : null
1530
+ )
1531
+ }
1532
+
1447
1533
  function MeshStatusSection() {
1448
1534
  var s0 = useState(null); var data = s0[0]; var setData = s0[1]
1449
1535
  var s1 = useState(true); var loading = s1[0]; var setLoading = s1[1]
@@ -1605,6 +1691,11 @@ function deviceLinkState(data, peer, lanMap) {
1605
1691
  ),
1606
1692
  error ? h('div', { className: 'dm-err' }, error) : null,
1607
1693
 
1694
+ h('details', { className: 'dm-details dm-lan' },
1695
+ h('summary', null, '局域网发现 / 配对(首次使用)'),
1696
+ h(LanOnboarding)
1697
+ ),
1698
+
1608
1699
  h('div', { className: 'dm-body', style: splitW > 0 ? { '--dm-main-w': splitW + 'px' } : undefined },
1609
1700
  h('div', { className: 'dm-pane' },
1610
1701
  h(Viewport, { data: data, layout: computeLayout(1 + filterSelfPeers(dedupePeers(data.peers || []), data.nodeName).length), selectedKey: selectedKey, onSelect: setSelectedKey }),
@@ -1623,8 +1714,8 @@ function deviceLinkState(data, peer, lanMap) {
1623
1714
  h(Details, { title: '告警历史', badge: ((data.alerts && data.alerts.history) || []).length }, h(AlertList, { data: data })),
1624
1715
  (issues.length || hints.length) ? h(Details, { title: '诊断建议', badge: issues.length + hints.length },
1625
1716
  h('div', null,
1626
- issues.slice(0, 6).map(function (issue, i) { return h('div', { key: 'i' + i, className: 'dm-err', style: { marginTop: 3 } }, '· ' + issue) }),
1627
- hints.slice(0, 6).map(function (hint, i) { return h('div', { key: 'h' + i, className: 'dm-sub', style: { marginTop: 3 } }, '· ' + hint) })
1717
+ issues.slice(0, 6).map(function (issue, i) { return h('div', { key: 'i' + i, className: 'dm-err', style: { marginTop: 3 } }, '· ' + maskText(issue)) }),
1718
+ hints.slice(0, 6).map(function (hint, i) { return h('div', { key: 'h' + i, className: 'dm-sub', style: { marginTop: 3 } }, '· ' + maskText(hint)) })
1628
1719
  )
1629
1720
  ) : null
1630
1721
  )
@@ -2322,6 +2413,13 @@ body[data-ds-dark-theme] .dm-dg-fs-item.active { background:rgba(88,166,255,.28)
2322
2413
  @media (hover:hover) { .dm-width-handle:hover::after { opacity:1; } }
2323
2414
  @media (max-width: 1000px) { .dm-width-handle { display:none; } }
2324
2415
  .dm-body { display:grid; grid-template-columns:minmax(300px, var(--dm-main-w, 62%)) 8px minmax(0,1fr); gap:10px; align-items:start; }
2416
+ .dm-lan { border:1px solid rgba(128,128,128,.25); border-radius:12px; padding:10px 12px; background:rgba(128,128,128,.035); }
2417
+ .dm-lan-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px; }
2418
+ .dm-lan input { font:inherit; font-size:12px; padding:5px 8px; border:1px solid rgba(128,128,128,.35); border-radius:7px; background:transparent; color:inherit; min-width:180px; }
2419
+ .dm-lan-node { display:flex; gap:8px; align-items:center; border-top:1px solid rgba(128,128,128,.16); padding:7px 0; font-size:12px; }
2420
+ .dm-lan-node:first-child { border-top:none; }
2421
+ .dm-lan-code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:12px; padding:6px 8px; border-radius:8px; background:rgba(88,166,255,.1); border:1px solid rgba(88,166,255,.35); word-break:break-all; }
2422
+
2325
2423
  .dm-pane { min-width:0; display:flex; flex-direction:column; gap:12px; }
2326
2424
  .dm-divider { cursor:col-resize; align-self:stretch; min-height:120px; border-radius:4px; position:relative; }
2327
2425
  .dm-divider::before { content:''; position:absolute; top:0; bottom:0; left:3px; width:1px; background:rgba(128,128,128,.28); }
package/lib/index.js CHANGED
@@ -11,8 +11,10 @@
11
11
  //
12
12
  // 归宿:reusable(dsh-mesh/plugin/dsh-mesh-ui/lib/)
13
13
 
14
- import { collect, resolveRepo, meshHome } from './collect.js'
14
+ import { collect, resolveRepo, meshHome, run, nodeExec } from './collect.js'
15
15
  import { listDelegations, sessionHistory, taskStatus } from './delegations.js'
16
+ import { networkInterfaces } from 'node:os'
17
+ import { join } from 'node:path'
16
18
 
17
19
  /** Cordis inject 列表:只需要 webServer。 */
18
20
  export const inject = ['webServer']
@@ -37,6 +39,32 @@ function writeJson(res, status, body) {
37
39
  res.end(JSON.stringify(body, null, 2))
38
40
  }
39
41
 
42
+ function readBody(req, limit = 16 * 1024) {
43
+ return new Promise((resolve) => {
44
+ let size = 0
45
+ const chunks = []
46
+ req.on('data', (chunk) => {
47
+ size += chunk.length
48
+ if (size > limit) { resolve(null); req.destroy(); return }
49
+ chunks.push(chunk)
50
+ })
51
+ req.on('end', () => {
52
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) } catch { resolve(null) }
53
+ })
54
+ req.on('error', () => resolve(null))
55
+ })
56
+ }
57
+
58
+ function lanIPv4() {
59
+ const ifaces = networkInterfaces()
60
+ for (const list of Object.values(ifaces)) {
61
+ for (const item of list || []) {
62
+ if (item.family === 'IPv4' && !item.internal && !/^169\.254\./.test(item.address)) return item.address
63
+ }
64
+ }
65
+ return '127.0.0.1'
66
+ }
67
+
40
68
  /**
41
69
  * Cordis apply:注册只读状态路由 + 后台刷新循环。
42
70
  * @param {import('@deepseek-ai/cordis').Context} ctx host 插件上下文(已注入 webServer)
@@ -158,6 +186,54 @@ export function apply(ctx) {
158
186
  }
159
187
  }
160
188
 
189
+ async function runCli(args, timeout = 30_000) {
190
+ if (!state.repo) return { error: 'dsh-mesh-node is not installed', stdout: '', stderr: '' }
191
+ return run(nodeExec(), [join(state.repo, 'src', 'cli.js'), ...args], { cwd: state.repo, timeout })
192
+ }
193
+
194
+ const lanScanRoute = readonlyRoute('/api/dsh-mesh/lan/scan', async () => {
195
+ const r = await runCli(['discover', '--json', '--timeout-ms', '1500'], 20_000)
196
+ if (r.error) return { ok: false, error: r.error, stderr: r.stderr }
197
+ try {
198
+ const data = JSON.parse(r.stdout)
199
+ return { ok: true, localAddress: lanIPv4(), nodes: data.nodes || [] }
200
+ } catch {
201
+ return { ok: false, error: 'invalid discover output', stdout: r.stdout, stderr: r.stderr }
202
+ }
203
+ })
204
+
205
+ const lanCodeRoute = readonlyRoute('/api/dsh-mesh/lan/code', async () => {
206
+ const host = `${lanIPv4()}:8124`
207
+ const r = await runCli(['pair', '--code', '--ttl', '600', '--role', 'node', '--host', host])
208
+ if (r.error) return { ok: false, error: r.error, stderr: r.stderr }
209
+ try {
210
+ return { ok: true, ...JSON.parse(r.stdout) }
211
+ } catch {
212
+ return { ok: false, error: 'invalid pair output', stdout: r.stdout, stderr: r.stderr }
213
+ }
214
+ })
215
+
216
+ const lanPairRoute = {
217
+ kind: 'exact',
218
+ path: '/api/dsh-mesh/lan/pair',
219
+ handler: async (req, res) => {
220
+ if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
221
+ if ((req.method || 'GET').toUpperCase() !== 'POST') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
222
+ const body = await readBody(req)
223
+ if (!body || typeof body.url !== 'string' || typeof body.code !== 'string') {
224
+ return writeJson(res, 400, { ok: false, error: 'url and code required' })
225
+ }
226
+ const args = ['peer', 'add', '--url', String(body.url), '--code', String(body.code), '--name', String(body.name || 'lan-peer')]
227
+ if (body.fingerprint) args.push('--fingerprint', String(body.fingerprint))
228
+ try {
229
+ const r = await runCli(args)
230
+ writeJson(res, 200, { ok: !r.error, error: r.error, stdout: r.stdout, stderr: r.stderr })
231
+ } catch (error) {
232
+ writeJson(res, 500, { ok: false, error: String(error?.message || error) })
233
+ }
234
+ }
235
+ }
236
+
161
237
  const delegateRoutes = [
162
238
  readonlyRoute('/api/dsh-mesh/delegations', async (url) => {
163
239
  // 缓存整份列表(与请求的 limit 无关),避免不同 limit 的请求互相污染缓存
@@ -187,6 +263,7 @@ export function apply(ctx) {
187
263
 
188
264
  ctx.effect(() => {
189
265
  const disposeRoute = ctx.webServer.register(route)
266
+ const disposeLanRoutes = [lanScanRoute, lanCodeRoute, lanPairRoute].map((item) => ctx.webServer.register(item))
190
267
  const disposeRoutes = delegateRoutes.map((item) => ctx.webServer.register(item))
191
268
  return () => {
192
269
  try {
@@ -194,7 +271,7 @@ export function apply(ctx) {
194
271
  } catch {
195
272
  /* ignore */
196
273
  }
197
- for (const dispose of disposeRoutes) { try { dispose() } catch { /* ignore */ } }
274
+ for (const dispose of disposeLanRoutes.concat(disposeRoutes)) { try { dispose() } catch { /* ignore */ } }
198
275
  if (typeof disposeRoute === 'function') disposeRoute()
199
276
  }
200
277
  }, 'dsh-mesh-ui: GET /api/dsh-mesh/status + background refresh')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mesh-ui",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "DSH sidebar page for dsh-mesh: AI network topology, peer health, and delegation panel. Read-only; data is collected from the installed dsh-mesh-node package.",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  }
29
29
  },
30
30
  "dependencies": {
31
- "dsh-mesh-node": "0.0.12"
31
+ "dsh-mesh-node": "0.0.13"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0",