dsh-server-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js ADDED
@@ -0,0 +1,4606 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-server-dashboard",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
7
+ //#region \0rolldown/runtime.js
8
+ var __create = Object.create;
9
+ var __defProp = Object.defineProperty;
10
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
11
+ var __getOwnPropNames = Object.getOwnPropertyNames;
12
+ var __getProtoOf = Object.getPrototypeOf;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __copyProps = (to, from, except, desc) => {
15
+ if (from && typeof from === "object" || typeof from === "function") {
16
+ for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
17
+ key = keys[i];
18
+ if (!__hasOwnProp.call(to, key) && key !== except) {
19
+ __defProp(to, key, {
20
+ get: ((k) => from[k]).bind(null, key),
21
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
22
+ });
23
+ }
24
+ }
25
+ }
26
+ return to;
27
+ };
28
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
29
+ value: mod,
30
+ enumerable: true
31
+ }) : target, mod));
32
+
33
+ //#endregion
34
+ let react = require("react");
35
+ react = __toESM(react, 1);
36
+ let react_dom = require("react-dom");
37
+ react_dom = __toESM(react_dom, 1);
38
+ let react_jsx_runtime = require("react/jsx-runtime");
39
+
40
+ //#region src/client/locales.ts
41
+ /** zh/en dictionaries for the serverDashboard namespace (zh is the key source of truth). */
42
+ const zh = {
43
+ "nav.label": "服务器",
44
+ "nav.title": "服务器看板",
45
+ "nav.sumOffline": "{count} 台离线",
46
+ "nav.sumHot": "{count} 卡高温/满载",
47
+ "nav.sumStalled": "{count} 个实验停滞",
48
+ "nav.sumOk": "全部正常",
49
+ "panel.empty": "还没有配置任何服务器。到设置 → 服务器看板 添加主机,或从 ~/.ssh/config 导入。",
50
+ "panel.updatedAt": "{time} 更新",
51
+ "panel.refresh": "刷新",
52
+ "panel.hostError": "无法连接宿主,显示的是最近一次数据",
53
+ "panel.offlineBanner": "看板离线:无法连接后台,显示的是 {time} 的旧数据",
54
+ "panel.retry": "重试",
55
+ "panel.nextRefresh": "{s}s 后刷新",
56
+ "rel.now": "刚刚",
57
+ "rel.sec": "{n} 秒前",
58
+ "rel.min": "{n} 分钟前",
59
+ "toast.clickToOpen": "点击查看该主机",
60
+ "panel.sse.offline": "SSH 连接失败(可能已关机/断网)",
61
+ "panel.sse.hot": "出现高温 GPU",
62
+ "host.retry": "立即重试(清除退避)",
63
+ "host.pin": "固定显示(常驻看板)",
64
+ "host.unpin": "取消固定(收进其他服务器)",
65
+ "host.offline": "离线",
66
+ "host.connecting": "首次采集进行中…",
67
+ "host.offlineMsg": "连接失败",
68
+ "host.authRejected": "认证被拒:服务器拒绝了这把密钥(检查 authorized_keys,或服务器 sshd 刚更新过策略;重启本应用后重试)",
69
+ "host.gpuCards": "{count} 卡",
70
+ "host.gpuUnavailable": "GPU 数据不可用",
71
+ "host.gpuUnavailableHint": "主机在线,但 nvidia-smi 没有返回有效数据(驱动未装/权限/PCIe 枚举失败都会这样)",
72
+ "host.hotGpus": "{count} 卡高温/满载",
73
+ "host.stalledExps": "⏸ {count} 实验停滞",
74
+ "host.diskWarn": "磁盘 {count} 个 >90%",
75
+ "host.logStalled": "日志停滞",
76
+ "host.runningExps": "{count} 实验进行中",
77
+ "host.allGood": "一切正常",
78
+ "other.collapsed": "其他服务器({count})",
79
+ "overview.title": "GPU 总览",
80
+ "fleet.running": "训练中",
81
+ "fleet.idle": "空闲",
82
+ "fleet.anomalous": "异常",
83
+ "chart.smoothHint": "曲线平滑(EMA)开/关,降噪看趋势",
84
+ "overview.offline": "离线",
85
+ "gpu.util": "利用率",
86
+ "gpu.memory": "显存",
87
+ "gpu.temp": "温度",
88
+ "gpu.power": "功耗",
89
+ "gpu.idle": "空闲",
90
+ "gpu.stalled": "停滞 {min}m",
91
+ "gpu.noLog": "未发现该 GPU 进程的日志文件(stdout 未重定向到文件)",
92
+ "gpu.expandHint": "展开该 GPU 的实验日志/曲线",
93
+ "gpu.switchMetric": "点击切换指标(当前 {name})",
94
+ "base.cpu": "CPU",
95
+ "base.memory": "内存",
96
+ "base.load": "负载",
97
+ "base.disk": "磁盘",
98
+ "disk.mounts": "个挂载点",
99
+ "proc.header": "进程",
100
+ "proc.empty": "无进程占用显存",
101
+ "proc.memFirst": "显存优先",
102
+ "proc.byPid": "按 PID",
103
+ "proc.memOrderHint": "按显存降序 — 点击改为按 PID",
104
+ "proc.pidOrderHint": "按 PID 升序 — 点击改为按显存",
105
+ "log.header": "日志",
106
+ "log.emptyShort": "(空)",
107
+ "log.readFailed": "读取失败",
108
+ "log.tailHint": "仅显示最近 20 行",
109
+ "log.stale": "日志已 {minutes} 分钟无更新,实验可能已结束",
110
+ "log.copyPath": "点击复制路径",
111
+ "log.copied": "已复制",
112
+ "chart.header": "曲线",
113
+ "chart.more": "…共 {count} 条可用曲线",
114
+ "chart.show": "显示 {name}",
115
+ "chart.hide": "隐藏 {name}",
116
+ "chart.span.now": "最近 <1 分钟",
117
+ "chart.span.min": "最近 {n} 分钟",
118
+ "chart.span.hour": "最近 {h} 小时",
119
+ "chart.span.hourMin": "最近 {h} 小时 {m} 分",
120
+ "chart.span.day": "最近 {d} 天 {h} 小时",
121
+ "settings.nav": "服务器看板",
122
+ "settings.addHost": "+ 添加主机",
123
+ "settings.addHostForm": "添加主机",
124
+ "settings.editHost": "编辑主机",
125
+ "settings.importSsh": "从 ~/.ssh/config 导入",
126
+ "settings.refreshInterval": "刷新间隔(秒)",
127
+ "settings.refreshIntervalShort": "刷新间隔",
128
+ "settings.seconds": "秒",
129
+ "settings.staleShort": "实验结束判定",
130
+ "settings.staleSuffix": "分钟无更新",
131
+ "settings.pin": "固定显示(常驻看板,不限数量)",
132
+ "settings.pinnedTag": "已固定",
133
+ "settings.test": "测试连接",
134
+ "settings.testShort": "测试",
135
+ "settings.testing": "测试中…",
136
+ "settings.edit": "编辑",
137
+ "settings.remove": "删除",
138
+ "settings.save": "保存",
139
+ "settings.cancel": "取消",
140
+ "settings.saved": "已保存刷新间隔与实验结束判定。",
141
+ "settings.loading": "加载中…",
142
+ "settings.empty": "还没有主机。点击「+ 添加主机」,或从 ~/.ssh/config 一键导入。",
143
+ "settings.searchHosts": "搜索主机…",
144
+ "settings.noMatch": "没有匹配的主机。",
145
+ "settings.cred": "凭据",
146
+ "settings.credUnnamed": "(未命名)",
147
+ "settings.identityFile": "密钥文件",
148
+ "settings.logPathLabel": "日志",
149
+ "settings.discover": "发现日志",
150
+ "settings.discovering": "发现中…",
151
+ "settings.discoverEmpty": "没有发现候选日志(可能没有训练进程在跑,或其他用户的目录无读权限)",
152
+ "settings.useIt": "用它",
153
+ "settings.noPerm": "无读权限",
154
+ "settings.confirmDelete": "删除主机「{name}」?该操作不可撤销。",
155
+ "settings.imported": "已导入 {added} 台主机,跳过 {skipped} 台重复/无效。建议逐台点击「测试」验证连通性。",
156
+ "settings.importNone": "~/.ssh/config 中没有可导入的新主机(全部已存在或缺少主机地址)。",
157
+ "f.name": "名称",
158
+ "f.host": "主机地址",
159
+ "f.port": "端口",
160
+ "f.user": "用户名",
161
+ "f.auth": "认证方式",
162
+ "f.authKey": "密钥(私钥文本)",
163
+ "f.authPassword": "密码",
164
+ "f.authNone": "无(默认密钥/agent)",
165
+ "f.credRef": "凭据引用名(存于 DSH 凭据库,如 SD_A5000_KEY)",
166
+ "f.secret": "私钥/密码内容(保存时写入凭据库,留空表示沿用已有)",
167
+ "f.logPath": "训练日志路径(远程绝对路径,可留空)",
168
+ "drawer.expand": "整窗展开",
169
+ "drawer.restore": "恢复停靠",
170
+ "drawer.collapse": "收起为侧边标签",
171
+ "drawer.close": "关闭",
172
+ "drawer.resize": "拖动调整宽度",
173
+ "rightbar.tabTitle": "GPU 监控",
174
+ "rightbar.guideDesc": "各服务器 GPU 温度与利用率一行流,常驻侧栏",
175
+ "rightbar.follow": "在右侧栏跟随",
176
+ "rightbar.openFull": "打开完整看板",
177
+ "rightbar.empty": "还没有配置任何服务器。到设置 → 服务器看板 添加主机。",
178
+ "rightbar.offline": "离线",
179
+ "rightbar.offlineCount": "{count} 台离线",
180
+ "rightbar.noGpu": "GPU 数据不可用",
181
+ "rightbar.idle": "空闲",
182
+ "rightbar.busy": "{n}/{m} 卡忙碌",
183
+ "rightbar.cpuMem": "CPU {cpu}% · 内存 {mem}"
184
+ };
185
+ const en = {
186
+ "nav.label": "Servers",
187
+ "nav.title": "Server Dashboard",
188
+ "nav.sumOffline": "{count} offline",
189
+ "nav.sumHot": "{count} hot/maxed",
190
+ "nav.sumStalled": "{count} stalled",
191
+ "nav.sumOk": "all good",
192
+ "panel.empty": "No servers configured yet. Add hosts in Settings → Server Dashboard, or import from ~/.ssh/config.",
193
+ "panel.updatedAt": "Updated {time}",
194
+ "panel.refresh": "Refresh",
195
+ "panel.hostError": "Host unreachable — showing the last snapshot",
196
+ "panel.offlineBanner": "Dashboard offline: cannot reach the backend — showing data from {time}",
197
+ "panel.retry": "Retry",
198
+ "panel.nextRefresh": "refresh in {s}s",
199
+ "rel.now": "just now",
200
+ "rel.sec": "{n}s ago",
201
+ "rel.min": "{n}min ago",
202
+ "toast.clickToOpen": "Click to open this host",
203
+ "panel.sse.offline": "SSH connection failed (host down or unreachable)",
204
+ "panel.sse.hot": "A GPU is running hot",
205
+ "host.retry": "Retry now (clears backoff)",
206
+ "host.pin": "Pin (always shown)",
207
+ "host.unpin": "Unpin (fold into other servers)",
208
+ "host.offline": "offline",
209
+ "host.connecting": "first collection in progress…",
210
+ "host.offlineMsg": "connection failed",
211
+ "host.authRejected": "Auth rejected: the server refused this key (check authorized_keys; if sshd was just updated, restart the app and retry)",
212
+ "host.gpuCards": "{count} GPUs",
213
+ "host.gpuUnavailable": "GPU data unavailable",
214
+ "host.gpuUnavailableHint": "Host is online but nvidia-smi returned no usable data (missing driver / permissions / PCIe enumeration failure)",
215
+ "host.hotGpus": "{count} hot/maxed",
216
+ "host.stalledExps": "⏸ {count} stalled",
217
+ "host.diskWarn": "{count} disks >90%",
218
+ "host.logStalled": "log stalled",
219
+ "host.runningExps": "{count} running",
220
+ "host.allGood": "all good",
221
+ "other.collapsed": "Other servers ({count})",
222
+ "overview.title": "GPU OVERVIEW",
223
+ "fleet.running": "training",
224
+ "fleet.idle": "idle",
225
+ "fleet.anomalous": "anomalous",
226
+ "chart.smoothHint": "Curve smoothing (EMA) on/off — denoise the trend",
227
+ "overview.offline": "offline",
228
+ "gpu.util": "Util",
229
+ "gpu.memory": "Memory",
230
+ "gpu.temp": "Temp",
231
+ "gpu.power": "Power",
232
+ "gpu.idle": "idle",
233
+ "gpu.stalled": "stalled {min}m",
234
+ "gpu.noLog": "No log file found for this GPU's process (stdout not redirected)",
235
+ "gpu.expandHint": "Expand this GPU's experiment log/curves",
236
+ "gpu.switchMetric": "Click to switch metric (current {name})",
237
+ "base.cpu": "CPU",
238
+ "base.memory": "Memory",
239
+ "base.load": "load",
240
+ "base.disk": "Disk",
241
+ "disk.mounts": "mounts",
242
+ "proc.header": "Processes",
243
+ "proc.empty": "No GPU processes",
244
+ "proc.memFirst": "memory first",
245
+ "proc.byPid": "by PID",
246
+ "proc.memOrderHint": "Sorted by memory desc — click to sort by PID",
247
+ "proc.pidOrderHint": "Sorted by PID asc — click to sort by memory",
248
+ "log.header": "Log",
249
+ "log.emptyShort": "(empty)",
250
+ "log.readFailed": "read failed",
251
+ "log.tailHint": "Showing only the last 20 lines",
252
+ "log.stale": "Log unchanged for {minutes} min — experiment may have finished",
253
+ "log.copyPath": "click to copy path",
254
+ "log.copied": "copied",
255
+ "chart.header": "Trends",
256
+ "chart.more": "…{count} series available",
257
+ "chart.show": "Show {name}",
258
+ "chart.hide": "Hide {name}",
259
+ "chart.span.now": "last <1 min",
260
+ "chart.span.min": "last {n} min",
261
+ "chart.span.hour": "last {h} h",
262
+ "chart.span.hourMin": "last {h} h {m} m",
263
+ "chart.span.day": "last {d} d {h} h",
264
+ "settings.nav": "Server Dashboard",
265
+ "settings.addHost": "+ Add host",
266
+ "settings.addHostForm": "Add host",
267
+ "settings.editHost": "Edit host",
268
+ "settings.importSsh": "Import from ~/.ssh/config",
269
+ "settings.refreshInterval": "Refresh interval (seconds)",
270
+ "settings.refreshIntervalShort": "Refresh every",
271
+ "settings.seconds": "s",
272
+ "settings.staleShort": "Finished after",
273
+ "settings.staleSuffix": "min idle",
274
+ "settings.pin": "Pinned (always shown, no limit)",
275
+ "settings.pinnedTag": "pinned",
276
+ "settings.test": "Test connection",
277
+ "settings.testShort": "Test",
278
+ "settings.testing": "testing…",
279
+ "settings.edit": "Edit",
280
+ "settings.remove": "Delete",
281
+ "settings.save": "Save",
282
+ "settings.cancel": "Cancel",
283
+ "settings.saved": "Refresh interval and finish threshold saved.",
284
+ "settings.loading": "Loading…",
285
+ "settings.empty": "No hosts yet. Click \"+ Add host\", or import from ~/.ssh/config.",
286
+ "settings.searchHosts": "Search hosts…",
287
+ "settings.noMatch": "No matching hosts.",
288
+ "settings.cred": "credential",
289
+ "settings.credUnnamed": "(unnamed)",
290
+ "settings.identityFile": "identity file",
291
+ "settings.logPathLabel": "log",
292
+ "settings.discover": "Discover logs",
293
+ "settings.discovering": "discovering…",
294
+ "settings.discoverEmpty": "No candidate logs found (no training process running, or other users' dirs are unreadable)",
295
+ "settings.useIt": "Use",
296
+ "settings.noPerm": "no read permission",
297
+ "settings.confirmDelete": "Delete host \"{name}\"? This cannot be undone.",
298
+ "settings.imported": "Imported {added} hosts, skipped {skipped} duplicate/invalid. Run \"Test\" on each to verify connectivity.",
299
+ "settings.importNone": "No new hosts to import from ~/.ssh/config (all exist or missing address).",
300
+ "f.name": "Name",
301
+ "f.host": "Host address",
302
+ "f.port": "Port",
303
+ "f.user": "Username",
304
+ "f.auth": "Authentication",
305
+ "f.authKey": "Key (private key text)",
306
+ "f.authPassword": "Password",
307
+ "f.authNone": "None (default key/agent)",
308
+ "f.credRef": "Credential ref name (stored in the DSH vault, e.g. SD_A5000_KEY)",
309
+ "f.secret": "Private key / password (written to the vault on save; empty = keep existing)",
310
+ "f.logPath": "Training log path (remote absolute path, optional)",
311
+ "drawer.expand": "Expand to full window",
312
+ "drawer.restore": "Restore dock",
313
+ "drawer.collapse": "Collapse to rail",
314
+ "drawer.close": "Close",
315
+ "drawer.resize": "Drag to resize",
316
+ "rightbar.tabTitle": "GPU Monitor",
317
+ "rightbar.guideDesc": "One-line GPU temp & utilization per server, pinned in the right sidebar",
318
+ "rightbar.follow": "Follow in right sidebar",
319
+ "rightbar.openFull": "Open full dashboard",
320
+ "rightbar.empty": "No servers configured yet. Add hosts in Settings → Server Dashboard.",
321
+ "rightbar.offline": "Offline",
322
+ "rightbar.offlineCount": "{count} offline",
323
+ "rightbar.noGpu": "GPU data unavailable",
324
+ "rightbar.idle": "Idle",
325
+ "rightbar.busy": "{n}/{m} busy",
326
+ "rightbar.cpuMem": "CPU {cpu}% · MEM {mem}"
327
+ };
328
+
329
+ //#endregion
330
+ //#region src/client/ui.tsx
331
+ /** full GPU process table: memory-desc by default (the trainer belongs on
332
+ * top), click the header to flip to PID order; optional GPU column */
333
+ function ProcTable({ procs, showGpu = false, t }) {
334
+ const [byMem, setByMem] = (0, react.useState)(true);
335
+ const sorted = [...procs].sort((a, b) => byMem ? b.memoryMiB - a.memoryMiB : a.pid - b.pid);
336
+ const tdBase = {
337
+ padding: "2px 6px 2px 0",
338
+ color: "var(--dsw-alias-label-secondary)",
339
+ fontVariantNumeric: "tabular-nums",
340
+ verticalAlign: "top"
341
+ };
342
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
343
+ style: {
344
+ width: "100%",
345
+ borderCollapse: "collapse",
346
+ fontSize: 11
347
+ },
348
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
349
+ colSpan: showGpu ? 4 : 3,
350
+ style: {
351
+ textAlign: "left",
352
+ fontWeight: 400
353
+ },
354
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
355
+ type: "button",
356
+ onClick: () => setByMem((v) => !v),
357
+ title: byMem ? t("proc.memOrderHint") : t("proc.pidOrderHint"),
358
+ style: {
359
+ border: "none",
360
+ background: "none",
361
+ padding: "0 0 2px",
362
+ cursor: "pointer",
363
+ userSelect: "none",
364
+ fontSize: 9.5,
365
+ color: "var(--dsw-alias-label-caption)",
366
+ borderRadius: 4
367
+ },
368
+ children: byMem ? `▾ ${t("proc.memFirst")}` : `⇅ ${t("proc.byPid")}`
369
+ })
370
+ }) }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: sorted.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
371
+ showGpu && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("td", {
372
+ style: tdBase,
373
+ children: ["GPU", p.gpu]
374
+ }),
375
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
376
+ style: {
377
+ ...tdBase,
378
+ color: "var(--dsw-alias-label-primary)"
379
+ },
380
+ children: p.pid
381
+ }),
382
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
383
+ style: tdBase,
384
+ children: p.user
385
+ }),
386
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
387
+ style: {
388
+ ...tdBase,
389
+ overflow: "hidden",
390
+ textOverflow: "ellipsis",
391
+ whiteSpace: "nowrap",
392
+ maxWidth: 130
393
+ },
394
+ title: p.cmd ?? p.name,
395
+ children: p.cmd ?? p.name
396
+ }),
397
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("td", {
398
+ style: {
399
+ ...tdBase,
400
+ textAlign: "right"
401
+ },
402
+ children: [(p.memoryMiB / 1024).toFixed(1), "G"]
403
+ })
404
+ ] }, `${p.gpu ?? ""}-${p.pid}`)) })]
405
+ });
406
+ }
407
+ /** "5 秒前" style relative time — reads faster than an absolute clock stamp */
408
+ function relTime(at, t) {
409
+ const s = Math.max(0, Math.round((Date.now() - at) / 1e3));
410
+ if (s < 5) return t("rel.now");
411
+ if (s < 60) return t("rel.sec", { n: s });
412
+ const m = Math.round(s / 60);
413
+ if (m < 60) return t("rel.min", { n: m });
414
+ return new Date(at).toLocaleTimeString();
415
+ }
416
+ /** text that flashes briefly when its content changes — the poll refresh
417
+ * otherwise swaps numbers silently and the eye can't tell what moved */
418
+ function LiveText({ children, style }) {
419
+ const prev = (0, react.useRef)(children);
420
+ const [flash, setFlash] = (0, react.useState)(false);
421
+ (0, react.useEffect)(() => {
422
+ if (prev.current !== children) {
423
+ prev.current = children;
424
+ setFlash(true);
425
+ const id = setTimeout(() => setFlash(false), 700);
426
+ return () => clearTimeout(id);
427
+ }
428
+ }, [children]);
429
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
430
+ style: {
431
+ ...style,
432
+ borderRadius: 4,
433
+ padding: flash ? "0 2px" : "0 2px",
434
+ margin: flash ? "0 -2px" : "0 -2px",
435
+ animation: flash ? "dsh-dash-numflash .7s ease-out" : void 0
436
+ },
437
+ children
438
+ });
439
+ }
440
+ const T = {
441
+ primary: "var(--dsw-alias-label-primary)",
442
+ secondary: "var(--dsw-alias-label-secondary)",
443
+ tertiary: "var(--dsw-alias-label-tertiary)",
444
+ caption: "var(--dsw-alias-label-caption)",
445
+ success: "var(--dsw-alias-state-success-primary, #30a46c)",
446
+ warning: "var(--dsw-alias-state-warn-primary, #f5a524)",
447
+ danger: "var(--dsw-alias-state-error-primary, #e5484d)",
448
+ business: "var(--dsw-alias-state-business-primary, #4d6bfe)",
449
+ borderL1: "var(--dsw-alias-border-l1)",
450
+ borderL2: "var(--dsw-alias-border-l2)",
451
+ hoverBg: "var(--dsw-alias-interactive-bg-hover)",
452
+ cardBg: "var(--dsw-alias-bg-layer-1, rgba(127,127,127,.06))",
453
+ trackFill: "var(--dsw-alias-fill-l2, rgba(127,127,127,.22))"
454
+ };
455
+ /** status dot: green / amber / red / gray */
456
+ function Dot({ tone = "gray", size = 8 }) {
457
+ const color = tone === "ok" ? T.success : tone === "warn" ? T.warning : tone === "err" ? T.danger : "var(--dsw-alias-label-caption)";
458
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
459
+ width: size,
460
+ height: size,
461
+ borderRadius: "50%",
462
+ background: color,
463
+ flex: "none",
464
+ display: "inline-block"
465
+ } });
466
+ }
467
+ /** thin rounded progress bar */
468
+ function Meter({ percent, tone, height = 7 }) {
469
+ const p = Math.max(0, Math.min(100, percent));
470
+ const color = tone === "hot" ? T.danger : tone === "warn" ? T.warning : T.business;
471
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
472
+ style: {
473
+ position: "relative",
474
+ height,
475
+ borderRadius: height / 2,
476
+ background: T.trackFill,
477
+ flex: 1,
478
+ minWidth: 30,
479
+ overflow: "hidden"
480
+ },
481
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { style: {
482
+ position: "absolute",
483
+ inset: 0,
484
+ width: `${p}%`,
485
+ borderRadius: height / 2,
486
+ background: color,
487
+ transition: "width .4s ease"
488
+ } })
489
+ });
490
+ }
491
+ /** 16px stroke icon set (matches the shell's 1.5px stroke style) */
492
+ function Icon({ d, size = 16, color = "currentColor" }) {
493
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
494
+ viewBox: "0 0 16 16",
495
+ width: size,
496
+ height: size,
497
+ "aria-hidden": true,
498
+ fill: "none",
499
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
500
+ d,
501
+ stroke: color,
502
+ strokeWidth: "1.5",
503
+ strokeLinecap: "round",
504
+ strokeLinejoin: "round"
505
+ })
506
+ });
507
+ }
508
+ const Icons = {
509
+ refresh: "M13.5 8A5.5 5.5 0 1 1 8 2.5M13.5 2.5V6h-3.5",
510
+ collapseRight: "M4.5 4l4 4-4 4M9 4l4 4-4 4",
511
+ close: "M4 4l8 8M12 4l-8 8",
512
+ chevronDown: "M4 6l4 4 4-4",
513
+ /** monitor glyph (sidebar trigger) */
514
+ monitor: "M2 3.5h12v7.5H2zM6 13.5h4M8 11v2.5",
515
+ /** full-window expand / restore */
516
+ expand: "M6 2H2v4M10 2h4v4M14 10v4h-4M2 10v4h4"
517
+ };
518
+ /** round icon button with DSH hover fill */
519
+ function IconButton(props) {
520
+ const { label, onClick, icon, disabled, size = 26, color } = props;
521
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
522
+ type: "button",
523
+ "aria-label": label,
524
+ title: label,
525
+ disabled,
526
+ onClick,
527
+ style: {
528
+ width: size,
529
+ height: size,
530
+ borderRadius: 999,
531
+ flex: "none",
532
+ display: "inline-flex",
533
+ alignItems: "center",
534
+ justifyContent: "center",
535
+ background: "none",
536
+ border: "none",
537
+ cursor: disabled ? "default" : "pointer",
538
+ color: disabled ? "var(--dsw-alias-label-dimmed, #9a9ea5)" : color ?? "var(--dsw-alias-label-secondary)",
539
+ opacity: disabled ? .55 : 1
540
+ },
541
+ onMouseEnter: (e) => {
542
+ if (!disabled) e.currentTarget.style.background = T.hoverBg;
543
+ },
544
+ onMouseLeave: (e) => {
545
+ e.currentTarget.style.background = "none";
546
+ },
547
+ children: icon
548
+ });
549
+ }
550
+ /** collapsible section header: chevron + label + optional count, DSH hover row */
551
+ function SectionHeader(props) {
552
+ const { label, count, open, onClick } = props;
553
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
554
+ type: "button",
555
+ onClick,
556
+ style: {
557
+ display: "inline-flex",
558
+ alignItems: "center",
559
+ gap: 4,
560
+ border: "none",
561
+ background: "none",
562
+ cursor: "pointer",
563
+ borderRadius: 6,
564
+ padding: "3px 8px 3px 5px",
565
+ fontSize: 12,
566
+ color: "var(--dsw-alias-label-secondary)",
567
+ transition: "background .12s"
568
+ },
569
+ onMouseEnter: (e) => {
570
+ e.currentTarget.style.background = T.hoverBg;
571
+ },
572
+ onMouseLeave: (e) => {
573
+ e.currentTarget.style.background = "none";
574
+ },
575
+ children: [
576
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
577
+ "aria-hidden": true,
578
+ style: {
579
+ display: "inline-flex",
580
+ transition: "transform .12s",
581
+ transform: open ? "rotate(0deg)" : "rotate(-90deg)",
582
+ color: "var(--dsw-alias-label-caption)"
583
+ },
584
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
585
+ d: Icons.chevronDown,
586
+ size: 12
587
+ })
588
+ }),
589
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }),
590
+ count !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
591
+ style: {
592
+ color: "var(--dsw-alias-label-caption)",
593
+ fontVariantNumeric: "tabular-nums"
594
+ },
595
+ children: count
596
+ })
597
+ ]
598
+ });
599
+ }
600
+ /** metric block: caption label + value + bar (used by CPU/memory/disk rows) */
601
+ function MetricBlock(props) {
602
+ const { label, value, percent, tone, flexBasis } = props;
603
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
604
+ style: {
605
+ display: "flex",
606
+ flexDirection: "column",
607
+ gap: 3,
608
+ flex: flexBasis !== void 0 ? `0 1 ${flexBasis}` : 1,
609
+ minWidth: 0
610
+ },
611
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
612
+ style: {
613
+ display: "flex",
614
+ justifyContent: "space-between",
615
+ alignItems: "baseline",
616
+ gap: 6
617
+ },
618
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
619
+ style: {
620
+ fontSize: 10,
621
+ color: "var(--dsw-alias-label-caption)",
622
+ overflow: "hidden",
623
+ textOverflow: "ellipsis",
624
+ whiteSpace: "nowrap"
625
+ },
626
+ children: label
627
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveText, {
628
+ style: {
629
+ fontSize: 11,
630
+ color: "var(--dsw-alias-label-primary)",
631
+ fontVariantNumeric: "tabular-nums",
632
+ whiteSpace: "nowrap"
633
+ },
634
+ children: value
635
+ })]
636
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meter, {
637
+ percent,
638
+ tone,
639
+ height: 6
640
+ })]
641
+ });
642
+ }
643
+ /** tiny trend line for the GPU row: loss-like metrics go green when improving */
644
+ function Sparkline({ series, width = 76, height = 20 }) {
645
+ const pts = series.points.slice(-40);
646
+ if (pts.length < 2) return null;
647
+ const vals = pts.map((p) => p.v);
648
+ let min = Math.min(...vals);
649
+ let max = Math.max(...vals);
650
+ if (min === max) {
651
+ min -= 1;
652
+ max += 1;
653
+ }
654
+ const x = (i) => i / (pts.length - 1) * width;
655
+ const y = (v) => height - (v - min) / (max - min) * (height - 5) - 2.5;
656
+ const path = pts.map((p, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(p.v).toFixed(1)}`).join("");
657
+ const isLoss = /loss/i.test(series.name);
658
+ const k = Math.max(3, Math.min(10, Math.floor(vals.length / 4)));
659
+ const mean = (slice) => slice.reduce((a, b) => a + b, 0) / slice.length;
660
+ const improved = isLoss ? mean(vals.slice(-k)) < mean(vals.slice(0, k)) : mean(vals.slice(-k)) >= mean(vals.slice(0, k));
661
+ const color = isLoss ? improved ? T.success : T.danger : T.business;
662
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
663
+ width,
664
+ height,
665
+ style: {
666
+ display: "block",
667
+ flex: "none"
668
+ },
669
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
670
+ d: path,
671
+ fill: "none",
672
+ stroke: color,
673
+ strokeWidth: 1.5,
674
+ strokeLinejoin: "round"
675
+ })
676
+ });
677
+ }
678
+ /** log line tone: errors read red, warnings amber, the rest neutral.
679
+ * 在词边界之外再排除路径段(/nan/、/error/)与 warn.log 这类文件名——
680
+ * 日志里的路径/目录名不再被误着色 */
681
+ const ERR_LINE = /(?<![/\\])(error|exception|traceback|failed|fatal|nan|cuda out of memory)(?![\w/\\])(?!\.log\b)/i;
682
+ const WARN_LINE = /(?<![/\\])(warning|warn|deprecated|fallback)(?![\w/\\])(?!\.log\b)/i;
683
+ /** metric name=value pairs whose numbers get tinted (loss/acc/lr/epoch/...) */
684
+ const METRIC_PAIR = /((?:loss|acc|lr|epoch|map|iou|f1|prec|rec|mem|grad)[\w]*\s*[=:]\s*)(-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)/gi;
685
+ function logLineNodes(line, key) {
686
+ if (ERR_LINE.test(line)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
687
+ style: { color: T.danger },
688
+ children: line
689
+ }, key);
690
+ if (WARN_LINE.test(line)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
691
+ style: { color: T.warning },
692
+ children: line
693
+ }, key);
694
+ METRIC_PAIR.lastIndex = 0;
695
+ const out = [];
696
+ let last = 0;
697
+ let m;
698
+ while ((m = METRIC_PAIR.exec(line)) !== null) {
699
+ if (m.index > last) out.push(line.slice(last, m.index));
700
+ out.push(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [m[1], /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
701
+ style: { color: T.business },
702
+ children: m[2]
703
+ })] }, `${key}-${m.index}`));
704
+ last = m.index + m[0].length;
705
+ }
706
+ if (last < line.length) out.push(line.slice(last));
707
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: out.length ? out : line }, key);
708
+ }
709
+ /** log tail viewer: sticks to the bottom as new lines arrive; manual scroll-up
710
+ * releases the stick until the user returns near the bottom. */
711
+ function LogView({ lines, maxHeight = 168, t, readFailed = false }) {
712
+ const ref = (0, react.useRef)(null);
713
+ const stick = (0, react.useRef)(true);
714
+ const onScroll = () => {
715
+ const el = ref.current;
716
+ if (!el) return;
717
+ stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
718
+ };
719
+ const nodes = lines.flatMap((l) => l.split(/\r+/)).map((s) => s.trim()).filter(Boolean).slice(-20).map((s, i) => logLineNodes(s, i));
720
+ (0, react.useLayoutEffect)(() => {
721
+ const el = ref.current;
722
+ if (el && stick.current) el.scrollTop = el.scrollHeight;
723
+ }, [nodes]);
724
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
725
+ style: { width: "100%" },
726
+ children: [nodes.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
727
+ style: {
728
+ fontSize: 9,
729
+ color: "var(--dsw-alias-label-caption)",
730
+ marginBottom: 2
731
+ },
732
+ children: t("log.tailHint")
733
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
734
+ ref,
735
+ onScroll,
736
+ style: {
737
+ margin: 0,
738
+ fontSize: 10.5,
739
+ fontFamily: "var(--ds-font-family-code, monospace)",
740
+ lineHeight: 1.55,
741
+ maxHeight,
742
+ overflow: "auto",
743
+ background: "var(--dsw-alias-markdown-code-block, rgba(127,127,127,.08))",
744
+ borderRadius: 8,
745
+ padding: "8px 10px",
746
+ color: "var(--dsw-alias-label-secondary)",
747
+ whiteSpace: "pre-wrap",
748
+ wordBreak: "break-all"
749
+ },
750
+ children: nodes.length ? nodes : readFailed ? t("log.readFailed") : t("log.emptyShort")
751
+ })]
752
+ });
753
+ }
754
+
755
+ //#endregion
756
+ //#region src/client/focus.ts
757
+ const focusBus = {
758
+ value: null,
759
+ listeners: /* @__PURE__ */ new Set(),
760
+ subscribe(listener) {
761
+ focusBus.listeners.add(listener);
762
+ return () => {
763
+ focusBus.listeners.delete(listener);
764
+ };
765
+ },
766
+ getSnapshot() {
767
+ return focusBus.value;
768
+ },
769
+ set(next) {
770
+ const cur = focusBus.value;
771
+ if (cur === null && next === null || cur !== null && next !== null && cur.hostId === next.hostId && cur.gpuIndex === next.gpuIndex) return;
772
+ focusBus.value = next;
773
+ for (const listener of focusBus.listeners) listener();
774
+ }
775
+ };
776
+
777
+ //#endregion
778
+ //#region src/client/DashboardPanel.tsx
779
+ /** 占位 GPU 判定:部分主机 ok 但 nvidia-smi 拿不到数据(index null/无名/全零)。
780
+ * 这种条目渲染出来只会是 "GPUnull · 0% · 0°C · 0.0/0G" 噪音,统一过滤
781
+ * (与右侧栏 GPU 页签同一谓词)。 */
782
+ function hasGpuData(g) {
783
+ return !!g.name || g.memoryTotalMiB > 0 || g.tempC > 0 || g.utilPercent > 0;
784
+ }
785
+ const PALETTE = [
786
+ "#4d6bfe",
787
+ "#30a46c",
788
+ "#f5a524",
789
+ "#e5484d",
790
+ "#8e4ec6",
791
+ "#12a594",
792
+ "#d6409f",
793
+ "#9099ac"
794
+ ];
795
+ function SeriesChart({ series, t }) {
796
+ const height = 76;
797
+ const plotLeft = 8;
798
+ const plotRight = 8;
799
+ const plotTop = 8;
800
+ const wrapRef = (0, react.useRef)(null);
801
+ const [width, setWidth] = (0, react.useState)(248);
802
+ (0, react.useEffect)(() => {
803
+ const el = wrapRef.current;
804
+ if (!el || typeof ResizeObserver === "undefined") return;
805
+ const ro = new ResizeObserver((entries) => {
806
+ const w = entries[0]?.contentRect?.width ?? 0;
807
+ if (w > 60) setWidth(Math.max(200, Math.floor(w)));
808
+ });
809
+ ro.observe(el);
810
+ return () => ro.disconnect();
811
+ }, []);
812
+ const plotW = width - plotLeft - plotRight;
813
+ const plotH = 58;
814
+ const [hidden, setHidden] = (0, react.useState)({});
815
+ const [hover, setHover] = (0, react.useState)(null);
816
+ const [smooth, setSmooth] = (0, react.useState)(true);
817
+ const [focusName, setFocusName] = (0, react.useState)(null);
818
+ const drawn = orderedSeries(series);
819
+ const initRef = (0, react.useRef)(/* @__PURE__ */ new Set());
820
+ (0, react.useEffect)(() => {
821
+ setHidden((h) => {
822
+ const next = { ...h };
823
+ let changed = false;
824
+ drawn.forEach((s, i) => {
825
+ if (initRef.current.has(s.name)) return;
826
+ initRef.current.add(s.name);
827
+ if (i >= 3 && next[s.name] !== true) {
828
+ next[s.name] = true;
829
+ changed = true;
830
+ }
831
+ });
832
+ return changed ? next : h;
833
+ });
834
+ }, [drawn.map((s) => s.name).join("|")]);
835
+ if (drawn.length === 0) return null;
836
+ const visible = drawn.filter((s) => !hidden[s.name]);
837
+ const colorOf = (i) => PALETTE[i % PALETTE.length];
838
+ /** exponential moving average over a point array — alpha 0.35 ≈ TB smoothing 0.65 */
839
+ const ema = (pts) => {
840
+ if (!smooth || pts.length < 4) return pts;
841
+ const a = .35;
842
+ let acc = pts[0].v;
843
+ return pts.map((p) => {
844
+ acc = a * p.v + .65 * acc;
845
+ return {
846
+ t: p.t,
847
+ v: acc
848
+ };
849
+ });
850
+ };
851
+ const toggle = (name) => {
852
+ setHidden((h) => {
853
+ const next = {
854
+ ...h,
855
+ [name]: !h[name]
856
+ };
857
+ if (drawn.filter((s) => !next[s.name]).length === 0) return h;
858
+ return next;
859
+ });
860
+ };
861
+ /** decimate dense per-iteration data to ~80 draw points: trend readable.
862
+ * 抽稀保尾:filter 后强制补回最后一个点,右端"当前值"始终是最新的 */
863
+ const drawPts = (s) => {
864
+ const pts = ema(s.points.slice(-200));
865
+ const step = Math.max(1, Math.ceil(pts.length / 80));
866
+ if (step <= 1) return pts;
867
+ const out = pts.filter((_, i) => i % step === 0);
868
+ if (out[out.length - 1] !== pts[pts.length - 1]) out.push(pts[pts.length - 1]);
869
+ return out;
870
+ };
871
+ const slice = (s) => drawPts(s);
872
+ const yOf = (s, v) => {
873
+ const vals = slice(s).map((p) => p.v);
874
+ let min = Math.min(...vals);
875
+ let max = Math.max(...vals);
876
+ if (min === max) {
877
+ min -= 1;
878
+ max += 1;
879
+ }
880
+ return 66 - (v - min) / (max - min) * plotH;
881
+ };
882
+ const pathOf = (s) => {
883
+ const pts = slice(s);
884
+ return pts.map((p, i) => `${i === 0 ? "M" : "L"}${(plotLeft + i / (pts.length - 1) * plotW).toFixed(1)},${yOf(s, p.v).toFixed(1)}`).join("");
885
+ };
886
+ const valueAt = (s, ratio) => {
887
+ const pts = slice(s);
888
+ const n = pts.length;
889
+ const i = Math.max(0, Math.min(n - 1, Math.round(ratio * (n - 1))));
890
+ return {
891
+ p: pts[i],
892
+ x: plotLeft + (n > 1 ? i / (n - 1) : 0) * plotW,
893
+ y: yOf(s, pts[i].v)
894
+ };
895
+ };
896
+ const onMove = (e) => {
897
+ const r = e.currentTarget.getBoundingClientRect();
898
+ const mx = Math.max(plotLeft, Math.min(plotLeft + plotW, e.clientX - r.left));
899
+ setHover({
900
+ mx,
901
+ ratio: (mx - plotLeft) / plotW
902
+ });
903
+ };
904
+ const hoverRows = (hover ? visible.map((s, i) => ({
905
+ name: s.name,
906
+ color: colorOf(drawn.indexOf(s)),
907
+ v: valueAt(s, hover.ratio).p.v
908
+ })) : []).slice(0, 5);
909
+ const hoverExtra = hover && visible.length > 5 ? visible.length - 5 : 0;
910
+ const hoverTime = hover && visible[0] ? valueAt(visible[0], hover.ratio).p.t : 0;
911
+ const tooltipLeft = hover ? hover.mx > width / 2 ? hover.mx - 150 : hover.mx + 10 : 0;
912
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
913
+ ref: wrapRef,
914
+ style: { position: "relative" },
915
+ children: [
916
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
917
+ style: {
918
+ display: "flex",
919
+ gap: "3px 10px",
920
+ marginBottom: 4,
921
+ overflowX: "auto",
922
+ scrollbarWidth: "thin",
923
+ paddingBottom: 1,
924
+ maxWidth: "100%",
925
+ alignItems: "center"
926
+ },
927
+ children: [drawn.map((s, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
928
+ type: "button",
929
+ onClick: () => toggle(s.name),
930
+ onMouseEnter: () => setFocusName(s.name),
931
+ onFocus: () => setFocusName(s.name),
932
+ onMouseLeave: () => setFocusName((cur) => cur === s.name ? null : cur),
933
+ onBlur: () => setFocusName((cur) => cur === s.name ? null : cur),
934
+ title: hidden[s.name] ? t("chart.show", { name: s.name }) : t("chart.hide", { name: s.name }),
935
+ style: {
936
+ display: "inline-flex",
937
+ alignItems: "center",
938
+ gap: 4,
939
+ border: "none",
940
+ background: "none",
941
+ padding: 0,
942
+ cursor: "pointer",
943
+ opacity: hidden[s.name] ? .5 : focusName && focusName !== s.name ? .35 : 1,
944
+ flex: "none",
945
+ whiteSpace: "nowrap"
946
+ },
947
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
948
+ width: 7,
949
+ height: 7,
950
+ borderRadius: 7,
951
+ background: colorOf(i),
952
+ display: "inline-block"
953
+ } }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
954
+ title: s.name,
955
+ style: {
956
+ fontSize: 10,
957
+ color: hidden[s.name] ? "var(--dsw-alias-label-caption)" : "var(--dsw-alias-label-secondary)",
958
+ textDecoration: hidden[s.name] ? "line-through" : "none",
959
+ maxWidth: 120,
960
+ overflow: "hidden",
961
+ textOverflow: "ellipsis"
962
+ },
963
+ children: s.name
964
+ })]
965
+ }, s.name)), visible.length >= 2 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
966
+ type: "button",
967
+ onClick: () => setSmooth((v) => !v),
968
+ title: t("chart.smoothHint"),
969
+ style: {
970
+ flex: "none",
971
+ marginLeft: "auto",
972
+ border: `1px solid ${smooth ? T.business : T.borderL2}`,
973
+ background: smooth ? `color-mix(in srgb, ${T.business} 12%, transparent)` : "none",
974
+ color: smooth ? T.business : "var(--dsw-alias-label-caption)",
975
+ borderRadius: 999,
976
+ fontSize: 9.5,
977
+ padding: "0 8px",
978
+ height: 16,
979
+ cursor: "pointer"
980
+ },
981
+ children: "S"
982
+ })]
983
+ }),
984
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
985
+ width,
986
+ height,
987
+ style: {
988
+ display: "block",
989
+ background: T.trackFill,
990
+ borderRadius: 6,
991
+ cursor: "crosshair",
992
+ touchAction: "none"
993
+ },
994
+ onPointerMove: onMove,
995
+ onPointerLeave: () => setHover(null),
996
+ children: [
997
+ [
998
+ .25,
999
+ .5,
1000
+ .75
1001
+ ].map((r) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
1002
+ x1: plotLeft,
1003
+ x2: plotLeft + plotW,
1004
+ y1: plotTop + plotH * r,
1005
+ y2: plotTop + plotH * r,
1006
+ stroke: "var(--dsw-alias-label-caption)",
1007
+ strokeOpacity: .18,
1008
+ strokeWidth: 1,
1009
+ strokeDasharray: "3 4"
1010
+ }, r)),
1011
+ visible.length > 0 && (() => {
1012
+ const vals = slice(visible[0]).map((p) => p.v);
1013
+ const min = Math.min(...vals);
1014
+ const max = Math.max(...vals);
1015
+ if (min === max) return null;
1016
+ const color = colorOf(drawn.indexOf(visible[0]));
1017
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", {
1018
+ x: 10,
1019
+ y: 16,
1020
+ fontSize: 8.5,
1021
+ fill: color,
1022
+ opacity: .65,
1023
+ children: fmt(max)
1024
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", {
1025
+ x: 10,
1026
+ y: 65,
1027
+ fontSize: 8.5,
1028
+ fill: color,
1029
+ opacity: .65,
1030
+ children: fmt(min)
1031
+ })] });
1032
+ })(),
1033
+ visible.map((s) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
1034
+ d: pathOf(s),
1035
+ fill: "none",
1036
+ stroke: colorOf(drawn.indexOf(s)),
1037
+ strokeWidth: 1.5,
1038
+ strokeLinejoin: "round",
1039
+ opacity: focusName && focusName !== s.name ? .18 : 1
1040
+ }, s.name)),
1041
+ visible.map((s) => {
1042
+ const at = valueAt(s, 1);
1043
+ const y = Math.max(15, Math.min(64, at.y - 3));
1044
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", {
1045
+ x: plotLeft + plotW - 2,
1046
+ y,
1047
+ textAnchor: "end",
1048
+ fill: colorOf(drawn.indexOf(s)),
1049
+ fontSize: 9,
1050
+ style: { fontVariantNumeric: "tabular-nums" },
1051
+ children: fmt(at.p.v)
1052
+ }, `lbl-${s.name}`);
1053
+ }),
1054
+ hover && (() => {
1055
+ const x = visible[0] ? valueAt(visible[0], hover.ratio).x : hover.mx;
1056
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
1057
+ x1: x,
1058
+ x2: x,
1059
+ y1: plotTop,
1060
+ y2: 66,
1061
+ stroke: "var(--dsw-alias-label-caption)",
1062
+ strokeWidth: 1
1063
+ }), visible.map((s) => {
1064
+ const at = valueAt(s, hover.ratio);
1065
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1066
+ cx: at.x,
1067
+ cy: at.y,
1068
+ r: 2.6,
1069
+ fill: colorOf(drawn.indexOf(s)),
1070
+ stroke: "var(--dsw-alias-bg-base)",
1071
+ strokeWidth: 1
1072
+ }, s.name);
1073
+ })] });
1074
+ })()
1075
+ ]
1076
+ }),
1077
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1078
+ style: {
1079
+ marginTop: 2,
1080
+ fontSize: 9.5,
1081
+ color: "var(--dsw-alias-label-caption)",
1082
+ textAlign: "right",
1083
+ fontVariantNumeric: "tabular-nums"
1084
+ },
1085
+ children: fmtSpan(drawn, t)
1086
+ }),
1087
+ hover && hoverRows.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1088
+ style: {
1089
+ position: "absolute",
1090
+ top: 26,
1091
+ left: tooltipLeft,
1092
+ zIndex: 3,
1093
+ pointerEvents: "none",
1094
+ background: "var(--dsw-specific-menu, var(--dsw-alias-bg-layer-2, #2a2a2a))",
1095
+ border: "1px solid var(--dsw-alias-border-l2)",
1096
+ borderRadius: 8,
1097
+ padding: "5px 8px",
1098
+ boxShadow: "var(--dsw-shadow-lv2, 0 4px 12px rgba(0,0,0,.2))",
1099
+ fontSize: 10.5,
1100
+ lineHeight: 1.6,
1101
+ minWidth: 130,
1102
+ maxWidth: 160
1103
+ },
1104
+ children: [
1105
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1106
+ style: {
1107
+ color: "var(--dsw-alias-label-caption)",
1108
+ marginBottom: 1
1109
+ },
1110
+ children: new Date(hoverTime).toLocaleTimeString()
1111
+ }),
1112
+ hoverRows.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1113
+ style: {
1114
+ display: "flex",
1115
+ alignItems: "center",
1116
+ gap: 5,
1117
+ fontVariantNumeric: "tabular-nums"
1118
+ },
1119
+ children: [
1120
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
1121
+ width: 7,
1122
+ height: 7,
1123
+ borderRadius: 7,
1124
+ background: row.color,
1125
+ display: "inline-block",
1126
+ flex: "none"
1127
+ } }),
1128
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1129
+ title: row.name,
1130
+ style: {
1131
+ flex: 1,
1132
+ color: "var(--dsw-alias-label-secondary)",
1133
+ overflow: "hidden",
1134
+ textOverflow: "ellipsis",
1135
+ whiteSpace: "nowrap"
1136
+ },
1137
+ children: row.name
1138
+ }),
1139
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1140
+ style: {
1141
+ color: "var(--dsw-alias-label-primary)",
1142
+ fontWeight: 500
1143
+ },
1144
+ children: fmt(row.v)
1145
+ })
1146
+ ]
1147
+ }, row.name)),
1148
+ hoverExtra > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1149
+ style: {
1150
+ color: "var(--dsw-alias-label-caption)",
1151
+ fontSize: 9.5
1152
+ },
1153
+ children: t("chart.more", { count: visible.length })
1154
+ })
1155
+ ]
1156
+ })
1157
+ ]
1158
+ });
1159
+ }
1160
+ /** 指标优先级打分:loss 结尾 0 > 含 loss 1 > epoch 2 > 其余 3 ——
1161
+ * 折线排序(orderedSeries)与主机卡摘要条共用同一打分,口径不再漂移 */
1162
+ function metricScore(name) {
1163
+ return /(^|_)loss$/.test(name) ? 0 : /loss/i.test(name) ? 1 : name === "epoch" ? 2 : 3;
1164
+ }
1165
+ /** order series for the row sparkline: loss-first, then epoch, then the rest */
1166
+ function orderedSeries(series) {
1167
+ return series.filter((s) => s.points.length >= 2).sort((a, b) => metricScore(a.name) - metricScore(b.name));
1168
+ }
1169
+ function fmt(v) {
1170
+ if (!Number.isFinite(v)) return "?";
1171
+ if (v !== 0 && Math.abs(v) < .001) return v.toExponential(1);
1172
+ if (Math.abs(v) >= 100) return v.toFixed(1);
1173
+ if (Math.abs(v) >= 1) return v.toFixed(2);
1174
+ return v.toFixed(4);
1175
+ }
1176
+ /** "最近 N 分钟/小时/天" caption for the chart's x coverage */
1177
+ function fmtSpan(series, t) {
1178
+ let minT = Infinity;
1179
+ let maxT = -Infinity;
1180
+ for (const s of series) for (const p of s.points) {
1181
+ if (p.t < minT) minT = p.t;
1182
+ if (p.t > maxT) maxT = p.t;
1183
+ }
1184
+ if (!Number.isFinite(minT) || !Number.isFinite(maxT) || maxT <= minT) return "";
1185
+ const minutes = Math.round((maxT - minT) / 6e4);
1186
+ if (minutes < 1) return t("chart.span.now");
1187
+ if (minutes < 60) return t("chart.span.min", { n: minutes });
1188
+ const hours = Math.floor(minutes / 60);
1189
+ const rest = minutes % 60;
1190
+ if (hours < 24) return rest > 0 ? t("chart.span.hourMin", {
1191
+ h: hours,
1192
+ m: rest
1193
+ }) : t("chart.span.hour", { h: hours });
1194
+ return t("chart.span.day", {
1195
+ d: Math.floor(hours / 24),
1196
+ h: hours % 24
1197
+ });
1198
+ }
1199
+ function GpuBlock({ gpu, staleMinutes, t, focusIndex }) {
1200
+ const [open, setOpen] = (0, react.useState)(false);
1201
+ const [tab, setTab] = (0, react.useState)(null);
1202
+ const [sparkIdx, setSparkIdx] = (0, react.useState)(0);
1203
+ (0, react.useEffect)(() => {
1204
+ if (focusIndex === gpu.index) setOpen(true);
1205
+ }, [focusIndex, gpu.index]);
1206
+ const procs = gpu.processes;
1207
+ const hasLog = gpu.log !== void 0;
1208
+ const series = gpu.series ?? [];
1209
+ const hasSeries = series.some((s) => s.points.length >= 2);
1210
+ const sparkCandidates = orderedSeries(series);
1211
+ const spark = sparkCandidates.length > 0 ? sparkCandidates[sparkIdx % sparkCandidates.length] : void 0;
1212
+ const idle = gpu.utilPercent < 5 && procs.length === 0;
1213
+ const expandable = procs.length > 0 || hasLog;
1214
+ const hot = gpu.tempC >= 82 || gpu.utilPercent >= 95;
1215
+ const tempTone = gpu.tempC >= 82 ? "hot" : gpu.tempC >= 75 ? "warm" : void 0;
1216
+ const idleMin = gpu.log && gpu.log.mtimeMs > 0 ? Math.floor((Date.now() - gpu.log.mtimeMs) / 6e4) : 0;
1217
+ const stalled = staleMinutes !== void 0 && idleMin >= staleMinutes;
1218
+ const toggleTab = (x) => setTab((cur) => cur === x ? null : x);
1219
+ const [copied, setCopied] = (0, react.useState)(false);
1220
+ const copyTimer = (0, react.useRef)(null);
1221
+ (0, react.useEffect)(() => () => {
1222
+ if (copyTimer.current) clearTimeout(copyTimer.current);
1223
+ }, []);
1224
+ const copyLogPath = (path) => {
1225
+ navigator.clipboard?.writeText(path).then(() => {
1226
+ setCopied(true);
1227
+ if (copyTimer.current) clearTimeout(copyTimer.current);
1228
+ copyTimer.current = setTimeout(() => setCopied(false), 1500);
1229
+ }).catch(() => {});
1230
+ };
1231
+ const tempPillStyle = {
1232
+ flex: "none",
1233
+ minWidth: 30,
1234
+ textAlign: "center",
1235
+ padding: "1px 6px",
1236
+ borderRadius: 999,
1237
+ fontSize: 10,
1238
+ fontWeight: 600,
1239
+ fontVariantNumeric: "tabular-nums",
1240
+ background: tempTone === "hot" ? `color-mix(in srgb, ${T.danger} 18%, transparent)` : tempTone === "warm" ? `color-mix(in srgb, ${T.warning} 18%, transparent)` : T.hoverBg,
1241
+ color: tempTone === "hot" ? T.danger : tempTone === "warm" ? T.warning : "var(--dsw-alias-label-caption)"
1242
+ };
1243
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1244
+ style: {
1245
+ opacity: idle ? .5 : 1,
1246
+ transition: "opacity .3s",
1247
+ minWidth: 0
1248
+ },
1249
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1250
+ type: "button",
1251
+ onClick: () => {
1252
+ if (expandable) setOpen((v) => !v);
1253
+ },
1254
+ title: expandable ? t("gpu.expandHint") : void 0,
1255
+ style: {
1256
+ display: "flex",
1257
+ flexDirection: "column",
1258
+ gap: 2,
1259
+ padding: "4px 2px",
1260
+ width: "100%",
1261
+ minWidth: 0,
1262
+ overflow: "hidden",
1263
+ border: "none",
1264
+ background: "none",
1265
+ cursor: expandable ? "pointer" : "default",
1266
+ borderRadius: 6,
1267
+ color: "inherit",
1268
+ font: "inherit",
1269
+ textAlign: "inherit"
1270
+ },
1271
+ onMouseEnter: (e) => {
1272
+ if (expandable) e.currentTarget.style.background = T.hoverBg;
1273
+ },
1274
+ onMouseLeave: (e) => {
1275
+ e.currentTarget.style.background = "none";
1276
+ },
1277
+ children: [
1278
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1279
+ style: {
1280
+ display: "flex",
1281
+ alignItems: "center",
1282
+ gap: 6,
1283
+ width: "100%"
1284
+ },
1285
+ children: [
1286
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1287
+ "aria-hidden": true,
1288
+ style: {
1289
+ width: 14,
1290
+ flex: "none",
1291
+ display: "inline-flex",
1292
+ justifyContent: "center",
1293
+ color: "var(--dsw-alias-label-caption)",
1294
+ transition: "transform .12s",
1295
+ transform: open ? "rotate(0deg)" : "rotate(-90deg)",
1296
+ opacity: expandable ? 1 : .35
1297
+ },
1298
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
1299
+ d: Icons.chevronDown,
1300
+ size: 10
1301
+ })
1302
+ }),
1303
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1304
+ style: {
1305
+ flex: "1 1 auto",
1306
+ minWidth: 0,
1307
+ fontSize: 11,
1308
+ color: "var(--dsw-alias-label-secondary)",
1309
+ overflow: "hidden",
1310
+ textOverflow: "ellipsis",
1311
+ whiteSpace: "nowrap",
1312
+ textAlign: "left"
1313
+ },
1314
+ title: gpu.name,
1315
+ children: [
1316
+ "GPU",
1317
+ gpu.index,
1318
+ " ",
1319
+ gpu.name
1320
+ ]
1321
+ }),
1322
+ spark && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1323
+ role: "button",
1324
+ tabIndex: 0,
1325
+ onClick: (e) => {
1326
+ e.stopPropagation();
1327
+ if (sparkCandidates.length > 1) setSparkIdx((i) => i + 1);
1328
+ },
1329
+ onKeyDown: (e) => {
1330
+ if (e.key === "Enter" || e.key === " ") {
1331
+ e.stopPropagation();
1332
+ e.preventDefault();
1333
+ setSparkIdx((i) => i + 1);
1334
+ }
1335
+ },
1336
+ title: sparkCandidates.length > 1 ? t("gpu.switchMetric", { name: spark.name }) : spark.name,
1337
+ style: {
1338
+ display: "inline-flex",
1339
+ flex: "none",
1340
+ cursor: sparkCandidates.length > 1 ? "pointer" : "default"
1341
+ },
1342
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Sparkline, { series: spark })
1343
+ }),
1344
+ idle ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1345
+ style: {
1346
+ flex: "none",
1347
+ minWidth: 32,
1348
+ textAlign: "center",
1349
+ padding: "1px 6px",
1350
+ borderRadius: 999,
1351
+ fontSize: 10,
1352
+ fontWeight: 500,
1353
+ background: T.hoverBg,
1354
+ color: "var(--dsw-alias-label-caption)"
1355
+ },
1356
+ children: t("gpu.idle")
1357
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1358
+ style: {
1359
+ flex: "none",
1360
+ minWidth: 32,
1361
+ textAlign: "right",
1362
+ fontSize: 13,
1363
+ fontWeight: 600,
1364
+ fontVariantNumeric: "tabular-nums",
1365
+ color: hot ? T.danger : "var(--dsw-alias-label-primary)"
1366
+ },
1367
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveText, { children: `${gpu.utilPercent}%` })
1368
+ }),
1369
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1370
+ style: tempPillStyle,
1371
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveText, { children: gpu.tempC > 0 ? `${gpu.tempC}°` : "—" })
1372
+ })
1373
+ ]
1374
+ }),
1375
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1376
+ style: {
1377
+ paddingLeft: 20,
1378
+ paddingRight: 2,
1379
+ display: "flex",
1380
+ flexDirection: "column",
1381
+ gap: 2,
1382
+ margin: "1px 0 1px"
1383
+ },
1384
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1385
+ style: {
1386
+ height: 3,
1387
+ borderRadius: 2,
1388
+ background: T.trackFill,
1389
+ overflow: "hidden"
1390
+ },
1391
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { style: {
1392
+ height: "100%",
1393
+ width: `${Math.min(100, Math.max(0, gpu.utilPercent))}%`,
1394
+ borderRadius: 2,
1395
+ background: hot ? T.danger : T.business,
1396
+ opacity: .85,
1397
+ transition: "width .4s ease"
1398
+ } })
1399
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1400
+ style: {
1401
+ height: 3,
1402
+ borderRadius: 2,
1403
+ background: T.trackFill,
1404
+ overflow: "hidden"
1405
+ },
1406
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { style: {
1407
+ height: "100%",
1408
+ width: `${Math.min(100, gpu.memoryTotalMiB > 0 ? gpu.memoryUsedMiB / gpu.memoryTotalMiB * 100 : 0)}%`,
1409
+ borderRadius: 2,
1410
+ background: gpu.memoryTotalMiB > 0 && gpu.memoryUsedMiB / gpu.memoryTotalMiB > .9 ? T.danger : T.business,
1411
+ opacity: .4,
1412
+ transition: "width .4s ease"
1413
+ } })
1414
+ })]
1415
+ }),
1416
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1417
+ style: {
1418
+ display: "flex",
1419
+ alignItems: "center",
1420
+ gap: 6,
1421
+ width: "100%",
1422
+ boxSizing: "border-box",
1423
+ paddingLeft: 20,
1424
+ minWidth: 0
1425
+ },
1426
+ children: [
1427
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1428
+ style: {
1429
+ flex: "none",
1430
+ fontSize: 10,
1431
+ color: "var(--dsw-alias-label-caption)",
1432
+ fontVariantNumeric: "tabular-nums"
1433
+ },
1434
+ children: [
1435
+ (gpu.memoryUsedMiB / 1024).toFixed(1),
1436
+ "/",
1437
+ (gpu.memoryTotalMiB / 1024).toFixed(0),
1438
+ "G · ",
1439
+ gpu.powerW > 0 ? `${Math.round(gpu.powerW)}W` : "—"
1440
+ ]
1441
+ }),
1442
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
1443
+ flex: "1 1 0",
1444
+ minWidth: 2
1445
+ } }),
1446
+ stalled && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1447
+ style: {
1448
+ display: "inline-flex",
1449
+ alignItems: "center",
1450
+ gap: 4,
1451
+ fontSize: 10,
1452
+ fontWeight: 500,
1453
+ color: T.warning,
1454
+ whiteSpace: "nowrap"
1455
+ },
1456
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1457
+ "aria-hidden": true,
1458
+ children: "⏸"
1459
+ }), t("gpu.stalled", { min: idleMin })]
1460
+ }),
1461
+ !stalled && spark && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1462
+ style: {
1463
+ flex: "0 1 auto",
1464
+ fontSize: 10,
1465
+ color: "var(--dsw-alias-label-caption)",
1466
+ fontVariantNumeric: "tabular-nums",
1467
+ whiteSpace: "nowrap",
1468
+ display: "inline-flex",
1469
+ gap: 4,
1470
+ overflow: "hidden",
1471
+ minWidth: 0
1472
+ },
1473
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1474
+ title: spark.name,
1475
+ style: {
1476
+ overflow: "hidden",
1477
+ textOverflow: "ellipsis",
1478
+ minWidth: 0
1479
+ },
1480
+ children: spark.name
1481
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1482
+ style: { flex: "none" },
1483
+ children: fmt(spark.points[spark.points.length - 1].v)
1484
+ })]
1485
+ })
1486
+ ]
1487
+ })
1488
+ ]
1489
+ }), open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1490
+ style: {
1491
+ margin: "2px 2px 6px 6px",
1492
+ paddingLeft: 10,
1493
+ paddingRight: 8,
1494
+ paddingTop: 4,
1495
+ paddingBottom: 6,
1496
+ borderLeft: `2px solid ${T.business}`,
1497
+ background: "var(--dsw-alias-bg-layer-2, rgba(127,127,127,.06))",
1498
+ borderRadius: 8,
1499
+ display: "flex",
1500
+ flexDirection: "column",
1501
+ gap: 2
1502
+ },
1503
+ children: [
1504
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1505
+ style: {
1506
+ display: "flex",
1507
+ flexWrap: "wrap",
1508
+ gap: 2
1509
+ },
1510
+ children: [
1511
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SectionHeader, {
1512
+ label: t("proc.header"),
1513
+ count: String(procs.length),
1514
+ open: tab === "proc",
1515
+ onClick: () => toggleTab("proc")
1516
+ }),
1517
+ hasLog && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SectionHeader, {
1518
+ label: t("log.header"),
1519
+ open: tab === "log",
1520
+ onClick: () => toggleTab("log")
1521
+ }),
1522
+ hasSeries && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SectionHeader, {
1523
+ label: t("chart.header"),
1524
+ open: tab === "chart",
1525
+ onClick: () => toggleTab("chart")
1526
+ })
1527
+ ]
1528
+ }),
1529
+ tab === "proc" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1530
+ style: { paddingTop: 2 },
1531
+ children: procs.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1532
+ style: {
1533
+ fontSize: 11,
1534
+ color: "var(--dsw-alias-label-caption)"
1535
+ },
1536
+ children: t("proc.empty")
1537
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProcTable, {
1538
+ procs,
1539
+ t
1540
+ })
1541
+ }),
1542
+ tab === "log" && gpu.log && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1543
+ style: { width: "100%" },
1544
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1545
+ role: "button",
1546
+ tabIndex: 0,
1547
+ onClick: () => copyLogPath(gpu.log.path),
1548
+ onKeyDown: (e) => {
1549
+ if (e.key === "Enter" || e.key === " ") {
1550
+ e.preventDefault();
1551
+ copyLogPath(gpu.log.path);
1552
+ }
1553
+ },
1554
+ title: copied ? t("log.copied") : `${gpu.log.path} · ${t("log.copyPath")}`,
1555
+ style: {
1556
+ fontSize: 10,
1557
+ color: "var(--dsw-alias-label-caption)",
1558
+ overflow: "hidden",
1559
+ textOverflow: "ellipsis",
1560
+ whiteSpace: "nowrap",
1561
+ marginBottom: 3,
1562
+ cursor: "pointer",
1563
+ width: "fit-content",
1564
+ maxWidth: "100%"
1565
+ },
1566
+ children: copied ? `✓ ${t("log.copied")}` : gpu.log.path
1567
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LogView, {
1568
+ lines: gpu.log.lines,
1569
+ t,
1570
+ readFailed: gpu.log.mtimeMs === 0
1571
+ })]
1572
+ }),
1573
+ tab === "chart" && hasSeries && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1574
+ style: { width: "100%" },
1575
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SeriesChart, {
1576
+ series,
1577
+ t
1578
+ })
1579
+ }),
1580
+ !hasLog && procs.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1581
+ style: {
1582
+ fontSize: 11,
1583
+ color: "var(--dsw-alias-label-caption)"
1584
+ },
1585
+ children: t("gpu.noLog")
1586
+ })
1587
+ ]
1588
+ })]
1589
+ });
1590
+ }
1591
+ function hostAnomalies(snap, staleMinutes) {
1592
+ if (!snap || !snap.ok) return {
1593
+ any: true,
1594
+ offline: true,
1595
+ hot: 0,
1596
+ stalled: 0,
1597
+ diskFull: 0,
1598
+ logStalled: false
1599
+ };
1600
+ let hot = 0;
1601
+ let stalled = 0;
1602
+ for (const g of snap.gpus ?? []) {
1603
+ if (g.tempC >= 82 || g.utilPercent >= 95) hot++;
1604
+ if (g.log && g.log.mtimeMs > 0 && staleMinutes !== void 0 && Date.now() - g.log.mtimeMs >= staleMinutes * 6e4) stalled++;
1605
+ }
1606
+ const diskFull = (snap.base?.disks ?? []).filter((d) => d.usedPercent > 90).length;
1607
+ const logStalled = !!snap.log && snap.log.mtimeMs > 0 && staleMinutes !== void 0 && Date.now() - snap.log.mtimeMs >= staleMinutes * 6e4;
1608
+ return {
1609
+ any: hot + stalled + diskFull > 0 || logStalled,
1610
+ offline: false,
1611
+ hot,
1612
+ stalled,
1613
+ diskFull,
1614
+ logStalled
1615
+ };
1616
+ }
1617
+ function HostCard$1({ snap, displayName, collapsed, staleMinutes, t, focused, focusGpuIndex, onRetry, pinned, onTogglePin, wideGpuGrid }) {
1618
+ const [section, setSection] = (0, react.useState)(null);
1619
+ const [forced, setForced] = (0, react.useState)(null);
1620
+ const [showAllDisks, setShowAllDisks] = (0, react.useState)(false);
1621
+ const cardRef = (0, react.useRef)(null);
1622
+ const toggle = (s) => setSection((cur) => cur === s ? null : s);
1623
+ const anom = hostAnomalies(snap, staleMinutes);
1624
+ const open = forced ?? anom.any;
1625
+ const cardStyle = {
1626
+ padding: 0,
1627
+ borderRadius: 10,
1628
+ background: T.cardBg,
1629
+ border: `1px solid ${anom.any ? `color-mix(in srgb, ${T.warning} 45%, transparent)` : T.borderL1}`,
1630
+ flex: "none",
1631
+ overflow: "hidden",
1632
+ animation: focused ? "dsh-dash-flash 2.2s ease-out 1" : void 0
1633
+ };
1634
+ (0, react.useEffect)(() => {
1635
+ if (!focused) return;
1636
+ cardRef.current?.scrollIntoView({
1637
+ behavior: "smooth",
1638
+ block: "nearest"
1639
+ });
1640
+ setForced(true);
1641
+ }, [focused]);
1642
+ if (!snap || !snap.ok) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1643
+ ref: cardRef,
1644
+ style: {
1645
+ ...cardStyle,
1646
+ opacity: .7,
1647
+ padding: "10px 12px"
1648
+ },
1649
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1650
+ style: {
1651
+ display: "flex",
1652
+ alignItems: "center",
1653
+ gap: 6
1654
+ },
1655
+ children: [
1656
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Dot, {
1657
+ tone: "err",
1658
+ size: 7
1659
+ }),
1660
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1661
+ style: {
1662
+ flex: 1,
1663
+ fontSize: 13,
1664
+ fontWeight: 500,
1665
+ color: "var(--dsw-alias-label-primary)"
1666
+ },
1667
+ children: displayName
1668
+ }),
1669
+ onRetry && snap && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconButton, {
1670
+ label: t("host.retry"),
1671
+ size: 22,
1672
+ onClick: () => onRetry(snap.hostId),
1673
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
1674
+ d: Icons.refresh,
1675
+ size: 12
1676
+ })
1677
+ })
1678
+ ]
1679
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1680
+ style: {
1681
+ fontSize: 11,
1682
+ color: "var(--dsw-alias-label-caption)",
1683
+ marginTop: 4,
1684
+ paddingLeft: 13,
1685
+ lineHeight: 1.5
1686
+ },
1687
+ children: snap ? /authentication methods failed/i.test(snap.error ?? "") ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1688
+ style: { color: T.warning },
1689
+ children: t("host.authRejected")
1690
+ }) : snap.error ?? t("host.offlineMsg") : t("host.connecting")
1691
+ })]
1692
+ });
1693
+ const gpus = (snap.gpus ?? []).filter(hasGpuData);
1694
+ const gpuUnavailable = (snap.gpus?.length ?? 0) > 0 && gpus.length === 0;
1695
+ const procs = gpus.flatMap((g) => g.processes.map((p) => ({
1696
+ ...p,
1697
+ gpu: g.index
1698
+ })));
1699
+ const hasSeries = (snap.series ?? []).some((s) => s.points.length >= 2);
1700
+ const summary = (snap.series ?? []).filter((s) => s.points.length > 0).sort((a, b) => metricScore(a.name) - metricScore(b.name)).slice(0, 5).map((s) => ({
1701
+ name: s.name,
1702
+ v: s.points[s.points.length - 1].v
1703
+ }));
1704
+ const activeExps = gpus.filter((g) => g.processes.length > 0).length;
1705
+ const statusParts = [];
1706
+ if (gpus.length > 0) statusParts.push({
1707
+ text: t("host.gpuCards", { count: gpus.length }),
1708
+ bad: false
1709
+ });
1710
+ if (anom.hot > 0) statusParts.push({
1711
+ text: t("host.hotGpus", { count: anom.hot }),
1712
+ bad: true
1713
+ });
1714
+ if (anom.stalled > 0) statusParts.push({
1715
+ text: t("host.stalledExps", { count: anom.stalled }),
1716
+ bad: true
1717
+ });
1718
+ if (anom.diskFull > 0) statusParts.push({
1719
+ text: t("host.diskWarn", { count: anom.diskFull }),
1720
+ bad: true
1721
+ });
1722
+ if (anom.logStalled) statusParts.push({
1723
+ text: t("host.logStalled"),
1724
+ bad: true
1725
+ });
1726
+ if (!anom.any) {
1727
+ if (activeExps > 0) statusParts.push({
1728
+ text: t("host.runningExps", { count: activeExps }),
1729
+ bad: false
1730
+ });
1731
+ if (statusParts.length === 1) statusParts.push({
1732
+ text: t("host.allGood"),
1733
+ bad: false
1734
+ });
1735
+ }
1736
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1737
+ ref: cardRef,
1738
+ style: cardStyle,
1739
+ children: [
1740
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1741
+ type: "button",
1742
+ onClick: () => setForced(!open),
1743
+ style: {
1744
+ display: "flex",
1745
+ alignItems: "center",
1746
+ gap: 6,
1747
+ width: "100%",
1748
+ padding: "10px 12px",
1749
+ border: "none",
1750
+ background: "none",
1751
+ cursor: "pointer",
1752
+ color: "inherit",
1753
+ font: "inherit",
1754
+ textAlign: "inherit"
1755
+ },
1756
+ onMouseEnter: (e) => {
1757
+ e.currentTarget.style.background = T.hoverBg;
1758
+ },
1759
+ onMouseLeave: (e) => {
1760
+ e.currentTarget.style.background = "none";
1761
+ },
1762
+ children: [
1763
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Dot, {
1764
+ tone: "ok",
1765
+ size: 8
1766
+ }),
1767
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1768
+ style: {
1769
+ flex: 1,
1770
+ fontSize: 13,
1771
+ fontWeight: 600,
1772
+ overflow: "hidden",
1773
+ textOverflow: "ellipsis",
1774
+ whiteSpace: "nowrap",
1775
+ color: "var(--dsw-alias-label-primary)"
1776
+ },
1777
+ children: displayName
1778
+ }),
1779
+ snap.at ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1780
+ style: {
1781
+ fontSize: 10,
1782
+ color: "var(--dsw-alias-label-caption)",
1783
+ fontVariantNumeric: "tabular-nums"
1784
+ },
1785
+ children: relTime(snap.at, t)
1786
+ }) : null,
1787
+ onTogglePin && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1788
+ role: "button",
1789
+ tabIndex: 0,
1790
+ title: pinned ? t("host.unpin") : t("host.pin"),
1791
+ onClick: (e) => {
1792
+ e.stopPropagation();
1793
+ onTogglePin();
1794
+ },
1795
+ onKeyDown: (e) => {
1796
+ if (e.key === "Enter" || e.key === " ") {
1797
+ e.preventDefault();
1798
+ e.stopPropagation();
1799
+ onTogglePin();
1800
+ }
1801
+ },
1802
+ style: {
1803
+ flex: "none",
1804
+ fontSize: 10,
1805
+ lineHeight: 1,
1806
+ cursor: "pointer",
1807
+ padding: "2px 3px",
1808
+ borderRadius: 6,
1809
+ color: pinned ? T.business : "var(--dsw-alias-label-caption)",
1810
+ opacity: pinned ? 1 : .55,
1811
+ background: "none"
1812
+ },
1813
+ children: "📌"
1814
+ }),
1815
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1816
+ "aria-hidden": true,
1817
+ style: {
1818
+ display: "inline-flex",
1819
+ color: "var(--dsw-alias-label-caption)",
1820
+ transition: "transform .12s",
1821
+ transform: open ? "rotate(0deg)" : "rotate(-90deg)"
1822
+ },
1823
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
1824
+ d: Icons.chevronDown,
1825
+ size: 12
1826
+ })
1827
+ })
1828
+ ]
1829
+ }),
1830
+ !open && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1831
+ style: {
1832
+ padding: "0 12px 9px 26px",
1833
+ display: "flex",
1834
+ flexWrap: "wrap",
1835
+ alignItems: "center",
1836
+ gap: "3px 10px",
1837
+ fontSize: 11
1838
+ },
1839
+ children: statusParts.map((p) => p.bad ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1840
+ style: {
1841
+ padding: "1px 8px",
1842
+ borderRadius: 999,
1843
+ fontSize: 10,
1844
+ fontWeight: 500,
1845
+ background: `color-mix(in srgb, ${T.warning} 16%, transparent)`,
1846
+ color: T.warning
1847
+ },
1848
+ children: p.text
1849
+ }, p.text) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1850
+ style: {
1851
+ fontSize: 11,
1852
+ color: "var(--dsw-alias-label-caption)"
1853
+ },
1854
+ children: p.text
1855
+ }, p.text))
1856
+ }),
1857
+ open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1858
+ style: { padding: "0 12px 10px" },
1859
+ children: [
1860
+ summary.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1861
+ style: {
1862
+ display: "flex",
1863
+ flexWrap: "wrap",
1864
+ gap: "4px 10px",
1865
+ marginBottom: 6,
1866
+ fontSize: 11
1867
+ },
1868
+ children: summary.map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1869
+ title: `${m.name} ${m.v}`,
1870
+ style: {
1871
+ color: "var(--dsw-alias-label-secondary)",
1872
+ fontVariantNumeric: "tabular-nums",
1873
+ maxWidth: "46%",
1874
+ overflow: "hidden",
1875
+ textOverflow: "ellipsis",
1876
+ whiteSpace: "nowrap"
1877
+ },
1878
+ children: [
1879
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1880
+ style: { color: "var(--dsw-alias-label-caption)" },
1881
+ children: m.name
1882
+ }),
1883
+ " ",
1884
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveText, { children: Number.isInteger(m.v) ? String(m.v) : m.v.toFixed(4) })
1885
+ ]
1886
+ }, m.name))
1887
+ }),
1888
+ snap.base && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1889
+ style: {
1890
+ display: "flex",
1891
+ gap: 10,
1892
+ marginBottom: 4,
1893
+ alignItems: "flex-end"
1894
+ },
1895
+ children: [
1896
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MetricBlock, {
1897
+ label: "CPU",
1898
+ value: `${snap.base.cpuPercent}%`,
1899
+ percent: snap.base.cpuPercent,
1900
+ tone: snap.base.cpuPercent > 90 ? "warn" : "normal"
1901
+ }),
1902
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MetricBlock, {
1903
+ label: t("base.memory"),
1904
+ value: `${(snap.base.memUsedMiB / 1024).toFixed(1)}/${(snap.base.memTotalMiB / 1024).toFixed(0)}G`,
1905
+ percent: snap.base.memUsedMiB / Math.max(1, snap.base.memTotalMiB) * 100,
1906
+ tone: snap.base.memUsedMiB / Math.max(1, snap.base.memTotalMiB) > .9 ? "warn" : "normal"
1907
+ }),
1908
+ snap.base.load1 !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1909
+ title: t("base.load"),
1910
+ style: {
1911
+ flex: "none",
1912
+ fontSize: 10,
1913
+ color: "var(--dsw-alias-label-caption)",
1914
+ fontVariantNumeric: "tabular-nums",
1915
+ paddingBottom: 2,
1916
+ whiteSpace: "nowrap"
1917
+ },
1918
+ children: [
1919
+ t("base.load"),
1920
+ " ",
1921
+ snap.base.load1.toFixed(2)
1922
+ ]
1923
+ })
1924
+ ]
1925
+ }),
1926
+ snap.base && (() => {
1927
+ const disks = snap.base.disks ?? [];
1928
+ const primary = disks.filter((d) => d.mount === "/" || d.usedPercent >= 70);
1929
+ const rest = disks.filter((d) => !(d.mount === "/" || d.usedPercent >= 70));
1930
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1931
+ style: {
1932
+ display: "flex",
1933
+ flexWrap: "wrap",
1934
+ gap: "6px 10px",
1935
+ alignItems: "flex-end"
1936
+ },
1937
+ children: [(showAllDisks ? disks : primary).map((d) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MetricBlock, {
1938
+ label: d.mount,
1939
+ value: `${d.usedPercent}%`,
1940
+ percent: d.usedPercent,
1941
+ tone: d.usedPercent > 90 ? "warn" : "normal",
1942
+ flexBasis: "calc(50% - 5px)"
1943
+ }, d.mount)), rest.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1944
+ type: "button",
1945
+ onClick: () => setShowAllDisks((v) => !v),
1946
+ style: {
1947
+ border: "none",
1948
+ background: "none",
1949
+ cursor: "pointer",
1950
+ padding: "2px 6px",
1951
+ fontSize: 10,
1952
+ color: "var(--dsw-alias-label-caption)",
1953
+ borderRadius: 6
1954
+ },
1955
+ title: showAllDisks ? void 0 : rest.map((d) => d.mount).join(" "),
1956
+ children: [
1957
+ showAllDisks ? "−" : "+",
1958
+ rest.length,
1959
+ " ",
1960
+ t("disk.mounts")
1961
+ ]
1962
+ })]
1963
+ });
1964
+ })(),
1965
+ gpus.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1966
+ style: {
1967
+ marginTop: 6,
1968
+ borderTop: `1px solid ${T.borderL2}`,
1969
+ paddingTop: 2,
1970
+ display: "grid",
1971
+ gridTemplateColumns: wideGpuGrid ? "repeat(2, minmax(0, 1fr))" : "minmax(0, 1fr)",
1972
+ columnGap: 10
1973
+ },
1974
+ children: gpus.map((g) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GpuBlock, {
1975
+ gpu: g,
1976
+ staleMinutes,
1977
+ t,
1978
+ focusIndex: focusGpuIndex
1979
+ }, g.index))
1980
+ }),
1981
+ gpuUnavailable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1982
+ title: t("host.gpuUnavailableHint"),
1983
+ style: {
1984
+ marginTop: 6,
1985
+ fontSize: 10.5,
1986
+ color: "var(--dsw-alias-label-caption)",
1987
+ lineHeight: 1.6,
1988
+ borderTop: `1px solid ${T.borderL2}`,
1989
+ paddingTop: 6
1990
+ },
1991
+ children: t("host.gpuUnavailable")
1992
+ }),
1993
+ !collapsed && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1994
+ style: {
1995
+ marginTop: 6,
1996
+ display: "flex",
1997
+ flexWrap: "wrap",
1998
+ gap: 2,
1999
+ borderTop: `1px solid ${T.borderL2}`,
2000
+ paddingTop: 6
2001
+ },
2002
+ children: [
2003
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SectionHeader, {
2004
+ label: t("proc.header"),
2005
+ count: String(procs.length),
2006
+ open: section === "proc",
2007
+ onClick: () => toggle("proc")
2008
+ }),
2009
+ snap.log && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SectionHeader, {
2010
+ label: t("log.header"),
2011
+ open: section === "log",
2012
+ onClick: () => toggle("log")
2013
+ }),
2014
+ hasSeries && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SectionHeader, {
2015
+ label: t("chart.header"),
2016
+ open: section === "chart",
2017
+ onClick: () => toggle("chart")
2018
+ }),
2019
+ section === "proc" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2020
+ style: {
2021
+ width: "100%",
2022
+ paddingTop: 2
2023
+ },
2024
+ children: procs.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2025
+ style: {
2026
+ fontSize: 11,
2027
+ color: "var(--dsw-alias-label-caption)"
2028
+ },
2029
+ children: t("proc.empty")
2030
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProcTable, {
2031
+ procs,
2032
+ showGpu: true,
2033
+ t
2034
+ })
2035
+ }),
2036
+ section === "log" && snap.log && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2037
+ style: {
2038
+ width: "100%",
2039
+ marginTop: 2
2040
+ },
2041
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LogView, {
2042
+ lines: snap.log.lines,
2043
+ t,
2044
+ readFailed: snap.log.mtimeMs === 0
2045
+ })
2046
+ }),
2047
+ section === "chart" && hasSeries && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2048
+ style: { width: "100%" },
2049
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SeriesChart, {
2050
+ series: snap.series ?? [],
2051
+ t
2052
+ })
2053
+ })
2054
+ ]
2055
+ })
2056
+ ]
2057
+ })
2058
+ ]
2059
+ });
2060
+ }
2061
+ /** aggregate fleet counters for the badge line above the heatmap */
2062
+ function fleetCounts(hosts, snapshots, staleMinutes) {
2063
+ let running = 0;
2064
+ let idle = 0;
2065
+ let anomalous = 0;
2066
+ let offline = 0;
2067
+ for (const h of hosts) {
2068
+ const s = snapshots[h.id];
2069
+ if (!s || !s.ok) {
2070
+ offline++;
2071
+ continue;
2072
+ }
2073
+ if (hostAnomalies(s, staleMinutes).any) anomalous++;
2074
+ for (const g of s.gpus ?? []) if (g.processes.length > 0) running++;
2075
+ else idle++;
2076
+ }
2077
+ return {
2078
+ running,
2079
+ idle,
2080
+ anomalous,
2081
+ offline
2082
+ };
2083
+ }
2084
+ function FleetOverview({ hosts, snapshots, staleMinutes, t, onPick }) {
2085
+ if (hosts.length === 0) return null;
2086
+ const c = fleetCounts(hosts, snapshots, staleMinutes);
2087
+ const badges = [
2088
+ {
2089
+ n: c.running,
2090
+ tone: T.success,
2091
+ label: t("fleet.running"),
2092
+ key: "r"
2093
+ },
2094
+ {
2095
+ n: c.anomalous + c.offline,
2096
+ tone: c.offline > 0 ? T.danger : T.warning,
2097
+ label: t("fleet.anomalous"),
2098
+ key: "a"
2099
+ },
2100
+ {
2101
+ n: c.idle,
2102
+ tone: "var(--dsw-alias-label-caption)",
2103
+ label: t("fleet.idle"),
2104
+ key: "i"
2105
+ }
2106
+ ];
2107
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2108
+ style: {
2109
+ flex: "none",
2110
+ paddingBottom: 6,
2111
+ borderBottom: `1px solid ${T.borderL2}`,
2112
+ marginBottom: 2
2113
+ },
2114
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2115
+ style: {
2116
+ display: "flex",
2117
+ alignItems: "center",
2118
+ gap: 5,
2119
+ marginBottom: 3
2120
+ },
2121
+ children: [
2122
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2123
+ style: {
2124
+ fontSize: 9,
2125
+ color: "var(--dsw-alias-label-caption)",
2126
+ letterSpacing: 1
2127
+ },
2128
+ children: t("overview.title")
2129
+ }),
2130
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: 1 } }),
2131
+ badges.filter((b) => b.n > 0).map((b) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2132
+ title: `${b.label} ${b.n}`,
2133
+ style: {
2134
+ display: "inline-flex",
2135
+ alignItems: "center",
2136
+ gap: 4,
2137
+ padding: "0 7px",
2138
+ height: 15,
2139
+ borderRadius: 999,
2140
+ fontSize: 9.5,
2141
+ fontVariantNumeric: "tabular-nums",
2142
+ background: `color-mix(in srgb, ${b.tone} 14%, transparent)`,
2143
+ color: b.tone
2144
+ },
2145
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
2146
+ width: 5,
2147
+ height: 5,
2148
+ borderRadius: 5,
2149
+ background: b.tone
2150
+ } }), b.n]
2151
+ }, b.key))
2152
+ ]
2153
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2154
+ style: {
2155
+ display: "flex",
2156
+ flexDirection: "column",
2157
+ gap: 3
2158
+ },
2159
+ children: hosts.map((h) => {
2160
+ const s = snapshots[h.id];
2161
+ const offline = !s || !s.ok;
2162
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2163
+ style: {
2164
+ display: "flex",
2165
+ alignItems: "center",
2166
+ gap: 5
2167
+ },
2168
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2169
+ role: "button",
2170
+ tabIndex: 0,
2171
+ title: h.name || h.id,
2172
+ onClick: () => onPick(h.id),
2173
+ onKeyDown: (e) => {
2174
+ if (e.key === "Enter" || e.key === " ") {
2175
+ e.preventDefault();
2176
+ onPick(h.id);
2177
+ }
2178
+ },
2179
+ style: {
2180
+ width: 66,
2181
+ flex: "none",
2182
+ fontSize: 9.5,
2183
+ lineHeight: "18px",
2184
+ color: offline ? T.danger : "var(--dsw-alias-label-caption)",
2185
+ cursor: "pointer",
2186
+ overflow: "hidden",
2187
+ textOverflow: "ellipsis",
2188
+ whiteSpace: "nowrap"
2189
+ },
2190
+ children: h.name || h.id
2191
+ }), offline ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2192
+ style: {
2193
+ flex: 1,
2194
+ fontSize: 9.5,
2195
+ lineHeight: "16px",
2196
+ color: T.danger,
2197
+ textAlign: "center",
2198
+ background: `color-mix(in srgb, ${T.danger} 12%, transparent)`,
2199
+ borderRadius: 4
2200
+ },
2201
+ children: t("overview.offline")
2202
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2203
+ style: {
2204
+ flex: 1,
2205
+ display: "flex",
2206
+ gap: 3
2207
+ },
2208
+ children: [(s.gpus ?? []).filter(hasGpuData).map((g) => {
2209
+ const hot = g.tempC >= 82 || g.utilPercent >= 95;
2210
+ const stalled = g.log && g.log.mtimeMs > 0 && staleMinutes !== void 0 && Date.now() - g.log.mtimeMs >= staleMinutes * 6e4;
2211
+ const bg = hot ? `color-mix(in srgb, ${T.danger} 55%, transparent)` : g.utilPercent >= 60 ? `color-mix(in srgb, ${T.business} 42%, transparent)` : g.utilPercent >= 5 ? `color-mix(in srgb, ${T.business} 20%, transparent)` : T.trackFill;
2212
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2213
+ type: "button",
2214
+ title: `GPU${g.index} · ${g.utilPercent}% · ${g.tempC}°C · ${(g.memoryUsedMiB / 1024).toFixed(1)}/${(g.memoryTotalMiB / 1024).toFixed(0)}G`,
2215
+ onClick: () => onPick(h.id, g.index),
2216
+ style: {
2217
+ flex: 1,
2218
+ minWidth: 0,
2219
+ height: 18,
2220
+ borderRadius: 4,
2221
+ padding: 0,
2222
+ position: "relative",
2223
+ border: `1px solid ${hot ? T.danger : "transparent"}`,
2224
+ background: bg,
2225
+ cursor: "pointer",
2226
+ color: "var(--dsw-alias-label-primary)",
2227
+ fontSize: 9,
2228
+ fontVariantNumeric: "tabular-nums",
2229
+ display: "flex",
2230
+ alignItems: "center",
2231
+ justifyContent: "center"
2232
+ },
2233
+ children: [g.utilPercent, stalled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
2234
+ position: "absolute",
2235
+ top: -2.5,
2236
+ right: -2.5,
2237
+ width: 6,
2238
+ height: 6,
2239
+ borderRadius: 6,
2240
+ background: T.warning,
2241
+ border: "1px solid var(--dsw-alias-bg-base)"
2242
+ } })]
2243
+ }, g.index);
2244
+ }), (s.gpus ?? []).length > 0 && (s.gpus ?? []).every((g) => !hasGpuData(g)) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2245
+ title: t("host.gpuUnavailableHint"),
2246
+ style: {
2247
+ flex: 1,
2248
+ fontSize: 9.5,
2249
+ lineHeight: "16px",
2250
+ color: "var(--dsw-alias-label-caption)",
2251
+ textAlign: "center",
2252
+ background: T.trackFill,
2253
+ borderRadius: 4
2254
+ },
2255
+ children: "—"
2256
+ })]
2257
+ })]
2258
+ }, h.id);
2259
+ })
2260
+ })]
2261
+ });
2262
+ }
2263
+ /** flip one host's pinned flag through the config route (read → flip → PUT) */
2264
+ async function togglePinnedHost(hostId) {
2265
+ const res = await fetch("/dash/config", { headers: { accept: "application/json" } });
2266
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2267
+ const cfg = (await res.json()).config;
2268
+ const hosts = cfg.hosts.map((h) => h.id === hostId ? {
2269
+ ...h,
2270
+ pinned: !h.pinned
2271
+ } : h);
2272
+ const put = await fetch("/dash/config", {
2273
+ method: "PUT",
2274
+ headers: { "content-type": "application/json" },
2275
+ body: JSON.stringify({
2276
+ hosts,
2277
+ refreshIntervalS: cfg.refreshIntervalS,
2278
+ staleMinutes: cfg.staleMinutes
2279
+ })
2280
+ });
2281
+ if (!put.ok) throw new Error(`HTTP ${put.status}`);
2282
+ }
2283
+ function DashboardPanel({ hosts, snapshots, staleMinutes, t, onRetryHost, onPinToggled }) {
2284
+ const focus = (0, react.useSyncExternalStore)(focusBus.subscribe, focusBus.getSnapshot);
2285
+ const focusId = focus?.hostId ?? null;
2286
+ const focusGpu = focus?.gpuIndex;
2287
+ (0, react.useEffect)(() => {
2288
+ if (!focus) return;
2289
+ const id = setTimeout(() => focusBus.set(null), 2500);
2290
+ return () => clearTimeout(id);
2291
+ }, [focus]);
2292
+ const rootRef = (0, react.useRef)(null);
2293
+ const [wide, setWide] = (0, react.useState)(false);
2294
+ (0, react.useEffect)(() => {
2295
+ const el = rootRef.current;
2296
+ if (!el || typeof ResizeObserver === "undefined") return;
2297
+ const ro = new ResizeObserver((entries) => {
2298
+ const w = entries[0]?.contentRect?.width ?? 0;
2299
+ setWide(w >= 420);
2300
+ });
2301
+ ro.observe(el);
2302
+ return () => ro.disconnect();
2303
+ }, []);
2304
+ const anomalous = hosts.filter((h) => hostAnomalies(snapshots[h.id], staleMinutes).any);
2305
+ const pinned = hosts.filter((h) => h.pinned);
2306
+ const main = [];
2307
+ const seen = /* @__PURE__ */ new Set();
2308
+ for (const h of [...anomalous, ...pinned]) {
2309
+ if (seen.has(h.id)) continue;
2310
+ seen.add(h.id);
2311
+ main.push(h);
2312
+ }
2313
+ const others = hosts.filter((h) => !seen.has(h.id));
2314
+ const [showOthers, setShowOthers] = (0, react.useState)(false);
2315
+ (0, react.useEffect)(() => {
2316
+ if (focusId && others.some((h) => h.id === focusId)) setShowOthers(true);
2317
+ }, [focusId]);
2318
+ const retry = onRetryHost;
2319
+ const togglePin = async (hostId) => {
2320
+ try {
2321
+ await togglePinnedHost(hostId);
2322
+ onPinToggled?.();
2323
+ } catch {}
2324
+ };
2325
+ const pickFromOverview = (hostId, gpuIndex) => {
2326
+ focusBus.set({
2327
+ hostId,
2328
+ gpuIndex
2329
+ });
2330
+ };
2331
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2332
+ ref: rootRef,
2333
+ style: {
2334
+ padding: "8px 10px 12px",
2335
+ display: "flex",
2336
+ flexDirection: "column",
2337
+ gap: 8,
2338
+ height: "100%",
2339
+ overflow: "auto",
2340
+ color: "var(--dsw-alias-label-primary)"
2341
+ },
2342
+ children: [
2343
+ hosts.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2344
+ style: {
2345
+ marginTop: 56,
2346
+ display: "flex",
2347
+ flexDirection: "column",
2348
+ alignItems: "center",
2349
+ gap: 10,
2350
+ textAlign: "center"
2351
+ },
2352
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2353
+ "aria-hidden": true,
2354
+ style: {
2355
+ fontSize: 30,
2356
+ opacity: .35
2357
+ },
2358
+ children: "🖥️"
2359
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2360
+ style: {
2361
+ fontSize: 12,
2362
+ lineHeight: 1.7,
2363
+ color: "var(--dsw-alias-label-caption)",
2364
+ maxWidth: 240
2365
+ },
2366
+ children: t("panel.empty")
2367
+ })]
2368
+ }),
2369
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FleetOverview, {
2370
+ hosts,
2371
+ snapshots,
2372
+ staleMinutes,
2373
+ t,
2374
+ onPick: pickFromOverview
2375
+ }),
2376
+ main.map((h) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostCard$1, {
2377
+ snap: snapshots[h.id],
2378
+ displayName: h.name || h.id,
2379
+ collapsed: false,
2380
+ staleMinutes,
2381
+ t,
2382
+ focused: focusId === h.id,
2383
+ focusGpuIndex: focusId === h.id ? focusGpu : void 0,
2384
+ onRetry: retry,
2385
+ pinned: h.pinned,
2386
+ onTogglePin: () => void togglePin(h.id),
2387
+ wideGpuGrid: wide
2388
+ }, h.id)),
2389
+ others.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2390
+ style: { flex: "none" },
2391
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SectionHeader, {
2392
+ label: t("other.collapsed", { count: others.length }),
2393
+ open: showOthers,
2394
+ onClick: () => setShowOthers((v) => !v)
2395
+ }), showOthers && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2396
+ style: {
2397
+ display: "flex",
2398
+ flexDirection: "column",
2399
+ gap: 8,
2400
+ marginTop: 6
2401
+ },
2402
+ children: others.map((h) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostCard$1, {
2403
+ snap: snapshots[h.id],
2404
+ displayName: h.name || h.id,
2405
+ collapsed: true,
2406
+ staleMinutes,
2407
+ t,
2408
+ focused: focusId === h.id,
2409
+ focusGpuIndex: focusId === h.id ? focusGpu : void 0,
2410
+ onRetry: retry,
2411
+ pinned: h.pinned,
2412
+ onTogglePin: () => void togglePin(h.id),
2413
+ wideGpuGrid: wide
2414
+ }, h.id))
2415
+ })]
2416
+ })
2417
+ ]
2418
+ });
2419
+ }
2420
+
2421
+ //#endregion
2422
+ //#region src/client/SettingsSection.tsx
2423
+ const input = {
2424
+ display: "block",
2425
+ width: "100%",
2426
+ boxSizing: "border-box",
2427
+ marginTop: 4,
2428
+ marginBottom: 8,
2429
+ padding: "4px 8px",
2430
+ borderRadius: 6,
2431
+ border: "1px solid var(--dsw-alias-border-l2)",
2432
+ background: "var(--dsw-alias-bg-layer-2, transparent)",
2433
+ color: "var(--dsw-alias-label-primary)",
2434
+ fontSize: 12
2435
+ };
2436
+ const btn = {
2437
+ border: "1px solid var(--dsw-alias-border-l2)",
2438
+ background: "none",
2439
+ borderRadius: 6,
2440
+ padding: "3px 10px",
2441
+ cursor: "pointer",
2442
+ fontSize: 12,
2443
+ color: "var(--dsw-alias-label-primary)"
2444
+ };
2445
+ const label = {
2446
+ fontSize: 11,
2447
+ color: "var(--dsw-alias-label-secondary)",
2448
+ display: "block"
2449
+ };
2450
+ function emptyHost() {
2451
+ return {
2452
+ id: `h${Date.now().toString(36)}`,
2453
+ name: "",
2454
+ host: "",
2455
+ port: 22,
2456
+ username: "",
2457
+ authKind: "key",
2458
+ credentialRef: "",
2459
+ identityFile: "",
2460
+ logPath: "",
2461
+ pinned: false
2462
+ };
2463
+ }
2464
+ async function api(path, init) {
2465
+ const res = await fetch(path, {
2466
+ headers: {
2467
+ "content-type": "application/json",
2468
+ accept: "application/json"
2469
+ },
2470
+ ...init
2471
+ });
2472
+ const body = await res.json().catch(() => ({}));
2473
+ if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
2474
+ return body;
2475
+ }
2476
+ function ServerDashboardSettings({ t }) {
2477
+ const [hosts, setHosts] = (0, react.useState)([]);
2478
+ const [refreshIntervalS, setRefreshIntervalS] = (0, react.useState)(30);
2479
+ const [staleMinutes, setStaleMinutes] = (0, react.useState)(10);
2480
+ const [loading, setLoading] = (0, react.useState)(true);
2481
+ const [error, setError] = (0, react.useState)("");
2482
+ const [notice, setNotice] = (0, react.useState)("");
2483
+ const [editing, setEditing] = (0, react.useState)(null);
2484
+ const [secrets, setSecrets] = (0, react.useState)({});
2485
+ const [testResult, setTestResult] = (0, react.useState)({});
2486
+ /** 正在测试连接的主机 id(每次真实 SSH 握手,期间按钮置灰防连点) */
2487
+ const [testing, setTesting] = (0, react.useState)("");
2488
+ const [importing, setImporting] = (0, react.useState)(false);
2489
+ const [filter, setFilter] = (0, react.useState)("");
2490
+ const load = (0, react.useCallback)(async () => {
2491
+ try {
2492
+ setLoading(true);
2493
+ const res = await api("/dash/config");
2494
+ setHosts(res.config.hosts ?? []);
2495
+ setRefreshIntervalS(res.config.refreshIntervalS ?? 30);
2496
+ setStaleMinutes(res.config.staleMinutes ?? 10);
2497
+ setError("");
2498
+ } catch (e) {
2499
+ setError(e instanceof Error ? e.message : String(e));
2500
+ } finally {
2501
+ setLoading(false);
2502
+ }
2503
+ }, []);
2504
+ (0, react.useEffect)(() => {
2505
+ load();
2506
+ }, [load]);
2507
+ /** PUT 成功且后端返回了新 config 时以后端为权威回显(并发 PUT 相互覆盖后
2508
+ * 界面不停留在旧值);旧版后端只回 {ok:true},此时沿用本地乐观状态 */
2509
+ const syncConfig = (body) => {
2510
+ if (!body?.config) return;
2511
+ setHosts(body.config.hosts ?? []);
2512
+ if (typeof body.config.refreshIntervalS === "number") setRefreshIntervalS(body.config.refreshIntervalS);
2513
+ if (typeof body.config.staleMinutes === "number") setStaleMinutes(body.config.staleMinutes);
2514
+ };
2515
+ const saveIntervals = async () => {
2516
+ try {
2517
+ const res = await api("/dash/config", {
2518
+ method: "PUT",
2519
+ body: JSON.stringify({
2520
+ hosts,
2521
+ refreshIntervalS,
2522
+ staleMinutes
2523
+ })
2524
+ });
2525
+ syncConfig(res);
2526
+ setError("");
2527
+ setNotice(t("settings.saved"));
2528
+ } catch (e) {
2529
+ setError(e instanceof Error ? e.message : String(e));
2530
+ }
2531
+ };
2532
+ /** 连接测试(一次真实 SSH 握手):per-host testing 态 + 期间禁用按钮 */
2533
+ const runTest = async (row, secret) => {
2534
+ setTesting(row.id);
2535
+ try {
2536
+ const res = await api("/dash/test", {
2537
+ method: "POST",
2538
+ body: JSON.stringify({
2539
+ host: row.host,
2540
+ port: row.port,
2541
+ username: row.username,
2542
+ authKind: row.authKind,
2543
+ credentialRef: row.credentialRef,
2544
+ identityFile: row.identityFile,
2545
+ password: secret?.password,
2546
+ privateKey: secret?.privateKey
2547
+ })
2548
+ });
2549
+ setTestResult((m) => ({
2550
+ ...m,
2551
+ [row.id]: (res.ok ? "✅ " : "❌ ") + res.detail
2552
+ }));
2553
+ } catch (e) {
2554
+ setTestResult((m) => ({
2555
+ ...m,
2556
+ [row.id]: "❌ " + (e instanceof Error ? e.message : String(e))
2557
+ }));
2558
+ } finally {
2559
+ setTesting("");
2560
+ }
2561
+ };
2562
+ const save = async (row) => {
2563
+ const next = hosts.some((h) => h.id === row.id) ? hosts.map((h) => h.id === row.id ? row : h) : [...hosts, row];
2564
+ const savedSecret = secrets[row.id];
2565
+ try {
2566
+ const put = await api("/dash/config", {
2567
+ method: "PUT",
2568
+ body: JSON.stringify({
2569
+ hosts: next,
2570
+ refreshIntervalS,
2571
+ staleMinutes,
2572
+ secrets: savedSecret ? { [row.id]: savedSecret } : void 0
2573
+ })
2574
+ });
2575
+ if (put.config) syncConfig(put);
2576
+ else setHosts(next);
2577
+ setEditing(null);
2578
+ setSecrets((s) => {
2579
+ const copy = { ...s };
2580
+ delete copy[row.id];
2581
+ return copy;
2582
+ });
2583
+ await runTest(row, savedSecret);
2584
+ } catch (e) {
2585
+ setError(e instanceof Error ? e.message : String(e));
2586
+ }
2587
+ };
2588
+ const remove = async (id) => {
2589
+ const row = hosts.find((h) => h.id === id);
2590
+ if (row && !window.confirm(t("settings.confirmDelete", { name: row.name || row.host }))) return;
2591
+ const next = hosts.filter((h) => h.id !== id);
2592
+ try {
2593
+ const res = await api("/dash/config", {
2594
+ method: "PUT",
2595
+ body: JSON.stringify({
2596
+ hosts: next,
2597
+ refreshIntervalS,
2598
+ staleMinutes
2599
+ })
2600
+ });
2601
+ if (res.config) syncConfig(res);
2602
+ else setHosts(next);
2603
+ } catch (e) {
2604
+ setError(e instanceof Error ? e.message : String(e));
2605
+ }
2606
+ };
2607
+ const [discovering, setDiscovering] = (0, react.useState)("");
2608
+ const [discovered, setDiscovered] = (0, react.useState)({});
2609
+ const discoverLogs = async (row) => {
2610
+ try {
2611
+ setDiscovering(row.id);
2612
+ const res = await api("/dash/discover-logs", {
2613
+ method: "POST",
2614
+ body: JSON.stringify({
2615
+ host: row.host,
2616
+ port: row.port,
2617
+ username: row.username,
2618
+ authKind: row.authKind,
2619
+ credentialRef: row.credentialRef,
2620
+ password: secrets[row.id]?.password,
2621
+ privateKey: secrets[row.id]?.privateKey
2622
+ })
2623
+ });
2624
+ setDiscovered((m) => ({
2625
+ ...m,
2626
+ [row.id]: res.candidates ?? []
2627
+ }));
2628
+ } catch (e) {
2629
+ setError(e instanceof Error ? e.message : String(e));
2630
+ } finally {
2631
+ setDiscovering("");
2632
+ }
2633
+ };
2634
+ const applyLog = async (row, logPath) => {
2635
+ const next = hosts.map((h) => h.id === row.id ? {
2636
+ ...h,
2637
+ logPath
2638
+ } : h);
2639
+ try {
2640
+ const res = await api("/dash/config", {
2641
+ method: "PUT",
2642
+ body: JSON.stringify({
2643
+ hosts: next,
2644
+ refreshIntervalS,
2645
+ staleMinutes
2646
+ })
2647
+ });
2648
+ if (res.config) syncConfig(res);
2649
+ else setHosts(next);
2650
+ setDiscovered((m) => ({
2651
+ ...m,
2652
+ [row.id]: []
2653
+ }));
2654
+ } catch (e) {
2655
+ setError(e instanceof Error ? e.message : String(e));
2656
+ }
2657
+ };
2658
+ const importSsh = async () => {
2659
+ try {
2660
+ setImporting(true);
2661
+ const res = await api("/dash/import-ssh");
2662
+ const existing = new Set(hosts.map((h) => h.host));
2663
+ const usedIds = new Set(hosts.map((h) => h.id));
2664
+ const added = res.hosts.filter((h) => h.host && !existing.has(h.host)).map((h) => {
2665
+ const base = `imp_${h.alias.replace(/[^A-Za-z0-9]/g, "_")}`;
2666
+ let id = base;
2667
+ for (let n = 2; usedIds.has(id); n++) id = `${base}_${n}`;
2668
+ usedIds.add(id);
2669
+ return {
2670
+ ...emptyHost(),
2671
+ id,
2672
+ name: h.alias,
2673
+ host: h.host,
2674
+ port: h.port,
2675
+ username: h.username,
2676
+ authKind: h.identityFile ? "key" : "none",
2677
+ identityFile: h.identityFile ?? "",
2678
+ logPath: ""
2679
+ };
2680
+ });
2681
+ const next = [...hosts, ...added];
2682
+ const put = await api("/dash/config", {
2683
+ method: "PUT",
2684
+ body: JSON.stringify({
2685
+ hosts: next,
2686
+ refreshIntervalS,
2687
+ staleMinutes
2688
+ })
2689
+ });
2690
+ if (put.config) syncConfig(put);
2691
+ else setHosts(next);
2692
+ setNotice(added.length > 0 ? t("settings.imported", {
2693
+ added: added.length,
2694
+ skipped: res.hosts.length - added.length
2695
+ }) : t("settings.importNone"));
2696
+ } catch (e) {
2697
+ setError(e instanceof Error ? e.message : String(e));
2698
+ } finally {
2699
+ setImporting(false);
2700
+ }
2701
+ };
2702
+ const test = (row) => void runTest(row, secrets[row.id]);
2703
+ const shown = filter.trim() ? hosts.filter((h) => `${h.name} ${h.host} ${h.username}`.toLowerCase().includes(filter.trim().toLowerCase())) : hosts;
2704
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2705
+ style: {
2706
+ padding: "12px 16px",
2707
+ fontSize: 12,
2708
+ maxWidth: 560
2709
+ },
2710
+ children: [
2711
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2712
+ style: {
2713
+ display: "flex",
2714
+ alignItems: "center",
2715
+ gap: 8,
2716
+ marginBottom: 10
2717
+ },
2718
+ children: [
2719
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2720
+ style: {
2721
+ fontWeight: 600,
2722
+ fontSize: 13
2723
+ },
2724
+ children: t("settings.nav")
2725
+ }),
2726
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: 1 } }),
2727
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2728
+ style: {
2729
+ ...input,
2730
+ width: 120,
2731
+ margin: 0
2732
+ },
2733
+ placeholder: t("settings.searchHosts"),
2734
+ value: filter,
2735
+ onChange: (e) => setFilter(e.target.value)
2736
+ }),
2737
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2738
+ type: "button",
2739
+ style: btn,
2740
+ onClick: importSsh,
2741
+ disabled: importing,
2742
+ children: importing ? "…" : t("settings.importSsh")
2743
+ }),
2744
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2745
+ type: "button",
2746
+ style: btn,
2747
+ onClick: () => setEditing(emptyHost()),
2748
+ children: t("settings.addHost")
2749
+ })
2750
+ ]
2751
+ }),
2752
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2753
+ style: {
2754
+ display: "flex",
2755
+ alignItems: "center",
2756
+ gap: 12,
2757
+ marginBottom: 12,
2758
+ padding: "8px 10px",
2759
+ border: "1px solid var(--dsw-alias-border-l2)",
2760
+ borderRadius: 8
2761
+ },
2762
+ children: [
2763
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2764
+ style: {
2765
+ ...label,
2766
+ margin: 0,
2767
+ display: "flex",
2768
+ alignItems: "center",
2769
+ gap: 6
2770
+ },
2771
+ children: [
2772
+ t("settings.refreshIntervalShort"),
2773
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2774
+ type: "number",
2775
+ min: 10,
2776
+ max: 300,
2777
+ step: 5,
2778
+ style: {
2779
+ ...input,
2780
+ width: 64,
2781
+ margin: 0
2782
+ },
2783
+ value: refreshIntervalS,
2784
+ onChange: (e) => setRefreshIntervalS(Number(e.target.value) || 30),
2785
+ onBlur: (e) => setRefreshIntervalS(Math.max(10, Math.min(300, Number(e.target.value) || 30)))
2786
+ }),
2787
+ t("settings.seconds")
2788
+ ]
2789
+ }),
2790
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2791
+ style: {
2792
+ ...label,
2793
+ margin: 0,
2794
+ display: "flex",
2795
+ alignItems: "center",
2796
+ gap: 6
2797
+ },
2798
+ children: [
2799
+ t("settings.staleShort"),
2800
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2801
+ type: "number",
2802
+ min: 1,
2803
+ max: 1440,
2804
+ step: 1,
2805
+ style: {
2806
+ ...input,
2807
+ width: 56,
2808
+ margin: 0
2809
+ },
2810
+ value: staleMinutes,
2811
+ onChange: (e) => setStaleMinutes(Number(e.target.value) || 10),
2812
+ onBlur: (e) => setStaleMinutes(Math.max(1, Math.min(1440, Number(e.target.value) || 10)))
2813
+ }),
2814
+ t("settings.staleSuffix")
2815
+ ]
2816
+ }),
2817
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: 1 } }),
2818
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2819
+ type: "button",
2820
+ style: btn,
2821
+ onClick: () => void saveIntervals(),
2822
+ children: t("settings.save")
2823
+ })
2824
+ ]
2825
+ }),
2826
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2827
+ style: {
2828
+ color: "var(--dsw-alias-state-danger, #e5484d)",
2829
+ marginBottom: 8
2830
+ },
2831
+ children: error
2832
+ }),
2833
+ !error && notice && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2834
+ style: {
2835
+ color: "var(--dsw-alias-state-success-primary, #30a46c)",
2836
+ marginBottom: 8
2837
+ },
2838
+ children: notice
2839
+ }),
2840
+ loading && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2841
+ style: { color: "var(--dsw-alias-label-caption)" },
2842
+ children: t("settings.loading")
2843
+ }),
2844
+ !loading && hosts.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2845
+ style: { color: "var(--dsw-alias-label-caption)" },
2846
+ children: t("settings.empty")
2847
+ }),
2848
+ filter.trim() && hosts.length > 0 && shown.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2849
+ style: { color: "var(--dsw-alias-label-caption)" },
2850
+ children: t("settings.noMatch")
2851
+ }),
2852
+ shown.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2853
+ style: {
2854
+ border: "1px solid var(--dsw-alias-border-l2)",
2855
+ borderRadius: 8,
2856
+ padding: 10,
2857
+ marginBottom: 8
2858
+ },
2859
+ children: [
2860
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2861
+ style: {
2862
+ display: "flex",
2863
+ alignItems: "center",
2864
+ gap: 6
2865
+ },
2866
+ children: [
2867
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2868
+ style: { fontWeight: 600 },
2869
+ children: row.name || row.host
2870
+ }),
2871
+ row.pinned && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2872
+ style: {
2873
+ fontSize: 10,
2874
+ color: "var(--dsw-alias-state-business-primary, #4d6bfe)"
2875
+ },
2876
+ children: t("settings.pinnedTag")
2877
+ }),
2878
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: 1 } }),
2879
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2880
+ type: "button",
2881
+ style: btn,
2882
+ onClick: () => test(row),
2883
+ disabled: testing === row.id,
2884
+ children: testing === row.id ? t("settings.testing") : t("settings.testShort")
2885
+ }),
2886
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2887
+ type: "button",
2888
+ style: btn,
2889
+ onClick: () => void discoverLogs(row),
2890
+ disabled: discovering === row.id,
2891
+ children: discovering === row.id ? t("settings.discovering") : `🔍 ${t("settings.discover")}`
2892
+ }),
2893
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2894
+ type: "button",
2895
+ style: btn,
2896
+ onClick: () => setEditing({ ...row }),
2897
+ children: t("settings.edit")
2898
+ }),
2899
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2900
+ type: "button",
2901
+ style: btn,
2902
+ onClick: () => void remove(row.id),
2903
+ children: t("settings.remove")
2904
+ })
2905
+ ]
2906
+ }),
2907
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2908
+ style: {
2909
+ fontSize: 11,
2910
+ color: "var(--dsw-alias-label-caption)",
2911
+ marginTop: 2
2912
+ },
2913
+ children: [
2914
+ row.username,
2915
+ "@",
2916
+ row.host,
2917
+ ":",
2918
+ row.port,
2919
+ row.authKind !== "none" && ` · ${t("settings.cred")} ${row.credentialRef || t("settings.credUnnamed")}`,
2920
+ row.authKind === "key" && !row.credentialRef && row.identityFile && ` · ${t("settings.identityFile")} ${row.identityFile}`,
2921
+ row.logPath && ` · ${t("settings.logPathLabel")} ${row.logPath}`
2922
+ ]
2923
+ }),
2924
+ discovered[row.id]?.length === 0 && discovering !== row.id && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2925
+ style: {
2926
+ fontSize: 11,
2927
+ color: "var(--dsw-alias-label-caption)",
2928
+ marginTop: 4
2929
+ },
2930
+ children: t("settings.discoverEmpty")
2931
+ }),
2932
+ (discovered[row.id] ?? []).map((c) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2933
+ style: {
2934
+ display: "flex",
2935
+ alignItems: "center",
2936
+ gap: 6,
2937
+ fontSize: 11,
2938
+ marginTop: 4
2939
+ },
2940
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2941
+ style: {
2942
+ flex: 1,
2943
+ overflow: "hidden",
2944
+ textOverflow: "ellipsis",
2945
+ whiteSpace: "nowrap"
2946
+ },
2947
+ title: `pid ${c.pid} (${c.user}) ${c.cmd}`,
2948
+ children: [c.logPath, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2949
+ style: { color: "var(--dsw-alias-label-caption)" },
2950
+ children: [
2951
+ " ",
2952
+ "· ",
2953
+ c.source,
2954
+ " · pid ",
2955
+ c.pid,
2956
+ " (",
2957
+ c.user,
2958
+ ")",
2959
+ c.size < 0 ? ` · ⚠️ ${t("settings.noPerm")}` : ` · ${(c.size / 1024).toFixed(0)}KB · ${new Date(c.mtimeMs).toLocaleTimeString()}`
2960
+ ]
2961
+ })]
2962
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2963
+ type: "button",
2964
+ style: btn,
2965
+ disabled: c.size < 0,
2966
+ onClick: () => void applyLog(row, c.logPath),
2967
+ children: t("settings.useIt")
2968
+ })]
2969
+ }, `${c.source}-${c.pid}-${c.logPath}`)),
2970
+ testResult[row.id] && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2971
+ style: {
2972
+ fontSize: 11,
2973
+ marginTop: 4
2974
+ },
2975
+ children: testResult[row.id]
2976
+ })
2977
+ ]
2978
+ }, row.id)),
2979
+ editing && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2980
+ style: {
2981
+ border: "1px solid var(--dsw-alias-state-business-primary, #4d6bfe)",
2982
+ borderRadius: 8,
2983
+ padding: 12,
2984
+ marginTop: 4
2985
+ },
2986
+ children: [
2987
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2988
+ style: {
2989
+ fontWeight: 600,
2990
+ marginBottom: 6
2991
+ },
2992
+ children: hosts.some((h) => h.id === editing.id) ? t("settings.editHost") : t("settings.addHostForm")
2993
+ }),
2994
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
2995
+ style: label,
2996
+ children: t("f.name")
2997
+ }),
2998
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2999
+ style: input,
3000
+ value: editing.name,
3001
+ onChange: (e) => setEditing({
3002
+ ...editing,
3003
+ name: e.target.value
3004
+ })
3005
+ }),
3006
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
3007
+ style: label,
3008
+ children: t("f.host")
3009
+ }),
3010
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3011
+ style: input,
3012
+ value: editing.host,
3013
+ onChange: (e) => setEditing({
3014
+ ...editing,
3015
+ host: e.target.value
3016
+ })
3017
+ }),
3018
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3019
+ style: {
3020
+ display: "flex",
3021
+ gap: 8
3022
+ },
3023
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3024
+ style: { flex: 1 },
3025
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
3026
+ style: label,
3027
+ children: t("f.port")
3028
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3029
+ style: input,
3030
+ type: "number",
3031
+ value: editing.port,
3032
+ onChange: (e) => setEditing({
3033
+ ...editing,
3034
+ port: Number(e.target.value) || 22
3035
+ }),
3036
+ onBlur: (e) => setEditing({
3037
+ ...editing,
3038
+ port: Math.max(1, Math.min(65535, Math.round(Number(e.target.value) || 22)))
3039
+ })
3040
+ })]
3041
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3042
+ style: { flex: 2 },
3043
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
3044
+ style: label,
3045
+ children: t("f.user")
3046
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3047
+ style: input,
3048
+ value: editing.username,
3049
+ onChange: (e) => setEditing({
3050
+ ...editing,
3051
+ username: e.target.value
3052
+ })
3053
+ })]
3054
+ })]
3055
+ }),
3056
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
3057
+ style: label,
3058
+ children: t("f.auth")
3059
+ }),
3060
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
3061
+ style: input,
3062
+ value: editing.authKind,
3063
+ onChange: (e) => setEditing({
3064
+ ...editing,
3065
+ authKind: e.target.value
3066
+ }),
3067
+ children: [
3068
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3069
+ value: "key",
3070
+ children: t("f.authKey")
3071
+ }),
3072
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3073
+ value: "password",
3074
+ children: t("f.authPassword")
3075
+ }),
3076
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3077
+ value: "none",
3078
+ children: t("f.authNone")
3079
+ })
3080
+ ]
3081
+ }),
3082
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
3083
+ style: label,
3084
+ children: t("f.credRef")
3085
+ }),
3086
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3087
+ style: input,
3088
+ value: editing.credentialRef,
3089
+ placeholder: "SD_<ID>_KEY",
3090
+ onChange: (e) => setEditing({
3091
+ ...editing,
3092
+ credentialRef: e.target.value
3093
+ })
3094
+ }),
3095
+ editing.authKind !== "none" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
3096
+ style: label,
3097
+ children: t("f.secret")
3098
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
3099
+ style: {
3100
+ ...input,
3101
+ minHeight: 56,
3102
+ fontFamily: "var(--dsw-font-mono, monospace)"
3103
+ },
3104
+ value: secrets[editing.id]?.privateKey ?? secrets[editing.id]?.password ?? "",
3105
+ onChange: (e) => {
3106
+ const value = e.target.value;
3107
+ setSecrets((s) => ({
3108
+ ...s,
3109
+ [editing.id]: editing.authKind === "key" ? { privateKey: value } : { password: value }
3110
+ }));
3111
+ }
3112
+ })] }),
3113
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
3114
+ style: label,
3115
+ children: t("f.logPath")
3116
+ }),
3117
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3118
+ style: input,
3119
+ value: editing.logPath,
3120
+ onChange: (e) => setEditing({
3121
+ ...editing,
3122
+ logPath: e.target.value
3123
+ })
3124
+ }),
3125
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3126
+ style: {
3127
+ ...label,
3128
+ display: "flex",
3129
+ alignItems: "center",
3130
+ gap: 6
3131
+ },
3132
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3133
+ type: "checkbox",
3134
+ checked: editing.pinned,
3135
+ onChange: (e) => setEditing({
3136
+ ...editing,
3137
+ pinned: e.target.checked
3138
+ })
3139
+ }), t("settings.pin")]
3140
+ }),
3141
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3142
+ style: {
3143
+ display: "flex",
3144
+ gap: 8,
3145
+ marginTop: 10
3146
+ },
3147
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3148
+ type: "button",
3149
+ style: btn,
3150
+ onClick: () => void save(editing),
3151
+ disabled: !editing.name || !editing.host,
3152
+ children: t("settings.save")
3153
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3154
+ type: "button",
3155
+ style: btn,
3156
+ onClick: () => setEditing(null),
3157
+ children: t("settings.cancel")
3158
+ })]
3159
+ })
3160
+ ]
3161
+ })
3162
+ ]
3163
+ });
3164
+ }
3165
+
3166
+ //#endregion
3167
+ //#region src/client/rightbar.tsx
3168
+ /**
3169
+ * 官方右侧 Sidebar(0.1.5+)的「GPU 监控」页签:常驻紧凑仪表。
3170
+ *
3171
+ * 两阶段注册姿势与 dsh-scholar/dsh-trajectory 的 rightbar 相同:
3172
+ * 1. ctx.sidebarRightTabs.register(定义)(kind 由 openTab 打开)
3173
+ * 2. ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
3174
+ * { name, key: 定义id, locale: NS }, Body))
3175
+ *
3176
+ * body 拉同一份 /dash/snapshots(与抽屉/状态丸同源),按主机一卡、每 GPU 一行:
3177
+ * 利用率/温度用 thresholds.ts 的统一阈值配色。旧宿主无 sidebarRightTabs
3178
+ * 服务时静默跳过,抽屉头部入口按钮同步隐藏。
3179
+ *
3180
+ * 注意:本文件从 './index' 导入 panelBus(点「打开完整看板」时唤起抽屉)——
3181
+ * ESM 循环引用安全:仅点击回调里解引用,不在模块求值期使用。
3182
+ */
3183
+ const NS$1 = "serverDashboard";
3184
+ /** 注册 id = 包名(tab 身份,也是 body 注册 key) */
3185
+ const TAB_ID = "dsh-server-dashboard";
3186
+ const GPU_TAB_KIND = "dsh-server-dashboard.gpu";
3187
+ let svc = null;
3188
+ /** 入口可用性(抽屉头部据此显示/隐藏「在右侧栏跟随」)。 */
3189
+ function rightbarAvailable() {
3190
+ return svc !== null;
3191
+ }
3192
+ /** 在右侧栏打开 GPU 监控页签;服务缺席时静默 no-op。 */
3193
+ function openGpuInRightbar() {
3194
+ try {
3195
+ svc?.openTab(GPU_TAB_KIND);
3196
+ } catch {}
3197
+ }
3198
+ /** MiB → 紧凑 GiB 文本(小盘面省宽度) */
3199
+ function gib(mib) {
3200
+ return `${Math.round(mib / 1024)}G`;
3201
+ }
3202
+ function utilColor(util) {
3203
+ if (util >= 95) return "var(--dsw-alias-state-danger-primary, #e5484d)";
3204
+ if (util >= 20) return "var(--dsw-alias-label-primary)";
3205
+ return "var(--dsw-alias-label-caption)";
3206
+ }
3207
+ function tempColor(temp) {
3208
+ if (temp >= 82) return "var(--dsw-alias-state-danger-primary, #e5484d)";
3209
+ if (temp >= 75) return "var(--dsw-alias-state-warn-primary, #f5a524)";
3210
+ return "var(--dsw-alias-label-caption)";
3211
+ }
3212
+ function GpuTabBody({ t }) {
3213
+ const [data, setData] = react.default.useState(null);
3214
+ const [err, setErr] = react.default.useState("");
3215
+ const [at, setAt] = react.default.useState(0);
3216
+ const periodS = Math.max(5, Math.min(30, data?.refreshIntervalS ?? 8));
3217
+ react.default.useEffect(() => {
3218
+ let alive = true;
3219
+ const load = async () => {
3220
+ if (document.hidden) return;
3221
+ try {
3222
+ const res = await fetch("/dash/snapshots", {
3223
+ headers: { accept: "application/json" },
3224
+ signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(3e4) : void 0
3225
+ });
3226
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
3227
+ const body = await res.json();
3228
+ if (!alive) return;
3229
+ setData(body);
3230
+ setAt(Date.now());
3231
+ setErr("");
3232
+ } catch (e) {
3233
+ if (alive) setErr(String(e instanceof Error ? e.message : e));
3234
+ }
3235
+ };
3236
+ load();
3237
+ const timer = setInterval(() => void load(), periodS * 1e3);
3238
+ return () => {
3239
+ alive = false;
3240
+ clearInterval(timer);
3241
+ };
3242
+ }, [periodS]);
3243
+ const hosts = data?.hosts ?? [];
3244
+ const offline = hosts.filter((h) => {
3245
+ const s = data?.snapshots?.[h.id];
3246
+ return !s || !s.ok;
3247
+ }).length;
3248
+ const stale = !!err && !!data;
3249
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3250
+ style: {
3251
+ padding: "10px 12px",
3252
+ display: "flex",
3253
+ flexDirection: "column",
3254
+ gap: 8,
3255
+ minHeight: "100%",
3256
+ overflow: "auto"
3257
+ },
3258
+ children: [
3259
+ err && !data && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3260
+ style: {
3261
+ fontSize: 11.5,
3262
+ color: "var(--dsw-alias-state-danger-primary)",
3263
+ lineHeight: 1.6
3264
+ },
3265
+ children: err
3266
+ }),
3267
+ hosts.length === 0 && !err && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3268
+ style: {
3269
+ fontSize: 12,
3270
+ color: "var(--dsw-alias-label-caption)",
3271
+ lineHeight: 1.7
3272
+ },
3273
+ children: t("rightbar.empty")
3274
+ }),
3275
+ hosts.map((h) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostCard, {
3276
+ name: h.name,
3277
+ snap: data?.snapshots?.[h.id],
3278
+ t
3279
+ }, h.id)),
3280
+ hosts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3281
+ style: {
3282
+ display: "flex",
3283
+ alignItems: "center",
3284
+ gap: 8,
3285
+ marginTop: 2,
3286
+ flexWrap: "wrap"
3287
+ },
3288
+ children: [
3289
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3290
+ title: stale ? err : void 0,
3291
+ style: {
3292
+ fontSize: 10.5,
3293
+ color: stale ? "var(--dsw-alias-state-warn-primary, #f5a524)" : "var(--dsw-alias-label-caption)"
3294
+ },
3295
+ children: [stale ? "⚠ " : "", offline > 0 ? t("rightbar.offlineCount", { count: offline }) : at ? t("panel.updatedAt", { time: relTime(at, t) }) : ""]
3296
+ }),
3297
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: 1 } }),
3298
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3299
+ type: "button",
3300
+ onClick: () => panelBus.set("open"),
3301
+ style: {
3302
+ cursor: "pointer",
3303
+ fontSize: 10.5,
3304
+ padding: "3px 9px",
3305
+ borderRadius: 7,
3306
+ border: "1px solid var(--dsw-alias-border-l1)",
3307
+ background: "var(--dsw-alias-bg-layer-1)",
3308
+ color: "var(--dsw-alias-label-secondary)"
3309
+ },
3310
+ children: t("rightbar.openFull")
3311
+ })
3312
+ ]
3313
+ })
3314
+ ]
3315
+ });
3316
+ }
3317
+ function HostCard({ name, snap, t }) {
3318
+ const [open, setOpen] = react.default.useState(false);
3319
+ const ok = !!snap?.ok;
3320
+ const base = snap?.base;
3321
+ const gpus = (snap?.gpus ?? []).filter((g) => g.name || g.memoryTotalMiB > 0 || g.tempC > 0 || g.utilPercent > 0);
3322
+ const gpuUnavailable = ok && (snap?.gpus?.length ?? 0) > 0 && gpus.length === 0;
3323
+ const busy = gpus.filter((g) => g.utilPercent >= 20 || g.processes.length > 0).length;
3324
+ const expandable = ok && gpus.length > 0;
3325
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3326
+ style: {
3327
+ borderRadius: 9,
3328
+ border: "1px solid var(--dsw-alias-border-l1)",
3329
+ background: "var(--dsw-alias-bg-layer-1)",
3330
+ padding: "7px 9px"
3331
+ },
3332
+ children: [
3333
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3334
+ onClick: () => expandable && setOpen((v) => !v),
3335
+ style: {
3336
+ display: "flex",
3337
+ alignItems: "center",
3338
+ gap: 6,
3339
+ marginBottom: 4,
3340
+ cursor: expandable ? "pointer" : "default",
3341
+ userSelect: "none"
3342
+ },
3343
+ children: [
3344
+ expandable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3345
+ "aria-hidden": true,
3346
+ style: {
3347
+ flex: "none",
3348
+ fontSize: 8,
3349
+ color: "var(--dsw-alias-label-caption)",
3350
+ transform: open ? "rotate(90deg)" : "rotate(0deg)",
3351
+ transition: "transform .15s",
3352
+ display: "inline-block",
3353
+ width: 8,
3354
+ textAlign: "center"
3355
+ },
3356
+ children: "▶"
3357
+ }),
3358
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3359
+ "aria-hidden": true,
3360
+ style: {
3361
+ width: 7,
3362
+ height: 7,
3363
+ borderRadius: 999,
3364
+ flex: "none",
3365
+ background: ok ? "var(--dsw-alias-state-success-primary, #30a46c)" : "var(--dsw-alias-state-danger-primary, #e5484d)"
3366
+ }
3367
+ }),
3368
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3369
+ style: {
3370
+ fontWeight: 600,
3371
+ fontSize: 12,
3372
+ overflow: "hidden",
3373
+ textOverflow: "ellipsis",
3374
+ whiteSpace: "nowrap"
3375
+ },
3376
+ children: name
3377
+ }),
3378
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: 1 } }),
3379
+ ok && gpus.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3380
+ style: {
3381
+ fontSize: 10,
3382
+ color: "var(--dsw-alias-label-caption)",
3383
+ flex: "none"
3384
+ },
3385
+ children: t("rightbar.busy", {
3386
+ n: busy,
3387
+ m: gpus.length
3388
+ })
3389
+ }),
3390
+ !ok && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3391
+ style: {
3392
+ fontSize: 10,
3393
+ color: "var(--dsw-alias-state-danger-primary)",
3394
+ flex: "none"
3395
+ },
3396
+ children: t("rightbar.offline")
3397
+ })
3398
+ ]
3399
+ }),
3400
+ ok && base && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3401
+ style: {
3402
+ fontSize: 10.5,
3403
+ color: "var(--dsw-alias-label-caption)",
3404
+ fontVariantNumeric: "tabular-nums",
3405
+ marginBottom: gpus.length ? 4 : 0
3406
+ },
3407
+ children: t("rightbar.cpuMem", {
3408
+ cpu: Math.round(base.cpuPercent),
3409
+ mem: `${gib(base.memUsedMiB)}/${gib(base.memTotalMiB)}`
3410
+ })
3411
+ }),
3412
+ ok && gpus.map((g) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.default.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3413
+ style: {
3414
+ display: "flex",
3415
+ alignItems: "baseline",
3416
+ gap: 6,
3417
+ fontSize: 11,
3418
+ fontVariantNumeric: "tabular-nums",
3419
+ lineHeight: 1.7
3420
+ },
3421
+ children: [
3422
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3423
+ style: {
3424
+ color: "var(--dsw-alias-label-caption)",
3425
+ flex: "none"
3426
+ },
3427
+ children: ["#", g.index]
3428
+ }),
3429
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3430
+ style: {
3431
+ flex: 1,
3432
+ overflow: "hidden",
3433
+ textOverflow: "ellipsis",
3434
+ whiteSpace: "nowrap",
3435
+ color: "var(--dsw-alias-label-secondary)"
3436
+ },
3437
+ children: g.name
3438
+ }),
3439
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3440
+ style: {
3441
+ flex: "none",
3442
+ color: utilColor(g.utilPercent),
3443
+ fontWeight: g.utilPercent >= 95 ? 700 : 400
3444
+ },
3445
+ children: [g.utilPercent, "%"]
3446
+ }),
3447
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3448
+ style: {
3449
+ flex: "none",
3450
+ color: tempColor(g.tempC)
3451
+ },
3452
+ children: [g.tempC, "°"]
3453
+ }),
3454
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3455
+ style: {
3456
+ flex: "none",
3457
+ color: "var(--dsw-alias-label-caption)"
3458
+ },
3459
+ children: [
3460
+ gib(g.memoryUsedMiB),
3461
+ "/",
3462
+ gib(g.memoryTotalMiB)
3463
+ ]
3464
+ })
3465
+ ]
3466
+ }), open && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GpuDetail, {
3467
+ g,
3468
+ t
3469
+ })] }, g.index)),
3470
+ ok && gpuUnavailable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3471
+ style: {
3472
+ fontSize: 10.5,
3473
+ color: "var(--dsw-alias-label-caption)",
3474
+ lineHeight: 1.6
3475
+ },
3476
+ children: t("rightbar.noGpu")
3477
+ }),
3478
+ !ok && snap?.error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3479
+ style: {
3480
+ fontSize: 10.5,
3481
+ color: "var(--dsw-alias-label-caption)",
3482
+ overflow: "hidden",
3483
+ textOverflow: "ellipsis",
3484
+ whiteSpace: "nowrap"
3485
+ },
3486
+ title: snap.error,
3487
+ children: snap.error
3488
+ })
3489
+ ]
3490
+ });
3491
+ }
3492
+ /** 展开态的每 GPU 明细:进程(谁在跑/显存)、指标最新值、日志最新一行 */
3493
+ function GpuDetail({ g, t }) {
3494
+ const lastLog = [...g.log?.lines ?? []].reverse().find((l) => l.trim().length > 0);
3495
+ const series = (g.series ?? []).slice(0, 3).map((s) => ({
3496
+ name: s.name,
3497
+ v: s.points.length ? s.points[s.points.length - 1].v : null
3498
+ }));
3499
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3500
+ style: {
3501
+ margin: "1px 0 6px 16px",
3502
+ padding: "3px 0 3px 8px",
3503
+ borderLeft: "2px solid var(--dsw-alias-border-l2)"
3504
+ },
3505
+ children: [
3506
+ g.processes.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3507
+ style: {
3508
+ fontSize: 10.5,
3509
+ color: "var(--dsw-alias-label-caption)",
3510
+ lineHeight: 1.6
3511
+ },
3512
+ children: t("rightbar.idle")
3513
+ }),
3514
+ g.processes.map((p) => {
3515
+ const cmd = p.cmd || p.name;
3516
+ const shown = cmd.length > 46 ? `…${cmd.slice(-45)}` : cmd;
3517
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3518
+ title: cmd,
3519
+ style: {
3520
+ fontSize: 10.5,
3521
+ lineHeight: 1.6,
3522
+ whiteSpace: "nowrap",
3523
+ overflow: "hidden",
3524
+ textOverflow: "ellipsis",
3525
+ color: "var(--dsw-alias-label-secondary)"
3526
+ },
3527
+ children: [
3528
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3529
+ style: {
3530
+ fontVariantNumeric: "tabular-nums",
3531
+ color: "var(--dsw-alias-label-primary)"
3532
+ },
3533
+ children: gib(p.memoryMiB)
3534
+ }),
3535
+ " ",
3536
+ shown,
3537
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3538
+ style: { color: "var(--dsw-alias-label-caption)" },
3539
+ children: ` · ${p.user} · ${p.pid}`
3540
+ })
3541
+ ]
3542
+ }, p.pid);
3543
+ }),
3544
+ series.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3545
+ style: {
3546
+ display: "flex",
3547
+ gap: 5,
3548
+ flexWrap: "wrap",
3549
+ margin: g.processes.length ? "3px 0 0" : 0
3550
+ },
3551
+ children: series.map((s) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3552
+ style: {
3553
+ fontSize: 10,
3554
+ padding: "1px 7px",
3555
+ borderRadius: 999,
3556
+ background: "var(--dsw-alias-bg-layer-2)",
3557
+ color: "var(--dsw-alias-label-secondary)",
3558
+ fontVariantNumeric: "tabular-nums"
3559
+ },
3560
+ children: [
3561
+ s.name,
3562
+ " ",
3563
+ s.v === null ? "—" : Math.abs(s.v) >= 1e3 ? Math.round(s.v) : Math.round(s.v * 1e3) / 1e3
3564
+ ]
3565
+ }, s.name))
3566
+ }),
3567
+ lastLog && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3568
+ title: lastLog,
3569
+ style: {
3570
+ fontSize: 10,
3571
+ color: "var(--dsw-alias-label-caption)",
3572
+ whiteSpace: "nowrap",
3573
+ overflow: "hidden",
3574
+ textOverflow: "ellipsis",
3575
+ lineHeight: 1.6,
3576
+ marginTop: series.length ? 0 : 2
3577
+ },
3578
+ children: lastLog.trim()
3579
+ })
3580
+ ]
3581
+ });
3582
+ }
3583
+ function registerDashboardRightbar(ctx) {
3584
+ ctx.inject(["sidebarRightTabs", "sidebarRight"], (ctx2) => {
3585
+ svc = ctx2.sidebarRight;
3586
+ const t = ctx2.locale.bind(NS$1);
3587
+ ctx2.effect(() => ctx2.sidebarRightTabs.register({
3588
+ id: TAB_ID,
3589
+ kind: GPU_TAB_KIND,
3590
+ title: () => t("rightbar.tabTitle"),
3591
+ guide: [{
3592
+ order: 62,
3593
+ title: () => t("rightbar.tabTitle"),
3594
+ description: () => t("rightbar.guideDesc")
3595
+ }]
3596
+ }), "server-dashboard: rightbar gpu tab type");
3597
+ ctx2.effect(() => ctx2.slots.inject("sidebar.right.pane.tab", () => ctx2.slots.register({
3598
+ name: "sidebar.right.pane.tab",
3599
+ key: TAB_ID,
3600
+ locale: NS$1
3601
+ }, GpuTabBody)), "server-dashboard: rightbar gpu tab body");
3602
+ return () => {
3603
+ svc = null;
3604
+ };
3605
+ });
3606
+ }
3607
+
3608
+ //#endregion
3609
+ //#region src/client/index.tsx
3610
+ const Fragment = react.default.Fragment;
3611
+ react.default.createElement;
3612
+ react.default.createElement;
3613
+ const inject = ["slots", "locale"];
3614
+ const NS = "serverDashboard";
3615
+ const panelBus = {
3616
+ mode: "closed",
3617
+ listeners: /* @__PURE__ */ new Set(),
3618
+ getSnapshot() {
3619
+ return panelBus.mode;
3620
+ },
3621
+ subscribe(listener) {
3622
+ panelBus.listeners.add(listener);
3623
+ return () => {
3624
+ panelBus.listeners.delete(listener);
3625
+ };
3626
+ },
3627
+ set(next) {
3628
+ if (panelBus.mode !== next) {
3629
+ panelBus.mode = next;
3630
+ for (const listener of panelBus.listeners) listener();
3631
+ }
3632
+ }
3633
+ };
3634
+ function usePanelMode() {
3635
+ return (0, react.useSyncExternalStore)(panelBus.subscribe, panelBus.getSnapshot);
3636
+ }
3637
+ const statusBus = {
3638
+ state: {
3639
+ hosts: 0,
3640
+ offline: 0,
3641
+ hot: 0,
3642
+ stalled: 0
3643
+ },
3644
+ listeners: /* @__PURE__ */ new Set(),
3645
+ getSnapshot() {
3646
+ return statusBus.state;
3647
+ },
3648
+ subscribe(listener) {
3649
+ statusBus.listeners.add(listener);
3650
+ return () => {
3651
+ statusBus.listeners.delete(listener);
3652
+ };
3653
+ },
3654
+ set(next) {
3655
+ const cur = statusBus.state;
3656
+ if (next.hosts !== cur.hosts || next.offline !== cur.offline || next.hot !== cur.hot || next.stalled !== cur.stalled) {
3657
+ statusBus.state = next;
3658
+ for (const listener of statusBus.listeners) listener();
3659
+ }
3660
+ }
3661
+ };
3662
+ function useDashStatus() {
3663
+ return (0, react.useSyncExternalStore)(statusBus.subscribe, statusBus.getSnapshot);
3664
+ }
3665
+ const staleLedger = /* @__PURE__ */ new Map();
3666
+ /** stallNotice keys already toasted (`hostId@at`) — a notice may ride several
3667
+ * cached responses while the host is in backoff; toast it exactly once */
3668
+ const notifiedNotices = /* @__PURE__ */ new Set();
3669
+ /**
3670
+ * Degraded-frame guard: when one SSH command stream dies mid-poll (`free -m`
3671
+ * losing its output is the reliable tell → memTotal parses as 0), don't let
3672
+ * that frame wipe the panel with fake zeros — fall back to the previous good
3673
+ * base/log/series for the affected host. Only provably-blanked fields are
3674
+ * restored; everything genuinely fresh (timestamps, processes) stays.
3675
+ */
3676
+ function mergeDegradedFrame(body, prev) {
3677
+ if (!prev) return body;
3678
+ let touched = false;
3679
+ const merged = {
3680
+ ...body,
3681
+ snapshots: { ...body.snapshots }
3682
+ };
3683
+ for (const [id, cur] of Object.entries(body.snapshots)) {
3684
+ if (!cur?.ok || !cur.base) continue;
3685
+ if (!(cur.base.memTotalMiB === 0 && cur.base.memUsedMiB === 0)) continue;
3686
+ const old = prev.snapshots[id];
3687
+ if (!old?.ok || !old.base || !(old.base.memTotalMiB > 0)) continue;
3688
+ merged.snapshots[id] = {
3689
+ ...cur,
3690
+ base: old.base,
3691
+ log: cur.log && cur.log.lines.length > 0 ? cur.log : old.log ?? cur.log,
3692
+ series: cur.series && cur.series.length > 0 ? cur.series : old.series,
3693
+ gpus: cur.gpus && cur.gpus.length > 0 ? cur.gpus.map((g, i) => {
3694
+ const og = old.gpus?.[i];
3695
+ if (!og) return g;
3696
+ return {
3697
+ ...g,
3698
+ log: g.log ?? og.log,
3699
+ series: g.series && g.series.length > 0 ? g.series : og.series,
3700
+ processes: g.processes.length > 0 ? g.processes : og.processes
3701
+ };
3702
+ }) : old.gpus
3703
+ };
3704
+ touched = true;
3705
+ }
3706
+ return touched ? merged : body;
3707
+ }
3708
+ function useDashboard(t) {
3709
+ const [data, setData] = (0, react.useState)(null);
3710
+ const [refreshing, setRefreshing] = (0, react.useState)(false);
3711
+ const [hostError, setHostError] = (0, react.useState)(false);
3712
+ const [toasts, setToasts] = (0, react.useState)([]);
3713
+ const [lastFetchAt, setLastFetchAt] = (0, react.useState)(0);
3714
+ const mountedRef = (0, react.useRef)(true);
3715
+ const toastSeq = (0, react.useRef)(0);
3716
+ const prevBody = (0, react.useRef)(null);
3717
+ /** toast 自动消失计时器:dismiss/挤掉/卸载时都要清,防止打已卸载组件 */
3718
+ const toastTimers = (0, react.useRef)(/* @__PURE__ */ new Map());
3719
+ const tRef = (0, react.useRef)(t);
3720
+ tRef.current = t;
3721
+ const dismissToast = (0, react.useCallback)((id) => {
3722
+ const timer = toastTimers.current.get(id);
3723
+ if (timer) {
3724
+ clearTimeout(timer);
3725
+ toastTimers.current.delete(id);
3726
+ }
3727
+ if (mountedRef.current) setToasts((cur) => cur.filter((x) => x.id !== id));
3728
+ }, []);
3729
+ /** 追加 toast:封顶 TOAST_MAX(挤掉最旧)并登记自动消失计时器 */
3730
+ const pushToasts = (0, react.useCallback)((add) => {
3731
+ if (add.length === 0) return;
3732
+ setToasts((cur) => {
3733
+ const next = [...cur, ...add].slice(-3);
3734
+ for (const x of cur) if (!next.includes(x)) {
3735
+ const timer = toastTimers.current.get(x.id);
3736
+ if (timer) {
3737
+ clearTimeout(timer);
3738
+ toastTimers.current.delete(x.id);
3739
+ }
3740
+ }
3741
+ return next;
3742
+ });
3743
+ for (const toast of add) {
3744
+ const timer = setTimeout(() => {
3745
+ toastTimers.current.delete(toast.id);
3746
+ if (mountedRef.current) setToasts((cur) => cur.filter((x) => x.id !== toast.id));
3747
+ }, 12e3);
3748
+ toastTimers.current.set(toast.id, timer);
3749
+ }
3750
+ }, []);
3751
+ (0, react.useEffect)(() => () => {
3752
+ for (const timer of toastTimers.current.values()) clearTimeout(timer);
3753
+ toastTimers.current.clear();
3754
+ }, []);
3755
+ const refresh = (0, react.useCallback)(async (force = false, hostId) => {
3756
+ setRefreshing(true);
3757
+ const qs = force ? hostId ? `?force=1&host=${encodeURIComponent(hostId)}` : "?force=1" : "";
3758
+ try {
3759
+ const res = await fetch(`/dash/snapshots${qs}`, {
3760
+ headers: { accept: "application/json" },
3761
+ signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(3e4) : void 0
3762
+ });
3763
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
3764
+ const body = mergeDegradedFrame(await res.json(), prevBody.current);
3765
+ prevBody.current = body;
3766
+ if (mountedRef.current) {
3767
+ setData(body);
3768
+ setHostError(false);
3769
+ setLastFetchAt(Date.now());
3770
+ }
3771
+ const staleMs = (body.staleMinutes ?? 10) * 6e4;
3772
+ const now = Date.now();
3773
+ const nextToasts = [];
3774
+ const liveKeys = /* @__PURE__ */ new Set();
3775
+ const checkStale = (key, hostId2, hostName, gpuIndex, log) => {
3776
+ liveKeys.add(key);
3777
+ const prev = staleLedger.get(key);
3778
+ if (prev && log.mtimeMs === prev.mtimeMs && log.size === prev.size) {
3779
+ if (!prev.notified && now - log.mtimeMs >= staleMs) {
3780
+ prev.notified = true;
3781
+ nextToasts.push({
3782
+ id: ++toastSeq.current,
3783
+ hostId: hostId2,
3784
+ hostName,
3785
+ minutes: Math.max(1, Math.round((now - log.mtimeMs) / 6e4)),
3786
+ gpuIndex
3787
+ });
3788
+ }
3789
+ } else staleLedger.set(key, {
3790
+ mtimeMs: log.mtimeMs,
3791
+ size: log.size,
3792
+ notified: false
3793
+ });
3794
+ };
3795
+ for (const host of body.hosts) {
3796
+ const s = body.snapshots[host.id];
3797
+ if (!s || !s.ok) continue;
3798
+ if (s.log && s.log.mtimeMs > 0) checkStale(`h:${host.id}`, host.id, host.name, void 0, s.log);
3799
+ for (const g of s.gpus ?? []) if (g.log && g.log.mtimeMs > 0) checkStale(`h:${host.id}:g${g.index}`, host.id, host.name, g.index, g.log);
3800
+ if (s.stallNotice) {
3801
+ const key = `${host.id}@${s.stallNotice.at}`;
3802
+ if (!notifiedNotices.has(key)) {
3803
+ notifiedNotices.add(key);
3804
+ if (notifiedNotices.size > 40) for (const k of [...notifiedNotices.keys()].slice(0, 20)) notifiedNotices.delete(k);
3805
+ nextToasts.push({
3806
+ id: ++toastSeq.current,
3807
+ hostId: host.id,
3808
+ hostName: host.name,
3809
+ minutes: s.stallNotice.minutes
3810
+ });
3811
+ }
3812
+ }
3813
+ }
3814
+ for (const key of [...staleLedger.keys()]) if (!liveKeys.has(key)) staleLedger.delete(key);
3815
+ if (nextToasts.length) pushToasts(nextToasts);
3816
+ const status = {
3817
+ hosts: body.hosts.length,
3818
+ offline: 0,
3819
+ hot: 0,
3820
+ stalled: 0
3821
+ };
3822
+ for (const host of body.hosts) {
3823
+ const s = body.snapshots[host.id];
3824
+ if (!s || !s.ok) {
3825
+ status.offline++;
3826
+ continue;
3827
+ }
3828
+ for (const g of s.gpus ?? []) {
3829
+ if (g.tempC >= 82 || g.utilPercent >= 95) status.hot++;
3830
+ if (g.log && g.log.mtimeMs > 0 && now - g.log.mtimeMs >= staleMs) status.stalled++;
3831
+ }
3832
+ }
3833
+ statusBus.set(status);
3834
+ } catch {
3835
+ setHostError(true);
3836
+ } finally {
3837
+ if (mountedRef.current) setRefreshing(false);
3838
+ }
3839
+ }, []);
3840
+ (0, react.useEffect)(() => {
3841
+ mountedRef.current = true;
3842
+ const sinceRef = { current: 0 };
3843
+ let stopped = false;
3844
+ let looping = false;
3845
+ let failures = 0;
3846
+ let inFlight = null;
3847
+ let wake = null;
3848
+ const onEvents = (events) => {
3849
+ for (const msg of events) {
3850
+ if (!mountedRef.current) continue;
3851
+ if (msg?.type === "stall") pushToasts([{
3852
+ id: ++toastSeq.current,
3853
+ hostId: msg.hostId,
3854
+ hostName: msg.hostName,
3855
+ minutes: msg.minutes ?? 10
3856
+ }]);
3857
+ else if (msg?.type === "host-status" && msg.level !== "ok") {
3858
+ const key = msg.level === "offline" ? "panel.sse.offline" : "panel.sse.hot";
3859
+ pushToasts([{
3860
+ id: ++toastSeq.current,
3861
+ hostId: msg.hostId,
3862
+ hostName: msg.hostName,
3863
+ minutes: 0,
3864
+ text: `${msg.hostName}: ${tRef.current(key)}`
3865
+ }]);
3866
+ }
3867
+ }
3868
+ };
3869
+ const sleep = (ms) => new Promise((resolve) => {
3870
+ const id = setTimeout(() => {
3871
+ wake = null;
3872
+ resolve();
3873
+ }, ms);
3874
+ wake = () => {
3875
+ clearTimeout(id);
3876
+ wake = null;
3877
+ resolve();
3878
+ };
3879
+ });
3880
+ const runLoop = async () => {
3881
+ if (looping) return;
3882
+ looping = true;
3883
+ try {
3884
+ while (!stopped && document.visibilityState !== "hidden") {
3885
+ const ac = new AbortController();
3886
+ inFlight = ac;
3887
+ const hardStop = setTimeout(() => ac.abort(), 3e4);
3888
+ try {
3889
+ const res = await fetch(`/dash/events?since=${sinceRef.current}`, {
3890
+ headers: { accept: "application/json" },
3891
+ signal: ac.signal
3892
+ });
3893
+ if (!res.ok) throw new Error("http");
3894
+ const body = await res.json();
3895
+ sinceRef.current = body.last ?? sinceRef.current;
3896
+ onEvents(body.events ?? []);
3897
+ failures = 0;
3898
+ } catch {
3899
+ if (stopped) break;
3900
+ if (document.visibilityState === "hidden") break;
3901
+ failures++;
3902
+ await sleep(Math.min(3e4, 3e3 * 2 ** (failures - 1)) + Math.floor(Math.random() * 1e3));
3903
+ } finally {
3904
+ clearTimeout(hardStop);
3905
+ if (inFlight === ac) inFlight = null;
3906
+ }
3907
+ }
3908
+ } finally {
3909
+ looping = false;
3910
+ }
3911
+ };
3912
+ const onVisibility = () => {
3913
+ if (stopped) return;
3914
+ if (document.visibilityState === "hidden") {
3915
+ inFlight?.abort();
3916
+ wake?.();
3917
+ } else {
3918
+ wake?.();
3919
+ runLoop();
3920
+ }
3921
+ };
3922
+ document.addEventListener("visibilitychange", onVisibility);
3923
+ runLoop();
3924
+ return () => {
3925
+ stopped = true;
3926
+ mountedRef.current = false;
3927
+ inFlight?.abort();
3928
+ wake?.();
3929
+ document.removeEventListener("visibilitychange", onVisibility);
3930
+ };
3931
+ }, []);
3932
+ return {
3933
+ data,
3934
+ refreshing,
3935
+ refresh,
3936
+ toasts,
3937
+ hostError,
3938
+ lastFetchAt,
3939
+ dismissToast
3940
+ };
3941
+ }
3942
+ function useAnomalySummary(t) {
3943
+ const status = useDashStatus();
3944
+ const anomalies = status.offline + status.hot + status.stalled;
3945
+ return {
3946
+ title: anomalies > 0 ? [
3947
+ status.offline ? t("nav.sumOffline", { count: status.offline }) : "",
3948
+ status.hot ? t("nav.sumHot", { count: status.hot }) : "",
3949
+ status.stalled ? t("nav.sumStalled", { count: status.stalled }) : ""
3950
+ ].filter(Boolean).join(" · ") : status.hosts > 0 ? t("nav.sumOk") : void 0,
3951
+ anomalies,
3952
+ hosts: status.hosts
3953
+ };
3954
+ }
3955
+ function StatusGlyph({ t }) {
3956
+ const { anomalies, hosts } = useAnomalySummary(t);
3957
+ const color = anomalies > 0 ? "var(--dsw-alias-state-warn-primary, #f5a524)" : "var(--dsw-alias-state-success-primary, #30a46c)";
3958
+ return /* @__PURE__ */ (0, react.default.createElement)("span", {
3959
+ "aria-hidden": true,
3960
+ style: {
3961
+ position: "relative",
3962
+ display: "inline-flex"
3963
+ },
3964
+ children: [/* @__PURE__ */ (0, react.default.createElement)(Icon, {
3965
+ d: Icons.monitor,
3966
+ size: 16
3967
+ }), hosts > 0 && /* @__PURE__ */ (0, react.default.createElement)("span", { style: {
3968
+ position: "absolute",
3969
+ top: -2,
3970
+ right: -5,
3971
+ width: 8,
3972
+ height: 8,
3973
+ borderRadius: 8,
3974
+ background: color,
3975
+ border: "1.5px solid var(--dsw-alias-bg-base)",
3976
+ boxShadow: "var(--dsw-shadow-lv1, 0 1px 3px rgba(0,0,0,.2))",
3977
+ animation: anomalies > 0 ? "dsh-dash-pulse 2.4s ease-out infinite" : void 0
3978
+ } })]
3979
+ });
3980
+ }
3981
+ function Trigger({ t, wide }) {
3982
+ const mode = usePanelMode();
3983
+ const { title } = useAnomalySummary(t);
3984
+ const active = mode === "open";
3985
+ return /* @__PURE__ */ (0, react.default.createElement)("button", {
3986
+ type: "button",
3987
+ "data-dsh-plugin": "dsh-server-dashboard",
3988
+ "data-dsh-part": "sidebar-entry",
3989
+ onClick: () => panelBus.set(active ? "closed" : "open"),
3990
+ title: title ? `${t("nav.title")} — ${title}` : t("nav.title"),
3991
+ style: {
3992
+ display: "inline-flex",
3993
+ alignItems: "center",
3994
+ gap: 6,
3995
+ height: 28,
3996
+ background: active ? "color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 14%, transparent)" : "none",
3997
+ border: 0,
3998
+ cursor: "pointer",
3999
+ color: active ? "var(--dsw-alias-state-business-primary, #4d6bfe)" : "var(--dsw-alias-label-secondary)",
4000
+ padding: "0 7px",
4001
+ borderRadius: 8,
4002
+ fontSize: 12,
4003
+ width: wide ? void 0 : "auto",
4004
+ boxShadow: active ? "inset 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 35%, transparent)" : "none",
4005
+ transition: "background .12s ease, color .12s ease"
4006
+ },
4007
+ onMouseEnter: (e) => {
4008
+ if (!active) e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover)";
4009
+ },
4010
+ onMouseLeave: (e) => {
4011
+ if (!active) e.currentTarget.style.background = "none";
4012
+ },
4013
+ children: [/* @__PURE__ */ (0, react.default.createElement)(StatusGlyph, { t }), wide && /* @__PURE__ */ (0, react.default.createElement)("span", { children: t("nav.label") })]
4014
+ });
4015
+ }
4016
+ const DRAWER_MIN = 300;
4017
+ const DRAWER_MAX = 520;
4018
+ const DRAWER_DEFAULT = 360;
4019
+ /** collapsed-rail width, published so sibling panels can dock around us */
4020
+ const RAIL_W = 32;
4021
+ function publishDock(id, fp) {
4022
+ const w = window;
4023
+ w.__dshDock = {
4024
+ ...w.__dshDock ?? {},
4025
+ [id]: fp
4026
+ };
4027
+ window.dispatchEvent(new CustomEvent("dsh-dock-change"));
4028
+ }
4029
+ function outerFootprint(selfId) {
4030
+ const w = window;
4031
+ let open = false;
4032
+ let width = 0;
4033
+ for (const [id, fp] of Object.entries(w.__dshDock ?? {})) {
4034
+ if (id === selfId) continue;
4035
+ if (fp?.open) {
4036
+ open = true;
4037
+ width += fp?.width ?? 0;
4038
+ }
4039
+ }
4040
+ return {
4041
+ open,
4042
+ width
4043
+ };
4044
+ }
4045
+ /** 官方右侧栏(0.1.5)是布局内占位而非 overlay:抽屉应让位于它,不能盖在它上面。
4046
+ * 测法:从聊天输入框向上找第一棵「右缘离窗口右沿 ≥16px 且高度过半屏」的列,
4047
+ * 其右缘缺口即右侧栏宽度;右栏关闭或浮出成窗(浮窗不占布局位)时列级祖先都贴满
4048
+ * 宽度 → 返回 0。16px 阈值同时排除窗口滚动条宽度。 */
4049
+ function officialRightInset() {
4050
+ try {
4051
+ const input = document.querySelector("textarea, [contenteditable=\"true\"], [role=\"textbox\"]");
4052
+ if (!input) return 0;
4053
+ let el = input.parentElement;
4054
+ const vw = window.innerWidth;
4055
+ while (el && el !== document.body) {
4056
+ const r = el.getBoundingClientRect();
4057
+ if (r.height > window.innerHeight * .5 && vw - r.right >= 16) return Math.round(vw - r.right);
4058
+ el = el.parentElement;
4059
+ }
4060
+ return 0;
4061
+ } catch {
4062
+ return 0;
4063
+ }
4064
+ }
4065
+ /** clamp with a RELATIVE cap: the drawer must never swallow a narrow window —
4066
+ * at most 60% of the viewport, and never past the absolute max. 窄窗(<500px)
4067
+ * 时 60% 上限优先于绝对最小宽,抽屉不会吞掉整个窗口 */
4068
+ function clampW(w) {
4069
+ const relative = typeof window === "undefined" ? DRAWER_MAX : Math.floor(window.innerWidth * .6);
4070
+ const min = Math.min(DRAWER_MIN, relative);
4071
+ return Math.max(min, Math.min(Math.max(min, Math.min(DRAWER_MAX, relative)), w));
4072
+ }
4073
+ function Drawer({ t }) {
4074
+ const mode = usePanelMode();
4075
+ const { data, refreshing, refresh, toasts, hostError, lastFetchAt, dismissToast } = useDashboard(t);
4076
+ const desiredW = react.default.useRef((() => {
4077
+ const saved = Number(localStorage.getItem("dsh-dash-drawer-w"));
4078
+ return Number.isFinite(saved) && saved > 0 ? saved : DRAWER_DEFAULT;
4079
+ })());
4080
+ const [width, setWidth] = react.default.useState(() => clampW(desiredW.current));
4081
+ const [dragging, setDragging] = react.default.useState(false);
4082
+ const [handleHover, setHandleHover] = react.default.useState(false);
4083
+ const [dashFull, setDashFull] = react.default.useState(false);
4084
+ const dragOrigin = react.default.useRef({
4085
+ x: 0,
4086
+ w: DRAWER_DEFAULT
4087
+ });
4088
+ react.default.useEffect(() => {
4089
+ if (mode === "open") refresh();
4090
+ }, [mode, refresh]);
4091
+ react.default.useEffect(() => {
4092
+ if (mode !== "open" || !data?.refreshIntervalS || data.refreshIntervalS < 1) return;
4093
+ const id = setInterval(() => {
4094
+ refresh();
4095
+ }, data.refreshIntervalS * 1e3);
4096
+ return () => clearInterval(id);
4097
+ }, [
4098
+ mode,
4099
+ data?.refreshIntervalS,
4100
+ refresh
4101
+ ]);
4102
+ const onHandleDown = react.default.useCallback((e) => {
4103
+ setDragging(true);
4104
+ dragOrigin.current = {
4105
+ x: e.clientX,
4106
+ w: width
4107
+ };
4108
+ e.currentTarget.setPointerCapture(e.pointerId);
4109
+ }, [width]);
4110
+ const onHandleMove = react.default.useCallback((e) => {
4111
+ if (!dragging) return;
4112
+ const next = clampW(dragOrigin.current.w + (dragOrigin.current.x - e.clientX));
4113
+ desiredW.current = next;
4114
+ setWidth(next);
4115
+ }, [dragging]);
4116
+ const onHandleUp = react.default.useCallback(() => {
4117
+ setDragging(false);
4118
+ try {
4119
+ localStorage.setItem("dsh-dash-drawer-w", String(desiredW.current));
4120
+ } catch {}
4121
+ }, []);
4122
+ react.default.useEffect(() => {
4123
+ const onResize = () => setWidth(clampW(desiredW.current));
4124
+ window.addEventListener("resize", onResize);
4125
+ return () => window.removeEventListener("resize", onResize);
4126
+ }, []);
4127
+ const at = useLatestAt(data);
4128
+ /** right offset = outer panel's (scholar's) current footprint */
4129
+ const [outerW, setOuterW] = react.default.useState(() => outerFootprint("server-dashboard").open ? outerFootprint("server-dashboard").width : 0);
4130
+ react.default.useEffect(() => {
4131
+ const read = () => {
4132
+ const fp = outerFootprint("server-dashboard");
4133
+ setOuterW(fp.open ? fp.width : 0);
4134
+ };
4135
+ read();
4136
+ window.addEventListener("dsh-dock-change", read);
4137
+ window.addEventListener("resize", read);
4138
+ return () => {
4139
+ window.removeEventListener("dsh-dock-change", read);
4140
+ window.removeEventListener("resize", read);
4141
+ };
4142
+ }, []);
4143
+ const [officialInset, setOfficialInset] = react.default.useState(() => officialRightInset());
4144
+ react.default.useEffect(() => {
4145
+ const read = () => setOfficialInset((cur) => {
4146
+ const next = officialRightInset();
4147
+ return cur === next ? cur : next;
4148
+ });
4149
+ const timer = setInterval(read, 1e3);
4150
+ return () => clearInterval(timer);
4151
+ }, []);
4152
+ const squeezed = !dashFull && window.innerWidth - outerW - officialInset < DRAWER_MIN;
4153
+ const selfW = mode === "open" ? width : mode === "rail" ? RAIL_W : 0;
4154
+ react.default.useEffect(() => {
4155
+ if (dragging) return;
4156
+ publishDock("server-dashboard", {
4157
+ open: mode !== "closed",
4158
+ width: selfW
4159
+ });
4160
+ }, [
4161
+ mode,
4162
+ selfW,
4163
+ dragging
4164
+ ]);
4165
+ react.default.useEffect(() => () => publishDock("server-dashboard", {
4166
+ open: false,
4167
+ width: 0
4168
+ }), []);
4169
+ react.default.useEffect(() => {
4170
+ const onResize = () => {
4171
+ const avail = window.innerWidth - outerW - officialInset - 280;
4172
+ setWidth(clampW(Math.min(desiredW.current, Math.max(avail, 0))));
4173
+ };
4174
+ onResize();
4175
+ window.addEventListener("resize", onResize);
4176
+ return () => window.removeEventListener("resize", onResize);
4177
+ }, [outerW, officialInset]);
4178
+ return /* @__PURE__ */ (0, react.default.createElement)(Fragment, { children: [mode !== "closed" && /* @__PURE__ */ (0, react.default.createElement)("div", {
4179
+ "data-dsh-plugin": "dsh-server-dashboard",
4180
+ "data-dsh-surface": "drawer",
4181
+ style: {
4182
+ position: "absolute",
4183
+ inset: 0,
4184
+ pointerEvents: "none",
4185
+ zIndex: 70
4186
+ },
4187
+ children: [mode === "rail" && /* @__PURE__ */ (0, react.default.createElement)("div", {
4188
+ "data-dsh-part": "drawer-rail",
4189
+ style: {
4190
+ position: "absolute",
4191
+ top: "var(--dsh-desktop-titlebar-inset, 0px)",
4192
+ ...squeezed ? {
4193
+ left: 0,
4194
+ right: "auto"
4195
+ } : { right: outerW + officialInset },
4196
+ bottom: 0,
4197
+ width: 32,
4198
+ pointerEvents: "auto",
4199
+ display: "flex",
4200
+ flexDirection: "column",
4201
+ alignItems: "center",
4202
+ gap: 2,
4203
+ padding: "10px 0",
4204
+ borderLeft: "1px solid var(--dsw-alias-border-l2)",
4205
+ background: "var(--dsw-alias-bg-base)",
4206
+ backdropFilter: "blur(16px) saturate(1.08)"
4207
+ },
4208
+ children: [
4209
+ /* @__PURE__ */ (0, react.default.createElement)(IconButton, {
4210
+ label: t("nav.title"),
4211
+ onClick: () => panelBus.set("open"),
4212
+ icon: /* @__PURE__ */ (0, react.default.createElement)(StatusGlyph, { t })
4213
+ }),
4214
+ /* @__PURE__ */ (0, react.default.createElement)("span", {
4215
+ style: {
4216
+ writingMode: "vertical-rl",
4217
+ fontSize: 10.5,
4218
+ color: "var(--dsw-alias-label-caption)",
4219
+ userSelect: "none",
4220
+ letterSpacing: 3,
4221
+ padding: "6px 0"
4222
+ },
4223
+ children: t("nav.title")
4224
+ }),
4225
+ /* @__PURE__ */ (0, react.default.createElement)("span", { style: { flex: 1 } }),
4226
+ /* @__PURE__ */ (0, react.default.createElement)(IconButton, {
4227
+ label: t("drawer.close"),
4228
+ onClick: () => panelBus.set("closed"),
4229
+ size: 22,
4230
+ icon: /* @__PURE__ */ (0, react.default.createElement)(Icon, {
4231
+ d: Icons.close,
4232
+ size: 13
4233
+ })
4234
+ })
4235
+ ]
4236
+ }), mode === "open" && /* @__PURE__ */ (0, react.default.createElement)("div", {
4237
+ "data-dsh-part": "drawer-panel",
4238
+ style: {
4239
+ position: "absolute",
4240
+ top: "var(--dsh-desktop-titlebar-inset, 0px)",
4241
+ ...squeezed ? {
4242
+ left: 0,
4243
+ right: "auto"
4244
+ } : {
4245
+ right: dashFull ? 0 : outerW + officialInset,
4246
+ left: dashFull ? 0 : "auto"
4247
+ },
4248
+ bottom: 0,
4249
+ width: dashFull ? "100vw" : width,
4250
+ pointerEvents: "auto",
4251
+ display: "flex",
4252
+ flexDirection: "column",
4253
+ background: "var(--dsw-alias-bg-base)",
4254
+ backdropFilter: "blur(16px) saturate(1.08)",
4255
+ borderLeft: "1px solid var(--dsw-alias-border-l2)",
4256
+ boxShadow: "var(--dsw-shadow-lv2, 0 8px 24px rgba(0,0,0,.25))",
4257
+ color: "var(--dsw-alias-label-primary)"
4258
+ },
4259
+ children: [
4260
+ /* @__PURE__ */ (0, react.default.createElement)("div", {
4261
+ onPointerDown: onHandleDown,
4262
+ onPointerMove: onHandleMove,
4263
+ onPointerUp: onHandleUp,
4264
+ onMouseEnter: () => setHandleHover(true),
4265
+ onMouseLeave: () => setHandleHover(false),
4266
+ title: t("drawer.resize"),
4267
+ style: {
4268
+ position: "absolute",
4269
+ left: -4,
4270
+ top: 0,
4271
+ bottom: 0,
4272
+ width: 9,
4273
+ cursor: "col-resize",
4274
+ zIndex: 2,
4275
+ userSelect: "none",
4276
+ touchAction: "none",
4277
+ display: "flex",
4278
+ justifyContent: "center"
4279
+ },
4280
+ children: /* @__PURE__ */ (0, react.default.createElement)("span", { style: {
4281
+ width: 3,
4282
+ borderRadius: 2,
4283
+ margin: "10px 0",
4284
+ flex: "none",
4285
+ background: "var(--dsw-alias-border-l3, var(--dsw-alias-label-caption))",
4286
+ opacity: dragging || handleHover ? 1 : 0,
4287
+ transition: "opacity .15s"
4288
+ } })
4289
+ }),
4290
+ /* @__PURE__ */ (0, react.default.createElement)("div", {
4291
+ style: {
4292
+ display: "flex",
4293
+ alignItems: "center",
4294
+ gap: 2,
4295
+ padding: "8px 6px 8px 12px",
4296
+ borderBottom: "1px solid var(--dsw-alias-border-l2)",
4297
+ flex: "none"
4298
+ },
4299
+ children: [
4300
+ /* @__PURE__ */ (0, react.default.createElement)("span", {
4301
+ "aria-hidden": true,
4302
+ style: {
4303
+ display: "inline-flex",
4304
+ color: "var(--dsw-alias-label-secondary)"
4305
+ },
4306
+ children: /* @__PURE__ */ (0, react.default.createElement)(Icon, {
4307
+ d: Icons.monitor,
4308
+ size: 15
4309
+ })
4310
+ }),
4311
+ /* @__PURE__ */ (0, react.default.createElement)("span", {
4312
+ style: {
4313
+ fontWeight: 600,
4314
+ fontSize: 13,
4315
+ marginLeft: 6,
4316
+ overflow: "hidden",
4317
+ textOverflow: "ellipsis",
4318
+ whiteSpace: "nowrap"
4319
+ },
4320
+ children: t("nav.title")
4321
+ }),
4322
+ /* @__PURE__ */ (0, react.default.createElement)("span", { style: { flex: 1 } }),
4323
+ /* @__PURE__ */ (0, react.default.createElement)("span", {
4324
+ style: {
4325
+ fontSize: 10,
4326
+ color: "var(--dsw-alias-label-caption)",
4327
+ whiteSpace: "nowrap",
4328
+ marginRight: 4,
4329
+ display: "inline-flex",
4330
+ alignItems: "center",
4331
+ gap: 4
4332
+ },
4333
+ children: [
4334
+ hostError && /* @__PURE__ */ (0, react.default.createElement)("span", {
4335
+ title: t("panel.hostError"),
4336
+ style: { color: "var(--dsw-alias-state-warn-primary, #f5a524)" },
4337
+ children: "⚠"
4338
+ }),
4339
+ at ? t("panel.updatedAt", { time: relTime(at, t) }) : hostError ? t("panel.hostError") : "",
4340
+ !hostError && at > 0 && data?.refreshIntervalS && /* @__PURE__ */ (0, react.default.createElement)(RefreshCountdown, {
4341
+ lastAt: lastFetchAt || at,
4342
+ intervalS: data.refreshIntervalS,
4343
+ t
4344
+ })
4345
+ ]
4346
+ }),
4347
+ /* @__PURE__ */ (0, react.default.createElement)(IconButton, {
4348
+ label: t("panel.refresh"),
4349
+ onClick: () => void refresh(true),
4350
+ disabled: refreshing,
4351
+ icon: /* @__PURE__ */ (0, react.default.createElement)("span", {
4352
+ style: {
4353
+ display: "inline-flex",
4354
+ animation: refreshing ? "dsh-dash-spin 0.9s linear infinite" : void 0
4355
+ },
4356
+ children: /* @__PURE__ */ (0, react.default.createElement)(Icon, {
4357
+ d: Icons.refresh,
4358
+ size: 15
4359
+ })
4360
+ })
4361
+ }),
4362
+ /* @__PURE__ */ (0, react.default.createElement)(IconButton, {
4363
+ label: dashFull ? t("drawer.restore") : t("drawer.expand"),
4364
+ onClick: () => setDashFull(!dashFull),
4365
+ icon: /* @__PURE__ */ (0, react.default.createElement)(Icon, {
4366
+ d: Icons.expand,
4367
+ size: 15
4368
+ })
4369
+ }),
4370
+ rightbarAvailable() && /* @__PURE__ */ (0, react.default.createElement)(IconButton, {
4371
+ label: t("rightbar.follow"),
4372
+ onClick: () => openGpuInRightbar(),
4373
+ icon: /* @__PURE__ */ (0, react.default.createElement)(Icon, {
4374
+ d: Icons.expand,
4375
+ size: 15
4376
+ })
4377
+ }),
4378
+ /* @__PURE__ */ (0, react.default.createElement)(IconButton, {
4379
+ label: t("drawer.collapse"),
4380
+ onClick: () => {
4381
+ setDashFull(false);
4382
+ panelBus.set("rail");
4383
+ },
4384
+ icon: /* @__PURE__ */ (0, react.default.createElement)(Icon, {
4385
+ d: Icons.collapseRight,
4386
+ size: 15
4387
+ })
4388
+ }),
4389
+ /* @__PURE__ */ (0, react.default.createElement)(IconButton, {
4390
+ label: t("drawer.close"),
4391
+ onClick: () => {
4392
+ setDashFull(false);
4393
+ panelBus.set("closed");
4394
+ },
4395
+ icon: /* @__PURE__ */ (0, react.default.createElement)(Icon, {
4396
+ d: Icons.close,
4397
+ size: 15
4398
+ })
4399
+ })
4400
+ ]
4401
+ }),
4402
+ /* @__PURE__ */ (0, react.default.createElement)("div", {
4403
+ style: {
4404
+ flex: 1,
4405
+ minHeight: 0,
4406
+ display: "flex",
4407
+ flexDirection: "column"
4408
+ },
4409
+ children: [hostError && /* @__PURE__ */ (0, react.default.createElement)("div", {
4410
+ style: {
4411
+ flex: "none",
4412
+ margin: "8px 10px 0",
4413
+ padding: "7px 10px",
4414
+ borderRadius: 8,
4415
+ fontSize: 11.5,
4416
+ lineHeight: 1.5,
4417
+ display: "flex",
4418
+ alignItems: "center",
4419
+ gap: 8,
4420
+ background: "color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f5a524) 14%, transparent)",
4421
+ border: "1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f5a524) 45%, transparent)",
4422
+ color: "var(--dsw-alias-label-primary)"
4423
+ },
4424
+ children: [
4425
+ /* @__PURE__ */ (0, react.default.createElement)("span", {
4426
+ "aria-hidden": true,
4427
+ style: { color: "var(--dsw-alias-state-warn-primary, #f5a524)" },
4428
+ children: "⚠"
4429
+ }),
4430
+ /* @__PURE__ */ (0, react.default.createElement)("span", {
4431
+ style: { flex: 1 },
4432
+ children: t("panel.offlineBanner", { time: at ? new Date(at).toLocaleTimeString() : "—" })
4433
+ }),
4434
+ /* @__PURE__ */ (0, react.default.createElement)("button", {
4435
+ type: "button",
4436
+ onClick: () => void refresh(true),
4437
+ disabled: refreshing,
4438
+ style: {
4439
+ flex: "none",
4440
+ border: "1px solid var(--dsw-alias-state-warn-primary, #f5a524)",
4441
+ background: "none",
4442
+ color: "var(--dsw-alias-state-warn-primary, #f5a524)",
4443
+ borderRadius: 6,
4444
+ fontSize: 11,
4445
+ padding: "2px 10px",
4446
+ cursor: refreshing ? "default" : "pointer"
4447
+ },
4448
+ children: refreshing ? "…" : t("panel.retry")
4449
+ })
4450
+ ]
4451
+ }), /* @__PURE__ */ (0, react.default.createElement)("div", {
4452
+ style: {
4453
+ flex: 1,
4454
+ minHeight: 0
4455
+ },
4456
+ children: /* @__PURE__ */ (0, react.default.createElement)(DashboardPanel, {
4457
+ hosts: data?.hosts ?? [],
4458
+ snapshots: data?.snapshots ?? {},
4459
+ staleMinutes: data?.staleMinutes,
4460
+ t,
4461
+ onRetryHost: (id) => void refresh(true, id),
4462
+ onPinToggled: () => void refresh()
4463
+ })
4464
+ })]
4465
+ })
4466
+ ]
4467
+ })]
4468
+ }), toasts.length > 0 && (0, react_dom.default.createPortal)(/* @__PURE__ */ (0, react.default.createElement)("div", {
4469
+ style: toastWrap,
4470
+ children: toasts.map((toast) => /* @__PURE__ */ (0, react.default.createElement)("div", {
4471
+ role: "button",
4472
+ tabIndex: 0,
4473
+ title: t("toast.clickToOpen"),
4474
+ onClick: () => {
4475
+ focusBus.set({ hostId: toast.hostId });
4476
+ panelBus.set("open");
4477
+ dismissToast(toast.id);
4478
+ },
4479
+ onKeyDown: (e) => {
4480
+ if (e.key === "Enter" || e.key === " ") {
4481
+ e.preventDefault();
4482
+ focusBus.set({ hostId: toast.hostId });
4483
+ panelBus.set("open");
4484
+ dismissToast(toast.id);
4485
+ }
4486
+ },
4487
+ style: {
4488
+ ...toastCard,
4489
+ cursor: "pointer",
4490
+ pointerEvents: "auto"
4491
+ },
4492
+ children: toast.text ? toast.text : toast.gpuIndex !== void 0 ? `${toast.hostName} · GPU${toast.gpuIndex}: ${toast.text ?? t("log.stale", { minutes: toast.minutes })}` : `${toast.hostName}: ${toast.text ?? t("log.stale", { minutes: toast.minutes })}`
4493
+ }, toast.id))
4494
+ }), document.body)] });
4495
+ }
4496
+ /** latest snapshot timestamp across hosts (0 = none yet) */
4497
+ function useLatestAt(data) {
4498
+ const [at, setAt] = react.default.useState(0);
4499
+ react.default.useEffect(() => {
4500
+ if (!data) return;
4501
+ const times = Object.values(data.snapshots).map((s) => s.at).filter((v) => typeof v === "number");
4502
+ if (times.length) setAt(Math.max(...times));
4503
+ }, [data]);
4504
+ return at;
4505
+ }
4506
+ /** ticking "Ns 后刷新" caption — reassures the user the panel is live */
4507
+ function RefreshCountdown({ lastAt, intervalS, t }) {
4508
+ const [, tick] = react.default.useState(0);
4509
+ react.default.useEffect(() => {
4510
+ let id = setInterval(() => tick((v) => v + 1), 1e3);
4511
+ const onVisibility = () => {
4512
+ clearInterval(id);
4513
+ if (document.visibilityState !== "hidden") id = setInterval(() => tick((v) => v + 1), 1e3);
4514
+ };
4515
+ document.addEventListener("visibilitychange", onVisibility);
4516
+ return () => {
4517
+ clearInterval(id);
4518
+ document.removeEventListener("visibilitychange", onVisibility);
4519
+ };
4520
+ }, []);
4521
+ if (!lastAt) return null;
4522
+ const left = Math.max(0, Math.ceil((lastAt + intervalS * 1e3 - Date.now()) / 1e3));
4523
+ return /* @__PURE__ */ (0, react.default.createElement)("span", {
4524
+ style: { color: "var(--dsw-alias-label-dimmed, var(--dsw-alias-label-caption))" },
4525
+ children: [" · ", t("panel.nextRefresh", { s: left })]
4526
+ });
4527
+ }
4528
+ const toastWrap = {
4529
+ position: "fixed",
4530
+ top: 16,
4531
+ left: "50%",
4532
+ transform: "translateX(-50%)",
4533
+ zIndex: 2147483e3,
4534
+ display: "flex",
4535
+ flexDirection: "column",
4536
+ gap: 8,
4537
+ alignItems: "center",
4538
+ pointerEvents: "none"
4539
+ };
4540
+ const toastCard = {
4541
+ background: "var(--dsw-alias-bg-layer-2, #2a2a2a)",
4542
+ color: "var(--dsw-alias-label-primary)",
4543
+ border: "1px solid var(--dsw-alias-state-warn-primary, #f5a524)",
4544
+ borderRadius: 10,
4545
+ padding: "9px 14px",
4546
+ fontSize: 12,
4547
+ lineHeight: 1.5,
4548
+ boxShadow: "var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.3))",
4549
+ maxWidth: 420
4550
+ };
4551
+ /** Client plugin entry: register locale dictionaries, the sidebar toggle, the right drawer, and the settings page. */
4552
+ function apply(ctx) {
4553
+ const g = globalThis;
4554
+ if (g.__dshDashApplied) return;
4555
+ g.__dshDashApplied = true;
4556
+ ctx.effect(() => () => {
4557
+ g.__dshDashApplied = false;
4558
+ }, "server-dashboard: apply guard");
4559
+ ctx.effect(() => {
4560
+ const onFocus = (e) => {
4561
+ const hostId = e.detail?.hostId;
4562
+ panelBus.set("open");
4563
+ if (hostId) focusBus.set({ hostId });
4564
+ };
4565
+ window.addEventListener("dsh-statusbar-focus", onFocus);
4566
+ return () => window.removeEventListener("dsh-statusbar-focus", onFocus);
4567
+ }, "server-dashboard: statusbar focus link");
4568
+ if (typeof document !== "undefined" && !document.querySelector("style[data-plugin=dsh-server-dashboard]")) {
4569
+ const tag = document.createElement("style");
4570
+ tag.dataset.plugin = "dsh-server-dashboard";
4571
+ tag.textContent = "@keyframes dsh-dash-pulse{0%,100%{box-shadow:0 0 0 0 rgba(245,158,11,.45)}50%{box-shadow:0 0 0 5px rgba(245,158,11,0)}}@keyframes dsh-dash-spin{to{transform:rotate(360deg)}}@keyframes dsh-dash-flash{0%,100%{box-shadow:none}30%{box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f5a524) 55%, transparent)}}@keyframes dsh-dash-numflash{0%{background-color:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f5a524) 32%, transparent)}100%{background-color:transparent}}";
4572
+ document.head.appendChild(tag);
4573
+ }
4574
+ ctx.effect(() => ctx.locale.register(NS, {
4575
+ zh,
4576
+ en
4577
+ }), "server-dashboard: dictionaries");
4578
+ registerDashboardRightbar(ctx);
4579
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
4580
+ name: "sidebar.footer.action",
4581
+ id: "server-dashboard",
4582
+ order: 10,
4583
+ locale: NS
4584
+ }, Trigger));
4585
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register({
4586
+ name: "shell.overlay",
4587
+ id: "server-dashboard-drawer",
4588
+ order: 100,
4589
+ locale: NS
4590
+ }, Drawer));
4591
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
4592
+ name: "settings.section",
4593
+ id: "server-dashboard",
4594
+ order: 100,
4595
+ label: () => ctx.locale.bind(NS)("settings.nav"),
4596
+ locale: NS
4597
+ }, ServerDashboardSettings));
4598
+ }
4599
+
4600
+ //#endregion
4601
+ exports.apply = apply;
4602
+ exports.inject = inject;
4603
+ exports.panelBus = panelBus;
4604
+ return module.exports;
4605
+ }
4606
+ });