dsh-mesh-ui 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +741 -171
- package/lib/collect.js +512 -458
- package/lib/delegations.js +240 -233
- package/lib/index.js +199 -198
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -25,11 +25,23 @@ window.__ModuleLoader__.load({
|
|
|
25
25
|
var useCallback = React.useCallback
|
|
26
26
|
|
|
27
27
|
var API = '/api/dsh-mesh/status'
|
|
28
|
-
var REFRESH_MS =
|
|
28
|
+
var REFRESH_MS = 5000
|
|
29
29
|
var SUPPORTS_ZOOM = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('zoom', '1.5')
|
|
30
30
|
|
|
31
31
|
// 2026-11-07 用户要求:卡片标题只用固定中文叫法,不显示电脑设备名
|
|
32
32
|
var NODE_LABELS = {}
|
|
33
|
+
// 2026-11-09 用户要求:名称后显示设备类型。类型依据设备快照(CPU 形态)判断,可直接改这张表:
|
|
34
|
+
var DEVICE_TYPES = {}
|
|
35
|
+
function deviceType(name) {
|
|
36
|
+
var raw = String(name || '').toLowerCase()
|
|
37
|
+
var key = peerDeviceKey(raw)
|
|
38
|
+
return DEVICE_TYPES[key] || DEVICE_TYPES[raw] || ''
|
|
39
|
+
}
|
|
40
|
+
function nodeDisplay(name) {
|
|
41
|
+
var label = nodeLabel(name)
|
|
42
|
+
var type = deviceType(name)
|
|
43
|
+
return type ? label + ' · ' + type : label
|
|
44
|
+
}
|
|
33
45
|
// 同一台设备可能有多个 peer 记录(不同 token/入口:phone-node / phone-node-lan / phone-node-exec)。
|
|
34
46
|
// AI 网络拓扑按「设备」展示:去掉后缀归并,取可达且延迟最优的那条。
|
|
35
47
|
var PEER_SUFFIX = /-(lan|wan|exec|exe|import|local|ctl)$/i
|
|
@@ -61,6 +73,50 @@ function filterSelfPeers(list, nodeName) {
|
|
|
61
73
|
return (list || []).filter(function (peer) { return !selfKey || peerDeviceKey(peer.name) !== selfKey })
|
|
62
74
|
}
|
|
63
75
|
function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()] || String(name || '未知节点') }
|
|
76
|
+
// [layout-helpers-begin] 2026-11-09 拓扑自定义布局:节点 key 顺序 + localStorage 持久化 + clamp
|
|
77
|
+
var LAYOUT_STORE_KEY = 'dsh-mesh-ui.topology.layout.v1'
|
|
78
|
+
function topologyNodeKeys(data) {
|
|
79
|
+
var d = data || {}
|
|
80
|
+
var peers = filterSelfPeers(dedupePeers(d.peers || []), d.nodeName)
|
|
81
|
+
return ['local'].concat(peers.map(function (peer) { return 'peer:' + peer.name }))
|
|
82
|
+
}
|
|
83
|
+
// 2026-11-10:设备显示顺序(左→右 / 逆时针)——拓扑渲染与拖拽位置表必须共用同一顺序
|
|
84
|
+
var FAN_ORDER = []
|
|
85
|
+
function fanRankOfNodeKey(key, data) {
|
|
86
|
+
var dev = key === 'local' ? peerDeviceKey(data && data.nodeName) : peerDeviceKey(String(key || '').replace(/^peer:/, ''))
|
|
87
|
+
var idx = FAN_ORDER.indexOf(dev)
|
|
88
|
+
return idx < 0 ? 99 : idx
|
|
89
|
+
}
|
|
90
|
+
function orderedNodeKeys(data) {
|
|
91
|
+
var keys = topologyNodeKeys(data).slice()
|
|
92
|
+
keys.sort(function (a, b) {
|
|
93
|
+
var d = fanRankOfNodeKey(a, data) - fanRankOfNodeKey(b, data)
|
|
94
|
+
return d !== 0 ? d : String(a).localeCompare(String(b))
|
|
95
|
+
})
|
|
96
|
+
return keys
|
|
97
|
+
}
|
|
98
|
+
// 2026-11-10:用户手工调整后认可的默认布局(世界坐标,10000×6000 画布内)
|
|
99
|
+
var DEFAULT_DEVICE_POS = {}
|
|
100
|
+
var DEFAULT_INFRA_POS = {
|
|
101
|
+
aliyun: [4887.59, 2856.78],
|
|
102
|
+
sakura: [5177.39, 2858.40]
|
|
103
|
+
}
|
|
104
|
+
function devKeyOfNodeKey(key, data) {
|
|
105
|
+
return key === 'local' ? peerDeviceKey(data && data.nodeName) : peerDeviceKey(String(key || '').replace(/^peer:/, ''))
|
|
106
|
+
}
|
|
107
|
+
function loadMovedLayout() {
|
|
108
|
+
try {
|
|
109
|
+
var raw = window.localStorage.getItem(LAYOUT_STORE_KEY)
|
|
110
|
+
if (!raw) return {}
|
|
111
|
+
var obj = JSON.parse(raw)
|
|
112
|
+
return obj && typeof obj === 'object' ? obj : {}
|
|
113
|
+
} catch (_) { return {} }
|
|
114
|
+
}
|
|
115
|
+
function saveMovedLayout(obj) {
|
|
116
|
+
try { window.localStorage.setItem(LAYOUT_STORE_KEY, JSON.stringify(obj || {})) } catch (_) { /* ignore */ }
|
|
117
|
+
}
|
|
118
|
+
function clampNum(v, min, max) { return Math.max(min, Math.min(max, v)) }
|
|
119
|
+
// [layout-helpers-end]
|
|
64
120
|
// [relay-helpers-begin] 2026-11-09 架构层:识别公网中转站(frps)与通信方式(纯函数,可单测)
|
|
65
121
|
// 判定:同一公网 host 被 >=2 个 peer 使用 → 视为共享中转站;LAN/loopback 不算。
|
|
66
122
|
var RELAY_PUBLIC_KINDS = { wan: 1, frp: 1 }
|
|
@@ -102,7 +158,7 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
102
158
|
// [relay-helpers-end]
|
|
103
159
|
var W = 560
|
|
104
160
|
var H = 400
|
|
105
|
-
var MIN_SCALE = 0.
|
|
161
|
+
var MIN_SCALE = 0.18
|
|
106
162
|
var MAX_SCALE = 2.6
|
|
107
163
|
|
|
108
164
|
var CSS = `
|
|
@@ -127,6 +183,14 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
127
183
|
linear-gradient(90deg, rgba(128,128,128,.05) 1px, transparent 1px) 0 0/26px 26px;
|
|
128
184
|
cursor:grab; touch-action:none; user-select:none; }
|
|
129
185
|
.dm-viewport.dragging { cursor:grabbing; }
|
|
186
|
+
.dm-viewport.resizing { cursor:ns-resize; }
|
|
187
|
+
/* 2026-11-09:画布高度可拖拽调整 */
|
|
188
|
+
.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; }
|
|
189
|
+
.dm-resize:hover { background:rgba(88,166,255,.28); color:#7fb4ff; }
|
|
190
|
+
/* 2026-11-09:悬浮节点高亮 + 连线上显示完整地址 */
|
|
191
|
+
.dm-edge-label { paint-order: stroke; stroke: rgba(15,17,21,.9); stroke-width: 3px; font-family: ui-monospace,Consolas,monospace; pointer-events: none; }
|
|
192
|
+
body.light .dm-edge-label { stroke: rgba(255,255,255,.92); }
|
|
193
|
+
.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; }
|
|
130
194
|
.dm-world-outer { position:absolute; left:0; top:0; }
|
|
131
195
|
.dm-world { position:absolute; left:0; top:0; transform-origin:0 0; }
|
|
132
196
|
.dm-lines { position:absolute; left:0; top:0; overflow:visible; pointer-events:none; }
|
|
@@ -144,7 +208,7 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
144
208
|
.dm-offline-row { margin-top:5px; font-size:10.5px; color:#c9362c; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
145
209
|
.dm-n-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
|
146
210
|
.dm-n-name { font-weight:650; font-size:13px; display:flex; align-items:center; gap:7px; min-width:0; }
|
|
147
|
-
.dm-n-name span.dm-ell { overflow:
|
|
211
|
+
.dm-n-name span.dm-ell { overflow:visible; text-overflow:clip; white-space:nowrap; }
|
|
148
212
|
.dm-dot { width:9px; height:9px; border-radius:50%; display:inline-block; background:#8b949e; flex:none; }
|
|
149
213
|
.dm-dot.ok { background:#2e9e4f; box-shadow:0 0 0 3px rgba(63,185,80,.18); }
|
|
150
214
|
.dm-dot.bad { background:#d14b41; box-shadow:0 0 0 3px rgba(248,81,73,.18); }
|
|
@@ -158,9 +222,27 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
158
222
|
.dm-extra { font-size:11px; opacity:.6; font-variant-numeric:tabular-nums; }
|
|
159
223
|
.dm-card-node.compact { padding:9px 11px; cursor:pointer; transition:border-color .12s, box-shadow .12s; }
|
|
160
224
|
.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; }
|
|
161
|
-
.dm-card-domain { margin-top:6px; font-size:10px; opacity:.72; overflow:
|
|
225
|
+
.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; }
|
|
162
226
|
.dm-card-dsh { margin-top:6px; }
|
|
163
|
-
|
|
227
|
+
/* 2026-11-09-v6:详情卡列数自适应(宽屏 3 列),不再固定 2 列 */
|
|
228
|
+
.dm-devices { display:grid; grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap:10px; align-items:start; }
|
|
229
|
+
/* 2026-11-09:三通道筛选浮层(画布内左上) */
|
|
230
|
+
.dm-topo-wrap { display:flex; flex-direction:column; gap:6px; }
|
|
231
|
+
.dm-topbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
|
232
|
+
.dm-topbar-info { font-size:10.5px; opacity:.55; margin-left:auto; white-space:nowrap; }
|
|
233
|
+
.dm-filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
234
|
+
.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; }
|
|
235
|
+
.dm-flt.on { opacity:1; border-color:var(--c); }
|
|
236
|
+
.dm-flt input { margin:0; accent-color:var(--c); }
|
|
237
|
+
.dm-card-node.server { border-radius:12px; background:rgba(128,128,128,.06); }
|
|
238
|
+
.dm-chip.type { border-color:rgba(88,166,255,.5); color:#7fb4ff; }
|
|
239
|
+
/* 2026-11-09:三通道虚线流动动画(位移量必须是 dash 周期 11px 的整数倍,否则循环会抽搐) */
|
|
240
|
+
@keyframes dm-dashflow { to { stroke-dashoffset: -22; } }
|
|
241
|
+
.dm-lines path.dm-edge-frp { animation: dm-dashflow 1.15s linear infinite; stroke-linecap: butt; }
|
|
242
|
+
@media (prefers-reduced-motion: reduce) { .dm-lines path.dm-edge-frp { animation: none; } }
|
|
243
|
+
.dm-infra-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(340px, 1fr)); gap:10px; margin-top:10px; }
|
|
244
|
+
.dm-infra-card { border:1px solid rgba(128,128,128,.3); border-radius:11px; padding:9px 11px; background:rgba(128,128,128,.05); }
|
|
245
|
+
.dm-infra-head { display:flex; align-items:center; gap:7px; font-weight:650; font-size:12.5px; margin-bottom:5px; }
|
|
164
246
|
.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; }
|
|
165
247
|
.dm-device.selected { border-color:rgba(88,166,255,.6); background:rgba(88,166,255,.07); }
|
|
166
248
|
.dm-device-head { display:flex; align-items:center; gap:7px; font-weight:650; font-size:12.5px; margin-bottom:6px; }
|
|
@@ -184,10 +266,10 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
184
266
|
/* 画布浮层控件 */
|
|
185
267
|
.dm-zoom { position:absolute; right:8px; bottom:8px; display:flex; gap:5px; padding:4px; border-radius:9px;
|
|
186
268
|
background:rgba(128,128,128,.12); backdrop-filter:blur(4px); z-index:5; }
|
|
187
|
-
.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:
|
|
269
|
+
.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; }
|
|
188
270
|
.dm-zoom button:hover { background:rgba(128,128,128,.16); }
|
|
189
271
|
.dm-zoom .pct { font-size:10.5px; opacity:.6; align-self:center; padding:0 2px; font-variant-numeric:tabular-nums; }
|
|
190
|
-
.dm-hint {
|
|
272
|
+
.dm-hint { font-size:10.5px; opacity:.45; padding:0 2px; }
|
|
191
273
|
|
|
192
274
|
/* 折叠面板 */
|
|
193
275
|
.dm-details { border:1px solid rgba(128,128,128,.22); border-radius:11px; background:rgba(128,128,128,.035); }
|
|
@@ -221,6 +303,11 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
221
303
|
.dm-details { background:rgba(255,255,255,.025); }
|
|
222
304
|
}
|
|
223
305
|
/* 2026-11-09 架构层:公网中转站(frps) */
|
|
306
|
+
/* 2026-11-09 拓扑自定义布局:卡片可拖拽 */
|
|
307
|
+
.dm-card-node.draggable { cursor:grab; touch-action:none; }
|
|
308
|
+
.dm-card-node.draggable:active { cursor:grabbing; }
|
|
309
|
+
.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; }
|
|
310
|
+
.dm-layout-btn.active { border-color:rgba(88,166,255,.6); color:#3b82d6; }
|
|
224
311
|
.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; }
|
|
225
312
|
.dm-relay-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
|
|
226
313
|
.dm-relay-title { font-size:12.5px; font-weight:650; }
|
|
@@ -314,20 +401,174 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
314
401
|
// ---------- 画布:缩放 / 平移 ----------
|
|
315
402
|
function Viewport(props) {
|
|
316
403
|
var layout = props.layout
|
|
404
|
+
// 2026-11-09-v5:卡片宽度自适应 —— 子组件测量真实卡片宽高后回传,按实际尺寸重算多边形布局
|
|
405
|
+
var sizeState = useState(null)
|
|
406
|
+
var measuredSize = sizeState[0]
|
|
407
|
+
var setMeasuredSize = sizeState[1]
|
|
408
|
+
if (measuredSize && measuredSize.w > 0 && measuredSize.h > 0) {
|
|
409
|
+
layout = computeLayout(layout.count, { cardW: measuredSize.w, cardH: measuredSize.h })
|
|
410
|
+
}
|
|
411
|
+
var infraBoundsRef = useRef([])
|
|
412
|
+
// 2026-11-09:重置拓扑时同时通知 Topology 复位服务器卡片
|
|
413
|
+
var resetNonceState = useState(0)
|
|
414
|
+
var resetNonce = resetNonceState[0]
|
|
415
|
+
var bumpResetNonce = resetNonceState[1]
|
|
416
|
+
function onInfraBounds(boxes) { infraBoundsRef.current = boxes || [] }
|
|
417
|
+
function onCardSize(w, h) {
|
|
418
|
+
setMeasuredSize(function (prev) {
|
|
419
|
+
if (prev && prev.w === w && prev.h === h) return prev
|
|
420
|
+
return { w: w, h: h }
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
// 2026-11-09 自定义布局:moved[key] = [x,y](绝对世界坐标;localStorage 持久化,重置清空)
|
|
424
|
+
// 2026-11-09-v2:用户反馈「拖不远」→ 世界画布固定放大(可拖很开),自适应按实际内容 bbox 缩放,
|
|
425
|
+
// 而不是按初始画布尺寸;卡片只能在世界边界内移动(防止拖丢),但世界足够大。
|
|
426
|
+
var WORLD_MIN_W = 10000
|
|
427
|
+
var WORLD_MIN_H = 6000
|
|
428
|
+
var WORLD_PAD = 16
|
|
429
|
+
var nodeKeys = orderedNodeKeys(props.data)
|
|
430
|
+
var layoutState = useState(loadMovedLayout)
|
|
431
|
+
var movedLayout = layoutState[0]
|
|
432
|
+
var setMovedLayout = layoutState[1]
|
|
433
|
+
var worldW = Math.max(layout.W, WORLD_MIN_W)
|
|
434
|
+
var worldH = Math.max(layout.H, WORLD_MIN_H)
|
|
435
|
+
var baseOffsetX = (worldW - layout.W) / 2
|
|
436
|
+
var baseOffsetY = (worldH - layout.H) / 2
|
|
437
|
+
var positions = layout.positions.map(function (p, idx) {
|
|
438
|
+
var m = movedLayout[nodeKeys[idx]]
|
|
439
|
+
if (m && m.length === 2) return [Number(m[0]), Number(m[1])]
|
|
440
|
+
var devKey = devKeyOfNodeKey(nodeKeys[idx], props.data)
|
|
441
|
+
var def = DEFAULT_DEVICE_POS[devKey]
|
|
442
|
+
if (def) return [def[0], def[1]]
|
|
443
|
+
return [p[0] + baseOffsetX, p[1] + baseOffsetY]
|
|
444
|
+
})
|
|
445
|
+
var effLayout = Object.assign({}, layout, { W: worldW, H: worldH, positions: positions, baseOffsetX: baseOffsetX, baseOffsetY: baseOffsetY, contentW: layout.W, contentH: layout.H })
|
|
446
|
+
function moveNode(key, pos) {
|
|
447
|
+
setMovedLayout(function (prev) {
|
|
448
|
+
var next = Object.assign({}, prev)
|
|
449
|
+
next[key] = pos
|
|
450
|
+
saveMovedLayout(next)
|
|
451
|
+
return next
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
function resetLayout() {
|
|
455
|
+
setMovedLayout({})
|
|
456
|
+
saveMovedLayout({})
|
|
457
|
+
// 2026-11-09:重置也恢复画布高度(用户反馈重置后"高度拉得很长"其实是之前拖拽的高度被保留)
|
|
458
|
+
setCustomHeight(0)
|
|
459
|
+
try { window.localStorage.removeItem('dsh-mesh-ui.viewportH.v1') } catch (_) { /* ignore */ }
|
|
460
|
+
interactedRef.current = false
|
|
461
|
+
bumpResetNonce(function (x) { return x + 1 })
|
|
462
|
+
setTimeout(fit, 120)
|
|
463
|
+
}
|
|
464
|
+
// 2026-11-09-v3:拖卡片到画布边缘时自动平移视角(不用手动挪画面就能拖很远)
|
|
465
|
+
function autoPanAt(clientX, clientY) {
|
|
466
|
+
var el = viewRef.current
|
|
467
|
+
if (!el) return
|
|
468
|
+
var rect = el.getBoundingClientRect()
|
|
469
|
+
var MARGIN = 60
|
|
470
|
+
var SPEED = 16
|
|
471
|
+
var dx = 0
|
|
472
|
+
var dy = 0
|
|
473
|
+
if (clientX < rect.left + MARGIN) dx = SPEED * Math.min(1, (rect.left + MARGIN - clientX) / MARGIN)
|
|
474
|
+
else if (clientX > rect.right - MARGIN) dx = -SPEED * Math.min(1, (clientX - (rect.right - MARGIN)) / MARGIN)
|
|
475
|
+
if (clientY < rect.top + MARGIN) dy = SPEED * Math.min(1, (rect.top + MARGIN - clientY) / MARGIN)
|
|
476
|
+
else if (clientY > rect.bottom - MARGIN) dy = -SPEED * Math.min(1, (clientY - (rect.bottom - MARGIN)) / MARGIN)
|
|
477
|
+
if (!dx && !dy) return
|
|
478
|
+
interactedRef.current = true
|
|
479
|
+
setView(function (prev) { return { scale: prev.scale, x: prev.x + dx, y: prev.y + dy } })
|
|
480
|
+
}
|
|
317
481
|
var viewRef = useRef(null)
|
|
482
|
+
// 2026-11-09:画布高度可上下拖拽调整(持久化;双击恢复默认)
|
|
483
|
+
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 } })
|
|
484
|
+
var customHeight = heightState[0]
|
|
485
|
+
var setCustomHeight = heightState[1]
|
|
486
|
+
// 2026-11-10:画布默认高度自动跟随内容(避免固定 34vh 导致"拓扑区域又高又空");手动拖过则以手动为准
|
|
487
|
+
var autoHState = useState(0)
|
|
488
|
+
var autoH = autoHState[0]
|
|
489
|
+
var setAutoH = autoHState[1]
|
|
490
|
+
var customHeightRef = useRef(0)
|
|
491
|
+
var resizeRef = useRef(null)
|
|
492
|
+
var resizingState = useState(false); var resizing = resizingState[0]; var setResizing = resizingState[1]
|
|
493
|
+
function onResizeDown(e) {
|
|
494
|
+
if (e.button !== 0) return
|
|
495
|
+
e.preventDefault()
|
|
496
|
+
e.stopPropagation()
|
|
497
|
+
var el = viewRef.current
|
|
498
|
+
if (!el) return
|
|
499
|
+
resizeRef.current = { sy: e.clientY, h: el.getBoundingClientRect().height }
|
|
500
|
+
interactedRef.current = true
|
|
501
|
+
setResizing(true)
|
|
502
|
+
try { e.currentTarget.setPointerCapture(e.pointerId) } catch (_) { /* ignore */ }
|
|
503
|
+
}
|
|
504
|
+
function onResizeMove(e) {
|
|
505
|
+
var r = resizeRef.current
|
|
506
|
+
if (!r) return
|
|
507
|
+
e.stopPropagation()
|
|
508
|
+
var h2 = Math.max(240, Math.min(1600, Math.round(r.h + (e.clientY - r.sy))))
|
|
509
|
+
setCustomHeight(h2)
|
|
510
|
+
try { window.localStorage.setItem('dsh-mesh-ui.viewportH.v1', String(h2)) } catch (_) { /* ignore */ }
|
|
511
|
+
}
|
|
512
|
+
function onResizeUp(e) {
|
|
513
|
+
if (!resizeRef.current) return
|
|
514
|
+
e.stopPropagation()
|
|
515
|
+
resizeRef.current = null
|
|
516
|
+
setResizing(false)
|
|
517
|
+
interactedRef.current = false
|
|
518
|
+
setTimeout(fit, 0)
|
|
519
|
+
}
|
|
520
|
+
function resetHeight() {
|
|
521
|
+
setCustomHeight(0)
|
|
522
|
+
try { window.localStorage.removeItem('dsh-mesh-ui.viewportH.v1') } catch (_) { /* ignore */ }
|
|
523
|
+
setTimeout(fit, 0)
|
|
524
|
+
}
|
|
318
525
|
var dragRef = useRef(null)
|
|
319
526
|
var interactedRef = useRef(false)
|
|
320
527
|
var s0 = useState({ scale: 1, x: 0, y: 0 }); var view = s0[0]; var setView = s0[1]
|
|
321
528
|
var s1 = useState(false); var dragging = s1[0]; var setDragging = s1[1]
|
|
529
|
+
// 2026-11-09:三通道筛选(局域网/公网/樱花)
|
|
530
|
+
var filterState = useState({ lan: true, public: true, sakura: true })
|
|
531
|
+
var showFilters = filterState[0]
|
|
532
|
+
var setShowFilters = filterState[1]
|
|
533
|
+
function toggleFilter(k) { setShowFilters(function (prev) { var n = Object.assign({}, prev); n[k] = !n[k]; return n }) }
|
|
534
|
+
// fit 用最新布局的 ref(拖拽中不重算视角;只有初始化 / 窗口变化 / ⟲ 重置 / ⤢ 自适应才重算)
|
|
535
|
+
var effRef = useRef(effLayout)
|
|
536
|
+
effRef.current = effLayout
|
|
322
537
|
|
|
323
538
|
var fit = useCallback(function () {
|
|
324
539
|
var el = viewRef.current
|
|
325
540
|
if (!el) return
|
|
326
541
|
var rect = el.getBoundingClientRect()
|
|
327
542
|
if (!rect.width || !rect.height) return
|
|
328
|
-
var
|
|
543
|
+
var cur = effRef.current || layout
|
|
544
|
+
var halfW = cur.cardW / 2
|
|
545
|
+
var halfH = cur.cardH / 2
|
|
546
|
+
var xs = []
|
|
547
|
+
var ys = []
|
|
548
|
+
;(cur.positions || []).forEach(function (p) { xs.push(p[0]); ys.push(p[1]) })
|
|
549
|
+
if (!xs.length) { xs = [cur.W / 2]; ys = [cur.H / 2] }
|
|
550
|
+
var minX = Math.min.apply(null, xs) - halfW - WORLD_PAD
|
|
551
|
+
var maxX = Math.max.apply(null, xs) + halfW + WORLD_PAD
|
|
552
|
+
var minY = Math.min.apply(null, ys) - halfH - WORLD_PAD
|
|
553
|
+
var maxY = Math.max.apply(null, ys) + halfH + WORLD_PAD
|
|
554
|
+
// 2026-11-09:中转服务器卡片(阿里云/樱花)位置由 Topology 维护,按真实包围盒纳入自适应
|
|
555
|
+
;(infraBoundsRef.current || []).forEach(function (b) {
|
|
556
|
+
if (!b) return
|
|
557
|
+
minX = Math.min(minX, b.x - b.w / 2 - WORLD_PAD)
|
|
558
|
+
maxX = Math.max(maxX, b.x + b.w / 2 + WORLD_PAD)
|
|
559
|
+
minY = Math.min(minY, b.y - b.h / 2 - WORLD_PAD)
|
|
560
|
+
maxY = Math.max(maxY, b.y + b.h / 2 + WORLD_PAD)
|
|
561
|
+
})
|
|
562
|
+
var bw = Math.max(1, maxX - minX)
|
|
563
|
+
var bh = Math.max(1, maxY - minY)
|
|
564
|
+
var scale = Math.min(rect.width / bw, rect.height / bh) * 0.94
|
|
329
565
|
scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale))
|
|
330
|
-
setView({ scale: scale, x: (rect.width -
|
|
566
|
+
setView({ scale: scale, x: (rect.width - bw * scale) / 2 - minX * scale, y: (rect.height - bh * scale) / 2 - minY * scale })
|
|
567
|
+
// 画布默认高度 = 内容渲染高度 + 余量(仅当用户没手动调过高度)
|
|
568
|
+
if (!customHeightRef.current) {
|
|
569
|
+
var wantH = Math.max(150, Math.min(380, Math.round(bh * scale + 64)))
|
|
570
|
+
setAutoH(function (prev) { return Math.abs(prev - wantH) >= 10 ? wantH : prev })
|
|
571
|
+
}
|
|
331
572
|
}, [layout.W, layout.H])
|
|
332
573
|
|
|
333
574
|
useEffect(function () {
|
|
@@ -341,6 +582,11 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
341
582
|
return function () { try { ro.disconnect() } catch (_) { /* ignore */ } }
|
|
342
583
|
}, [fit])
|
|
343
584
|
|
|
585
|
+
// 2026-11-10:布局变化(节点数/卡片尺寸)后自动 fit(用户未手动缩放/平移时),避免停留在旧的缩放比例
|
|
586
|
+
useEffect(function () {
|
|
587
|
+
if (!interactedRef.current) setTimeout(fit, 40)
|
|
588
|
+
}, [layout.count, layout.cardW, layout.cardH])
|
|
589
|
+
|
|
344
590
|
useEffect(function () {
|
|
345
591
|
var el = viewRef.current
|
|
346
592
|
if (!el) return
|
|
@@ -363,7 +609,7 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
363
609
|
|
|
364
610
|
function onPointerDown(event) {
|
|
365
611
|
if (event.button !== 0) return
|
|
366
|
-
if (event.target && event.target.closest && event.target.closest('button,a,summary')) return
|
|
612
|
+
if (event.target && event.target.closest && event.target.closest('button,a,summary,.dm-filters,.dm-zoom,.dm-resize')) return
|
|
367
613
|
dragRef.current = { x: event.clientX, y: event.clientY, vx: view.x, vy: view.y }
|
|
368
614
|
setDragging(true)
|
|
369
615
|
event.currentTarget.setPointerCapture && event.currentTarget.setPointerCapture(event.pointerId)
|
|
@@ -390,24 +636,48 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
390
636
|
})
|
|
391
637
|
}
|
|
392
638
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
639
|
+
customHeightRef.current = customHeight
|
|
640
|
+
customHeightRef.current = customHeight
|
|
641
|
+
// 2026-11-10:画布内的文字/勾选项全部移到画布外(上方工具栏 = 勾选 + 链路信息;下方 = 缩放/说明)
|
|
642
|
+
return h('div', { className: 'dm-topo-wrap' },
|
|
643
|
+
h('div', { className: 'dm-topbar' },
|
|
644
|
+
h('div', { className: 'dm-filters' },
|
|
645
|
+
[['lan', '局域网'], ['public', '阿里云'], ['sakura', '樱花穿透']].map(function (it) {
|
|
646
|
+
var tr = TRANSPORTS[it[0]]
|
|
647
|
+
return h('label', { key: it[0], className: 'dm-flt' + (showFilters[it[0]] ? ' on' : ''), style: { '--c': tr.color } },
|
|
648
|
+
h('input', { type: 'checkbox', checked: showFilters[it[0]], onChange: function () { toggleFilter(it[0]) } }),
|
|
649
|
+
h('span', null, it[1])
|
|
650
|
+
)
|
|
651
|
+
})
|
|
652
|
+
),
|
|
653
|
+
h('span', { className: 'dm-topbar-info' }, effLayout.count + ' 节点 · ' + effLayout.edges.length + ' 条链路 · 拖拽卡片自定义布局')
|
|
403
654
|
),
|
|
404
|
-
h('div', { className: 'dm-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
655
|
+
h('div', { className: 'dm-viewport' + (dragging ? ' dragging' : '') + (resizing ? ' resizing' : ''), ref: viewRef,
|
|
656
|
+
style: (customHeight || autoH) ? { height: (customHeight || autoH) + 'px' } : undefined,
|
|
657
|
+
onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: endDrag, onPointerCancel: endDrag, onPointerLeave: endDrag,
|
|
658
|
+
onDoubleClick: function () { interactedRef.current = false; fit() } },
|
|
659
|
+
// 2026-11-07:缩放改用 CSS zoom(重新布局、文字矢量重绘 → 放大不糊);
|
|
660
|
+
// 平移用外层 transform(不参与光栅缓存)。不再使用 will-change,避免整块被缓存成位图后放大发虚。
|
|
661
|
+
h('div', { className: 'dm-world-outer', style: { transform: 'translate(' + view.x + 'px,' + view.y + 'px)' } },
|
|
662
|
+
h('div', { className: 'dm-world', style: Object.assign({ width: effLayout.W + 'px', height: effLayout.H + 'px' },
|
|
663
|
+
SUPPORTS_ZOOM ? { zoom: view.scale } : { transform: 'scale(' + view.scale + ')' }) },
|
|
664
|
+
h(Topology, { data: props.data, layout: effLayout, selectedKey: props.selectedKey, onSelect: props.onSelect, scale: view.scale, onNodeMove: moveNode, onDragEdge: autoPanAt, onCardSize: onCardSize, show: showFilters, onInfraBounds: onInfraBounds, resetNonce: resetNonce })
|
|
665
|
+
)
|
|
666
|
+
),
|
|
667
|
+
h('div', { className: 'dm-zoom' },
|
|
668
|
+
h('button', { title: '放大', onClick: function () { zoomBy(1.22) } }, '+'),
|
|
669
|
+
h('button', { title: '缩小', onClick: function () { zoomBy(1 / 1.22) } }, '−'),
|
|
670
|
+
h('button', { title: '自适应', onClick: function () { interactedRef.current = false; fit() } }, '⤢'),
|
|
671
|
+
h('button', { className: 'dm-layout-btn', title: '重置为默认拓扑布局', onClick: resetLayout }, '↺'),
|
|
672
|
+
h('span', { className: 'pct' }, Math.round(view.scale * 100) + '%')
|
|
673
|
+
),
|
|
674
|
+
h('div', { className: 'dm-resize', title: '上下拖拽调整画布高度(双击恢复默认)',
|
|
675
|
+
onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onPointerCancel: onResizeUp,
|
|
676
|
+
onDoubleClick: resetHeight },
|
|
677
|
+
h('span', null, '⋯')
|
|
678
|
+
)
|
|
409
679
|
),
|
|
410
|
-
h('div', { className: 'dm-hint' }, '
|
|
680
|
+
h('div', { className: 'dm-hint' }, '拓扑 v17 · 内容 ' + Math.round(layout.W) + '×' + Math.round(layout.H) + 'px · 卡 ' + Math.round(layout.cardW) + '×' + Math.round(layout.cardH) + ' · 缩放 ' + Math.round(view.scale * 100) + '% · 滚轮缩放 / 拖拽平移')
|
|
411
681
|
)
|
|
412
682
|
}
|
|
413
683
|
|
|
@@ -415,65 +685,59 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
415
685
|
// 用户偏好(2026-11-07 明确):
|
|
416
686
|
// 4 节点 = 菱形(上/右/下/左);5 节点 = 顶点朝上的五边形;6 = 六边形;n 节点 = 顶点朝上的正 n 边形。
|
|
417
687
|
// 相邻节点的「轴对齐包围盒」必须留出 GAP,否则菱形斜向两张卡会贴住/重叠。
|
|
418
|
-
|
|
688
|
+
// 2026-11-09-v6:默认布局 = 顶部「阿里云 / 樱花」服务器一排 + 设备沿下方弧线扇形展开(用户指定)
|
|
689
|
+
function computeLayout(count, override) {
|
|
419
690
|
var GAP = 30
|
|
420
|
-
var cardW = count <= 4 ?
|
|
421
|
-
var cardH = count <= 4 ?
|
|
691
|
+
var cardW = count <= 4 ? 210 : count <= 6 ? 196 : 176
|
|
692
|
+
var cardH = count <= 4 ? 112 : count <= 6 ? 104 : 96
|
|
693
|
+
if (override && override.cardW > 0) cardW = Math.ceil(override.cardW)
|
|
694
|
+
if (override && override.cardH > 0) cardH = Math.ceil(override.cardH)
|
|
422
695
|
var PAD = 16
|
|
423
|
-
var
|
|
424
|
-
var H
|
|
425
|
-
var positions
|
|
696
|
+
var topTierH = Math.round(cardH * 1.1)
|
|
697
|
+
var W, H, positions
|
|
426
698
|
if (count <= 1) {
|
|
427
699
|
W = cardW + PAD * 2
|
|
428
|
-
H = cardH + PAD * 2
|
|
429
|
-
positions = [[W / 2,
|
|
700
|
+
H = topTierH + cardH + PAD * 2
|
|
701
|
+
positions = [[W / 2, topTierH + cardH / 2 + PAD]]
|
|
430
702
|
} else if (count === 2) {
|
|
431
703
|
W = cardW * 2 + GAP + PAD * 2
|
|
432
|
-
H = cardH + PAD * 2
|
|
433
|
-
positions = [[PAD + cardW / 2,
|
|
434
|
-
} else if (count === 4) {
|
|
435
|
-
// 菱形:rx - cardW = ry - cardH = GAP → 水平/垂直相邻间隙都正好 GAP
|
|
436
|
-
var rx4 = cardW + GAP
|
|
437
|
-
var ry4 = cardH + GAP
|
|
438
|
-
W = Math.ceil(2 * (rx4 + cardW / 2) + PAD * 2)
|
|
439
|
-
H = Math.ceil(2 * (ry4 + cardH / 2) + PAD * 2)
|
|
440
|
-
positions = []
|
|
441
|
-
for (var i4 = 0; i4 < 4; i4++) {
|
|
442
|
-
var a4 = (-90 + i4 * 90) * Math.PI / 180 // -90, 0, 90, 180 → 上 右 下 左
|
|
443
|
-
positions.push([W / 2 + rx4 * Math.cos(a4), H / 2 + ry4 * Math.sin(a4)])
|
|
444
|
-
}
|
|
704
|
+
H = topTierH + cardH + PAD * 2
|
|
705
|
+
positions = [[PAD + cardW / 2, topTierH + cardH / 2 + PAD], [PAD + cardW * 1.5 + GAP, topTierH + cardH / 2 + PAD]]
|
|
445
706
|
} else {
|
|
446
|
-
//
|
|
447
|
-
var
|
|
448
|
-
var
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
pts.push([R * Math.cos(ap), R * Math.sin(ap)])
|
|
454
|
-
}
|
|
707
|
+
// 2026-11-09-v7:浅扁椭圆弧扇形(横向铺开、纵向贴近顶部服务器,压缩中间空白)
|
|
708
|
+
var START = 150 * Math.PI / 180
|
|
709
|
+
var END = 30 * Math.PI / 180
|
|
710
|
+
var stepA = (START - END) / (count - 1)
|
|
711
|
+
var MARGIN = 22
|
|
712
|
+
var A = (cardW + MARGIN) / (2 * Math.sin(stepA / 2))
|
|
713
|
+
for (var step = 0; step < 400; step++) {
|
|
455
714
|
var ok = true
|
|
456
|
-
for (var
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
715
|
+
for (var i = 0; i < count - 1 && ok; i++) {
|
|
716
|
+
var a1 = START - i * stepA
|
|
717
|
+
var a2 = START - (i + 1) * stepA
|
|
718
|
+
var dx = Math.abs(A * Math.cos(a1) - A * Math.cos(a2))
|
|
719
|
+
var dy = Math.abs(0.35 * A * Math.sin(a1) - 0.35 * A * Math.sin(a2))
|
|
720
|
+
if (dx < cardW + MARGIN * 0.5 && dy < cardH + MARGIN * 0.5) ok = false
|
|
462
721
|
}
|
|
463
722
|
if (ok) break
|
|
464
|
-
|
|
723
|
+
A += 4
|
|
465
724
|
}
|
|
466
|
-
|
|
467
|
-
|
|
725
|
+
var B = A * 0.35
|
|
726
|
+
var halfW = A * Math.cos(END) + cardW / 2
|
|
727
|
+
W = Math.ceil(2 * halfW + PAD * 2)
|
|
728
|
+
var cx = W / 2
|
|
729
|
+
var cy = topTierH + PAD
|
|
730
|
+
H = Math.ceil(cy + B + cardH / 2 + PAD)
|
|
468
731
|
positions = []
|
|
469
732
|
for (var j = 0; j < count; j++) {
|
|
470
|
-
var aj =
|
|
471
|
-
positions.push([
|
|
733
|
+
var aj = START - j * stepA
|
|
734
|
+
positions.push([cx + A * Math.cos(aj), cy + B * Math.sin(aj)])
|
|
472
735
|
}
|
|
473
736
|
}
|
|
474
737
|
var edges = []
|
|
475
738
|
for (var m = 0; m < count; m++) for (var n = m + 1; n < count; n++) edges.push([m, n])
|
|
476
|
-
|
|
739
|
+
var topDeviceY = positions.length ? Math.min.apply(null, positions.map(function (p) { return p[1] })) : 0
|
|
740
|
+
return { count: count, cardW: cardW, cardH: cardH, gap: GAP, W: W, H: H, positions: positions, edges: edges, topDeviceY: topDeviceY }
|
|
477
741
|
}
|
|
478
742
|
// ---------- 架构层:公网中转站(frps)与各节点通信关系(2026-11-09) ----------
|
|
479
743
|
function RelayLayer(props) {
|
|
@@ -484,7 +748,9 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
484
748
|
var direct = peers.filter(function (p) { var t = p.tunnel || {}; return t.kind === 'lan' || t.kind === 'loopback' })
|
|
485
749
|
var instances = (data.local && data.local.instances) || []
|
|
486
750
|
var localUp = instances.filter(function (x) { return x.up }).length
|
|
487
|
-
|
|
751
|
+
// 2026-11-09:樱花账号隧道(PC 端 API 采集,跨设备含手机)
|
|
752
|
+
var sakuraAccount = (data.tunnels && data.tunnels.sakuraAccount) || null
|
|
753
|
+
if (!groups.length && !direct.length && !sakuraAccount) return null
|
|
488
754
|
return h('div', { className: 'dm-relay' },
|
|
489
755
|
h('div', { className: 'dm-relay-head' },
|
|
490
756
|
h('span', { className: 'dm-relay-title' }, '架构层 · 公网中转站'),
|
|
@@ -494,7 +760,7 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
494
760
|
var ports = Object.keys(g.ports).sort()
|
|
495
761
|
return h('div', { className: 'dm-relay-row', key: 'g' + gi },
|
|
496
762
|
h('div', { className: 'dm-relay-end' },
|
|
497
|
-
h('div', { className: 'dm-relay-name' },
|
|
763
|
+
h('div', { className: 'dm-relay-name' }, nodeDisplay(data.nodeName) + '(本机)'),
|
|
498
764
|
h('div', { className: 'dm-relay-sub' }, 'DSH ' + localUp + '/' + instances.length + ' 在线')
|
|
499
765
|
),
|
|
500
766
|
h('div', { className: 'dm-relay-link' }, h('span', { className: 'dm-relay-line' }), h('span', { className: 'dm-relay-dirlabel' }, '出站')),
|
|
@@ -511,26 +777,194 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
511
777
|
var t = peer.tunnel || {}
|
|
512
778
|
return h('div', { className: 'dm-relay-peer' + (peer.up ? '' : ' down'), key: peer.name },
|
|
513
779
|
h('span', { className: 'dm-dot ' + (peer.up ? 'ok' : 'bad') }),
|
|
514
|
-
h('span', { className: 'dm-relay-peer-name' },
|
|
780
|
+
h('span', { className: 'dm-relay-peer-name' }, nodeDisplay(peerDeviceKey(peer.name))),
|
|
515
781
|
h('span', { className: 'dm-relay-peer-meta' }, (t.port ? ':' + t.port : '') + (peer.up && peer.latencyMs != null ? ' · ' + peer.latencyMs + 'ms' : ' · 离线'))
|
|
516
782
|
)
|
|
517
783
|
}))
|
|
518
784
|
)
|
|
519
785
|
}),
|
|
786
|
+
sakuraAccount && sakuraAccount.available ? h('div', { className: 'dm-relay-direct' },
|
|
787
|
+
h('span', { className: 'dm-k' }, '樱花隧道:'),
|
|
788
|
+
h('span', { className: 'dm-chip ' + (sakuraAccount.online === sakuraAccount.total ? 'run' : 'bad') }, sakuraAccount.online + '/' + sakuraAccount.total + ' 在线'),
|
|
789
|
+
(sakuraAccount.tunnels || []).map(function (t) {
|
|
790
|
+
return h('span', { className: 'dm-chip ' + (t.online ? 'run' : 'bad'), key: 'st' + t.id, title: (t.nodeName || '') + ' ' + t.addr + (t.online ? '' : '(离线)') },
|
|
791
|
+
(t.deviceKey || t.name) + ' ' + t.addr)
|
|
792
|
+
})
|
|
793
|
+
) : null,
|
|
520
794
|
direct.length ? h('div', { className: 'dm-relay-direct' },
|
|
521
795
|
h('span', { className: 'dm-k' }, '直连:'),
|
|
522
796
|
direct.map(function (peer) {
|
|
523
797
|
var c = commLabel(peer, relayHosts)
|
|
524
|
-
return h('span', { className: 'dm-chip ' + (peer.up ? 'run' : 'bad'), key: peer.name, title: c.text },
|
|
798
|
+
return h('span', { className: 'dm-chip ' + (peer.up ? 'run' : 'bad'), key: peer.name, title: c.text }, nodeDisplay(peerDeviceKey(peer.name)) + ' · ' + c.text)
|
|
525
799
|
})
|
|
526
800
|
) : null
|
|
527
801
|
)
|
|
528
802
|
}
|
|
529
803
|
|
|
530
|
-
//
|
|
804
|
+
// ===== 2026-11-09 传输层封装:局域网 / 阿里云(公网) / 樱花穿透 三通道 =====
|
|
805
|
+
var TRANSPORTS = {
|
|
806
|
+
lan: { label: '局域网直连', short: 'LAN', color: '#2e9e4f', dash: false },
|
|
807
|
+
public: { label: '公网服务器', short: 'PUB', color: '#4d9bff', dash: true },
|
|
808
|
+
sakura: { label: '樱花穿透', short: 'SAK', color: '#a371f7', dash: true }
|
|
809
|
+
}
|
|
810
|
+
var DOWN_COLOR = "rgba(209,75,65,.7)"
|
|
811
|
+
// 各机 DSH 安装形态(实测/档案)
|
|
812
|
+
var DEVICE_DSH = {}
|
|
813
|
+
function dshTypesFor(devKey) { return DEVICE_DSH[peerDeviceKey(devKey)] || [] }
|
|
814
|
+
function isLanPeer(peer) {
|
|
815
|
+
var t = (peer && peer.tunnel) || {}
|
|
816
|
+
var name = String((peer && peer.name) || '')
|
|
817
|
+
var host = String(t.host || '')
|
|
818
|
+
if (/-lan$/i.test(name)) return true
|
|
819
|
+
if (t.kind === 'lan' || t.kind === 'loopback') return true
|
|
820
|
+
if (/^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(host)) return true
|
|
821
|
+
return false
|
|
822
|
+
}
|
|
823
|
+
function sakuraAccountOf(data) {
|
|
824
|
+
var acc = data && data.tunnels && data.tunnels.sakuraAccount
|
|
825
|
+
return acc && acc.available === true ? acc : null
|
|
826
|
+
}
|
|
827
|
+
function sakuraTunnelOf(data, devKey) {
|
|
828
|
+
var acc = sakuraAccountOf(data)
|
|
829
|
+
if (!acc) return null
|
|
830
|
+
var k = peerDeviceKey(devKey)
|
|
831
|
+
for (var i = 0; i < (acc.tunnels || []).length; i++) if (acc.tunnels[i].deviceKey === k) return acc.tunnels[i]
|
|
832
|
+
return null
|
|
833
|
+
}
|
|
834
|
+
// 设备状态 = 任一链路存活(全断才离线/红)
|
|
835
|
+
function deviceLinkState(data, peer, lanMap) {
|
|
836
|
+
var dk = peerDeviceKey(peer.name)
|
|
837
|
+
var t = peer.tunnel || {}
|
|
838
|
+
var publicUp = Boolean(peer.up)
|
|
839
|
+
var lan = lanMap ? lanMap[dk] : (t.kind === 'lan' ? { up: Boolean(peer.up), host: t.host, port: t.port, ip: t.ip || '' } : null)
|
|
840
|
+
var lanUp = Boolean(lan && lan.up)
|
|
841
|
+
var st = sakuraTunnelOf(data, dk)
|
|
842
|
+
var sakuraUp = st ? st.online === true : null
|
|
843
|
+
var anyUp = publicUp || sakuraUp === true || lanUp
|
|
844
|
+
return { devKey: dk, publicUp: publicUp, lanUp: lanUp, lanTunnel: lan, sakuraUp: sakuraUp, sakuraTunnel: st, anyUp: anyUp }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ---------- 2026-11-09 画布下方:中转服务器情况(阿里云 / 樱花) ----------
|
|
848
|
+
function InfraPanels(props) {
|
|
849
|
+
var data = props.data || {}
|
|
850
|
+
var acc = sakuraAccountOf(data)
|
|
851
|
+
var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
|
|
852
|
+
// 2026-11-10:本机自身也有一条阿里云入口(publicEntry/selfHosted 的 7012),之前漏掉了
|
|
853
|
+
var lt = data.localTunnel || {}
|
|
854
|
+
var selfHost = lt.selfHosted || {}
|
|
855
|
+
var entry = lt.publicEntry || selfHost || {}
|
|
856
|
+
var localPort = entry.port || selfHost.port || null
|
|
857
|
+
var localHost = entry.host || selfHost.host || "relay.example.com"
|
|
858
|
+
var localKey = peerDeviceKey(data.nodeName)
|
|
859
|
+
var devs = [{ devKey: localKey, name: nodeDisplay(data.nodeName) + "(本机)", up: true, port: localPort, host: localHost }].concat(peers.filter(function (p) { return !isLanPeer(p) }).map(function (p) {
|
|
860
|
+
var tu = p.tunnel || {}
|
|
861
|
+
return { devKey: peerDeviceKey(p.name), name: nodeDisplay(peerDeviceKey(p.name)), up: Boolean(p.up), port: tu.port || null, host: tu.host || 'relay.example.com' }
|
|
862
|
+
}))
|
|
863
|
+
var aliyunRows = devs.filter(function (d) { return d.port }).map(function (d) {
|
|
864
|
+
return h('div', { key: d.devKey, className: 'dm-device-row' },
|
|
865
|
+
h('span', { className: 'dm-device-k' }, d.name),
|
|
866
|
+
h('span', { className: 'dm-device-v' }, (d.up ? '✅ ' : '❌ ') + (d.host || 'relay.example.com') + ':' + d.port)
|
|
867
|
+
)
|
|
868
|
+
})
|
|
869
|
+
var tunnelRows = acc ? (acc.tunnels || []).map(function (x, i) {
|
|
870
|
+
return h('div', { key: 'st' + i, className: 'dm-device-row' },
|
|
871
|
+
h('span', { className: 'dm-device-k' }, (x.deviceKey ? nodeDisplay(peerDeviceKey(x.deviceKey)) : (x.name || '未归类'))),
|
|
872
|
+
h('span', { className: 'dm-device-v' }, (x.online ? '✅ ' : '❌ ') + (x.addr || ''))
|
|
873
|
+
)
|
|
874
|
+
}) : null
|
|
875
|
+
if (!aliyunRows.length && !tunnelRows) return null
|
|
876
|
+
return h('div', { className: 'dm-infra-grid' },
|
|
877
|
+
h('div', { className: 'dm-infra-card' },
|
|
878
|
+
h('div', { className: 'dm-infra-head' },
|
|
879
|
+
h('span', { className: 'dm-dot ok', style: { background: TRANSPORTS.public.color } }),
|
|
880
|
+
h('span', null, '阿里云服务器 · ECS'),
|
|
881
|
+
h('span', { className: 'dm-sub', style: { marginLeft: 'auto' } }, '自建 frps')
|
|
882
|
+
),
|
|
883
|
+
h('div', { className: 'dm-sub dm-mono', style: { marginBottom: 4 } }, 'relay.example.com · 隧道 ' + (devs.filter(function (d) { return d.port }).map(function (d) { return d.port }).sort().join(' / ') || '—')),
|
|
884
|
+
aliyunRows
|
|
885
|
+
),
|
|
886
|
+
acc ? h('div', { className: 'dm-infra-card' },
|
|
887
|
+
h('div', { className: 'dm-infra-head' },
|
|
888
|
+
h('span', { className: 'dm-dot', style: { background: TRANSPORTS.sakura.color } }),
|
|
889
|
+
h('span', null, '樱花穿透服务器'),
|
|
890
|
+
h('span', { className: 'dm-sub', style: { marginLeft: 'auto' } }, acc.online + '/' + acc.total + ' 在线')
|
|
891
|
+
),
|
|
892
|
+
h('div', { className: 'dm-sub dm-mono', style: { marginBottom: 4 } }, 'relay.example.com / relay.example.com / relay.example.com'),
|
|
893
|
+
tunnelRows
|
|
894
|
+
) : null
|
|
895
|
+
)
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ---------- 拓扑:极简卡片(名称 / 穿透域名 / DSH 数)+ 点击选中 ----------
|
|
531
899
|
function Topology(props) {
|
|
532
900
|
var data = props.data
|
|
533
901
|
var layout = props.layout
|
|
902
|
+
// 2026-11-09-v5:卡片宽度自适应 —— 每次渲染后测量真实尺寸,回传父组件重算布局;
|
|
903
|
+
// 同时把每张卡的实际尺寸给连线用(不同卡片宽窄不同,连线端点贴各自边缘)。
|
|
904
|
+
var cardRefs = useRef({})
|
|
905
|
+
var cardSizeState = useState({})
|
|
906
|
+
var cardSizes = cardSizeState[0]
|
|
907
|
+
var setCardSizes = cardSizeState[1]
|
|
908
|
+
useEffect(function () {
|
|
909
|
+
var next = {}
|
|
910
|
+
var maxW = 0
|
|
911
|
+
var maxH = 0
|
|
912
|
+
var changed = false
|
|
913
|
+
Object.keys(cardRefs.current).forEach(function (key) {
|
|
914
|
+
var el = cardRefs.current[key]
|
|
915
|
+
if (!el) return
|
|
916
|
+
var w = el.offsetWidth
|
|
917
|
+
var h = el.offsetHeight
|
|
918
|
+
if (!w || !h) return
|
|
919
|
+
next[key] = { w: w, h: h }
|
|
920
|
+
if (w > maxW) maxW = w
|
|
921
|
+
if (h > maxH) maxH = h
|
|
922
|
+
var old = cardSizes[key]
|
|
923
|
+
if (!old || Math.abs(old.w - w) > 1 || Math.abs(old.h - h) > 1) changed = true
|
|
924
|
+
})
|
|
925
|
+
if (!maxW) return
|
|
926
|
+
if (changed) setCardSizes(next)
|
|
927
|
+
if (props.onCardSize) props.onCardSize(maxW, maxH)
|
|
928
|
+
})
|
|
929
|
+
// 2026-11-09 卡片拖拽:在世界坐标里移动;连线由 layout.positions 派生,移动即自动跟随。
|
|
930
|
+
var dragState = useState(null)
|
|
931
|
+
var dragKey = dragState[0]
|
|
932
|
+
var setDragKey = dragState[1]
|
|
933
|
+
var dragRef = useRef(null)
|
|
934
|
+
function cardPointerDown(node, pos, event) {
|
|
935
|
+
if (event.button !== 0) return
|
|
936
|
+
event.stopPropagation()
|
|
937
|
+
dragRef.current = { key: node.key, sx: event.clientX, sy: event.clientY, ox: pos[0], oy: pos[1], moved: false }
|
|
938
|
+
setDragKey(node.key)
|
|
939
|
+
try { event.currentTarget.setPointerCapture(event.pointerId) } catch (_) { /* ignore */ }
|
|
940
|
+
}
|
|
941
|
+
function cardPointerMove(node, event) {
|
|
942
|
+
var drag = dragRef.current
|
|
943
|
+
if (!drag || drag.key !== node.key) return
|
|
944
|
+
event.stopPropagation()
|
|
945
|
+
var scale = props.scale || 1
|
|
946
|
+
var dx = (event.clientX - drag.sx) / scale
|
|
947
|
+
var dy = (event.clientY - drag.sy) / scale
|
|
948
|
+
if (!drag.moved && (Math.abs(dx) + Math.abs(dy)) > 3) drag.moved = true
|
|
949
|
+
if (!drag.moved) return
|
|
950
|
+
if (String(node.key).indexOf('server-') === 0) { infraMove(node.key, [drag.ox + dx, drag.oy + dy]); return }
|
|
951
|
+
// 2026-11-09-v3:不再限制在画布边界内(用户反馈左侧有「空气墙」);
|
|
952
|
+
// 坐标允许任意值,.dm-world/.dm-lines 均 overflow:visible,配合拖到边缘自动平移可无限拖。
|
|
953
|
+
var nx = drag.ox + dx
|
|
954
|
+
var ny = drag.oy + dy
|
|
955
|
+
if (props.onNodeMove) props.onNodeMove(node.key, [nx, ny])
|
|
956
|
+
if (props.onDragEdge) props.onDragEdge(event.clientX, event.clientY)
|
|
957
|
+
}
|
|
958
|
+
function cardPointerUp(node, event) {
|
|
959
|
+
var drag = dragRef.current
|
|
960
|
+
if (!drag || drag.key !== node.key) return
|
|
961
|
+
event.stopPropagation()
|
|
962
|
+
var wasDrag = drag.moved
|
|
963
|
+
dragRef.current = null
|
|
964
|
+
setDragKey(null)
|
|
965
|
+
if (!wasDrag && props.onSelect) props.onSelect(node.key)
|
|
966
|
+
}
|
|
967
|
+
function cardPointerCancel() { dragRef.current = null; setDragKey(null) }
|
|
534
968
|
var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
|
|
535
969
|
var relayHosts = relayHostSet(peers)
|
|
536
970
|
var endpoint = data.endpoint || {}
|
|
@@ -544,7 +978,11 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
544
978
|
|
|
545
979
|
function localDomain() {
|
|
546
980
|
var entry = localTunnel.publicEntry
|
|
547
|
-
|
|
981
|
+
var src = localTunnel.mainSource || (entry && entry.source === 'self-hosted-frps' ? 'self-hosted' : '')
|
|
982
|
+
if (entry && entry.host) {
|
|
983
|
+
var tag = src === 'self-hosted' ? '(自建 frps)' : (src === 'sakura' ? '(Sakura 备用)' : '')
|
|
984
|
+
return entry.host + ':' + entry.port + tag
|
|
985
|
+
}
|
|
548
986
|
if (localTunnel.frpRunning) return 'SakuraFrp 已开 · ' + (localTunnel.bind || '?') + ':' + localTunnel.port
|
|
549
987
|
if (localTunnel.port) return '未开穿透 · ' + (localTunnel.bind || '?') + ':' + localTunnel.port
|
|
550
988
|
return '未配置'
|
|
@@ -555,104 +993,233 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
555
993
|
return t.host + (t.port ? ':' + t.port : '') + (t.kind === 'lan' ? '(内网)' : '')
|
|
556
994
|
}
|
|
557
995
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
996
|
+
// LAN peer 单独收集(dedupe 后可能被更快的 frp 记录覆盖,但 LAN 线仍要画)
|
|
997
|
+
var lanMap = {}
|
|
998
|
+
filterSelfPeers(data.peers || [], data.nodeName).forEach(function (p) {
|
|
999
|
+
var t = p.tunnel || {}
|
|
1000
|
+
if (isLanPeer(p)) lanMap[peerDeviceKey(p.name)] = { up: Boolean(p.up), host: t.host, port: t.port, ip: t.ip || '' }
|
|
1001
|
+
})
|
|
1002
|
+
var nodes = [{
|
|
1003
|
+
key: 'local',
|
|
1004
|
+
local: true,
|
|
1005
|
+
devKey: peerDeviceKey(data.nodeName),
|
|
1006
|
+
up: true,
|
|
1007
|
+
name: nodeDisplay(data.nodeName),
|
|
1008
|
+
dsh: localUp + '/' + instances.length,
|
|
1009
|
+
ports: instances.map(function (inst) { return { port: inst.port, up: inst.up } }),
|
|
1010
|
+
extra: ''
|
|
1011
|
+
}].concat(peers.map(function (peer) {
|
|
1012
|
+
var ls = deviceLinkState(data, peer, lanMap)
|
|
1013
|
+
return {
|
|
1014
|
+
key: 'peer:' + peer.name,
|
|
1015
|
+
local: false,
|
|
1016
|
+
devKey: ls.devKey,
|
|
1017
|
+
up: ls.anyUp,
|
|
1018
|
+
publicUp: ls.publicUp,
|
|
1019
|
+
sakuraUp: ls.sakuraUp,
|
|
1020
|
+
sakuraTunnel: ls.sakuraTunnel,
|
|
1021
|
+
lanUp: ls.lanUp,
|
|
1022
|
+
lanTunnel: ls.lanTunnel,
|
|
1023
|
+
name: nodeDisplay(ls.devKey),
|
|
1024
|
+
dsh: String(peer.instancesCount || 0),
|
|
1025
|
+
ports: peer.instancesList || [],
|
|
1026
|
+
latencyMs: peer.latencyMs,
|
|
1027
|
+
tunnel: peer.tunnel || {},
|
|
1028
|
+
error: peer.error || ''
|
|
1029
|
+
}
|
|
1030
|
+
}))
|
|
1031
|
+
// 2026-11-10:按共用顺序排序(与 Viewport 拖拽位置表一致,修复"拖 A 动 B"的 bug)
|
|
1032
|
+
var orderKeys = orderedNodeKeys(data)
|
|
1033
|
+
nodes.sort(function (a, b) { return orderKeys.indexOf(a.key) - orderKeys.indexOf(b.key) })
|
|
1034
|
+
// 基础设施节点(中转服务器):阿里云 / 樱花
|
|
1035
|
+
var sakuraAcc = sakuraAccountOf(data)
|
|
1036
|
+
var aliyunPorts = peers.map(function (p) { return isLanPeer(p) ? null : (p.tunnel || {}).port }).filter(Boolean)
|
|
1037
|
+
var infra = [
|
|
1038
|
+
{ key: 'server-aliyun', transport: 'public', name: '阿里云服务器 · ECS', host: 'relay.example.com', color: TRANSPORTS.public.color, detail: '隧道 ' + (aliyunPorts.length ? aliyunPorts.sort().join('/') : '—'), up: true },
|
|
1039
|
+
{ key: 'server-sakura', transport: 'sakura', name: '樱花穿透服务器', host: 'relay.example.com / relay.example.com / relay.example.com', color: TRANSPORTS.sakura.color, detail: sakuraAcc ? (sakuraAcc.online + '/' + sakuraAcc.total + ' 在线') : '未采集', up: Boolean(sakuraAcc) },
|
|
1040
|
+
]
|
|
1041
|
+
// 2026-11-09-v8:服务器默认位置贴住扇形最高一排设备(用户要求缩小服务器↔设备的垂直间隔)
|
|
1042
|
+
function infraDefaultPos(idx) {
|
|
1043
|
+
var dx = layout.cardW * 1.08
|
|
1044
|
+
var ox = layout.baseOffsetX || 0
|
|
1045
|
+
var oy = layout.baseOffsetY || 0
|
|
1046
|
+
var cx = ox + (layout.contentW || layout.W) / 2
|
|
1047
|
+
var topYAbs = oy + (layout.topDeviceY || 0)
|
|
1048
|
+
return [Math.round(cx + (idx === 0 ? -dx : dx)), Math.round(topYAbs - layout.cardH * 1.05)]
|
|
1049
|
+
}
|
|
1050
|
+
var infraState = useState(function () { try { return JSON.parse(window.localStorage.getItem('dsh-mesh-ui.infra.v2') || '{}') || {} } catch (e) { return {} } })
|
|
1051
|
+
var infraPos = infraState[0]
|
|
1052
|
+
var setInfraPos = infraState[1]
|
|
1053
|
+
function infraPosition(idx) { var k = infra[idx].key; var v = infraPos[k]; if (v && v.length === 2) return v; var def = DEFAULT_INFRA_POS[infra[idx].transport === 'public' ? 'aliyun' : 'sakura']; if (def) return [def[0], def[1]]; return infraDefaultPos(idx) }
|
|
1054
|
+
useEffect(function () {
|
|
1055
|
+
if (!props.resetNonce) return
|
|
1056
|
+
try { window.localStorage.removeItem('dsh-mesh-ui.infra.v2') } catch (e) { /* ignore */ }
|
|
1057
|
+
setInfraPos({})
|
|
1058
|
+
}, [props.resetNonce])
|
|
1059
|
+
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 }) }
|
|
583
1060
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
var GAP_EDGE = 6
|
|
587
|
-
function edgePoint(from, to) {
|
|
1061
|
+
var GAP_EDGE = 6
|
|
1062
|
+
function edgePoint(from, to, hw, hh) {
|
|
588
1063
|
var dx = to[0] - from[0]
|
|
589
1064
|
var dy = to[1] - from[1]
|
|
590
1065
|
var len = Math.sqrt(dx * dx + dy * dy) || 1
|
|
591
1066
|
var ux = dx / len
|
|
592
1067
|
var uy = dy / len
|
|
593
|
-
var tx = ux === 0 ? Infinity : (
|
|
594
|
-
var ty = uy === 0 ? Infinity : (
|
|
1068
|
+
var tx = ux === 0 ? Infinity : (hw + GAP_EDGE) / Math.abs(ux)
|
|
1069
|
+
var ty = uy === 0 ? Infinity : (hh + GAP_EDGE) / Math.abs(uy)
|
|
595
1070
|
var t = Math.min(tx, ty)
|
|
596
1071
|
return [from[0] + ux * t, from[1] + uy * t]
|
|
597
1072
|
}
|
|
598
1073
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1074
|
+
// 2026-11-09:三通道连线(设备→阿里云 / 设备→樱花 / 局域网直连)+ 中转服务器卡片 + 筛选
|
|
1075
|
+
var show = props.show || { lan: true, public: true, sakura: true }
|
|
1076
|
+
// 2026-11-09:悬浮节点 → 只高亮它的连线并显示完整地址(其它线变淡)
|
|
1077
|
+
var hoverState = useState(null)
|
|
1078
|
+
var hoverKey = hoverState[0]
|
|
1079
|
+
var setHoverKey = hoverState[1]
|
|
1080
|
+
function edgeRelated(aKey, bKey) { return hoverKey ? (hoverKey === aKey || hoverKey === bKey) : true }
|
|
1081
|
+
var infraRefs = useRef({})
|
|
1082
|
+
var infraSizeState = useState({})
|
|
1083
|
+
var infraSizes = infraSizeState[0]
|
|
1084
|
+
var setInfraSizes = infraSizeState[1]
|
|
1085
|
+
useEffect(function () {
|
|
1086
|
+
var next = {}
|
|
1087
|
+
var changed = false
|
|
1088
|
+
Object.keys(infraRefs.current).forEach(function (key) {
|
|
1089
|
+
var el = infraRefs.current[key]
|
|
1090
|
+
if (!el) return
|
|
1091
|
+
var w = el.offsetWidth
|
|
1092
|
+
var h2 = el.offsetHeight
|
|
1093
|
+
if (!w || !h2) return
|
|
1094
|
+
next[key] = { w: w, h: h2 }
|
|
1095
|
+
var old = infraSizes[key]
|
|
1096
|
+
if (!old || Math.abs(old.w - w) > 1 || Math.abs(old.h - h2) > 1) changed = true
|
|
1097
|
+
})
|
|
1098
|
+
if (!Object.keys(next).length) return
|
|
1099
|
+
if (changed) setInfraSizes(next)
|
|
1100
|
+
})
|
|
1101
|
+
function sizeOf(key) { return cardSizes[key] || infraSizes[key] || { w: layout.cardW, h: layout.cardH } }
|
|
1102
|
+
function curve(key, from, to, up, transport, label, fromSize, toSize, highlight) {
|
|
1103
|
+
var a = edgePoint(from, to, fromSize.w / 2, fromSize.h / 2)
|
|
1104
|
+
var b = edgePoint(to, from, toSize.w / 2, toSize.h / 2)
|
|
1105
|
+
var mx = (a[0] + b[0]) / 2
|
|
1106
|
+
var my = (a[1] + b[1]) / 2
|
|
1107
|
+
var dx = b[0] - a[0]
|
|
1108
|
+
var dy = b[1] - a[1]
|
|
1109
|
+
var len = Math.sqrt(dx * dx + dy * dy) || 1
|
|
1110
|
+
var bow = Math.min(12, len * 0.07)
|
|
1111
|
+
var color = up ? transport.color : DOWN_COLOR
|
|
1112
|
+
var dash = up ? (transport.dash ? '6 5' : 'none') : '6 5'
|
|
1113
|
+
var related = highlight !== false
|
|
1114
|
+
var els = [h('path', {
|
|
1115
|
+
key: key,
|
|
1116
|
+
d: 'M ' + a[0] + ' ' + a[1] + ' Q ' + (mx + (-dy / len) * bow) + ' ' + (my + (dx / len) * bow) + ' ' + b[0] + ' ' + b[1],
|
|
1117
|
+
fill: 'none', stroke: color, strokeWidth: 1.8, strokeDasharray: dash,
|
|
1118
|
+
className: up ? (transport.dash ? 'dm-edge-frp' : 'dm-edge-solid') : 'dm-edge-down',
|
|
1119
|
+
opacity: related ? 1 : 0.12
|
|
1120
|
+
})]
|
|
1121
|
+
if (label) {
|
|
1122
|
+
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))
|
|
1123
|
+
}
|
|
1124
|
+
return els
|
|
1125
|
+
}
|
|
1126
|
+
var svgEls = []
|
|
1127
|
+
var aliyunPos = infraPosition(0)
|
|
1128
|
+
var sakuraPos = infraPosition(1)
|
|
1129
|
+
var aliyunSize = infraSizes['server-aliyun'] || { w: 300, h: 90 }
|
|
1130
|
+
var sakuraSize = infraSizes['server-sakura'] || { w: 300, h: 90 }
|
|
1131
|
+
var infraBoxes = []
|
|
1132
|
+
if (show.public) infraBoxes.push({ x: aliyunPos[0], y: aliyunPos[1], w: aliyunSize.w, h: aliyunSize.h })
|
|
1133
|
+
if (show.sakura) infraBoxes.push({ x: sakuraPos[0], y: sakuraPos[1], w: sakuraSize.w, h: sakuraSize.h })
|
|
1134
|
+
if (props.onInfraBounds) props.onInfraBounds(infraBoxes)
|
|
1135
|
+
var localKey = peerDeviceKey(data.nodeName)
|
|
1136
|
+
var localPub = localTunnel.publicEntry || localTunnel.selfHosted || null
|
|
1137
|
+
var localSak = sakuraTunnelOf(data, localKey)
|
|
1138
|
+
// 2026-11-10:LAN 线必须从本机节点出发(排序后 local 可能不在 index 0,之前误连到peer-x)
|
|
1139
|
+
var localNodeIdx = 0
|
|
1140
|
+
nodes.forEach(function (n, ii) { if (n.local) localNodeIdx = ii })
|
|
1141
|
+
nodes.forEach(function (n, i) {
|
|
1142
|
+
var pa = layout.positions[i]
|
|
1143
|
+
if (n.local) {
|
|
1144
|
+
// 主机自己的两条上级链路:自建 frps(阿里云) + 本机樱花隧道
|
|
1145
|
+
if (show.public && localPub) svgEls = svgEls.concat(curve('pub-local', pa, aliyunPos, true, TRANSPORTS.public, localPub.host + ':' + localPub.port, sizeOf('local'), aliyunSize, edgeRelated('local', 'server-aliyun')))
|
|
1146
|
+
if (show.sakura && localSak) svgEls = svgEls.concat(curve('sak-local', pa, sakuraPos, localSak.online === true, TRANSPORTS.sakura, localSak.addr || '', sizeOf('local'), sakuraSize, edgeRelated('local', 'server-sakura')))
|
|
1147
|
+
return
|
|
1148
|
+
}
|
|
1149
|
+
var sizeN = sizeOf(n.key)
|
|
1150
|
+
if (show.public) svgEls = svgEls.concat(curve('pub' + i, pa, aliyunPos, n.publicUp, TRANSPORTS.public, 'relay.example.com' + ((n.tunnel && n.tunnel.port) ? ':' + n.tunnel.port : ''), sizeN, aliyunSize, edgeRelated(n.key, 'server-aliyun')))
|
|
1151
|
+
if (show.sakura && n.sakuraTunnel) svgEls = svgEls.concat(curve('sak' + i, pa, sakuraPos, n.sakuraUp === true, TRANSPORTS.sakura, n.sakuraTunnel.addr || '', sizeN, sakuraSize, edgeRelated(n.key, 'server-sakura')))
|
|
1152
|
+
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))) }
|
|
1153
|
+
})
|
|
1154
|
+
return h('div', null,
|
|
1155
|
+
h('svg', { className: 'dm-lines', width: layout.W, height: layout.H, viewBox: '0 0 ' + layout.W + ' ' + layout.H }, svgEls),
|
|
1156
|
+
nodes.map(function (node, i) {
|
|
1157
|
+
var pos = layout.positions[i]
|
|
1158
|
+
var selected = props.selectedKey === node.key
|
|
1159
|
+
var types = dshTypesFor(node.devKey)
|
|
1160
|
+
return h('div', {
|
|
1161
|
+
key: node.key,
|
|
1162
|
+
className: 'dm-card-node compact draggable' + (node.local ? ' local' : '') + (node.up ? '' : ' down') + (selected ? ' selected' : '') + (hoverKey === node.key ? ' hovered' : '') + (dragKey === node.key ? ' dragging' : ''),
|
|
1163
|
+
onMouseEnter: function () { setHoverKey(node.key) },
|
|
1164
|
+
onMouseLeave: function () { setHoverKey(null) },
|
|
1165
|
+
style: { left: pos[0] + 'px', top: pos[1] + 'px', maxWidth: '380px', transform: 'translate(-50%,-50%)' },
|
|
1166
|
+
ref: function (el) { if (el) cardRefs.current[node.key] = el; else delete cardRefs.current[node.key] },
|
|
1167
|
+
onPointerDown: function (event) { cardPointerDown(node, pos, event) },
|
|
1168
|
+
onPointerMove: function (event) { cardPointerMove(node, event) },
|
|
1169
|
+
onPointerUp: function (event) { cardPointerUp(node, event) },
|
|
1170
|
+
onPointerCancel: cardPointerCancel,
|
|
1171
|
+
title: '拖拽调整位置 · 单击查看 ' + node.name + ' 的详情'
|
|
1172
|
+
},
|
|
1173
|
+
h('div', { className: 'dm-n-head' },
|
|
1174
|
+
h('div', { className: 'dm-n-name' }, h('span', { className: 'dm-dot ' + (node.up ? 'ok' : 'bad') }), h('span', { className: 'dm-ell' }, node.name)),
|
|
1175
|
+
node.local ? h('span', { className: 'dm-chip ok' }, '本机')
|
|
1176
|
+
: (node.up ? h('span', { className: 'dm-extra' }, node.latencyMs == null ? '' : node.latencyMs + 'ms') : h('span', { className: 'dm-chip bad' }, '离线'))
|
|
624
1177
|
),
|
|
625
|
-
h('div', { className: 'dm-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
style: { left: pos[0] + 'px', top: pos[1] + 'px', width: layout.cardW + 'px', minHeight: layout.cardH + 'px', transform: 'translate(-50%,-50%)' },
|
|
633
|
-
onClick: function () { if (props.onSelect) props.onSelect(node.key) },
|
|
634
|
-
title: '点击查看 ' + node.name + ' 的详情'
|
|
635
|
-
},
|
|
636
|
-
h('div', { className: 'dm-n-head' },
|
|
637
|
-
h('div', { className: 'dm-n-name' }, h('span', { className: 'dm-dot ' + (node.up ? 'ok' : 'bad') }), h('span', { className: 'dm-ell' }, node.name)),
|
|
638
|
-
node.local
|
|
639
|
-
? h('span', { className: 'dm-chip ok' }, '本机') : (node.up ? h('span', { className: 'dm-extra' }, node.extra || '') : h('span', { className: 'dm-chip bad' }, '离线'))
|
|
640
|
-
),
|
|
641
|
-
h('div', { className: 'dm-card-domain', title: node.domain }, node.domain),
|
|
642
|
-
node.comm ? h('div', { className: 'dm-relay-tag' }, node.comm) : null,
|
|
643
|
-
node.up ? null : h('div', { className: 'dm-offline-row', title: (node.error || '') }, '离线' + (node.error ? ' · ' + node.error : ' · 无法连接') + (node.detectedAt ? ' · 最后探测 ' + new Date(node.detectedAt).toLocaleTimeString() : '')),
|
|
644
|
-
h('div', { className: 'dm-card-dsh' },
|
|
645
|
-
h('span', { className: 'dm-chip' }, 'DSH ' + node.dsh + (node.local ? ' 在线' : ' 个')),
|
|
646
|
-
(node.ports || []).map(function (item, pi) {
|
|
647
|
-
return h('span', { key: pi, className: 'dm-chip' + (item.up ? ' run' : ' bad'), title: (item.up ? '在线' : '离线') }, String(item.port))
|
|
648
|
-
})
|
|
649
|
-
)
|
|
650
|
-
)
|
|
651
|
-
})
|
|
1178
|
+
node.up ? null : h('div', { className: 'dm-offline-row' }, '全部链路不可用' + (node.error ? ' · ' + node.error : '')),
|
|
1179
|
+
h('div', { className: 'dm-card-dsh' },
|
|
1180
|
+
types.map(function (tp, ti) { return h('span', { key: 't' + ti, className: 'dm-chip type' }, tp) }),
|
|
1181
|
+
(node.ports || []).map(function (item, pi) {
|
|
1182
|
+
return h('span', { key: pi, className: 'dm-chip' + (item.up ? ' run' : ' bad'), title: (item.up ? '在线' : '离线') }, String(item.port))
|
|
1183
|
+
})
|
|
1184
|
+
)
|
|
652
1185
|
)
|
|
653
|
-
}
|
|
1186
|
+
}),
|
|
1187
|
+
(show.public ? h('div', {
|
|
1188
|
+
key: 'server-aliyun',
|
|
1189
|
+
className: 'dm-card-node server' + (infra[0].up ? '' : ' down') + (hoverKey === 'server-aliyun' ? ' hovered' : ''),
|
|
1190
|
+
onMouseEnter: function () { setHoverKey('server-aliyun') },
|
|
1191
|
+
onMouseLeave: function () { setHoverKey(null) },
|
|
1192
|
+
style: { left: aliyunPos[0] + 'px', top: aliyunPos[1] + 'px', maxWidth: '360px', transform: 'translate(-50%,-50%)', borderColor: TRANSPORTS.public.color },
|
|
1193
|
+
ref: function (el) { if (el) infraRefs.current['server-aliyun'] = el; else delete infraRefs.current['server-aliyun'] },
|
|
1194
|
+
onPointerDown: function (event) { cardPointerDown({ key: 'server-aliyun' }, aliyunPos, event) },
|
|
1195
|
+
onPointerMove: function (event) { cardPointerMove({ key: 'server-aliyun' }, event) },
|
|
1196
|
+
onPointerUp: function (event) { cardPointerUp({ key: 'server-aliyun' }, event) },
|
|
1197
|
+
onPointerCancel: cardPointerCancel
|
|
1198
|
+
},
|
|
1199
|
+
h('div', { className: 'dm-n-head' }, h('div', { className: 'dm-n-name' }, h('span', { className: 'dm-dot ok' }), h('span', {}, infra[0].name))),
|
|
1200
|
+
h('div', { className: 'dm-card-domain' }, infra[0].host),
|
|
1201
|
+
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))
|
|
1202
|
+
) : null),
|
|
1203
|
+
(show.sakura ? h('div', {
|
|
1204
|
+
key: 'server-sakura',
|
|
1205
|
+
className: 'dm-card-node server' + (infra[1].up ? '' : ' down') + (hoverKey === 'server-sakura' ? ' hovered' : ''),
|
|
1206
|
+
onMouseEnter: function () { setHoverKey('server-sakura') },
|
|
1207
|
+
onMouseLeave: function () { setHoverKey(null) },
|
|
1208
|
+
style: { left: sakuraPos[0] + 'px', top: sakuraPos[1] + 'px', maxWidth: '360px', transform: 'translate(-50%,-50%)', borderColor: TRANSPORTS.sakura.color },
|
|
1209
|
+
ref: function (el) { if (el) infraRefs.current['server-sakura'] = el; else delete infraRefs.current['server-sakura'] },
|
|
1210
|
+
onPointerDown: function (event) { cardPointerDown({ key: 'server-sakura' }, sakuraPos, event) },
|
|
1211
|
+
onPointerMove: function (event) { cardPointerMove({ key: 'server-sakura' }, event) },
|
|
1212
|
+
onPointerUp: function (event) { cardPointerUp({ key: 'server-sakura' }, event) },
|
|
1213
|
+
onPointerCancel: cardPointerCancel
|
|
1214
|
+
},
|
|
1215
|
+
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))),
|
|
1216
|
+
h('div', { className: 'dm-card-domain' }, infra[1].host),
|
|
1217
|
+
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))
|
|
1218
|
+
) : null)
|
|
1219
|
+
)
|
|
1220
|
+
}
|
|
654
1221
|
|
|
655
|
-
|
|
1222
|
+
// ---------- 画布下方:设备详情(默认全展示,点卡片高亮对应项) ----------
|
|
656
1223
|
function DeviceDetails(props) {
|
|
657
1224
|
var data = props.data
|
|
658
1225
|
var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
|
|
@@ -669,19 +1236,23 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
669
1236
|
var tunnels = localTunnel.tunnels || []
|
|
670
1237
|
var entry = localTunnel.publicEntry || null
|
|
671
1238
|
var tunnelNames = tunnels.map(function (x) { return x.name + '→' + x.host + ':' + x.port }).join(' / ')
|
|
1239
|
+
var mainSrc = localTunnel.mainSource || (entry && entry.source === 'self-hosted-frps' ? 'self-hosted' : (entry ? 'sakura' : 'none'))
|
|
1240
|
+
var entryText = entry && entry.host
|
|
1241
|
+
? entry.host + ':' + entry.port + (mainSrc === 'self-hosted' ? '(自建 frps · 主用)' : (mainSrc === 'sakura' ? '(Sakura · 备用)' : ''))
|
|
1242
|
+
: '未配置'
|
|
672
1243
|
var localTunnelRows = [
|
|
673
|
-
['
|
|
674
|
-
['
|
|
675
|
-
['frp 状态', localTunnel.frpRunning ? '运行中 ×' + (localTunnel.frpcCount || 1) : '未运行']
|
|
1244
|
+
['入口', entryText],
|
|
1245
|
+
['监听 / frpc', (localTunnel.bind || '?') + ':' + (localTunnel.port || '?') + (localTunnel.tls ? ' (TLS)' : '') + ' · ' + (localTunnel.frpRunning ? 'frpc ×' + (localTunnel.frpcCount || 1) : 'frpc 未运行')]
|
|
676
1246
|
]
|
|
677
|
-
if (tunnels.length) localTunnelRows.push(['
|
|
1247
|
+
if (tunnels.length) localTunnelRows.push(['Sakura 备用', tunnels.map(function (x) { return x.host + ':' + x.port }).join(' / ')])
|
|
1248
|
+
if (mainSrc === 'self-hosted' && localTunnel.selfHosted && localTunnel.selfHosted.configPath) localTunnelRows.push(['frpc 配置', localTunnel.selfHosted.configPath])
|
|
678
1249
|
var localTunnelAll = tunnelNames
|
|
679
1250
|
var memText = hw.memTotalGB ? ((hw.memFreeGB != null ? hw.memFreeGB : '?') + ' / ' + hw.memTotalGB + ' GB') : '—'
|
|
680
1251
|
var cpuText = hw.cpuModel ? hw.cpuModel + ' · ' + (hw.cpuCores || '?') + ' 线程' : '—'
|
|
681
1252
|
|
|
682
1253
|
var devices = [{
|
|
683
1254
|
key: 'local',
|
|
684
|
-
name:
|
|
1255
|
+
name: nodeDisplay(data.nodeName) + '(本机)',
|
|
685
1256
|
up: Boolean(endpoint.up),
|
|
686
1257
|
rows: [
|
|
687
1258
|
['操作系统', hw.os || '—'],
|
|
@@ -698,19 +1269,17 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
698
1269
|
var t = peer.tunnel || {}
|
|
699
1270
|
return {
|
|
700
1271
|
key: 'peer:' + peer.name,
|
|
701
|
-
name:
|
|
1272
|
+
name: nodeDisplay(peerDeviceKey(peer.name)),
|
|
702
1273
|
up: Boolean(peer.up),
|
|
703
1274
|
rows: [
|
|
704
1275
|
['操作系统', peer.os || '—'],
|
|
705
1276
|
['运行时长', uptimeText(peer.uptimeSec)],
|
|
706
|
-
['节点 pid / tools', (peer.nodePid == null ? '—' : peer.nodePid) + ' / ' + (peer.nodeTools == null ? '—' : peer.nodeTools)],
|
|
707
1277
|
['DSH 实例', (peer.instancesCount || 0) + ' 个(' + ((peer.instancesList || []).map(function (x) { return x.port + (x.up ? '✓' : '✗') }).join(' ') || '—') + ')'],
|
|
708
1278
|
['会话 / 运行', (peer.sessions || 0) + ' / ' + (peer.running || 0)],
|
|
709
1279
|
['任务(进行中)', peer.tasksActive == null ? '—' : String(peer.tasksActive)],
|
|
710
|
-
['
|
|
711
|
-
['
|
|
712
|
-
['
|
|
713
|
-
['CPU / 内存', '对端未暴露,无法直读'],
|
|
1280
|
+
['入口', t.host ? t.host + (t.port ? ':' + t.port : '') : '—'],
|
|
1281
|
+
['链路', commLabel(peer, relayHosts).text + ' · ' + (peer.up ? (peer.latencyMs == null ? '—' : peer.latencyMs + 'ms') : '离线') + ' · ' + (peer.tls ? 'TLS ✓' : 'TLS ✗') + ' · v' + (peer.version || '?')],
|
|
1282
|
+
['节点 pid / tools', (peer.nodePid == null ? '—' : peer.nodePid) + ' / ' + (peer.nodeTools == null ? '—' : peer.nodeTools)],
|
|
714
1283
|
['检测时间', clockText(peer.detectedAt || data.at)]
|
|
715
1284
|
]
|
|
716
1285
|
}
|
|
@@ -862,8 +1431,9 @@ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()]
|
|
|
862
1431
|
h('button', { className: 'dm-btn primary', onClick: function () { load(true) } }, loading ? '刷新中…' : '刷新')
|
|
863
1432
|
),
|
|
864
1433
|
error ? h('div', { className: 'dm-err' }, error) : null,
|
|
865
|
-
|
|
1434
|
+
|
|
866
1435
|
h(Viewport, { data: data, layout: computeLayout(1 + filterSelfPeers(dedupePeers(data.peers || []), data.nodeName).length), selectedKey: selectedKey, onSelect: setSelectedKey }),
|
|
1436
|
+
h(InfraPanels, { data: data }),
|
|
867
1437
|
h(DeviceDetails, { data: data, selectedKey: selectedKey }),
|
|
868
1438
|
h(Details, { title: '本机实例', badge: ((data.local && data.local.instances) || []).length, open: true }, h(InstanceList, { data: data })),
|
|
869
1439
|
h(Details, { title: '版本一致性', badge: versions.length ? versions.length + ' 处升级' : '一致' },
|