@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/client.js
ADDED
|
@@ -0,0 +1,3196 @@
|
|
|
1
|
+
// dsh-mesh-ui — 浏览器半(client plugin bundle)v3
|
|
2
|
+
//
|
|
3
|
+
// 手写成 DSH 期望的形状:window.__ModuleLoader__.load({ id, factory(require) }),react 由平台提供。
|
|
4
|
+
// 设置窗口 settings.section(契约:id 必填 + label)→「AI 网络」:
|
|
5
|
+
//
|
|
6
|
+
// ┌ 头部:标题 / verdict / 版本 / 时间 / 刷新 / 复制诊断 / 原始 JSON
|
|
7
|
+
// ├ 画布(viewport):滚轮缩放(以鼠标为中心)、拖拽平移、双击复位、缩放按钮、自适应 fit
|
|
8
|
+
// │ 画布内 = 世界坐标(1080×680):本机居中 + peer 卡片 + 曲线连线 + 延迟标签
|
|
9
|
+
// └ 折叠面板:本机实例 / 委托台账 / 告警历史 / 诊断建议(默认收起,省垂直空间)
|
|
10
|
+
//
|
|
11
|
+
// 2026-11-06 v3 重做(用户反馈:v2 卡片/连线糟糕、设置面板窄导致内容看不见、缺缩放拖拽)。
|
|
12
|
+
//
|
|
13
|
+
// 归宿:reusable(dsh-mesh/plugin/dsh-mesh-ui/lib/)
|
|
14
|
+
|
|
15
|
+
window.__ModuleLoader__.load({
|
|
16
|
+
id: 'dsh-mesh-ui',
|
|
17
|
+
factory: (require) => {
|
|
18
|
+
var module = { exports: {} }
|
|
19
|
+
var exports = module.exports
|
|
20
|
+
var React = require('react')
|
|
21
|
+
var h = React.createElement
|
|
22
|
+
var useState = React.useState
|
|
23
|
+
var useEffect = React.useEffect
|
|
24
|
+
var useRef = React.useRef
|
|
25
|
+
var useCallback = React.useCallback
|
|
26
|
+
|
|
27
|
+
var API = '/api/dsh-mesh/status'
|
|
28
|
+
var REFRESH_MS = 5000
|
|
29
|
+
var SUPPORTS_ZOOM = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('zoom', '1.5')
|
|
30
|
+
|
|
31
|
+
// 2026-11-07 用户要求:卡片标题只用固定中文叫法,不显示电脑设备名
|
|
32
|
+
var NODE_LABELS = {
|
|
33
|
+
home: '个人电脑1号',
|
|
34
|
+
home1: '个人电脑1号',
|
|
35
|
+
home2: '个人电脑2号',
|
|
36
|
+
company: '公司电脑1号',
|
|
37
|
+
company2: '公司电脑2号',
|
|
38
|
+
'company2-lan': '公司电脑2号',
|
|
39
|
+
local: '本机(local)',
|
|
40
|
+
phone1: '个人手机1号'
|
|
41
|
+
}
|
|
42
|
+
// 2026-11-09 用户要求:名称后显示设备类型。类型依据设备快照(CPU 形态)判断,可直接改这张表:
|
|
43
|
+
// peer-x Ryzen 7 5700U(移动 U)→ 笔记本;peer-x 7800X3D(桌面 U)→ 主机
|
|
44
|
+
// peer-x i5-8500(桌面 U)→ 主机;peer-x i5-8250U(移动 U)→ 笔记本;手机 → 手机
|
|
45
|
+
var DEVICE_TYPES = { home: '笔记本', home1: '笔记本', home2: '主机', company: '主机', company2: '笔记本', phone1: '手机' }
|
|
46
|
+
function deviceType(name) {
|
|
47
|
+
var raw = String(name || '').toLowerCase()
|
|
48
|
+
var key = peerDeviceKey(raw)
|
|
49
|
+
return DEVICE_TYPES[key] || DEVICE_TYPES[raw] || ''
|
|
50
|
+
}
|
|
51
|
+
function nodeDisplay(name) {
|
|
52
|
+
var label = nodeLabel(name)
|
|
53
|
+
var type = deviceType(name)
|
|
54
|
+
return type ? label + ' · ' + type : label
|
|
55
|
+
}
|
|
56
|
+
// 同一台设备可能有多个 peer 记录(不同 token/入口:peer-node / peer-node-lan / peer-node-exec)。
|
|
57
|
+
// AI 网络拓扑按「设备」展示:去掉后缀归并,取可达且延迟最优的那条。
|
|
58
|
+
var PEER_SUFFIX = /-(lan|wan|exec|exe|import|local|ctl)$/i
|
|
59
|
+
function peerDeviceKey(name) {
|
|
60
|
+
var n = String(name || '').trim().toLowerCase()
|
|
61
|
+
return n.replace(PEER_SUFFIX, '')
|
|
62
|
+
}
|
|
63
|
+
function dedupePeers(list) {
|
|
64
|
+
var byKey = {}
|
|
65
|
+
var order = []
|
|
66
|
+
;(list || []).forEach(function (peer) {
|
|
67
|
+
var key = peerDeviceKey(peer.name)
|
|
68
|
+
if (!byKey[key]) { byKey[key] = peer; order.push(key); return }
|
|
69
|
+
var cur = byKey[key]
|
|
70
|
+
var better = false
|
|
71
|
+
if (!cur.up && peer.up) better = true
|
|
72
|
+
else if (Boolean(cur.up) === Boolean(peer.up)) {
|
|
73
|
+
var a = cur.latencyMs == null ? 1e9 : cur.latencyMs
|
|
74
|
+
var b = peer.latencyMs == null ? 1e9 : peer.latencyMs
|
|
75
|
+
if (b < a) better = true
|
|
76
|
+
else if (b === a && (peer.instancesCount || 0) > (cur.instancesCount || 0)) better = true
|
|
77
|
+
}
|
|
78
|
+
if (better) byKey[key] = peer
|
|
79
|
+
})
|
|
80
|
+
return order.map(function (k) { return byKey[k] })
|
|
81
|
+
}
|
|
82
|
+
function filterSelfPeers(list, nodeName) {
|
|
83
|
+
var selfKey = peerDeviceKey(nodeName)
|
|
84
|
+
return (list || []).filter(function (peer) { return !selfKey || peerDeviceKey(peer.name) !== selfKey })
|
|
85
|
+
}
|
|
86
|
+
function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()] || String(name || '未知节点') }
|
|
87
|
+
// [layout-helpers-begin] 2026-11-09 拓扑自定义布局:节点 key 顺序 + localStorage 持久化 + clamp
|
|
88
|
+
var LAYOUT_STORE_KEY = 'dsh-mesh-ui.topology.layout.v1'
|
|
89
|
+
function topologyNodeKeys(data) {
|
|
90
|
+
var d = data || {}
|
|
91
|
+
var peers = filterSelfPeers(dedupePeers(d.peers || []), d.nodeName)
|
|
92
|
+
return ['local'].concat(peers.map(function (peer) { return 'peer:' + peer.name }))
|
|
93
|
+
}
|
|
94
|
+
// 2026-11-10:设备显示顺序(左→右 / 逆时针)——拓扑渲染与拖拽位置表必须共用同一顺序
|
|
95
|
+
var FAN_ORDER = ['home', 'home2', 'company', 'company2', 'phone1']
|
|
96
|
+
function fanRankOfNodeKey(key, data) {
|
|
97
|
+
var dev = key === 'local' ? peerDeviceKey(data && data.nodeName) : peerDeviceKey(String(key || '').replace(/^peer:/, ''))
|
|
98
|
+
var idx = FAN_ORDER.indexOf(dev)
|
|
99
|
+
return idx < 0 ? 99 : idx
|
|
100
|
+
}
|
|
101
|
+
function orderedNodeKeys(data) {
|
|
102
|
+
var keys = topologyNodeKeys(data).slice()
|
|
103
|
+
keys.sort(function (a, b) {
|
|
104
|
+
var d = fanRankOfNodeKey(a, data) - fanRankOfNodeKey(b, data)
|
|
105
|
+
return d !== 0 ? d : String(a).localeCompare(String(b))
|
|
106
|
+
})
|
|
107
|
+
return keys
|
|
108
|
+
}
|
|
109
|
+
// 2026-11-10:用户手工调整后认可的默认布局(世界坐标,10000×6000 画布内)
|
|
110
|
+
// 设备按设备名(home/peer-node/company/peer-node/peer-node)映射,各机通用;服务器两张卡单独一张表
|
|
111
|
+
var DEFAULT_DEVICE_POS = {
|
|
112
|
+
home: [4701.61, 3002.94],
|
|
113
|
+
home2: [4783.27, 3130.21],
|
|
114
|
+
company: [5016.63, 3153.48],
|
|
115
|
+
company2: [5255.63, 3132.50],
|
|
116
|
+
phone1: [5356.20, 3007.42]
|
|
117
|
+
}
|
|
118
|
+
var DEFAULT_INFRA_POS = {
|
|
119
|
+
aliyun: [4887.59, 2856.78],
|
|
120
|
+
sakura: [5177.39, 2858.40]
|
|
121
|
+
}
|
|
122
|
+
function devKeyOfNodeKey(key, data) {
|
|
123
|
+
return key === 'local' ? peerDeviceKey(data && data.nodeName) : peerDeviceKey(String(key || '').replace(/^peer:/, ''))
|
|
124
|
+
}
|
|
125
|
+
function loadMovedLayout() {
|
|
126
|
+
try {
|
|
127
|
+
var raw = window.localStorage.getItem(LAYOUT_STORE_KEY)
|
|
128
|
+
if (!raw) return {}
|
|
129
|
+
var obj = JSON.parse(raw)
|
|
130
|
+
return obj && typeof obj === 'object' ? obj : {}
|
|
131
|
+
} catch (_) { return {} }
|
|
132
|
+
}
|
|
133
|
+
function saveMovedLayout(obj) {
|
|
134
|
+
try { window.localStorage.setItem(LAYOUT_STORE_KEY, JSON.stringify(obj || {})) } catch (_) { /* ignore */ }
|
|
135
|
+
}
|
|
136
|
+
function clampNum(v, min, max) { return Math.max(min, Math.min(max, v)) }
|
|
137
|
+
// [layout-helpers-end]
|
|
138
|
+
// [relay-helpers-begin] 2026-11-09 架构层:识别公网中转站(frps)与通信方式(纯函数,可单测)
|
|
139
|
+
// 2026-11-10 隐私:默认不展示公网 IP/域名;局域网/回环地址保留。
|
|
140
|
+
// 用户在页头打开「显示公网地址」后,maskHost/maskText 直接返回原值。
|
|
141
|
+
var showPublicAddresses = false
|
|
142
|
+
function isPrivateNetworkHost(raw) {
|
|
143
|
+
var h = String(raw || '').trim().toLowerCase().replace(/^\[|\]$/g, '')
|
|
144
|
+
h = h.replace(/^https?:\/\//, '').split('/')[0].replace(/:\d+$/, '')
|
|
145
|
+
if (!h) return true
|
|
146
|
+
if (h === 'localhost' || h === '::1' || h === '0.0.0.0' || h === '127.0.0.1') return true
|
|
147
|
+
if (h.indexOf('.') < 0) return true // 主机名(DESKTOP-xxx / home2)
|
|
148
|
+
if (/(\.local|\.lan|\.home|\.internal)$/.test(h)) return true
|
|
149
|
+
var m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
|
|
150
|
+
if (m) {
|
|
151
|
+
var a = Number(m[1]); var b = Number(m[2])
|
|
152
|
+
return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31)
|
|
153
|
+
}
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
function maskHost(raw) {
|
|
157
|
+
var s = String(raw || '').trim()
|
|
158
|
+
if (showPublicAddresses) return s || '—'
|
|
159
|
+
if (!s) return '—'
|
|
160
|
+
var port = ''
|
|
161
|
+
var m = /^(.*):(\d+)$/.exec(s)
|
|
162
|
+
if (m && m[1].indexOf(']') < 0) { s = m[1]; port = ':' + m[2] }
|
|
163
|
+
return (isPrivateNetworkHost(s) ? s : '公网地址已隐藏') + port
|
|
164
|
+
}
|
|
165
|
+
function maskText(value) {
|
|
166
|
+
if (showPublicAddresses) return value
|
|
167
|
+
if (typeof value !== 'string') return value
|
|
168
|
+
return value
|
|
169
|
+
.replace(/https?:\/\/[^\s"'<>]+/gi, function (u) {
|
|
170
|
+
try { var x = new URL(u); return isPrivateNetworkHost(x.hostname) ? u : (x.protocol + '//公网地址已隐藏' + (x.port ? ':' + x.port : '')) } catch (e) { return u }
|
|
171
|
+
})
|
|
172
|
+
.replace(/\b(?!10\.|192\.168\.|127\.|172\.(?:1[6-9]|2\d|3[01])\.)(?:\d{1,3}\.){3}\d{1,3}\b/g, '公网地址已隐藏')
|
|
173
|
+
.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, '公网地址已隐藏')
|
|
174
|
+
}
|
|
175
|
+
// 判定:同一公网 host 被 >=2 个 peer 使用 → 视为共享中转站;LAN/loopback 不算。
|
|
176
|
+
var RELAY_PUBLIC_KINDS = { wan: 1, frp: 1 }
|
|
177
|
+
function relayGroups(peers) {
|
|
178
|
+
var groups = {}
|
|
179
|
+
var order = []
|
|
180
|
+
;(peers || []).forEach(function (peer) {
|
|
181
|
+
var t = peer.tunnel || {}
|
|
182
|
+
var host = String(t.host || '').trim().toLowerCase()
|
|
183
|
+
if (!host || !RELAY_PUBLIC_KINDS[t.kind]) return
|
|
184
|
+
var g = groups[host]
|
|
185
|
+
if (!g) { g = groups[host] = { host: t.host || host, hostKey: host, kind: t.kind, peers: [], ports: {}, up: 0 }; order.push(host) }
|
|
186
|
+
g.peers.push(peer)
|
|
187
|
+
if (t.port) g.ports[String(t.port)] = (g.ports[String(t.port)] || 0) + 1
|
|
188
|
+
if (peer.up) g.up++
|
|
189
|
+
})
|
|
190
|
+
return order.map(function (k) { return groups[k] })
|
|
191
|
+
.filter(function (g) { return g.peers.length >= 2 })
|
|
192
|
+
.sort(function (a, b) { return b.peers.length - a.peers.length })
|
|
193
|
+
}
|
|
194
|
+
function relayHostSet(peers) {
|
|
195
|
+
var set = {}
|
|
196
|
+
relayGroups(peers).forEach(function (g) { set[g.hostKey] = g })
|
|
197
|
+
return set
|
|
198
|
+
}
|
|
199
|
+
function commLabel(peer, relayHosts) {
|
|
200
|
+
var t = peer.tunnel || {}
|
|
201
|
+
var host = String(t.host || '').toLowerCase()
|
|
202
|
+
if (!host) return { text: '—', short: '—' }
|
|
203
|
+
var relay = relayHosts && relayHosts[host]
|
|
204
|
+
var port = t.port ? ':' + t.port : ''
|
|
205
|
+
if (relay) return { text: (relay.kind === 'frp' ? '域名穿透' : '自建 frps 中转') + ' · ' + maskHost(relay.host) + port, short: '中转' + (t.port ? ' ' + t.port : '') }
|
|
206
|
+
if (t.kind === 'loopback') return { text: '本机回环', short: '本机' }
|
|
207
|
+
if (t.kind === 'lan') return { text: '局域网直连 · ' + maskHost(t.host) + port, short: 'LAN' }
|
|
208
|
+
if (t.kind === 'frp') return { text: '公网穿透 · ' + maskHost(t.host) + port, short: '穿透' }
|
|
209
|
+
if (t.kind === 'wan') return { text: '公网直连 · ' + maskHost(t.host) + port, short: '公网' }
|
|
210
|
+
return { text: (t.label || t.kind || '未知') + ' · ' + maskHost(t.host) + port, short: t.label || '未知' }
|
|
211
|
+
}
|
|
212
|
+
// [relay-helpers-end]
|
|
213
|
+
var W = 560
|
|
214
|
+
var H = 400
|
|
215
|
+
var MIN_SCALE = 0.18
|
|
216
|
+
var MAX_SCALE = 2.6
|
|
217
|
+
|
|
218
|
+
var CSS = `
|
|
219
|
+
.dm-root { display:flex; flex-direction:column; gap:12px; font-size:13px; line-height:1.5; color:inherit; }
|
|
220
|
+
.dm-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
221
|
+
.dm-title { font-size:15.5px; font-weight:650; letter-spacing:.2px; }
|
|
222
|
+
.dm-sub { font-size:11.5px; opacity:.55; }
|
|
223
|
+
.dm-pill { padding:1px 9px; border-radius:999px; font-size:11.5px; font-weight:600; border:1px solid transparent; }
|
|
224
|
+
.dm-pill.ok { background:rgba(63,185,80,.14); color:#2e9e4f; border-color:rgba(63,185,80,.35); }
|
|
225
|
+
.dm-pill.bad { background:rgba(248,81,73,.14); color:#c9362c; border-color:rgba(248,81,73,.35); }
|
|
226
|
+
.dm-pill.warn { background:rgba(210,153,34,.16); color:#9a6c12; border-color:rgba(210,153,34,.4); }
|
|
227
|
+
.dm-pill.muted { background:rgba(128,128,128,.12); color:inherit; border-color:rgba(128,128,128,.28); opacity:.8; }
|
|
228
|
+
.dm-btn { font-size:11.5px; padding:3px 10px; border-radius:7px; border:1px solid rgba(128,128,128,.38); background:transparent; color:inherit; cursor:pointer; white-space:nowrap; }
|
|
229
|
+
.dm-btn:hover { background:rgba(128,128,128,.12); }
|
|
230
|
+
.dm-btn.primary { border-color:rgba(88,166,255,.55); color:#3b82d6; }
|
|
231
|
+
|
|
232
|
+
/* 画布视口 */
|
|
233
|
+
.dm-viewport { position:relative; width:100%; height:clamp(240px, 34vh, 380px); min-height:220px; overflow:hidden; border-radius:12px;
|
|
234
|
+
border:1px solid rgba(128,128,128,.22); background:
|
|
235
|
+
radial-gradient(ellipse at 50% 45%, rgba(88,166,255,.07), transparent 60%),
|
|
236
|
+
linear-gradient(rgba(128,128,128,.05) 1px, transparent 1px) 0 0/26px 26px,
|
|
237
|
+
linear-gradient(90deg, rgba(128,128,128,.05) 1px, transparent 1px) 0 0/26px 26px;
|
|
238
|
+
cursor:grab; touch-action:none; user-select:none; }
|
|
239
|
+
.dm-viewport.dragging { cursor:grabbing; }
|
|
240
|
+
.dm-viewport.resizing { cursor:ns-resize; }
|
|
241
|
+
/* 2026-11-09:画布高度可拖拽调整 */
|
|
242
|
+
.dm-resize { position:absolute; left:50%; bottom:0; transform:translateX(-50%); width:64px; height:12px; z-index:6; cursor:ns-resize; display:flex; align-items:center; justify-content:center; border-radius:6px 6px 0 0; background:rgba(128,128,128,.18); color:rgba(128,128,128,.9); font-size:12px; line-height:1; user-select:none; }
|
|
243
|
+
.dm-resize:hover { background:rgba(88,166,255,.28); color:#7fb4ff; }
|
|
244
|
+
/* 2026-11-09:悬浮节点高亮 + 连线上显示完整地址 */
|
|
245
|
+
.dm-edge-label { paint-order: stroke; stroke: rgba(15,17,21,.9); stroke-width: 3px; font-family: ui-monospace,Consolas,monospace; pointer-events: none; }
|
|
246
|
+
body.light .dm-edge-label { stroke: rgba(255,255,255,.92); }
|
|
247
|
+
.dm-card-node.hovered { border-color: rgba(88,166,255,.95) !important; box-shadow: 0 0 0 2px rgba(88,166,255,.35), 0 10px 26px rgba(0,0,0,.25); z-index: 5; }
|
|
248
|
+
.dm-world-outer { position:absolute; left:0; top:0; }
|
|
249
|
+
.dm-world { position:absolute; left:0; top:0; transform-origin:0 0; }
|
|
250
|
+
.dm-lines { position:absolute; left:0; top:0; overflow:visible; pointer-events:none; }
|
|
251
|
+
.dm-world-label { position:absolute; left:10px; top:8px; font-size:11px; opacity:.5; font-variant-numeric:tabular-nums; }
|
|
252
|
+
|
|
253
|
+
/* 节点卡片(世界坐标里) */
|
|
254
|
+
.dm-card-node { position:absolute; box-sizing:border-box; overflow:visible; border-radius:12px; padding:10px 12px;
|
|
255
|
+
border:1px solid rgba(128,128,128,.3); background:rgba(128,128,128,.06); color:inherit; box-shadow:0 6px 22px rgba(0,0,0,.10); }
|
|
256
|
+
.dm-card-node.local { border-width:1.6px; border-color:rgba(88,166,255,.55); background:rgba(88,166,255,.10); }
|
|
257
|
+
.dm-card-node.down { border-style:dashed; border-color:rgba(248,81,73,.6); background:rgba(248,81,73,.07); }
|
|
258
|
+
.dm-card-node.down .dm-n-name { opacity:1; }
|
|
259
|
+
.dm-card-node.down .dm-card-domain, .dm-card-node.down .dm-card-dsh { opacity:.62; }
|
|
260
|
+
.dm-card-node.down .dm-v, .dm-card-node.down .dm-big, .dm-card-node.down .dm-extra { opacity:.6; }
|
|
261
|
+
.dm-card-node.down .dm-chip { opacity:.72; }
|
|
262
|
+
.dm-offline-row { margin-top:5px; font-size:10.5px; color:#c9362c; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
263
|
+
.dm-n-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
|
264
|
+
.dm-n-name { font-weight:650; font-size:13px; display:flex; align-items:center; gap:7px; min-width:0; }
|
|
265
|
+
.dm-n-name span.dm-ell { overflow:visible; text-overflow:clip; white-space:nowrap; }
|
|
266
|
+
.dm-dot { width:9px; height:9px; border-radius:50%; display:inline-block; background:#8b949e; flex:none; }
|
|
267
|
+
.dm-dot.ok { background:#2e9e4f; box-shadow:0 0 0 3px rgba(63,185,80,.18); }
|
|
268
|
+
.dm-dot.bad { background:#d14b41; box-shadow:0 0 0 3px rgba(248,81,73,.18); }
|
|
269
|
+
.dm-big { font-size:17px; font-weight:650; font-variant-numeric:tabular-nums; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
|
270
|
+
.dm-unit { font-size:11px; opacity:.55; margin-left:2px; }
|
|
271
|
+
.dm-row { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:5px; font-size:11.5px; }
|
|
272
|
+
.dm-k { opacity:.58; font-size:11px; }
|
|
273
|
+
.dm-v { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11.5px; font-variant-numeric:tabular-nums; }
|
|
274
|
+
.dm-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
|
275
|
+
.dm-mini { display:flex; gap:4px; flex-wrap:wrap; margin-top:6px; }
|
|
276
|
+
.dm-extra { font-size:11px; opacity:.6; font-variant-numeric:tabular-nums; }
|
|
277
|
+
.dm-card-node.compact { padding:9px 11px; cursor:pointer; transition:border-color .12s, box-shadow .12s; }
|
|
278
|
+
.dm-card-node.selected { border-color:rgba(88,166,255,.85) !important; box-shadow:0 0 0 2px rgba(88,166,255,.35), 0 6px 18px rgba(0,0,0,.12) !important; }
|
|
279
|
+
.dm-card-domain { margin-top:6px; font-size:10px; opacity:.72; overflow:visible; text-overflow:clip; white-space:nowrap; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
|
280
|
+
.dm-card-dsh { margin-top:6px; }
|
|
281
|
+
/* 2026-11-09-v6:详情卡列数自适应(宽屏 3 列),不再固定 2 列 */
|
|
282
|
+
.dm-devices { display:grid; grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap:10px; align-items:start; }
|
|
283
|
+
/* 2026-11-09:三通道筛选浮层(画布内左上) */
|
|
284
|
+
.dm-topo-wrap { display:flex; flex-direction:column; gap:6px; }
|
|
285
|
+
.dm-topbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
|
286
|
+
.dm-topbar-info { font-size:10.5px; opacity:.55; margin-left:auto; white-space:nowrap; }
|
|
287
|
+
.dm-filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
288
|
+
.dm-flt { display:flex; align-items:center; gap:5px; font-size:11.5px; opacity:.55; border:1px solid rgba(128,128,128,.3); border-radius:999px; padding:1px 7px; cursor:pointer; user-select:none; }
|
|
289
|
+
.dm-flt.on { opacity:1; border-color:var(--c); }
|
|
290
|
+
.dm-flt input { margin:0; accent-color:var(--c); }
|
|
291
|
+
.dm-card-node.server { border-radius:12px; background:rgba(128,128,128,.06); }
|
|
292
|
+
.dm-chip.type { border-color:rgba(88,166,255,.5); color:#7fb4ff; }
|
|
293
|
+
/* 2026-11-09:三通道虚线流动动画(位移量必须是 dash 周期 11px 的整数倍,否则循环会抽搐) */
|
|
294
|
+
@keyframes dm-dashflow { to { stroke-dashoffset: -22; } }
|
|
295
|
+
.dm-lines path.dm-edge-frp { animation: dm-dashflow 1.15s linear infinite; stroke-linecap: butt; }
|
|
296
|
+
@media (prefers-reduced-motion: reduce) { .dm-lines path.dm-edge-frp { animation: none; } }
|
|
297
|
+
.dm-infra-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(340px, 1fr)); gap:10px; margin-top:10px; }
|
|
298
|
+
.dm-infra-card { border:1px solid rgba(128,128,128,.3); border-radius:11px; padding:9px 11px; background:rgba(128,128,128,.05); }
|
|
299
|
+
.dm-infra-head { display:flex; align-items:center; gap:7px; font-weight:650; font-size:12.5px; margin-bottom:5px; }
|
|
300
|
+
.dm-device { border:1px solid rgba(128,128,128,.22); border-radius:11px; padding:9px 11px; background:rgba(128,128,128,.035); min-width:0; overflow:hidden; }
|
|
301
|
+
.dm-device.selected { border-color:rgba(88,166,255,.6); background:rgba(88,166,255,.07); }
|
|
302
|
+
.dm-device-head { display:flex; align-items:center; gap:7px; font-weight:650; font-size:12.5px; margin-bottom:6px; }
|
|
303
|
+
.dm-device-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
304
|
+
.dm-device-body { display:flex; flex-direction:column; gap:3px; }
|
|
305
|
+
.dm-device-row { display:flex; align-items:baseline; justify-content:space-between; gap:10px; font-size:11px; min-width:0; }
|
|
306
|
+
.dm-device-k { opacity:.55; flex:0 0 auto; }
|
|
307
|
+
.dm-device-v { flex:1 1 auto; min-width:0; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; }
|
|
308
|
+
@media (max-width:720px) { .dm-devices { grid-template-columns:1fr; } }
|
|
309
|
+
.dm-fields { display:grid; grid-template-columns:1fr 1fr; gap:4px 10px; margin-top:6px; }
|
|
310
|
+
.dm-field { min-width:0; }
|
|
311
|
+
.dm-field-k { font-size:9.5px; opacity:.5; line-height:1.2; }
|
|
312
|
+
.dm-field-v { font-size:11px; line-height:1.35; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-variant-numeric:tabular-nums; }
|
|
313
|
+
.dm-tunnel { margin-top:6px; font-size:10px; opacity:.7; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
314
|
+
.dm-chip { font-size:9.5px; padding:0 6px; border-radius:999px; border:1px solid rgba(128,128,128,.3); opacity:.85; white-space:nowrap; }
|
|
315
|
+
.dm-chip.run { border-color:rgba(63,185,80,.45); color:#2e9e4f; opacity:1; }
|
|
316
|
+
.dm-chip.warn { border-color:rgba(210,153,34,.5); color:#9a6c12; opacity:1; }
|
|
317
|
+
.dm-chip.bad { border-color:rgba(248,81,73,.5); color:#c9362c; opacity:1; }
|
|
318
|
+
.dm-chip.ok { border-color:rgba(63,185,80,.4); color:#2e9e4f; opacity:1; }
|
|
319
|
+
|
|
320
|
+
/* 画布浮层控件 */
|
|
321
|
+
.dm-zoom { position:absolute; right:8px; bottom:8px; display:flex; gap:5px; padding:4px; border-radius:9px;
|
|
322
|
+
background:rgba(128,128,128,.12); backdrop-filter:blur(4px); z-index:5; }
|
|
323
|
+
.dm-zoom button { width:22px; height:22px; border-radius:6px; border:1px solid rgba(128,128,128,.3); background:transparent; color:inherit; cursor:pointer; font-size:13px; line-height:1; padding:0; display:flex; align-items:center; justify-content:center; }
|
|
324
|
+
.dm-zoom button:hover { background:rgba(128,128,128,.16); }
|
|
325
|
+
.dm-zoom .pct { font-size:10.5px; opacity:.6; align-self:center; padding:0 2px; font-variant-numeric:tabular-nums; }
|
|
326
|
+
.dm-hint { font-size:10.5px; opacity:.45; padding:0 2px; }
|
|
327
|
+
|
|
328
|
+
/* 折叠面板 */
|
|
329
|
+
.dm-details { border:1px solid rgba(128,128,128,.22); border-radius:11px; background:rgba(128,128,128,.035); }
|
|
330
|
+
.dm-details > summary { cursor:pointer; padding:9px 12px; font-weight:600; font-size:12.5px; list-style:none; display:flex; align-items:center; gap:8px; }
|
|
331
|
+
.dm-details > summary::-webkit-details-marker { display:none; }
|
|
332
|
+
.dm-details > summary::before { content:'▸'; font-size:10px; opacity:.5; transition:transform .12s; }
|
|
333
|
+
.dm-details[open] > summary::before { transform:rotate(90deg); }
|
|
334
|
+
.dm-details > .dm-details-body { padding:0 12px 11px; }
|
|
335
|
+
.dm-table { width:100%; border-collapse:collapse; }
|
|
336
|
+
.dm-table th { text-align:left; font-weight:600; font-size:11px; opacity:.5; padding:2px 8px 5px 0; border-bottom:1px solid rgba(128,128,128,.2); white-space:nowrap; }
|
|
337
|
+
.dm-table td { padding:5px 8px 5px 0; border-bottom:1px solid rgba(128,128,128,.1); font-size:12px; }
|
|
338
|
+
.dm-table tr:last-child td { border-bottom:none; }
|
|
339
|
+
/* 2026-11-10:委托台账表改为固定列宽 + 单行省略;非固定布局下 5 列会把「节点/任务」压到
|
|
340
|
+
每行一个字,就是用户看到的竖排。完整内容看 title 悬浮提示。 */
|
|
341
|
+
.dm-details .dm-table { table-layout:fixed; min-width:420px; }
|
|
342
|
+
.dm-table-wrap { overflow-x:auto; }
|
|
343
|
+
.dm-details .dm-table th, .dm-details .dm-table td { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
344
|
+
.dm-details .dm-table th:nth-child(1), .dm-details .dm-table td:nth-child(1) { width:16%; }
|
|
345
|
+
.dm-details .dm-table th:nth-child(2), .dm-details .dm-table td:nth-child(2) { width:18%; }
|
|
346
|
+
.dm-details .dm-table th:nth-child(3), .dm-details .dm-table td:nth-child(3) { width:22%; }
|
|
347
|
+
.dm-details .dm-table th:nth-child(4), .dm-details .dm-table td:nth-child(4) { width:10%; }
|
|
348
|
+
/* 目标列不写死百分比:让它吃掉剩余空间(单元格右内边距不参与百分比,写死 34% 会溢出 1 条横向滚动条) */
|
|
349
|
+
.dm-trunc { max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:inline-block; vertical-align:bottom; }
|
|
350
|
+
.dm-instrow { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:6px 0; border-bottom:1px solid rgba(128,128,128,.1); }
|
|
351
|
+
.dm-instrow:last-child { border-bottom:none; }
|
|
352
|
+
.dm-alertrow { display:flex; gap:8px; align-items:baseline; padding:5px 0; border-bottom:1px dashed rgba(128,128,128,.16); font-size:12px; }
|
|
353
|
+
.dm-alertrow:last-child { border-bottom:none; }
|
|
354
|
+
.dm-empty { opacity:.55; font-size:12px; padding:4px 0; }
|
|
355
|
+
.dm-err { color:#c9362c; font-size:12px; }
|
|
356
|
+
.dm-foot { font-size:11px; opacity:.45; }
|
|
357
|
+
@media (prefers-color-scheme: dark) {
|
|
358
|
+
.dm-pill.ok { color:#3fb950; } .dm-pill.bad { color:#ff7b72; } .dm-pill.warn { color:#d29922; }
|
|
359
|
+
.dm-btn.primary { color:#79b8ff; }
|
|
360
|
+
.dm-card-node { background:#1d2025; box-shadow:0 8px 26px rgba(0,0,0,.45); }
|
|
361
|
+
.dm-card-node.local { background:#19222e; }
|
|
362
|
+
.dm-card-node.down { background:rgba(90,32,32,.45); border-color:rgba(248,81,73,.65); }
|
|
363
|
+
.dm-offline-row { color:#ff7b72; }
|
|
364
|
+
.dm-dot.ok { background:#3fb950; } .dm-dot.bad { background:#ff7b72; }
|
|
365
|
+
.dm-chip.run, .dm-chip.ok { color:#3fb950; } .dm-chip.warn { color:#d29922; } .dm-chip.bad { color:#ff7b72; }
|
|
366
|
+
.dm-err { color:#ff7b72; }
|
|
367
|
+
.dm-details { background:rgba(255,255,255,.025); }
|
|
368
|
+
}
|
|
369
|
+
/* 2026-11-09 架构层:公网中转站(frps) */
|
|
370
|
+
/* 2026-11-09 拓扑自定义布局:卡片可拖拽 */
|
|
371
|
+
.dm-card-node.draggable { cursor:grab; touch-action:none; }
|
|
372
|
+
.dm-card-node.draggable:active { cursor:grabbing; }
|
|
373
|
+
.dm-card-node.dragging { z-index:20; border-color:rgba(88,166,255,.85) !important; box-shadow:0 12px 34px rgba(0,0,0,.30) !important; }
|
|
374
|
+
.dm-layout-btn.active { border-color:rgba(88,166,255,.6); color:#3b82d6; }
|
|
375
|
+
.dm-relay { border:1px solid rgba(128,128,128,.22); border-radius:12px; padding:10px 12px; background:rgba(128,128,128,.035); display:flex; flex-direction:column; gap:10px; }
|
|
376
|
+
.dm-relay-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
|
|
377
|
+
.dm-relay-title { font-size:12.5px; font-weight:650; }
|
|
378
|
+
.dm-relay-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:8px; border:1px dashed rgba(128,128,128,.26); border-radius:10px; }
|
|
379
|
+
.dm-relay-end { min-width:120px; display:flex; flex-direction:column; gap:2px; }
|
|
380
|
+
.dm-relay-name { font-size:12px; font-weight:650; }
|
|
381
|
+
.dm-relay-sub { font-size:10.5px; opacity:.55; }
|
|
382
|
+
.dm-relay-hub { min-width:190px; display:flex; flex-direction:column; gap:4px; padding:8px 10px; border:1.4px solid rgba(210,153,34,.55); border-radius:10px; background:rgba(210,153,34,.10); }
|
|
383
|
+
.dm-relay-chips { display:flex; gap:5px; flex-wrap:wrap; }
|
|
384
|
+
.dm-relay-link { display:flex; align-items:center; gap:5px; min-width:56px; color:rgba(128,128,128,.75); }
|
|
385
|
+
.dm-relay-line { flex:1 1 auto; height:0; border-top:1.6px dashed rgba(63,185,80,.55); }
|
|
386
|
+
.dm-relay-dirlabel { font-size:10px; opacity:.6; white-space:nowrap; }
|
|
387
|
+
.dm-relay-peers { display:flex; flex-direction:column; gap:4px; min-width:150px; flex:1 1 auto; }
|
|
388
|
+
.dm-relay-peer { display:flex; align-items:center; gap:6px; font-size:11.5px; }
|
|
389
|
+
.dm-relay-peer.down { opacity:.55; }
|
|
390
|
+
.dm-relay-peer-name { font-weight:600; }
|
|
391
|
+
.dm-relay-peer-meta { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:10.5px; opacity:.7; }
|
|
392
|
+
.dm-relay-direct { display:flex; align-items:center; gap:6px; flex-wrap:wrap; font-size:11px; }
|
|
393
|
+
.dm-relay-tag { margin-top:5px; font-size:10px; opacity:.75; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
|
394
|
+
`
|
|
395
|
+
|
|
396
|
+
function fetchStatus(force) {
|
|
397
|
+
return fetch(force ? API + '?force=1' : API, { headers: { accept: 'application/json' } }).then(function (res) { return res.json() })
|
|
398
|
+
}
|
|
399
|
+
function tone(verdict) {
|
|
400
|
+
if (verdict === 'ok') return 'ok'
|
|
401
|
+
if (verdict === 'down') return 'bad'
|
|
402
|
+
if (verdict === 'degraded') return 'warn'
|
|
403
|
+
return 'muted'
|
|
404
|
+
}
|
|
405
|
+
function ago(iso) {
|
|
406
|
+
if (!iso) return '—'
|
|
407
|
+
var diff = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000))
|
|
408
|
+
if (diff < 60) return diff + ' 秒前'
|
|
409
|
+
if (diff < 3600) return Math.round(diff / 60) + ' 分钟前'
|
|
410
|
+
if (diff < 86400) return Math.round(diff / 3600) + ' 小时前'
|
|
411
|
+
return Math.round(diff / 86400) + ' 天前'
|
|
412
|
+
}
|
|
413
|
+
function clockText(iso) {
|
|
414
|
+
if (!iso) return '—'
|
|
415
|
+
var d = new Date(iso)
|
|
416
|
+
if (isNaN(d.getTime())) return '—'
|
|
417
|
+
function p2(n) { return (n < 10 ? '0' : '') + n }
|
|
418
|
+
return p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds())
|
|
419
|
+
}
|
|
420
|
+
function uptimeText(sec) {
|
|
421
|
+
if (sec == null) return '—'
|
|
422
|
+
var s = Math.max(0, Number(sec))
|
|
423
|
+
var days = Math.floor(s / 86400)
|
|
424
|
+
var hours = Math.floor((s % 86400) / 3600)
|
|
425
|
+
var mins = Math.floor((s % 3600) / 60)
|
|
426
|
+
if (days > 0) return days + '天' + hours + '小时'
|
|
427
|
+
if (hours > 0) return hours + '小时' + mins + '分'
|
|
428
|
+
return mins + '分'
|
|
429
|
+
}
|
|
430
|
+
function dur(sec) {
|
|
431
|
+
if (sec == null) return '—'
|
|
432
|
+
if (sec < 60) return sec + 's'
|
|
433
|
+
if (sec < 3600) return Math.floor(sec / 60) + 'm' + (sec % 60) + 's'
|
|
434
|
+
return Math.floor(sec / 3600) + 'h' + Math.floor((sec % 3600) / 60) + 'm'
|
|
435
|
+
}
|
|
436
|
+
function stateChip(state) {
|
|
437
|
+
// 设置页表格里的状态也统一中文
|
|
438
|
+
var map = { done: 'ok', failed: 'bad', cancelled: 'muted', stalled: 'warn', working: 'run', question: 'warn', submitted: 'muted', accepted: 'muted' }
|
|
439
|
+
return h('span', { className: 'dm-chip ' + (map[state] || 'muted') }, DG_STATE_LABELS[state] || state || '未知')
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function Spark(props) {
|
|
443
|
+
var values = props.values || []
|
|
444
|
+
var pts = values.filter(function (v) { return v != null })
|
|
445
|
+
if (pts.length < 2) return h('span', { className: 'dm-sub' }, '—')
|
|
446
|
+
var min = Math.min.apply(null, pts)
|
|
447
|
+
var max = Math.max.apply(null, pts)
|
|
448
|
+
var span = max - min || 1
|
|
449
|
+
var SW = 54
|
|
450
|
+
var SH = 15
|
|
451
|
+
var d = []
|
|
452
|
+
var open = false
|
|
453
|
+
values.forEach(function (v, i) {
|
|
454
|
+
if (v == null) { open = false; return }
|
|
455
|
+
var x = (i / Math.max(1, values.length - 1)) * SW
|
|
456
|
+
var y = SH - ((v - min) / span) * (SH - 4) - 2
|
|
457
|
+
d.push((open ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1))
|
|
458
|
+
open = true
|
|
459
|
+
})
|
|
460
|
+
return h('svg', { width: SW, height: SH, viewBox: '0 0 ' + SW + ' ' + SH, style: { overflow: 'visible' } },
|
|
461
|
+
h('path', { d: d.join(' '), fill: 'none', stroke: props.up ? '#2e9e4f' : '#d14b41', strokeWidth: 1.6, strokeLinejoin: 'round', strokeLinecap: 'round' })
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------- 画布:缩放 / 平移 ----------
|
|
466
|
+
// 2026-11-10:当前筛选下可见的服务器数量(0/1/2),用于拓扑布局
|
|
467
|
+
function visibleServerCount(data, show) {
|
|
468
|
+
var s = show || { lan: true, public: true, sakura: true }
|
|
469
|
+
var d = data || {}
|
|
470
|
+
var peers = filterSelfPeers(dedupePeers(d.peers || []), d.nodeName)
|
|
471
|
+
var hasPublic = Boolean(publicEntryFor(d.localTunnel || {})) || peers.some(function (p) { var pt = p.tunnel || {}; return !isLanPeer(p) && Boolean(pt.host) && (pt.kind === 'frp' || pt.kind === 'wan') })
|
|
472
|
+
var acc = sakuraAccountOf(d)
|
|
473
|
+
var hasSakura = Boolean(acc && ((acc.tunnels || []).length || acc.error))
|
|
474
|
+
return (s.public && hasPublic ? 1 : 0) + (s.sakura && hasSakura ? 1 : 0)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function Viewport(props) {
|
|
478
|
+
var layout = props.layout
|
|
479
|
+
// 2026-11-10:按当前筛选的可见服务器数量决定布局(0/1/2)
|
|
480
|
+
var currentFilters = (function () { try { var s = JSON.parse(window.localStorage.getItem('dsh-mesh-ui.filters.v1') || '{}') || {}; return { lan: s.lan !== false, public: s.public !== false, sakura: s.sakura !== false } } catch (_) { return { lan: true, public: true, sakura: true } } })()
|
|
481
|
+
// 2026-11-10:布局服务器数使用快照;筛选切换只隐藏服务器,不触发重排。
|
|
482
|
+
var layoutServerState = useState(function () { return visibleServerCount(props.data, currentFilters) })
|
|
483
|
+
var layoutServerCount = layoutServerState[0]
|
|
484
|
+
var setLayoutServerCount = layoutServerState[1]
|
|
485
|
+
// 2026-11-09-v5:卡片宽度自适应 —— 子组件测量真实卡片宽高后回传,按实际尺寸重算多边形布局
|
|
486
|
+
var sizeState = useState(null)
|
|
487
|
+
var measuredSize = sizeState[0]
|
|
488
|
+
var setMeasuredSize = sizeState[1]
|
|
489
|
+
if (measuredSize && measuredSize.w > 0 && measuredSize.h > 0) {
|
|
490
|
+
layout = computeLayout(layout.count, { cardW: measuredSize.w, cardH: measuredSize.h, serverCount: layoutServerCount })
|
|
491
|
+
} else {
|
|
492
|
+
layout = computeLayout(layout.count, { serverCount: layoutServerCount })
|
|
493
|
+
}
|
|
494
|
+
var infraBoundsRef = useRef([])
|
|
495
|
+
// 2026-11-09:重置拓扑时同时通知 Topology 复位服务器卡片
|
|
496
|
+
var resetNonceState = useState(0)
|
|
497
|
+
var resetNonce = resetNonceState[0]
|
|
498
|
+
var bumpResetNonce = resetNonceState[1]
|
|
499
|
+
function onInfraBounds(boxes) { infraBoundsRef.current = boxes || [] }
|
|
500
|
+
function onCardSize(w, h) {
|
|
501
|
+
setMeasuredSize(function (prev) {
|
|
502
|
+
if (prev && prev.w === w && prev.h === h) return prev
|
|
503
|
+
return { w: w, h: h }
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
// 2026-11-09 自定义布局:moved[key] = [x,y](绝对世界坐标;localStorage 持久化,重置清空)
|
|
507
|
+
// 2026-11-09-v2:用户反馈「拖不远」→ 世界画布固定放大(可拖很开),自适应按实际内容 bbox 缩放,
|
|
508
|
+
// 而不是按初始画布尺寸;卡片只能在世界边界内移动(防止拖丢),但世界足够大。
|
|
509
|
+
var WORLD_MIN_W = 10000
|
|
510
|
+
var WORLD_MIN_H = 6000
|
|
511
|
+
var WORLD_PAD = 16
|
|
512
|
+
var nodeKeys = orderedNodeKeys(props.data)
|
|
513
|
+
var layoutState = useState(loadMovedLayout)
|
|
514
|
+
var movedLayout = layoutState[0]
|
|
515
|
+
var setMovedLayout = layoutState[1]
|
|
516
|
+
var worldW = Math.max(layout.W, WORLD_MIN_W)
|
|
517
|
+
var worldH = Math.max(layout.H, WORLD_MIN_H)
|
|
518
|
+
var baseOffsetX = (worldW - layout.W) / 2
|
|
519
|
+
var baseOffsetY = (worldH - layout.H) / 2
|
|
520
|
+
var positions = layout.positions.map(function (p, idx) {
|
|
521
|
+
var m = movedLayout[nodeKeys[idx]]
|
|
522
|
+
if (m && m.length === 2) return [Number(m[0]), Number(m[1])]
|
|
523
|
+
var devKey = devKeyOfNodeKey(nodeKeys[idx], props.data)
|
|
524
|
+
var def = (layout.mode === 'servers-top' || layout.mode === 'servers-top-single') ? DEFAULT_DEVICE_POS[devKey] : null
|
|
525
|
+
if (def) return [def[0], def[1]]
|
|
526
|
+
return [p[0] + baseOffsetX, p[1] + baseOffsetY]
|
|
527
|
+
})
|
|
528
|
+
// 2026-11-10:node positions 会被平移到世界画布中心;服务器中心也必须加同一偏移,否则单服务器布局会隔半个画布
|
|
529
|
+
var worldServerCenter = layout.serverCenter ? [layout.serverCenter[0] + baseOffsetX, layout.serverCenter[1] + baseOffsetY] : null
|
|
530
|
+
var effLayout = Object.assign({}, layout, { W: worldW, H: worldH, positions: positions, baseOffsetX: baseOffsetX, baseOffsetY: baseOffsetY, contentW: layout.W, contentH: layout.H, serverCenter: worldServerCenter })
|
|
531
|
+
function moveNode(key, pos) {
|
|
532
|
+
setMovedLayout(function (prev) {
|
|
533
|
+
var next = Object.assign({}, prev)
|
|
534
|
+
next[key] = pos
|
|
535
|
+
saveMovedLayout(next)
|
|
536
|
+
return next
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
function resetLayout() {
|
|
540
|
+
setMovedLayout({})
|
|
541
|
+
saveMovedLayout({})
|
|
542
|
+
// 只有点重置时才读取当前勾选服务器并重排;筛选本身不重绘
|
|
543
|
+
setLayoutServerCount(visibleServerCount(props.data, currentFilters))
|
|
544
|
+
// 2026-11-10:重置只恢复拓扑结构,保留用户手动拖拽的画布高度
|
|
545
|
+
interactedRef.current = false
|
|
546
|
+
bumpResetNonce(function (x) { return x + 1 })
|
|
547
|
+
setTimeout(fit, 160)
|
|
548
|
+
}
|
|
549
|
+
// 2026-11-09-v3:拖卡片到画布边缘时自动平移视角(不用手动挪画面就能拖很远)
|
|
550
|
+
function autoPanAt(clientX, clientY) {
|
|
551
|
+
var el = viewRef.current
|
|
552
|
+
if (!el) return
|
|
553
|
+
var rect = el.getBoundingClientRect()
|
|
554
|
+
var MARGIN = 60
|
|
555
|
+
var SPEED = 16
|
|
556
|
+
var dx = 0
|
|
557
|
+
var dy = 0
|
|
558
|
+
if (clientX < rect.left + MARGIN) dx = SPEED * Math.min(1, (rect.left + MARGIN - clientX) / MARGIN)
|
|
559
|
+
else if (clientX > rect.right - MARGIN) dx = -SPEED * Math.min(1, (clientX - (rect.right - MARGIN)) / MARGIN)
|
|
560
|
+
if (clientY < rect.top + MARGIN) dy = SPEED * Math.min(1, (rect.top + MARGIN - clientY) / MARGIN)
|
|
561
|
+
else if (clientY > rect.bottom - MARGIN) dy = -SPEED * Math.min(1, (clientY - (rect.bottom - MARGIN)) / MARGIN)
|
|
562
|
+
if (!dx && !dy) return
|
|
563
|
+
interactedRef.current = true
|
|
564
|
+
setView(function (prev) { return { scale: prev.scale, x: prev.x + dx, y: prev.y + dy } })
|
|
565
|
+
}
|
|
566
|
+
var viewRef = useRef(null)
|
|
567
|
+
// 2026-11-09:画布高度可上下拖拽调整(持久化;双击恢复默认)
|
|
568
|
+
var heightState = useState(function () { try { var v = Number(window.localStorage.getItem('dsh-mesh-ui.viewportH.v1') || 0); return v > 0 ? v : 0 } catch (e) { return 0 } })
|
|
569
|
+
var customHeight = heightState[0]
|
|
570
|
+
var setCustomHeight = heightState[1]
|
|
571
|
+
// 2026-11-10:画布默认高度自动跟随内容(避免固定 34vh 导致"拓扑区域又高又空");手动拖过则以手动为准
|
|
572
|
+
var autoHState = useState(0)
|
|
573
|
+
var autoH = autoHState[0]
|
|
574
|
+
var setAutoH = autoHState[1]
|
|
575
|
+
var customHeightRef = useRef(0)
|
|
576
|
+
var resizeRef = useRef(null)
|
|
577
|
+
var resizingState = useState(false); var resizing = resizingState[0]; var setResizing = resizingState[1]
|
|
578
|
+
function onResizeDown(e) {
|
|
579
|
+
if (e.button !== 0) return
|
|
580
|
+
e.preventDefault()
|
|
581
|
+
e.stopPropagation()
|
|
582
|
+
var el = viewRef.current
|
|
583
|
+
if (!el) return
|
|
584
|
+
resizeRef.current = { sy: e.clientY, h: el.getBoundingClientRect().height }
|
|
585
|
+
interactedRef.current = true
|
|
586
|
+
setResizing(true)
|
|
587
|
+
try { e.currentTarget.setPointerCapture(e.pointerId) } catch (_) { /* ignore */ }
|
|
588
|
+
}
|
|
589
|
+
function onResizeMove(e) {
|
|
590
|
+
var r = resizeRef.current
|
|
591
|
+
if (!r) return
|
|
592
|
+
e.stopPropagation()
|
|
593
|
+
var h2 = Math.max(240, Math.min(1600, Math.round(r.h + (e.clientY - r.sy))))
|
|
594
|
+
setCustomHeight(h2)
|
|
595
|
+
try { window.localStorage.setItem('dsh-mesh-ui.viewportH.v1', String(h2)) } catch (_) { /* ignore */ }
|
|
596
|
+
}
|
|
597
|
+
function onResizeUp(e) {
|
|
598
|
+
if (!resizeRef.current) return
|
|
599
|
+
e.stopPropagation()
|
|
600
|
+
resizeRef.current = null
|
|
601
|
+
setResizing(false)
|
|
602
|
+
interactedRef.current = false
|
|
603
|
+
setTimeout(fit, 0)
|
|
604
|
+
}
|
|
605
|
+
function resetHeight() {
|
|
606
|
+
setCustomHeight(0)
|
|
607
|
+
try { window.localStorage.removeItem('dsh-mesh-ui.viewportH.v1') } catch (_) { /* ignore */ }
|
|
608
|
+
setTimeout(fit, 0)
|
|
609
|
+
}
|
|
610
|
+
var dragRef = useRef(null)
|
|
611
|
+
var interactedRef = useRef(false)
|
|
612
|
+
var s0 = useState({ scale: 1, x: 0, y: 0 }); var view = s0[0]; var setView = s0[1]
|
|
613
|
+
var s1 = useState(false); var dragging = s1[0]; var setDragging = s1[1]
|
|
614
|
+
// 2026-11-09:三通道筛选(局域网/公网/樱花)
|
|
615
|
+
// 2026-11-10:筛选状态持久化;刷新/切页面后不再丢。
|
|
616
|
+
var FILTER_STORE_KEY = 'dsh-mesh-ui.filters.v1'
|
|
617
|
+
var filterState = useState(function () {
|
|
618
|
+
try {
|
|
619
|
+
var saved = JSON.parse(window.localStorage.getItem(FILTER_STORE_KEY) || '{}') || {}
|
|
620
|
+
return { lan: saved.lan !== false, public: saved.public !== false, sakura: saved.sakura !== false }
|
|
621
|
+
} catch (_) { return { lan: true, public: true, sakura: true } }
|
|
622
|
+
})
|
|
623
|
+
var showFilters = filterState[0]
|
|
624
|
+
var setShowFilters = filterState[1]
|
|
625
|
+
function toggleFilter(k) {
|
|
626
|
+
setShowFilters(function (prev) {
|
|
627
|
+
var n = Object.assign({}, prev)
|
|
628
|
+
n[k] = !n[k]
|
|
629
|
+
try { window.localStorage.setItem(FILTER_STORE_KEY, JSON.stringify(n)) } catch (_) { /* ignore */ }
|
|
630
|
+
return n
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
// fit 用最新布局的 ref(拖拽中不重算视角;只有初始化 / 窗口变化 / ⟲ 重置 / ⤢ 自适应才重算)
|
|
634
|
+
var effRef = useRef(effLayout)
|
|
635
|
+
effRef.current = effLayout
|
|
636
|
+
|
|
637
|
+
var fit = useCallback(function () {
|
|
638
|
+
var el = viewRef.current
|
|
639
|
+
if (!el) return
|
|
640
|
+
var rect = el.getBoundingClientRect()
|
|
641
|
+
if (!rect.width || !rect.height) return
|
|
642
|
+
var cur = effRef.current || layout
|
|
643
|
+
var halfW = cur.cardW / 2
|
|
644
|
+
var halfH = cur.cardH / 2
|
|
645
|
+
var xs = []
|
|
646
|
+
var ys = []
|
|
647
|
+
;(cur.positions || []).forEach(function (p) { xs.push(p[0]); ys.push(p[1]) })
|
|
648
|
+
if (!xs.length) { xs = [cur.W / 2]; ys = [cur.H / 2] }
|
|
649
|
+
var minX = Math.min.apply(null, xs) - halfW - WORLD_PAD
|
|
650
|
+
var maxX = Math.max.apply(null, xs) + halfW + WORLD_PAD
|
|
651
|
+
var minY = Math.min.apply(null, ys) - halfH - WORLD_PAD
|
|
652
|
+
var maxY = Math.max.apply(null, ys) + halfH + WORLD_PAD
|
|
653
|
+
// 2026-11-09:中转服务器卡片(阿里云/樱花)位置由 Topology 维护,按真实包围盒纳入自适应
|
|
654
|
+
;(infraBoundsRef.current || []).forEach(function (b) {
|
|
655
|
+
if (!b) return
|
|
656
|
+
minX = Math.min(minX, b.x - b.w / 2 - WORLD_PAD)
|
|
657
|
+
maxX = Math.max(maxX, b.x + b.w / 2 + WORLD_PAD)
|
|
658
|
+
minY = Math.min(minY, b.y - b.h / 2 - WORLD_PAD)
|
|
659
|
+
maxY = Math.max(maxY, b.y + b.h / 2 + WORLD_PAD)
|
|
660
|
+
})
|
|
661
|
+
var bw = Math.max(1, maxX - minX)
|
|
662
|
+
var bh = Math.max(1, maxY - minY)
|
|
663
|
+
var scale = Math.min(rect.width / bw, rect.height / bh) * 0.94
|
|
664
|
+
scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale))
|
|
665
|
+
setView({ scale: scale, x: (rect.width - bw * scale) / 2 - minX * scale, y: (rect.height - bh * scale) / 2 - minY * scale })
|
|
666
|
+
// 画布默认高度 = 内容渲染高度 + 余量(仅当用户没手动调过高度)
|
|
667
|
+
if (!customHeightRef.current) {
|
|
668
|
+
var wantH = Math.max(150, Math.min(380, Math.round(bh * scale + 64)))
|
|
669
|
+
setAutoH(function (prev) { return Math.abs(prev - wantH) >= 10 ? wantH : prev })
|
|
670
|
+
}
|
|
671
|
+
}, [layout.W, layout.H])
|
|
672
|
+
|
|
673
|
+
useEffect(function () {
|
|
674
|
+
fit()
|
|
675
|
+
var el = viewRef.current
|
|
676
|
+
if (!el || typeof ResizeObserver === 'undefined') return
|
|
677
|
+
var ro = new ResizeObserver(function () {
|
|
678
|
+
if (!interactedRef.current) fit()
|
|
679
|
+
})
|
|
680
|
+
ro.observe(el)
|
|
681
|
+
return function () { try { ro.disconnect() } catch (_) { /* ignore */ } }
|
|
682
|
+
}, [fit])
|
|
683
|
+
|
|
684
|
+
// 2026-11-10:布局变化(节点数/卡片尺寸)后自动 fit(用户未手动缩放/平移时),避免停留在旧的缩放比例
|
|
685
|
+
useEffect(function () {
|
|
686
|
+
if (!interactedRef.current) setTimeout(fit, 40)
|
|
687
|
+
}, [layout.count, layout.cardW, layout.cardH, layout.mode])
|
|
688
|
+
|
|
689
|
+
useEffect(function () {
|
|
690
|
+
var el = viewRef.current
|
|
691
|
+
if (!el) return
|
|
692
|
+
function onWheel(event) {
|
|
693
|
+
event.preventDefault()
|
|
694
|
+
var rect = el.getBoundingClientRect()
|
|
695
|
+
var px = event.clientX - rect.left
|
|
696
|
+
var py = event.clientY - rect.top
|
|
697
|
+
var factor = Math.pow(1.0015, -event.deltaY)
|
|
698
|
+
interactedRef.current = true
|
|
699
|
+
setView(function (prev) {
|
|
700
|
+
var next = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * factor))
|
|
701
|
+
var k = next / prev.scale
|
|
702
|
+
return { scale: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k }
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
el.addEventListener('wheel', onWheel, { passive: false })
|
|
706
|
+
return function () { el.removeEventListener('wheel', onWheel) }
|
|
707
|
+
}, [])
|
|
708
|
+
|
|
709
|
+
function onPointerDown(event) {
|
|
710
|
+
if (event.button !== 0) return
|
|
711
|
+
if (event.target && event.target.closest && event.target.closest('button,a,summary,.dm-filters,.dm-zoom,.dm-resize')) return
|
|
712
|
+
dragRef.current = { x: event.clientX, y: event.clientY, vx: view.x, vy: view.y }
|
|
713
|
+
setDragging(true)
|
|
714
|
+
event.currentTarget.setPointerCapture && event.currentTarget.setPointerCapture(event.pointerId)
|
|
715
|
+
}
|
|
716
|
+
function onPointerMove(event) {
|
|
717
|
+
var drag = dragRef.current
|
|
718
|
+
if (!drag) return
|
|
719
|
+
interactedRef.current = true
|
|
720
|
+
setView(function (prev) { return { scale: prev.scale, x: drag.vx + (event.clientX - drag.x), y: drag.vy + (event.clientY - drag.y) } })
|
|
721
|
+
}
|
|
722
|
+
function endDrag() { dragRef.current = null; setDragging(false) }
|
|
723
|
+
|
|
724
|
+
function zoomBy(multiplier) {
|
|
725
|
+
var el = viewRef.current
|
|
726
|
+
if (!el) return
|
|
727
|
+
var rect = el.getBoundingClientRect()
|
|
728
|
+
var px = rect.width / 2
|
|
729
|
+
var py = rect.height / 2
|
|
730
|
+
interactedRef.current = true
|
|
731
|
+
setView(function (prev) {
|
|
732
|
+
var next = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * multiplier))
|
|
733
|
+
var k = next / prev.scale
|
|
734
|
+
return { scale: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k }
|
|
735
|
+
})
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
customHeightRef.current = customHeight
|
|
739
|
+
customHeightRef.current = customHeight
|
|
740
|
+
// 2026-11-10:画布内的文字/勾选项全部移到画布外(上方工具栏 = 勾选 + 链路信息;下方 = 缩放/说明)
|
|
741
|
+
return h('div', { className: 'dm-topo-wrap' },
|
|
742
|
+
h('div', { className: 'dm-topbar' },
|
|
743
|
+
h('div', { className: 'dm-filters' },
|
|
744
|
+
[['lan', '局域网'], ['public', '公网中继'], ['sakura', '第三方穿透']].map(function (it) {
|
|
745
|
+
var tr = TRANSPORTS[it[0]]
|
|
746
|
+
return h('label', { key: it[0], className: 'dm-flt' + (showFilters[it[0]] ? ' on' : ''), style: { '--c': tr.color } },
|
|
747
|
+
h('input', { type: 'checkbox', checked: showFilters[it[0]], onChange: function () { toggleFilter(it[0]) } }),
|
|
748
|
+
h('span', null, it[1])
|
|
749
|
+
)
|
|
750
|
+
})
|
|
751
|
+
),
|
|
752
|
+
h('span', { className: 'dm-topbar-info' }, effLayout.count + ' 节点 · ' + effLayout.edges.length + ' 条链路 · 拖拽卡片自定义布局')
|
|
753
|
+
),
|
|
754
|
+
h('div', { className: 'dm-viewport' + (dragging ? ' dragging' : '') + (resizing ? ' resizing' : ''), ref: viewRef,
|
|
755
|
+
style: (customHeight || autoH) ? { height: (customHeight || autoH) + 'px' } : undefined,
|
|
756
|
+
onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: endDrag, onPointerCancel: endDrag, onPointerLeave: endDrag,
|
|
757
|
+
onDoubleClick: function () { interactedRef.current = false; fit() } },
|
|
758
|
+
// 2026-11-07:缩放改用 CSS zoom(重新布局、文字矢量重绘 → 放大不糊);
|
|
759
|
+
// 平移用外层 transform(不参与光栅缓存)。不再使用 will-change,避免整块被缓存成位图后放大发虚。
|
|
760
|
+
h('div', { className: 'dm-world-outer', style: { transform: 'translate(' + view.x + 'px,' + view.y + 'px)' } },
|
|
761
|
+
h('div', { className: 'dm-world', style: Object.assign({ width: effLayout.W + 'px', height: effLayout.H + 'px' },
|
|
762
|
+
SUPPORTS_ZOOM ? { zoom: view.scale } : { transform: 'scale(' + view.scale + ')' }) },
|
|
763
|
+
h(Topology, { data: props.data, layout: effLayout, selectedKey: props.selectedKey, onSelect: props.onSelect, scale: view.scale, onNodeMove: moveNode, onDragEdge: autoPanAt, onCardSize: onCardSize, show: showFilters, federated: props.federated, onInfraBounds: onInfraBounds, resetNonce: resetNonce })
|
|
764
|
+
)
|
|
765
|
+
),
|
|
766
|
+
h('div', { className: 'dm-zoom' },
|
|
767
|
+
h('button', { title: '放大', onClick: function () { zoomBy(1.22) } }, '+'),
|
|
768
|
+
h('button', { title: '缩小', onClick: function () { zoomBy(1 / 1.22) } }, '−'),
|
|
769
|
+
h('button', { title: '自适应', onClick: function () { interactedRef.current = false; fit() } }, '⤢'),
|
|
770
|
+
h('button', { className: 'dm-layout-btn', title: '重置为默认拓扑布局', onClick: resetLayout }, '↺'),
|
|
771
|
+
h('span', { className: 'pct' }, Math.round(view.scale * 100) + '%')
|
|
772
|
+
),
|
|
773
|
+
h('div', { className: 'dm-resize', title: '上下拖拽调整画布高度(双击恢复默认)',
|
|
774
|
+
onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onPointerCancel: onResizeUp,
|
|
775
|
+
onDoubleClick: resetHeight },
|
|
776
|
+
h('span', null, '⋯')
|
|
777
|
+
)
|
|
778
|
+
),
|
|
779
|
+
h('div', { className: 'dm-hint' }, '拓扑 v23 · 内容 ' + Math.round(layout.W) + '×' + Math.round(layout.H) + 'px · 卡 ' + Math.round(layout.cardW) + '×' + Math.round(layout.cardH) + ' · 缩放 ' + Math.round(view.scale * 100) + '% · 滚轮缩放 / 拖拽平移')
|
|
780
|
+
)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ---------- 布局计算 ----------
|
|
784
|
+
// 用户偏好(2026-11-07 明确):
|
|
785
|
+
// 4 节点 = 菱形(上/右/下/左);5 节点 = 顶点朝上的五边形;6 = 六边形;n 节点 = 顶点朝上的正 n 边形。
|
|
786
|
+
// 相邻节点的「轴对齐包围盒」必须留出 GAP,否则菱形斜向两张卡会贴住/重叠。
|
|
787
|
+
// 2026-11-09-v6:默认布局 = 顶部「阿里云 / 樱花」服务器一排 + 设备沿下方弧线扇形展开(用户指定)
|
|
788
|
+
|
|
789
|
+
// 2026-11-10:按可见服务器数量布局
|
|
790
|
+
// 0 个服务器:仅节点,圆环居中;1 个服务器:服务器居中,节点在下方扇形展开;2 个:顶部服务器 + 下方弧线(原布局)
|
|
791
|
+
function polygonEdges(count) {
|
|
792
|
+
var edges = []
|
|
793
|
+
for (var m = 0; m < count; m++) for (var n = m + 1; n < count; n++) edges.push([m, n])
|
|
794
|
+
return edges
|
|
795
|
+
}
|
|
796
|
+
// 2026-11-10:按矩形碰撞检测扩大半径,保证默认布局卡片不重叠
|
|
797
|
+
function fanHasOverlap(points, cardW, cardH, GAP, hasServer) {
|
|
798
|
+
var halfW = cardW / 2
|
|
799
|
+
var halfH = cardH / 2
|
|
800
|
+
for (var i = 0; i < points.length; i++) {
|
|
801
|
+
for (var j = i + 1; j < points.length; j++) {
|
|
802
|
+
if (Math.abs(points[i][0] - points[j][0]) < cardW + GAP && Math.abs(points[i][1] - points[j][1]) < cardH + GAP) return true
|
|
803
|
+
}
|
|
804
|
+
if (hasServer) {
|
|
805
|
+
var serverW = Math.max(cardW * 1.35, 280)
|
|
806
|
+
var serverH = Math.max(cardH, 96)
|
|
807
|
+
if (Math.abs(points[i][0]) < (cardW + serverW) / 2 + GAP && Math.abs(points[i][1]) < (cardH + serverH) / 2 + GAP) return true
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return false
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function layoutNodesOnly(count, cardW, cardH, GAP, PAD) {
|
|
814
|
+
var n = Math.max(1, count)
|
|
815
|
+
var radius = n > 1 ? Math.max((cardW + GAP) / (2 * Math.sin(Math.PI / n)), cardH * 1.2) : 0
|
|
816
|
+
function nodePoints(r) {
|
|
817
|
+
var pts = []
|
|
818
|
+
for (var i = 0; i < n; i++) {
|
|
819
|
+
var a = -Math.PI / 2 + (i * 2 * Math.PI) / n
|
|
820
|
+
pts.push([r * Math.cos(a), r * Math.sin(a)])
|
|
821
|
+
}
|
|
822
|
+
return pts
|
|
823
|
+
}
|
|
824
|
+
var raw = nodePoints(radius)
|
|
825
|
+
for (var tries = 0; tries < 240 && n > 1 && fanHasOverlap(raw, cardW, cardH, GAP, false); tries++) {
|
|
826
|
+
radius += 8
|
|
827
|
+
raw = nodePoints(radius)
|
|
828
|
+
}
|
|
829
|
+
var minX = Math.min(-cardW / 2, ...raw.map(function (p) { return p[0] - cardW / 2 }))
|
|
830
|
+
var maxX = Math.max(cardW / 2, ...raw.map(function (p) { return p[0] + cardW / 2 }))
|
|
831
|
+
var minY = Math.min(-cardH / 2, ...raw.map(function (p) { return p[1] - cardH / 2 }))
|
|
832
|
+
var maxY = Math.max(cardH / 2, ...raw.map(function (p) { return p[1] + cardH / 2 }))
|
|
833
|
+
var ox = -minX + PAD
|
|
834
|
+
var oy = -minY + PAD
|
|
835
|
+
var positions = raw.map(function (p) { return [Math.round(p[0] + ox), Math.round(p[1] + oy)] })
|
|
836
|
+
var W = Math.ceil(maxX - minX + PAD * 2)
|
|
837
|
+
var H = Math.ceil(maxY - minY + PAD * 2)
|
|
838
|
+
var topDeviceY = positions.length ? Math.min.apply(null, positions.map(function (p) { return p[1] })) : 0
|
|
839
|
+
return { count: n, cardW: cardW, cardH: cardH, gap: GAP, W: W, H: H, positions: positions, edges: polygonEdges(n), topDeviceY: topDeviceY, mode: 'nodes-only', serverCenter: null }
|
|
840
|
+
}
|
|
841
|
+
// 2026-11-10:一个服务器时用紧凑两排扇形,不再靠扩大半径防重叠
|
|
842
|
+
// 服务器在上方居中;设备分两排(上排少、下排多),列距/行距按卡片尺寸,整体宽度接近双服务器布局。
|
|
843
|
+
function layoutServerCenter(count, cardW, cardH, GAP, PAD) {
|
|
844
|
+
var n = Math.max(1, count)
|
|
845
|
+
var topN = Math.floor(n / 2)
|
|
846
|
+
var botN = n - topN
|
|
847
|
+
var serverH = Math.max(cardH, 96)
|
|
848
|
+
var stepX = cardW + GAP
|
|
849
|
+
var upperY = serverH / 2 + GAP + cardH / 2
|
|
850
|
+
var lowerY = upperY + cardH + GAP
|
|
851
|
+
var raw = []
|
|
852
|
+
function rowXs(k) {
|
|
853
|
+
var xs = []
|
|
854
|
+
for (var i = 0; i < k; i++) xs.push((i - (k - 1) / 2) * stepX)
|
|
855
|
+
return xs
|
|
856
|
+
}
|
|
857
|
+
var ux = rowXs(topN)
|
|
858
|
+
for (var i = 0; i < topN; i++) raw.push([ux[i], upperY])
|
|
859
|
+
var bx = rowXs(botN)
|
|
860
|
+
var by = topN > 0 ? lowerY : upperY
|
|
861
|
+
for (var j = 0; j < botN; j++) raw.push([bx[j], by])
|
|
862
|
+
var minX = Math.min(-cardW / 2, ...raw.map(function (p) { return p[0] - cardW / 2 }))
|
|
863
|
+
var maxX = Math.max(cardW / 2, ...raw.map(function (p) { return p[0] + cardW / 2 }))
|
|
864
|
+
var minY = -serverH / 2
|
|
865
|
+
var maxY = Math.max(cardH / 2, ...raw.map(function (p) { return p[1] + cardH / 2 }))
|
|
866
|
+
var ox = -minX + PAD
|
|
867
|
+
var oy = -minY + PAD
|
|
868
|
+
var positions = raw.map(function (p) { return [Math.round(p[0] + ox), Math.round(p[1] + oy)] })
|
|
869
|
+
var W = Math.ceil(maxX - minX + PAD * 2)
|
|
870
|
+
var H = Math.ceil(maxY - minY + PAD * 2)
|
|
871
|
+
var topDeviceY = positions.length ? Math.min.apply(null, positions.map(function (p) { return p[1] })) : 0
|
|
872
|
+
return { count: n, cardW: cardW, cardH: cardH, gap: GAP, W: W, H: H, positions: positions, edges: polygonEdges(n), topDeviceY: topDeviceY, mode: 'server-center', serverCenter: [Math.round(ox), Math.round(oy)] }
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function computeLayout(count, override) {
|
|
876
|
+
var GAP = 30
|
|
877
|
+
var cardW = count <= 4 ? 210 : count <= 6 ? 196 : 176
|
|
878
|
+
var cardH = count <= 4 ? 112 : count <= 6 ? 104 : 96
|
|
879
|
+
if (override && override.cardW > 0) cardW = Math.ceil(override.cardW)
|
|
880
|
+
if (override && override.cardH > 0) cardH = Math.ceil(override.cardH)
|
|
881
|
+
var PAD = 16
|
|
882
|
+
var serverCount = override && Number.isInteger(override.serverCount) ? override.serverCount : 2
|
|
883
|
+
if (serverCount === 0) return layoutNodesOnly(count, cardW, cardH, GAP, PAD)
|
|
884
|
+
var topTierH = Math.round(cardH * 1.1)
|
|
885
|
+
var W, H, positions
|
|
886
|
+
if (count <= 1) {
|
|
887
|
+
W = cardW + PAD * 2
|
|
888
|
+
H = topTierH + cardH + PAD * 2
|
|
889
|
+
positions = [[W / 2, topTierH + cardH / 2 + PAD]]
|
|
890
|
+
} else if (count === 2) {
|
|
891
|
+
W = cardW * 2 + GAP + PAD * 2
|
|
892
|
+
H = topTierH + cardH + PAD * 2
|
|
893
|
+
positions = [[PAD + cardW / 2, topTierH + cardH / 2 + PAD], [PAD + cardW * 1.5 + GAP, topTierH + cardH / 2 + PAD]]
|
|
894
|
+
} else {
|
|
895
|
+
// 2026-11-09-v7:浅扁椭圆弧扇形(横向铺开、纵向贴近顶部服务器,压缩中间空白)
|
|
896
|
+
var START = 150 * Math.PI / 180
|
|
897
|
+
var END = 30 * Math.PI / 180
|
|
898
|
+
var stepA = (START - END) / (count - 1)
|
|
899
|
+
var MARGIN = 22
|
|
900
|
+
var A = (cardW + MARGIN) / (2 * Math.sin(stepA / 2))
|
|
901
|
+
for (var step = 0; step < 400; step++) {
|
|
902
|
+
var ok = true
|
|
903
|
+
for (var i = 0; i < count - 1 && ok; i++) {
|
|
904
|
+
var a1 = START - i * stepA
|
|
905
|
+
var a2 = START - (i + 1) * stepA
|
|
906
|
+
var dx = Math.abs(A * Math.cos(a1) - A * Math.cos(a2))
|
|
907
|
+
var dy = Math.abs(0.35 * A * Math.sin(a1) - 0.35 * A * Math.sin(a2))
|
|
908
|
+
if (dx < cardW + MARGIN * 0.5 && dy < cardH + MARGIN * 0.5) ok = false
|
|
909
|
+
}
|
|
910
|
+
if (ok) break
|
|
911
|
+
A += 4
|
|
912
|
+
}
|
|
913
|
+
var B = A * 0.35
|
|
914
|
+
var halfW = A * Math.cos(END) + cardW / 2
|
|
915
|
+
W = Math.ceil(2 * halfW + PAD * 2)
|
|
916
|
+
var cx = W / 2
|
|
917
|
+
var cy = topTierH + PAD
|
|
918
|
+
H = Math.ceil(cy + B + cardH / 2 + PAD)
|
|
919
|
+
positions = []
|
|
920
|
+
for (var j = 0; j < count; j++) {
|
|
921
|
+
var aj = START - j * stepA
|
|
922
|
+
positions.push([cx + A * Math.cos(aj), cy + B * Math.sin(aj)])
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
var edges = []
|
|
926
|
+
for (var m = 0; m < count; m++) for (var n = m + 1; n < count; n++) edges.push([m, n])
|
|
927
|
+
var topDeviceY = positions.length ? Math.min.apply(null, positions.map(function (p) { return p[1] })) : 0
|
|
928
|
+
return { count: count, cardW: cardW, cardH: cardH, gap: GAP, W: W, H: H, positions: positions, edges: edges, topDeviceY: topDeviceY, mode: serverCount === 1 ? 'servers-top-single' : 'servers-top', serverCenter: null }
|
|
929
|
+
}
|
|
930
|
+
// ---------- 架构层:公网中转站(frps)与各节点通信关系(2026-11-09) ----------
|
|
931
|
+
function RelayLayer(props) {
|
|
932
|
+
var data = props.data || {}
|
|
933
|
+
var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
|
|
934
|
+
var groups = relayGroups(peers)
|
|
935
|
+
var relayHosts = relayHostSet(peers)
|
|
936
|
+
var direct = peers.filter(function (p) { var t = p.tunnel || {}; return t.kind === 'lan' || t.kind === 'loopback' })
|
|
937
|
+
var instances = (data.local && data.local.instances) || []
|
|
938
|
+
var localUp = instances.filter(function (x) { return x.up }).length
|
|
939
|
+
// 2026-11-09:樱花账号隧道(PC 端 API 采集,跨设备含手机)
|
|
940
|
+
var sakuraAccount = sakuraAccountOf(data)
|
|
941
|
+
if (!groups.length && !direct.length && !sakuraAccount) return null
|
|
942
|
+
return h('div', { className: 'dm-relay' },
|
|
943
|
+
h('div', { className: 'dm-relay-head' },
|
|
944
|
+
h('span', { className: 'dm-relay-title' }, '架构层 · 公网中转站'),
|
|
945
|
+
h('span', { className: 'dm-sub' }, groups.length ? '虚线 = 经中转;实线/标签 = 直连。端口是各节点在中转站上的隧道入口' : '当前没有共享公网中转的 peer,全部为直连')
|
|
946
|
+
),
|
|
947
|
+
groups.map(function (g, gi) {
|
|
948
|
+
var ports = Object.keys(g.ports).sort()
|
|
949
|
+
return h('div', { className: 'dm-relay-row', key: 'g' + gi },
|
|
950
|
+
h('div', { className: 'dm-relay-end' },
|
|
951
|
+
h('div', { className: 'dm-relay-name' }, nodeDisplay(data.nodeName) + '(本机)'),
|
|
952
|
+
h('div', { className: 'dm-relay-sub' }, 'DSH ' + localUp + '/' + instances.length + ' 在线')
|
|
953
|
+
),
|
|
954
|
+
h('div', { className: 'dm-relay-link' }, h('span', { className: 'dm-relay-line' }), h('span', { className: 'dm-relay-dirlabel' }, '出站')),
|
|
955
|
+
h('div', { className: 'dm-relay-hub' },
|
|
956
|
+
h('div', { className: 'dm-relay-name' }, '公网中转站 · frps'),
|
|
957
|
+
h('div', { className: 'dm-relay-sub dm-mono' }, maskHost(g.host)),
|
|
958
|
+
h('div', { className: 'dm-relay-chips' },
|
|
959
|
+
ports.map(function (p) { return h('span', { className: 'dm-chip', key: p, title: '隧道入口 :' + p }, ':' + p + ' ×' + g.ports[p]) }),
|
|
960
|
+
h('span', { className: 'dm-chip ' + (g.up === g.peers.length ? 'run' : 'bad') }, '在线 ' + g.up + '/' + g.peers.length)
|
|
961
|
+
)
|
|
962
|
+
),
|
|
963
|
+
h('div', { className: 'dm-relay-link' }, h('span', { className: 'dm-relay-line' }), h('span', { className: 'dm-relay-dirlabel' }, '中转')),
|
|
964
|
+
h('div', { className: 'dm-relay-peers' }, g.peers.map(function (peer) {
|
|
965
|
+
var t = peer.tunnel || {}
|
|
966
|
+
return h('div', { className: 'dm-relay-peer' + (peer.up ? '' : ' down'), key: peer.name },
|
|
967
|
+
h('span', { className: 'dm-dot ' + (peer.up ? 'ok' : 'bad') }),
|
|
968
|
+
h('span', { className: 'dm-relay-peer-name' }, nodeDisplay(peerDeviceKey(peer.name))),
|
|
969
|
+
h('span', { className: 'dm-relay-peer-meta' }, (t.port ? ':' + t.port : '') + (peer.up && peer.latencyMs != null ? ' · ' + peer.latencyMs + 'ms' : ' · 离线'))
|
|
970
|
+
)
|
|
971
|
+
}))
|
|
972
|
+
)
|
|
973
|
+
}),
|
|
974
|
+
sakuraAccount && sakuraAccount.available ? h('div', { className: 'dm-relay-direct' },
|
|
975
|
+
h('span', { className: 'dm-k' }, '樱花隧道:'),
|
|
976
|
+
h('span', { className: 'dm-chip ' + (sakuraAccount.online === sakuraAccount.total ? 'run' : 'bad') }, sakuraAccount.online + '/' + sakuraAccount.total + ' 在线'),
|
|
977
|
+
(sakuraAccount.tunnels || []).map(function (t) {
|
|
978
|
+
return h('span', { className: 'dm-chip ' + (t.online ? 'run' : 'bad'), key: 'st' + t.id, title: (t.nodeName || '') + ' ' + maskText(t.addr) + (t.online ? '' : '(离线)') },
|
|
979
|
+
(t.deviceKey || t.name) + ' ' + maskText(t.addr))
|
|
980
|
+
})
|
|
981
|
+
) : null,
|
|
982
|
+
direct.length ? h('div', { className: 'dm-relay-direct' },
|
|
983
|
+
h('span', { className: 'dm-k' }, '直连:'),
|
|
984
|
+
direct.map(function (peer) {
|
|
985
|
+
var c = commLabel(peer, relayHosts)
|
|
986
|
+
return h('span', { className: 'dm-chip ' + (peer.up ? 'run' : 'bad'), key: peer.name, title: c.text }, nodeDisplay(peerDeviceKey(peer.name)) + ' · ' + c.text)
|
|
987
|
+
})
|
|
988
|
+
) : null
|
|
989
|
+
)
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ===== 2026-11-09 传输层封装:局域网 / 阿里云(公网) / 樱花穿透 三通道 =====
|
|
993
|
+
var TRANSPORTS = {
|
|
994
|
+
lan: { label: '局域网直连', short: 'LAN', color: '#2e9e4f', dash: false },
|
|
995
|
+
public: { label: '公网服务器', short: 'PUB', color: '#4d9bff', dash: true },
|
|
996
|
+
sakura: { label: '樱花穿透', short: 'SAK', color: '#a371f7', dash: true }
|
|
997
|
+
}
|
|
998
|
+
var DOWN_COLOR = "rgba(209,75,65,.7)"
|
|
999
|
+
// LAN peer 判定:-lan 后缀 / 内网 IP / kind=lan(实际数据里 peer-node-lan 的 kind 是 frp)
|
|
1000
|
+
function isLanPeer(peer) {
|
|
1001
|
+
var t = (peer && peer.tunnel) || {}
|
|
1002
|
+
var name = String((peer && peer.name) || '')
|
|
1003
|
+
var host = String(t.host || '')
|
|
1004
|
+
if (/-lan$/i.test(name)) return true
|
|
1005
|
+
if (t.kind === 'lan' || t.kind === 'loopback') return true
|
|
1006
|
+
if (/^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(host)) return true
|
|
1007
|
+
return false
|
|
1008
|
+
}
|
|
1009
|
+
// publicEntry 可能来自机器级 frpc;只有它映射的 localPort 与当前 mesh server 端口一致才属于本节点。
|
|
1010
|
+
function publicEntryFor(localTunnel) {
|
|
1011
|
+
var lt = localTunnel || {}
|
|
1012
|
+
var entry = lt.publicEntry || lt.selfHosted
|
|
1013
|
+
if (!entry || !entry.host) return null
|
|
1014
|
+
var nodePort = Number(lt.port)
|
|
1015
|
+
if (nodePort && entry.localPort != null && Number(entry.localPort) !== nodePort) return null
|
|
1016
|
+
return entry
|
|
1017
|
+
}
|
|
1018
|
+
function tunnelMatchesNode(port) {
|
|
1019
|
+
var nodePort = Number(port)
|
|
1020
|
+
return function (tunnel) {
|
|
1021
|
+
if (!nodePort || tunnel.localPort == null) return true
|
|
1022
|
+
return Number(tunnel.localPort) === nodePort
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
function sakuraAccountOf(data) {
|
|
1026
|
+
var acc = data && data.tunnels && data.tunnels.sakuraAccount
|
|
1027
|
+
var local = (data && data.tunnels && data.tunnels.sakura && data.tunnels.sakura.tunnels) || []
|
|
1028
|
+
if (!acc && !local.length) return null
|
|
1029
|
+
var localMapped = local.map(function (t) {
|
|
1030
|
+
return {
|
|
1031
|
+
id: t.name + ':' + t.host + ':' + t.port,
|
|
1032
|
+
name: t.name,
|
|
1033
|
+
host: t.host,
|
|
1034
|
+
remotePort: t.port,
|
|
1035
|
+
localPort: t.localPort == null ? null : Number(t.localPort),
|
|
1036
|
+
online: t.probe ? t.probe.up === true : t.online === true,
|
|
1037
|
+
deviceKey: peerDeviceKey(t.name),
|
|
1038
|
+
addr: (t.host || '') + ':' + (t.port || '')
|
|
1039
|
+
}
|
|
1040
|
+
})
|
|
1041
|
+
var list = (acc && acc.tunnels && acc.tunnels.length) ? acc.tunnels : localMapped
|
|
1042
|
+
if (!acc && !list.length) return null
|
|
1043
|
+
var base = acc ? Object.assign({}, acc) : { available: false, error: '账号 API 暂不可用', total: list.length, online: list.filter(function (x) { return x.online }).length, tunnels: list }
|
|
1044
|
+
if (localMapped.length && (!acc || acc.available !== true || !(acc.tunnels || []).length)) {
|
|
1045
|
+
base.available = true
|
|
1046
|
+
base.source = 'local-log'
|
|
1047
|
+
base.error = null
|
|
1048
|
+
base.total = list.length
|
|
1049
|
+
base.online = list.filter(function (x) { return x.online }).length
|
|
1050
|
+
base.tunnels = list
|
|
1051
|
+
}
|
|
1052
|
+
var port = Number(data && data.localTunnel && data.localTunnel.port)
|
|
1053
|
+
var filtered = list
|
|
1054
|
+
if (port) filtered = list.filter(function (x) { return x.localPort == null || Number(x.localPort) === port })
|
|
1055
|
+
return Object.assign({}, base, { tunnels: filtered, total: filtered.length, online: filtered.filter(function (x) { return x.online }).length })
|
|
1056
|
+
}
|
|
1057
|
+
function sakuraTunnelOf(data, devKey) {
|
|
1058
|
+
var acc = sakuraAccountOf(data)
|
|
1059
|
+
if (!acc) return null
|
|
1060
|
+
var k = peerDeviceKey(devKey)
|
|
1061
|
+
for (var i = 0; i < (acc.tunnels || []).length; i++) if (acc.tunnels[i].deviceKey === k) return acc.tunnels[i]
|
|
1062
|
+
return null
|
|
1063
|
+
}
|
|
1064
|
+
// 设备状态 = 任一链路存活(全断才离线/红)
|
|
1065
|
+
function deviceLinkState(data, peer, lanMap) {
|
|
1066
|
+
var dk = peerDeviceKey(peer.name)
|
|
1067
|
+
var t = peer.tunnel || {}
|
|
1068
|
+
// 2026-11-10:蓝线状态以隧道端口 TCP 探测为准(HTTP /health 对高延迟节点会误判断连)
|
|
1069
|
+
var probe = t.probe && typeof t.probe.up === "boolean" ? t.probe : null
|
|
1070
|
+
var publicUp = (probe ? probe.up === true : false) || Boolean(peer.up)
|
|
1071
|
+
var lan = lanMap ? lanMap[dk] : (t.kind === 'lan' ? { up: Boolean(peer.up), host: t.host, port: t.port, ip: t.ip || '' } : null)
|
|
1072
|
+
var lanUp = Boolean(lan && (lan.probe && lan.probe.up === true ? true : lan.up))
|
|
1073
|
+
var st = sakuraTunnelOf(data, dk)
|
|
1074
|
+
var sakuraUp = st ? st.online === true : null
|
|
1075
|
+
var anyUp = publicUp || sakuraUp === true || lanUp
|
|
1076
|
+
return { devKey: dk, publicUp: publicUp, lanUp: lanUp, lanTunnel: lan, sakuraUp: sakuraUp, sakuraTunnel: st, anyUp: anyUp }
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ---------- 2026-11-09 画布下方:中转服务器情况(阿里云 / 樱花) ----------
|
|
1080
|
+
function InfraPanels(props) {
|
|
1081
|
+
var data = props.data || {}
|
|
1082
|
+
var acc = sakuraAccountOf(data)
|
|
1083
|
+
var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
|
|
1084
|
+
// 2026-11-10:本机自身也有一条阿里云入口(publicEntry/selfHosted 的 7012),之前漏掉了
|
|
1085
|
+
var lt = data.localTunnel || {}
|
|
1086
|
+
var selfHost = lt.selfHosted || {}
|
|
1087
|
+
var localPublic = publicEntryFor(lt)
|
|
1088
|
+
if (!localPublic) return null
|
|
1089
|
+
var entry = localPublic
|
|
1090
|
+
var localPort = entry.port || null
|
|
1091
|
+
var localHost = entry.host || ''
|
|
1092
|
+
var localKey = peerDeviceKey(data.nodeName)
|
|
1093
|
+
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) {
|
|
1094
|
+
var tu = p.tunnel || {}
|
|
1095
|
+
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 || '' }
|
|
1096
|
+
}))
|
|
1097
|
+
var aliyunRows = devs.filter(function (d) { return d.port }).map(function (d) {
|
|
1098
|
+
return h('div', { key: d.devKey, className: 'dm-device-row' },
|
|
1099
|
+
h('span', { className: 'dm-device-k' }, d.name),
|
|
1100
|
+
h('span', { className: 'dm-device-v' }, (d.up ? '✅ ' : '❌ ') + maskHost(d.host) + ':' + d.port)
|
|
1101
|
+
)
|
|
1102
|
+
})
|
|
1103
|
+
var tunnelRows = acc ? (acc.tunnels || []).filter(tunnelMatchesNode(lt.port)).map(function (x, i) {
|
|
1104
|
+
return h('div', { key: 'st' + i, className: 'dm-device-row' },
|
|
1105
|
+
h('span', { className: 'dm-device-k' }, (x.deviceKey ? nodeDisplay(peerDeviceKey(x.deviceKey)) : (x.name || '未归类'))),
|
|
1106
|
+
h('span', { className: 'dm-device-v' }, (x.online ? '✅ ' : '❌ ') + maskText(x.addr || ''))
|
|
1107
|
+
)
|
|
1108
|
+
}) : null
|
|
1109
|
+
if (!aliyunRows.length && !tunnelRows) return null
|
|
1110
|
+
return h('div', { className: 'dm-infra-grid' },
|
|
1111
|
+
h('div', { className: 'dm-infra-card' },
|
|
1112
|
+
h('div', { className: 'dm-infra-head' },
|
|
1113
|
+
h('span', { className: 'dm-dot ok', style: { background: TRANSPORTS.public.color } }),
|
|
1114
|
+
h('span', null, '公网中转'),
|
|
1115
|
+
h('span', { className: 'dm-sub', style: { marginLeft: 'auto' } }, '自建 frps')
|
|
1116
|
+
),
|
|
1117
|
+
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(' / ') || '—')),
|
|
1118
|
+
aliyunRows
|
|
1119
|
+
),
|
|
1120
|
+
acc ? h('div', { className: 'dm-infra-card' },
|
|
1121
|
+
h('div', { className: 'dm-infra-head' },
|
|
1122
|
+
h('span', { className: 'dm-dot', style: { background: TRANSPORTS.sakura.color } }),
|
|
1123
|
+
h('span', null, '第三方 frps 穿透'),
|
|
1124
|
+
h('span', { className: 'dm-sub', style: { marginLeft: 'auto' } }, acc.online + '/' + acc.total + ' 在线')
|
|
1125
|
+
),
|
|
1126
|
+
h('div', { className: 'dm-sub dm-mono', style: { marginBottom: 4 } }, acc && acc.tunnels && acc.tunnels[0] ? maskText(acc.tunnels[0].addr || '') : '未配置'),
|
|
1127
|
+
tunnelRows
|
|
1128
|
+
) : null
|
|
1129
|
+
)
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// ---------- 拓扑:极简卡片(名称 / 穿透域名 / DSH 数)+ 点击选中 ----------
|
|
1133
|
+
function Topology(props) {
|
|
1134
|
+
var data = props.data
|
|
1135
|
+
var layout = props.layout
|
|
1136
|
+
var show = props.show || { lan: true, public: true, sakura: true }
|
|
1137
|
+
// 2026-11-09-v5:卡片宽度自适应 —— 每次渲染后测量真实尺寸,回传父组件重算布局;
|
|
1138
|
+
// 同时把每张卡的实际尺寸给连线用(不同卡片宽窄不同,连线端点贴各自边缘)。
|
|
1139
|
+
var cardRefs = useRef({})
|
|
1140
|
+
var cardSizeState = useState({})
|
|
1141
|
+
var cardSizes = cardSizeState[0]
|
|
1142
|
+
var setCardSizes = cardSizeState[1]
|
|
1143
|
+
useEffect(function () {
|
|
1144
|
+
var next = {}
|
|
1145
|
+
var maxW = 0
|
|
1146
|
+
var maxH = 0
|
|
1147
|
+
var changed = false
|
|
1148
|
+
Object.keys(cardRefs.current).forEach(function (key) {
|
|
1149
|
+
var el = cardRefs.current[key]
|
|
1150
|
+
if (!el) return
|
|
1151
|
+
var w = el.offsetWidth
|
|
1152
|
+
var h = el.offsetHeight
|
|
1153
|
+
if (!w || !h) return
|
|
1154
|
+
next[key] = { w: w, h: h }
|
|
1155
|
+
if (w > maxW) maxW = w
|
|
1156
|
+
if (h > maxH) maxH = h
|
|
1157
|
+
var old = cardSizes[key]
|
|
1158
|
+
if (!old || Math.abs(old.w - w) > 1 || Math.abs(old.h - h) > 1) changed = true
|
|
1159
|
+
})
|
|
1160
|
+
if (!maxW) return
|
|
1161
|
+
if (changed) setCardSizes(next)
|
|
1162
|
+
if (props.onCardSize) props.onCardSize(maxW, maxH)
|
|
1163
|
+
})
|
|
1164
|
+
// 2026-11-09 卡片拖拽:在世界坐标里移动;连线由 layout.positions 派生,移动即自动跟随。
|
|
1165
|
+
var dragState = useState(null)
|
|
1166
|
+
var dragKey = dragState[0]
|
|
1167
|
+
var setDragKey = dragState[1]
|
|
1168
|
+
var dragRef = useRef(null)
|
|
1169
|
+
function cardPointerDown(node, pos, event) {
|
|
1170
|
+
if (event.button !== 0) return
|
|
1171
|
+
event.stopPropagation()
|
|
1172
|
+
dragRef.current = { key: node.key, sx: event.clientX, sy: event.clientY, ox: pos[0], oy: pos[1], moved: false }
|
|
1173
|
+
setDragKey(node.key)
|
|
1174
|
+
try { event.currentTarget.setPointerCapture(event.pointerId) } catch (_) { /* ignore */ }
|
|
1175
|
+
}
|
|
1176
|
+
function cardPointerMove(node, event) {
|
|
1177
|
+
var drag = dragRef.current
|
|
1178
|
+
if (!drag || drag.key !== node.key) return
|
|
1179
|
+
event.stopPropagation()
|
|
1180
|
+
var scale = props.scale || 1
|
|
1181
|
+
var dx = (event.clientX - drag.sx) / scale
|
|
1182
|
+
var dy = (event.clientY - drag.sy) / scale
|
|
1183
|
+
if (!drag.moved && (Math.abs(dx) + Math.abs(dy)) > 3) drag.moved = true
|
|
1184
|
+
if (!drag.moved) return
|
|
1185
|
+
if (String(node.key).indexOf('server-') === 0) { infraMove(node.key, [drag.ox + dx, drag.oy + dy]); return }
|
|
1186
|
+
// 2026-11-09-v3:不再限制在画布边界内(用户反馈左侧有「空气墙」);
|
|
1187
|
+
// 坐标允许任意值,.dm-world/.dm-lines 均 overflow:visible,配合拖到边缘自动平移可无限拖。
|
|
1188
|
+
var nx = drag.ox + dx
|
|
1189
|
+
var ny = drag.oy + dy
|
|
1190
|
+
if (props.onNodeMove) props.onNodeMove(node.key, [nx, ny])
|
|
1191
|
+
if (props.onDragEdge) props.onDragEdge(event.clientX, event.clientY)
|
|
1192
|
+
}
|
|
1193
|
+
function cardPointerUp(node, event) {
|
|
1194
|
+
var drag = dragRef.current
|
|
1195
|
+
if (!drag || drag.key !== node.key) return
|
|
1196
|
+
event.stopPropagation()
|
|
1197
|
+
var wasDrag = drag.moved
|
|
1198
|
+
dragRef.current = null
|
|
1199
|
+
setDragKey(null)
|
|
1200
|
+
if (!wasDrag && props.onSelect) props.onSelect(node.key)
|
|
1201
|
+
}
|
|
1202
|
+
function cardPointerCancel() { dragRef.current = null; setDragKey(null) }
|
|
1203
|
+
var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
|
|
1204
|
+
var relayHosts = relayHostSet(peers)
|
|
1205
|
+
var endpoint = data.endpoint || {}
|
|
1206
|
+
var instances = (data.local && data.local.instances) || []
|
|
1207
|
+
var hw = data.hardware || {}
|
|
1208
|
+
var localTunnel = data.localTunnel || {}
|
|
1209
|
+
var localUp = instances.filter(function (x) { return x.up }).length
|
|
1210
|
+
var localTunnelText = localTunnel.port
|
|
1211
|
+
? (localTunnel.frpRunning ? (localTunnel.frpCount ? localTunnel.frpCount : '') : '') || ''
|
|
1212
|
+
: ''
|
|
1213
|
+
|
|
1214
|
+
function localDomain() {
|
|
1215
|
+
var entry = localTunnel.publicEntry
|
|
1216
|
+
var src = localTunnel.mainSource || (entry && entry.source === 'self-hosted-frps' ? 'self-hosted' : '')
|
|
1217
|
+
if (entry && entry.host) {
|
|
1218
|
+
var tag = src === 'self-hosted' ? '(自建 frps)' : (src === 'sakura' ? '(Sakura 备用)' : '')
|
|
1219
|
+
return maskHost(entry.host) + ':' + entry.port + tag
|
|
1220
|
+
}
|
|
1221
|
+
if (localTunnel.frpRunning) return 'SakuraFrp 已开 · ' + (localTunnel.bind || '?') + ':' + localTunnel.port
|
|
1222
|
+
if (localTunnel.port) return '未开穿透 · ' + (localTunnel.bind || '?') + ':' + localTunnel.port
|
|
1223
|
+
return '未配置'
|
|
1224
|
+
}
|
|
1225
|
+
function peerDomain(peer) {
|
|
1226
|
+
var t = peer.tunnel || {}
|
|
1227
|
+
if (!t.host) return '—'
|
|
1228
|
+
return maskHost(t.host) + (t.port ? ':' + t.port : '') + (t.kind === 'lan' ? '(内网)' : '')
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// LAN peer 单独收集(dedupe 后可能被更快的 frp 记录覆盖,但 LAN 线仍要画)
|
|
1232
|
+
var lanMap = {}
|
|
1233
|
+
filterSelfPeers(data.peers || [], data.nodeName).forEach(function (p) {
|
|
1234
|
+
var t = p.tunnel || {}
|
|
1235
|
+
if (isLanPeer(p)) lanMap[peerDeviceKey(p.name)] = { up: Boolean(p.up), host: t.host, port: t.port, ip: t.ip || '', probe: t.probe || null }
|
|
1236
|
+
})
|
|
1237
|
+
var nodes = [{
|
|
1238
|
+
key: 'local',
|
|
1239
|
+
local: true,
|
|
1240
|
+
devKey: peerDeviceKey(data.nodeName),
|
|
1241
|
+
up: true,
|
|
1242
|
+
name: nodeDisplay(data.nodeName),
|
|
1243
|
+
dsh: localUp + '/' + instances.length,
|
|
1244
|
+
ports: instances.map(function (inst) { return { port: inst.port, up: inst.up, error: inst.error || '' } }),
|
|
1245
|
+
extra: ''
|
|
1246
|
+
}].concat(peers.map(function (peer) {
|
|
1247
|
+
var ls = deviceLinkState(data, peer, lanMap)
|
|
1248
|
+
return {
|
|
1249
|
+
key: 'peer:' + peer.name,
|
|
1250
|
+
local: false,
|
|
1251
|
+
devKey: ls.devKey,
|
|
1252
|
+
up: ls.anyUp,
|
|
1253
|
+
publicUp: ls.publicUp,
|
|
1254
|
+
sakuraUp: ls.sakuraUp,
|
|
1255
|
+
sakuraTunnel: ls.sakuraTunnel,
|
|
1256
|
+
lanUp: ls.lanUp,
|
|
1257
|
+
lanTunnel: ls.lanTunnel,
|
|
1258
|
+
name: nodeDisplay(ls.devKey),
|
|
1259
|
+
dsh: String(peer.instancesCount || 0),
|
|
1260
|
+
ports: peer.instancesList || [],
|
|
1261
|
+
latencyMs: peer.latencyMs,
|
|
1262
|
+
tunnel: peer.tunnel || {},
|
|
1263
|
+
error: peer.error || ''
|
|
1264
|
+
}
|
|
1265
|
+
}))
|
|
1266
|
+
// 2026-11-10:按共用顺序排序(与 Viewport 拖拽位置表一致,修复"拖 A 动 B"的 bug)
|
|
1267
|
+
var orderKeys = orderedNodeKeys(data)
|
|
1268
|
+
nodes.sort(function (a, b) { return orderKeys.indexOf(a.key) - orderKeys.indexOf(b.key) })
|
|
1269
|
+
// 基础设施节点(中转服务器):阿里云 / 樱花
|
|
1270
|
+
var localPub = publicEntryFor(localTunnel)
|
|
1271
|
+
var hasPublic = Boolean(localPub) || peers.some(function (p) { var pt = p.tunnel || {}; return !isLanPeer(p) && Boolean(pt.host) && (pt.kind === 'frp' || pt.kind === 'wan') })
|
|
1272
|
+
var publicVisible = show.public && hasPublic
|
|
1273
|
+
var sakuraRaw = sakuraAccountOf(data)
|
|
1274
|
+
var sakuraAcc = sakuraRaw ? Object.assign({}, sakuraRaw, { tunnels: (sakuraRaw.tunnels || []).filter(tunnelMatchesNode(localTunnel.port)) }) : null
|
|
1275
|
+
var sakuraVisible = show.sakura && Boolean(sakuraAcc && ((sakuraAcc.tunnels || []).length || sakuraAcc.error))
|
|
1276
|
+
var aliyunPorts = peers.map(function (p) { return isLanPeer(p) ? null : (p.tunnel || {}).port }).filter(Boolean)
|
|
1277
|
+
var infra = [
|
|
1278
|
+
{ key: 'server-aliyun', transport: 'public', name: '公网中转', host: localPub ? maskHost(localPub.host) : '未配置', color: TRANSPORTS.public.color, detail: '隧道 ' + (aliyunPorts.length ? aliyunPorts.sort().join('/') : '—'), up: Boolean(localPub) || hasPublic },
|
|
1279
|
+
{ key: 'server-sakura', transport: 'sakura', name: '第三方 frps 穿透', host: sakuraAcc && sakuraAcc.tunnels && sakuraAcc.tunnels[0] ? maskText(sakuraAcc.tunnels[0].addr || '') : (sakuraAcc && sakuraAcc.error ? '状态不可用' : '未配置'), color: TRANSPORTS.sakura.color, detail: sakuraAcc ? (sakuraAcc.available === false ? ('Sakura 限流/未就绪:' + (sakuraAcc.error || 'unknown')) : (sakuraAcc.online + '/' + sakuraAcc.total + ' 在线')) : '未采集', up: Boolean(sakuraAcc && Number(sakuraAcc.online || 0) > 0) },
|
|
1280
|
+
]
|
|
1281
|
+
// 2026-11-09-v8:服务器默认位置贴住扇形最高一排设备(用户要求缩小服务器↔设备的垂直间隔)
|
|
1282
|
+
function infraDefaultPos(idx) {
|
|
1283
|
+
var dx = layout.cardW * 1.08
|
|
1284
|
+
var ox = layout.baseOffsetX || 0
|
|
1285
|
+
var oy = layout.baseOffsetY || 0
|
|
1286
|
+
var cx = ox + (layout.contentW || layout.W) / 2
|
|
1287
|
+
var topYAbs = oy + (layout.topDeviceY || 0)
|
|
1288
|
+
return [Math.round(cx + (idx === 0 ? -dx : dx)), Math.round(topYAbs - layout.cardH * 1.05)]
|
|
1289
|
+
}
|
|
1290
|
+
var infraState = useState(function () { try { return JSON.parse(window.localStorage.getItem('dsh-mesh-ui.infra.v2') || '{}') || {} } catch (e) { return {} } })
|
|
1291
|
+
var infraPos = infraState[0]
|
|
1292
|
+
var setInfraPos = infraState[1]
|
|
1293
|
+
function infraPosition(idx) {
|
|
1294
|
+
var k = infra[idx].key
|
|
1295
|
+
var v = infraPos[k]
|
|
1296
|
+
if (v && v.length === 2) return v
|
|
1297
|
+
if (layout.mode === 'server-center') return layout.serverCenter || infraDefaultPos(idx)
|
|
1298
|
+
if (layout.mode === 'servers-top-single') return [Math.round((DEFAULT_INFRA_POS.aliyun[0] + DEFAULT_INFRA_POS.sakura[0]) / 2), Math.round((DEFAULT_INFRA_POS.aliyun[1] + DEFAULT_INFRA_POS.sakura[1]) / 2)]
|
|
1299
|
+
var def = layout.mode === 'servers-top' ? DEFAULT_INFRA_POS[infra[idx].transport === 'public' ? 'aliyun' : 'sakura'] : null
|
|
1300
|
+
if (def) return [def[0], def[1]]
|
|
1301
|
+
return infraDefaultPos(idx)
|
|
1302
|
+
}
|
|
1303
|
+
useEffect(function () {
|
|
1304
|
+
if (!props.resetNonce) return
|
|
1305
|
+
try { window.localStorage.removeItem('dsh-mesh-ui.infra.v2') } catch (e) { /* ignore */ }
|
|
1306
|
+
setInfraPos({})
|
|
1307
|
+
}, [props.resetNonce])
|
|
1308
|
+
function infraMove(key, pos) { setInfraPos(function (prev) { var next = Object.assign({}, prev); next[key] = pos; try { window.localStorage.setItem('dsh-mesh-ui.infra.v2', JSON.stringify(next)) } catch (e) { /* ignore */ } return next }) }
|
|
1309
|
+
|
|
1310
|
+
var GAP_EDGE = 6
|
|
1311
|
+
function edgePoint(from, to, hw, hh) {
|
|
1312
|
+
var dx = to[0] - from[0]
|
|
1313
|
+
var dy = to[1] - from[1]
|
|
1314
|
+
var len = Math.sqrt(dx * dx + dy * dy) || 1
|
|
1315
|
+
var ux = dx / len
|
|
1316
|
+
var uy = dy / len
|
|
1317
|
+
var tx = ux === 0 ? Infinity : (hw + GAP_EDGE) / Math.abs(ux)
|
|
1318
|
+
var ty = uy === 0 ? Infinity : (hh + GAP_EDGE) / Math.abs(uy)
|
|
1319
|
+
var t = Math.min(tx, ty)
|
|
1320
|
+
return [from[0] + ux * t, from[1] + uy * t]
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// 2026-11-09:三通道连线(设备→阿里云 / 设备→樱花 / 局域网直连)+ 中转服务器卡片 + 筛选
|
|
1324
|
+
// 2026-11-09:悬浮节点 → 只高亮它的连线并显示完整地址(其它线变淡)
|
|
1325
|
+
var hoverState = useState(null)
|
|
1326
|
+
var hoverKey = hoverState[0]
|
|
1327
|
+
var setHoverKey = hoverState[1]
|
|
1328
|
+
function edgeRelated(aKey, bKey) { return hoverKey ? (hoverKey === aKey || hoverKey === bKey) : true }
|
|
1329
|
+
var infraRefs = useRef({})
|
|
1330
|
+
var infraSizeState = useState({})
|
|
1331
|
+
var infraSizes = infraSizeState[0]
|
|
1332
|
+
var setInfraSizes = infraSizeState[1]
|
|
1333
|
+
useEffect(function () {
|
|
1334
|
+
var next = {}
|
|
1335
|
+
var changed = false
|
|
1336
|
+
Object.keys(infraRefs.current).forEach(function (key) {
|
|
1337
|
+
var el = infraRefs.current[key]
|
|
1338
|
+
if (!el) return
|
|
1339
|
+
var w = el.offsetWidth
|
|
1340
|
+
var h2 = el.offsetHeight
|
|
1341
|
+
if (!w || !h2) return
|
|
1342
|
+
next[key] = { w: w, h: h2 }
|
|
1343
|
+
var old = infraSizes[key]
|
|
1344
|
+
if (!old || Math.abs(old.w - w) > 1 || Math.abs(old.h - h2) > 1) changed = true
|
|
1345
|
+
})
|
|
1346
|
+
if (!Object.keys(next).length) return
|
|
1347
|
+
if (changed) setInfraSizes(next)
|
|
1348
|
+
})
|
|
1349
|
+
function sizeOf(key) { return cardSizes[key] || infraSizes[key] || { w: layout.cardW, h: layout.cardH } }
|
|
1350
|
+
function curve(key, from, to, up, transport, label, fromSize, toSize, highlight) {
|
|
1351
|
+
var a = edgePoint(from, to, fromSize.w / 2, fromSize.h / 2)
|
|
1352
|
+
var b = edgePoint(to, from, toSize.w / 2, toSize.h / 2)
|
|
1353
|
+
var mx = (a[0] + b[0]) / 2
|
|
1354
|
+
var my = (a[1] + b[1]) / 2
|
|
1355
|
+
var dx = b[0] - a[0]
|
|
1356
|
+
var dy = b[1] - a[1]
|
|
1357
|
+
var len = Math.sqrt(dx * dx + dy * dy) || 1
|
|
1358
|
+
var bow = Math.min(12, len * 0.07)
|
|
1359
|
+
var color = up ? transport.color : DOWN_COLOR
|
|
1360
|
+
var dash = up ? (transport.dash ? '6 5' : 'none') : '6 5'
|
|
1361
|
+
var related = highlight !== false
|
|
1362
|
+
var els = [h('path', {
|
|
1363
|
+
key: key,
|
|
1364
|
+
d: 'M ' + a[0] + ' ' + a[1] + ' Q ' + (mx + (-dy / len) * bow) + ' ' + (my + (dx / len) * bow) + ' ' + b[0] + ' ' + b[1],
|
|
1365
|
+
fill: 'none', stroke: color, strokeWidth: 1.8, strokeDasharray: dash,
|
|
1366
|
+
className: up ? (transport.dash ? 'dm-edge-frp' : 'dm-edge-solid') : 'dm-edge-down',
|
|
1367
|
+
opacity: related ? 1 : 0.12
|
|
1368
|
+
})]
|
|
1369
|
+
if (label) {
|
|
1370
|
+
els.push(h('text', { key: key + '-t', className: 'dm-edge-label', x: mx + (-dy / len) * bow, y: my + (dx / len) * bow - 4, fill: color, fontSize: 9, textAnchor: 'middle', style: { display: (hoverKey && related) ? 'block' : 'none' } }, label))
|
|
1371
|
+
}
|
|
1372
|
+
return els
|
|
1373
|
+
}
|
|
1374
|
+
var svgEls = []
|
|
1375
|
+
var aliyunPos = infraPosition(0)
|
|
1376
|
+
var sakuraPos = infraPosition(1)
|
|
1377
|
+
var aliyunSize = infraSizes['server-aliyun'] || { w: 300, h: 90 }
|
|
1378
|
+
var sakuraSize = infraSizes['server-sakura'] || { w: 300, h: 90 }
|
|
1379
|
+
var infraBoxes = []
|
|
1380
|
+
if (publicVisible) infraBoxes.push({ x: aliyunPos[0], y: aliyunPos[1], w: aliyunSize.w, h: aliyunSize.h })
|
|
1381
|
+
if (sakuraVisible) infraBoxes.push({ x: sakuraPos[0], y: sakuraPos[1], w: sakuraSize.w, h: sakuraSize.h })
|
|
1382
|
+
if (props.onInfraBounds) props.onInfraBounds(infraBoxes)
|
|
1383
|
+
var localKey = peerDeviceKey(data.nodeName)
|
|
1384
|
+
var localSak = sakuraTunnelOf(data, localKey)
|
|
1385
|
+
// 2026-11-10:LAN 线必须从本机节点出发(排序后 local 可能不在 index 0,之前误连到peer-x)
|
|
1386
|
+
var localNodeIdx = 0
|
|
1387
|
+
nodes.forEach(function (n, ii) { if (n.local) localNodeIdx = ii })
|
|
1388
|
+
nodes.forEach(function (n, i) {
|
|
1389
|
+
var pa = layout.positions[i]
|
|
1390
|
+
if (n.local) {
|
|
1391
|
+
// 主机自己的两条上级链路:自建 frps(阿里云) + 本机樱花隧道
|
|
1392
|
+
if (publicVisible && localPub) svgEls = svgEls.concat(curve('pub-local', pa, aliyunPos, true, TRANSPORTS.public, maskHost(localPub.host) + ':' + localPub.port, sizeOf('local'), aliyunSize, edgeRelated('local', 'server-aliyun')))
|
|
1393
|
+
if (sakuraVisible && localSak) svgEls = svgEls.concat(curve('sak-local', pa, sakuraPos, localSak.online === true, TRANSPORTS.sakura, maskText(localSak.addr || ''), sizeOf('local'), sakuraSize, edgeRelated('local', 'server-sakura')))
|
|
1394
|
+
return
|
|
1395
|
+
}
|
|
1396
|
+
var sizeN = sizeOf(n.key)
|
|
1397
|
+
if (publicVisible && n.tunnel && n.tunnel.host && !isLanPeer(n) && (n.tunnel.kind === 'frp' || n.tunnel.kind === 'wan')) svgEls = svgEls.concat(curve('pub' + i, pa, aliyunPos, n.publicUp, TRANSPORTS.public, maskHost(n.tunnel.host) + ((n.tunnel.port) ? ':' + n.tunnel.port : ''), sizeN, aliyunSize, edgeRelated(n.key, 'server-aliyun')))
|
|
1398
|
+
if (sakuraVisible && 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')))
|
|
1399
|
+
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))) }
|
|
1400
|
+
})
|
|
1401
|
+
var fedItems = []
|
|
1402
|
+
try {
|
|
1403
|
+
var directKeys = {}
|
|
1404
|
+
nodes.forEach(function (n) { directKeys[peerDeviceKey(n.devKey)] = true; directKeys[peerDeviceKey(n.name)] = true })
|
|
1405
|
+
var seenFed = {}
|
|
1406
|
+
var fedRaw = (props.federated && props.federated.nodes) || []
|
|
1407
|
+
fedRaw.forEach(function (item) {
|
|
1408
|
+
var dn = peerDeviceKey(item.name)
|
|
1409
|
+
if (!dn || directKeys[dn] || seenFed[dn] || dn === peerDeviceKey(data.nodeName)) return
|
|
1410
|
+
seenFed[dn] = true
|
|
1411
|
+
var gi = -1
|
|
1412
|
+
for (var ni = 0; ni < nodes.length; ni++) if (peerDeviceKey(nodes[ni].devKey) === peerDeviceKey(item.via)) { gi = ni; break }
|
|
1413
|
+
if (gi < 0) return
|
|
1414
|
+
var gp = layout.positions[gi]
|
|
1415
|
+
var idx = fedItems.length
|
|
1416
|
+
fedItems.push({ item: item, x: gp[0] + (idx % 3 - 1) * 170, y: gp[1] + 150 + Math.floor(idx / 3) * 92, gx: gp[0], gy: gp[1] })
|
|
1417
|
+
})
|
|
1418
|
+
} catch (error) { fedItems = [] }
|
|
1419
|
+
fedItems.forEach(function (fed) {
|
|
1420
|
+
svgEls.push(h('line', { key: 'fed-edge-' + fed.item.name + '-' + fed.item.via, x1: fed.gx, y1: fed.gy, x2: fed.x, y2: fed.y, className: 'dm-edge-indirect', strokeDasharray: '4 4' }))
|
|
1421
|
+
})
|
|
1422
|
+
return h('div', null,
|
|
1423
|
+
h('svg', { className: 'dm-lines', width: layout.W, height: layout.H, viewBox: '0 0 ' + layout.W + ' ' + layout.H }, svgEls),
|
|
1424
|
+
nodes.map(function (node, i) {
|
|
1425
|
+
var pos = layout.positions[i]
|
|
1426
|
+
var selected = props.selectedKey === node.key
|
|
1427
|
+
return h('div', {
|
|
1428
|
+
key: node.key,
|
|
1429
|
+
className: 'dm-card-node compact draggable' + (node.local ? ' local' : '') + (node.up ? '' : ' down') + (selected ? ' selected' : '') + (hoverKey === node.key ? ' hovered' : '') + (dragKey === node.key ? ' dragging' : ''),
|
|
1430
|
+
onMouseEnter: function () { setHoverKey(node.key) },
|
|
1431
|
+
onMouseLeave: function () { setHoverKey(null) },
|
|
1432
|
+
style: { left: pos[0] + 'px', top: pos[1] + 'px', maxWidth: '380px', transform: 'translate(-50%,-50%)' },
|
|
1433
|
+
ref: function (el) { if (el) cardRefs.current[node.key] = el; else delete cardRefs.current[node.key] },
|
|
1434
|
+
onPointerDown: function (event) { cardPointerDown(node, pos, event) },
|
|
1435
|
+
onPointerMove: function (event) { cardPointerMove(node, event) },
|
|
1436
|
+
onPointerUp: function (event) { cardPointerUp(node, event) },
|
|
1437
|
+
onPointerCancel: cardPointerCancel,
|
|
1438
|
+
title: '拖拽调整位置 · 单击查看 ' + node.name + ' 的详情'
|
|
1439
|
+
},
|
|
1440
|
+
h('div', { className: 'dm-n-head' },
|
|
1441
|
+
h('div', { className: 'dm-n-name' }, h('span', { className: 'dm-dot ' + (node.up ? 'ok' : 'bad') }), h('span', { className: 'dm-ell' }, node.name)),
|
|
1442
|
+
node.local ? h('span', { className: 'dm-chip ok' }, '本机')
|
|
1443
|
+
: (node.up ? h('span', { className: 'dm-extra' }, node.latencyMs == null ? '' : node.latencyMs + 'ms') : h('span', { className: 'dm-chip bad' }, '离线'))
|
|
1444
|
+
),
|
|
1445
|
+
node.up ? null : h('div', { className: 'dm-offline-row' }, '全部链路不可用' + (node.error ? ' · ' + node.error : '')),
|
|
1446
|
+
h('div', { className: 'dm-card-dsh' },
|
|
1447
|
+
(node.ports || []).map(function (item, pi) {
|
|
1448
|
+
var portUp = item.up == null ? node.up : Boolean(item.up)
|
|
1449
|
+
var bridgeUnknown = !portUp && node.up && !node.local
|
|
1450
|
+
return h('span', { key: pi, className: 'dm-chip type' + (portUp ? ' run' : (bridgeUnknown ? ' warn' : ' bad')), title: (portUp ? '实例在线' : (bridgeUnknown ? '节点在线,但 host bridge 未连接,实例状态未知' : (item.error ? 'host bridge 不可达:' + item.error : '实例离线 / host bridge 未连接'))) + ' · DSH 端口 ' + item.port }, String(item.port))
|
|
1451
|
+
})
|
|
1452
|
+
)
|
|
1453
|
+
)
|
|
1454
|
+
}),
|
|
1455
|
+
fedItems.map(function (fed) {
|
|
1456
|
+
var item = fed.item
|
|
1457
|
+
return h('div', { key: 'fed:' + item.via + ':' + item.name, className: 'dm-card-node indirect', style: { left: fed.x + 'px', top: fed.y + 'px', width: '172px', transform: 'translate(-50%,-50%)' }, title: '间接可达:经 ' + item.via },
|
|
1458
|
+
h('div', { className: 'dm-n-head' },
|
|
1459
|
+
h('div', { className: 'dm-n-name' }, h('span', { className: 'dm-dot ' + (item.up === true ? 'ok' : (item.up === false ? 'bad' : 'muted')), style: item.up == null ? { opacity: .45 } : undefined }), h('span', { className: 'dm-ell' }, item.name)),
|
|
1460
|
+
h('span', { className: 'dm-fed-badge' }, '经 ' + item.via)
|
|
1461
|
+
),
|
|
1462
|
+
h('div', { className: 'dm-card-dsh' },
|
|
1463
|
+
h('span', { className: 'dm-chip' }, item.up === true ? (item.latencyMs != null ? item.latencyMs + 'ms' : '可达') : (item.up === false ? '不可达' : '间接 · 只读'))
|
|
1464
|
+
)
|
|
1465
|
+
)
|
|
1466
|
+
}),
|
|
1467
|
+
(publicVisible ? h('div', {
|
|
1468
|
+
key: 'server-aliyun',
|
|
1469
|
+
className: 'dm-card-node server' + (infra[0].up ? '' : ' down') + (hoverKey === 'server-aliyun' ? ' hovered' : ''),
|
|
1470
|
+
onMouseEnter: function () { setHoverKey('server-aliyun') },
|
|
1471
|
+
onMouseLeave: function () { setHoverKey(null) },
|
|
1472
|
+
style: { left: aliyunPos[0] + 'px', top: aliyunPos[1] + 'px', maxWidth: '360px', transform: 'translate(-50%,-50%)', borderColor: TRANSPORTS.public.color },
|
|
1473
|
+
ref: function (el) { if (el) infraRefs.current['server-aliyun'] = el; else delete infraRefs.current['server-aliyun'] },
|
|
1474
|
+
onPointerDown: function (event) { cardPointerDown({ key: 'server-aliyun' }, aliyunPos, event) },
|
|
1475
|
+
onPointerMove: function (event) { cardPointerMove({ key: 'server-aliyun' }, event) },
|
|
1476
|
+
onPointerUp: function (event) { cardPointerUp({ key: 'server-aliyun' }, event) },
|
|
1477
|
+
onPointerCancel: cardPointerCancel
|
|
1478
|
+
},
|
|
1479
|
+
h('div', { className: 'dm-n-head' }, h('div', { className: 'dm-n-name' }, h('span', { className: 'dm-dot ok' }), h('span', {}, infra[0].name))),
|
|
1480
|
+
h('div', { className: 'dm-card-domain' }, infra[0].host),
|
|
1481
|
+
h('div', { className: 'dm-card-dsh' }, h('span', { className: 'dm-chip', style: { borderColor: TRANSPORTS.public.color, color: TRANSPORTS.public.color } }, 'frps'), h('span', { className: 'dm-chip' }, infra[0].detail))
|
|
1482
|
+
) : null),
|
|
1483
|
+
(sakuraVisible ? h('div', {
|
|
1484
|
+
key: 'server-sakura',
|
|
1485
|
+
className: 'dm-card-node server' + (infra[1].up ? '' : ' down') + (hoverKey === 'server-sakura' ? ' hovered' : ''),
|
|
1486
|
+
onMouseEnter: function () { setHoverKey('server-sakura') },
|
|
1487
|
+
onMouseLeave: function () { setHoverKey(null) },
|
|
1488
|
+
style: { left: sakuraPos[0] + 'px', top: sakuraPos[1] + 'px', maxWidth: '360px', transform: 'translate(-50%,-50%)', borderColor: TRANSPORTS.sakura.color },
|
|
1489
|
+
ref: function (el) { if (el) infraRefs.current['server-sakura'] = el; else delete infraRefs.current['server-sakura'] },
|
|
1490
|
+
onPointerDown: function (event) { cardPointerDown({ key: 'server-sakura' }, sakuraPos, event) },
|
|
1491
|
+
onPointerMove: function (event) { cardPointerMove({ key: 'server-sakura' }, event) },
|
|
1492
|
+
onPointerUp: function (event) { cardPointerUp({ key: 'server-sakura' }, event) },
|
|
1493
|
+
onPointerCancel: cardPointerCancel
|
|
1494
|
+
},
|
|
1495
|
+
h('div', { className: 'dm-n-head' }, h('div', { className: 'dm-n-name' }, h('span', { className: 'dm-dot ' + (infra[1].up ? 'ok' : 'bad') }), h('span', {}, infra[1].name))),
|
|
1496
|
+
h('div', { className: 'dm-card-domain' }, infra[1].host),
|
|
1497
|
+
h('div', { className: 'dm-card-dsh' }, h('span', { className: 'dm-chip', style: { borderColor: TRANSPORTS.sakura.color, color: TRANSPORTS.sakura.color } }, 'Sakura'), h('span', { className: 'dm-chip' }, infra[1].detail))
|
|
1498
|
+
) : null)
|
|
1499
|
+
)
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// ---------- 画布下方:设备详情(默认全展示,点卡片高亮对应项) ----------
|
|
1503
|
+
function DeviceDetails(props) {
|
|
1504
|
+
var data = props.data
|
|
1505
|
+
var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
|
|
1506
|
+
var relayHosts = relayHostSet(peers)
|
|
1507
|
+
var hw = data.hardware || {}
|
|
1508
|
+
var instances = (data.local && data.local.instances) || []
|
|
1509
|
+
var endpoint = data.endpoint || {}
|
|
1510
|
+
var localTunnel = data.localTunnel || {}
|
|
1511
|
+
var localUp = instances.filter(function (x) { return x.up }).length
|
|
1512
|
+
var sessions = instances.reduce(function (n, x) { return n + (x.sessions || 0) }, 0)
|
|
1513
|
+
var running = instances.reduce(function (n, x) { return n + (x.running || 0) }, 0)
|
|
1514
|
+
var model = instances.length && instances[0].model ? (instances[0].model.provider || '') + ' / ' + (instances[0].model.model || '') : '—'
|
|
1515
|
+
var localTunnelText = null
|
|
1516
|
+
var tunnels = (localTunnel.tunnels || []).filter(tunnelMatchesNode(localTunnel.port))
|
|
1517
|
+
var entry = publicEntryFor(localTunnel) || null
|
|
1518
|
+
var tunnelNames = tunnels.map(function (x) { return x.name + '→' + maskHost(x.host) + ':' + x.port }).join(' / ')
|
|
1519
|
+
var mainSrc = entry ? (localTunnel.mainSource || (entry.source === 'self-hosted-frps' ? 'self-hosted' : (entry.source === 'sakura' ? 'sakura' : 'none'))) : 'none'
|
|
1520
|
+
var entryText = entry && entry.host
|
|
1521
|
+
? maskHost(entry.host) + ':' + entry.port + (mainSrc === 'self-hosted' ? '(自建 frps · 主用)' : (mainSrc === 'sakura' ? '(Sakura · 备用)' : ''))
|
|
1522
|
+
: '未配置'
|
|
1523
|
+
var localTunnelRows = [
|
|
1524
|
+
['入口', entryText],
|
|
1525
|
+
['监听 / frpc', (localTunnel.bind || '?') + ':' + (localTunnel.port || '?') + (localTunnel.tls ? ' (TLS)' : '') + ' · ' + (localTunnel.frpRunning ? 'frpc ×' + (localTunnel.frpcCount || 1) : 'frpc 未运行')]
|
|
1526
|
+
]
|
|
1527
|
+
if (tunnels.length) localTunnelRows.push(['Sakura 备用', tunnels.map(function (x) { return maskHost(x.host) + ':' + x.port }).join(' / ')])
|
|
1528
|
+
if (mainSrc === 'self-hosted' && localTunnel.selfHosted && localTunnel.selfHosted.configPath) localTunnelRows.push(['frpc 配置', localTunnel.selfHosted.configPath])
|
|
1529
|
+
var localTunnelAll = tunnelNames
|
|
1530
|
+
var memText = hw.memTotalGB ? ((hw.memFreeGB != null ? hw.memFreeGB : '?') + ' / ' + hw.memTotalGB + ' GB') : '—'
|
|
1531
|
+
var cpuText = hw.cpuModel ? hw.cpuModel + ' · ' + (hw.cpuCores || '?') + ' 线程' : '—'
|
|
1532
|
+
function diskText(d) {
|
|
1533
|
+
if (!d || d.totalGB == null) return '—'
|
|
1534
|
+
var p = String(d.path || '').replace(/[\\/]+$/, '')
|
|
1535
|
+
return (d.freeGB != null ? d.freeGB : '?') + ' / ' + d.totalGB + ' GB' + (p ? ' (' + p + ')' : '')
|
|
1536
|
+
}
|
|
1537
|
+
// 设备类型:按平台/CPU 形态泛化推断(不依赖任何个人设备表)
|
|
1538
|
+
function formFactorOf(h) {
|
|
1539
|
+
if (!h) return '—'
|
|
1540
|
+
if (h.platform === 'linux' && String(h.arch || '').indexOf('arm') === 0) return '手机'
|
|
1541
|
+
var cpu = String(h.cpuModel || '')
|
|
1542
|
+
if (/\b(i[3579]-\d{4,5}(U|H|P|HS)|Ryzen \d+ \d+(U|H|HS))\b/i.test(cpu)) return '笔记本'
|
|
1543
|
+
return cpu ? '主机' : '—'
|
|
1544
|
+
}
|
|
1545
|
+
function memTextOf(h) {
|
|
1546
|
+
return h && h.memTotalGB ? ((h.memFreeGB != null ? h.memFreeGB : '?') + ' / ' + h.memTotalGB + ' GB') : '—'
|
|
1547
|
+
}
|
|
1548
|
+
function cpuTextOf(h) {
|
|
1549
|
+
return h && h.cpuModel ? h.cpuModel + ' · ' + (h.cpuCores || '?') + ' 线程' : '—'
|
|
1550
|
+
}
|
|
1551
|
+
function ipKindOf(peer) {
|
|
1552
|
+
var t = peer.tunnel || {}
|
|
1553
|
+
if (t.kind === 'loopback') return '本机回环'
|
|
1554
|
+
if (t.kind === 'lan') return '局域网直连'
|
|
1555
|
+
if (t.kind === 'frp') return '公网穿透(域名)'
|
|
1556
|
+
if (t.kind === 'wan') return relayHosts[String(t.host || '').toLowerCase()] ? '公网(共享中转)' : '公网 IP 直连'
|
|
1557
|
+
return '—'
|
|
1558
|
+
}
|
|
1559
|
+
function relayKindOf(peer) {
|
|
1560
|
+
var t = peer.tunnel || {}
|
|
1561
|
+
if (t.kind === 'loopback') return '无(本机回环)'
|
|
1562
|
+
if (t.kind === 'lan') return '无(局域网直连)'
|
|
1563
|
+
if (t.kind === 'frp') return 'Sakura / 第三方 frp 穿透'
|
|
1564
|
+
if (t.kind === 'wan') return relayHosts[String(t.host || '').toLowerCase()] ? '自建公网中继(frps)' : '公网 IP 直连'
|
|
1565
|
+
return '—'
|
|
1566
|
+
}
|
|
1567
|
+
function dshVerOf(peer) {
|
|
1568
|
+
var v = peer.version ? 'v' + peer.version : '—'
|
|
1569
|
+
if (peer.plugin) v += ' · 插件 ' + peer.plugin
|
|
1570
|
+
return v
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// 2026-11-10:设备状态与拓扑同口径 —— 任一链路可达即算在线(HTTP 探测偶发超时不再误判红点)
|
|
1574
|
+
var lanUpByDev = {}
|
|
1575
|
+
;(data.peers || []).forEach(function (p) {
|
|
1576
|
+
if (!isLanPeer(p)) return
|
|
1577
|
+
var tu = p.tunnel || {}
|
|
1578
|
+
lanUpByDev[peerDeviceKey(p.name)] = tu.probe ? tu.probe.up === true : Boolean(p.up)
|
|
1579
|
+
})
|
|
1580
|
+
function peerLinkState(peer) {
|
|
1581
|
+
var t = peer.tunnel || {}
|
|
1582
|
+
var httpUp = Boolean(peer.up)
|
|
1583
|
+
var tcpUp = t.probe ? t.probe.up === true : false
|
|
1584
|
+
var sk = sakuraTunnelOf(data, peerDeviceKey(peer.name))
|
|
1585
|
+
var sakuraUp = sk ? sk.online === true : null
|
|
1586
|
+
var lanUp = lanUpByDev[peerDeviceKey(peer.name)] === true
|
|
1587
|
+
return {
|
|
1588
|
+
httpUp: httpUp, tcpUp: tcpUp, sakuraUp: sakuraUp, lanUp: lanUp,
|
|
1589
|
+
up: httpUp || tcpUp || sakuraUp === true || lanUp
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
function linkStateText(ls) {
|
|
1593
|
+
return "公网 " + ((ls.httpUp || ls.tcpUp) ? "✅" : "❌")
|
|
1594
|
+
+ " / 樱花 " + (ls.sakuraUp == null ? "—" : (ls.sakuraUp ? "✅" : "❌"))
|
|
1595
|
+
+ (ls.lanUp ? " / LAN ✅" : "")
|
|
1596
|
+
+ (ls.httpUp ? "" : "(HTTP 超时,隧道" + (ls.tcpUp ? "通" : "断") + ")")
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// 2026-11-10:点击节点后自动滚动到对应设备卡片,保证卡片完整可见
|
|
1600
|
+
var selectedDeviceRef = useRef(null)
|
|
1601
|
+
useEffect(function () {
|
|
1602
|
+
if (!props.selectedKey || !selectedDeviceRef.current) return
|
|
1603
|
+
var timer = setTimeout(function () {
|
|
1604
|
+
try { selectedDeviceRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) } catch (error) { selectedDeviceRef.current.scrollIntoView() }
|
|
1605
|
+
}, 40)
|
|
1606
|
+
return function () { clearTimeout(timer) }
|
|
1607
|
+
}, [props.selectedKey])
|
|
1608
|
+
|
|
1609
|
+
var devices = [{
|
|
1610
|
+
key: 'local',
|
|
1611
|
+
name: nodeDisplay(data.nodeName) + '(本机)',
|
|
1612
|
+
up: Boolean(endpoint.up),
|
|
1613
|
+
rows: [
|
|
1614
|
+
['设备类型', formFactorOf(hw)],
|
|
1615
|
+
['操作系统', hw.os || '—'],
|
|
1616
|
+
['内存', memTextOf(hw)],
|
|
1617
|
+
['磁盘', diskText(hw.disk)],
|
|
1618
|
+
['CPU', cpuTextOf(hw)],
|
|
1619
|
+
['DSH 版本', data.meshVersion ? 'v' + data.meshVersion : '—'],
|
|
1620
|
+
['IP 类型', '本机回环'],
|
|
1621
|
+
['端口号', localTunnel.port != null ? String(localTunnel.port) : '—'],
|
|
1622
|
+
['公网穿透类型', mainSrc === 'self-hosted' ? '自建 frps 中继(主用)' : (mainSrc === 'sakura' ? 'Sakura 穿透(备用)' : '无(仅局域网)')]
|
|
1623
|
+
]
|
|
1624
|
+
}].concat(peers.map(function (peer) {
|
|
1625
|
+
var t = peer.tunnel || {}
|
|
1626
|
+
var ls = peerLinkState(peer)
|
|
1627
|
+
var hwp = peer.hardware || {}
|
|
1628
|
+
return {
|
|
1629
|
+
key: 'peer:' + peer.name,
|
|
1630
|
+
name: nodeDisplay(peerDeviceKey(peer.name)),
|
|
1631
|
+
up: ls.up,
|
|
1632
|
+
rows: [
|
|
1633
|
+
['设备类型', formFactorOf(hwp)],
|
|
1634
|
+
['操作系统', hwp.os || peer.os || '—'],
|
|
1635
|
+
['内存', memTextOf(hwp)],
|
|
1636
|
+
['磁盘', diskText(hwp.disk)],
|
|
1637
|
+
['CPU', cpuTextOf(hwp)],
|
|
1638
|
+
['DSH 版本', dshVerOf(peer)],
|
|
1639
|
+
['IP 类型', ipKindOf(peer)],
|
|
1640
|
+
['端口号', t.port ? String(t.port) : '—'],
|
|
1641
|
+
['公网穿透类型', relayKindOf(peer)],
|
|
1642
|
+
['链路状态', linkStateText(ls)]
|
|
1643
|
+
]
|
|
1644
|
+
}
|
|
1645
|
+
}))
|
|
1646
|
+
|
|
1647
|
+
return h('div', { className: 'dm-devices' },
|
|
1648
|
+
devices.map(function (device) {
|
|
1649
|
+
return h('div', { key: device.key, ref: props.selectedKey === device.key ? function (el) { selectedDeviceRef.current = el } : null, className: 'dm-device' + (props.selectedKey === device.key ? ' selected' : '') },
|
|
1650
|
+
h('div', { className: 'dm-device-head' },
|
|
1651
|
+
h('span', { className: 'dm-dot ' + (device.up ? 'ok' : 'bad') }),
|
|
1652
|
+
h('span', { className: 'dm-device-name' }, device.name),
|
|
1653
|
+
device.key === 'local' ? h('span', { className: 'dm-chip ok' }, '本机') : null
|
|
1654
|
+
),
|
|
1655
|
+
h('div', { className: 'dm-device-body' }, device.rows.map(function (row, i) {
|
|
1656
|
+
return h('div', { className: 'dm-device-row', key: i },
|
|
1657
|
+
h('span', { className: 'dm-device-k' }, row[0]),
|
|
1658
|
+
h('span', { className: 'dm-device-v', title: String(row[1]) }, String(row[1]))
|
|
1659
|
+
)
|
|
1660
|
+
}))
|
|
1661
|
+
)
|
|
1662
|
+
})
|
|
1663
|
+
)
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function Details(props) {
|
|
1667
|
+
var storeKey = 'dsh-mesh-ui.details.' + encodeURIComponent(String(props.title || ''))
|
|
1668
|
+
var sOpen = useState(function () {
|
|
1669
|
+
try {
|
|
1670
|
+
var saved = window.localStorage.getItem(storeKey)
|
|
1671
|
+
return saved == null ? Boolean(props.open) : saved === '1'
|
|
1672
|
+
} catch (_) { return Boolean(props.open) }
|
|
1673
|
+
})
|
|
1674
|
+
var open = sOpen[0]; var setOpen = sOpen[1]
|
|
1675
|
+
function onToggle(event) {
|
|
1676
|
+
var next = Boolean(event.currentTarget.open)
|
|
1677
|
+
setOpen(next)
|
|
1678
|
+
try { window.localStorage.setItem(storeKey, next ? '1' : '0') } catch (_) { /* ignore */ }
|
|
1679
|
+
}
|
|
1680
|
+
return h('details', { className: 'dm-details', open: open, onToggle: onToggle },
|
|
1681
|
+
h('summary', null, props.title, props.badge != null ? h('span', { className: 'dm-pill muted' }, String(props.badge)) : null),
|
|
1682
|
+
// 2026-11-10:折叠面板正文用独立容器整栏铺满;原先借用 .dm-body(62% 双栏 grid)
|
|
1683
|
+
// 会把「委托台账」这类单列表格压到 300px,中文被逐字折成竖排。
|
|
1684
|
+
h('div', { className: 'dm-details-body' }, props.children)
|
|
1685
|
+
)
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function InstanceList(props) {
|
|
1689
|
+
var instances = (props.data.local && props.data.local.instances) || []
|
|
1690
|
+
if (!instances.length) return h('div', { className: 'dm-empty' }, '无本机实例数据')
|
|
1691
|
+
return h('div', null, instances.map(function (inst) {
|
|
1692
|
+
var model = inst.model ? (inst.model.provider || '') + ' / ' + (inst.model.model || '') : '—'
|
|
1693
|
+
return h('div', { className: 'dm-instrow', key: inst.id },
|
|
1694
|
+
h('div', { style: { minWidth: 0 } },
|
|
1695
|
+
h('div', { style: { fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 } }, h('span', { className: 'dm-dot ' + (inst.up ? 'ok' : 'bad') }), inst.id),
|
|
1696
|
+
h('div', { className: 'dm-sub', style: { marginTop: 1 } }, model)
|
|
1697
|
+
),
|
|
1698
|
+
h('div', { className: 'dm-mini', style: { marginTop: 0 } },
|
|
1699
|
+
h('span', { className: 'dm-chip' }, 'pid ' + (inst.pid == null ? '—' : inst.pid)),
|
|
1700
|
+
h('span', { className: 'dm-chip' }, 'tools ' + (inst.tools == null ? '—' : inst.tools)),
|
|
1701
|
+
h('span', { className: 'dm-chip' }, '会话 ' + (inst.sessions || 0)),
|
|
1702
|
+
h('span', { className: 'dm-chip' + (inst.running ? ' run' : '') }, '运行 ' + (inst.running || 0))
|
|
1703
|
+
)
|
|
1704
|
+
)
|
|
1705
|
+
}))
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function DelegationTable(props) {
|
|
1709
|
+
var d = props.data.delegations || {}
|
|
1710
|
+
var list = (d.recent || []).slice(0, 6)
|
|
1711
|
+
var stats = d.byState || {}
|
|
1712
|
+
if (!list.length) return h('div', { className: 'dm-empty' }, '暂无委托记录')
|
|
1713
|
+
return h('div', null,
|
|
1714
|
+
h('div', { className: 'dm-sub', style: { marginBottom: 5 } }, '共 ' + (d.total || 0) + ' 条 · ' + Object.keys(stats).map(function (k) { return k + ' ' + stats[k] }).join(' · ')),
|
|
1715
|
+
// 2026-11-10:外层给横向滚动兜底——栏窄到连固定列宽都放不下时滚动,而不是压缩成竖排。
|
|
1716
|
+
h('div', { className: 'dm-table-wrap' },
|
|
1717
|
+
h('table', { className: 'dm-table' },
|
|
1718
|
+
h('thead', null, h('tr', null, h('th', null, '状态'), h('th', null, '节点'), h('th', null, '任务'), h('th', null, '耗时'), h('th', null, '目标'))),
|
|
1719
|
+
h('tbody', null, list.map(function (task) {
|
|
1720
|
+
return h('tr', { key: task.taskId },
|
|
1721
|
+
h('td', null, stateChip(task.state)),
|
|
1722
|
+
h('td', { title: nodeLabel(task.node) }, nodeLabel(task.node)),
|
|
1723
|
+
h('td', { className: 'dm-mono', style: { fontSize: 11 }, title: String(task.taskId || '') }, String(task.taskId || '').replace(/^T-\d{8}-/, 'T-…')),
|
|
1724
|
+
h('td', { className: 'dm-mono' }, dur(task.durationSec)),
|
|
1725
|
+
h('td', null, h('span', { className: 'dm-trunc', title: task.goal }, task.goal || ''))
|
|
1726
|
+
)
|
|
1727
|
+
}))
|
|
1728
|
+
)
|
|
1729
|
+
)
|
|
1730
|
+
)
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function AlertList(props) {
|
|
1734
|
+
var alerts = props.data.alerts || {}
|
|
1735
|
+
var latest = alerts.latest
|
|
1736
|
+
var history = (alerts.history || []).filter(function (item) { return !latest || item.at !== latest.at }).slice().reverse()
|
|
1737
|
+
if (!latest && !history.length) return h('div', { className: 'dm-empty' }, '没有告警记录')
|
|
1738
|
+
return h('div', null,
|
|
1739
|
+
latest ? h('div', { className: 'dm-alertrow' },
|
|
1740
|
+
h('span', { className: 'dm-chip bad' }, latest.kind || 'alert'),
|
|
1741
|
+
h('span', null, h('span', { className: 'dm-mono' }, latest.at), ' · ', latest.summary || '', latest.consecutiveBad ? ' · 连续 ' + latest.consecutiveBad + ' 次' : '')
|
|
1742
|
+
) : null,
|
|
1743
|
+
history.slice(0, 6).map(function (item, i) {
|
|
1744
|
+
return h('div', { className: 'dm-alertrow', key: i, style: { opacity: 0.75 } },
|
|
1745
|
+
h('span', { className: 'dm-chip' }, item.kind || '?'),
|
|
1746
|
+
h('span', null, h('span', { className: 'dm-mono' }, item.at || ''), ' · ', item.summary || '')
|
|
1747
|
+
)
|
|
1748
|
+
})
|
|
1749
|
+
)
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function jsonFetch(url, options) {
|
|
1753
|
+
return fetch(url, options).then(function (r) { return r.text() }).then(function (text) { try { return JSON.parse(text) } catch (error) { return { ok: false, error: text.slice(0, 120) } } })
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// 2026-11-10:节点管理(从本机移除 peer / 吊销入站 token)。
|
|
1757
|
+
function NodeManager(props) {
|
|
1758
|
+
var sp = useState(null); var peers = sp[0]; var setPeers = sp[1]
|
|
1759
|
+
var st = useState(null); var tokens = st[0]; var setTokens = st[1]
|
|
1760
|
+
var sb = useState(false); var busy = sb[0]; var setBusy = sb[1]
|
|
1761
|
+
var sm = useState(''); var msg = sm[0]; var setMsg = sm[1]
|
|
1762
|
+
function refresh() {
|
|
1763
|
+
setBusy(true)
|
|
1764
|
+
Promise.all([jsonFetch('/api/dsh-mesh/peers'), jsonFetch('/api/dsh-mesh/tokens')]).then(function (r) {
|
|
1765
|
+
if (r[0].ok) setPeers(r[0].peers || []); else setMsg(r[0].error || '读取节点失败')
|
|
1766
|
+
if (r[1].ok) setTokens(r[1].tokens || [])
|
|
1767
|
+
}).finally(function () { setBusy(false) })
|
|
1768
|
+
}
|
|
1769
|
+
useEffect(function () { refresh() }, [])
|
|
1770
|
+
function togglePeer(peer) {
|
|
1771
|
+
setBusy(true); setMsg('')
|
|
1772
|
+
fetch('/api/dsh-mesh/peers/toggle', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: peer.id, disabled: !peer.disabled }) })
|
|
1773
|
+
.then(function (r) { return r.text() }).then(function (text) { try { var j = JSON.parse(text); setMsg(j.ok ? ((peer.disabled ? '已启用 ' : '已禁用 ') + peer.name) : ('操作失败:' + (j.stderr || j.error || '').slice(0, 200))) } catch (e) { setMsg(text.slice(0, 120)) } refresh(); if (props.onChanged) props.onChanged() })
|
|
1774
|
+
.finally(function () { setBusy(false) })
|
|
1775
|
+
}
|
|
1776
|
+
function removePeer(peer) {
|
|
1777
|
+
if (!window.confirm('从本机移除节点「' + peer.name + '」?\n这会删除本机到它的连接;对方的入站 token 需在下方吊销。')) return
|
|
1778
|
+
setBusy(true); setMsg('')
|
|
1779
|
+
jsonFetch('/api/dsh-mesh/peers/remove', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: peer.id }) })
|
|
1780
|
+
.then(function (j) { setMsg(j.ok ? '已移除 ' + peer.name : ('移除失败:' + (j.stderr || j.error || '').slice(0, 200))); refresh(); if (props.onChanged) props.onChanged() })
|
|
1781
|
+
.finally(function () { setBusy(false) })
|
|
1782
|
+
}
|
|
1783
|
+
function revokeToken(tok) {
|
|
1784
|
+
if (!window.confirm('吊销入站 token「' + (tok.note || tok.id) + '」?\n该凭据对应的对端会立即失去访问。')) return
|
|
1785
|
+
setBusy(true); setMsg('')
|
|
1786
|
+
jsonFetch('/api/dsh-mesh/tokens/revoke', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: tok.id }) })
|
|
1787
|
+
.then(function (j) { setMsg(j.ok ? '已吊销 ' + tok.id : ('吊销失败:' + (j.stderr || j.error || '').slice(0, 200))); refresh(); if (props.onChanged) props.onChanged() })
|
|
1788
|
+
.finally(function () { setBusy(false) })
|
|
1789
|
+
}
|
|
1790
|
+
return h('div', { className: 'dm-nodes' },
|
|
1791
|
+
h('div', { className: 'dm-lan-row' },
|
|
1792
|
+
h('button', { className: 'dm-btn', disabled: busy, onClick: refresh }, '刷新节点'),
|
|
1793
|
+
h('span', { className: 'dm-sub' }, '移除只影响本机;要让对方也失去访问,请吊销对应入站 token')
|
|
1794
|
+
),
|
|
1795
|
+
msg ? h('div', { className: 'dm-sub', style: { marginTop: 6 } }, msg) : null,
|
|
1796
|
+
peers === null ? h('div', { className: 'dm-sub' }, '加载中…') :
|
|
1797
|
+
peers.length ? peers.map(function (peer) {
|
|
1798
|
+
return h('div', { className: 'dm-node-row', key: peer.id },
|
|
1799
|
+
h('span', { className: 'dm-dot ' + (peer.disabled ? 'muted' : 'ok'), style: peer.disabled ? { opacity: .4 } : undefined }),
|
|
1800
|
+
h('span', { className: 'dm-node-name' }, peer.name + (peer.disabled ? '(已禁用)' : '')),
|
|
1801
|
+
h('span', { className: 'dm-node-url' }, maskText(peer.url || '')),
|
|
1802
|
+
h('button', { className: 'dm-btn', disabled: busy, onClick: function () { togglePeer(peer) } }, peer.disabled ? '启用' : '禁用'),
|
|
1803
|
+
h('button', { className: 'dm-btn bad', disabled: busy, onClick: function () { removePeer(peer) } }, '移除')
|
|
1804
|
+
)
|
|
1805
|
+
}) : h('div', { className: 'dm-sub' }, '没有直接配对的节点'),
|
|
1806
|
+
tokens && tokens.length ? h('details', { className: 'dm-details', style: { marginTop: 8 } },
|
|
1807
|
+
h('summary', null, '入站 token(' + tokens.length + ')'),
|
|
1808
|
+
tokens.map(function (tok) {
|
|
1809
|
+
return h('div', { className: 'dm-node-row', key: tok.id },
|
|
1810
|
+
h('span', { className: 'dm-node-name' }, tok.id),
|
|
1811
|
+
h('span', { className: 'dm-node-url' }, (tok.note || '') + (tok.role ? ' · ' + tok.role : '')),
|
|
1812
|
+
h('button', { className: 'dm-btn bad', disabled: busy, onClick: function () { revokeToken(tok) } }, '吊销')
|
|
1813
|
+
)
|
|
1814
|
+
})
|
|
1815
|
+
) : null
|
|
1816
|
+
)
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// 2026-11-11:AI 网络使用说明 / 可复制提示词
|
|
1820
|
+
function UsageHelp(props) {
|
|
1821
|
+
return h('div', { className: 'dm-usage' },
|
|
1822
|
+
h('div', { className: 'dm-sub' }, '这里主要看网络状态;具体操作可以直接让 AI 执行,不需要手动记命令。'),
|
|
1823
|
+
h('div', { className: 'dm-usage-row' }, h('span', { className: 'dm-dot ok' }), h('span', null, '绿色:在线;黄色:节点在线但 host bridge 未连接;红色:离线')),
|
|
1824
|
+
h('div', { className: 'dm-usage-row' }, h('span', { className: 'dm-dot warn' }), h('span', null, '局域网配对仍需对方的一次性配对码,不会自动连接')),
|
|
1825
|
+
h('button', { className: 'dm-btn primary', style: { marginTop: 6 }, onClick: props.onOpen }, '查看可以让 AI 做什么')
|
|
1826
|
+
)
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function HelpSheet(props) {
|
|
1830
|
+
var cp = useState(''); var copied = cp[0]; var setCopied = cp[1]
|
|
1831
|
+
var prompts = [
|
|
1832
|
+
['检查网络', '检查当前 AI 网络状态,列出离线节点、host bridge 未连接原因和可执行的修复建议。'],
|
|
1833
|
+
['委托任务', '委托peer-x检查当前定时任务和日志,汇总异常后汇报,不要重启 3081。'],
|
|
1834
|
+
['传文件', '把本机文件传到peer-x指定目录,分块传输,传完用 sha256 校验并告诉我结果。'],
|
|
1835
|
+
['局域网', '扫描局域网节点,自动检查链路;如果我要配对,先让我生成/输入一次性配对码。'],
|
|
1836
|
+
['更新节点', '把peer-x的 dsh-mesh 插件更新到最新版本,更新前备份,完成后验证三实例和定时任务。']
|
|
1837
|
+
]
|
|
1838
|
+
function copy(text) {
|
|
1839
|
+
try { navigator.clipboard && navigator.clipboard.writeText(text) } catch (_) {}
|
|
1840
|
+
setCopied(text); setTimeout(function () { setCopied('') }, 1500)
|
|
1841
|
+
}
|
|
1842
|
+
return h('div', { className: 'dm-help-overlay', onClick: props.onClose },
|
|
1843
|
+
h('div', { className: 'dm-help-card', onClick: function (e) { e.stopPropagation() } },
|
|
1844
|
+
h('div', { className: 'dm-help-head' },
|
|
1845
|
+
h('span', { className: 'dm-help-title' }, 'AI 网络 · 使用说明'),
|
|
1846
|
+
h('button', { className: 'dm-btn', onClick: props.onClose }, '关闭')
|
|
1847
|
+
),
|
|
1848
|
+
h('div', { className: 'dm-help-section' },
|
|
1849
|
+
h('div', { className: 'dm-help-h' }, '可以直接让 AI 做的事'),
|
|
1850
|
+
h('div', { className: 'dm-sub' }, '下面只是示例;实际直接把目标/路径/节点说清楚即可。')
|
|
1851
|
+
),
|
|
1852
|
+
prompts.map(function (p, i) {
|
|
1853
|
+
return h('div', { className: 'dm-help-prompt', key: i },
|
|
1854
|
+
h('div', { className: 'dm-help-prompt-head' }, h('b', null, p[0]), h('button', { className: 'dm-btn', onClick: function () { copy(p[1]) } }, copied === p[1] ? '已复制' : '复制')),
|
|
1855
|
+
h('div', { className: 'dm-sub' }, p[1])
|
|
1856
|
+
)
|
|
1857
|
+
}),
|
|
1858
|
+
h('div', { className: 'dm-help-section' },
|
|
1859
|
+
h('div', { className: 'dm-help-h' }, '边界'),
|
|
1860
|
+
h('div', { className: 'dm-sub' }, '跨机写入默认受限;文件写入需要目标节点 ACL 允许。配对码不要发给不可信节点;关闭公网地址只影响显示,不改变实际链路。')
|
|
1861
|
+
)
|
|
1862
|
+
)
|
|
1863
|
+
)
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function TransferHistory() {
|
|
1867
|
+
var s = useState(null); var items = s[0]; var setItems = s[1]
|
|
1868
|
+
var b = useState(false); var busy = b[0]; var setBusy = b[1]
|
|
1869
|
+
var m = useState(''); var msg = m[0]; var setMsg = m[1]
|
|
1870
|
+
function refresh() {
|
|
1871
|
+
setBusy(true)
|
|
1872
|
+
jsonFetch('/api/dsh-mesh/transfers').then(function (j) {
|
|
1873
|
+
if (j.ok) setItems(j.items || []); else setMsg(j.error || '读取传输历史失败')
|
|
1874
|
+
}).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
|
|
1875
|
+
}
|
|
1876
|
+
useEffect(function () { refresh() }, [])
|
|
1877
|
+
function fmtBytes(n) { n = Number(n) || 0; if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB' }
|
|
1878
|
+
return h('div', { className: 'dm-transfers' },
|
|
1879
|
+
h('div', { className: 'dm-lan-row' },
|
|
1880
|
+
h('button', { className: 'dm-btn', disabled: busy, onClick: refresh }, busy ? '读取中…' : '刷新历史'),
|
|
1881
|
+
h('span', { className: 'dm-sub' }, '记录通过 file_read_chunk / file_write_chunk 完成的传输')
|
|
1882
|
+
),
|
|
1883
|
+
msg ? h('div', { className: 'dm-sub', style: { marginTop: 6 } }, msg) : null,
|
|
1884
|
+
items === null ? h('div', { className: 'dm-sub' }, '加载中…') :
|
|
1885
|
+
items.length ? items.map(function (it, i) {
|
|
1886
|
+
return h('div', { className: 'dm-node-row', key: i },
|
|
1887
|
+
h('span', { className: 'dm-chip ' + (it.direction === 'write' ? 'run' : 'muted') }, it.direction === 'write' ? '写入' : '读取'),
|
|
1888
|
+
h('span', { className: 'dm-node-name' }, String(it.path || '').split(/[\\/]/).pop() || '?'),
|
|
1889
|
+
h('span', { className: 'dm-node-url' }, (it.peer || '本机') + ' · ' + fmtBytes(it.bytes) + (it.totalBytes ? ' / ' + fmtBytes(it.totalBytes) : '')),
|
|
1890
|
+
h('span', { className: 'dm-sub' }, it.at ? new Date(it.at).toLocaleTimeString() : '')
|
|
1891
|
+
)
|
|
1892
|
+
}) : h('div', { className: 'dm-sub' }, '还没有传输记录')
|
|
1893
|
+
)
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// 2026-11-10:局域网首次接入(扫描 / 生成配对码 / 配对)。
|
|
1897
|
+
function LanOnboarding(props) {
|
|
1898
|
+
var sNodes = useState(null); var nodes = sNodes[0]; var setNodes = sNodes[1]
|
|
1899
|
+
var sBusy = useState(false); var busy = sBusy[0]; var setBusy = sBusy[1]
|
|
1900
|
+
var sMsg = useState(''); var msg = sMsg[0]; var setMsg = sMsg[1]
|
|
1901
|
+
var sCode = useState(''); var code = sCode[0]; var setCode = sCode[1]
|
|
1902
|
+
var sMine = useState(null); var mine = sMine[0]; var setMine = sMine[1]
|
|
1903
|
+
var sStatus = useState({}); var statuses = sStatus[0]; var setStatuses = sStatus[1]
|
|
1904
|
+
|
|
1905
|
+
function lanJson(url, options) {
|
|
1906
|
+
return fetch(url, options).then(function (res) {
|
|
1907
|
+
return res.text().then(function (text) {
|
|
1908
|
+
try { return JSON.parse(text) } catch (error) { return { ok: false, error: '此实例的 dsh-mesh-ui 服务端还是旧版(重启 DSH 后可用):' + text.slice(0, 120) } }
|
|
1909
|
+
})
|
|
1910
|
+
})
|
|
1911
|
+
}
|
|
1912
|
+
function scan() {
|
|
1913
|
+
setBusy(true); setMsg('')
|
|
1914
|
+
lanJson('/api/dsh-mesh/lan/scan').then(function (j) {
|
|
1915
|
+
if (!j.ok) { setMsg(j.error || '扫描失败'); return }
|
|
1916
|
+
setNodes(j.nodes || [])
|
|
1917
|
+
setStatuses({})
|
|
1918
|
+
setMsg((j.nodes || []).length ? '发现 ' + j.nodes.length + ' 个节点,正在自动检查链路…' : '没有发现局域网节点(对方需 bind 0.0.0.0 并放行 8124)')
|
|
1919
|
+
;(j.nodes || []).forEach(function (node, idx) {
|
|
1920
|
+
lanJson('/api/dsh-mesh/lan/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: String(node.url || '') }) })
|
|
1921
|
+
.then(function (r) { setStatuses(function (prev) { var n = Object.assign({}, prev); n[idx] = r.ok ? 'ok' : 'bad'; return n }); if (r.ok) setMsg('发现 ' + (j.nodes || []).length + ' 个节点 · 链路正常 ' + (node.node || '')) })
|
|
1922
|
+
.catch(function () { setStatuses(function (prev) { var n = Object.assign({}, prev); n[idx] = 'bad'; return n }) })
|
|
1923
|
+
})
|
|
1924
|
+
}).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
|
|
1925
|
+
}
|
|
1926
|
+
useEffect(function () { scan() }, [])
|
|
1927
|
+
function myCode() {
|
|
1928
|
+
setBusy(true); setMine(null); setMsg('')
|
|
1929
|
+
lanJson('/api/dsh-mesh/lan/code').then(function (j) {
|
|
1930
|
+
if (!j.ok) { setMsg(j.error || '生成配对码失败'); return }
|
|
1931
|
+
setMine(j)
|
|
1932
|
+
}).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
|
|
1933
|
+
}
|
|
1934
|
+
function checkNode(node) {
|
|
1935
|
+
setBusy(true); setMsg('')
|
|
1936
|
+
lanJson('/api/dsh-mesh/lan/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: String(node.url || '') }) })
|
|
1937
|
+
.then(function (j) {
|
|
1938
|
+
if (!j.ok) { setMsg('链路不可达:' + (j.error || 'unknown')); return }
|
|
1939
|
+
setMsg('链路可达:' + j.host + ':' + j.port + ' · TCP ok · ' + j.latencyMs + 'ms' + (j.tls && j.tls.ok ? ' · TLS ok' : ''))
|
|
1940
|
+
}).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
|
|
1941
|
+
}
|
|
1942
|
+
function pairWith(node) {
|
|
1943
|
+
var c = String(code || '').trim()
|
|
1944
|
+
if (!c) { setMsg('先填入对方显示的配对码'); return }
|
|
1945
|
+
setBusy(true); setMsg('')
|
|
1946
|
+
lanJson('/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 || '' }) })
|
|
1947
|
+
.then(function (j) {
|
|
1948
|
+
setMsg(j.ok ? '配对成功:' + (node.node || '') + ',正在复查链路…' : ('配对失败:' + (j.stderr || j.error || j.stdout || '').slice(0, 300)))
|
|
1949
|
+
if (j.ok) setTimeout(function () { checkNode(node); scan(); if (props.onChanged) props.onChanged() }, 900)
|
|
1950
|
+
}).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
|
|
1951
|
+
}
|
|
1952
|
+
return h('div', null,
|
|
1953
|
+
h('div', { className: 'dm-lan-row' },
|
|
1954
|
+
h('button', { className: 'dm-btn', disabled: busy, onClick: scan }, busy ? '处理中…' : '扫描局域网'),
|
|
1955
|
+
h('button', { className: 'dm-btn', disabled: busy, onClick: myCode }, '生成我的配对码'),
|
|
1956
|
+
h('span', { className: 'dm-sub' }, '扫描只发现节点,不会自动连接;配对必须输入对方生成的一次性配对码')
|
|
1957
|
+
),
|
|
1958
|
+
msg ? h('div', { className: 'dm-sub', style: { marginTop: 6 } }, msg) : null,
|
|
1959
|
+
mine ? h('div', { className: 'dm-lan-code', style: { marginTop: 8 } }, '配对码 ' + mine.pairingCode + ' · ' + (mine.endpoint || '') + (mine.certFingerprint ? ' · 指纹 ' + mine.certFingerprint : '')) : null,
|
|
1960
|
+
nodes && nodes.length ? h('div', { style: { marginTop: 8 } },
|
|
1961
|
+
nodes.map(function (node, i) {
|
|
1962
|
+
return h('div', { className: 'dm-lan-node', key: i },
|
|
1963
|
+
h('span', { className: 'dm-dot ' + (statuses[i] === 'ok' ? 'ok' : statuses[i] === 'bad' ? 'bad' : 'muted'), title: statuses[i] === 'ok' ? '链路可达' : statuses[i] === 'bad' ? '链路不可达' : '检查中' }),
|
|
1964
|
+
h('span', null, (node.node || '?') + ' · ' + (node.url || '').replace('/mcp', '')),
|
|
1965
|
+
h('button', { className: 'dm-btn', disabled: busy, onClick: function () { checkNode(node) } }, '检查链路'),
|
|
1966
|
+
h('button', { className: 'dm-btn primary', disabled: busy || !String(code || '').trim(), onClick: function () { pairWith(node) } }, '用此节点配对')
|
|
1967
|
+
)
|
|
1968
|
+
})
|
|
1969
|
+
) : null,
|
|
1970
|
+
nodes && nodes.length ? h('div', { className: 'dm-lan-row' },
|
|
1971
|
+
h('input', { placeholder: '对方显示的配对码,如 H5K4-9RXV', value: code, onChange: function (e) { setCode(e.target.value) } })
|
|
1972
|
+
) : null
|
|
1973
|
+
)
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function MeshStatusSection() {
|
|
1977
|
+
var s0 = useState(null); var data = s0[0]; var setData = s0[1]
|
|
1978
|
+
var s1 = useState(true); var loading = s1[0]; var setLoading = s1[1]
|
|
1979
|
+
var sFed = useState(null); var federated = sFed[0]; var setFederated = sFed[1]
|
|
1980
|
+
var federatedRef = useRef({ at: 0 })
|
|
1981
|
+
var s2 = useState(''); var error = s2[0]; var setError = s2[1]
|
|
1982
|
+
var s3 = useState(0); var tick = s3[0]; var setTick = s3[1]
|
|
1983
|
+
var s4 = useState(''); var copied = s4[0]; var setCopied = s4[1]
|
|
1984
|
+
// 2026-11-10:真刷新 —— 点击后等待后台采集完成再渲染;本地冷却 + 忙锁防止连点刷爆请求
|
|
1985
|
+
var s6 = useState(""); var refreshHint = s6[0]; var setRefreshHint = s6[1]
|
|
1986
|
+
var forceRef = useRef({ at: 0, busy: false })
|
|
1987
|
+
// 2026-11-10:verdict 防抖 —— 单次探测失败(远端忙/超时)只显示 degraded,连续 2 次才显示 down
|
|
1988
|
+
var verdictState = useState(''); var verdictShown = verdictState[0]; var setVerdictShown = verdictState[1]
|
|
1989
|
+
var verdictRef = useRef({ raw: '', streak: 0 })
|
|
1990
|
+
var s5 = useState('local'); var selectedKey = s5[0]; var setSelectedKey = s5[1]
|
|
1991
|
+
var sHelp = useState(false); var helpOpen = sHelp[0]; var setHelpOpen = sHelp[1]
|
|
1992
|
+
var sPublic = useState(function () {
|
|
1993
|
+
try { return window.localStorage.getItem('dsh-mesh.show-public-addresses') === '1' } catch (error) { return false }
|
|
1994
|
+
})
|
|
1995
|
+
var showPublic = sPublic[0]; var setShowPublic = sPublic[1]
|
|
1996
|
+
showPublicAddresses = showPublic
|
|
1997
|
+
function toggleShowPublic(event) {
|
|
1998
|
+
var next = Boolean(event && event.target && event.target.checked)
|
|
1999
|
+
setShowPublic(next)
|
|
2000
|
+
showPublicAddresses = next
|
|
2001
|
+
try { window.localStorage.setItem('dsh-mesh.show-public-addresses', next ? '1' : '0') } catch (error) { /* ignore */ }
|
|
2002
|
+
}
|
|
2003
|
+
// 双栏宽度:拖动分隔条调整并记忆,窄屏由 CSS 媒体查询自动堆叠。
|
|
2004
|
+
var dragState = useState(function () {
|
|
2005
|
+
try { var saved = Number(window.localStorage.getItem('dsh-mesh-split-w')); return Number.isFinite(saved) && saved > 0 ? saved : 0 } catch (error) { return 0 }
|
|
2006
|
+
})
|
|
2007
|
+
var splitW = dragState[0]; var setSplitW = dragState[1]
|
|
2008
|
+
|
|
2009
|
+
function startSplit(event) {
|
|
2010
|
+
event.preventDefault()
|
|
2011
|
+
var body = event.currentTarget.parentElement
|
|
2012
|
+
if (!body) return
|
|
2013
|
+
var rect = body.getBoundingClientRect()
|
|
2014
|
+
var startW = splitW > 0 ? splitW : Math.round(rect.width * 0.62)
|
|
2015
|
+
var currentW = startW
|
|
2016
|
+
function onMove(moveEvent) {
|
|
2017
|
+
var width = Math.max(300, Math.min(moveEvent.clientX - rect.left, Math.max(300, rect.width - 360)))
|
|
2018
|
+
currentW = width
|
|
2019
|
+
setSplitW(width)
|
|
2020
|
+
}
|
|
2021
|
+
function onUp() {
|
|
2022
|
+
window.removeEventListener('pointermove', onMove)
|
|
2023
|
+
window.removeEventListener('pointerup', onUp)
|
|
2024
|
+
document.body.style.cursor = ''
|
|
2025
|
+
document.body.style.userSelect = ''
|
|
2026
|
+
try { window.localStorage.setItem('dsh-mesh-split-w', String(currentW)) } catch (error) { /* ignore */ }
|
|
2027
|
+
}
|
|
2028
|
+
document.body.style.cursor = 'col-resize'
|
|
2029
|
+
document.body.style.userSelect = 'none'
|
|
2030
|
+
window.addEventListener('pointermove', onMove)
|
|
2031
|
+
window.addEventListener('pointerup', onUp)
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function load(force) {
|
|
2035
|
+
if (!force) {
|
|
2036
|
+
setLoading(true)
|
|
2037
|
+
fetchStatus(false)
|
|
2038
|
+
.then(function (json) {
|
|
2039
|
+
setData(json)
|
|
2040
|
+
setError(json && json.ok === false && !json.warming ? json.error || "mesh-health error" : "")
|
|
2041
|
+
})
|
|
2042
|
+
.catch(function (err) { setError(String((err && err.message) || err)) })
|
|
2043
|
+
.finally(function () { setLoading(false) })
|
|
2044
|
+
return
|
|
2045
|
+
}
|
|
2046
|
+
var st = forceRef.current
|
|
2047
|
+
var now = Date.now()
|
|
2048
|
+
if (st.busy) return
|
|
2049
|
+
if (now - st.at < 8000) {
|
|
2050
|
+
var waitSec = Math.ceil((8000 - (now - st.at)) / 1000)
|
|
2051
|
+
setRefreshHint("刚采集过," + waitSec + "s 后可再刷新")
|
|
2052
|
+
setTimeout(function () { setRefreshHint("") }, 2200)
|
|
2053
|
+
return
|
|
2054
|
+
}
|
|
2055
|
+
st.busy = true
|
|
2056
|
+
st.at = now
|
|
2057
|
+
setLoading(true)
|
|
2058
|
+
setRefreshHint("采集中…")
|
|
2059
|
+
var prevAt = data && data.at
|
|
2060
|
+
var attempts = 0
|
|
2061
|
+
function poll() {
|
|
2062
|
+
attempts++
|
|
2063
|
+
fetchStatus(false).then(function (json) {
|
|
2064
|
+
var fresh = json && json.refreshing === false && json.at && json.at !== prevAt
|
|
2065
|
+
if (fresh || attempts >= 15) {
|
|
2066
|
+
setData(json)
|
|
2067
|
+
setError(json && json.ok === false && !json.warming ? json.error || "mesh-health error" : "")
|
|
2068
|
+
setLoading(false)
|
|
2069
|
+
st.busy = false
|
|
2070
|
+
setRefreshHint(fresh ? "已刷新" : "无新数据(采集超时)")
|
|
2071
|
+
setTimeout(function () { setRefreshHint("") }, 2000)
|
|
2072
|
+
return
|
|
2073
|
+
}
|
|
2074
|
+
setTimeout(poll, 800)
|
|
2075
|
+
}).catch(function () {
|
|
2076
|
+
st.busy = false
|
|
2077
|
+
setLoading(false)
|
|
2078
|
+
setRefreshHint("刷新失败")
|
|
2079
|
+
setTimeout(function () { setRefreshHint("") }, 2500)
|
|
2080
|
+
})
|
|
2081
|
+
}
|
|
2082
|
+
fetchStatus(true)
|
|
2083
|
+
.catch(function () { /* continue polling */ })
|
|
2084
|
+
.then(function () { setTimeout(poll, 500) })
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
useEffect(function () {
|
|
2088
|
+
load(false)
|
|
2089
|
+
var timer = setInterval(function () { setTick(function (n) { return n + 1 }) }, REFRESH_MS)
|
|
2090
|
+
return function () { clearInterval(timer) }
|
|
2091
|
+
}, [])
|
|
2092
|
+
|
|
2093
|
+
useEffect(function () { if (tick > 0 && !forceRef.current.busy) load(false) }, [tick])
|
|
2094
|
+
useEffect(function () {
|
|
2095
|
+
if (!data) return
|
|
2096
|
+
var now = Date.now()
|
|
2097
|
+
if (now - federatedRef.current.at < 60000) return
|
|
2098
|
+
federatedRef.current.at = now
|
|
2099
|
+
fetch('/api/dsh-mesh/federated').then(function (r) { return r.text() }).then(function (text) { try { setFederated(JSON.parse(text)) } catch (error) {} }).catch(function () {})
|
|
2100
|
+
}, [data])
|
|
2101
|
+
useEffect(function () {
|
|
2102
|
+
var raw = String((data && data.verdict) || '')
|
|
2103
|
+
if (!raw) return
|
|
2104
|
+
var st = verdictRef.current
|
|
2105
|
+
var at = String((data && data.at) || '')
|
|
2106
|
+
var fresh = Boolean(at) && at !== st.lastAt
|
|
2107
|
+
if (raw === 'down') {
|
|
2108
|
+
if (fresh && !st.downAt) st.downAt = Date.now()
|
|
2109
|
+
st.lastAt = at
|
|
2110
|
+
var persisted = Boolean(st.downAt) && Date.now() - st.downAt >= 15000
|
|
2111
|
+
setVerdictShown(persisted ? 'down' : 'degraded')
|
|
2112
|
+
} else {
|
|
2113
|
+
st.downAt = 0
|
|
2114
|
+
st.lastAt = at
|
|
2115
|
+
setVerdictShown(raw)
|
|
2116
|
+
}
|
|
2117
|
+
st.raw = raw
|
|
2118
|
+
}, [data])
|
|
2119
|
+
|
|
2120
|
+
function copyJson() {
|
|
2121
|
+
var text = JSON.stringify(data || {}, null, 2)
|
|
2122
|
+
var done = function () { setCopied('已复制'); setTimeout(function () { setCopied('') }, 1600) }
|
|
2123
|
+
try {
|
|
2124
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, done)
|
|
2125
|
+
else done()
|
|
2126
|
+
} catch (_) { done() }
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
if (!data) {
|
|
2130
|
+
return h('div', { className: 'dm-root' },
|
|
2131
|
+
h('div', { className: 'dm-head' }, h('span', { className: 'dm-title' }, 'AI 网络'), h('span', { className: 'dm-pill muted' }, loading ? '加载中…' : '无数据')),
|
|
2132
|
+
error ? h('div', { className: 'dm-err' }, error) : null
|
|
2133
|
+
)
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
var versions = data.versionMismatch || []
|
|
2137
|
+
var issues = data.issues || []
|
|
2138
|
+
var hints = data.hints || []
|
|
2139
|
+
|
|
2140
|
+
return h('div', { className: 'dm-root' },
|
|
2141
|
+
h('style', null, CSS),
|
|
2142
|
+
h('div', { className: 'dm-head' },
|
|
2143
|
+
h('span', { className: 'dm-title' }, 'AI 网络 · dsh-mesh'),
|
|
2144
|
+
h('span', {
|
|
2145
|
+
className: 'dm-pill ' + (data.warming ? 'muted' : tone(verdictShown || data.verdict)),
|
|
2146
|
+
title: data.warming ? '' : (verdictShown === 'degraded' ? '探测波动:需持续 ≥15s 且新快照仍 down 才显示 down' : ''),
|
|
2147
|
+
style: verdictShown === 'degraded' ? { opacity: 0.75 } : undefined
|
|
2148
|
+
}, data.warming ? '预热中' : (verdictShown || data.verdict || '?')),
|
|
2149
|
+
h('span', { className: 'dm-sub' }, (data.meshVersion ? 'mesh ' + data.meshVersion + ' · ' : '') + (data.at ? ago(data.at) : '')),
|
|
2150
|
+
h('span', { style: { flex: 1 } }),
|
|
2151
|
+
h('label', { className: 'dm-toggle', title: '默认隐藏公网 IP/域名;勾选后显示真实地址并记住选择' },
|
|
2152
|
+
h('input', { type: 'checkbox', checked: Boolean(showPublic), onChange: toggleShowPublic }),
|
|
2153
|
+
h('span', null, '显示公网地址')
|
|
2154
|
+
),
|
|
2155
|
+
h('a', { className: 'dm-btn', href: API, target: '_blank', rel: 'noreferrer', style: { textDecoration: 'none' } }, '原始 JSON'),
|
|
2156
|
+
h('button', { className: 'dm-btn', onClick: copyJson }, copied || '复制诊断'),
|
|
2157
|
+
h('button', { className: 'dm-btn', onClick: function () { setHelpOpen(true) } }, '使用说明'),
|
|
2158
|
+
h('button', { className: 'dm-btn primary', onClick: function () { load(true) }, title: '立即采集一次并等待新快照(8s 冷却)' }, loading ? '采集中…' : '刷新'),
|
|
2159
|
+
refreshHint ? h('span', { className: 'dm-sub' }, refreshHint) : null
|
|
2160
|
+
),
|
|
2161
|
+
error ? h('div', { className: 'dm-err' }, error) : null,
|
|
2162
|
+
|
|
2163
|
+
helpOpen ? h(HelpSheet, { onClose: function () { setHelpOpen(false) } }) : null,
|
|
2164
|
+
|
|
2165
|
+
h('div', { className: 'dm-body', style: splitW > 0 ? { '--dm-main-w': splitW + 'px' } : undefined },
|
|
2166
|
+
h('div', { className: 'dm-pane' },
|
|
2167
|
+
h(Viewport, { data: data, federated: federated, layout: computeLayout(1 + filterSelfPeers(dedupePeers(data.peers || []), data.nodeName).length), selectedKey: selectedKey, onSelect: setSelectedKey }),
|
|
2168
|
+
h(InfraPanels, { data: data }),
|
|
2169
|
+
h(DeviceDetails, { data: data, selectedKey: selectedKey })
|
|
2170
|
+
),
|
|
2171
|
+
h('div', { className: 'dm-divider', onPointerDown: startSplit, title: '↔ 拖拽调整左右宽度(双击恢复默认)', onDoubleClick: function () { setSplitW(0); try { window.localStorage.removeItem('dsh-mesh-split-w') } catch (error) { /* ignore */ } } }),
|
|
2172
|
+
h('div', { className: 'dm-pane' },
|
|
2173
|
+
h(Details, { title: '使用说明', badge: 'AI 能做什么', open: true }, h(UsageHelp, { onOpen: function () { setHelpOpen(true) } })),
|
|
2174
|
+
h(Details, { title: '局域网连接', badge: '扫描 / 配对' }, h(LanOnboarding, { onChanged: function () { load(true) } })),
|
|
2175
|
+
h(Details, { title: '节点管理', badge: '移除 / 吊销' }, h(NodeManager, { onChanged: function () { load(true) } })),
|
|
2176
|
+
h(Details, { title: '文件传输', badge: '历史' }, h(TransferHistory)),
|
|
2177
|
+
h(Details, { title: '本机实例', badge: ((data.local && data.local.instances) || []).length, open: true }, h(InstanceList, { data: data })),
|
|
2178
|
+
h(Details, { title: '版本一致性', badge: versions.length ? versions.length + ' 处升级' : '一致' },
|
|
2179
|
+
versions.length
|
|
2180
|
+
? versions.map(function (item, i) { return h('div', { key: i, className: 'dm-sub', style: { marginTop: 4 } }, '· ' + (typeof item === 'string' ? item : JSON.stringify(item))) })
|
|
2181
|
+
: h('div', { className: 'dm-empty' }, '所有节点/实例版本一致')
|
|
2182
|
+
),
|
|
2183
|
+
h(Details, { title: '委托台账', badge: (data.delegations && data.delegations.total) || 0 }, h(DelegationTable, { data: data })),
|
|
2184
|
+
h(Details, { title: '告警历史', badge: ((data.alerts && data.alerts.history) || []).length }, h(AlertList, { data: data })),
|
|
2185
|
+
(issues.length || hints.length) ? h(Details, { title: '诊断建议', badge: issues.length + hints.length },
|
|
2186
|
+
h('div', null,
|
|
2187
|
+
issues.slice(0, 6).map(function (issue, i) { return h('div', { key: 'i' + i, className: 'dm-err', style: { marginTop: 3 } }, '· ' + maskText(issue)) }),
|
|
2188
|
+
hints.slice(0, 6).map(function (hint, i) { return h('div', { key: 'h' + i, className: 'dm-sub', style: { marginTop: 3 } }, '· ' + maskText(hint)) })
|
|
2189
|
+
)
|
|
2190
|
+
) : null
|
|
2191
|
+
)
|
|
2192
|
+
),
|
|
2193
|
+
h('div', { className: 'dm-foot' }, '数据源:mesh-health / host status / peer host_self / 委托台账 / 告警历史 · ' + (data.repo || '') + ' · node ' + (data.nodeVersion || '') + ' · ' + REFRESH_MS / 1000 + 's 自动刷新(只读)')
|
|
2194
|
+
)
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// ================= 委托面板(只读,方案 A / v2 视觉重做) =================
|
|
2198
|
+
var DG_API_LIST = '/api/dsh-mesh/delegations'
|
|
2199
|
+
var DG_API_TASK = '/api/dsh-mesh/task'
|
|
2200
|
+
var DG_API_SESSION = '/api/dsh-mesh/session'
|
|
2201
|
+
|
|
2202
|
+
var DG_STATE_TONE = {
|
|
2203
|
+
done: 'ok', working: 'run', accepted: 'run', submitted: 'run',
|
|
2204
|
+
question: 'warn', stalled: 'warn', failed: 'bad', cancelled: 'muted'
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
var DG_CSS = `
|
|
2208
|
+
.dm-dg-root { display:flex; flex-direction:column; gap:12px; height:100%; min-height:0; padding:16px; box-sizing:border-box; font-size:13px; color:inherit; }
|
|
2209
|
+
.dm-dg-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
|
2210
|
+
.dm-dg-title { font-size:16px; font-weight:650; letter-spacing:.2px; }
|
|
2211
|
+
.dm-dg-sub { font-size:11.5px; opacity:.55; }
|
|
2212
|
+
.dm-dg-pill { font-size:10.5px; padding:1px 8px; border-radius:999px; border:1px solid rgba(128,128,128,.32); opacity:.85; }
|
|
2213
|
+
.dm-dg-pill.readonly { border-color:rgba(88,166,255,.5); color:#3b82d6; opacity:1; }
|
|
2214
|
+
.dm-dg-stats { display:flex; gap:6px; flex-wrap:wrap; }
|
|
2215
|
+
.dm-dg-stat { font-size:10.5px; padding:1px 8px; border-radius:999px; border:1px solid rgba(128,128,128,.28); display:flex; gap:5px; align-items:center; }
|
|
2216
|
+
.dm-dg-stat b { font-weight:650; }
|
|
2217
|
+
.dm-dg-spacer { flex:1; }
|
|
2218
|
+
.dm-dg-btn { font-size:11.5px; padding:4px 11px; border-radius:8px; border:1px solid rgba(128,128,128,.4); background:transparent; color:inherit; cursor:pointer; white-space:nowrap; display:inline-flex; align-items:center; gap:5px; }
|
|
2219
|
+
.dm-dg-btn:hover:not(:disabled) { background:rgba(128,128,128,.12); }
|
|
2220
|
+
.dm-dg-btn:disabled { opacity:.45; cursor:default; }
|
|
2221
|
+
.dm-dg-btn.primary { border-color:rgba(88,166,255,.6); color:#3b82d6; }
|
|
2222
|
+
.dm-dg-notice { font-size:11.5px; padding:7px 10px; border-radius:8px; border:1px solid transparent; display:flex; gap:8px; align-items:center; }
|
|
2223
|
+
.dm-dg-notice.info { background:rgba(88,166,255,.1); border-color:rgba(88,166,255,.35); }
|
|
2224
|
+
.dm-dg-notice.ok { background:rgba(63,185,80,.12); border-color:rgba(63,185,80,.4); color:#2e9e4f; }
|
|
2225
|
+
.dm-dg-notice.bad { background:rgba(248,81,73,.12); border-color:rgba(248,81,73,.4); color:#c9362c; }
|
|
2226
|
+
.dm-dg-body { display:grid; grid-template-columns:var(--dm-dg-list-w, 360px) 7px minmax(0,1fr); gap:10px; min-height:0; flex:1; }
|
|
2227
|
+
@media (max-width: 960px) { .dm-dg-body { grid-template-columns:1fr; } .dm-dg-divider { display:none; } }
|
|
2228
|
+
.dm-dg-pane { display:flex; flex-direction:column; min-height:0; border:1px solid rgba(128,128,128,.25); border-radius:12px; background:rgba(128,128,128,.035); overflow:hidden; }
|
|
2229
|
+
.dm-dg-pane-head { flex:none; display:flex; align-items:center; gap:8px; padding:9px 12px; border-bottom:1px solid rgba(128,128,128,.2); background:rgba(128,128,128,.05); font-weight:650; font-size:12px; }
|
|
2230
|
+
.dm-dg-pane-head .dm-dg-sub { font-weight:400; }
|
|
2231
|
+
.dm-dg-scroll { flex:1; min-height:0; overflow:auto; }
|
|
2232
|
+
.dm-dg-row { position:relative; display:flex; flex-direction:column; gap:5px; padding:10px 12px 10px 15px; border-bottom:1px solid rgba(128,128,128,.14); cursor:pointer; }
|
|
2233
|
+
.dm-dg-row:last-child { border-bottom:none; }
|
|
2234
|
+
.dm-dg-row::before { content:''; position:absolute; left:0; top:0; bottom:0; width:3px; background:transparent; }
|
|
2235
|
+
.dm-dg-row.ok::before { background:#2e9e4f; }
|
|
2236
|
+
.dm-dg-row.run::before { background:#3b82d6; }
|
|
2237
|
+
.dm-dg-row.warn::before { background:#d29922; }
|
|
2238
|
+
.dm-dg-row.bad::before { background:#d14b41; }
|
|
2239
|
+
.dm-dg-row:hover { background:rgba(128,128,128,.09); }
|
|
2240
|
+
.dm-dg-row.active { background:rgba(88,166,255,.13); }
|
|
2241
|
+
.dm-dg-divider { position:relative; cursor:col-resize; min-height:0; border-radius:4px; }
|
|
2242
|
+
.dm-dg-divider::before { content:''; position:absolute; top:0; bottom:0; left:3px; width:1px; background:rgba(128,128,128,.28); }
|
|
2243
|
+
.dm-dg-divider:hover::before, .dm-dg-divider:active::before { background:rgba(88,166,255,.7); width:2px; left:2.5px; }
|
|
2244
|
+
.dm-dg-spinner { flex:none; color:var(--dsw-alias-label-tertiary, rgba(128,128,128,.85)); }
|
|
2245
|
+
.dm-dg-spinner-motion { transform-origin:center; animation:dm-dg-spin 1.5s linear infinite; }
|
|
2246
|
+
.dm-dg-spinner-track, .dm-dg-spinner-arc { fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; }
|
|
2247
|
+
.dm-dg-spinner-track { opacity:.25; }
|
|
2248
|
+
.dm-dg-spinner-arc { stroke-dasharray:12 150; animation:dm-dg-dash 1.5s ease-in-out infinite; }
|
|
2249
|
+
@keyframes dm-dg-spin { to { transform:rotate(360deg); } }
|
|
2250
|
+
@keyframes dm-dg-dash {
|
|
2251
|
+
0% { stroke-dasharray:12 150; stroke-dashoffset:0; }
|
|
2252
|
+
50% { stroke-dasharray:24 150; stroke-dashoffset:-6; }
|
|
2253
|
+
100% { stroke-dasharray:12 150; stroke-dashoffset:0; }
|
|
2254
|
+
}
|
|
2255
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2256
|
+
.dm-dg-spinner-motion, .dm-dg-spinner-arc { animation:none; }
|
|
2257
|
+
.dm-dg-spinner-arc { stroke-dasharray:18 150; stroke-dashoffset:-3; }
|
|
2258
|
+
}
|
|
2259
|
+
.dm-dg-row-top { display:flex; align-items:center; gap:8px; min-width:0; }
|
|
2260
|
+
.dm-dg-row-node { font-weight:650; white-space:nowrap; }
|
|
2261
|
+
.dm-dg-route { display:inline-flex; align-items:center; gap:6px; min-width:0; }
|
|
2262
|
+
.dm-dg-from, .dm-dg-to { font-weight:650; font-size:12.5px; white-space:nowrap; }
|
|
2263
|
+
.dm-dg-from { color:inherit; opacity:.85; }
|
|
2264
|
+
.dm-dg-to { color:#3b82d6; }
|
|
2265
|
+
.dm-dg-arrow { opacity:.5; font-size:11px; }
|
|
2266
|
+
.dm-dg-me { font-size:9.5px; padding:0 5px; border-radius:999px; border:1px solid rgba(63,185,80,.55); color:#2e9e4f; line-height:15px; }
|
|
2267
|
+
.dm-dg-filters { position:relative; z-index:5; display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:7px 10px; border-bottom:1px solid rgba(128,128,128,.16); background:rgba(128,128,128,.03); }
|
|
2268
|
+
.dm-dg-fgroup { display:flex; align-items:center; gap:5px; flex-wrap:wrap; font-size:10px; opacity:.8; }
|
|
2269
|
+
.dm-dg-fs { position:relative; display:inline-flex; align-items:center; gap:4px; }
|
|
2270
|
+
.dm-dg-fs-label { font-size:10px; opacity:.6; }
|
|
2271
|
+
.dm-dg-fs-btn { display:inline-flex; align-items:center; gap:6px; font-size:10.5px; padding:2px 8px; border-radius:6px; border:1px solid rgba(128,128,128,.35); background:rgba(128,128,128,.08); color:inherit; cursor:pointer; }
|
|
2272
|
+
.dm-dg-fs-btn:hover { background:rgba(128,128,128,.16); }
|
|
2273
|
+
.dm-dg-fs-caret { font-size:8px; opacity:.55; }
|
|
2274
|
+
.dm-dg-fs-menu { position:absolute; top:calc(100% + 4px); left:0; min-width:132px; padding:4px; border-radius:8px; border:1px solid rgba(128,128,128,.3); background:#ffffff; color:#1a1a1a; box-shadow:0 8px 24px rgba(0,0,0,.18); z-index:90; display:flex; flex-direction:column; gap:2px; }
|
|
2275
|
+
.dm-dg-fs-item { text-align:left; font-size:11px; padding:4px 9px; border-radius:5px; border:none; background:transparent; color:inherit; cursor:pointer; white-space:nowrap; }
|
|
2276
|
+
.dm-dg-fs-item:hover { background:rgba(128,128,128,.18); }
|
|
2277
|
+
.dm-dg-fs-item.active { background:rgba(88,166,255,.2); color:#2b6fc4; }
|
|
2278
|
+
body[data-ds-dark-theme] .dm-dg-fs-menu { background:#24262b; color:#e6e6e6; border-color:rgba(255,255,255,.16); box-shadow:0 8px 24px rgba(0,0,0,.55); }
|
|
2279
|
+
body[data-ds-dark-theme] .dm-dg-fs-item:hover { background:rgba(255,255,255,.1); }
|
|
2280
|
+
body[data-ds-dark-theme] .dm-dg-fs-item.active { background:rgba(88,166,255,.28); color:#79b8ff; }
|
|
2281
|
+
.dm-dg-fbtn { font-size:10px; padding:2px 8px; border-radius:6px; border:1px solid rgba(128,128,128,.35); background:transparent; color:inherit; cursor:pointer; }
|
|
2282
|
+
.dm-dg-fhint { font-size:9.5px; opacity:.45; }
|
|
2283
|
+
.dm-dg-fchip { font-size:10px; padding:0 8px; line-height:16px; border-radius:999px; border:1px solid rgba(128,128,128,.3); background:transparent; color:inherit; cursor:pointer; }
|
|
2284
|
+
.dm-dg-fchip.active { border-color:rgba(88,166,255,.7); color:#3b82d6; background:rgba(88,166,255,.12); }
|
|
2285
|
+
.dm-dg-badge { padding:0 6px; border-radius:5px; border:1px solid rgba(128,128,128,.32); font-size:10px; opacity:.8; }
|
|
2286
|
+
.dm-dg-row-time { margin-left:auto; font-size:10.5px; opacity:.5; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
|
2287
|
+
.dm-dg-chip { font-size:10px; padding:0 8px; border-radius:999px; border:1px solid rgba(128,128,128,.32); white-space:nowrap; line-height:17px; display:inline-block; }
|
|
2288
|
+
.dm-dg-chip.ok { border-color:rgba(63,185,80,.55); color:#2e9e4f; }
|
|
2289
|
+
.dm-dg-chip.bad { border-color:rgba(248,81,73,.55); color:#c9362c; }
|
|
2290
|
+
.dm-dg-chip.run { border-color:rgba(88,166,255,.55); color:#3b82d6; }
|
|
2291
|
+
.dm-dg-chip.warn { border-color:rgba(210,153,34,.6); color:#9a6c12; }
|
|
2292
|
+
.dm-dg-chip.muted { opacity:.7; }
|
|
2293
|
+
.dm-dg-goal { font-size:11.5px; opacity:.8; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
2294
|
+
.dm-dg-meta { display:flex; gap:8px; flex-wrap:wrap; font-size:10px; opacity:.5; font-variant-numeric:tabular-nums; }
|
|
2295
|
+
.dm-dg-empty { padding:22px 14px; text-align:center; opacity:.55; font-size:12px; line-height:1.7; }
|
|
2296
|
+
.dm-dg-skel { padding:10px 12px; display:flex; flex-direction:column; gap:8px; }
|
|
2297
|
+
.dm-dg-skel i { display:block; height:10px; border-radius:6px; background:rgba(128,128,128,.18); animation:dm-dg-pulse 1.2s ease-in-out infinite; }
|
|
2298
|
+
.dm-dg-skel i:nth-child(2) { width:70%; } .dm-dg-skel i:nth-child(3) { width:45%; }
|
|
2299
|
+
@keyframes dm-dg-pulse { 0%,100% { opacity:.45 } 50% { opacity:.9 } }
|
|
2300
|
+
.dm-dg-detail { display:flex; flex-direction:column; gap:12px; padding:12px; }
|
|
2301
|
+
.dm-dg-sec { border:1px solid rgba(128,128,128,.22); border-radius:10px; overflow:hidden; }
|
|
2302
|
+
.dm-dg-sec-head { display:flex; align-items:center; gap:8px; padding:7px 11px; border-bottom:1px solid rgba(128,128,128,.18); background:rgba(128,128,128,.05); font-weight:650; font-size:11.5px; }
|
|
2303
|
+
.dm-dg-sec-body { padding:10px 11px; display:flex; flex-direction:column; gap:7px; }
|
|
2304
|
+
.dm-dg-kv { display:grid; grid-template-columns:92px minmax(0,1fr); gap:6px 10px; align-items:baseline; font-size:11.5px; }
|
|
2305
|
+
.dm-dg-k { opacity:.55; }
|
|
2306
|
+
.dm-dg-v { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; display:flex; gap:6px; align-items:center; }
|
|
2307
|
+
.dm-dg-copy { font-size:10px; padding:0 6px; border-radius:6px; border:1px solid rgba(128,128,128,.35); background:transparent; color:inherit; cursor:pointer; opacity:.6; flex:none; }
|
|
2308
|
+
.dm-dg-copy:hover { opacity:1; }
|
|
2309
|
+
.dm-dg-pre { white-space:pre-wrap; word-break:break-word; font-size:11px; line-height:1.55; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; max-height:220px; overflow:auto; border:1px dashed rgba(128,128,128,.25); border-radius:8px; padding:8px; background:rgba(128,128,128,.04); }
|
|
2310
|
+
.dm-dg-msgs { display:flex; flex-direction:column; gap:8px; max-height:380px; overflow:auto; padding-right:2px; }
|
|
2311
|
+
.dm-dg-msg { border:1px solid rgba(128,128,128,.18); border-radius:9px; padding:8px 10px; background:rgba(128,128,128,.03); }
|
|
2312
|
+
.dm-dg-msg.user { border-left:3px solid rgba(63,185,80,.6); }
|
|
2313
|
+
.dm-dg-msg.assistant { border-left:3px solid rgba(88,166,255,.6); }
|
|
2314
|
+
.dm-dg-msg-head { display:flex; gap:8px; align-items:baseline; margin-bottom:5px; font-size:10px; opacity:.6; text-transform:uppercase; letter-spacing:.4px; }
|
|
2315
|
+
.dm-dg-msg-text { white-space:pre-wrap; word-break:break-word; font-size:11.5px; line-height:1.55; }
|
|
2316
|
+
.dm-dg-loading { display:inline-flex; gap:6px; align-items:center; font-size:11.5px; opacity:.7; }
|
|
2317
|
+
.dm-dg-spin { width:11px; height:11px; border:2px solid rgba(128,128,128,.35); border-top-color:currentColor; border-radius:50%; animation:dm-dg-rot .8s linear infinite; }
|
|
2318
|
+
@keyframes dm-dg-rot { to { transform:rotate(360deg) } }
|
|
2319
|
+
@media (prefers-color-scheme: dark) {
|
|
2320
|
+
.dm-dg-pill.readonly { color:#79b8ff; }
|
|
2321
|
+
.dm-dg-btn.primary { color:#79b8ff; }
|
|
2322
|
+
.dm-dg-notice.ok { color:#3fb950; } .dm-dg-notice.bad { color:#ff7b72; }
|
|
2323
|
+
.dm-dg-chip.ok { color:#3fb950; } .dm-dg-to { color:#79b8ff; } .dm-dg-chip.run { color:#79b8ff; } .dm-dg-chip.warn { color:#d29922; } .dm-dg-chip.bad { color:#ff7b72; }
|
|
2324
|
+
}
|
|
2325
|
+
`
|
|
2326
|
+
|
|
2327
|
+
// 仿 DSH StateDot 的 ongoing 转圈环(1.5s 转 + 弧线呼吸)
|
|
2328
|
+
function LoadingSpinner(props) {
|
|
2329
|
+
var size = (props && props.size) || 14
|
|
2330
|
+
return h('svg', {
|
|
2331
|
+
className: 'dm-dg-spinner',
|
|
2332
|
+
width: size,
|
|
2333
|
+
height: size,
|
|
2334
|
+
viewBox: '0 0 24 24',
|
|
2335
|
+
'aria-hidden': 'true',
|
|
2336
|
+
'data-state': 'ongoing'
|
|
2337
|
+
},
|
|
2338
|
+
h('g', { className: 'dm-dg-spinner-motion' },
|
|
2339
|
+
h('circle', { className: 'dm-dg-spinner-track', cx: 12, cy: 12, r: 9.5 }),
|
|
2340
|
+
h('circle', { className: 'dm-dg-spinner-arc', cx: 12, cy: 12, r: 9.5 })
|
|
2341
|
+
)
|
|
2342
|
+
)
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function dgFetch(path) {
|
|
2346
|
+
return fetch(path, { headers: { accept: 'application/json' } })
|
|
2347
|
+
.then(function (res) {
|
|
2348
|
+
return res.text().then(function (text) {
|
|
2349
|
+
var type = res.headers.get('content-type') || ''
|
|
2350
|
+
if (res.status === 404 || !/json/i.test(type)) {
|
|
2351
|
+
var routeError = new Error('route unavailable (HTTP ' + res.status + ')')
|
|
2352
|
+
routeError.code = 'E_NO_ROUTE'
|
|
2353
|
+
routeError.body = String(text || '').slice(0, 80)
|
|
2354
|
+
throw routeError
|
|
2355
|
+
}
|
|
2356
|
+
try {
|
|
2357
|
+
return JSON.parse(text)
|
|
2358
|
+
} catch (error) {
|
|
2359
|
+
var parseError = new Error('服务端返回的不是 JSON(可能需要重启该 DSH 实例)')
|
|
2360
|
+
parseError.code = 'E_PARSE'
|
|
2361
|
+
parseError.body = String(text || '').slice(0, 80)
|
|
2362
|
+
throw parseError
|
|
2363
|
+
}
|
|
2364
|
+
})
|
|
2365
|
+
})
|
|
2366
|
+
}
|
|
2367
|
+
function dgFriendly(err) {
|
|
2368
|
+
if (err && (err.code === 'E_NO_ROUTE' || err.code === 'E_PARSE')) return '当前 DSH 实例的服务端还是旧版本(缺接口),重启该实例后可用'
|
|
2369
|
+
return String((err && err.message) || err)
|
|
2370
|
+
}
|
|
2371
|
+
function dgDur(sec) {
|
|
2372
|
+
if (sec == null) return '—'
|
|
2373
|
+
if (sec < 60) return sec + 's'
|
|
2374
|
+
if (sec < 3600) return Math.floor(sec / 60) + 'm' + (sec % 60) + 's'
|
|
2375
|
+
return Math.floor(sec / 3600) + 'h' + Math.floor((sec % 3600) / 60) + 'm'
|
|
2376
|
+
}
|
|
2377
|
+
function dgClock(iso) {
|
|
2378
|
+
if (!iso) return '—'
|
|
2379
|
+
var d = new Date(iso)
|
|
2380
|
+
if (isNaN(d.getTime())) return '—'
|
|
2381
|
+
function p2(n) { return (n < 10 ? '0' : '') + n }
|
|
2382
|
+
return p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes())
|
|
2383
|
+
}
|
|
2384
|
+
function dgTone(state) { return DG_STATE_TONE[state] || 'muted' }
|
|
2385
|
+
// 状态/策略展示统一中文(内部值保持英文,避免影响筛选与接口)
|
|
2386
|
+
var DG_STATE_LABELS = {
|
|
2387
|
+
done: '已完成', working: '执行中', submitted: '已提交', accepted: '已受理',
|
|
2388
|
+
question: '待回复', stalled: '疑似卡住', failed: '失败', cancelled: '已取消',
|
|
2389
|
+
timeout: '超时', running: '运行中', provisioning: '准备中', inactive: '空闲', unknown: '未知'
|
|
2390
|
+
}
|
|
2391
|
+
var DG_POLICY_LABELS = { new: '新会话', continue: '续会话', auto: '自动', session: '指定会话' }
|
|
2392
|
+
function dgStateLabel(state) { return DG_STATE_LABELS[String(state || '')] || String(state || '未知') }
|
|
2393
|
+
function dgPolicyLabel(policy) { return DG_POLICY_LABELS[String(policy || '')] || String(policy || '—') }
|
|
2394
|
+
function dgChip(state) { return h('span', { className: 'dm-dg-chip ' + dgTone(state) }, dgStateLabel(state)) }
|
|
2395
|
+
|
|
2396
|
+
// 必须是 React 组件(返回元素),不能返回 DOM 节点
|
|
2397
|
+
function meshPanelIcon(props) {
|
|
2398
|
+
var size = (props && props.size) || 16
|
|
2399
|
+
return h('svg', {
|
|
2400
|
+
'data-dsh-panel-entry': 'mesh-delegations', viewBox: '0 0 16 16', width: size, height: size,
|
|
2401
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: '1.4', strokeLinecap: 'round', 'aria-hidden': 'true',
|
|
2402
|
+
style: { color: 'var(--dsh-brand-color, currentColor)' }
|
|
2403
|
+
},
|
|
2404
|
+
h('path', { d: 'M8 3.2 L3.2 8 M8 3.2 L12.8 8 M3.2 8 L12.8 8', opacity: '.7' }),
|
|
2405
|
+
h('circle', { cx: 8, cy: 3.2, r: 1.6, fill: 'currentColor', stroke: 'none' }),
|
|
2406
|
+
h('circle', { cx: 3.2, cy: 8, r: 1.6, fill: 'currentColor', stroke: 'none' }),
|
|
2407
|
+
h('circle', { cx: 12.8, cy: 8, r: 1.6, fill: 'currentColor', stroke: 'none' })
|
|
2408
|
+
)
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
/** 左侧栏「AI 网络」专用图标:中心节点 + 四向设备,和「委托」区分开。 */
|
|
2412
|
+
function networkPanelIcon(props) {
|
|
2413
|
+
var size = (props && props.size) || 16
|
|
2414
|
+
return h('svg', {
|
|
2415
|
+
'data-dsh-panel-entry': 'mesh-network', viewBox: '0 0 16 16', width: size, height: size,
|
|
2416
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: '1.3', strokeLinecap: 'round', 'aria-hidden': 'true',
|
|
2417
|
+
style: { color: 'var(--dsh-brand-color, currentColor)' }
|
|
2418
|
+
},
|
|
2419
|
+
h('path', { d: 'M8 8 L8 2.6 M8 8 L13.4 8 M8 8 L8 13.4 M8 8 L2.6 8', opacity: '.55' }),
|
|
2420
|
+
h('circle', { cx: 8, cy: 8, r: 2.1, fill: 'none', strokeWidth: '1.4' }),
|
|
2421
|
+
h('circle', { cx: 8, cy: 2.6, r: 1.25, fill: 'currentColor', stroke: 'none' }),
|
|
2422
|
+
h('circle', { cx: 13.4, cy: 8, r: 1.25, fill: 'currentColor', stroke: 'none' }),
|
|
2423
|
+
h('circle', { cx: 8, cy: 13.4, r: 1.25, fill: 'currentColor', stroke: 'none' }),
|
|
2424
|
+
h('circle', { cx: 2.6, cy: 8, r: 1.25, fill: 'currentColor', stroke: 'none' })
|
|
2425
|
+
)
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
function MeshDelegationPanel() {
|
|
2429
|
+
var s0 = useState(null); var list = s0[0]; var setList = s0[1]
|
|
2430
|
+
var s1 = useState(true); var loading = s1[0]; var setLoading = s1[1]
|
|
2431
|
+
var s2 = useState(null); var notice = s2[0]; var setNotice = s2[1]
|
|
2432
|
+
var s3 = useState(null); var selectedId = s3[0]; var setSelectedId = s3[1]
|
|
2433
|
+
var s4 = useState(null); var detail = s4[0]; var setDetail = s4[1]
|
|
2434
|
+
var s5 = useState(false); var taskLoading = s5[0]; var setTaskLoading = s5[1]
|
|
2435
|
+
var s6 = useState(null); var session = s6[0]; var setSession = s6[1]
|
|
2436
|
+
var s7 = useState(false); var sessionLoading = s7[0]; var setSessionLoading = s7[1]
|
|
2437
|
+
var s8 = useState(''); var copied = s8[0]; var setCopied = s8[1]
|
|
2438
|
+
var s10 = useState(null); var artifacts = s10[0]; var setArtifacts = s10[1]
|
|
2439
|
+
var s11 = useState(''); var localNode = s11[0]; var setLocalNode = s11[1]
|
|
2440
|
+
var s12 = useState(''); var lastListAt = s12[0]; var setLastListAt = s12[1]
|
|
2441
|
+
var s13 = useState(''); var lastTaskAt = s13[0]; var setLastTaskAt = s13[1]
|
|
2442
|
+
var s14 = useState(''); var filterFrom = s14[0]; var setFilterFrom = s14[1]
|
|
2443
|
+
var s15 = useState(''); var filterTo = s15[0]; var setFilterTo = s15[1]
|
|
2444
|
+
var s16 = useState(''); var filterFromNode = s16[0]; var setFilterFromNode = s16[1]
|
|
2445
|
+
var s17 = useState(''); var filterToNode = s17[0]; var setFilterToNode = s17[1]
|
|
2446
|
+
var s19 = useState(null); var rich = s19[0]; var setRich = s19[1]
|
|
2447
|
+
var s18 = useState(function () {
|
|
2448
|
+
try {
|
|
2449
|
+
var saved = window.localStorage.getItem('dsh-mesh-list-width')
|
|
2450
|
+
var value = saved ? Number(saved) : NaN
|
|
2451
|
+
if (Number.isFinite(value)) return Math.max(260, Math.min(640, value))
|
|
2452
|
+
} catch (error) { /* ignore */ }
|
|
2453
|
+
return 360
|
|
2454
|
+
})
|
|
2455
|
+
var listWidth = s18[0]; var setListWidth = s18[1]
|
|
2456
|
+
function startResize(event) {
|
|
2457
|
+
event.preventDefault()
|
|
2458
|
+
var startX = event.clientX
|
|
2459
|
+
var startW = listWidth
|
|
2460
|
+
var currentW = startW
|
|
2461
|
+
function onMove(moveEvent) {
|
|
2462
|
+
currentW = Math.max(260, Math.min(640, startW + (moveEvent.clientX - startX)))
|
|
2463
|
+
setListWidth(currentW)
|
|
2464
|
+
}
|
|
2465
|
+
function onUp() {
|
|
2466
|
+
window.removeEventListener('pointermove', onMove)
|
|
2467
|
+
window.removeEventListener('pointerup', onUp)
|
|
2468
|
+
document.body.style.cursor = ''
|
|
2469
|
+
document.body.style.userSelect = ''
|
|
2470
|
+
try { window.localStorage.setItem('dsh-mesh-list-width', String(currentW)) } catch (error) { /* ignore */ }
|
|
2471
|
+
}
|
|
2472
|
+
window.addEventListener('pointermove', onMove)
|
|
2473
|
+
window.addEventListener('pointerup', onUp)
|
|
2474
|
+
document.body.style.cursor = 'col-resize'
|
|
2475
|
+
document.body.style.userSelect = 'none'
|
|
2476
|
+
}
|
|
2477
|
+
function resetResize() {
|
|
2478
|
+
setListWidth(360)
|
|
2479
|
+
try { window.localStorage.setItem('dsh-mesh-list-width', '360') } catch (error) { /* ignore */ }
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
// 只保留「错误」提示(持久显示,下一次成功操作/切换任务时清除);成功不再弹自动消失的提示
|
|
2483
|
+
function flash(type, text) {
|
|
2484
|
+
if (type === 'bad') setNotice({ type: 'bad', text: text })
|
|
2485
|
+
else setNotice(null)
|
|
2486
|
+
}
|
|
2487
|
+
function clearNotice() { setNotice(null) }
|
|
2488
|
+
|
|
2489
|
+
function loadList(silent) {
|
|
2490
|
+
if (!silent) setLoading(true)
|
|
2491
|
+
dgFetch(DG_API_LIST + '?limit=100')
|
|
2492
|
+
.then(function (json) {
|
|
2493
|
+
if (!json || !json.ok) throw new Error((json && json.error) || 'load failed')
|
|
2494
|
+
setList(Object.assign({}, json, { degraded: false }))
|
|
2495
|
+
if (json.localNode) setLocalNode(json.localNode)
|
|
2496
|
+
setLastListAt(new Date().toISOString())
|
|
2497
|
+
})
|
|
2498
|
+
.catch(function (err) {
|
|
2499
|
+
// 2026-11-07 兼容:旧实例没有 /delegations 路由 → 用 /status 里的 delegations.recent 降级
|
|
2500
|
+
if (err && (err.code === 'E_NO_ROUTE' || err.code === 'E_PARSE')) {
|
|
2501
|
+
return dgFetch('/api/dsh-mesh/status')
|
|
2502
|
+
.then(function (st) {
|
|
2503
|
+
var recent = (st && st.delegations && st.delegations.recent) || []
|
|
2504
|
+
var tasks = recent.map(function (item) {
|
|
2505
|
+
return {
|
|
2506
|
+
taskId: item.taskId,
|
|
2507
|
+
conversationId: item.conversationId || '',
|
|
2508
|
+
sessionId: item.sessionId || '',
|
|
2509
|
+
from: '',
|
|
2510
|
+
fromLabel: '',
|
|
2511
|
+
node: item.node,
|
|
2512
|
+
nodeLabel: nodeLabel(item.node),
|
|
2513
|
+
instance: '',
|
|
2514
|
+
state: item.state,
|
|
2515
|
+
policy: item.policy || '',
|
|
2516
|
+
goal: item.goal || '',
|
|
2517
|
+
createdAt: item.createdAt || '',
|
|
2518
|
+
updatedAt: item.updatedAt || '',
|
|
2519
|
+
durationSec: item.durationSec == null ? null : (item.durationSec > 43200 ? null : item.durationSec)
|
|
2520
|
+
}
|
|
2521
|
+
})
|
|
2522
|
+
if (st && st.nodeName) setLocalNode(st.nodeName)
|
|
2523
|
+
setList({
|
|
2524
|
+
total: (st && st.delegations && st.delegations.total) || tasks.length,
|
|
2525
|
+
byState: (st && st.delegations && st.delegations.byState) || {},
|
|
2526
|
+
tasks: tasks,
|
|
2527
|
+
degraded: true
|
|
2528
|
+
})
|
|
2529
|
+
flash('bad', '当前实例服务端未重启:已降级显示最近 ' + tasks.length + ' 条(重启后显示完整列表/详情)')
|
|
2530
|
+
})
|
|
2531
|
+
.catch(function () { throw err })
|
|
2532
|
+
}
|
|
2533
|
+
throw err
|
|
2534
|
+
})
|
|
2535
|
+
.catch(function (err) { flash('bad', '列表加载失败:' + dgFriendly(err)) })
|
|
2536
|
+
.finally(function () { setLoading(false) })
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
useEffect(function () {
|
|
2540
|
+
loadList(true)
|
|
2541
|
+
var timer = setInterval(function () { loadList(true) }, 20000)
|
|
2542
|
+
return function () { clearInterval(timer) }
|
|
2543
|
+
}, [])
|
|
2544
|
+
|
|
2545
|
+
var tasks = (list && list.tasks) || []
|
|
2546
|
+
var selected = null
|
|
2547
|
+
for (var i = 0; i < tasks.length; i++) { if (tasks[i].taskId === selectedId) { selected = tasks[i]; break } }
|
|
2548
|
+
|
|
2549
|
+
function selectTask(task) {
|
|
2550
|
+
setSelectedId(task.taskId)
|
|
2551
|
+
clearNotice()
|
|
2552
|
+
setDetail(null)
|
|
2553
|
+
setArtifacts(null)
|
|
2554
|
+
setRich(null)
|
|
2555
|
+
setSession(null)
|
|
2556
|
+
setTaskLoading(true)
|
|
2557
|
+
dgFetch(DG_API_TASK + '?taskId=' + encodeURIComponent(task.taskId))
|
|
2558
|
+
.then(function (json) {
|
|
2559
|
+
if (!json || !json.ok) throw new Error((json && json.error) || 'task status failed')
|
|
2560
|
+
setDetail(json.status || null)
|
|
2561
|
+
setArtifacts(json.artifacts || null)
|
|
2562
|
+
setRich(json.detail || null)
|
|
2563
|
+
setLastTaskAt(new Date().toISOString())
|
|
2564
|
+
clearNotice()
|
|
2565
|
+
})
|
|
2566
|
+
.catch(function (err) { flash('bad', '查询任务状态失败:' + dgFriendly(err)) })
|
|
2567
|
+
.finally(function () { setTaskLoading(false) })
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
function loadSession() {
|
|
2571
|
+
if (!selected || !selected.sessionId) return
|
|
2572
|
+
// 2026-11-10:本机任务(local)没有对端会话,不要发 peer call
|
|
2573
|
+
if (/^(local|self|localhost)$/i.test(String(selected.node || ''))) {
|
|
2574
|
+
flash('bad', '本机任务(local)没有对端会话;请在 DSH 侧栏查看本机会话')
|
|
2575
|
+
return
|
|
2576
|
+
}
|
|
2577
|
+
setSessionLoading(true)
|
|
2578
|
+
dgFetch(DG_API_SESSION + '?node=' + encodeURIComponent(selected.node) + '&sessionId=' + encodeURIComponent(selected.sessionId) + '&limit=60')
|
|
2579
|
+
.then(function (json) {
|
|
2580
|
+
if (!json || !json.ok) throw new Error((json && json.error) || 'session history failed')
|
|
2581
|
+
setSession(json.payload || null)
|
|
2582
|
+
clearNotice()
|
|
2583
|
+
})
|
|
2584
|
+
.catch(function (err) { flash('bad', '加载对端会话失败:' + dgFriendly(err)) })
|
|
2585
|
+
.finally(function () { setSessionLoading(false) })
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
function copyText(key, text) {
|
|
2589
|
+
try {
|
|
2590
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { setCopied(key) }, function () { flash('bad', '复制失败') })
|
|
2591
|
+
else { setCopied(key) }
|
|
2592
|
+
} catch (_) { flash('bad', '复制失败') }
|
|
2593
|
+
setTimeout(function () { setCopied('') }, 1500)
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
var byState = (list && list.byState) || {}
|
|
2597
|
+
// 2026-11-07:节点筛选(委托方/接收方)+ 实例筛选(源实例仅新委托记录;旧数据为空 → 未记录)
|
|
2598
|
+
var fromNodeOptions = []
|
|
2599
|
+
var toNodeOptions = []
|
|
2600
|
+
var fromOptions = []
|
|
2601
|
+
var toOptions = []
|
|
2602
|
+
for (var ti = 0; ti < tasks.length; ti++) {
|
|
2603
|
+
var task0 = tasks[ti]
|
|
2604
|
+
if (fromNodeOptions.indexOf(task0.from || '') < 0) fromNodeOptions.push(task0.from || '')
|
|
2605
|
+
if (toNodeOptions.indexOf(task0.node || '') < 0) toNodeOptions.push(task0.node || '')
|
|
2606
|
+
var fi = task0.fromInstance || ''
|
|
2607
|
+
if (fromOptions.indexOf(fi) < 0) fromOptions.push(fi)
|
|
2608
|
+
var ii = task0.instance || ''
|
|
2609
|
+
if (toOptions.indexOf(ii) < 0) toOptions.push(ii)
|
|
2610
|
+
}
|
|
2611
|
+
fromNodeOptions.sort()
|
|
2612
|
+
toNodeOptions.sort()
|
|
2613
|
+
fromOptions.sort()
|
|
2614
|
+
toOptions.sort()
|
|
2615
|
+
function nodeOptionLabel(value, isFrom) {
|
|
2616
|
+
if (!value) return '未知节点'
|
|
2617
|
+
for (var k = 0; k < tasks.length; k++) {
|
|
2618
|
+
var item = tasks[k]
|
|
2619
|
+
if (isFrom && (item.from || '') === value) return item.fromLabel || value
|
|
2620
|
+
if (!isFrom && (item.node || '') === value) return (item.nodeLabel || value) + (item.fromIsLocal !== undefined ? '' : '')
|
|
2621
|
+
}
|
|
2622
|
+
return value
|
|
2623
|
+
}
|
|
2624
|
+
var visibleTasks = tasks.filter(function (task) {
|
|
2625
|
+
if (filterFromNode && (task.from || '') !== filterFromNode) return false
|
|
2626
|
+
if (filterToNode && (task.node || '') !== filterToNode) return false
|
|
2627
|
+
if (filterFrom && (task.fromInstance || '') !== filterFrom) return false
|
|
2628
|
+
if (filterTo && (task.instance || '') !== filterTo) return false
|
|
2629
|
+
return true
|
|
2630
|
+
})
|
|
2631
|
+
function resetFilters() { setFilterFromNode(''); setFilterToNode(''); setFilterFrom(''); setFilterTo('') }
|
|
2632
|
+
// 自绘下拉:不用原生 select(暗色主题下原生弹层会保持白底)
|
|
2633
|
+
function FilterSelect(props) {
|
|
2634
|
+
var fs = useState(false)
|
|
2635
|
+
var open = fs[0]
|
|
2636
|
+
var setOpen = fs[1]
|
|
2637
|
+
useEffect(function () {
|
|
2638
|
+
if (!open) return
|
|
2639
|
+
function close() { setOpen(false) }
|
|
2640
|
+
document.addEventListener('mousedown', close)
|
|
2641
|
+
return function () { document.removeEventListener('mousedown', close) }
|
|
2642
|
+
}, [open])
|
|
2643
|
+
return h('div', { className: 'dm-dg-fs' },
|
|
2644
|
+
h('span', { className: 'dm-dg-fs-label' }, props.label),
|
|
2645
|
+
h('button', {
|
|
2646
|
+
type: 'button',
|
|
2647
|
+
className: 'dm-dg-fs-btn',
|
|
2648
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
2649
|
+
onClick: function (event) { event.stopPropagation(); setOpen(!open) }
|
|
2650
|
+
},
|
|
2651
|
+
h('span', { className: 'dm-dg-fs-val' }, props.renderLabel(props.value)),
|
|
2652
|
+
h('span', { className: 'dm-dg-fs-caret' }, '▾')
|
|
2653
|
+
),
|
|
2654
|
+
open ? h('div', { className: 'dm-dg-fs-menu', onMouseDown: function (event) { event.stopPropagation() } },
|
|
2655
|
+
props.options.map(function (opt) {
|
|
2656
|
+
return h('button', {
|
|
2657
|
+
key: opt,
|
|
2658
|
+
type: 'button',
|
|
2659
|
+
className: 'dm-dg-fs-item' + (opt === props.value ? ' active' : ''),
|
|
2660
|
+
onClick: function (event) { event.stopPropagation(); props.onChange(opt); setOpen(false) }
|
|
2661
|
+
}, props.renderLabel(opt))
|
|
2662
|
+
})
|
|
2663
|
+
) : null
|
|
2664
|
+
)
|
|
2665
|
+
}
|
|
2666
|
+
var stats = ['done', 'working', 'submitted', 'question', 'stalled', 'failed'].filter(function (key) { return byState[key] })
|
|
2667
|
+
var selectedTone = selected ? dgTone(selected.state) : 'muted'
|
|
2668
|
+
var sessionMessages = (session && session.messages) || []
|
|
2669
|
+
var st = detail || {}
|
|
2670
|
+
|
|
2671
|
+
function copyBtn(key, text) {
|
|
2672
|
+
if (!text) return null
|
|
2673
|
+
return h('button', { type: 'button', className: 'dm-dg-copy', title: '复制 ' + key, onClick: function (e) { e.stopPropagation(); copyText(key, text) } }, copied === key ? '已复制' : '复制')
|
|
2674
|
+
}
|
|
2675
|
+
function kv(label, value, copyKey) {
|
|
2676
|
+
return h('div', { className: 'dm-dg-kv' },
|
|
2677
|
+
h('span', { className: 'dm-dg-k' }, label),
|
|
2678
|
+
h('span', { className: 'dm-dg-v', title: String(value == null ? '' : value) }, String(value == null || value === '' ? '—' : value), copyKey ? copyBtn(copyKey, String(value || '')) : null)
|
|
2679
|
+
)
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
return h('div', { className: 'dm-dg-root' },
|
|
2683
|
+
h('style', null, DG_CSS),
|
|
2684
|
+
h('div', { className: 'dm-dg-head' },
|
|
2685
|
+
h('span', { className: 'dm-dg-title' }, '委托'),
|
|
2686
|
+
h('span', { className: 'dm-dg-pill readonly' }, '只读'),
|
|
2687
|
+
h('div', { className: 'dm-dg-stats' },
|
|
2688
|
+
h('span', { className: 'dm-dg-stat' }, '共 ', h('b', null, String((list && list.total) || tasks.length || 0)), ' 条'),
|
|
2689
|
+
stats.map(function (key) { return h('span', { key: key, className: 'dm-dg-stat' }, h('b', null, String(byState[key])), ' ', dgStateLabel(key)) })
|
|
2690
|
+
),
|
|
2691
|
+
h('span', { className: 'dm-dg-spacer' }),
|
|
2692
|
+
h('span', { className: 'dm-dg-sub' }, '20s 自动刷新 · 数据来自本地台账' + (lastListAt ? ' · 上次 ' + dgClock(lastListAt) : '')),
|
|
2693
|
+
h('button', { type: 'button', className: 'dm-dg-btn', onClick: function () { loadList(false) }, disabled: loading },
|
|
2694
|
+
loading ? h('span', { className: 'dm-dg-loading' }, h('span', { className: 'dm-dg-spin' }), '刷新中') : '刷新')
|
|
2695
|
+
),
|
|
2696
|
+
notice ? h('div', { className: 'dm-dg-notice ' + notice.type }, notice.text) : null,
|
|
2697
|
+
selected && selected.staleActive ? h('div', { className: 'dm-dg-notice warn' }, '该任务已超过 ' + Math.round(selected.staleAfterSec / 60) + ' 分钟没有更新,可能已失联;可点「刷新状态」再查对端,或重新发起委托。') : null,
|
|
2698
|
+
h('div', { className: 'dm-dg-body', style: { '--dm-dg-list-w': listWidth + 'px' } },
|
|
2699
|
+
h('section', { className: 'dm-dg-pane' },
|
|
2700
|
+
h('div', { className: 'dm-dg-pane-head' }, '委托列表', h('span', { className: 'dm-dg-sub' }, '(' + visibleTasks.length + (visibleTasks.length !== tasks.length ? '/' + tasks.length : '') + ')')),
|
|
2701
|
+
h('div', { className: 'dm-dg-filters' },
|
|
2702
|
+
h(FilterSelect, { label: '委托方', value: filterFromNode, options: [''].concat(fromNodeOptions), onChange: setFilterFromNode, renderLabel: function (opt) { return opt === '' ? '全部节点' : nodeOptionLabel(opt, true) } }),
|
|
2703
|
+
h(FilterSelect, { label: '接收方', value: filterToNode, options: [''].concat(toNodeOptions), onChange: setFilterToNode, renderLabel: function (opt) { return opt === '' ? '全部节点' : nodeOptionLabel(opt, false) } }),
|
|
2704
|
+
h(FilterSelect, { label: '源实例', value: filterFrom, options: [''].concat(fromOptions), onChange: setFilterFrom, renderLabel: function (opt) { return opt === '' ? '全部' : (opt || '未记录') } }),
|
|
2705
|
+
h(FilterSelect, { label: '目标实例', value: filterTo, options: [''].concat(toOptions), onChange: setFilterTo, renderLabel: function (opt) { return opt === '' ? '全部' : (opt || '未记录') } }),
|
|
2706
|
+
h('button', { type: 'button', className: 'dm-dg-fbtn', onClick: resetFilters }, '重置筛选'),
|
|
2707
|
+
h('span', { className: 'dm-dg-fhint' }, '源实例仅对 2026-11-07 之后新发的委托有效,历史记录显示未记录')
|
|
2708
|
+
),
|
|
2709
|
+
h('div', { className: 'dm-dg-scroll' },
|
|
2710
|
+
loading && tasks.length === 0 ? h('div', { className: 'dm-dg-skel' }, h('i'), h('i'), h('i'), h('i')) : null,
|
|
2711
|
+
!loading && tasks.length === 0 ? h('div', { className: 'dm-dg-empty' }, '暂无委托记录', h('br'), '有委托后这里会自动出现') : null,
|
|
2712
|
+
!loading && tasks.length > 0 && visibleTasks.length === 0 ? h('div', { className: 'dm-dg-empty' }, '当前筛选无匹配委托') : null,
|
|
2713
|
+
visibleTasks.map(function (task) {
|
|
2714
|
+
var tone = dgTone(task.state)
|
|
2715
|
+
return h('div', {
|
|
2716
|
+
key: task.taskId,
|
|
2717
|
+
className: 'dm-dg-row ' + tone + (task.taskId === selectedId ? ' active' : ''),
|
|
2718
|
+
onClick: function () { if (task.taskId !== selectedId) selectTask(task) },
|
|
2719
|
+
title: task.staleActive ? '超过 ' + Math.round(task.staleAfterSec / 60) + ' 分钟无更新(疑似失联,可点刷新状态)' : (task.goal || '')
|
|
2720
|
+
},
|
|
2721
|
+
h('div', { className: 'dm-dg-row-top' },
|
|
2722
|
+
task.activeFresh ? h(LoadingSpinner, { size: 12 }) : null,
|
|
2723
|
+
h('span', { className: 'dm-dg-route' },
|
|
2724
|
+
h('span', { className: 'dm-dg-from' }, task.fromLabel || task.from || '?'),
|
|
2725
|
+
(task.fromIsLocal || (localNode && task.from === localNode)) ? h('span', { className: 'dm-dg-me' }, task.fromInstance ? '本机·' + task.fromInstance : '本机') : null,
|
|
2726
|
+
h('span', { className: 'dm-dg-arrow' }, '→'),
|
|
2727
|
+
h('span', { className: 'dm-dg-to' }, (task.nodeLabel || task.node || '?') + (task.instance ? ' · ' + task.instance : ''))
|
|
2728
|
+
),
|
|
2729
|
+
dgChip(task.displayState || task.state),
|
|
2730
|
+
h('span', { className: 'dm-dg-row-time' }, (task.isActive ? '更新 ' : '完成 ') + dgClock(task.displayAt || task.updatedAt))
|
|
2731
|
+
),
|
|
2732
|
+
h('div', { className: 'dm-dg-goal' }, task.goal || '(无目标)'),
|
|
2733
|
+
h('div', { className: 'dm-dg-meta' },
|
|
2734
|
+
task.instance ? h('span', { className: 'dm-dg-badge' }, task.instance) : null,
|
|
2735
|
+
h('span', null, '历时 ' + dgDur(task.durationSec)),
|
|
2736
|
+
h('span', null, task.taskId),
|
|
2737
|
+
task.policy ? h('span', null, dgPolicyLabel(task.policy)) : null
|
|
2738
|
+
)
|
|
2739
|
+
)
|
|
2740
|
+
})
|
|
2741
|
+
)
|
|
2742
|
+
),
|
|
2743
|
+
h('div', { className: 'dm-dg-divider', title: '拖动调整列表宽度,双击复位', onPointerDown: startResize, onDoubleClick: resetResize }),
|
|
2744
|
+
h('section', { className: 'dm-dg-pane' },
|
|
2745
|
+
h('div', { className: 'dm-dg-pane-head' },
|
|
2746
|
+
'任务详情',
|
|
2747
|
+
h('span', { className: 'dm-dg-sub' }, selected ? selected.taskId + (lastTaskAt ? ' · 状态更新于 ' + dgClock(lastTaskAt) : '') : '未选择'),
|
|
2748
|
+
h('span', { className: 'dm-dg-spacer' }),
|
|
2749
|
+
selected ? h('button', { type: 'button', className: 'dm-dg-btn', onClick: function () { selectTask(selected) }, disabled: taskLoading },
|
|
2750
|
+
taskLoading ? h('span', { className: 'dm-dg-loading' }, h('span', { className: 'dm-dg-spin' }), '查询中') : '刷新状态') : null
|
|
2751
|
+
),
|
|
2752
|
+
h('div', { className: 'dm-dg-scroll' },
|
|
2753
|
+
selected === null
|
|
2754
|
+
? h('div', { className: 'dm-dg-empty' }, '在左侧选择一条委托', h('br'), '可查看任务详情与对端会话(只读)')
|
|
2755
|
+
: h('div', { className: 'dm-dg-detail' },
|
|
2756
|
+
h('div', { className: 'dm-dg-sec' },
|
|
2757
|
+
h('div', { className: 'dm-dg-sec-head' }, '概要'),
|
|
2758
|
+
h('div', { className: 'dm-dg-sec-body' },
|
|
2759
|
+
h('div', { className: 'dm-dg-kv' },
|
|
2760
|
+
h('span', { className: 'dm-dg-k' }, '状态'),
|
|
2761
|
+
h('span', { className: 'dm-dg-v' },
|
|
2762
|
+
selected.activeFresh ? h(LoadingSpinner, { size: 13 }) : null,
|
|
2763
|
+
dgChip(selected.displayState || selected.state)
|
|
2764
|
+
)
|
|
2765
|
+
),
|
|
2766
|
+
kv('委托方', (selected.fromLabel || selected.from || '—') + ((selected.fromIsLocal || (localNode && selected.from === localNode)) ? '(本机)' : '')),
|
|
2767
|
+
kv('接收方', (selected.nodeLabel || selected.node || '—') + (selected.instance ? ' · ' + selected.instance : '')),
|
|
2768
|
+
kv('目标实例', selected.instance || '—'),
|
|
2769
|
+
kv('策略', dgPolicyLabel(selected.policy)),
|
|
2770
|
+
kv('历时' + (artifacts && artifacts.durationSec != null ? '(实测)' : ''), artifacts && artifacts.durationSec != null ? dgDur(artifacts.durationSec) : dgDur(selected.durationSec)),
|
|
2771
|
+
kv('开始时间', artifacts && artifacts.requestedAt ? dgClock(artifacts.requestedAt) : dgClock(selected.createdAt)),
|
|
2772
|
+
kv('结束时间', artifacts && artifacts.finishedAt ? dgClock(artifacts.finishedAt) : '—'),
|
|
2773
|
+
kv('台账更新', dgClock(selected.updatedAt))
|
|
2774
|
+
)
|
|
2775
|
+
),
|
|
2776
|
+
h('div', { className: 'dm-dg-sec' },
|
|
2777
|
+
h('div', { className: 'dm-dg-sec-head' }, '标识'),
|
|
2778
|
+
h('div', { className: 'dm-dg-sec-body' },
|
|
2779
|
+
kv('taskId', selected.taskId, 'taskId'),
|
|
2780
|
+
kv('conversation', selected.conversationId, 'conversationId'),
|
|
2781
|
+
kv('sessionId', selected.sessionId, 'sessionId')
|
|
2782
|
+
)
|
|
2783
|
+
),
|
|
2784
|
+
h('div', { className: 'dm-dg-sec' },
|
|
2785
|
+
h('div', { className: 'dm-dg-sec-head' }, '目标'),
|
|
2786
|
+
h('div', { className: 'dm-dg-sec-body' }, h('div', { className: 'dm-dg-pre' }, selected.goal || '(无目标)'))
|
|
2787
|
+
),
|
|
2788
|
+
rich ? h('div', { className: 'dm-dg-sec' },
|
|
2789
|
+
h('div', { className: 'dm-dg-sec-head' }, '时间线'),
|
|
2790
|
+
h('div', { className: 'dm-dg-sec-body' },
|
|
2791
|
+
kv('委托发出', rich.timeline ? dgClock(rich.timeline.requestedAt) : '—'),
|
|
2792
|
+
kv('对端心跳', rich.timeline ? dgClock(rich.timeline.heartbeatAt) : '—'),
|
|
2793
|
+
kv('结束时间', rich.timeline ? dgClock(rich.timeline.finishedAt) : '—'),
|
|
2794
|
+
kv('响应耗时', rich.timeline && rich.timeline.responseSec != null ? dgDur(rich.timeline.responseSec) : '—'),
|
|
2795
|
+
kv('执行耗时', rich.timeline && rich.timeline.execSec != null ? dgDur(rich.timeline.execSec) : '—'),
|
|
2796
|
+
kv('总耗时', rich.timeline && rich.timeline.totalSec != null ? dgDur(rich.timeline.totalSec) : dgDur(selected.durationSec))
|
|
2797
|
+
)
|
|
2798
|
+
) : null,
|
|
2799
|
+
rich && (rich.operations && (rich.operations.commandsRun.length > 0 || rich.operations.evidence.length > 0 || rich.operations.notes)) ? h('div', { className: 'dm-dg-sec' },
|
|
2800
|
+
h('div', { className: 'dm-dg-sec-head' }, '对端操作',
|
|
2801
|
+
rich.operations.readonly ? h('span', { className: 'dm-dg-chip ok' }, '只读') : null
|
|
2802
|
+
),
|
|
2803
|
+
h('div', { className: 'dm-dg-sec-body' },
|
|
2804
|
+
rich.operations.notes ? h('div', { className: 'dm-dg-pre' }, rich.operations.notes) : null,
|
|
2805
|
+
rich.operations.commandsRun.length > 0 ? h('div', null,
|
|
2806
|
+
h('div', { className: 'dm-dg-sub' }, '执行过的命令(对端回报)'),
|
|
2807
|
+
h('div', { className: 'dm-dg-pre' }, rich.operations.commandsRun.map(function (item) { return '· ' + item }).join('\n'))
|
|
2808
|
+
) : null,
|
|
2809
|
+
rich.operations.evidence.length > 0 ? h('div', null,
|
|
2810
|
+
h('div', { className: 'dm-dg-sub' }, '产物/证据'),
|
|
2811
|
+
h('div', { className: 'dm-dg-pre' }, rich.operations.evidence.map(function (item) { return '· ' + item }).join('\n'))
|
|
2812
|
+
) : null
|
|
2813
|
+
)
|
|
2814
|
+
) : null,
|
|
2815
|
+
rich && (rich.operations && (rich.operations.scope || rich.operations.acceptance || rich.operations.sessionPolicy)) ? h('div', { className: 'dm-dg-sec' },
|
|
2816
|
+
h('div', { className: 'dm-dg-sec-head' }, '权限 / 范围'),
|
|
2817
|
+
h('div', { className: 'dm-dg-sec-body' },
|
|
2818
|
+
kv('会话策略', rich.operations.sessionPolicy ? dgPolicyLabel(rich.operations.sessionPolicy) : '—'),
|
|
2819
|
+
kv('目标实例', rich.operations.targetInstance || '—'),
|
|
2820
|
+
kv('工作区', rich.operations.workspace || '—'),
|
|
2821
|
+
kv('授权范围', rich.operations.scope || '(未声明)'),
|
|
2822
|
+
kv('验收标准', rich.operations.acceptance || '(未声明)')
|
|
2823
|
+
)
|
|
2824
|
+
) : null,
|
|
2825
|
+
h('div', { className: 'dm-dg-sec' },
|
|
2826
|
+
h('div', { className: 'dm-dg-sec-head' }, '对端状态',
|
|
2827
|
+
h('span', { className: 'dm-dg-sub' }, taskLoading ? '查询中…' : detail ? '已取到 status.json' : '未查询')),
|
|
2828
|
+
h('div', { className: 'dm-dg-sec-body' },
|
|
2829
|
+
st.degraded ? h('div', { className: 'dm-dg-notice bad' }, '对端查询失败,已降级显示本地台账:' + String(st.error || '')) : null,
|
|
2830
|
+
kv('状态', st.state ? dgStateLabel(st.state) : '—'),
|
|
2831
|
+
kv('会话状态', st.sessionState ? dgStateLabel(st.sessionState) : '—'),
|
|
2832
|
+
kv('心跳延迟', st.staleSeconds == null ? '—' : String(st.staleSeconds) + ' 秒'),
|
|
2833
|
+
kv('error', st.error || '—'),
|
|
2834
|
+
st.result ? h('div', { className: 'dm-dg-pre' }, JSON.stringify(st.result, null, 2)) : null
|
|
2835
|
+
)
|
|
2836
|
+
),
|
|
2837
|
+
h('div', { className: 'dm-dg-sec' },
|
|
2838
|
+
h('div', { className: 'dm-dg-sec-head' },
|
|
2839
|
+
'对端会话',
|
|
2840
|
+
h('span', { className: 'dm-dg-sub' }, session ? ('轮次 ' + (session.totalMessages || sessionMessages.length) + ' · 帧 ' + (session.frameCount || '—') + ' · ' + Math.round((session.bytes || 0) / 1024) + ' KB') : '只读'),
|
|
2841
|
+
h('span', { className: 'dm-dg-spacer' }),
|
|
2842
|
+
h('button', { type: 'button', className: 'dm-dg-btn primary', onClick: loadSession, disabled: sessionLoading || !selected.sessionId || /^(local|self|localhost)$/i.test(String(selected.node || '')) },
|
|
2843
|
+
sessionLoading ? h('span', { className: 'dm-dg-loading' }, h('span', { className: 'dm-dg-spin' }), '加载中') : (session ? '重新加载' : '加载会话'))
|
|
2844
|
+
),
|
|
2845
|
+
h('div', { className: 'dm-dg-sec-body' },
|
|
2846
|
+
session ? (session.title ? h('div', { className: 'dm-dg-sub' }, '标题:' + session.title) : null) : null,
|
|
2847
|
+
session
|
|
2848
|
+
? h('div', { className: 'dm-dg-msgs' },
|
|
2849
|
+
sessionMessages.length === 0 ? h('div', { className: 'dm-dg-empty' }, '无消息') : null,
|
|
2850
|
+
sessionMessages.map(function (msg, idx) {
|
|
2851
|
+
return h('div', { key: idx, className: 'dm-dg-msg ' + (msg.role || '') },
|
|
2852
|
+
h('div', { className: 'dm-dg-msg-head' }, h('span', null, msg.role || '?'), msg.time ? h('span', null, msg.time) : null),
|
|
2853
|
+
h('div', { className: 'dm-dg-msg-text' }, String(msg.text || ''))
|
|
2854
|
+
)
|
|
2855
|
+
})
|
|
2856
|
+
)
|
|
2857
|
+
: h('div', { className: 'dm-dg-empty' }, selected.sessionId ? '点击右上「加载会话」查看对端会话正文' : '该委托没有 sessionId')
|
|
2858
|
+
)
|
|
2859
|
+
)
|
|
2860
|
+
)
|
|
2861
|
+
)
|
|
2862
|
+
)
|
|
2863
|
+
)
|
|
2864
|
+
)
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
// 2026-11-10:AI 网络现在是左侧栏主页面,不再受设置面板的窄容器约束。
|
|
2868
|
+
// 2026-11-10(二次):内边距从 .dm-page 挪到滚动容器上——滚动条属于滚动容器,只有在滚动容器
|
|
2869
|
+
// 铺满整页时才会像 DSH 对话区那样贴到最右边;内容留白靠滚动容器自己的 padding 提供。
|
|
2870
|
+
var PAGE_CSS = `
|
|
2871
|
+
.dm-page { box-sizing:border-box; width:100%; height:100%; overflow:hidden; padding:0; --dm-pad-x:clamp(18px, 2.6vw, 42px); --dm-pad-y:clamp(16px, 2.2vw, 30px); --dm-scroll-inset:2px; }
|
|
2872
|
+
.dm-content-shell { width:100%; height:100%; position:relative; --dm-content-w:920px; }
|
|
2873
|
+
/* 纵向滚动条:对齐 DSH 对话区 wSkVaW_scrollBody 的观感——滚动容器 margin-right:2px
|
|
2874
|
+
(滚动条离面板边 2px,不贴死),滑块 track 留 2px(滑块上下不顶到边)。
|
|
2875
|
+
滚动条只占右侧会把内容挤得偏左,所以左侧补等宽内边距(--dm-sb-pad)。 */
|
|
2876
|
+
/* border-box 必须加:height:100% + 上下 padding 否则把滚动容器撑出面板高度,
|
|
2877
|
+
底部 padding 和页脚会被 .dm-page 的 overflow:hidden 裁掉(2026-11-10 实测 1051 > 985)。 */
|
|
2878
|
+
.dm-content-scroll { box-sizing:border-box; height:100%; overflow:auto; scrollbar-gutter:stable; margin-right:var(--dm-scroll-inset); padding:var(--dm-pad-y) var(--dm-pad-x) 36px; padding-left:calc(var(--dm-pad-x) + var(--dm-sb-pad, 0px) + var(--dm-scroll-inset)); }
|
|
2879
|
+
.dm-content-scroll::-webkit-scrollbar-track { margin:2px; }
|
|
2880
|
+
.dm-content { width:min(100%, var(--dm-content-w)); max-width:100%; margin:0 auto; box-sizing:border-box; container-type:inline-size; container-name:dm-content; }
|
|
2881
|
+
.dm-content > .dm-root { width:100%; max-width:100%; margin:0 auto; }
|
|
2882
|
+
/* 宽度手柄:位置由 JS 实测的「内容边距 + 页面留白」算出高亮线中心点,写进 --dm-bar-l / --dm-bar-r。
|
|
2883
|
+
高亮线在 10px 手柄内偏内侧 5px(::after 4px + 2px 线宽的一半),所以手柄 = 中心点 ∓ 5px。
|
|
2884
|
+
这样留白窄时(拖到最大)高亮线自动居中在「内容边 ↔ 页面边」中间,留白宽时保持 24px 间隙。 */
|
|
2885
|
+
.dm-width-handle { z-index:1; position:absolute; top:0; bottom:0; cursor:col-resize; width:10px; }
|
|
2886
|
+
.dm-width-handle[data-side="left"] { left:calc(var(--dm-bar-l, -100px) - 5px); }
|
|
2887
|
+
.dm-width-handle[data-side="right"] { right:calc(var(--dm-bar-r, -100px) - 5px); }
|
|
2888
|
+
.dm-width-handle::after { content:''; pointer-events:none; opacity:0; position:absolute; top:0; bottom:0; width:2px; border-radius:2px; background:linear-gradient(to bottom, transparent calc(var(--dm-width-handle-pointer-y, 50%) - 36px), var(--dsw-alias-scrollbar-bg-l1, rgba(128,128,128,.55)) calc(var(--dm-width-handle-pointer-y, 50%) - 8px), var(--dsw-alias-scrollbar-bg-l1, rgba(128,128,128,.55)) calc(var(--dm-width-handle-pointer-y, 50%) + 8px), transparent calc(var(--dm-width-handle-pointer-y, 50%) + 36px)); }
|
|
2889
|
+
.dm-width-handle[data-side="left"]::after { right:4px; }
|
|
2890
|
+
.dm-width-handle[data-side="right"]::after { left:4px; }
|
|
2891
|
+
.dm-width-handle[data-dragging]::after { opacity:1; }
|
|
2892
|
+
@media (hover:hover) { .dm-width-handle:hover::after { opacity:1; } }
|
|
2893
|
+
@media (max-width: 1000px) { .dm-width-handle { display:none; } }
|
|
2894
|
+
.dm-body { display:grid; grid-template-columns:minmax(300px, var(--dm-main-w, 62%)) 8px minmax(0,1fr); gap:10px; align-items:start; }
|
|
2895
|
+
.dm-lan { border:1px solid rgba(128,128,128,.25); border-radius:12px; padding:10px 12px; background:rgba(128,128,128,.035); }
|
|
2896
|
+
.dm-lan-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px; }
|
|
2897
|
+
.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; }
|
|
2898
|
+
.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; }
|
|
2899
|
+
.dm-lan-node:first-child { border-top:none; }
|
|
2900
|
+
.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; }
|
|
2901
|
+
.dm-toggle { display:inline-flex; align-items:center; gap:5px; font-size:11.5px; opacity:.8; cursor:pointer; user-select:none; white-space:nowrap; }
|
|
2902
|
+
.dm-toggle input { margin:0; }
|
|
2903
|
+
.dm-nodes { display:flex; flex-direction:column; gap:4px; }
|
|
2904
|
+
.dm-node-row { display:flex; align-items:center; gap:8px; padding:6px 0; border-top:1px solid rgba(128,128,128,.14); font-size:12px; }
|
|
2905
|
+
.dm-node-row:first-child { border-top:none; }
|
|
2906
|
+
.dm-node-name { font-weight:600; min-width:80px; }
|
|
2907
|
+
.dm-node-url { opacity:.6; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
2908
|
+
.dm-usage { display:flex; flex-direction:column; gap:5px; font-size:12px; }
|
|
2909
|
+
.dm-usage-row { display:flex; align-items:center; gap:7px; }
|
|
2910
|
+
.dm-help-overlay { position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; }
|
|
2911
|
+
.dm-help-card { width:min(760px,100%); max-height:86vh; overflow:auto; background:#161a20; color:#d8dee9; border:1px solid rgba(128,128,128,.3); border-radius:14px; padding:16px 18px; box-shadow:0 24px 80px rgba(0,0,0,.55); }
|
|
2912
|
+
.dm-help-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; }
|
|
2913
|
+
.dm-help-title { font-weight:700; font-size:15px; }
|
|
2914
|
+
.dm-help-section { margin-top:12px; }
|
|
2915
|
+
.dm-help-h { font-weight:650; font-size:12.5px; margin-bottom:4px; }
|
|
2916
|
+
.dm-help-prompt { border-top:1px solid rgba(128,128,128,.16); padding:9px 0; }
|
|
2917
|
+
.dm-help-prompt-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:3px; }
|
|
2918
|
+
.dm-btn.bad { border-color:rgba(209,75,65,.55); color:#d14b41; }
|
|
2919
|
+
|
|
2920
|
+
.dm-card-node.indirect { opacity:.62; border-style:dashed; border-color:rgba(128,128,128,.45); background:rgba(128,128,128,.03); }
|
|
2921
|
+
.dm-fed-badge { font-size:10px; opacity:.6; padding:1px 5px; border:1px dashed rgba(128,128,128,.5); border-radius:999px; white-space:nowrap; }
|
|
2922
|
+
.dm-edge-indirect { stroke:rgba(128,128,128,.5); stroke-width:1.1; fill:none; }
|
|
2923
|
+
|
|
2924
|
+
.dm-pane { min-width:0; display:flex; flex-direction:column; gap:12px; }
|
|
2925
|
+
.dm-divider { cursor:col-resize; align-self:stretch; min-height:120px; border-radius:4px; position:relative; }
|
|
2926
|
+
.dm-divider::before { content:''; position:absolute; top:0; bottom:0; left:3px; width:1px; background:rgba(128,128,128,.28); }
|
|
2927
|
+
.dm-divider:hover::before, .dm-divider:active::before { background:rgba(88,166,255,.7); width:2px; left:2.5px; }
|
|
2928
|
+
@container dm-content (max-width: 860px) { .dm-body { grid-template-columns:1fr; } .dm-divider { display:none; } }
|
|
2929
|
+
`
|
|
2930
|
+
function MeshNetworkPage() {
|
|
2931
|
+
// 宽度偏好:与 DSH 对话区相同语义——空 = 默认宽度,数字 = 用户拖拽固定值。
|
|
2932
|
+
var prefState = useState(function () {
|
|
2933
|
+
try { var saved = Number(window.localStorage.getItem('dsh-mesh.contentWidth')); return Number.isFinite(saved) && saved > 0 ? saved : 0 } catch (error) { return 0 }
|
|
2934
|
+
})
|
|
2935
|
+
var pref = prefState[0]; var setPref = prefState[1]
|
|
2936
|
+
var shellState = useState(0); var containerW = shellState[0]; var setContainerW = shellState[1]
|
|
2937
|
+
// 滚动条实际占位宽度(px):左侧补等宽内边距,让内容在 shell 上真正居中。
|
|
2938
|
+
var sbState = useState(0); var sbPad = sbState[0]; var setSbPad = sbState[1]
|
|
2939
|
+
// 手柄几何:内容元素到 shell 左右边的实测 px(null = 还没量到)。
|
|
2940
|
+
var geoState = useState(null); var geo = geoState[0]; var setGeo = geoState[1]
|
|
2941
|
+
var shellRef = useRef(null)
|
|
2942
|
+
|
|
2943
|
+
// 2026-11-10:宽度基准 =「内容可用宽度」= 滚动容器内容盒宽(clientWidth - 左右 padding);
|
|
2944
|
+
// 滚动条贴页面右边,其占位由 CSS 在左侧补等宽 padding 抵消(--dm-sb-pad)。
|
|
2945
|
+
useEffect(function () {
|
|
2946
|
+
var el = shellRef.current
|
|
2947
|
+
if (!el) return
|
|
2948
|
+
function measure() {
|
|
2949
|
+
var scroller = el.querySelector('.dm-content-scroll')
|
|
2950
|
+
if (!scroller) { setContainerW(el.clientWidth || 0); return }
|
|
2951
|
+
var sb = Math.max(0, (scroller.offsetWidth || 0) - (scroller.clientWidth || 0))
|
|
2952
|
+
var scs = getComputedStyle(scroller)
|
|
2953
|
+
var padL = parseFloat(scs.paddingLeft) || 0
|
|
2954
|
+
var padR = parseFloat(scs.paddingRight) || 0
|
|
2955
|
+
// 内容盒宽 = clientWidth - 实际左右 padding(左侧含滚动条补偿与 2px 滚动条外留白)
|
|
2956
|
+
var avail = Math.max(0, (scroller.clientWidth || 0) - padL - padR)
|
|
2957
|
+
setContainerW(avail)
|
|
2958
|
+
setSbPad(function (prev) { return Math.abs(prev - sb) >= 0.5 ? sb : prev })
|
|
2959
|
+
}
|
|
2960
|
+
measure()
|
|
2961
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
2962
|
+
var observer = new ResizeObserver(measure)
|
|
2963
|
+
observer.observe(el)
|
|
2964
|
+
return function () { observer.disconnect() }
|
|
2965
|
+
}
|
|
2966
|
+
window.addEventListener('resize', measure)
|
|
2967
|
+
return function () { window.removeEventListener('resize', measure) }
|
|
2968
|
+
}, [sbPad])
|
|
2969
|
+
|
|
2970
|
+
// 手柄定位:高亮线在「内容边 ↔ 页面边」的留白里自动居中(留白宽时退回 24px 间隙)。
|
|
2971
|
+
// 滚动容器已铺满整页,所以内容到 shell 左右边的距离就是完整留白(右侧含滚动条)。 */
|
|
2972
|
+
useEffect(function () {
|
|
2973
|
+
var el = shellRef.current
|
|
2974
|
+
if (!el) return
|
|
2975
|
+
var raf = 0
|
|
2976
|
+
function sync() {
|
|
2977
|
+
var content = el.querySelector('.dm-content')
|
|
2978
|
+
if (!content) return
|
|
2979
|
+
var sr = el.getBoundingClientRect()
|
|
2980
|
+
var cr = content.getBoundingClientRect()
|
|
2981
|
+
// 留白量到 shell(页面)边:滚动条外那 2px 也算留白,高亮线才居中在视觉留白里
|
|
2982
|
+
var offL = cr.left - sr.left
|
|
2983
|
+
var offR = sr.right - cr.right
|
|
2984
|
+
// 高亮线中心到内容边的距离:留白的一半,但夹在 [12, 29](29 = 24px 间隙 + 5px 线中心偏移)
|
|
2985
|
+
var gapL = Math.max(12, Math.min(29, offL / 2))
|
|
2986
|
+
var gapR = Math.max(12, Math.min(29, offR / 2))
|
|
2987
|
+
var bl = Math.round((offL - gapL) * 10) / 10
|
|
2988
|
+
var br = Math.round((offR - gapR) * 10) / 10
|
|
2989
|
+
setGeo(function (prev) {
|
|
2990
|
+
if (prev && Math.abs(prev.l - bl) < 0.5 && Math.abs(prev.r - br) < 0.5) return prev
|
|
2991
|
+
return { l: bl, r: br }
|
|
2992
|
+
})
|
|
2993
|
+
}
|
|
2994
|
+
sync()
|
|
2995
|
+
raf = window.requestAnimationFrame(sync)
|
|
2996
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
2997
|
+
var observer = new ResizeObserver(sync)
|
|
2998
|
+
var content = el.querySelector('.dm-content')
|
|
2999
|
+
if (content) observer.observe(content)
|
|
3000
|
+
observer.observe(el)
|
|
3001
|
+
return function () { window.cancelAnimationFrame(raf); observer.disconnect() }
|
|
3002
|
+
}
|
|
3003
|
+
return function () { window.cancelAnimationFrame(raf) }
|
|
3004
|
+
}, [contentW, sbPad])
|
|
3005
|
+
|
|
3006
|
+
// 默认宽度 = 内容可用宽度 x DEFAULT_W_RATIO;拖拽限制在 [640, 可用宽度],
|
|
3007
|
+
// 双击手柄清掉偏好后回到默认。想留一点内边距就把 DEFAULT_W_RATIO 调到 0.98/0.96。
|
|
3008
|
+
var DEFAULT_W_RATIO = 1
|
|
3009
|
+
var widthNow = containerW || 920
|
|
3010
|
+
var maxW = Math.max(640, widthNow)
|
|
3011
|
+
var contentW = pref > 0 ? Math.min(Math.max(pref, 640), maxW) : Math.round(widthNow * DEFAULT_W_RATIO)
|
|
3012
|
+
|
|
3013
|
+
function beginWidthDrag(side, event) {
|
|
3014
|
+
event.preventDefault()
|
|
3015
|
+
var handle = event.currentTarget
|
|
3016
|
+
var shell = handle.closest ? handle.closest('.dm-content-shell') : (handle.parentElement && handle.parentElement.parentElement)
|
|
3017
|
+
if (!shell) return
|
|
3018
|
+
var scroller = shell.querySelector('.dm-content-scroll')
|
|
3019
|
+
var max = 920
|
|
3020
|
+
if (scroller) {
|
|
3021
|
+
var scs2 = getComputedStyle(scroller)
|
|
3022
|
+
var padL2 = parseFloat(scs2.paddingLeft) || 0
|
|
3023
|
+
var padR2 = parseFloat(scs2.paddingRight) || 0
|
|
3024
|
+
max = Math.max(0, (scroller.clientWidth || 0) - padL2 - padR2)
|
|
3025
|
+
} else if (shell.clientWidth) {
|
|
3026
|
+
max = shell.clientWidth
|
|
3027
|
+
}
|
|
3028
|
+
max = Math.max(640, max || 920)
|
|
3029
|
+
var base = contentW
|
|
3030
|
+
var originX = event.clientX
|
|
3031
|
+
var latest = contentW
|
|
3032
|
+
var firstBox = handle.getBoundingClientRect()
|
|
3033
|
+
handle.style.setProperty('--dm-width-handle-pointer-y', (event.clientY - firstBox.top) + 'px')
|
|
3034
|
+
handle.toggleAttribute('data-dragging', true)
|
|
3035
|
+
document.body.style.cursor = 'col-resize'
|
|
3036
|
+
document.body.style.userSelect = 'none'
|
|
3037
|
+
function onMove(moveEvent) {
|
|
3038
|
+
var dx = moveEvent.clientX - originX
|
|
3039
|
+
var outward = side === 'right' ? dx : -dx
|
|
3040
|
+
// 只有拖拽时才更新高亮位置(与 DSH 一致);hover 时保持手柄高度的 50%。
|
|
3041
|
+
var box = handle.getBoundingClientRect()
|
|
3042
|
+
handle.style.setProperty('--dm-width-handle-pointer-y', (moveEvent.clientY - box.top) + 'px')
|
|
3043
|
+
// 两侧同时外扩/内收,所以内容宽度变化是手柄位移的 2 倍(与 DSH 一致)。
|
|
3044
|
+
latest = Math.min(Math.max(base + outward * 2, 640), max)
|
|
3045
|
+
setPref(latest)
|
|
3046
|
+
}
|
|
3047
|
+
function onUp() {
|
|
3048
|
+
handle.toggleAttribute('data-dragging', false)
|
|
3049
|
+
window.removeEventListener('pointermove', onMove)
|
|
3050
|
+
window.removeEventListener('pointerup', onUp)
|
|
3051
|
+
document.body.style.cursor = ''
|
|
3052
|
+
document.body.style.userSelect = ''
|
|
3053
|
+
try { window.localStorage.setItem('dsh-mesh.contentWidth', String(latest)) } catch (error) { /* ignore */ }
|
|
3054
|
+
}
|
|
3055
|
+
window.addEventListener('pointermove', onMove)
|
|
3056
|
+
window.addEventListener('pointerup', onUp)
|
|
3057
|
+
}
|
|
3058
|
+
function resetContentWidth() {
|
|
3059
|
+
setPref(0)
|
|
3060
|
+
try { window.localStorage.removeItem('dsh-mesh.contentWidth') } catch (error) { /* ignore */ }
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
return h('div', { className: 'dm-page' },
|
|
3064
|
+
h('style', null, PAGE_CSS),
|
|
3065
|
+
h('div', {
|
|
3066
|
+
className: 'dm-content-shell',
|
|
3067
|
+
ref: shellRef,
|
|
3068
|
+
style: {
|
|
3069
|
+
'--dm-content-w': contentW + 'px',
|
|
3070
|
+
'--dm-sb-pad': sbPad + 'px',
|
|
3071
|
+
'--dm-bar-l': (geo ? geo.l : -100) + 'px',
|
|
3072
|
+
'--dm-bar-r': (geo ? geo.r : -100) + 'px'
|
|
3073
|
+
}
|
|
3074
|
+
},
|
|
3075
|
+
h('div', { className: 'dm-width-handle', 'data-side': 'left', title: '拖拽调整内容宽度(双击恢复默认)', onPointerDown: function (event) { beginWidthDrag('left', event) }, onDoubleClick: resetContentWidth }),
|
|
3076
|
+
h('div', { className: 'dm-width-handle', 'data-side': 'right', title: '拖拽调整内容宽度(双击恢复默认)', onPointerDown: function (event) { beginWidthDrag('right', event) }, onDoubleClick: resetContentWidth }),
|
|
3077
|
+
h('div', { className: 'dm-content-scroll' },
|
|
3078
|
+
h('div', { className: 'dm-content', style: { width: 'min(100%, ' + contentW + 'px)' } }, h(MeshStatusSection))
|
|
3079
|
+
)
|
|
3080
|
+
)
|
|
3081
|
+
)
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
var SECTION = { name: 'settings.section', id: 'dsh-mesh', order: 90, label: 'AI 网络' }
|
|
3085
|
+
var inject = ['slots']
|
|
3086
|
+
|
|
3087
|
+
// 2026-11-07:设置左侧导航里「AI 网络」这一项,把壳的默认齿轮换成三节点图标,颜色跟随 --dsh-brand-color
|
|
3088
|
+
function navMeshIconDom(referenceClass) {
|
|
3089
|
+
var NS = 'http://www.w3.org/2000/svg'
|
|
3090
|
+
var svg = document.createElementNS(NS, 'svg')
|
|
3091
|
+
if (referenceClass) svg.setAttribute('class', referenceClass)
|
|
3092
|
+
svg.setAttribute('width', '16')
|
|
3093
|
+
svg.setAttribute('height', '16')
|
|
3094
|
+
svg.setAttribute('viewBox', '0 0 16 16')
|
|
3095
|
+
svg.setAttribute('fill', 'none')
|
|
3096
|
+
svg.setAttribute('stroke', 'currentColor')
|
|
3097
|
+
svg.setAttribute('stroke-width', '1.4')
|
|
3098
|
+
svg.setAttribute('stroke-linecap', 'round')
|
|
3099
|
+
svg.setAttribute('aria-hidden', 'true')
|
|
3100
|
+
svg.setAttribute('data-dsh-mesh-nav-icon', '1')
|
|
3101
|
+
svg.style.color = 'var(--dsh-brand-color, currentColor)'
|
|
3102
|
+
var line = document.createElementNS(NS, 'path')
|
|
3103
|
+
line.setAttribute('d', 'M8 3.2 L3.2 8 M8 3.2 L12.8 8 M3.2 8 L12.8 8')
|
|
3104
|
+
line.setAttribute('opacity', '.7')
|
|
3105
|
+
svg.appendChild(line)
|
|
3106
|
+
var dots = [[8, 3.2], [3.2, 8], [12.8, 8]]
|
|
3107
|
+
for (var i = 0; i < dots.length; i++) {
|
|
3108
|
+
var circle = document.createElementNS(NS, 'circle')
|
|
3109
|
+
circle.setAttribute('cx', dots[i][0])
|
|
3110
|
+
circle.setAttribute('cy', dots[i][1])
|
|
3111
|
+
circle.setAttribute('r', '1.6')
|
|
3112
|
+
circle.setAttribute('fill', 'currentColor')
|
|
3113
|
+
circle.setAttribute('stroke', 'none')
|
|
3114
|
+
svg.appendChild(circle)
|
|
3115
|
+
}
|
|
3116
|
+
return svg
|
|
3117
|
+
}
|
|
3118
|
+
function decorateMeshNav() {
|
|
3119
|
+
try {
|
|
3120
|
+
var buttons = document.querySelectorAll('[role="dialog"] nav button')
|
|
3121
|
+
for (var i = 0; i < buttons.length; i++) {
|
|
3122
|
+
var button = buttons[i]
|
|
3123
|
+
var text = (button.textContent || '').trim()
|
|
3124
|
+
if (text !== 'AI 网络' && text !== 'AI 网络(dsh-mesh)') continue
|
|
3125
|
+
// 0.2.2 的默认图标可能包在 span 里;在默认图标的父容器里原地替换,
|
|
3126
|
+
// 保留间距/对齐,并清掉所有旧 SVG,避免齿轮 + 自绘图标双图标。
|
|
3127
|
+
var existingIcon = button.querySelector('svg[data-dsh-mesh-nav-icon]')
|
|
3128
|
+
var old = []
|
|
3129
|
+
var all = button.querySelectorAll('svg')
|
|
3130
|
+
for (var j = 0; j < all.length; j++) if (all[j] !== existingIcon) old.push(all[j])
|
|
3131
|
+
var reference = old[0] || null
|
|
3132
|
+
var parent = reference ? reference.parentNode : button
|
|
3133
|
+
var className = reference ? reference.getAttribute('class') || '' : ''
|
|
3134
|
+
if (!existingIcon) {
|
|
3135
|
+
var icon = navMeshIconDom(className)
|
|
3136
|
+
if (reference) parent.insertBefore(icon, reference)
|
|
3137
|
+
else if (button.firstChild) button.insertBefore(icon, button.firstChild)
|
|
3138
|
+
else button.appendChild(icon)
|
|
3139
|
+
}
|
|
3140
|
+
for (var k = 0; k < old.length; k++) if (old[k].parentNode) old[k].parentNode.removeChild(old[k])
|
|
3141
|
+
}
|
|
3142
|
+
} catch (error) {
|
|
3143
|
+
/* ignore */
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
function apply(ctx) {
|
|
3148
|
+
var disposers = []
|
|
3149
|
+
// 2026-11-10:AI 网络从「设置」迁到左侧栏独立页面,使用更大的主区域。
|
|
3150
|
+
try {
|
|
3151
|
+
disposers.push(ctx.slots.inject('sidebar.panellist', function () {
|
|
3152
|
+
return ctx.slots.register({ name: 'sidebar.panellist', id: 'mesh-network', order: 54, label: function () { return 'AI 网络' } }, networkPanelIcon)
|
|
3153
|
+
}))
|
|
3154
|
+
disposers.push(ctx.slots.inject('main', function () {
|
|
3155
|
+
return ctx.slots.register({ name: 'main', key: 'mesh-network', inject: function () { return {} } }, MeshNetworkPage)
|
|
3156
|
+
}))
|
|
3157
|
+
} catch (error) {
|
|
3158
|
+
console.warn('[dsh-mesh-ui] sidebar network page registration failed:', error)
|
|
3159
|
+
}
|
|
3160
|
+
// 2026-11-07 方案 A:左侧栏「委托」面板(sidebar.panellist + main)
|
|
3161
|
+
try {
|
|
3162
|
+
disposers.push(ctx.slots.inject('sidebar.panellist', function () {
|
|
3163
|
+
return ctx.slots.register({ name: 'sidebar.panellist', id: 'mesh-delegations', order: 55, label: function () { return '委托' } }, meshPanelIcon)
|
|
3164
|
+
}))
|
|
3165
|
+
disposers.push(ctx.slots.inject('main', function () {
|
|
3166
|
+
return ctx.slots.register({ name: 'main', key: 'mesh-delegations', inject: function () { return {} } }, MeshDelegationPanel)
|
|
3167
|
+
}))
|
|
3168
|
+
} catch (error) {
|
|
3169
|
+
console.warn('[dsh-mesh-ui] delegation panel registration failed:', error)
|
|
3170
|
+
}
|
|
3171
|
+
var navObserver = null
|
|
3172
|
+
var navTimer = null
|
|
3173
|
+
try {
|
|
3174
|
+
if (typeof MutationObserver !== 'undefined' && document.body) {
|
|
3175
|
+
navObserver = new MutationObserver(function () { decorateMeshNav() })
|
|
3176
|
+
navObserver.observe(document.body, { childList: true, subtree: true })
|
|
3177
|
+
}
|
|
3178
|
+
navTimer = setInterval(decorateMeshNav, 1500)
|
|
3179
|
+
if (navTimer && typeof navTimer.unref === 'function') navTimer.unref()
|
|
3180
|
+
} catch (error) {
|
|
3181
|
+
/* ignore */
|
|
3182
|
+
}
|
|
3183
|
+
ctx.effect(function () {
|
|
3184
|
+
return function () {
|
|
3185
|
+
for (var i = 0; i < disposers.length; i++) { try { disposers[i]() } catch (_) { /* ignore */ } }
|
|
3186
|
+
try { if (navObserver) navObserver.disconnect() } catch (_) { /* ignore */ }
|
|
3187
|
+
try { if (navTimer) clearInterval(navTimer) } catch (_) { /* ignore */ }
|
|
3188
|
+
}
|
|
3189
|
+
}, 'dsh-mesh-ui: settings section + nav icon')
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
exports.apply = apply
|
|
3193
|
+
exports.inject = inject
|
|
3194
|
+
return module.exports
|
|
3195
|
+
}
|
|
3196
|
+
})
|