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.
@@ -0,0 +1,878 @@
1
+ /* ---------- ssh2 plumbing ---------- */
2
+ import { createHash } from 'node:crypto';
3
+ import { createRequire } from 'node:module';
4
+ const nodeRequire = createRequire(import.meta.url);
5
+ function loadSsh2() {
6
+ return nodeRequire('ssh2');
7
+ }
8
+ const CONNECT_TIMEOUT_MS = 10_000;
9
+ const COMMAND_TIMEOUT_MS = 12_000;
10
+ /** pooled connections send keepalives so half-dead TCP states surface within ~45s */
11
+ const KEEPALIVE_INTERVAL_MS = 15_000;
12
+ /** an idle pooled connection is recycled instead of pinning a server slot */
13
+ const POOL_IDLE_MS = 10 * 60_000;
14
+ function connect(auth, keepalive = false) {
15
+ const { Client } = loadSsh2();
16
+ return new Promise((resolve, reject) => {
17
+ const conn = new Client();
18
+ const timer = setTimeout(() => {
19
+ conn.end();
20
+ reject(new Error(`SSH 连接超时 ${auth.host}:${auth.port}`));
21
+ }, CONNECT_TIMEOUT_MS);
22
+ conn.on('ready', () => {
23
+ clearTimeout(timer);
24
+ resolve(conn);
25
+ });
26
+ conn.on('error', (err) => {
27
+ clearTimeout(timer);
28
+ reject(err);
29
+ });
30
+ conn.connect({
31
+ host: auth.host,
32
+ port: auth.port,
33
+ username: auth.username,
34
+ privateKey: auth.privateKey,
35
+ password: auth.password,
36
+ readyTimeout: CONNECT_TIMEOUT_MS,
37
+ keepaliveInterval: keepalive ? KEEPALIVE_INTERVAL_MS : 0,
38
+ });
39
+ });
40
+ }
41
+ function authKeyOf(auth) {
42
+ // 凭据部分只入 sha1 摘要不入池键 —— 池键不得携带明文密码/私钥
43
+ const cred = createHash('sha1').update(`${auth.password ?? ''}|${auth.privateKey ?? ''}`).digest('hex').slice(0, 16);
44
+ return `${auth.username}@${auth.host}:${auth.port}|${cred}`;
45
+ }
46
+ async function acquireConn(runtime, state) {
47
+ if (state.disposed)
48
+ throw new Error('主机状态已释放,不再建立 SSH 连接');
49
+ const want = authKeyOf(runtime.auth);
50
+ const cur = state.ssh;
51
+ if (cur && !cur.dead && cur.authKey === want && Date.now() - cur.lastUsed < POOL_IDLE_MS) {
52
+ cur.lastUsed = Date.now();
53
+ return cur.conn;
54
+ }
55
+ // 并发窗口内的重复 acquire 共享同一次握手 —— 否则会建第二条连接并
56
+ // 泄漏刚被 end() 掉的旧连接;凭据不一致或共享握手失败时才另起连接
57
+ if (state.connecting) {
58
+ const ok = await state.connecting.then(() => true, () => false);
59
+ if (ok && state.ssh && !state.ssh.dead && state.ssh.authKey === want) {
60
+ state.ssh.lastUsed = Date.now();
61
+ return state.ssh.conn;
62
+ }
63
+ if (state.disposed)
64
+ throw new Error('主机状态已释放,不再建立 SSH 连接');
65
+ }
66
+ if (cur && !cur.dead) {
67
+ try {
68
+ cur.conn.end();
69
+ }
70
+ catch { /* already closing */ }
71
+ }
72
+ const connPromise = connect(runtime.auth, true);
73
+ state.connecting = connPromise;
74
+ try {
75
+ const conn = await connPromise;
76
+ if (state.disposed) {
77
+ // 等待期间宿主已释放 —— 不入池,立即关闭,避免卸载后漏一条 SSH
78
+ try {
79
+ conn.end();
80
+ }
81
+ catch { /* closing */ }
82
+ throw new Error('主机状态已释放,不再建立 SSH 连接');
83
+ }
84
+ const pooled = { conn, authKey: want, lastUsed: Date.now(), dead: false };
85
+ conn.on('close', () => { pooled.dead = true; });
86
+ conn.on('error', () => { pooled.dead = true; });
87
+ state.ssh = pooled;
88
+ return conn;
89
+ }
90
+ finally {
91
+ if (state.connecting === connPromise)
92
+ state.connecting = undefined;
93
+ }
94
+ }
95
+ /** close a host's pooled connection (host removed from config / plugin disposal);
96
+ * the disposed flag makes later acquireConn attempts (in-flight retries) fail
97
+ * fast instead of silently reconnecting after unload. */
98
+ export function disposeState(state) {
99
+ state.disposed = true;
100
+ if (state.ssh && !state.ssh.dead) {
101
+ try {
102
+ state.ssh.conn.end();
103
+ }
104
+ catch { /* already closing */ }
105
+ }
106
+ state.ssh = undefined;
107
+ }
108
+ function exec(conn, cmd) {
109
+ return new Promise((resolve, reject) => {
110
+ conn.exec(cmd, (err, stream) => {
111
+ if (err)
112
+ return reject(err);
113
+ let out = '';
114
+ let errOut = '';
115
+ const timer = setTimeout(() => {
116
+ // close 只发通道关闭请求,destroy 才真正断掉本地流 —— 半死连接上
117
+ // 只 close 会把挂起的读取一并留下
118
+ stream.close();
119
+ stream.destroy();
120
+ reject(new Error(`远程命令超时: ${cmd.slice(0, 60)}`));
121
+ }, COMMAND_TIMEOUT_MS);
122
+ stream.on('data', (d) => { out += d.toString('utf8'); });
123
+ stream.stderr.on('data', (d) => { errOut += d.toString('utf8'); });
124
+ stream.on('close', (code) => {
125
+ clearTimeout(timer);
126
+ if (code !== 0 && !out.trim())
127
+ reject(new Error(errOut.trim() || `命令退出码 ${code}`));
128
+ else
129
+ resolve(out);
130
+ });
131
+ });
132
+ });
133
+ }
134
+ /** SFTP tail read. The whole stat/open/read chain runs under the same 12s
135
+ * budget as exec — a half-dead connection would otherwise hang the
136
+ * single-flight poll forever (no callback ever fires). `onHang` fires on
137
+ * timeout so the caller can recycle the pooled connection immediately. */
138
+ function readTail(conn, path, maxBytes = 262_144, maxLines = 20, onHang) {
139
+ return new Promise((resolve, reject) => {
140
+ let settled = false;
141
+ let sftp = null;
142
+ const settle = (fn) => {
143
+ if (settled)
144
+ return;
145
+ settled = true;
146
+ clearTimeout(timer);
147
+ fn();
148
+ };
149
+ const timer = setTimeout(() => {
150
+ if (settled)
151
+ return;
152
+ settled = true;
153
+ try {
154
+ sftp?.end();
155
+ }
156
+ catch { /* half-dead session */ }
157
+ onHang?.(); // 超时 = 半死连接:立即标记回收,走既有重连路径
158
+ reject(new Error(`SFTP 读取超时: ${path}`));
159
+ }, COMMAND_TIMEOUT_MS);
160
+ conn.sftp((err, s) => {
161
+ if (err)
162
+ return settle(() => reject(err));
163
+ sftp = s;
164
+ s.stat(path, (statErr, stats) => {
165
+ if (statErr)
166
+ return settle(() => reject(statErr));
167
+ const size = stats.size;
168
+ const offset = Math.max(0, size - maxBytes);
169
+ s.open(path, 'r', (openErr, handle) => {
170
+ if (openErr)
171
+ return settle(() => reject(openErr));
172
+ const buf = Buffer.alloc(maxBytes);
173
+ s.read(handle, buf, 0, maxBytes, offset, (readErr, n) => {
174
+ s.close(handle, () => { });
175
+ if (readErr)
176
+ return settle(() => reject(readErr));
177
+ // short reads leave NUL padding — cut at the actual byte count
178
+ const text = buf.toString('utf8', 0, n).replace(/\0/g, '');
179
+ const lines = text.split('\n').filter((l) => l.length > 0);
180
+ // 从文件中部起读时首行大概率是被切断的半行(tqdm 长行会产出垃圾点),丢弃
181
+ if (offset > 0 && lines.length > 0)
182
+ lines.shift();
183
+ settle(() => resolve({ path, lines: lines.slice(-maxLines), size, mtimeMs: (stats.mtime ?? 0) * 1000 }));
184
+ });
185
+ });
186
+ });
187
+ });
188
+ });
189
+ }
190
+ /* ---------- command recipes (read-only) ---------- */
191
+ const CMD_GPUS = "nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw,uuid --format=csv,noheader,nounits 2>/dev/null";
192
+ const CMD_PROCS = "nvidia-smi --query-compute-apps=pid,gpu_uuid,used_memory --format=csv,noheader,nounits 2>/dev/null";
193
+ const CMD_CPU = "grep '^cpu ' /proc/stat";
194
+ const CMD_MEM = "free -m | awk '/^Mem:/{print $3, $2}'";
195
+ const CMD_DISK = "df -P -x tmpfs -x devtmpfs -x squashfs 2>/dev/null | awk 'NR>1 && $6 ~ /^\\// {print $6, $5}'";
196
+ const CMD_LOAD = 'cat /proc/loadavg';
197
+ /* ---------- parsers ---------- */
198
+ function parseGpus(text) {
199
+ return text.split('\n').map((l) => l.trim()).filter(Boolean).map((line) => {
200
+ const [index, name, util, memUsed, memTotal, temp, power, uuid] = line.split(',').map((s) => s.trim());
201
+ return {
202
+ index: Number(index),
203
+ name,
204
+ uuid,
205
+ utilPercent: Number(util) || 0,
206
+ memoryUsedMiB: Number(memUsed) || 0,
207
+ memoryTotalMiB: Number(memTotal) || 0,
208
+ tempC: Number(temp) || 0,
209
+ powerW: Number(power) || 0,
210
+ processes: [],
211
+ };
212
+ });
213
+ }
214
+ function parseProcs(text) {
215
+ const map = new Map();
216
+ for (const line of text.split('\n').map((l) => l.trim()).filter(Boolean)) {
217
+ const parts = line.split(',').map((s) => s.trim());
218
+ const p = Number(parts[0]);
219
+ if (!(p > 0))
220
+ continue;
221
+ if (parts.length >= 3)
222
+ map.set(p, { memMiB: Number(parts[2]) || 0, gpuUuid: parts[1] });
223
+ else
224
+ map.set(p, { memMiB: Number(parts[1]) || 0 });
225
+ }
226
+ return map;
227
+ }
228
+ /** cumulative /proc/stat counters (utilization needs a delta between two reads) */
229
+ function parseCpuParts(text) {
230
+ const parts = text.replace(/^cpu\s+/, '').trim().split(/\s+/).map(Number);
231
+ if (parts.length < 4 || parts.some((n) => !Number.isFinite(n)))
232
+ return null;
233
+ const idle = parts[3] + (parts[4] ?? 0);
234
+ const total = parts.reduce((a, b) => a + b, 0);
235
+ return total > 0 ? { idle, total } : null;
236
+ }
237
+ /**
238
+ * Current CPU utilization from cumulative counters. /proc/stat holds counters
239
+ * since boot — a single read yields the lifetime AVERAGE, not the load now.
240
+ * Deltas against the previous poll give the true window utilization; the first
241
+ * poll (no history yet) samples twice 400ms apart.
242
+ */
243
+ async function sampleCpu(conn, state) {
244
+ const read = async () => parseCpuParts(await exec(conn, CMD_CPU).catch(() => ''));
245
+ let a = state.cpuPrev ?? null;
246
+ if (!a) {
247
+ a = await read();
248
+ if (!a)
249
+ return 0;
250
+ await new Promise((r) => setTimeout(r, 400));
251
+ }
252
+ const b = await read();
253
+ if (!b)
254
+ return 0;
255
+ state.cpuPrev = b;
256
+ const dTotal = b.total - a.total;
257
+ const dIdle = b.idle - a.idle;
258
+ if (dTotal <= 0)
259
+ return 0;
260
+ return Math.max(0, Math.min(100, Math.round((1 - dIdle / dTotal) * 100)));
261
+ }
262
+ function parseLoad(text) {
263
+ const v = Number(text.trim().split(/\s+/)[0]);
264
+ return Number.isFinite(v) && v >= 0 ? Math.round(v * 100) / 100 : undefined;
265
+ }
266
+ function parseMem(text) {
267
+ const [used, total] = text.trim().split(/\s+/).map(Number);
268
+ return { usedMiB: used || 0, totalMiB: total || 0 };
269
+ }
270
+ function parseDisk(text) {
271
+ return text.split('\n').map((l) => l.trim()).filter(Boolean).map((line) => {
272
+ // 行形如 "<挂载点> <百分比>":按空白切分,最后一个 token 是百分比,
273
+ // 其余整体是挂载点 —— 挂载点含空格时 lastIndexOf 切分会错位
274
+ const parts = line.split(/\s+/);
275
+ const pct = parseInt(parts[parts.length - 1] ?? '', 10);
276
+ const mount = parts.slice(0, -1).join(' ');
277
+ return { mount, usedPercent: Number.isFinite(pct) ? pct : 0 };
278
+ });
279
+ }
280
+ /** 数值字面量源(支持前导 + 号与大写指数):+5 / 1.5E-5 / -0.25 */
281
+ const NUM_PATTERN = '[+-]?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?';
282
+ /** Extract `name=value` / `name: value` numeric pairs from a log line (tqdm-style). */
283
+ export function parseMetricLine(line) {
284
+ const out = [];
285
+ const seen = new Set();
286
+ const add = (name, v) => {
287
+ if (Number.isFinite(v) && !seen.has(name)) {
288
+ seen.add(name);
289
+ out.push({ name, v });
290
+ }
291
+ };
292
+ const re = new RegExp(`([A-Za-z][\\w./-]*)\\s*[:=]\\s*(${NUM_PATTERN})`, 'g');
293
+ let m;
294
+ while ((m = re.exec(line)) !== null)
295
+ add(m[1], Number(m[2]));
296
+ // flat JSON object lines: {"train_loss": 1.23, "epoch": 57, ...} (DEIM-style)
297
+ const jsonRe = new RegExp(`"([A-Za-z][\\w./-]*)"\\s*:\\s*(${NUM_PATTERN})`, 'g');
298
+ while ((m = jsonRe.exec(line)) !== null)
299
+ add(m[1], Number(m[2]));
300
+ // epoch with slash total, space-separated: "Epoch 12/100" (\b 挡住 Subepoch 之类的词内命中)
301
+ const epoch = line.match(/\b(?:epoch|Epoch)\s+(\d+)\s*\/\s*(\d+)/);
302
+ if (epoch)
303
+ add('epoch', Number(epoch[1]));
304
+ // bare leading "269/300" (YOLO data rows); 行首日期不算 epoch:
305
+ // 首个数字 ≥1000 且行含时间样式(d+:dd)或三段 d/d/d(如 2026/09/02)时跳过
306
+ const lead = line.match(/^(\d+)\s*\/\s*(\d+)/);
307
+ if (lead) {
308
+ const n = Number(lead[1]);
309
+ const looksLikeDate = n >= 1000
310
+ && (/\d+\s*:\s*\d+/.test(line) || /^\d+\s*\/\s*\d+\s*\/\s*\d+/.test(line));
311
+ if (!looksLikeDate)
312
+ add('epoch', n);
313
+ }
314
+ // tqdm progress bar: "12/100 [00:05<00:10, 0.05s/it]"
315
+ const bar = line.match(/(?:^|[\s|])(\d+)\s*\/\s*(\d+)\s*\[/);
316
+ if (bar)
317
+ add('progress', Number(bar[1]));
318
+ return out;
319
+ }
320
+ export function appendSeries(series, name, v, t, maxPoints = 200) {
321
+ let s = series.get(name);
322
+ if (!s) {
323
+ s = { name, points: [] };
324
+ series.set(name, s);
325
+ }
326
+ // skip consecutive identical values: \r-update logs re-expose the same
327
+ // segment on every poll; only actual changes extend the curve
328
+ if (s.points.length > 0 && s.points[s.points.length - 1].v === v)
329
+ return;
330
+ s.points.push({ t, v });
331
+ if (s.points.length > maxPoints)
332
+ s.points.splice(0, s.points.length - maxPoints);
333
+ }
334
+ /** names worth keeping when the series budget is tight (loss/lr/epoch/acc/map/...) */
335
+ const PRIORITY_METRIC = /(loss|lr|epoch|acc|map|f1|iou|mem)/i;
336
+ /**
337
+ * Keep the most valuable maxSeries series when the budget overflows. Rank by
338
+ * point count, then recency, then priority name. This is what lets live curves
339
+ * push out one-shot static values: a hyperparameter table printed at training
340
+ * start ("epochs: 300, fliplr: 0.5, iou: 0.7, …") creates a dozen frozen
341
+ * 1-point series that would otherwise jam the whole budget — several of those
342
+ * names even match the priority regex by accident ("fliplr" contains "lr").
343
+ */
344
+ function compactSeries(series, maxSeries) {
345
+ if (series.size <= maxSeries)
346
+ return;
347
+ const ranked = [...series.entries()].map(([key, s], idx) => {
348
+ const last = s.points[s.points.length - 1];
349
+ return {
350
+ key, idx,
351
+ count: s.points.length,
352
+ lastT: last ? last.t : -1,
353
+ priority: PRIORITY_METRIC.test(key) ? 1 : 0,
354
+ };
355
+ });
356
+ ranked.sort((a, b) => (b.count - a.count) || (b.lastT - a.lastT) || (b.priority - a.priority) || (a.idx - b.idx));
357
+ const keep = new Set(ranked.slice(0, maxSeries).map((r) => r.key));
358
+ for (const key of [...series.keys()]) {
359
+ if (!keep.has(key))
360
+ series.delete(key);
361
+ }
362
+ }
363
+ /** Append new lines of one log into its series (shared by host-level and per-GPU logs). */
364
+ export function ingestLogLines(state, tail, at, maxSeries = Infinity, maxPoints = 200) {
365
+ // parse only lines newer than the last parsed one (prevents duplicate points)
366
+ let start = 0;
367
+ // rotation detection: a smaller size (including truncate-to-0, which used to
368
+ // slip through and re-ingest the file once it grew back) OR an older mtime
369
+ // (rotated-in fresh file) means the cursor no longer maps to this content
370
+ const rotated = (state.lastSize !== undefined && tail.size < state.lastSize)
371
+ || (state.lastMtime !== undefined && tail.mtimeMs < state.lastMtime);
372
+ if (rotated) {
373
+ state.lastLine = undefined;
374
+ state.lastHeader = undefined;
375
+ }
376
+ else if (state.lastLine) {
377
+ const idx = tail.lines.lastIndexOf(state.lastLine);
378
+ if (idx >= 0)
379
+ start = idx + 1;
380
+ else {
381
+ // mid-write growth: the stored final line is now a PREFIX of a longer line
382
+ // (tqdm/\r writers keep appending) — re-ingest only that one line; the
383
+ // consecutive-value dedupe absorbs unchanged metrics
384
+ for (let i = tail.lines.length - 1; i >= 0; i--) {
385
+ if (tail.lines[i].startsWith(state.lastLine)) {
386
+ start = i;
387
+ break;
388
+ }
389
+ }
390
+ }
391
+ }
392
+ // \r-update logs (YOLO etc.) refresh one physical line: split into segments
393
+ const segments = [];
394
+ for (const line of tail.lines.slice(start))
395
+ segments.push(...line.split(/\r+/).map((s) => stripAnsi(s).trim()).filter(Boolean));
396
+ let lastHeader = state.lastHeader ?? '';
397
+ for (const seg of segments) {
398
+ const pairs = [...parseMetricLine(seg)];
399
+ if (lastHeader) {
400
+ const t = parseTablePair(lastHeader, seg);
401
+ if (t.length)
402
+ pairs.push(...t);
403
+ }
404
+ const isHeader = /^[A-Za-z_][\w]*(?:\s+[A-Za-z_][\w]*){2,}$/.test(seg) && !/\d/.test(seg);
405
+ const seen = new Set();
406
+ for (const { name, v } of pairs) {
407
+ if (seen.has(name))
408
+ continue;
409
+ seen.add(name);
410
+ appendSeries(state.series, name, v, at, maxPoints);
411
+ }
412
+ if (isHeader)
413
+ lastHeader = seg;
414
+ }
415
+ state.lastHeader = lastHeader || state.lastHeader;
416
+ // 更新游标:轮转后即使本轮没有可读行也要落盘新的 size/mtime,
417
+ // 否则截到 0 后涨回来的文件会被当成同一文件重吃
418
+ if (rotated || tail.lines.length > 0) {
419
+ state.lastLine = tail.lines.length > 0 ? tail.lines[tail.lines.length - 1] : undefined;
420
+ state.lastSize = tail.size;
421
+ state.lastMtime = tail.mtimeMs;
422
+ }
423
+ // budget compaction AFTER ingestion: creation-time rejection cannot tell a
424
+ // frozen 1-point table value from the first point of a live curve (they share
425
+ // the poll timestamp), but point count can
426
+ if (maxSeries !== Infinity)
427
+ compactSeries(state.series, maxSeries);
428
+ return [...state.series.values()];
429
+ }
430
+ /**
431
+ * Columnar table parser: align header-column spans with character positions in
432
+ * the data row (fixed-width / space-padded tables, e.g. YOLO training logs).
433
+ * Header names may be fused (dfl_lossrgb_evidence_box_loss) — each name span
434
+ * extracts the first number at the same character range of the data row.
435
+ */
436
+ export function parseTablePair(headerLine, dataLine) {
437
+ const h = headerLine;
438
+ const d = dataLine;
439
+ if (h.length < 8 || d.length < 8)
440
+ return [];
441
+ if (!/\d/.test(d))
442
+ return [];
443
+ if (Math.abs(h.length - d.length) / Math.max(h.length, 1) > 0.35)
444
+ return [];
445
+ const out = [];
446
+ const re = /[A-Za-z_][\w]*/g;
447
+ let m;
448
+ while ((m = re.exec(h)) !== null) {
449
+ // widen the extraction window past the name span so trailing digits survive
450
+ const start = m.index;
451
+ const span = d.slice(start, Math.min(start + m[0].length + 10, d.length));
452
+ const val = span.match(new RegExp(NUM_PATTERN));
453
+ if (!val)
454
+ continue;
455
+ out.push({ name: m[0].toLowerCase(), v: Number(val[0]) });
456
+ }
457
+ return out;
458
+ }
459
+ /** Strip ANSI escape sequences (progress bars / clear-line codes) from a log segment. */
460
+ export function stripAnsi(text) {
461
+ return text.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').replace(/[\u2500-\u25FF]+\s*/g, ' ');
462
+ }
463
+ /**
464
+ * Auto-discover training logs: for GPU/py processes, resolve the file behind
465
+ * /proc/<pid>/fd/{1,2} and scan the process cwd for recently modified logs.
466
+ * Processes owned by OTHER users yield cmdline only (no fd/cwd access) and are
467
+ * reported as unreadable-path candidates so the UI can explain why.
468
+ */
469
+ export async function discoverLogs(auth, maxProcs = 12) {
470
+ const conn = await connect(auth);
471
+ const out = [];
472
+ const seen = new Set();
473
+ try {
474
+ // running python/training processes + their tee/screen wrappers (log paths
475
+ // often live in `tee -a /path/x.log` rather than in python's own args)
476
+ const psText = await exec(conn, "ps -eo pid,user,args | grep -Ei 'python|torch|train|jupyter|tee -|screen' | grep -v grep | head -30");
477
+ const procs = [];
478
+ for (const line of psText.split('\n').map((l) => l.trim()).filter(Boolean)) {
479
+ const m = line.match(/^(\d+)\s+(\S+)\s+(.*)$/);
480
+ if (m)
481
+ procs.push({ pid: Number(m[1]), user: m[2], cmd: m[3].slice(0, 200) });
482
+ }
483
+ const add = (pid, user, cmd, path, source) => {
484
+ const key = `${path}`;
485
+ if (!path || path === '/dev/null' || path === 'pipe:' || seen.has(key))
486
+ return;
487
+ seen.add(key);
488
+ out.push({ pid, user, cmd, logPath: path, size: -1, mtimeMs: 0, source });
489
+ };
490
+ for (const p of procs.slice(0, maxProcs)) {
491
+ // 1) stdout/stderr redirects (same-user readable only)
492
+ for (const [fd, source] of [[1, 'stdout'], [2, 'stderr']]) {
493
+ try {
494
+ const link = (await exec(conn, `readlink /proc/${p.pid}/fd/${fd} 2>/dev/null`)).trim();
495
+ if (link.startsWith('/') && /\.(log|out|txt)$|nohup|output/i.test(link))
496
+ add(p.pid, p.user, p.cmd, link, source);
497
+ }
498
+ catch { /* fd unreadable (other user) — skip */ }
499
+ }
500
+ // 2) cwd scan for recent log files, 3 levels deep (logs often sit in subdirs)
501
+ try {
502
+ const cwd = (await exec(conn, `readlink /proc/${p.pid}/cwd 2>/dev/null`)).trim();
503
+ if (cwd.startsWith('/')) {
504
+ const ls = await exec(conn, `find "${cwd}" -maxdepth 3 -type f \\( -name '*.log' -o -name '*.out' -o -name 'nohup.out' \\) -mmin -1440 2>/dev/null | head -8`);
505
+ for (const full of ls.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 4)) {
506
+ add(p.pid, p.user, p.cmd, full, 'cwd');
507
+ }
508
+ }
509
+ }
510
+ catch { /* cwd unreadable — skip */ }
511
+ // 3) other-user processes: cmdline often contains a readable script/log hint
512
+ try {
513
+ const hint = p.cmd.match(/(\/[^\s]*(?:\.log|\.out|\.txt))/);
514
+ if (hint)
515
+ add(p.pid, p.user, p.cmd, hint[1], 'cwd');
516
+ }
517
+ catch { /* ignore */ }
518
+ }
519
+ // stat the candidates (only readable ones get real size/mtime)
520
+ for (const c of out) {
521
+ try {
522
+ const st = (await exec(conn, `stat -c '%s %Y' "${c.logPath}" 2>/dev/null`)).trim().split(/\s+/);
523
+ if (st.length === 2) {
524
+ c.size = Number(st[0]);
525
+ c.mtimeMs = Number(st[1]) * 1000;
526
+ }
527
+ }
528
+ catch { /* stat failed: other-user path */ }
529
+ }
530
+ }
531
+ finally {
532
+ conn.end();
533
+ }
534
+ return out;
535
+ }
536
+ /**
537
+ * Find the training log behind one compute process. Tries, in order:
538
+ * 1. open file descriptors pointing at a log-ish regular file
539
+ * (logger-opened logs show up here even when stdout is a pipe),
540
+ * 2. cmdline: a direct `*.log` arg or `--output-dir <dir>` → `<dir>/log.txt`,
541
+ * 3. unique recent `*.log` under the process cwd (only when unambiguous).
542
+ */
543
+ async function statOk(conn, path) {
544
+ try {
545
+ const out = (await exec(conn, `stat -c '%s %Y' "${path}" 2>/dev/null`)).trim();
546
+ const [size, mtime] = out.split(/\s+/).map(Number);
547
+ return Number.isFinite(size) && Number.isFinite(mtime);
548
+ }
549
+ catch {
550
+ return false;
551
+ }
552
+ }
553
+ export async function discoverGpuLog(conn, pid) {
554
+ // 1) open fds
555
+ const fdOut = await exec(conn, `for f in /proc/${pid}/fd/*; do readlink "$f" 2>/dev/null; done`);
556
+ const fdCandidates = [];
557
+ for (const raw of fdOut.split('\n')) {
558
+ const p = raw.trim();
559
+ if (!p.startsWith('/'))
560
+ continue;
561
+ if (/\(deleted\)$/.test(p))
562
+ continue;
563
+ if (/^\/dev\/(null|pts|tty)/.test(p))
564
+ continue;
565
+ if (/(pipe|socket):/.test(p))
566
+ continue;
567
+ if (/\.(log|out|txt)$/i.test(p) || /nohup/i.test(p))
568
+ fdCandidates.push(p);
569
+ }
570
+ // prefer the newest / largest regular log among the open fds
571
+ let bestFd;
572
+ for (const p of fdCandidates.slice(0, 6)) {
573
+ try {
574
+ const st = (await exec(conn, `stat -c '%s %Y' "${p}" 2>/dev/null`)).trim().split(/\s+/);
575
+ if (st.length !== 2)
576
+ continue;
577
+ const size = Number(st[0]);
578
+ const mtime = Number(st[1]);
579
+ if (!bestFd || mtime > bestFd.mtime || (mtime === bestFd.mtime && size > bestFd.size))
580
+ bestFd = { path: p, size, mtime };
581
+ }
582
+ catch { /* stat failed */ }
583
+ }
584
+ // 2) cmdline: direct log arg, then --output-dir/<log.txt>
585
+ try {
586
+ const cmd = await exec(conn, `tr '\\0' '\\n' < /proc/${pid}/cmdline 2>/dev/null`);
587
+ const args = cmd.split('\n').map((s) => s.trim()).filter(Boolean);
588
+ for (const a of args) {
589
+ const m = a.match(/^(\/\S+\.(?:log|out|txt))$/i);
590
+ if (m)
591
+ return m[1];
592
+ }
593
+ let outDir;
594
+ for (let i = 0; i < args.length; i++) {
595
+ if (args[i] === '--output-dir' || args[i] === '-o')
596
+ outDir = args[i + 1];
597
+ else if (args[i].startsWith('--output-dir='))
598
+ outDir = args[i].slice('--output-dir='.length);
599
+ }
600
+ if (outDir?.startsWith('/')) {
601
+ const candidate = `${outDir}/log.txt`;
602
+ if (await statOk(conn, candidate))
603
+ return candidate;
604
+ }
605
+ }
606
+ catch { /* cmdline unreadable */ }
607
+ // 3) open fd winner
608
+ if (bestFd)
609
+ return bestFd.path;
610
+ // 4) unique recent log under cwd
611
+ try {
612
+ const cwd = (await exec(conn, `readlink /proc/${pid}/cwd 2>/dev/null`)).trim();
613
+ if (cwd.startsWith('/')) {
614
+ const finds = (await exec(conn, `find "${cwd}" -maxdepth 3 -type f -name '*.log' -mmin -60 2>/dev/null | head -3`))
615
+ .split('\n').map((s) => s.trim()).filter(Boolean);
616
+ if (finds.length === 1)
617
+ return finds[0];
618
+ }
619
+ }
620
+ catch { /* cwd unreadable */ }
621
+ return undefined;
622
+ }
623
+ /** Light connectivity probe: hostname + GPU count (for the settings "test connection" button). */
624
+ export async function testConnection(auth) {
625
+ let conn = null;
626
+ try {
627
+ conn = await connect(auth);
628
+ const hostname = (await exec(conn, 'hostname')).trim();
629
+ let gpus = 0;
630
+ try {
631
+ const text = await exec(conn, 'nvidia-smi -L 2>/dev/null');
632
+ gpus = text.split('\n').filter((l) => l.trim().startsWith('GPU')).length;
633
+ }
634
+ catch {
635
+ gpus = 0;
636
+ }
637
+ return { ok: true, detail: `${hostname} · ${gpus} 张 GPU`, gpus };
638
+ }
639
+ catch (err) {
640
+ return { ok: false, detail: err instanceof Error ? err.message : String(err), gpus: 0 };
641
+ }
642
+ finally {
643
+ conn?.end();
644
+ }
645
+ }
646
+ /** per-GPU curve budget (4 GPUs × 8 series × 150 points keeps the payload sane) */
647
+ const MAX_GPU_SERIES = 8;
648
+ const MAX_GPU_POINTS = 150;
649
+ const LOG_CACHE_TTL_MS = 5 * 60_000;
650
+ const LOG_CACHE_MISS_TTL_MS = 60_000;
651
+ export async function collectSnapshot(runtime, state) {
652
+ const snap = { hostId: runtime.id, at: Date.now(), ok: false };
653
+ // 1) acquire the pooled connection — handshake/auth failures are not
654
+ // retryable (the credentials would be the same)
655
+ let conn;
656
+ try {
657
+ conn = await acquireConn(runtime, state);
658
+ }
659
+ catch (err) {
660
+ snap.error = err instanceof Error ? err.message : String(err);
661
+ // a half-dead pooled connection can poison later attempts — drop ONLY the
662
+ // pooled connection so the next poll reconnects. The state itself must
663
+ // survive: a temporarily unreachable host (reboot / network blip) has to
664
+ // recover via the backoff ladder, and disposeState here would permanently
665
+ // kill it ("主机状态已释放" forever) until the plugin reloads.
666
+ if (state.ssh) {
667
+ try {
668
+ state.ssh.conn.end();
669
+ }
670
+ catch { /* closing */ }
671
+ state.ssh = undefined;
672
+ }
673
+ return snap;
674
+ }
675
+ const run = async () => {
676
+ // half-dead pooled connection recycle (SFTP hang / repeated read failures):
677
+ // flag it dead so the next acquireConn reconnects
678
+ const recycleConn = () => {
679
+ if (state.ssh && !state.ssh.dead) {
680
+ try {
681
+ state.ssh.conn.end();
682
+ }
683
+ catch { /* closing */ }
684
+ state.ssh.dead = true;
685
+ }
686
+ };
687
+ // GPU queries are best-effort: a CPU-only server (or a down driver) must
688
+ // still surface CPU/memory/disk instead of failing the whole snapshot
689
+ const [gpuText, procText, cpuPercent, memText, diskText, loadText] = await Promise.all([
690
+ exec(conn, CMD_GPUS).catch(() => ''),
691
+ exec(conn, CMD_PROCS).catch(() => ''),
692
+ sampleCpu(conn, state),
693
+ exec(conn, CMD_MEM).catch(() => ''),
694
+ exec(conn, CMD_DISK).catch(() => ''),
695
+ exec(conn, CMD_LOAD).catch(() => ''),
696
+ ]);
697
+ const gpus = parseGpus(gpuText);
698
+ const procs = parseProcs(procText);
699
+ if (procs.size > 0) {
700
+ const pids = [...procs.keys()].join(',');
701
+ // full command line so the table answers "WHICH experiment is this" —
702
+ // comm would only say "python"; cut bounds the wire payload
703
+ const psText = await exec(conn, `ps -o pid=,user=,args= -p ${pids} 2>/dev/null | cut -c1-220`).catch(() => '');
704
+ const psMap = new Map();
705
+ for (const line of psText.split('\n').map((l) => l.trim()).filter(Boolean)) {
706
+ const [pid, user, ...rest] = line.split(/\s+/);
707
+ const cmd = rest.join(' ').slice(0, 200);
708
+ // name: leading token's basename (compact display), cmd: the full line
709
+ const name = cmd.split(/\s+/)[0]?.split('/').pop() ?? cmd;
710
+ psMap.set(Number(pid), { user, name, cmd });
711
+ }
712
+ // map processes onto GPUs via driver uuid (legacy 2-col output → all on GPU 0)
713
+ const uuidToIndex = new Map(gpus.map((g) => [g.uuid, g.index]));
714
+ const byGpu = new Map();
715
+ for (const [pid, { memMiB, gpuUuid }] of procs) {
716
+ const info = psMap.get(pid);
717
+ const gpuIndex = gpuUuid !== undefined ? (uuidToIndex.get(gpuUuid) ?? 0) : 0;
718
+ const row = { pid, user: info?.user ?? '?', name: info?.name ?? '?', cmd: info?.cmd, memoryMiB: memMiB };
719
+ const list = byGpu.get(gpuIndex) ?? [];
720
+ list.push(row);
721
+ byGpu.set(gpuIndex, list);
722
+ }
723
+ for (const gpu of gpus)
724
+ gpu.processes = byGpu.get(gpu.index) ?? [];
725
+ }
726
+ snap.gpus = gpus;
727
+ const mem = parseMem(memText);
728
+ snap.base = {
729
+ cpuPercent,
730
+ memUsedMiB: mem.usedMiB,
731
+ memTotalMiB: mem.totalMiB,
732
+ disks: parseDisk(diskText),
733
+ load1: parseLoad(loadText),
734
+ };
735
+ // host-level log tail result — a per-GPU log resolving to the SAME path
736
+ // reuses it instead of a second SFTP read of the file
737
+ let hostTail;
738
+ if (runtime.logPath) {
739
+ try {
740
+ // cold start: seed the curves from a larger tail window — the log file
741
+ // itself is the persistence, so a host restart must not zero the history
742
+ const seed = state.path === undefined && state.lastLine === undefined;
743
+ const tail = await readTail(conn, runtime.logPath, seed ? 2_000_000 : 262_144, seed ? 2000 : 20, recycleConn);
744
+ hostTail = tail;
745
+ snap.log = seed ? { ...tail, lines: tail.lines.slice(-20) } : tail;
746
+ state.sshFail = 0;
747
+ if (state.path !== tail.path) {
748
+ state.series.clear();
749
+ state.lastLine = undefined;
750
+ state.lastHeader = undefined;
751
+ state.lastSize = undefined;
752
+ state.lastMtime = undefined;
753
+ state.path = tail.path;
754
+ }
755
+ snap.series = ingestLogLines(state, tail, snap.at, 12, 200);
756
+ }
757
+ catch {
758
+ snap.log = { path: runtime.logPath, lines: [], size: 0, mtimeMs: 0 };
759
+ // SFTP reads dying while exec still works = a half-dead pooled
760
+ // connection; recycle it after two consecutive failures or the curves
761
+ // never come back
762
+ state.sshFail = (state.sshFail ?? 0) + 1;
763
+ if (state.sshFail >= 2)
764
+ recycleConn();
765
+ }
766
+ }
767
+ // per-GPU experiment logs: discover the log behind each GPU's processes and
768
+ // keep independent curves per GPU
769
+ if (gpus.length > 0) {
770
+ const gpuStates = state.gpu ??= new Map();
771
+ const logCache = state.logCache ??= new Map();
772
+ // PID-reuse guard: one exec reads the starttime (/proc/<pid>/stat field
773
+ // 22) of every compute process; a cached path only survives while the
774
+ // starttime matches. When the read fails the map stays empty and we
775
+ // degrade to trusting the cache (previous behavior).
776
+ const startTimes = new Map();
777
+ if (procs.size > 0) {
778
+ const pidList = [...procs.keys()].map(String);
779
+ const stText = await exec(conn, `for p in ${pidList.join(' ')}; do awk '{print $22}' /proc/$p/stat 2>/dev/null || echo __gone__; done`).catch(() => '');
780
+ stText.split('\n').forEach((l, i) => {
781
+ if (pidList[i] !== undefined)
782
+ startTimes.set(pidList[i], l.trim());
783
+ });
784
+ }
785
+ const livePids = new Set();
786
+ await Promise.all(gpus.map(async (gpu) => {
787
+ const gpuProcs = gpu.processes.filter((p) => p.memoryMiB > 0).slice(0, 3);
788
+ let gpuLog;
789
+ for (const p of gpuProcs) {
790
+ livePids.add(String(p.pid));
791
+ const pidKey = String(p.pid);
792
+ const cached = logCache.get(pidKey);
793
+ const curStart = startTimes.get(pidKey);
794
+ let path;
795
+ if (cached && Date.now() - cached.at < (cached.path ? LOG_CACHE_TTL_MS : LOG_CACHE_MISS_TTL_MS)) {
796
+ path = cached.path;
797
+ // starttime mismatch = the PID was reused by a new process — the
798
+ // cached path belongs to a dead experiment, rediscover
799
+ if (path && cached.starttime !== undefined && curStart !== undefined && cached.starttime !== curStart) {
800
+ path = undefined;
801
+ }
802
+ }
803
+ else {
804
+ try {
805
+ path = await discoverGpuLog(conn, p.pid);
806
+ }
807
+ catch {
808
+ path = undefined;
809
+ }
810
+ logCache.set(pidKey, { path, at: Date.now(), starttime: curStart });
811
+ }
812
+ if (!path)
813
+ continue;
814
+ try {
815
+ const key = String(gpu.index);
816
+ let gs = gpuStates.get(key);
817
+ if (!gs) {
818
+ gs = { series: new Map() };
819
+ gpuStates.set(key, gs);
820
+ }
821
+ const seedGpu = gs.path === undefined && gs.lastLine === undefined;
822
+ // same file as the host-level logPath → reuse that tail (one SFTP
823
+ // read instead of two; the seed window then follows the host level)
824
+ const tail = hostTail && hostTail.path === path
825
+ ? hostTail
826
+ : await readTail(conn, path, seedGpu ? 524_288 : 65_536, seedGpu ? 800 : 20, recycleConn);
827
+ gpuLog = seedGpu ? { ...tail, lines: tail.lines.slice(-20) } : tail;
828
+ if (gs.path !== gpuLog.path) {
829
+ gs.series.clear();
830
+ gs.lastLine = undefined;
831
+ gs.lastHeader = undefined;
832
+ gs.lastSize = undefined;
833
+ gs.lastMtime = undefined;
834
+ gs.path = gpuLog.path;
835
+ }
836
+ gpu.log = gpuLog;
837
+ gpu.series = ingestLogLines(gs, gpuLog, snap.at, MAX_GPU_SERIES, MAX_GPU_POINTS);
838
+ break;
839
+ }
840
+ catch {
841
+ logCache.delete(String(p.pid)); // stale path — rediscover next poll
842
+ }
843
+ }
844
+ }));
845
+ // drop cache rows of processes that are gone
846
+ for (const pid of [...logCache.keys()]) {
847
+ if (!livePids.has(pid))
848
+ logCache.delete(pid);
849
+ }
850
+ }
851
+ snap.ok = true;
852
+ };
853
+ try {
854
+ await run();
855
+ }
856
+ catch (err) {
857
+ // mid-poll failure — most likely the pooled connection died under us:
858
+ // recycle it and retry once on a fresh connection (read-only, idempotent)
859
+ if (state.ssh) {
860
+ try {
861
+ if (!state.ssh.dead)
862
+ state.ssh.conn.end();
863
+ }
864
+ catch { /* closing */ }
865
+ state.ssh = undefined;
866
+ }
867
+ try {
868
+ conn = await acquireConn(runtime, state);
869
+ await run();
870
+ }
871
+ catch (err2) {
872
+ snap.error = err2 instanceof Error ? err2.message : String(err2);
873
+ if (!snap.error && err instanceof Error)
874
+ snap.error = err.message;
875
+ }
876
+ }
877
+ return snap;
878
+ }