dsh-mesh-ui 0.0.14 → 0.0.16

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
@@ -120,6 +120,8 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
120
120
  // [layout-helpers-end]
121
121
  // [relay-helpers-begin] 2026-11-09 架构层:识别公网中转站(frps)与通信方式(纯函数,可单测)
122
122
  // 2026-11-10 隐私:默认不展示公网 IP/域名;局域网/回环地址保留。
123
+ // 用户在页头打开「显示公网地址」后,maskHost/maskText 直接返回原值。
124
+ var showPublicAddresses = false
123
125
  function isPrivateNetworkHost(raw) {
124
126
  var h = String(raw || '').trim().toLowerCase().replace(/^\[|\]$/g, '')
125
127
  h = h.replace(/^https?:\/\//, '').split('/')[0].replace(/:\d+$/, '')
@@ -135,6 +137,7 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
135
137
  }
136
138
  function maskHost(raw) {
137
139
  var s = String(raw || '').trim()
140
+ if (showPublicAddresses) return s || '—'
138
141
  if (!s) return '—'
139
142
  var port = ''
140
143
  var m = /^(.*):(\d+)$/.exec(s)
@@ -142,6 +145,7 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
142
145
  return (isPrivateNetworkHost(s) ? s : '公网地址已隐藏') + port
143
146
  }
144
147
  function maskText(value) {
148
+ if (showPublicAddresses) return value
145
149
  if (typeof value !== 'string') return value
146
150
  return value
147
151
  .replace(/https?:\/\/[^\s"'<>]+/gi, function (u) {
@@ -1475,6 +1479,61 @@ function deviceLinkState(data, peer, lanMap) {
1475
1479
  )
1476
1480
  }
1477
1481
 
1482
+ // 2026-11-10:局域网首次接入(扫描 / 生成配对码 / 配对)。
1483
+ function LanOnboarding() {
1484
+ var sNodes = useState(null); var nodes = sNodes[0]; var setNodes = sNodes[1]
1485
+ var sBusy = useState(false); var busy = sBusy[0]; var setBusy = sBusy[1]
1486
+ var sMsg = useState(''); var msg = sMsg[0]; var setMsg = sMsg[1]
1487
+ var sCode = useState(''); var code = sCode[0]; var setCode = sCode[1]
1488
+ var sMine = useState(null); var mine = sMine[0]; var setMine = sMine[1]
1489
+
1490
+ function scan() {
1491
+ setBusy(true); setMsg('')
1492
+ fetch('/api/dsh-mesh/lan/scan').then(function (r) { return r.json() }).then(function (j) {
1493
+ if (!j.ok) { setMsg(j.error || '扫描失败'); return }
1494
+ setNodes(j.nodes || [])
1495
+ setMsg((j.nodes || []).length ? '发现 ' + j.nodes.length + ' 个节点' : '没有发现局域网节点(对方需 bind 0.0.0.0 并放行 8124)')
1496
+ }).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
1497
+ }
1498
+ function myCode() {
1499
+ setBusy(true); setMine(null); setMsg('')
1500
+ fetch('/api/dsh-mesh/lan/code').then(function (r) { return r.json() }).then(function (j) {
1501
+ if (!j.ok) { setMsg(j.error || '生成配对码失败'); return }
1502
+ setMine(j)
1503
+ }).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
1504
+ }
1505
+ function pairWith(node) {
1506
+ var c = String(code || '').trim()
1507
+ if (!c) { setMsg('先填入对方显示的配对码'); return }
1508
+ setBusy(true); setMsg('')
1509
+ 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 || '' }) })
1510
+ .then(function (r) { return r.json() }).then(function (j) {
1511
+ setMsg(j.ok ? '配对成功:' + (node.node || '') + ',稍后刷新网络' : ('配对失败:' + (j.stderr || j.error || j.stdout || '').slice(0, 300)))
1512
+ }).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
1513
+ }
1514
+ return h('div', null,
1515
+ h('div', { className: 'dm-lan-row' },
1516
+ h('button', { className: 'dm-btn', disabled: busy, onClick: scan }, busy ? '处理中…' : '扫描局域网'),
1517
+ h('button', { className: 'dm-btn', disabled: busy, onClick: myCode }, '生成我的配对码'),
1518
+ h('span', { className: 'dm-sub' }, '对方的 mesh 需 bind 0.0.0.0;第一次可能需放行 8124')
1519
+ ),
1520
+ msg ? h('div', { className: 'dm-sub', style: { marginTop: 6 } }, msg) : null,
1521
+ mine ? h('div', { className: 'dm-lan-code', style: { marginTop: 8 } }, '配对码 ' + mine.pairingCode + ' · ' + (mine.endpoint || '') + (mine.certFingerprint ? ' · 指纹 ' + mine.certFingerprint : '')) : null,
1522
+ nodes && nodes.length ? h('div', { style: { marginTop: 8 } },
1523
+ nodes.map(function (node, i) {
1524
+ return h('div', { className: 'dm-lan-node', key: i },
1525
+ h('span', { className: 'dm-dot ok' }),
1526
+ h('span', null, (node.node || '?') + ' · ' + (node.url || '').replace('/mcp', '')),
1527
+ h('button', { className: 'dm-btn primary', disabled: busy, onClick: function () { pairWith(node) } }, '用此节点配对')
1528
+ )
1529
+ })
1530
+ ) : null,
1531
+ nodes && nodes.length ? h('div', { className: 'dm-lan-row' },
1532
+ h('input', { placeholder: '对方显示的配对码,如 H5K4-9RXV', value: code, onChange: function (e) { setCode(e.target.value) } })
1533
+ ) : null
1534
+ )
1535
+ }
1536
+
1478
1537
  function MeshStatusSection() {
1479
1538
  var s0 = useState(null); var data = s0[0]; var setData = s0[1]
1480
1539
  var s1 = useState(true); var loading = s1[0]; var setLoading = s1[1]
@@ -1488,6 +1547,17 @@ function deviceLinkState(data, peer, lanMap) {
1488
1547
  var verdictState = useState(''); var verdictShown = verdictState[0]; var setVerdictShown = verdictState[1]
1489
1548
  var verdictRef = useRef({ raw: '', streak: 0 })
1490
1549
  var s5 = useState('local'); var selectedKey = s5[0]; var setSelectedKey = s5[1]
1550
+ var sPublic = useState(function () {
1551
+ try { return window.localStorage.getItem('dsh-mesh.show-public-addresses') === '1' } catch (error) { return false }
1552
+ })
1553
+ var showPublic = sPublic[0]; var setShowPublic = sPublic[1]
1554
+ showPublicAddresses = showPublic
1555
+ function toggleShowPublic(event) {
1556
+ var next = Boolean(event && event.target && event.target.checked)
1557
+ setShowPublic(next)
1558
+ showPublicAddresses = next
1559
+ try { window.localStorage.setItem('dsh-mesh.show-public-addresses', next ? '1' : '0') } catch (error) { /* ignore */ }
1560
+ }
1491
1561
  // 双栏宽度:拖动分隔条调整并记忆,窄屏由 CSS 媒体查询自动堆叠。
1492
1562
  var dragState = useState(function () {
1493
1563
  try { var saved = Number(window.localStorage.getItem('dsh-mesh-split-w')); return Number.isFinite(saved) && saved > 0 ? saved : 0 } catch (error) { return 0 }
@@ -1629,6 +1699,10 @@ function deviceLinkState(data, peer, lanMap) {
1629
1699
  }, data.warming ? '预热中' : (verdictShown || data.verdict || '?')),
1630
1700
  h('span', { className: 'dm-sub' }, (data.meshVersion ? 'mesh ' + data.meshVersion + ' · ' : '') + (data.at ? ago(data.at) : '')),
1631
1701
  h('span', { style: { flex: 1 } }),
1702
+ h('label', { className: 'dm-toggle', title: '默认隐藏公网 IP/域名;勾选后显示真实地址并记住选择' },
1703
+ h('input', { type: 'checkbox', checked: Boolean(showPublic), onChange: toggleShowPublic }),
1704
+ h('span', null, '显示公网地址')
1705
+ ),
1632
1706
  h('a', { className: 'dm-btn', href: API, target: '_blank', rel: 'noreferrer', style: { textDecoration: 'none' } }, '原始 JSON'),
1633
1707
  h('button', { className: 'dm-btn', onClick: copyJson }, copied || '复制诊断'),
1634
1708
  h('button', { className: 'dm-btn primary', onClick: function () { load(true) }, title: '立即采集一次并等待新快照(8s 冷却)' }, loading ? '采集中…' : '刷新'),
@@ -1636,6 +1710,11 @@ function deviceLinkState(data, peer, lanMap) {
1636
1710
  ),
1637
1711
  error ? h('div', { className: 'dm-err' }, error) : null,
1638
1712
 
1713
+ h('details', { className: 'dm-details dm-lan' },
1714
+ h('summary', null, '局域网发现 / 配对(首次使用)'),
1715
+ h(LanOnboarding)
1716
+ ),
1717
+
1639
1718
  h('div', { className: 'dm-body', style: splitW > 0 ? { '--dm-main-w': splitW + 'px' } : undefined },
1640
1719
  h('div', { className: 'dm-pane' },
1641
1720
  h(Viewport, { data: data, layout: computeLayout(1 + filterSelfPeers(dedupePeers(data.peers || []), data.nodeName).length), selectedKey: selectedKey, onSelect: setSelectedKey }),
@@ -2353,6 +2432,15 @@ body[data-ds-dark-theme] .dm-dg-fs-item.active { background:rgba(88,166,255,.28)
2353
2432
  @media (hover:hover) { .dm-width-handle:hover::after { opacity:1; } }
2354
2433
  @media (max-width: 1000px) { .dm-width-handle { display:none; } }
2355
2434
  .dm-body { display:grid; grid-template-columns:minmax(300px, var(--dm-main-w, 62%)) 8px minmax(0,1fr); gap:10px; align-items:start; }
2435
+ .dm-lan { border:1px solid rgba(128,128,128,.25); border-radius:12px; padding:10px 12px; background:rgba(128,128,128,.035); }
2436
+ .dm-lan-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px; }
2437
+ .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; }
2438
+ .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; }
2439
+ .dm-lan-node:first-child { border-top:none; }
2440
+ .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; }
2441
+ .dm-toggle { display:inline-flex; align-items:center; gap:5px; font-size:11.5px; opacity:.8; cursor:pointer; user-select:none; white-space:nowrap; }
2442
+ .dm-toggle input { margin:0; }
2443
+
2356
2444
  .dm-pane { min-width:0; display:flex; flex-direction:column; gap:12px; }
2357
2445
  .dm-divider { cursor:col-resize; align-self:stretch; min-height:120px; border-radius:4px; position:relative; }
2358
2446
  .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.14",
3
+ "version": "0.0.16",
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",