dsh-mesh-ui 0.0.0-stage → 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 ADDED
@@ -0,0 +1,2199 @@
1
+ // dsh-mesh-ui — 浏览器半(client plugin bundle)v3
2
+ //
3
+ // 手写成 DSH 期望的形状:window.__ModuleLoader__.load({ id, factory(require) }),react 由平台提供。
4
+ // 设置窗口 settings.section(契约:id 必填 + label)→「AI 网络」:
5
+ //
6
+ // ┌ 头部:标题 / verdict / 版本 / 时间 / 刷新 / 复制诊断 / 原始 JSON
7
+ // ├ 画布(viewport):滚轮缩放(以鼠标为中心)、拖拽平移、双击复位、缩放按钮、自适应 fit
8
+ // │ 画布内 = 世界坐标(1080×680):本机居中 + peer 卡片 + 曲线连线 + 延迟标签
9
+ // └ 折叠面板:本机实例 / 委托台账 / 告警历史 / 诊断建议(默认收起,省垂直空间)
10
+ //
11
+ // 2026-11-06 v3 重做(用户反馈:v2 卡片/连线糟糕、设置面板窄导致内容看不见、缺缩放拖拽)。
12
+ //
13
+ // 归宿:reusable(dsh-mesh/plugin/dsh-mesh-ui/lib/)
14
+
15
+ window.__ModuleLoader__.load({
16
+ id: 'dsh-mesh-ui',
17
+ factory: (require) => {
18
+ var module = { exports: {} }
19
+ var exports = module.exports
20
+ var React = require('react')
21
+ var h = React.createElement
22
+ var useState = React.useState
23
+ var useEffect = React.useEffect
24
+ var useRef = React.useRef
25
+ var useCallback = React.useCallback
26
+
27
+ var API = '/api/dsh-mesh/status'
28
+ var REFRESH_MS = 5000
29
+ var SUPPORTS_ZOOM = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('zoom', '1.5')
30
+
31
+ // 2026-11-07 用户要求:卡片标题只用固定中文叫法,不显示电脑设备名
32
+ var NODE_LABELS = {}
33
+ // 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
+ }
45
+ // 同一台设备可能有多个 peer 记录(不同 token/入口:phone-node / phone-node-lan / phone-node-exec)。
46
+ // AI 网络拓扑按「设备」展示:去掉后缀归并,取可达且延迟最优的那条。
47
+ var PEER_SUFFIX = /-(lan|wan|exec|exe|import|local|ctl)$/i
48
+ function peerDeviceKey(name) {
49
+ var n = String(name || '').trim().toLowerCase()
50
+ return n.replace(PEER_SUFFIX, '')
51
+ }
52
+ function dedupePeers(list) {
53
+ var byKey = {}
54
+ var order = []
55
+ ;(list || []).forEach(function (peer) {
56
+ var key = peerDeviceKey(peer.name)
57
+ if (!byKey[key]) { byKey[key] = peer; order.push(key); return }
58
+ var cur = byKey[key]
59
+ var better = false
60
+ if (!cur.up && peer.up) better = true
61
+ else if (Boolean(cur.up) === Boolean(peer.up)) {
62
+ var a = cur.latencyMs == null ? 1e9 : cur.latencyMs
63
+ var b = peer.latencyMs == null ? 1e9 : peer.latencyMs
64
+ if (b < a) better = true
65
+ else if (b === a && (peer.instancesCount || 0) > (cur.instancesCount || 0)) better = true
66
+ }
67
+ if (better) byKey[key] = peer
68
+ })
69
+ return order.map(function (k) { return byKey[k] })
70
+ }
71
+ function filterSelfPeers(list, nodeName) {
72
+ var selfKey = peerDeviceKey(nodeName)
73
+ return (list || []).filter(function (peer) { return !selfKey || peerDeviceKey(peer.name) !== selfKey })
74
+ }
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]
120
+ // [relay-helpers-begin] 2026-11-09 架构层:识别公网中转站(frps)与通信方式(纯函数,可单测)
121
+ // 判定:同一公网 host 被 >=2 个 peer 使用 → 视为共享中转站;LAN/loopback 不算。
122
+ var RELAY_PUBLIC_KINDS = { wan: 1, frp: 1 }
123
+ function relayGroups(peers) {
124
+ var groups = {}
125
+ var order = []
126
+ ;(peers || []).forEach(function (peer) {
127
+ var t = peer.tunnel || {}
128
+ var host = String(t.host || '').trim().toLowerCase()
129
+ if (!host || !RELAY_PUBLIC_KINDS[t.kind]) return
130
+ var g = groups[host]
131
+ if (!g) { g = groups[host] = { host: t.host || host, hostKey: host, kind: t.kind, peers: [], ports: {}, up: 0 }; order.push(host) }
132
+ g.peers.push(peer)
133
+ if (t.port) g.ports[String(t.port)] = (g.ports[String(t.port)] || 0) + 1
134
+ if (peer.up) g.up++
135
+ })
136
+ return order.map(function (k) { return groups[k] })
137
+ .filter(function (g) { return g.peers.length >= 2 })
138
+ .sort(function (a, b) { return b.peers.length - a.peers.length })
139
+ }
140
+ function relayHostSet(peers) {
141
+ var set = {}
142
+ relayGroups(peers).forEach(function (g) { set[g.hostKey] = g })
143
+ return set
144
+ }
145
+ function commLabel(peer, relayHosts) {
146
+ var t = peer.tunnel || {}
147
+ var host = String(t.host || '').toLowerCase()
148
+ if (!host) return { text: '—', short: '—' }
149
+ var relay = relayHosts && relayHosts[host]
150
+ var port = t.port ? ':' + t.port : ''
151
+ if (relay) return { text: (relay.kind === 'frp' ? '域名穿透' : '自建 frps 中转') + ' · ' + relay.host + port, short: '中转' + (t.port ? ' ' + t.port : '') }
152
+ if (t.kind === 'loopback') return { text: '本机回环', short: '本机' }
153
+ if (t.kind === 'lan') return { text: '局域网直连 · ' + t.host + port, short: 'LAN' }
154
+ if (t.kind === 'frp') return { text: '公网穿透 · ' + t.host + port, short: '穿透' }
155
+ if (t.kind === 'wan') return { text: '公网直连 · ' + t.host + port, short: '公网' }
156
+ return { text: (t.label || t.kind || '未知') + ' · ' + t.host + port, short: t.label || '未知' }
157
+ }
158
+ // [relay-helpers-end]
159
+ var W = 560
160
+ var H = 400
161
+ var MIN_SCALE = 0.18
162
+ var MAX_SCALE = 2.6
163
+
164
+ var CSS = `
165
+ .dm-root { display:flex; flex-direction:column; gap:12px; font-size:13px; line-height:1.5; color:inherit; }
166
+ .dm-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
167
+ .dm-title { font-size:15.5px; font-weight:650; letter-spacing:.2px; }
168
+ .dm-sub { font-size:11.5px; opacity:.55; }
169
+ .dm-pill { padding:1px 9px; border-radius:999px; font-size:11.5px; font-weight:600; border:1px solid transparent; }
170
+ .dm-pill.ok { background:rgba(63,185,80,.14); color:#2e9e4f; border-color:rgba(63,185,80,.35); }
171
+ .dm-pill.bad { background:rgba(248,81,73,.14); color:#c9362c; border-color:rgba(248,81,73,.35); }
172
+ .dm-pill.warn { background:rgba(210,153,34,.16); color:#9a6c12; border-color:rgba(210,153,34,.4); }
173
+ .dm-pill.muted { background:rgba(128,128,128,.12); color:inherit; border-color:rgba(128,128,128,.28); opacity:.8; }
174
+ .dm-btn { font-size:11.5px; padding:3px 10px; border-radius:7px; border:1px solid rgba(128,128,128,.38); background:transparent; color:inherit; cursor:pointer; white-space:nowrap; }
175
+ .dm-btn:hover { background:rgba(128,128,128,.12); }
176
+ .dm-btn.primary { border-color:rgba(88,166,255,.55); color:#3b82d6; }
177
+
178
+ /* 画布视口 */
179
+ .dm-viewport { position:relative; width:100%; height:clamp(240px, 34vh, 380px); min-height:220px; overflow:hidden; border-radius:12px;
180
+ border:1px solid rgba(128,128,128,.22); background:
181
+ radial-gradient(ellipse at 50% 45%, rgba(88,166,255,.07), transparent 60%),
182
+ linear-gradient(rgba(128,128,128,.05) 1px, transparent 1px) 0 0/26px 26px,
183
+ linear-gradient(90deg, rgba(128,128,128,.05) 1px, transparent 1px) 0 0/26px 26px;
184
+ cursor:grab; touch-action:none; user-select:none; }
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; }
194
+ .dm-world-outer { position:absolute; left:0; top:0; }
195
+ .dm-world { position:absolute; left:0; top:0; transform-origin:0 0; }
196
+ .dm-lines { position:absolute; left:0; top:0; overflow:visible; pointer-events:none; }
197
+ .dm-world-label { position:absolute; left:10px; top:8px; font-size:11px; opacity:.5; font-variant-numeric:tabular-nums; }
198
+
199
+ /* 节点卡片(世界坐标里) */
200
+ .dm-card-node { position:absolute; box-sizing:border-box; overflow:visible; border-radius:12px; padding:10px 12px;
201
+ border:1px solid rgba(128,128,128,.3); background:rgba(128,128,128,.06); color:inherit; box-shadow:0 6px 22px rgba(0,0,0,.10); }
202
+ .dm-card-node.local { border-width:1.6px; border-color:rgba(88,166,255,.55); background:rgba(88,166,255,.10); }
203
+ .dm-card-node.down { border-style:dashed; border-color:rgba(248,81,73,.6); background:rgba(248,81,73,.07); }
204
+ .dm-card-node.down .dm-n-name { opacity:1; }
205
+ .dm-card-node.down .dm-card-domain, .dm-card-node.down .dm-card-dsh { opacity:.62; }
206
+ .dm-card-node.down .dm-v, .dm-card-node.down .dm-big, .dm-card-node.down .dm-extra { opacity:.6; }
207
+ .dm-card-node.down .dm-chip { opacity:.72; }
208
+ .dm-offline-row { margin-top:5px; font-size:10.5px; color:#c9362c; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
209
+ .dm-n-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }
210
+ .dm-n-name { font-weight:650; font-size:13px; display:flex; align-items:center; gap:7px; min-width:0; }
211
+ .dm-n-name span.dm-ell { overflow:visible; text-overflow:clip; white-space:nowrap; }
212
+ .dm-dot { width:9px; height:9px; border-radius:50%; display:inline-block; background:#8b949e; flex:none; }
213
+ .dm-dot.ok { background:#2e9e4f; box-shadow:0 0 0 3px rgba(63,185,80,.18); }
214
+ .dm-dot.bad { background:#d14b41; box-shadow:0 0 0 3px rgba(248,81,73,.18); }
215
+ .dm-big { font-size:17px; font-weight:650; font-variant-numeric:tabular-nums; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
216
+ .dm-unit { font-size:11px; opacity:.55; margin-left:2px; }
217
+ .dm-row { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:5px; font-size:11.5px; }
218
+ .dm-k { opacity:.58; font-size:11px; }
219
+ .dm-v { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11.5px; font-variant-numeric:tabular-nums; }
220
+ .dm-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
221
+ .dm-mini { display:flex; gap:4px; flex-wrap:wrap; margin-top:6px; }
222
+ .dm-extra { font-size:11px; opacity:.6; font-variant-numeric:tabular-nums; }
223
+ .dm-card-node.compact { padding:9px 11px; cursor:pointer; transition:border-color .12s, box-shadow .12s; }
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; }
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; }
226
+ .dm-card-dsh { margin-top:6px; }
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; }
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; }
247
+ .dm-device.selected { border-color:rgba(88,166,255,.6); background:rgba(88,166,255,.07); }
248
+ .dm-device-head { display:flex; align-items:center; gap:7px; font-weight:650; font-size:12.5px; margin-bottom:6px; }
249
+ .dm-device-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
250
+ .dm-device-body { display:flex; flex-direction:column; gap:3px; }
251
+ .dm-device-row { display:flex; align-items:baseline; justify-content:space-between; gap:10px; font-size:11px; min-width:0; }
252
+ .dm-device-k { opacity:.55; flex:0 0 auto; }
253
+ .dm-device-v { flex:1 1 auto; min-width:0; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; }
254
+ @media (max-width:720px) { .dm-devices { grid-template-columns:1fr; } }
255
+ .dm-fields { display:grid; grid-template-columns:1fr 1fr; gap:4px 10px; margin-top:6px; }
256
+ .dm-field { min-width:0; }
257
+ .dm-field-k { font-size:9.5px; opacity:.5; line-height:1.2; }
258
+ .dm-field-v { font-size:11px; line-height:1.35; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-variant-numeric:tabular-nums; }
259
+ .dm-tunnel { margin-top:6px; font-size:10px; opacity:.7; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
260
+ .dm-chip { font-size:9.5px; padding:0 6px; border-radius:999px; border:1px solid rgba(128,128,128,.3); opacity:.85; white-space:nowrap; }
261
+ .dm-chip.run { border-color:rgba(63,185,80,.45); color:#2e9e4f; opacity:1; }
262
+ .dm-chip.warn { border-color:rgba(210,153,34,.5); color:#9a6c12; opacity:1; }
263
+ .dm-chip.bad { border-color:rgba(248,81,73,.5); color:#c9362c; opacity:1; }
264
+ .dm-chip.ok { border-color:rgba(63,185,80,.4); color:#2e9e4f; opacity:1; }
265
+
266
+ /* 画布浮层控件 */
267
+ .dm-zoom { position:absolute; right:8px; bottom:8px; display:flex; gap:5px; padding:4px; border-radius:9px;
268
+ background:rgba(128,128,128,.12); backdrop-filter:blur(4px); z-index:5; }
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; }
270
+ .dm-zoom button:hover { background:rgba(128,128,128,.16); }
271
+ .dm-zoom .pct { font-size:10.5px; opacity:.6; align-self:center; padding:0 2px; font-variant-numeric:tabular-nums; }
272
+ .dm-hint { font-size:10.5px; opacity:.45; padding:0 2px; }
273
+
274
+ /* 折叠面板 */
275
+ .dm-details { border:1px solid rgba(128,128,128,.22); border-radius:11px; background:rgba(128,128,128,.035); }
276
+ .dm-details > summary { cursor:pointer; padding:9px 12px; font-weight:600; font-size:12.5px; list-style:none; display:flex; align-items:center; gap:8px; }
277
+ .dm-details > summary::-webkit-details-marker { display:none; }
278
+ .dm-details > summary::before { content:'▸'; font-size:10px; opacity:.5; transition:transform .12s; }
279
+ .dm-details[open] > summary::before { transform:rotate(90deg); }
280
+ .dm-details > .dm-body { padding:0 12px 11px; }
281
+ .dm-table { width:100%; border-collapse:collapse; }
282
+ .dm-table th { text-align:left; font-weight:600; font-size:11px; opacity:.5; padding:2px 8px 5px 0; border-bottom:1px solid rgba(128,128,128,.2); white-space:nowrap; }
283
+ .dm-table td { padding:5px 8px 5px 0; border-bottom:1px solid rgba(128,128,128,.1); font-size:12px; }
284
+ .dm-table tr:last-child td { border-bottom:none; }
285
+ .dm-trunc { max-width:190px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:inline-block; vertical-align:bottom; }
286
+ .dm-instrow { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:6px 0; border-bottom:1px solid rgba(128,128,128,.1); }
287
+ .dm-instrow:last-child { border-bottom:none; }
288
+ .dm-alertrow { display:flex; gap:8px; align-items:baseline; padding:5px 0; border-bottom:1px dashed rgba(128,128,128,.16); font-size:12px; }
289
+ .dm-alertrow:last-child { border-bottom:none; }
290
+ .dm-empty { opacity:.55; font-size:12px; padding:4px 0; }
291
+ .dm-err { color:#c9362c; font-size:12px; }
292
+ .dm-foot { font-size:11px; opacity:.45; }
293
+ @media (prefers-color-scheme: dark) {
294
+ .dm-pill.ok { color:#3fb950; } .dm-pill.bad { color:#ff7b72; } .dm-pill.warn { color:#d29922; }
295
+ .dm-btn.primary { color:#79b8ff; }
296
+ .dm-card-node { background:#1d2025; box-shadow:0 8px 26px rgba(0,0,0,.45); }
297
+ .dm-card-node.local { background:#19222e; }
298
+ .dm-card-node.down { background:rgba(90,32,32,.45); border-color:rgba(248,81,73,.65); }
299
+ .dm-offline-row { color:#ff7b72; }
300
+ .dm-dot.ok { background:#3fb950; } .dm-dot.bad { background:#ff7b72; }
301
+ .dm-chip.run, .dm-chip.ok { color:#3fb950; } .dm-chip.warn { color:#d29922; } .dm-chip.bad { color:#ff7b72; }
302
+ .dm-err { color:#ff7b72; }
303
+ .dm-details { background:rgba(255,255,255,.025); }
304
+ }
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; }
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; }
312
+ .dm-relay-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
313
+ .dm-relay-title { font-size:12.5px; font-weight:650; }
314
+ .dm-relay-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:8px; border:1px dashed rgba(128,128,128,.26); border-radius:10px; }
315
+ .dm-relay-end { min-width:120px; display:flex; flex-direction:column; gap:2px; }
316
+ .dm-relay-name { font-size:12px; font-weight:650; }
317
+ .dm-relay-sub { font-size:10.5px; opacity:.55; }
318
+ .dm-relay-hub { min-width:190px; display:flex; flex-direction:column; gap:4px; padding:8px 10px; border:1.4px solid rgba(210,153,34,.55); border-radius:10px; background:rgba(210,153,34,.10); }
319
+ .dm-relay-chips { display:flex; gap:5px; flex-wrap:wrap; }
320
+ .dm-relay-link { display:flex; align-items:center; gap:5px; min-width:56px; color:rgba(128,128,128,.75); }
321
+ .dm-relay-line { flex:1 1 auto; height:0; border-top:1.6px dashed rgba(63,185,80,.55); }
322
+ .dm-relay-dirlabel { font-size:10px; opacity:.6; white-space:nowrap; }
323
+ .dm-relay-peers { display:flex; flex-direction:column; gap:4px; min-width:150px; flex:1 1 auto; }
324
+ .dm-relay-peer { display:flex; align-items:center; gap:6px; font-size:11.5px; }
325
+ .dm-relay-peer.down { opacity:.55; }
326
+ .dm-relay-peer-name { font-weight:600; }
327
+ .dm-relay-peer-meta { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:10.5px; opacity:.7; }
328
+ .dm-relay-direct { display:flex; align-items:center; gap:6px; flex-wrap:wrap; font-size:11px; }
329
+ .dm-relay-tag { margin-top:5px; font-size:10px; opacity:.75; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
330
+ `
331
+
332
+ function fetchStatus(force) {
333
+ return fetch(force ? API + '?force=1' : API, { headers: { accept: 'application/json' } }).then(function (res) { return res.json() })
334
+ }
335
+ function tone(verdict) {
336
+ if (verdict === 'ok') return 'ok'
337
+ if (verdict === 'down') return 'bad'
338
+ if (verdict === 'degraded') return 'warn'
339
+ return 'muted'
340
+ }
341
+ function ago(iso) {
342
+ if (!iso) return '—'
343
+ var diff = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000))
344
+ if (diff < 60) return diff + ' 秒前'
345
+ if (diff < 3600) return Math.round(diff / 60) + ' 分钟前'
346
+ if (diff < 86400) return Math.round(diff / 3600) + ' 小时前'
347
+ return Math.round(diff / 86400) + ' 天前'
348
+ }
349
+ function clockText(iso) {
350
+ if (!iso) return '—'
351
+ var d = new Date(iso)
352
+ if (isNaN(d.getTime())) return '—'
353
+ function p2(n) { return (n < 10 ? '0' : '') + n }
354
+ return p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds())
355
+ }
356
+ function uptimeText(sec) {
357
+ if (sec == null) return '—'
358
+ var s = Math.max(0, Number(sec))
359
+ var days = Math.floor(s / 86400)
360
+ var hours = Math.floor((s % 86400) / 3600)
361
+ var mins = Math.floor((s % 3600) / 60)
362
+ if (days > 0) return days + '天' + hours + '小时'
363
+ if (hours > 0) return hours + '小时' + mins + '分'
364
+ return mins + '分'
365
+ }
366
+ function dur(sec) {
367
+ if (sec == null) return '—'
368
+ if (sec < 60) return sec + 's'
369
+ if (sec < 3600) return Math.floor(sec / 60) + 'm' + (sec % 60) + 's'
370
+ return Math.floor(sec / 3600) + 'h' + Math.floor((sec % 3600) / 60) + 'm'
371
+ }
372
+ function stateChip(state) {
373
+ // 设置页表格里的状态也统一中文
374
+ var map = { done: 'ok', failed: 'bad', cancelled: 'muted', stalled: 'warn', working: 'run', question: 'warn', submitted: 'muted', accepted: 'muted' }
375
+ return h('span', { className: 'dm-chip ' + (map[state] || 'muted') }, DG_STATE_LABELS[state] || state || '未知')
376
+ }
377
+
378
+ function Spark(props) {
379
+ var values = props.values || []
380
+ var pts = values.filter(function (v) { return v != null })
381
+ if (pts.length < 2) return h('span', { className: 'dm-sub' }, '—')
382
+ var min = Math.min.apply(null, pts)
383
+ var max = Math.max.apply(null, pts)
384
+ var span = max - min || 1
385
+ var SW = 54
386
+ var SH = 15
387
+ var d = []
388
+ var open = false
389
+ values.forEach(function (v, i) {
390
+ if (v == null) { open = false; return }
391
+ var x = (i / Math.max(1, values.length - 1)) * SW
392
+ var y = SH - ((v - min) / span) * (SH - 4) - 2
393
+ d.push((open ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1))
394
+ open = true
395
+ })
396
+ return h('svg', { width: SW, height: SH, viewBox: '0 0 ' + SW + ' ' + SH, style: { overflow: 'visible' } },
397
+ h('path', { d: d.join(' '), fill: 'none', stroke: props.up ? '#2e9e4f' : '#d14b41', strokeWidth: 1.6, strokeLinejoin: 'round', strokeLinecap: 'round' })
398
+ )
399
+ }
400
+
401
+ // ---------- 画布:缩放 / 平移 ----------
402
+ function Viewport(props) {
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
+ }
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
+ }
525
+ var dragRef = useRef(null)
526
+ var interactedRef = useRef(false)
527
+ var s0 = useState({ scale: 1, x: 0, y: 0 }); var view = s0[0]; var setView = s0[1]
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
537
+
538
+ var fit = useCallback(function () {
539
+ var el = viewRef.current
540
+ if (!el) return
541
+ var rect = el.getBoundingClientRect()
542
+ if (!rect.width || !rect.height) return
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
565
+ scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale))
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
+ }
572
+ }, [layout.W, layout.H])
573
+
574
+ useEffect(function () {
575
+ fit()
576
+ var el = viewRef.current
577
+ if (!el || typeof ResizeObserver === 'undefined') return
578
+ var ro = new ResizeObserver(function () {
579
+ if (!interactedRef.current) fit()
580
+ })
581
+ ro.observe(el)
582
+ return function () { try { ro.disconnect() } catch (_) { /* ignore */ } }
583
+ }, [fit])
584
+
585
+ // 2026-11-10:布局变化(节点数/卡片尺寸)后自动 fit(用户未手动缩放/平移时),避免停留在旧的缩放比例
586
+ useEffect(function () {
587
+ if (!interactedRef.current) setTimeout(fit, 40)
588
+ }, [layout.count, layout.cardW, layout.cardH])
589
+
590
+ useEffect(function () {
591
+ var el = viewRef.current
592
+ if (!el) return
593
+ function onWheel(event) {
594
+ event.preventDefault()
595
+ var rect = el.getBoundingClientRect()
596
+ var px = event.clientX - rect.left
597
+ var py = event.clientY - rect.top
598
+ var factor = Math.pow(1.0015, -event.deltaY)
599
+ interactedRef.current = true
600
+ setView(function (prev) {
601
+ var next = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * factor))
602
+ var k = next / prev.scale
603
+ return { scale: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k }
604
+ })
605
+ }
606
+ el.addEventListener('wheel', onWheel, { passive: false })
607
+ return function () { el.removeEventListener('wheel', onWheel) }
608
+ }, [])
609
+
610
+ function onPointerDown(event) {
611
+ if (event.button !== 0) return
612
+ if (event.target && event.target.closest && event.target.closest('button,a,summary,.dm-filters,.dm-zoom,.dm-resize')) return
613
+ dragRef.current = { x: event.clientX, y: event.clientY, vx: view.x, vy: view.y }
614
+ setDragging(true)
615
+ event.currentTarget.setPointerCapture && event.currentTarget.setPointerCapture(event.pointerId)
616
+ }
617
+ function onPointerMove(event) {
618
+ var drag = dragRef.current
619
+ if (!drag) return
620
+ interactedRef.current = true
621
+ setView(function (prev) { return { scale: prev.scale, x: drag.vx + (event.clientX - drag.x), y: drag.vy + (event.clientY - drag.y) } })
622
+ }
623
+ function endDrag() { dragRef.current = null; setDragging(false) }
624
+
625
+ function zoomBy(multiplier) {
626
+ var el = viewRef.current
627
+ if (!el) return
628
+ var rect = el.getBoundingClientRect()
629
+ var px = rect.width / 2
630
+ var py = rect.height / 2
631
+ interactedRef.current = true
632
+ setView(function (prev) {
633
+ var next = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * multiplier))
634
+ var k = next / prev.scale
635
+ return { scale: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k }
636
+ })
637
+ }
638
+
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 + ' 条链路 · 拖拽卡片自定义布局')
654
+ ),
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
+ )
679
+ ),
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) + '% · 滚轮缩放 / 拖拽平移')
681
+ )
682
+ }
683
+
684
+ // ---------- 布局计算 ----------
685
+ // 用户偏好(2026-11-07 明确):
686
+ // 4 节点 = 菱形(上/右/下/左);5 节点 = 顶点朝上的五边形;6 = 六边形;n 节点 = 顶点朝上的正 n 边形。
687
+ // 相邻节点的「轴对齐包围盒」必须留出 GAP,否则菱形斜向两张卡会贴住/重叠。
688
+ // 2026-11-09-v6:默认布局 = 顶部「阿里云 / 樱花」服务器一排 + 设备沿下方弧线扇形展开(用户指定)
689
+ function computeLayout(count, override) {
690
+ var GAP = 30
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)
695
+ var PAD = 16
696
+ var topTierH = Math.round(cardH * 1.1)
697
+ var W, H, positions
698
+ if (count <= 1) {
699
+ W = cardW + PAD * 2
700
+ H = topTierH + cardH + PAD * 2
701
+ positions = [[W / 2, topTierH + cardH / 2 + PAD]]
702
+ } else if (count === 2) {
703
+ W = cardW * 2 + GAP + PAD * 2
704
+ H = topTierH + cardH + PAD * 2
705
+ positions = [[PAD + cardW / 2, topTierH + cardH / 2 + PAD], [PAD + cardW * 1.5 + GAP, topTierH + cardH / 2 + PAD]]
706
+ } else {
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++) {
714
+ var ok = true
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
721
+ }
722
+ if (ok) break
723
+ A += 4
724
+ }
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)
731
+ positions = []
732
+ for (var j = 0; j < count; j++) {
733
+ var aj = START - j * stepA
734
+ positions.push([cx + A * Math.cos(aj), cy + B * Math.sin(aj)])
735
+ }
736
+ }
737
+ var edges = []
738
+ for (var m = 0; m < count; m++) for (var n = m + 1; n < count; n++) edges.push([m, n])
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 }
741
+ }
742
+ // ---------- 架构层:公网中转站(frps)与各节点通信关系(2026-11-09) ----------
743
+ function RelayLayer(props) {
744
+ var data = props.data || {}
745
+ var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
746
+ var groups = relayGroups(peers)
747
+ var relayHosts = relayHostSet(peers)
748
+ var direct = peers.filter(function (p) { var t = p.tunnel || {}; return t.kind === 'lan' || t.kind === 'loopback' })
749
+ var instances = (data.local && data.local.instances) || []
750
+ var localUp = instances.filter(function (x) { return x.up }).length
751
+ // 2026-11-09:樱花账号隧道(PC 端 API 采集,跨设备含手机)
752
+ var sakuraAccount = (data.tunnels && data.tunnels.sakuraAccount) || null
753
+ if (!groups.length && !direct.length && !sakuraAccount) return null
754
+ return h('div', { className: 'dm-relay' },
755
+ h('div', { className: 'dm-relay-head' },
756
+ h('span', { className: 'dm-relay-title' }, '架构层 · 公网中转站'),
757
+ h('span', { className: 'dm-sub' }, groups.length ? '虚线 = 经中转;实线/标签 = 直连。端口是各节点在中转站上的隧道入口' : '当前没有共享公网中转的 peer,全部为直连')
758
+ ),
759
+ groups.map(function (g, gi) {
760
+ var ports = Object.keys(g.ports).sort()
761
+ return h('div', { className: 'dm-relay-row', key: 'g' + gi },
762
+ h('div', { className: 'dm-relay-end' },
763
+ h('div', { className: 'dm-relay-name' }, nodeDisplay(data.nodeName) + '(本机)'),
764
+ h('div', { className: 'dm-relay-sub' }, 'DSH ' + localUp + '/' + instances.length + ' 在线')
765
+ ),
766
+ h('div', { className: 'dm-relay-link' }, h('span', { className: 'dm-relay-line' }), h('span', { className: 'dm-relay-dirlabel' }, '出站')),
767
+ h('div', { className: 'dm-relay-hub' },
768
+ h('div', { className: 'dm-relay-name' }, '公网中转站 · frps'),
769
+ h('div', { className: 'dm-relay-sub dm-mono' }, g.host),
770
+ h('div', { className: 'dm-relay-chips' },
771
+ ports.map(function (p) { return h('span', { className: 'dm-chip', key: p, title: '隧道入口 :' + p }, ':' + p + ' ×' + g.ports[p]) }),
772
+ h('span', { className: 'dm-chip ' + (g.up === g.peers.length ? 'run' : 'bad') }, '在线 ' + g.up + '/' + g.peers.length)
773
+ )
774
+ ),
775
+ h('div', { className: 'dm-relay-link' }, h('span', { className: 'dm-relay-line' }), h('span', { className: 'dm-relay-dirlabel' }, '中转')),
776
+ h('div', { className: 'dm-relay-peers' }, g.peers.map(function (peer) {
777
+ var t = peer.tunnel || {}
778
+ return h('div', { className: 'dm-relay-peer' + (peer.up ? '' : ' down'), key: peer.name },
779
+ h('span', { className: 'dm-dot ' + (peer.up ? 'ok' : 'bad') }),
780
+ h('span', { className: 'dm-relay-peer-name' }, nodeDisplay(peerDeviceKey(peer.name))),
781
+ h('span', { className: 'dm-relay-peer-meta' }, (t.port ? ':' + t.port : '') + (peer.up && peer.latencyMs != null ? ' · ' + peer.latencyMs + 'ms' : ' · 离线'))
782
+ )
783
+ }))
784
+ )
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,
794
+ direct.length ? h('div', { className: 'dm-relay-direct' },
795
+ h('span', { className: 'dm-k' }, '直连:'),
796
+ direct.map(function (peer) {
797
+ var c = commLabel(peer, relayHosts)
798
+ return h('span', { className: 'dm-chip ' + (peer.up ? 'run' : 'bad'), key: peer.name, title: c.text }, nodeDisplay(peerDeviceKey(peer.name)) + ' · ' + c.text)
799
+ })
800
+ ) : null
801
+ )
802
+ }
803
+
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 数)+ 点击选中 ----------
899
+ function Topology(props) {
900
+ var data = props.data
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) }
968
+ var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
969
+ var relayHosts = relayHostSet(peers)
970
+ var endpoint = data.endpoint || {}
971
+ var instances = (data.local && data.local.instances) || []
972
+ var hw = data.hardware || {}
973
+ var localTunnel = data.localTunnel || {}
974
+ var localUp = instances.filter(function (x) { return x.up }).length
975
+ var localTunnelText = localTunnel.port
976
+ ? (localTunnel.frpRunning ? (localTunnel.frpCount ? localTunnel.frpCount : '') : '') || ''
977
+ : ''
978
+
979
+ function localDomain() {
980
+ var entry = localTunnel.publicEntry
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
+ }
986
+ if (localTunnel.frpRunning) return 'SakuraFrp 已开 · ' + (localTunnel.bind || '?') + ':' + localTunnel.port
987
+ if (localTunnel.port) return '未开穿透 · ' + (localTunnel.bind || '?') + ':' + localTunnel.port
988
+ return '未配置'
989
+ }
990
+ function peerDomain(peer) {
991
+ var t = peer.tunnel || {}
992
+ if (!t.host) return '—'
993
+ return t.host + (t.port ? ':' + t.port : '') + (t.kind === 'lan' ? '(内网)' : '')
994
+ }
995
+
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 }) }
1060
+
1061
+ var GAP_EDGE = 6
1062
+ function edgePoint(from, to, hw, hh) {
1063
+ var dx = to[0] - from[0]
1064
+ var dy = to[1] - from[1]
1065
+ var len = Math.sqrt(dx * dx + dy * dy) || 1
1066
+ var ux = dx / len
1067
+ var uy = dy / len
1068
+ var tx = ux === 0 ? Infinity : (hw + GAP_EDGE) / Math.abs(ux)
1069
+ var ty = uy === 0 ? Infinity : (hh + GAP_EDGE) / Math.abs(uy)
1070
+ var t = Math.min(tx, ty)
1071
+ return [from[0] + ux * t, from[1] + uy * t]
1072
+ }
1073
+
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' }, '离线'))
1177
+ ),
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
+ )
1185
+ )
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
+ }
1221
+
1222
+ // ---------- 画布下方:设备详情(默认全展示,点卡片高亮对应项) ----------
1223
+ function DeviceDetails(props) {
1224
+ var data = props.data
1225
+ var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
1226
+ var relayHosts = relayHostSet(peers)
1227
+ var hw = data.hardware || {}
1228
+ var instances = (data.local && data.local.instances) || []
1229
+ var endpoint = data.endpoint || {}
1230
+ var localTunnel = data.localTunnel || {}
1231
+ var localUp = instances.filter(function (x) { return x.up }).length
1232
+ var sessions = instances.reduce(function (n, x) { return n + (x.sessions || 0) }, 0)
1233
+ var running = instances.reduce(function (n, x) { return n + (x.running || 0) }, 0)
1234
+ var model = instances.length && instances[0].model ? (instances[0].model.provider || '') + ' / ' + (instances[0].model.model || '') : '—'
1235
+ var localTunnelText = null
1236
+ var tunnels = localTunnel.tunnels || []
1237
+ var entry = localTunnel.publicEntry || null
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
+ : '未配置'
1243
+ var localTunnelRows = [
1244
+ ['入口', entryText],
1245
+ ['监听 / frpc', (localTunnel.bind || '?') + ':' + (localTunnel.port || '?') + (localTunnel.tls ? ' (TLS)' : '') + ' · ' + (localTunnel.frpRunning ? 'frpc ×' + (localTunnel.frpcCount || 1) : 'frpc 未运行')]
1246
+ ]
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])
1249
+ var localTunnelAll = tunnelNames
1250
+ var memText = hw.memTotalGB ? ((hw.memFreeGB != null ? hw.memFreeGB : '?') + ' / ' + hw.memTotalGB + ' GB') : '—'
1251
+ var cpuText = hw.cpuModel ? hw.cpuModel + ' · ' + (hw.cpuCores || '?') + ' 线程' : '—'
1252
+
1253
+ var devices = [{
1254
+ key: 'local',
1255
+ name: nodeDisplay(data.nodeName) + '(本机)',
1256
+ up: Boolean(endpoint.up),
1257
+ rows: [
1258
+ ['操作系统', hw.os || '—'],
1259
+ ['CPU', cpuText],
1260
+ ['内存', memText],
1261
+ ['DSH 实例', localUp + '/' + instances.length + ' 在线'],
1262
+ ['会话 / 运行', sessions + ' / ' + running],
1263
+ ['模型', model],
1264
+ ...localTunnelRows,
1265
+ ['端点', (endpoint.pid != null ? 'pid ' + endpoint.pid : '—') + ' · tools ' + (endpoint.tools != null ? endpoint.tools : '—') + ' · mesh ' + (data.meshVersion || '?')],
1266
+ ['检测时间', clockText(data.at)]
1267
+ ]
1268
+ }].concat(peers.map(function (peer) {
1269
+ var t = peer.tunnel || {}
1270
+ return {
1271
+ key: 'peer:' + peer.name,
1272
+ name: nodeDisplay(peerDeviceKey(peer.name)),
1273
+ up: Boolean(peer.up),
1274
+ rows: [
1275
+ ['操作系统', peer.os || '—'],
1276
+ ['运行时长', uptimeText(peer.uptimeSec)],
1277
+ ['DSH 实例', (peer.instancesCount || 0) + ' 个(' + ((peer.instancesList || []).map(function (x) { return x.port + (x.up ? '✓' : '✗') }).join(' ') || '—') + ')'],
1278
+ ['会话 / 运行', (peer.sessions || 0) + ' / ' + (peer.running || 0)],
1279
+ ['任务(进行中)', peer.tasksActive == null ? '—' : String(peer.tasksActive)],
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)],
1283
+ ['检测时间', clockText(peer.detectedAt || data.at)]
1284
+ ]
1285
+ }
1286
+ }))
1287
+
1288
+ return h('div', { className: 'dm-devices' },
1289
+ devices.map(function (device) {
1290
+ return h('div', { key: device.key, className: 'dm-device' + (props.selectedKey === device.key ? ' selected' : '') },
1291
+ h('div', { className: 'dm-device-head' },
1292
+ h('span', { className: 'dm-dot ' + (device.up ? 'ok' : 'bad') }),
1293
+ h('span', { className: 'dm-device-name' }, device.name),
1294
+ device.key === 'local' ? h('span', { className: 'dm-chip ok' }, '本机') : null
1295
+ ),
1296
+ h('div', { className: 'dm-device-body' }, device.rows.map(function (row, i) {
1297
+ return h('div', { className: 'dm-device-row', key: i },
1298
+ h('span', { className: 'dm-device-k' }, row[0]),
1299
+ h('span', { className: 'dm-device-v', title: String(row[1]) }, String(row[1]))
1300
+ )
1301
+ }))
1302
+ )
1303
+ })
1304
+ )
1305
+ }
1306
+
1307
+ function Details(props) {
1308
+ return h('details', { className: 'dm-details', open: Boolean(props.open) },
1309
+ h('summary', null, props.title, props.badge != null ? h('span', { className: 'dm-pill muted' }, String(props.badge)) : null),
1310
+ h('div', { className: 'dm-body' }, props.children)
1311
+ )
1312
+ }
1313
+
1314
+ function InstanceList(props) {
1315
+ var instances = (props.data.local && props.data.local.instances) || []
1316
+ if (!instances.length) return h('div', { className: 'dm-empty' }, '无本机实例数据')
1317
+ return h('div', null, instances.map(function (inst) {
1318
+ var model = inst.model ? (inst.model.provider || '') + ' / ' + (inst.model.model || '') : '—'
1319
+ return h('div', { className: 'dm-instrow', key: inst.id },
1320
+ h('div', { style: { minWidth: 0 } },
1321
+ h('div', { style: { fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 } }, h('span', { className: 'dm-dot ' + (inst.up ? 'ok' : 'bad') }), inst.id),
1322
+ h('div', { className: 'dm-sub', style: { marginTop: 1 } }, model)
1323
+ ),
1324
+ h('div', { className: 'dm-mini', style: { marginTop: 0 } },
1325
+ h('span', { className: 'dm-chip' }, 'pid ' + (inst.pid == null ? '—' : inst.pid)),
1326
+ h('span', { className: 'dm-chip' }, 'tools ' + (inst.tools == null ? '—' : inst.tools)),
1327
+ h('span', { className: 'dm-chip' }, '会话 ' + (inst.sessions || 0)),
1328
+ h('span', { className: 'dm-chip' + (inst.running ? ' run' : '') }, '运行 ' + (inst.running || 0))
1329
+ )
1330
+ )
1331
+ }))
1332
+ }
1333
+
1334
+ function DelegationTable(props) {
1335
+ var d = props.data.delegations || {}
1336
+ var list = (d.recent || []).slice(0, 6)
1337
+ var stats = d.byState || {}
1338
+ if (!list.length) return h('div', { className: 'dm-empty' }, '暂无委托记录')
1339
+ return h('div', null,
1340
+ h('div', { className: 'dm-sub', style: { marginBottom: 5 } }, '共 ' + (d.total || 0) + ' 条 · ' + Object.keys(stats).map(function (k) { return k + ' ' + stats[k] }).join(' · ')),
1341
+ h('table', { className: 'dm-table' },
1342
+ h('thead', null, h('tr', null, h('th', null, '状态'), h('th', null, '节点'), h('th', null, '任务'), h('th', null, '耗时'), h('th', null, '目标'))),
1343
+ h('tbody', null, list.map(function (task) {
1344
+ return h('tr', { key: task.taskId },
1345
+ h('td', null, stateChip(task.state)),
1346
+ h('td', null, nodeLabel(task.node)),
1347
+ h('td', { className: 'dm-mono', style: { fontSize: 11 } }, String(task.taskId || '').replace(/^T-\d{8}-/, 'T-…')),
1348
+ h('td', { className: 'dm-mono' }, dur(task.durationSec)),
1349
+ h('td', null, h('span', { className: 'dm-trunc', title: task.goal }, task.goal || ''))
1350
+ )
1351
+ }))
1352
+ )
1353
+ )
1354
+ }
1355
+
1356
+ function AlertList(props) {
1357
+ var alerts = props.data.alerts || {}
1358
+ var latest = alerts.latest
1359
+ var history = (alerts.history || []).filter(function (item) { return !latest || item.at !== latest.at }).slice().reverse()
1360
+ if (!latest && !history.length) return h('div', { className: 'dm-empty' }, '没有告警记录')
1361
+ return h('div', null,
1362
+ latest ? h('div', { className: 'dm-alertrow' },
1363
+ h('span', { className: 'dm-chip bad' }, latest.kind || 'alert'),
1364
+ h('span', null, h('span', { className: 'dm-mono' }, latest.at), ' · ', latest.summary || '', latest.consecutiveBad ? ' · 连续 ' + latest.consecutiveBad + ' 次' : '')
1365
+ ) : null,
1366
+ history.slice(0, 6).map(function (item, i) {
1367
+ return h('div', { className: 'dm-alertrow', key: i, style: { opacity: 0.75 } },
1368
+ h('span', { className: 'dm-chip' }, item.kind || '?'),
1369
+ h('span', null, h('span', { className: 'dm-mono' }, item.at || ''), ' · ', item.summary || '')
1370
+ )
1371
+ })
1372
+ )
1373
+ }
1374
+
1375
+ function MeshStatusSection() {
1376
+ var s0 = useState(null); var data = s0[0]; var setData = s0[1]
1377
+ var s1 = useState(true); var loading = s1[0]; var setLoading = s1[1]
1378
+ var s2 = useState(''); var error = s2[0]; var setError = s2[1]
1379
+ var s3 = useState(0); var tick = s3[0]; var setTick = s3[1]
1380
+ var s4 = useState(''); var copied = s4[0]; var setCopied = s4[1]
1381
+ var s5 = useState('local'); var selectedKey = s5[0]; var setSelectedKey = s5[1]
1382
+
1383
+ function load(force) {
1384
+ setLoading(true)
1385
+ fetchStatus(Boolean(force))
1386
+ .then(function (json) {
1387
+ setData(json)
1388
+ setError(json && json.ok === false && !json.warming ? json.error || 'mesh-health error' : '')
1389
+ })
1390
+ .catch(function (err) { setError(String((err && err.message) || err)) })
1391
+ .finally(function () { setLoading(false) })
1392
+ }
1393
+
1394
+ useEffect(function () {
1395
+ load(false)
1396
+ var timer = setInterval(function () { setTick(function (n) { return n + 1 }) }, REFRESH_MS)
1397
+ return function () { clearInterval(timer) }
1398
+ }, [])
1399
+
1400
+ useEffect(function () { if (tick > 0) load(false) }, [tick])
1401
+
1402
+ function copyJson() {
1403
+ var text = JSON.stringify(data || {}, null, 2)
1404
+ var done = function () { setCopied('已复制'); setTimeout(function () { setCopied('') }, 1600) }
1405
+ try {
1406
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, done)
1407
+ else done()
1408
+ } catch (_) { done() }
1409
+ }
1410
+
1411
+ if (!data) {
1412
+ return h('div', { className: 'dm-root' },
1413
+ h('div', { className: 'dm-head' }, h('span', { className: 'dm-title' }, 'AI 网络'), h('span', { className: 'dm-pill muted' }, loading ? '加载中…' : '无数据')),
1414
+ error ? h('div', { className: 'dm-err' }, error) : null
1415
+ )
1416
+ }
1417
+
1418
+ var versions = data.versionMismatch || []
1419
+ var issues = data.issues || []
1420
+ var hints = data.hints || []
1421
+
1422
+ return h('div', { className: 'dm-root' },
1423
+ h('style', null, CSS),
1424
+ h('div', { className: 'dm-head' },
1425
+ h('span', { className: 'dm-title' }, 'AI 网络 · dsh-mesh'),
1426
+ h('span', { className: 'dm-pill ' + (data.warming ? 'muted' : tone(data.verdict)) }, data.warming ? '预热中' : (data.verdict || '?')),
1427
+ h('span', { className: 'dm-sub' }, (data.meshVersion ? 'mesh ' + data.meshVersion + ' · ' : '') + (data.at ? ago(data.at) : '')),
1428
+ h('span', { style: { flex: 1 } }),
1429
+ h('a', { className: 'dm-btn', href: API, target: '_blank', rel: 'noreferrer', style: { textDecoration: 'none' } }, '原始 JSON'),
1430
+ h('button', { className: 'dm-btn', onClick: copyJson }, copied || '复制诊断'),
1431
+ h('button', { className: 'dm-btn primary', onClick: function () { load(true) } }, loading ? '刷新中…' : '刷新')
1432
+ ),
1433
+ error ? h('div', { className: 'dm-err' }, error) : null,
1434
+
1435
+ h(Viewport, { data: data, layout: computeLayout(1 + filterSelfPeers(dedupePeers(data.peers || []), data.nodeName).length), selectedKey: selectedKey, onSelect: setSelectedKey }),
1436
+ h(InfraPanels, { data: data }),
1437
+ h(DeviceDetails, { data: data, selectedKey: selectedKey }),
1438
+ h(Details, { title: '本机实例', badge: ((data.local && data.local.instances) || []).length, open: true }, h(InstanceList, { data: data })),
1439
+ h(Details, { title: '版本一致性', badge: versions.length ? versions.length + ' 处升级' : '一致' },
1440
+ versions.length
1441
+ ? versions.map(function (item, i) { return h('div', { key: i, className: 'dm-sub', style: { marginTop: 4 } }, '· ' + (typeof item === 'string' ? item : JSON.stringify(item))) })
1442
+ : h('div', { className: 'dm-empty' }, '所有节点/实例版本一致')
1443
+ ),
1444
+ h(Details, { title: '委托台账', badge: (data.delegations && data.delegations.total) || 0 }, h(DelegationTable, { data: data })),
1445
+ h(Details, { title: '告警历史', badge: ((data.alerts && data.alerts.history) || []).length }, h(AlertList, { data: data })),
1446
+ (issues.length || hints.length) ? h(Details, { title: '诊断建议', badge: issues.length + hints.length },
1447
+ h('div', null,
1448
+ issues.slice(0, 6).map(function (issue, i) { return h('div', { key: 'i' + i, className: 'dm-err', style: { marginTop: 3 } }, '· ' + issue) }),
1449
+ hints.slice(0, 6).map(function (hint, i) { return h('div', { key: 'h' + i, className: 'dm-sub', style: { marginTop: 3 } }, '· ' + hint) })
1450
+ )
1451
+ ) : null,
1452
+ h('div', { className: 'dm-foot' }, '数据源:mesh-health / host status / peer host_self / 委托台账 / 告警历史 · ' + (data.repo || '') + ' · node ' + (data.nodeVersion || '') + ' · ' + REFRESH_MS / 1000 + 's 自动刷新(只读)')
1453
+ )
1454
+ }
1455
+
1456
+ // ================= 委托面板(只读,方案 A / v2 视觉重做) =================
1457
+ var DG_API_LIST = '/api/dsh-mesh/delegations'
1458
+ var DG_API_TASK = '/api/dsh-mesh/task'
1459
+ var DG_API_SESSION = '/api/dsh-mesh/session'
1460
+
1461
+ var DG_STATE_TONE = {
1462
+ done: 'ok', working: 'run', accepted: 'run', submitted: 'run',
1463
+ question: 'warn', stalled: 'warn', failed: 'bad', cancelled: 'muted'
1464
+ }
1465
+
1466
+ var DG_CSS = `
1467
+ .dm-dg-root { display:flex; flex-direction:column; gap:12px; height:100%; min-height:0; padding:16px; box-sizing:border-box; font-size:13px; color:inherit; }
1468
+ .dm-dg-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
1469
+ .dm-dg-title { font-size:16px; font-weight:650; letter-spacing:.2px; }
1470
+ .dm-dg-sub { font-size:11.5px; opacity:.55; }
1471
+ .dm-dg-pill { font-size:10.5px; padding:1px 8px; border-radius:999px; border:1px solid rgba(128,128,128,.32); opacity:.85; }
1472
+ .dm-dg-pill.readonly { border-color:rgba(88,166,255,.5); color:#3b82d6; opacity:1; }
1473
+ .dm-dg-stats { display:flex; gap:6px; flex-wrap:wrap; }
1474
+ .dm-dg-stat { font-size:10.5px; padding:1px 8px; border-radius:999px; border:1px solid rgba(128,128,128,.28); display:flex; gap:5px; align-items:center; }
1475
+ .dm-dg-stat b { font-weight:650; }
1476
+ .dm-dg-spacer { flex:1; }
1477
+ .dm-dg-btn { font-size:11.5px; padding:4px 11px; border-radius:8px; border:1px solid rgba(128,128,128,.4); background:transparent; color:inherit; cursor:pointer; white-space:nowrap; display:inline-flex; align-items:center; gap:5px; }
1478
+ .dm-dg-btn:hover:not(:disabled) { background:rgba(128,128,128,.12); }
1479
+ .dm-dg-btn:disabled { opacity:.45; cursor:default; }
1480
+ .dm-dg-btn.primary { border-color:rgba(88,166,255,.6); color:#3b82d6; }
1481
+ .dm-dg-notice { font-size:11.5px; padding:7px 10px; border-radius:8px; border:1px solid transparent; display:flex; gap:8px; align-items:center; }
1482
+ .dm-dg-notice.info { background:rgba(88,166,255,.1); border-color:rgba(88,166,255,.35); }
1483
+ .dm-dg-notice.ok { background:rgba(63,185,80,.12); border-color:rgba(63,185,80,.4); color:#2e9e4f; }
1484
+ .dm-dg-notice.bad { background:rgba(248,81,73,.12); border-color:rgba(248,81,73,.4); color:#c9362c; }
1485
+ .dm-dg-body { display:grid; grid-template-columns:var(--dm-dg-list-w, 360px) 7px minmax(0,1fr); gap:10px; min-height:0; flex:1; }
1486
+ @media (max-width: 960px) { .dm-dg-body { grid-template-columns:1fr; } .dm-dg-divider { display:none; } }
1487
+ .dm-dg-pane { display:flex; flex-direction:column; min-height:0; border:1px solid rgba(128,128,128,.25); border-radius:12px; background:rgba(128,128,128,.035); overflow:hidden; }
1488
+ .dm-dg-pane-head { flex:none; display:flex; align-items:center; gap:8px; padding:9px 12px; border-bottom:1px solid rgba(128,128,128,.2); background:rgba(128,128,128,.05); font-weight:650; font-size:12px; }
1489
+ .dm-dg-pane-head .dm-dg-sub { font-weight:400; }
1490
+ .dm-dg-scroll { flex:1; min-height:0; overflow:auto; }
1491
+ .dm-dg-row { position:relative; display:flex; flex-direction:column; gap:5px; padding:10px 12px 10px 15px; border-bottom:1px solid rgba(128,128,128,.14); cursor:pointer; }
1492
+ .dm-dg-row:last-child { border-bottom:none; }
1493
+ .dm-dg-row::before { content:''; position:absolute; left:0; top:0; bottom:0; width:3px; background:transparent; }
1494
+ .dm-dg-row.ok::before { background:#2e9e4f; }
1495
+ .dm-dg-row.run::before { background:#3b82d6; }
1496
+ .dm-dg-row.warn::before { background:#d29922; }
1497
+ .dm-dg-row.bad::before { background:#d14b41; }
1498
+ .dm-dg-row:hover { background:rgba(128,128,128,.09); }
1499
+ .dm-dg-row.active { background:rgba(88,166,255,.13); }
1500
+ .dm-dg-divider { position:relative; cursor:col-resize; min-height:0; border-radius:4px; }
1501
+ .dm-dg-divider::before { content:''; position:absolute; top:0; bottom:0; left:3px; width:1px; background:rgba(128,128,128,.28); }
1502
+ .dm-dg-divider:hover::before, .dm-dg-divider:active::before { background:rgba(88,166,255,.7); width:2px; left:2.5px; }
1503
+ .dm-dg-spinner { flex:none; color:var(--dsw-alias-label-tertiary, rgba(128,128,128,.85)); }
1504
+ .dm-dg-spinner-motion { transform-origin:center; animation:dm-dg-spin 1.5s linear infinite; }
1505
+ .dm-dg-spinner-track, .dm-dg-spinner-arc { fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; }
1506
+ .dm-dg-spinner-track { opacity:.25; }
1507
+ .dm-dg-spinner-arc { stroke-dasharray:12 150; animation:dm-dg-dash 1.5s ease-in-out infinite; }
1508
+ @keyframes dm-dg-spin { to { transform:rotate(360deg); } }
1509
+ @keyframes dm-dg-dash {
1510
+ 0% { stroke-dasharray:12 150; stroke-dashoffset:0; }
1511
+ 50% { stroke-dasharray:24 150; stroke-dashoffset:-6; }
1512
+ 100% { stroke-dasharray:12 150; stroke-dashoffset:0; }
1513
+ }
1514
+ @media (prefers-reduced-motion: reduce) {
1515
+ .dm-dg-spinner-motion, .dm-dg-spinner-arc { animation:none; }
1516
+ .dm-dg-spinner-arc { stroke-dasharray:18 150; stroke-dashoffset:-3; }
1517
+ }
1518
+ .dm-dg-row-top { display:flex; align-items:center; gap:8px; min-width:0; }
1519
+ .dm-dg-row-node { font-weight:650; white-space:nowrap; }
1520
+ .dm-dg-route { display:inline-flex; align-items:center; gap:6px; min-width:0; }
1521
+ .dm-dg-from, .dm-dg-to { font-weight:650; font-size:12.5px; white-space:nowrap; }
1522
+ .dm-dg-from { color:inherit; opacity:.85; }
1523
+ .dm-dg-to { color:#3b82d6; }
1524
+ .dm-dg-arrow { opacity:.5; font-size:11px; }
1525
+ .dm-dg-me { font-size:9.5px; padding:0 5px; border-radius:999px; border:1px solid rgba(63,185,80,.55); color:#2e9e4f; line-height:15px; }
1526
+ .dm-dg-filters { position:relative; z-index:5; display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:7px 10px; border-bottom:1px solid rgba(128,128,128,.16); background:rgba(128,128,128,.03); }
1527
+ .dm-dg-fgroup { display:flex; align-items:center; gap:5px; flex-wrap:wrap; font-size:10px; opacity:.8; }
1528
+ .dm-dg-fs { position:relative; display:inline-flex; align-items:center; gap:4px; }
1529
+ .dm-dg-fs-label { font-size:10px; opacity:.6; }
1530
+ .dm-dg-fs-btn { display:inline-flex; align-items:center; gap:6px; font-size:10.5px; padding:2px 8px; border-radius:6px; border:1px solid rgba(128,128,128,.35); background:rgba(128,128,128,.08); color:inherit; cursor:pointer; }
1531
+ .dm-dg-fs-btn:hover { background:rgba(128,128,128,.16); }
1532
+ .dm-dg-fs-caret { font-size:8px; opacity:.55; }
1533
+ .dm-dg-fs-menu { position:absolute; top:calc(100% + 4px); left:0; min-width:132px; padding:4px; border-radius:8px; border:1px solid rgba(128,128,128,.3); background:#ffffff; color:#1a1a1a; box-shadow:0 8px 24px rgba(0,0,0,.18); z-index:90; display:flex; flex-direction:column; gap:2px; }
1534
+ .dm-dg-fs-item { text-align:left; font-size:11px; padding:4px 9px; border-radius:5px; border:none; background:transparent; color:inherit; cursor:pointer; white-space:nowrap; }
1535
+ .dm-dg-fs-item:hover { background:rgba(128,128,128,.18); }
1536
+ .dm-dg-fs-item.active { background:rgba(88,166,255,.2); color:#2b6fc4; }
1537
+ body[data-ds-dark-theme] .dm-dg-fs-menu { background:#24262b; color:#e6e6e6; border-color:rgba(255,255,255,.16); box-shadow:0 8px 24px rgba(0,0,0,.55); }
1538
+ body[data-ds-dark-theme] .dm-dg-fs-item:hover { background:rgba(255,255,255,.1); }
1539
+ body[data-ds-dark-theme] .dm-dg-fs-item.active { background:rgba(88,166,255,.28); color:#79b8ff; }
1540
+ .dm-dg-fbtn { font-size:10px; padding:2px 8px; border-radius:6px; border:1px solid rgba(128,128,128,.35); background:transparent; color:inherit; cursor:pointer; }
1541
+ .dm-dg-fhint { font-size:9.5px; opacity:.45; }
1542
+ .dm-dg-fchip { font-size:10px; padding:0 8px; line-height:16px; border-radius:999px; border:1px solid rgba(128,128,128,.3); background:transparent; color:inherit; cursor:pointer; }
1543
+ .dm-dg-fchip.active { border-color:rgba(88,166,255,.7); color:#3b82d6; background:rgba(88,166,255,.12); }
1544
+ .dm-dg-badge { padding:0 6px; border-radius:5px; border:1px solid rgba(128,128,128,.32); font-size:10px; opacity:.8; }
1545
+ .dm-dg-row-time { margin-left:auto; font-size:10.5px; opacity:.5; font-variant-numeric:tabular-nums; white-space:nowrap; }
1546
+ .dm-dg-chip { font-size:10px; padding:0 8px; border-radius:999px; border:1px solid rgba(128,128,128,.32); white-space:nowrap; line-height:17px; display:inline-block; }
1547
+ .dm-dg-chip.ok { border-color:rgba(63,185,80,.55); color:#2e9e4f; }
1548
+ .dm-dg-chip.bad { border-color:rgba(248,81,73,.55); color:#c9362c; }
1549
+ .dm-dg-chip.run { border-color:rgba(88,166,255,.55); color:#3b82d6; }
1550
+ .dm-dg-chip.warn { border-color:rgba(210,153,34,.6); color:#9a6c12; }
1551
+ .dm-dg-chip.muted { opacity:.7; }
1552
+ .dm-dg-goal { font-size:11.5px; opacity:.8; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
1553
+ .dm-dg-meta { display:flex; gap:8px; flex-wrap:wrap; font-size:10px; opacity:.5; font-variant-numeric:tabular-nums; }
1554
+ .dm-dg-empty { padding:22px 14px; text-align:center; opacity:.55; font-size:12px; line-height:1.7; }
1555
+ .dm-dg-skel { padding:10px 12px; display:flex; flex-direction:column; gap:8px; }
1556
+ .dm-dg-skel i { display:block; height:10px; border-radius:6px; background:rgba(128,128,128,.18); animation:dm-dg-pulse 1.2s ease-in-out infinite; }
1557
+ .dm-dg-skel i:nth-child(2) { width:70%; } .dm-dg-skel i:nth-child(3) { width:45%; }
1558
+ @keyframes dm-dg-pulse { 0%,100% { opacity:.45 } 50% { opacity:.9 } }
1559
+ .dm-dg-detail { display:flex; flex-direction:column; gap:12px; padding:12px; }
1560
+ .dm-dg-sec { border:1px solid rgba(128,128,128,.22); border-radius:10px; overflow:hidden; }
1561
+ .dm-dg-sec-head { display:flex; align-items:center; gap:8px; padding:7px 11px; border-bottom:1px solid rgba(128,128,128,.18); background:rgba(128,128,128,.05); font-weight:650; font-size:11.5px; }
1562
+ .dm-dg-sec-body { padding:10px 11px; display:flex; flex-direction:column; gap:7px; }
1563
+ .dm-dg-kv { display:grid; grid-template-columns:92px minmax(0,1fr); gap:6px 10px; align-items:baseline; font-size:11.5px; }
1564
+ .dm-dg-k { opacity:.55; }
1565
+ .dm-dg-v { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; display:flex; gap:6px; align-items:center; }
1566
+ .dm-dg-copy { font-size:10px; padding:0 6px; border-radius:6px; border:1px solid rgba(128,128,128,.35); background:transparent; color:inherit; cursor:pointer; opacity:.6; flex:none; }
1567
+ .dm-dg-copy:hover { opacity:1; }
1568
+ .dm-dg-pre { white-space:pre-wrap; word-break:break-word; font-size:11px; line-height:1.55; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; max-height:220px; overflow:auto; border:1px dashed rgba(128,128,128,.25); border-radius:8px; padding:8px; background:rgba(128,128,128,.04); }
1569
+ .dm-dg-msgs { display:flex; flex-direction:column; gap:8px; max-height:380px; overflow:auto; padding-right:2px; }
1570
+ .dm-dg-msg { border:1px solid rgba(128,128,128,.18); border-radius:9px; padding:8px 10px; background:rgba(128,128,128,.03); }
1571
+ .dm-dg-msg.user { border-left:3px solid rgba(63,185,80,.6); }
1572
+ .dm-dg-msg.assistant { border-left:3px solid rgba(88,166,255,.6); }
1573
+ .dm-dg-msg-head { display:flex; gap:8px; align-items:baseline; margin-bottom:5px; font-size:10px; opacity:.6; text-transform:uppercase; letter-spacing:.4px; }
1574
+ .dm-dg-msg-text { white-space:pre-wrap; word-break:break-word; font-size:11.5px; line-height:1.55; }
1575
+ .dm-dg-loading { display:inline-flex; gap:6px; align-items:center; font-size:11.5px; opacity:.7; }
1576
+ .dm-dg-spin { width:11px; height:11px; border:2px solid rgba(128,128,128,.35); border-top-color:currentColor; border-radius:50%; animation:dm-dg-rot .8s linear infinite; }
1577
+ @keyframes dm-dg-rot { to { transform:rotate(360deg) } }
1578
+ @media (prefers-color-scheme: dark) {
1579
+ .dm-dg-pill.readonly { color:#79b8ff; }
1580
+ .dm-dg-btn.primary { color:#79b8ff; }
1581
+ .dm-dg-notice.ok { color:#3fb950; } .dm-dg-notice.bad { color:#ff7b72; }
1582
+ .dm-dg-chip.ok { color:#3fb950; } .dm-dg-to { color:#79b8ff; } .dm-dg-chip.run { color:#79b8ff; } .dm-dg-chip.warn { color:#d29922; } .dm-dg-chip.bad { color:#ff7b72; }
1583
+ }
1584
+ `
1585
+
1586
+ // 仿 DSH StateDot 的 ongoing 转圈环(1.5s 转 + 弧线呼吸)
1587
+ function LoadingSpinner(props) {
1588
+ var size = (props && props.size) || 14
1589
+ return h('svg', {
1590
+ className: 'dm-dg-spinner',
1591
+ width: size,
1592
+ height: size,
1593
+ viewBox: '0 0 24 24',
1594
+ 'aria-hidden': 'true',
1595
+ 'data-state': 'ongoing'
1596
+ },
1597
+ h('g', { className: 'dm-dg-spinner-motion' },
1598
+ h('circle', { className: 'dm-dg-spinner-track', cx: 12, cy: 12, r: 9.5 }),
1599
+ h('circle', { className: 'dm-dg-spinner-arc', cx: 12, cy: 12, r: 9.5 })
1600
+ )
1601
+ )
1602
+ }
1603
+
1604
+ function dgFetch(path) {
1605
+ return fetch(path, { headers: { accept: 'application/json' } })
1606
+ .then(function (res) {
1607
+ return res.text().then(function (text) {
1608
+ var type = res.headers.get('content-type') || ''
1609
+ if (res.status === 404 || !/json/i.test(type)) {
1610
+ var routeError = new Error('route unavailable (HTTP ' + res.status + ')')
1611
+ routeError.code = 'E_NO_ROUTE'
1612
+ routeError.body = String(text || '').slice(0, 80)
1613
+ throw routeError
1614
+ }
1615
+ try {
1616
+ return JSON.parse(text)
1617
+ } catch (error) {
1618
+ var parseError = new Error('服务端返回的不是 JSON(可能需要重启该 DSH 实例)')
1619
+ parseError.code = 'E_PARSE'
1620
+ parseError.body = String(text || '').slice(0, 80)
1621
+ throw parseError
1622
+ }
1623
+ })
1624
+ })
1625
+ }
1626
+ function dgFriendly(err) {
1627
+ if (err && (err.code === 'E_NO_ROUTE' || err.code === 'E_PARSE')) return '当前 DSH 实例的服务端还是旧版本(缺接口),重启该实例后可用'
1628
+ return String((err && err.message) || err)
1629
+ }
1630
+ function dgDur(sec) {
1631
+ if (sec == null) return '—'
1632
+ if (sec < 60) return sec + 's'
1633
+ if (sec < 3600) return Math.floor(sec / 60) + 'm' + (sec % 60) + 's'
1634
+ return Math.floor(sec / 3600) + 'h' + Math.floor((sec % 3600) / 60) + 'm'
1635
+ }
1636
+ function dgClock(iso) {
1637
+ if (!iso) return '—'
1638
+ var d = new Date(iso)
1639
+ if (isNaN(d.getTime())) return '—'
1640
+ function p2(n) { return (n < 10 ? '0' : '') + n }
1641
+ return p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes())
1642
+ }
1643
+ function dgTone(state) { return DG_STATE_TONE[state] || 'muted' }
1644
+ // 状态/策略展示统一中文(内部值保持英文,避免影响筛选与接口)
1645
+ var DG_STATE_LABELS = {
1646
+ done: '已完成', working: '执行中', submitted: '已提交', accepted: '已受理',
1647
+ question: '待回复', stalled: '疑似卡住', failed: '失败', cancelled: '已取消',
1648
+ timeout: '超时', running: '运行中', provisioning: '准备中', inactive: '空闲', unknown: '未知'
1649
+ }
1650
+ var DG_POLICY_LABELS = { new: '新会话', continue: '续会话', auto: '自动', session: '指定会话' }
1651
+ function dgStateLabel(state) { return DG_STATE_LABELS[String(state || '')] || String(state || '未知') }
1652
+ function dgPolicyLabel(policy) { return DG_POLICY_LABELS[String(policy || '')] || String(policy || '—') }
1653
+ function dgChip(state) { return h('span', { className: 'dm-dg-chip ' + dgTone(state) }, dgStateLabel(state)) }
1654
+
1655
+ // 必须是 React 组件(返回元素),不能返回 DOM 节点
1656
+ function meshPanelIcon(props) {
1657
+ var size = (props && props.size) || 16
1658
+ return h('svg', {
1659
+ 'data-dsh-panel-entry': 'mesh-delegations', viewBox: '0 0 16 16', width: size, height: size,
1660
+ fill: 'none', stroke: 'currentColor', strokeWidth: '1.4', strokeLinecap: 'round', 'aria-hidden': 'true',
1661
+ style: { color: 'var(--dsh-brand-color, currentColor)' }
1662
+ },
1663
+ h('path', { d: 'M8 3.2 L3.2 8 M8 3.2 L12.8 8 M3.2 8 L12.8 8', opacity: '.7' }),
1664
+ h('circle', { cx: 8, cy: 3.2, r: 1.6, fill: 'currentColor', stroke: 'none' }),
1665
+ h('circle', { cx: 3.2, cy: 8, r: 1.6, fill: 'currentColor', stroke: 'none' }),
1666
+ h('circle', { cx: 12.8, cy: 8, r: 1.6, fill: 'currentColor', stroke: 'none' })
1667
+ )
1668
+ }
1669
+
1670
+ function MeshDelegationPanel() {
1671
+ var s0 = useState(null); var list = s0[0]; var setList = s0[1]
1672
+ var s1 = useState(true); var loading = s1[0]; var setLoading = s1[1]
1673
+ var s2 = useState(null); var notice = s2[0]; var setNotice = s2[1]
1674
+ var s3 = useState(null); var selectedId = s3[0]; var setSelectedId = s3[1]
1675
+ var s4 = useState(null); var detail = s4[0]; var setDetail = s4[1]
1676
+ var s5 = useState(false); var taskLoading = s5[0]; var setTaskLoading = s5[1]
1677
+ var s6 = useState(null); var session = s6[0]; var setSession = s6[1]
1678
+ var s7 = useState(false); var sessionLoading = s7[0]; var setSessionLoading = s7[1]
1679
+ var s8 = useState(''); var copied = s8[0]; var setCopied = s8[1]
1680
+ var s10 = useState(null); var artifacts = s10[0]; var setArtifacts = s10[1]
1681
+ var s11 = useState(''); var localNode = s11[0]; var setLocalNode = s11[1]
1682
+ var s12 = useState(''); var lastListAt = s12[0]; var setLastListAt = s12[1]
1683
+ var s13 = useState(''); var lastTaskAt = s13[0]; var setLastTaskAt = s13[1]
1684
+ var s14 = useState(''); var filterFrom = s14[0]; var setFilterFrom = s14[1]
1685
+ var s15 = useState(''); var filterTo = s15[0]; var setFilterTo = s15[1]
1686
+ var s16 = useState(''); var filterFromNode = s16[0]; var setFilterFromNode = s16[1]
1687
+ var s17 = useState(''); var filterToNode = s17[0]; var setFilterToNode = s17[1]
1688
+ var s19 = useState(null); var rich = s19[0]; var setRich = s19[1]
1689
+ var s18 = useState(function () {
1690
+ try {
1691
+ var saved = window.localStorage.getItem('dsh-mesh-list-width')
1692
+ var value = saved ? Number(saved) : NaN
1693
+ if (Number.isFinite(value)) return Math.max(260, Math.min(640, value))
1694
+ } catch (error) { /* ignore */ }
1695
+ return 360
1696
+ })
1697
+ var listWidth = s18[0]; var setListWidth = s18[1]
1698
+ function startResize(event) {
1699
+ event.preventDefault()
1700
+ var startX = event.clientX
1701
+ var startW = listWidth
1702
+ var currentW = startW
1703
+ function onMove(moveEvent) {
1704
+ currentW = Math.max(260, Math.min(640, startW + (moveEvent.clientX - startX)))
1705
+ setListWidth(currentW)
1706
+ }
1707
+ function onUp() {
1708
+ window.removeEventListener('pointermove', onMove)
1709
+ window.removeEventListener('pointerup', onUp)
1710
+ document.body.style.cursor = ''
1711
+ document.body.style.userSelect = ''
1712
+ try { window.localStorage.setItem('dsh-mesh-list-width', String(currentW)) } catch (error) { /* ignore */ }
1713
+ }
1714
+ window.addEventListener('pointermove', onMove)
1715
+ window.addEventListener('pointerup', onUp)
1716
+ document.body.style.cursor = 'col-resize'
1717
+ document.body.style.userSelect = 'none'
1718
+ }
1719
+ function resetResize() {
1720
+ setListWidth(360)
1721
+ try { window.localStorage.setItem('dsh-mesh-list-width', '360') } catch (error) { /* ignore */ }
1722
+ }
1723
+
1724
+ // 只保留「错误」提示(持久显示,下一次成功操作/切换任务时清除);成功不再弹自动消失的提示
1725
+ function flash(type, text) {
1726
+ if (type === 'bad') setNotice({ type: 'bad', text: text })
1727
+ else setNotice(null)
1728
+ }
1729
+ function clearNotice() { setNotice(null) }
1730
+
1731
+ function loadList(silent) {
1732
+ if (!silent) setLoading(true)
1733
+ dgFetch(DG_API_LIST + '?limit=100')
1734
+ .then(function (json) {
1735
+ if (!json || !json.ok) throw new Error((json && json.error) || 'load failed')
1736
+ setList(Object.assign({}, json, { degraded: false }))
1737
+ if (json.localNode) setLocalNode(json.localNode)
1738
+ setLastListAt(new Date().toISOString())
1739
+ })
1740
+ .catch(function (err) {
1741
+ // 2026-11-07 兼容:旧实例没有 /delegations 路由 → 用 /status 里的 delegations.recent 降级
1742
+ if (err && (err.code === 'E_NO_ROUTE' || err.code === 'E_PARSE')) {
1743
+ return dgFetch('/api/dsh-mesh/status')
1744
+ .then(function (st) {
1745
+ var recent = (st && st.delegations && st.delegations.recent) || []
1746
+ var tasks = recent.map(function (item) {
1747
+ return {
1748
+ taskId: item.taskId,
1749
+ conversationId: item.conversationId || '',
1750
+ sessionId: item.sessionId || '',
1751
+ from: '',
1752
+ fromLabel: '',
1753
+ node: item.node,
1754
+ nodeLabel: nodeLabel(item.node),
1755
+ instance: '',
1756
+ state: item.state,
1757
+ policy: item.policy || '',
1758
+ goal: item.goal || '',
1759
+ createdAt: item.createdAt || '',
1760
+ updatedAt: item.updatedAt || '',
1761
+ durationSec: item.durationSec == null ? null : (item.durationSec > 43200 ? null : item.durationSec)
1762
+ }
1763
+ })
1764
+ if (st && st.nodeName) setLocalNode(st.nodeName)
1765
+ setList({
1766
+ total: (st && st.delegations && st.delegations.total) || tasks.length,
1767
+ byState: (st && st.delegations && st.delegations.byState) || {},
1768
+ tasks: tasks,
1769
+ degraded: true
1770
+ })
1771
+ flash('bad', '当前实例服务端未重启:已降级显示最近 ' + tasks.length + ' 条(重启后显示完整列表/详情)')
1772
+ })
1773
+ .catch(function () { throw err })
1774
+ }
1775
+ throw err
1776
+ })
1777
+ .catch(function (err) { flash('bad', '列表加载失败:' + dgFriendly(err)) })
1778
+ .finally(function () { setLoading(false) })
1779
+ }
1780
+
1781
+ useEffect(function () {
1782
+ loadList(true)
1783
+ var timer = setInterval(function () { loadList(true) }, 20000)
1784
+ return function () { clearInterval(timer) }
1785
+ }, [])
1786
+
1787
+ var tasks = (list && list.tasks) || []
1788
+ var selected = null
1789
+ for (var i = 0; i < tasks.length; i++) { if (tasks[i].taskId === selectedId) { selected = tasks[i]; break } }
1790
+
1791
+ function selectTask(task) {
1792
+ setSelectedId(task.taskId)
1793
+ clearNotice()
1794
+ setDetail(null)
1795
+ setArtifacts(null)
1796
+ setRich(null)
1797
+ setSession(null)
1798
+ setTaskLoading(true)
1799
+ dgFetch(DG_API_TASK + '?taskId=' + encodeURIComponent(task.taskId))
1800
+ .then(function (json) {
1801
+ if (!json || !json.ok) throw new Error((json && json.error) || 'task status failed')
1802
+ setDetail(json.status || null)
1803
+ setArtifacts(json.artifacts || null)
1804
+ setRich(json.detail || null)
1805
+ setLastTaskAt(new Date().toISOString())
1806
+ clearNotice()
1807
+ })
1808
+ .catch(function (err) { flash('bad', '查询任务状态失败:' + dgFriendly(err)) })
1809
+ .finally(function () { setTaskLoading(false) })
1810
+ }
1811
+
1812
+ function loadSession() {
1813
+ if (!selected || !selected.sessionId) return
1814
+ setSessionLoading(true)
1815
+ dgFetch(DG_API_SESSION + '?node=' + encodeURIComponent(selected.node) + '&sessionId=' + encodeURIComponent(selected.sessionId) + '&limit=60')
1816
+ .then(function (json) {
1817
+ if (!json || !json.ok) throw new Error((json && json.error) || 'session history failed')
1818
+ setSession(json.payload || null)
1819
+ clearNotice()
1820
+ })
1821
+ .catch(function (err) { flash('bad', '加载对端会话失败:' + dgFriendly(err)) })
1822
+ .finally(function () { setSessionLoading(false) })
1823
+ }
1824
+
1825
+ function copyText(key, text) {
1826
+ try {
1827
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { setCopied(key) }, function () { flash('bad', '复制失败') })
1828
+ else { setCopied(key) }
1829
+ } catch (_) { flash('bad', '复制失败') }
1830
+ setTimeout(function () { setCopied('') }, 1500)
1831
+ }
1832
+
1833
+ var byState = (list && list.byState) || {}
1834
+ // 2026-11-07:节点筛选(委托方/接收方)+ 实例筛选(源实例仅新委托记录;旧数据为空 → 未记录)
1835
+ var fromNodeOptions = []
1836
+ var toNodeOptions = []
1837
+ var fromOptions = []
1838
+ var toOptions = []
1839
+ for (var ti = 0; ti < tasks.length; ti++) {
1840
+ var task0 = tasks[ti]
1841
+ if (fromNodeOptions.indexOf(task0.from || '') < 0) fromNodeOptions.push(task0.from || '')
1842
+ if (toNodeOptions.indexOf(task0.node || '') < 0) toNodeOptions.push(task0.node || '')
1843
+ var fi = task0.fromInstance || ''
1844
+ if (fromOptions.indexOf(fi) < 0) fromOptions.push(fi)
1845
+ var ii = task0.instance || ''
1846
+ if (toOptions.indexOf(ii) < 0) toOptions.push(ii)
1847
+ }
1848
+ fromNodeOptions.sort()
1849
+ toNodeOptions.sort()
1850
+ fromOptions.sort()
1851
+ toOptions.sort()
1852
+ function nodeOptionLabel(value, isFrom) {
1853
+ if (!value) return '未知节点'
1854
+ for (var k = 0; k < tasks.length; k++) {
1855
+ var item = tasks[k]
1856
+ if (isFrom && (item.from || '') === value) return item.fromLabel || value
1857
+ if (!isFrom && (item.node || '') === value) return (item.nodeLabel || value) + (item.fromIsLocal !== undefined ? '' : '')
1858
+ }
1859
+ return value
1860
+ }
1861
+ var visibleTasks = tasks.filter(function (task) {
1862
+ if (filterFromNode && (task.from || '') !== filterFromNode) return false
1863
+ if (filterToNode && (task.node || '') !== filterToNode) return false
1864
+ if (filterFrom && (task.fromInstance || '') !== filterFrom) return false
1865
+ if (filterTo && (task.instance || '') !== filterTo) return false
1866
+ return true
1867
+ })
1868
+ function resetFilters() { setFilterFromNode(''); setFilterToNode(''); setFilterFrom(''); setFilterTo('') }
1869
+ // 自绘下拉:不用原生 select(暗色主题下原生弹层会保持白底)
1870
+ function FilterSelect(props) {
1871
+ var fs = useState(false)
1872
+ var open = fs[0]
1873
+ var setOpen = fs[1]
1874
+ useEffect(function () {
1875
+ if (!open) return
1876
+ function close() { setOpen(false) }
1877
+ document.addEventListener('mousedown', close)
1878
+ return function () { document.removeEventListener('mousedown', close) }
1879
+ }, [open])
1880
+ return h('div', { className: 'dm-dg-fs' },
1881
+ h('span', { className: 'dm-dg-fs-label' }, props.label),
1882
+ h('button', {
1883
+ type: 'button',
1884
+ className: 'dm-dg-fs-btn',
1885
+ 'aria-expanded': open ? 'true' : 'false',
1886
+ onClick: function (event) { event.stopPropagation(); setOpen(!open) }
1887
+ },
1888
+ h('span', { className: 'dm-dg-fs-val' }, props.renderLabel(props.value)),
1889
+ h('span', { className: 'dm-dg-fs-caret' }, '▾')
1890
+ ),
1891
+ open ? h('div', { className: 'dm-dg-fs-menu', onMouseDown: function (event) { event.stopPropagation() } },
1892
+ props.options.map(function (opt) {
1893
+ return h('button', {
1894
+ key: opt,
1895
+ type: 'button',
1896
+ className: 'dm-dg-fs-item' + (opt === props.value ? ' active' : ''),
1897
+ onClick: function (event) { event.stopPropagation(); props.onChange(opt); setOpen(false) }
1898
+ }, props.renderLabel(opt))
1899
+ })
1900
+ ) : null
1901
+ )
1902
+ }
1903
+ var stats = ['done', 'working', 'submitted', 'question', 'stalled', 'failed'].filter(function (key) { return byState[key] })
1904
+ var selectedTone = selected ? dgTone(selected.state) : 'muted'
1905
+ var sessionMessages = (session && session.messages) || []
1906
+ var st = detail || {}
1907
+
1908
+ function copyBtn(key, text) {
1909
+ if (!text) return null
1910
+ return h('button', { type: 'button', className: 'dm-dg-copy', title: '复制 ' + key, onClick: function (e) { e.stopPropagation(); copyText(key, text) } }, copied === key ? '已复制' : '复制')
1911
+ }
1912
+ function kv(label, value, copyKey) {
1913
+ return h('div', { className: 'dm-dg-kv' },
1914
+ h('span', { className: 'dm-dg-k' }, label),
1915
+ h('span', { className: 'dm-dg-v', title: String(value == null ? '' : value) }, String(value == null || value === '' ? '—' : value), copyKey ? copyBtn(copyKey, String(value || '')) : null)
1916
+ )
1917
+ }
1918
+
1919
+ return h('div', { className: 'dm-dg-root' },
1920
+ h('style', null, DG_CSS),
1921
+ h('div', { className: 'dm-dg-head' },
1922
+ h('span', { className: 'dm-dg-title' }, '委托'),
1923
+ h('span', { className: 'dm-dg-pill readonly' }, '只读'),
1924
+ h('div', { className: 'dm-dg-stats' },
1925
+ h('span', { className: 'dm-dg-stat' }, '共 ', h('b', null, String((list && list.total) || tasks.length || 0)), ' 条'),
1926
+ stats.map(function (key) { return h('span', { key: key, className: 'dm-dg-stat' }, h('b', null, String(byState[key])), ' ', dgStateLabel(key)) })
1927
+ ),
1928
+ h('span', { className: 'dm-dg-spacer' }),
1929
+ h('span', { className: 'dm-dg-sub' }, '20s 自动刷新 · 数据来自本地台账' + (lastListAt ? ' · 上次 ' + dgClock(lastListAt) : '')),
1930
+ h('button', { type: 'button', className: 'dm-dg-btn', onClick: function () { loadList(false) }, disabled: loading },
1931
+ loading ? h('span', { className: 'dm-dg-loading' }, h('span', { className: 'dm-dg-spin' }), '刷新中') : '刷新')
1932
+ ),
1933
+ notice ? h('div', { className: 'dm-dg-notice ' + notice.type }, notice.text) : null,
1934
+ h('div', { className: 'dm-dg-body', style: { '--dm-dg-list-w': listWidth + 'px' } },
1935
+ h('section', { className: 'dm-dg-pane' },
1936
+ h('div', { className: 'dm-dg-pane-head' }, '委托列表', h('span', { className: 'dm-dg-sub' }, '(' + visibleTasks.length + (visibleTasks.length !== tasks.length ? '/' + tasks.length : '') + ')')),
1937
+ h('div', { className: 'dm-dg-filters' },
1938
+ h(FilterSelect, { label: '委托方', value: filterFromNode, options: [''].concat(fromNodeOptions), onChange: setFilterFromNode, renderLabel: function (opt) { return opt === '' ? '全部节点' : nodeOptionLabel(opt, true) } }),
1939
+ h(FilterSelect, { label: '接收方', value: filterToNode, options: [''].concat(toNodeOptions), onChange: setFilterToNode, renderLabel: function (opt) { return opt === '' ? '全部节点' : nodeOptionLabel(opt, false) } }),
1940
+ h(FilterSelect, { label: '源实例', value: filterFrom, options: [''].concat(fromOptions), onChange: setFilterFrom, renderLabel: function (opt) { return opt === '' ? '全部' : (opt || '未记录') } }),
1941
+ h(FilterSelect, { label: '目标实例', value: filterTo, options: [''].concat(toOptions), onChange: setFilterTo, renderLabel: function (opt) { return opt === '' ? '全部' : (opt || '未记录') } }),
1942
+ h('button', { type: 'button', className: 'dm-dg-fbtn', onClick: resetFilters }, '重置筛选'),
1943
+ h('span', { className: 'dm-dg-fhint' }, '源实例仅对 2026-11-07 之后新发的委托有效,历史记录显示未记录')
1944
+ ),
1945
+ h('div', { className: 'dm-dg-scroll' },
1946
+ loading && tasks.length === 0 ? h('div', { className: 'dm-dg-skel' }, h('i'), h('i'), h('i'), h('i')) : null,
1947
+ !loading && tasks.length === 0 ? h('div', { className: 'dm-dg-empty' }, '暂无委托记录', h('br'), '有委托后这里会自动出现') : null,
1948
+ !loading && tasks.length > 0 && visibleTasks.length === 0 ? h('div', { className: 'dm-dg-empty' }, '当前筛选无匹配委托') : null,
1949
+ visibleTasks.map(function (task) {
1950
+ var tone = dgTone(task.state)
1951
+ return h('div', {
1952
+ key: task.taskId,
1953
+ className: 'dm-dg-row ' + tone + (task.taskId === selectedId ? ' active' : ''),
1954
+ onClick: function () { if (task.taskId !== selectedId) selectTask(task) },
1955
+ title: task.goal || ''
1956
+ },
1957
+ h('div', { className: 'dm-dg-row-top' },
1958
+ task.activeFresh ? h(LoadingSpinner, { size: 12 }) : null,
1959
+ h('span', { className: 'dm-dg-route' },
1960
+ h('span', { className: 'dm-dg-from' }, task.fromLabel || task.from || '?'),
1961
+ (task.fromIsLocal || (localNode && task.from === localNode)) ? h('span', { className: 'dm-dg-me' }, task.fromInstance ? '本机·' + task.fromInstance : '本机') : null,
1962
+ h('span', { className: 'dm-dg-arrow' }, '→'),
1963
+ h('span', { className: 'dm-dg-to' }, (task.nodeLabel || task.node || '?') + (task.instance ? ' · ' + task.instance : ''))
1964
+ ),
1965
+ dgChip(task.state),
1966
+ h('span', { className: 'dm-dg-row-time' }, (task.isActive ? '更新 ' : '完成 ') + dgClock(task.displayAt || task.updatedAt))
1967
+ ),
1968
+ h('div', { className: 'dm-dg-goal' }, task.goal || '(无目标)'),
1969
+ h('div', { className: 'dm-dg-meta' },
1970
+ task.instance ? h('span', { className: 'dm-dg-badge' }, task.instance) : null,
1971
+ h('span', null, '历时 ' + dgDur(task.durationSec)),
1972
+ h('span', null, task.taskId),
1973
+ task.policy ? h('span', null, dgPolicyLabel(task.policy)) : null
1974
+ )
1975
+ )
1976
+ })
1977
+ )
1978
+ ),
1979
+ h('div', { className: 'dm-dg-divider', title: '拖动调整列表宽度,双击复位', onPointerDown: startResize, onDoubleClick: resetResize }),
1980
+ h('section', { className: 'dm-dg-pane' },
1981
+ h('div', { className: 'dm-dg-pane-head' },
1982
+ '任务详情',
1983
+ h('span', { className: 'dm-dg-sub' }, selected ? selected.taskId + (lastTaskAt ? ' · 状态更新于 ' + dgClock(lastTaskAt) : '') : '未选择'),
1984
+ h('span', { className: 'dm-dg-spacer' }),
1985
+ selected ? h('button', { type: 'button', className: 'dm-dg-btn', onClick: function () { selectTask(selected) }, disabled: taskLoading },
1986
+ taskLoading ? h('span', { className: 'dm-dg-loading' }, h('span', { className: 'dm-dg-spin' }), '查询中') : '刷新状态') : null
1987
+ ),
1988
+ h('div', { className: 'dm-dg-scroll' },
1989
+ selected === null
1990
+ ? h('div', { className: 'dm-dg-empty' }, '在左侧选择一条委托', h('br'), '可查看任务详情与对端会话(只读)')
1991
+ : h('div', { className: 'dm-dg-detail' },
1992
+ h('div', { className: 'dm-dg-sec' },
1993
+ h('div', { className: 'dm-dg-sec-head' }, '概要'),
1994
+ h('div', { className: 'dm-dg-sec-body' },
1995
+ h('div', { className: 'dm-dg-kv' },
1996
+ h('span', { className: 'dm-dg-k' }, '状态'),
1997
+ h('span', { className: 'dm-dg-v' },
1998
+ selected.activeFresh ? h(LoadingSpinner, { size: 13 }) : null,
1999
+ dgChip(selected.state)
2000
+ )
2001
+ ),
2002
+ kv('委托方', (selected.fromLabel || selected.from || '—') + ((selected.fromIsLocal || (localNode && selected.from === localNode)) ? '(本机)' : '')),
2003
+ kv('接收方', (selected.nodeLabel || selected.node || '—') + (selected.instance ? ' · ' + selected.instance : '')),
2004
+ kv('目标实例', selected.instance || '—'),
2005
+ kv('策略', dgPolicyLabel(selected.policy)),
2006
+ kv('历时' + (artifacts && artifacts.durationSec != null ? '(实测)' : ''), artifacts && artifacts.durationSec != null ? dgDur(artifacts.durationSec) : dgDur(selected.durationSec)),
2007
+ kv('开始时间', artifacts && artifacts.requestedAt ? dgClock(artifacts.requestedAt) : dgClock(selected.createdAt)),
2008
+ kv('结束时间', artifacts && artifacts.finishedAt ? dgClock(artifacts.finishedAt) : '—'),
2009
+ kv('台账更新', dgClock(selected.updatedAt))
2010
+ )
2011
+ ),
2012
+ h('div', { className: 'dm-dg-sec' },
2013
+ h('div', { className: 'dm-dg-sec-head' }, '标识'),
2014
+ h('div', { className: 'dm-dg-sec-body' },
2015
+ kv('taskId', selected.taskId, 'taskId'),
2016
+ kv('conversation', selected.conversationId, 'conversationId'),
2017
+ kv('sessionId', selected.sessionId, 'sessionId')
2018
+ )
2019
+ ),
2020
+ h('div', { className: 'dm-dg-sec' },
2021
+ h('div', { className: 'dm-dg-sec-head' }, '目标'),
2022
+ h('div', { className: 'dm-dg-sec-body' }, h('div', { className: 'dm-dg-pre' }, selected.goal || '(无目标)'))
2023
+ ),
2024
+ rich ? h('div', { className: 'dm-dg-sec' },
2025
+ h('div', { className: 'dm-dg-sec-head' }, '时间线'),
2026
+ h('div', { className: 'dm-dg-sec-body' },
2027
+ kv('委托发出', rich.timeline ? dgClock(rich.timeline.requestedAt) : '—'),
2028
+ kv('对端心跳', rich.timeline ? dgClock(rich.timeline.heartbeatAt) : '—'),
2029
+ kv('结束时间', rich.timeline ? dgClock(rich.timeline.finishedAt) : '—'),
2030
+ kv('响应耗时', rich.timeline && rich.timeline.responseSec != null ? dgDur(rich.timeline.responseSec) : '—'),
2031
+ kv('执行耗时', rich.timeline && rich.timeline.execSec != null ? dgDur(rich.timeline.execSec) : '—'),
2032
+ kv('总耗时', rich.timeline && rich.timeline.totalSec != null ? dgDur(rich.timeline.totalSec) : dgDur(selected.durationSec))
2033
+ )
2034
+ ) : null,
2035
+ rich && (rich.operations && (rich.operations.commandsRun.length > 0 || rich.operations.evidence.length > 0 || rich.operations.notes)) ? h('div', { className: 'dm-dg-sec' },
2036
+ h('div', { className: 'dm-dg-sec-head' }, '对端操作',
2037
+ rich.operations.readonly ? h('span', { className: 'dm-dg-chip ok' }, '只读') : null
2038
+ ),
2039
+ h('div', { className: 'dm-dg-sec-body' },
2040
+ rich.operations.notes ? h('div', { className: 'dm-dg-pre' }, rich.operations.notes) : null,
2041
+ rich.operations.commandsRun.length > 0 ? h('div', null,
2042
+ h('div', { className: 'dm-dg-sub' }, '执行过的命令(对端回报)'),
2043
+ h('div', { className: 'dm-dg-pre' }, rich.operations.commandsRun.map(function (item) { return '· ' + item }).join('\n'))
2044
+ ) : null,
2045
+ rich.operations.evidence.length > 0 ? h('div', null,
2046
+ h('div', { className: 'dm-dg-sub' }, '产物/证据'),
2047
+ h('div', { className: 'dm-dg-pre' }, rich.operations.evidence.map(function (item) { return '· ' + item }).join('\n'))
2048
+ ) : null
2049
+ )
2050
+ ) : null,
2051
+ rich && (rich.operations && (rich.operations.scope || rich.operations.acceptance || rich.operations.sessionPolicy)) ? h('div', { className: 'dm-dg-sec' },
2052
+ h('div', { className: 'dm-dg-sec-head' }, '权限 / 范围'),
2053
+ h('div', { className: 'dm-dg-sec-body' },
2054
+ kv('会话策略', rich.operations.sessionPolicy ? dgPolicyLabel(rich.operations.sessionPolicy) : '—'),
2055
+ kv('目标实例', rich.operations.targetInstance || '—'),
2056
+ kv('工作区', rich.operations.workspace || '—'),
2057
+ kv('授权范围', rich.operations.scope || '(未声明)'),
2058
+ kv('验收标准', rich.operations.acceptance || '(未声明)')
2059
+ )
2060
+ ) : null,
2061
+ h('div', { className: 'dm-dg-sec' },
2062
+ h('div', { className: 'dm-dg-sec-head' }, '对端状态',
2063
+ h('span', { className: 'dm-dg-sub' }, taskLoading ? '查询中…' : detail ? '已取到 status.json' : '未查询')),
2064
+ h('div', { className: 'dm-dg-sec-body' },
2065
+ st.degraded ? h('div', { className: 'dm-dg-notice bad' }, '对端查询失败,已降级显示本地台账:' + String(st.error || '')) : null,
2066
+ kv('状态', st.state ? dgStateLabel(st.state) : '—'),
2067
+ kv('会话状态', st.sessionState ? dgStateLabel(st.sessionState) : '—'),
2068
+ kv('心跳延迟', st.staleSeconds == null ? '—' : String(st.staleSeconds) + ' 秒'),
2069
+ kv('error', st.error || '—'),
2070
+ st.result ? h('div', { className: 'dm-dg-pre' }, JSON.stringify(st.result, null, 2)) : null
2071
+ )
2072
+ ),
2073
+ h('div', { className: 'dm-dg-sec' },
2074
+ h('div', { className: 'dm-dg-sec-head' },
2075
+ '对端会话',
2076
+ h('span', { className: 'dm-dg-sub' }, session ? ('轮次 ' + (session.totalMessages || sessionMessages.length) + ' · 帧 ' + (session.frameCount || '—') + ' · ' + Math.round((session.bytes || 0) / 1024) + ' KB') : '只读'),
2077
+ h('span', { className: 'dm-dg-spacer' }),
2078
+ h('button', { type: 'button', className: 'dm-dg-btn primary', onClick: loadSession, disabled: sessionLoading || !selected.sessionId },
2079
+ sessionLoading ? h('span', { className: 'dm-dg-loading' }, h('span', { className: 'dm-dg-spin' }), '加载中') : (session ? '重新加载' : '加载会话'))
2080
+ ),
2081
+ h('div', { className: 'dm-dg-sec-body' },
2082
+ session ? (session.title ? h('div', { className: 'dm-dg-sub' }, '标题:' + session.title) : null) : null,
2083
+ session
2084
+ ? h('div', { className: 'dm-dg-msgs' },
2085
+ sessionMessages.length === 0 ? h('div', { className: 'dm-dg-empty' }, '无消息') : null,
2086
+ sessionMessages.map(function (msg, idx) {
2087
+ return h('div', { key: idx, className: 'dm-dg-msg ' + (msg.role || '') },
2088
+ h('div', { className: 'dm-dg-msg-head' }, h('span', null, msg.role || '?'), msg.time ? h('span', null, msg.time) : null),
2089
+ h('div', { className: 'dm-dg-msg-text' }, String(msg.text || ''))
2090
+ )
2091
+ })
2092
+ )
2093
+ : h('div', { className: 'dm-dg-empty' }, selected.sessionId ? '点击右上「加载会话」查看对端会话正文' : '该委托没有 sessionId')
2094
+ )
2095
+ )
2096
+ )
2097
+ )
2098
+ )
2099
+ )
2100
+ )
2101
+ }
2102
+
2103
+ var SECTION = { name: 'settings.section', id: 'dsh-mesh', order: 90, label: 'AI 网络' }
2104
+ var inject = ['slots']
2105
+
2106
+ // 2026-11-07:设置左侧导航里「AI 网络」这一项,把壳的默认齿轮换成三节点图标,颜色跟随 --dsh-brand-color
2107
+ function navMeshIconDom(referenceClass) {
2108
+ var NS = 'http://www.w3.org/2000/svg'
2109
+ var svg = document.createElementNS(NS, 'svg')
2110
+ if (referenceClass) svg.setAttribute('class', referenceClass)
2111
+ svg.setAttribute('width', '16')
2112
+ svg.setAttribute('height', '16')
2113
+ svg.setAttribute('viewBox', '0 0 16 16')
2114
+ svg.setAttribute('fill', 'none')
2115
+ svg.setAttribute('stroke', 'currentColor')
2116
+ svg.setAttribute('stroke-width', '1.4')
2117
+ svg.setAttribute('stroke-linecap', 'round')
2118
+ svg.setAttribute('aria-hidden', 'true')
2119
+ svg.setAttribute('data-dsh-mesh-nav-icon', '1')
2120
+ svg.style.color = 'var(--dsh-brand-color, currentColor)'
2121
+ var line = document.createElementNS(NS, 'path')
2122
+ line.setAttribute('d', 'M8 3.2 L3.2 8 M8 3.2 L12.8 8 M3.2 8 L12.8 8')
2123
+ line.setAttribute('opacity', '.7')
2124
+ svg.appendChild(line)
2125
+ var dots = [[8, 3.2], [3.2, 8], [12.8, 8]]
2126
+ for (var i = 0; i < dots.length; i++) {
2127
+ var circle = document.createElementNS(NS, 'circle')
2128
+ circle.setAttribute('cx', dots[i][0])
2129
+ circle.setAttribute('cy', dots[i][1])
2130
+ circle.setAttribute('r', '1.6')
2131
+ circle.setAttribute('fill', 'currentColor')
2132
+ circle.setAttribute('stroke', 'none')
2133
+ svg.appendChild(circle)
2134
+ }
2135
+ return svg
2136
+ }
2137
+ function decorateMeshNav() {
2138
+ try {
2139
+ var buttons = document.querySelectorAll('[role="dialog"] nav button')
2140
+ for (var i = 0; i < buttons.length; i++) {
2141
+ var button = buttons[i]
2142
+ var text = (button.textContent || '').trim()
2143
+ if (text !== 'AI 网络' && text !== 'AI 网络(dsh-mesh)') continue
2144
+ if (button.querySelector('svg[data-dsh-mesh-nav-icon]')) continue
2145
+ var gear = button.querySelector('svg')
2146
+ var className = gear ? gear.getAttribute('class') || '' : ''
2147
+ var icon = navMeshIconDom(className)
2148
+ if (gear && gear.parentNode === button) button.replaceChild(icon, gear)
2149
+ else button.insertBefore(icon, button.firstChild)
2150
+ }
2151
+ } catch (error) {
2152
+ /* ignore */
2153
+ }
2154
+ }
2155
+
2156
+ function apply(ctx) {
2157
+ var disposers = []
2158
+ try {
2159
+ disposers.push(ctx.slots.inject('settings.section', function () { return ctx.slots.register(SECTION, MeshStatusSection) }))
2160
+ } catch (error) {
2161
+ console.warn('[dsh-mesh-ui] settings section registration failed:', error)
2162
+ }
2163
+ // 2026-11-07 方案 A:左侧栏「委托」面板(sidebar.panellist + main)
2164
+ try {
2165
+ disposers.push(ctx.slots.inject('sidebar.panellist', function () {
2166
+ return ctx.slots.register({ name: 'sidebar.panellist', id: 'mesh-delegations', order: 55, label: function () { return '委托' } }, meshPanelIcon)
2167
+ }))
2168
+ disposers.push(ctx.slots.inject('main', function () {
2169
+ return ctx.slots.register({ name: 'main', key: 'mesh-delegations', inject: function () { return {} } }, MeshDelegationPanel)
2170
+ }))
2171
+ } catch (error) {
2172
+ console.warn('[dsh-mesh-ui] delegation panel registration failed:', error)
2173
+ }
2174
+ var navObserver = null
2175
+ var navTimer = null
2176
+ try {
2177
+ if (typeof MutationObserver !== 'undefined' && document.body) {
2178
+ navObserver = new MutationObserver(function () { decorateMeshNav() })
2179
+ navObserver.observe(document.body, { childList: true, subtree: true })
2180
+ }
2181
+ navTimer = setInterval(decorateMeshNav, 1500)
2182
+ if (navTimer && typeof navTimer.unref === 'function') navTimer.unref()
2183
+ } catch (error) {
2184
+ /* ignore */
2185
+ }
2186
+ ctx.effect(function () {
2187
+ return function () {
2188
+ for (var i = 0; i < disposers.length; i++) { try { disposers[i]() } catch (_) { /* ignore */ } }
2189
+ try { if (navObserver) navObserver.disconnect() } catch (_) { /* ignore */ }
2190
+ try { if (navTimer) clearInterval(navTimer) } catch (_) { /* ignore */ }
2191
+ }
2192
+ }, 'dsh-mesh-ui: settings section + nav icon')
2193
+ }
2194
+
2195
+ exports.apply = apply
2196
+ exports.inject = inject
2197
+ return module.exports
2198
+ }
2199
+ })