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 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 = 10000
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.35
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:hidden; text-overflow:ellipsis; white-space:nowrap; }
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:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
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
- .dm-devices { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; align-items:start; }
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:14px; line-height:1; }
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 { position:absolute; left:8px; bottom:8px; font-size:10.5px; opacity:.45; pointer-events:none; z-index:5; }
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 scale = Math.min(rect.width / layout.W, rect.height / layout.H) * 0.94
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 - layout.W * scale) / 2, y: (rect.height - layout.H * scale) / 2 })
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
- return h('div', { className: 'dm-viewport' + (dragging ? ' dragging' : ''), ref: viewRef,
394
- onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: endDrag, onPointerCancel: endDrag, onPointerLeave: endDrag,
395
- onDoubleClick: function () { interactedRef.current = false; fit() } },
396
- // 2026-11-07:缩放改用 CSS zoom(重新布局、文字矢量重绘 → 放大不糊);
397
- // 平移用外层 transform(不参与光栅缓存)。不再使用 will-change,避免整块被缓存成位图后放大发虚。
398
- h('div', { className: 'dm-world-outer', style: { transform: 'translate(' + view.x + 'px,' + view.y + 'px)' } },
399
- h('div', { className: 'dm-world', style: Object.assign({ width: layout.W + 'px', height: layout.H + 'px' },
400
- SUPPORTS_ZOOM ? { zoom: view.scale } : { transform: 'scale(' + view.scale + ')' }) },
401
- h(Topology, { data: props.data, layout: layout, selectedKey: props.selectedKey, onSelect: props.onSelect })
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-zoom' },
405
- h('button', { title: '放大', onClick: function () { zoomBy(1.22) } }, '+'),
406
- h('button', { title: '缩小', onClick: function () { zoomBy(1 / 1.22) } }, '−'),
407
- h('button', { title: '自适应', onClick: function () { interactedRef.current = false; fit() } }, '⤢'),
408
- h('span', { className: 'pct' }, Math.round(view.scale * 100) + '%')
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
- function computeLayout(count) {
688
+ // 2026-11-09-v6:默认布局 = 顶部「阿里云 / 樱花」服务器一排 + 设备沿下方弧线扇形展开(用户指定)
689
+ function computeLayout(count, override) {
419
690
  var GAP = 30
420
- var cardW = count <= 4 ? 176 : count <= 6 ? 152 : 136
421
- var cardH = count <= 4 ? 104 : count <= 6 ? 96 : 88
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 W
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, H / 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, H / 2], [PAD + cardW + GAP + cardW / 2, H / 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 MARGIN = 16
448
- var R = (Math.max(cardW, cardH) + GAP) / (2 * Math.sin(Math.PI / count))
449
- for (var step = 0; step < 500; step++) {
450
- var pts = []
451
- for (var p = 0; p < count; p++) {
452
- var ap = (-90 + p * (360 / count)) * Math.PI / 180
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 x = 0; x < count && ok; x++) {
457
- for (var y = x + 1; y < count; y++) {
458
- var dx = Math.abs(pts[x][0] - pts[y][0])
459
- var dy = Math.abs(pts[x][1] - pts[y][1])
460
- if (dx < cardW + MARGIN && dy < cardH + MARGIN) { ok = false; break }
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
- R += 2
723
+ A += 4
465
724
  }
466
- W = Math.ceil(2 * (R + cardW / 2) + PAD * 2)
467
- H = Math.ceil(2 * (R + cardH / 2) + PAD * 2)
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 = (-90 + j * (360 / count)) * Math.PI / 180
471
- positions.push([W / 2 + R * Math.cos(aj), H / 2 + R * Math.sin(aj)])
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
- return { count: count, cardW: cardW, cardH: cardH, gap: GAP, W: W, H: H, positions: positions, edges: edges }
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
- if (!groups.length && !direct.length) return null
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' }, nodeLabel(data.nodeName) + '(本机)'),
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' }, nodeLabel(peerDeviceKey(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 }, nodeLabel(peerDeviceKey(peer.name)) + ' · ' + 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
- // ---------- 拓扑:极简卡片(名称 / 穿透域名 / DSH 数)+ 点击选中 ----------
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
- if (entry && entry.host) return entry.host + ':' + entry.port
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
- var nodes = [{
559
- key: 'local',
560
- local: true,
561
- up: Boolean(endpoint.up),
562
- name: nodeLabel(data.nodeName) + '(本机)',
563
- domain: localDomain(),
564
- dsh: localUp + '/' + instances.length,
565
- ports: instances.map(function (inst) { return { port: inst.port, up: inst.up } }),
566
- extra: ''
567
- }].concat(peers.map(function (peer) {
568
- var t = peer.tunnel || {}
569
- var tunnelText = peer.tunnelText || ''
570
- return {
571
- key: 'peer:' + peer.name,
572
- local: false,
573
- up: Boolean(peer.up),
574
- name: nodeLabel(peerDeviceKey(peer.name)),
575
- domain: peerDomain(peer),
576
- comm: commLabel(peer, relayHosts).short,
577
- dsh: String(peer.instancesCount || 0),
578
- ports: peer.instancesList || [],
579
- extra: peer.up ? (peer.latencyMs == null ? '' : peer.latencyMs + 'ms') : '离线',
580
- error: peer.error || '', detectedAt: peer.detectedAt || ''
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
- var halfW = layout.cardW / 2
585
- var halfH = layout.cardH / 2
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 : (halfW + GAP_EDGE) / Math.abs(ux)
594
- var ty = uy === 0 ? Infinity : (halfH + GAP_EDGE) / Math.abs(uy)
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
- return h('div', null,
600
- h('svg', { className: 'dm-lines', width: layout.W, height: layout.H, viewBox: '0 0 ' + layout.W + ' ' + layout.H },
601
- layout.edges.map(function (edge, k) {
602
- var a = nodes[edge[0]]
603
- var b = nodes[edge[1]]
604
- var pa = layout.positions[edge[0]]
605
- var pb = layout.positions[edge[1]]
606
- var bothUp = a && b && a.up && b.up
607
- var sa = edgePoint(pa, pb)
608
- var sb = edgePoint(pb, pa)
609
- var mx = (sa[0] + sb[0]) / 2
610
- var my = (sa[1] + sb[1]) / 2
611
- var dx = sb[0] - sa[0]
612
- var dy = sb[1] - sa[1]
613
- var len = Math.sqrt(dx * dx + dy * dy) || 1
614
- var bow = Math.min(12, len * 0.06)
615
- return h('path', {
616
- key: 'e' + k,
617
- d: 'M ' + sa[0] + ' ' + sa[1] + ' Q ' + (mx + (-dy / len) * bow) + ' ' + (my + (dx / len) * bow) + ' ' + sb[0] + ' ' + sb[1],
618
- fill: 'none',
619
- stroke: bothUp ? 'rgba(63,185,80,.42)' : 'rgba(248,81,73,.55)',
620
- strokeWidth: 1.8,
621
- strokeDasharray: bothUp ? 'none' : '6 5'
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-world-label' }, layout.count + ' 节点 · ' + layout.edges.length + ' 条链路 · 点击节点看详情'),
626
- nodes.map(function (node, i) {
627
- var pos = layout.positions[i]
628
- var selected = props.selectedKey === node.key
629
- return h('div', {
630
- key: node.key,
631
- className: 'dm-card-node compact' + (node.local ? ' local' : '') + (node.up ? '' : ' down') + (selected ? ' selected' : ''),
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
- ['公网入口', entry && entry.host ? entry.host + ':' + entry.port : '未配置'],
674
- ['本地监听', (localTunnel.bind || '?') + ':' + (localTunnel.port || '?') + (localTunnel.tls ? ' (TLS)' : '')],
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(['隧道', tunnels.map(function (x) { return x.name }).join(' / ')])
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: nodeLabel(data.nodeName) + '(本机)',
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: nodeLabel(peerDeviceKey(peer.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
- ['公网入口', t.host ? t.host + (t.port ? ':' + t.port : '') : '—'],
711
- ['链路类型', commLabel(peer, relayHosts).text],
712
- ['链路', (peer.up ? (peer.latencyMs == null ? '—' : peer.latencyMs + 'ms') : '离线') + ' · ' + (peer.tls ? 'TLS ✓' : 'TLS ✗') + ' · v' + (peer.version || '?')],
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
- h(RelayLayer, { data: data }),
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 + ' 处升级' : '一致' },