@linyanzhi/dsh-mesh 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +16 -2
- package/cordis.patch.yml +6 -0
- package/icon.svg +6 -0
- package/lib/client.js +3196 -0
- package/lib/collect.js +584 -0
- package/lib/delegations.js +270 -0
- package/lib/index.js +422 -0
- package/package.json +50 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// dsh-mesh-ui — 服务端半(host 进程)
|
|
2
|
+
//
|
|
3
|
+
// 注册只读路由 GET /api/dsh-mesh/status(设置页「AI 网络」用)+ 后台刷新循环。
|
|
4
|
+
// 数据由 lib/collect.js 采集(mesh-health / host status / peer host_self / 台账 / 告警历史)。
|
|
5
|
+
//
|
|
6
|
+
// 2026-11-06 修正(三台实测死锁):v1 在 request handler 里 spawnSync 跑 mesh-health,
|
|
7
|
+
// 同步阻塞 DSH host 事件循环,而 mesh-health 要探测同进程的 host bridge(9100/9101)→ 自死锁 20s。
|
|
8
|
+
// 现在:后台 execFile 异步刷新 + 内存缓存,handler 只读缓存(0ms,不阻塞)。
|
|
9
|
+
//
|
|
10
|
+
// 安全:仅 loopback;不含 token/凭据;纯只读(只读文件 + 只调只读 CLI)。
|
|
11
|
+
//
|
|
12
|
+
// 归宿:reusable(dsh-mesh/plugin/dsh-mesh-ui/lib/)
|
|
13
|
+
|
|
14
|
+
import { collect, resolveRepo, meshHome, run, nodeExec } from './collect.js'
|
|
15
|
+
import { listDelegations, sessionHistory, taskStatus } from './delegations.js'
|
|
16
|
+
import { networkInterfaces } from 'node:os'
|
|
17
|
+
import net from 'node:net'
|
|
18
|
+
import tls from 'node:tls'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { readFileSync } from 'node:fs'
|
|
21
|
+
|
|
22
|
+
/** Cordis inject 列表:只需要 webServer。 */
|
|
23
|
+
export const inject = ['webServer']
|
|
24
|
+
|
|
25
|
+
export const name = 'dsh-mesh-ui'
|
|
26
|
+
|
|
27
|
+
// 2026-11-09:默认 10s(可用 DSH_MESH_UI_REFRESH_MS 覆盖);隧道/节点变化更快可见
|
|
28
|
+
const REFRESH_MS = Math.max(3_000, Number(process.env.DSH_MESH_UI_REFRESH_MS || 5_000))
|
|
29
|
+
const LATENCY_HISTORY_MAX = 30
|
|
30
|
+
const REQUEST_REFRESH_COOLDOWN_MS = 8_000
|
|
31
|
+
|
|
32
|
+
function isLoopback(req) {
|
|
33
|
+
const raw = req?.socket?.remoteAddress || ''
|
|
34
|
+
const addr = String(raw).replace(/^::ffff:/, '')
|
|
35
|
+
return addr === '127.0.0.1' || addr === '::1' || addr === 'localhost' || addr.startsWith('127.')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeJson(res, status, body) {
|
|
39
|
+
res.statusCode = status
|
|
40
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
41
|
+
res.setHeader('cache-control', 'no-store')
|
|
42
|
+
res.end(JSON.stringify(body, null, 2))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readBody(req, limit = 16 * 1024) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let size = 0
|
|
48
|
+
const chunks = []
|
|
49
|
+
req.on('data', (chunk) => {
|
|
50
|
+
size += chunk.length
|
|
51
|
+
if (size > limit) { resolve(null); req.destroy(); return }
|
|
52
|
+
chunks.push(chunk)
|
|
53
|
+
})
|
|
54
|
+
req.on('end', () => {
|
|
55
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) } catch { resolve(null) }
|
|
56
|
+
})
|
|
57
|
+
req.on('error', () => resolve(null))
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function lanIPv4() {
|
|
62
|
+
const ifaces = networkInterfaces()
|
|
63
|
+
for (const list of Object.values(ifaces)) {
|
|
64
|
+
for (const item of list || []) {
|
|
65
|
+
if (item.family === 'IPv4' && !item.internal && !/^169\.254\./.test(item.address)) return item.address
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return '127.0.0.1'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Cordis apply:注册只读状态路由 + 后台刷新循环。
|
|
73
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx host 插件上下文(已注入 webServer)
|
|
74
|
+
*/
|
|
75
|
+
export function apply(ctx) {
|
|
76
|
+
const repo = resolveRepo()
|
|
77
|
+
const state = {
|
|
78
|
+
repo,
|
|
79
|
+
snapshot: {
|
|
80
|
+
ok: false,
|
|
81
|
+
warming: Boolean(repo),
|
|
82
|
+
at: null,
|
|
83
|
+
node: process.env.COMPUTERNAME || process.env.HOSTNAME || '',
|
|
84
|
+
meshHome: meshHome(),
|
|
85
|
+
nodeVersion: process.version,
|
|
86
|
+
repo,
|
|
87
|
+
error: repo ? 'warming up (first background refresh pending)' : 'dsh-mesh repo not found (set DSH_MESH_REPO or clone to ~/Code/dsh-mesh)'
|
|
88
|
+
},
|
|
89
|
+
refreshing: false,
|
|
90
|
+
lastStartedAt: 0,
|
|
91
|
+
latencyHistory: new Map()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 记录每个 peer 的延迟小趋势(在内存里滚动,不落盘)。 */
|
|
95
|
+
function rememberLatency(peers) {
|
|
96
|
+
for (const peer of peers || []) {
|
|
97
|
+
const history = state.latencyHistory.get(peer.name) || []
|
|
98
|
+
history.push(peer.up ? peer.latencyMs : null)
|
|
99
|
+
while (history.length > LATENCY_HISTORY_MAX) history.shift()
|
|
100
|
+
state.latencyHistory.set(peer.name, history)
|
|
101
|
+
}
|
|
102
|
+
return (peers || []).map((peer) => ({ ...peer, latencyHistory: state.latencyHistory.get(peer.name) || [] }))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function refresh() {
|
|
106
|
+
if (state.refreshing || !state.repo) return
|
|
107
|
+
state.refreshing = true
|
|
108
|
+
state.lastStartedAt = Date.now()
|
|
109
|
+
try {
|
|
110
|
+
const data = await collect(state.repo)
|
|
111
|
+
state.snapshot = {
|
|
112
|
+
...data,
|
|
113
|
+
warming: false,
|
|
114
|
+
at: data.generatedAt || new Date().toISOString(),
|
|
115
|
+
latencyMs: Date.now() - state.lastStartedAt,
|
|
116
|
+
node: process.env.COMPUTERNAME || process.env.HOSTNAME || '',
|
|
117
|
+
meshHome: meshHome(),
|
|
118
|
+
nodeVersion: process.version,
|
|
119
|
+
peers: data.ok ? rememberLatency(data.peers) : data.peers || []
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
state.snapshot = { ...state.snapshot, ok: false, warming: false, at: new Date().toISOString(), error: String(error?.message || error) }
|
|
123
|
+
} finally {
|
|
124
|
+
state.refreshing = false
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const timer = setInterval(() => {
|
|
129
|
+
refresh().catch(() => {})
|
|
130
|
+
}, REFRESH_MS)
|
|
131
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
132
|
+
refresh().catch(() => {})
|
|
133
|
+
|
|
134
|
+
// ---- 委托面板只读路由(2026-11-07 方案 A)----
|
|
135
|
+
const delegationCache = { at: 0, data: null }
|
|
136
|
+
const taskCache = new Map()
|
|
137
|
+
const sessionCache = new Map()
|
|
138
|
+
function cached(taskIdKey, map, ttlMs, producer) {
|
|
139
|
+
const hit = map.get(taskIdKey)
|
|
140
|
+
if (hit && Date.now() - hit.at < ttlMs) return hit.data
|
|
141
|
+
const data = producer()
|
|
142
|
+
const settled = data instanceof Promise ? data.then((value) => { map.set(taskIdKey, { at: Date.now(), data: value }); return value }) : (map.set(taskIdKey, { at: Date.now(), data }), data)
|
|
143
|
+
return settled
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const route = {
|
|
147
|
+
kind: 'exact',
|
|
148
|
+
path: '/api/dsh-mesh/status',
|
|
149
|
+
handler: async (req, res) => {
|
|
150
|
+
if (!isLoopback(req)) {
|
|
151
|
+
writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
if ((req.method || 'GET').toUpperCase() !== 'GET') {
|
|
155
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
160
|
+
if (url.searchParams.get('force') === '1' && Date.now() - state.lastStartedAt > REQUEST_REFRESH_COOLDOWN_MS) {
|
|
161
|
+
refresh().catch(() => {})
|
|
162
|
+
}
|
|
163
|
+
writeJson(res, 200, {
|
|
164
|
+
...state.snapshot,
|
|
165
|
+
refreshing: state.refreshing,
|
|
166
|
+
snapshotAgeSeconds: state.snapshot.at ? Math.round((Date.now() - Date.parse(state.snapshot.at)) / 1000) : null
|
|
167
|
+
})
|
|
168
|
+
} catch (error) {
|
|
169
|
+
writeJson(res, 500, { ok: false, error: String(error?.message || error) })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 统一 GET 只读处理器。 */
|
|
175
|
+
function readonlyRoute(path, handler) {
|
|
176
|
+
return {
|
|
177
|
+
kind: 'exact',
|
|
178
|
+
path,
|
|
179
|
+
handler: async (req, res) => {
|
|
180
|
+
if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
181
|
+
if ((req.method || 'GET').toUpperCase() !== 'GET') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
182
|
+
try {
|
|
183
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
184
|
+
writeJson(res, 200, await handler(url))
|
|
185
|
+
} catch (error) {
|
|
186
|
+
writeJson(res, 500, { ok: false, error: String(error?.message || error) })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function meshPort() {
|
|
193
|
+
try { return Number(JSON.parse(readFileSync(join(meshHome(), 'config.json'), 'utf8')).node?.port) || 8124 } catch { return 8124 }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function runCli(args, timeout = 30_000) {
|
|
197
|
+
if (!state.repo) return { error: 'dsh-mesh-node is not installed', stdout: '', stderr: '' }
|
|
198
|
+
return run(nodeExec(), [join(state.repo, 'src', 'cli.js'), ...args], { cwd: state.repo, timeout })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const lanScanRoute = readonlyRoute('/api/dsh-mesh/lan/scan', async () => {
|
|
202
|
+
const r = await runCli(['discover', '--json', '--timeout-ms', '1500'], 20_000)
|
|
203
|
+
if (r.error) return { ok: false, error: r.error, stderr: r.stderr }
|
|
204
|
+
try {
|
|
205
|
+
const data = JSON.parse(r.stdout)
|
|
206
|
+
return { ok: true, localAddress: lanIPv4(), nodes: data.nodes || [] }
|
|
207
|
+
} catch {
|
|
208
|
+
return { ok: false, error: 'invalid discover output', stdout: r.stdout, stderr: r.stderr }
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const lanCodeRoute = readonlyRoute('/api/dsh-mesh/lan/code', async () => {
|
|
213
|
+
const host = `${lanIPv4()}:${meshPort()}`
|
|
214
|
+
const r = await runCli(['pair', '--code', '--ttl', '600', '--role', 'node', '--host', host])
|
|
215
|
+
if (r.error) return { ok: false, error: r.error, stderr: r.stderr }
|
|
216
|
+
try {
|
|
217
|
+
return { ok: true, ...JSON.parse(r.stdout) }
|
|
218
|
+
} catch {
|
|
219
|
+
return { ok: false, error: 'invalid pair output', stdout: r.stdout, stderr: r.stderr }
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
const lanCheckRoute = {
|
|
225
|
+
kind: 'exact',
|
|
226
|
+
path: '/api/dsh-mesh/lan/check',
|
|
227
|
+
handler: async (req, res) => {
|
|
228
|
+
if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
229
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
230
|
+
const body = await readBody(req)
|
|
231
|
+
if (!body || typeof body.url !== 'string') return writeJson(res, 400, { ok: false, error: 'url required' })
|
|
232
|
+
let target
|
|
233
|
+
try { target = new URL(String(body.url)) } catch { return writeJson(res, 400, { ok: false, error: 'invalid url' }) }
|
|
234
|
+
const host = target.hostname.replace(/^\[|\]$/g, '')
|
|
235
|
+
const port = Number(target.port || (target.protocol === 'https:' ? 443 : 80))
|
|
236
|
+
const started = Date.now()
|
|
237
|
+
function tcp() {
|
|
238
|
+
return new Promise((resolve) => {
|
|
239
|
+
const socket = net.connect({ host, port, timeout: 2500 })
|
|
240
|
+
socket.once('connect', () => { socket.destroy(); resolve({ ok: true }) })
|
|
241
|
+
socket.once('timeout', () => { socket.destroy(); resolve({ ok: false, error: 'tcp timeout' }) })
|
|
242
|
+
socket.once('error', (error) => { socket.destroy(); resolve({ ok: false, error: String(error.message || error) }) })
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
const tcpResult = await tcp()
|
|
246
|
+
let tlsResult = null
|
|
247
|
+
if (tcpResult.ok && target.protocol === 'https:') {
|
|
248
|
+
tlsResult = await new Promise((resolve) => {
|
|
249
|
+
const socket = tls.connect({ host, port, rejectUnauthorized: false, servername: host, timeout: 2500 })
|
|
250
|
+
socket.once('secureConnect', () => { socket.destroy(); resolve({ ok: true, fingerprint: socket.getPeerCertificate()?.fingerprint256 || null }) })
|
|
251
|
+
socket.once('timeout', () => { socket.destroy(); resolve({ ok: false, error: 'tls timeout' }) })
|
|
252
|
+
socket.once('error', (error) => { socket.destroy(); resolve({ ok: false, error: String(error.message || error) }) })
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
writeJson(res, 200, { ok: tcpResult.ok, host, port, tcp: tcpResult.ok, tls: tlsResult, latencyMs: Date.now() - started })
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const lanPairRoute = {
|
|
260
|
+
kind: 'exact',
|
|
261
|
+
path: '/api/dsh-mesh/lan/pair',
|
|
262
|
+
handler: async (req, res) => {
|
|
263
|
+
if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
264
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
265
|
+
const body = await readBody(req)
|
|
266
|
+
if (!body || typeof body.url !== 'string' || typeof body.code !== 'string') {
|
|
267
|
+
return writeJson(res, 400, { ok: false, error: 'url and code required' })
|
|
268
|
+
}
|
|
269
|
+
const pairingCode = String(body.code).trim()
|
|
270
|
+
if (pairingCode.replace(/-/g, '').length < 6) {
|
|
271
|
+
return writeJson(res, 400, { ok: false, error: 'pairing code is required (one-time code from the target node)' })
|
|
272
|
+
}
|
|
273
|
+
const args = ['peer', 'add', '--url', String(body.url), '--code', pairingCode, '--name', String(body.name || 'lan-peer')]
|
|
274
|
+
if (body.fingerprint) args.push('--fingerprint', String(body.fingerprint))
|
|
275
|
+
try {
|
|
276
|
+
const r = await runCli(args)
|
|
277
|
+
writeJson(res, 200, { ok: !r.error, error: r.error, stdout: r.stdout, stderr: r.stderr })
|
|
278
|
+
} catch (error) {
|
|
279
|
+
writeJson(res, 500, { ok: false, error: String(error?.message || error) })
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 只读联邦拓扑:向每个直连 peer 查询它的 cluster_status,得到 1 跳间接节点。
|
|
285
|
+
const federatedCache = { at: 0, data: null }
|
|
286
|
+
const federatedRoute = readonlyRoute('/api/dsh-mesh/federated', async () => {
|
|
287
|
+
if (federatedCache.data && Date.now() - federatedCache.at < 45_000) return federatedCache.data
|
|
288
|
+
const listResult = await runCli(['peer', 'list'])
|
|
289
|
+
let direct = []
|
|
290
|
+
try { direct = JSON.parse(listResult.stdout).peers || [] } catch { /* ignore */ }
|
|
291
|
+
const directNames = new Set(direct.map((p) => String(p.name || '')).filter(Boolean))
|
|
292
|
+
const nodes = []
|
|
293
|
+
const errors = []
|
|
294
|
+
const results = await Promise.all(direct.slice(0, 6).map(async (peer) => {
|
|
295
|
+
const name = String(peer.name || '')
|
|
296
|
+
if (!name) return null
|
|
297
|
+
// 快速路径:新节点暴露 mesh_peers(只读、不探测、毫秒级)。
|
|
298
|
+
const fast = await runCli(['peer', 'call', 'mesh_peers', '--to', name, '--timeout-ms', '4000'], 10_000)
|
|
299
|
+
if (!fast.error) {
|
|
300
|
+
try {
|
|
301
|
+
const fw = JSON.parse(fast.stdout)
|
|
302
|
+
const fi = JSON.parse(fw?.result?.content?.[0]?.text || '{}')
|
|
303
|
+
if (Array.isArray(fi.peers)) return { via: name, peers: fi.peers, instances: [], fast: true }
|
|
304
|
+
} catch { /* fall through */ }
|
|
305
|
+
}
|
|
306
|
+
// 兼容旧节点:回退到 cluster_status。
|
|
307
|
+
const r = await runCli(['peer', 'call', 'cluster_status', '--to', name, '--args', JSON.stringify({ noPeers: true, probeSelf: false }), '--timeout-ms', '10000'], 16_000)
|
|
308
|
+
if (r.error) { errors.push({ via: name, error: r.error }); return null }
|
|
309
|
+
try {
|
|
310
|
+
const wrapper = JSON.parse(r.stdout)
|
|
311
|
+
const inner = JSON.parse(wrapper?.result?.content?.[0]?.text || '{}')
|
|
312
|
+
return { via: name, peers: inner.peers || [], instances: inner.remoteInstances || [], fast: false }
|
|
313
|
+
} catch (error) { errors.push({ via: name, error: 'invalid cluster_status' }); return null }
|
|
314
|
+
}))
|
|
315
|
+
for (const result of results.filter(Boolean)) {
|
|
316
|
+
for (const peer of result.peers) {
|
|
317
|
+
const name = String(peer.name || '')
|
|
318
|
+
if (!name || directNames.has(name)) continue
|
|
319
|
+
nodes.push({ name, via: result.via, up: peer.up == null ? null : Boolean(peer.up), latencyMs: peer.latencyMs ?? null, version: peer.version || null, node: peer.node || name })
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const data = { ok: true, nodes: nodes.slice(0, 40), errors }
|
|
323
|
+
federatedCache.at = Date.now(); federatedCache.data = data
|
|
324
|
+
return data
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
const transfersRoute = readonlyRoute('/api/dsh-mesh/transfers', async () => {
|
|
328
|
+
try {
|
|
329
|
+
const list = JSON.parse(readFileSync(join(meshHome(), 'transfers.json'), 'utf8'))
|
|
330
|
+
const items = (Array.isArray(list) ? list : []).slice(-60).reverse().map((x) => ({ at: x.at || null, direction: x.direction || '', path: x.path || '', bytes: Number(x.bytes) || 0, totalBytes: Number(x.totalBytes) || 0, peer: x.peer || '' }))
|
|
331
|
+
return { ok: true, items }
|
|
332
|
+
} catch { return { ok: true, items: [] } }
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
const peersListRoute = readonlyRoute('/api/dsh-mesh/peers', async () => {
|
|
336
|
+
const r = await runCli(['peer', 'list'])
|
|
337
|
+
try { const data = JSON.parse(r.stdout); return { ok: true, peers: (data.peers || []).map((p) => ({ id: p.id, name: p.name, url: p.url, addedAt: p.addedAt || null })) } } catch { return { ok: false, error: 'invalid peer list', stderr: r.stderr } }
|
|
338
|
+
})
|
|
339
|
+
const peersRemoveRoute = {
|
|
340
|
+
kind: 'exact', path: '/api/dsh-mesh/peers/remove',
|
|
341
|
+
handler: async (req, res) => {
|
|
342
|
+
if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
343
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
344
|
+
const body = await readBody(req)
|
|
345
|
+
const id = String(body?.id || '').trim()
|
|
346
|
+
if (!id) return writeJson(res, 400, { ok: false, error: 'id required' })
|
|
347
|
+
const r = await runCli(['peer', 'remove', id])
|
|
348
|
+
writeJson(res, 200, { ok: !r.error, error: r.error, stdout: r.stdout, stderr: r.stderr })
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const peersToggleRoute = {
|
|
352
|
+
kind: 'exact', path: '/api/dsh-mesh/peers/toggle',
|
|
353
|
+
handler: async (req, res) => {
|
|
354
|
+
if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
355
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
356
|
+
const body = await readBody(req)
|
|
357
|
+
const id = String(body?.id || '').trim()
|
|
358
|
+
if (!id) return writeJson(res, 400, { ok: false, error: 'id required' })
|
|
359
|
+
const r = await runCli(['peer', body?.disabled ? 'disable' : 'enable', id])
|
|
360
|
+
writeJson(res, 200, { ok: !r.error, error: r.error, stdout: r.stdout, stderr: r.stderr })
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const tokensListRoute = readonlyRoute('/api/dsh-mesh/tokens', async () => {
|
|
365
|
+
const r = await runCli(['token', 'list'])
|
|
366
|
+
try { const data = JSON.parse(r.stdout); return { ok: true, tokens: (data || []).map((x) => ({ id: x.id, note: x.note || '', role: x.role || null, roleHint: x.roleHint || '' })) } } catch { return { ok: false, error: 'invalid token list', stderr: r.stderr } }
|
|
367
|
+
})
|
|
368
|
+
const tokensRevokeRoute = {
|
|
369
|
+
kind: 'exact', path: '/api/dsh-mesh/tokens/revoke',
|
|
370
|
+
handler: async (req, res) => {
|
|
371
|
+
if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
372
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
373
|
+
const body = await readBody(req)
|
|
374
|
+
const id = String(body?.id || '').trim()
|
|
375
|
+
if (!id) return writeJson(res, 400, { ok: false, error: 'id required' })
|
|
376
|
+
const r = await runCli(['revoke', id])
|
|
377
|
+
writeJson(res, 200, { ok: !r.error, error: r.error, stdout: r.stdout, stderr: r.stderr })
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const delegateRoutes = [
|
|
382
|
+
readonlyRoute('/api/dsh-mesh/delegations', async (url) => {
|
|
383
|
+
// 缓存整份列表(与请求的 limit 无关),避免不同 limit 的请求互相污染缓存
|
|
384
|
+
if (Date.now() - delegationCache.at > 15_000) {
|
|
385
|
+
delegationCache.at = Date.now()
|
|
386
|
+
delegationCache.data = listDelegations(200)
|
|
387
|
+
}
|
|
388
|
+
const data = delegationCache.data
|
|
389
|
+
const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit')) || 100, 200))
|
|
390
|
+
return { ok: true, total: data.total, byState: data.byState, localNode: data.localNode, tasks: data.tasks.slice(0, limit) }
|
|
391
|
+
}),
|
|
392
|
+
readonlyRoute('/api/dsh-mesh/task', async (url) => {
|
|
393
|
+
const taskId = String(url.searchParams.get('taskId') || '')
|
|
394
|
+
if (!taskId) return { ok: false, error: 'taskId required' }
|
|
395
|
+
return cached('task:' + taskId, taskCache, 60_000, () => taskStatus(taskId))
|
|
396
|
+
}),
|
|
397
|
+
readonlyRoute('/api/dsh-mesh/session', async (url) => {
|
|
398
|
+
const node = String(url.searchParams.get('node') || '')
|
|
399
|
+
// 2026-11-10:本机任务(node=local)没有对端会话,直接给出明确错误,避免 peer call --to local 报 unknown peer
|
|
400
|
+
if (/^(local|self|localhost)$/i.test(node)) return { ok: false, code: "E_LOCAL_NODE", error: "本机任务(local)没有对端会话;请在 DSH 侧栏查看本机会话" }
|
|
401
|
+
const sessionId = String(url.searchParams.get('sessionId') || '')
|
|
402
|
+
const limit = Number(url.searchParams.get('limit')) || 40
|
|
403
|
+
if (!node || !sessionId) return { ok: false, error: 'node and sessionId required' }
|
|
404
|
+
return cached('sess:' + node + ':' + sessionId + ':' + limit, sessionCache, 120_000, () => sessionHistory(node, sessionId, limit))
|
|
405
|
+
})
|
|
406
|
+
]
|
|
407
|
+
|
|
408
|
+
ctx.effect(() => {
|
|
409
|
+
const disposeRoute = ctx.webServer.register(route)
|
|
410
|
+
const disposeLanRoutes = [lanScanRoute, lanCodeRoute, lanCheckRoute, lanPairRoute, federatedRoute, peersListRoute, peersRemoveRoute, peersToggleRoute, tokensListRoute, tokensRevokeRoute, transfersRoute].map((item) => ctx.webServer.register(item))
|
|
411
|
+
const disposeRoutes = delegateRoutes.map((item) => ctx.webServer.register(item))
|
|
412
|
+
return () => {
|
|
413
|
+
try {
|
|
414
|
+
clearInterval(timer)
|
|
415
|
+
} catch {
|
|
416
|
+
/* ignore */
|
|
417
|
+
}
|
|
418
|
+
for (const dispose of disposeLanRoutes.concat(disposeRoutes)) { try { dispose() } catch { /* ignore */ } }
|
|
419
|
+
if (typeof disposeRoute === 'function') disposeRoute()
|
|
420
|
+
}
|
|
421
|
+
}, 'dsh-mesh-ui: GET /api/dsh-mesh/status + background refresh')
|
|
422
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linyanzhi/dsh-mesh",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "dsh-mesh main plugin: multi-device AI network topology in the DSH sidebar, with host bridge and server autostart installed as dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": "^22.19.0 || >=24.0.0",
|
|
14
|
+
"dsh": ">=0.2.0-rc.2 <0.3.0"
|
|
15
|
+
},
|
|
16
|
+
"dsh": {
|
|
17
|
+
"manifestVersion": 1,
|
|
18
|
+
"bundle": {
|
|
19
|
+
"patch": "./cordis.patch.yml"
|
|
20
|
+
},
|
|
21
|
+
"client": {
|
|
22
|
+
"inject": [
|
|
23
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
24
|
+
"@deepseek-ai/dsh-client-ui-layout"
|
|
25
|
+
],
|
|
26
|
+
"platform": "web"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"dsh-mesh-node": "0.0.20",
|
|
31
|
+
"dsh-mesh-host": "0.0.5",
|
|
32
|
+
"dsh-mesh-autostart": "0.0.7"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0",
|
|
36
|
+
"react": "^18.2.0",
|
|
37
|
+
"react-dom": "^18.2.0"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"lib/**/*.js",
|
|
41
|
+
"cordis.patch.yml",
|
|
42
|
+
"README.md",
|
|
43
|
+
"icon.svg",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/LinYanZhi/dsh-mesh.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/LinYanZhi/dsh-mesh#readme"
|
|
52
|
+
}
|