@hyzyn/dsh-tty 0.16.2 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/stats.js ADDED
@@ -0,0 +1,882 @@
1
+ /**
2
+ * @hyzyn/dsh-tty — 服务器状态条的数据层(0.17.0)。
3
+ *
4
+ * 形态与职责(为什么这么切):
5
+ * - **SSH 会话**:在会话已保留的 ssh2 连接上再开一条非 PTY 的 exec channel,
6
+ * 远端跑一段 POSIX sh + awk 的常驻循环,每秒吐一行 JSON。速率类(CPU%、
7
+ * 网速)必须在采集端算好——只有远端同时握着前后两次采样,宿主拿到的
8
+ * 已经是现成值,中间不经过 PTY(本模块只给命令文本,channel 由 ssh.ts 建)。
9
+ * - **本地会话**:宿主进程自己就是「那台机器」,用 node:os + /proc(Linux)
10
+ * 或 netstat(macOS)采,产出与远端同形状的帧。
11
+ * - **纯函数**:所有文本解析、数值清洗、脚本常量都放这里,不碰 IO 与状态机,
12
+ * 便于 vitest 直接测(test/stats.test.ts)。
13
+ *
14
+ * 不变式:**best-effort**。任何字段拿不到就省略,解析失败返回空对象/null,
15
+ * 绝不抛错打断终端;帧的字段白名单与数值边界由 sanitizeStatsFrame 统一把关
16
+ * (远端脚本、本地采样器、将来可能的第三方采集器都过这一道)。
17
+ */
18
+ import { execFile } from 'node:child_process';
19
+ import { readFileSync, readdirSync } from 'node:fs';
20
+ import { cpus, freemem, loadavg, totalmem, uptime as osUptime } from 'node:os';
21
+ import { shSingleQuote } from './shell-integration.js';
22
+ const STATS_KEYS = ['cpuPct', 'cores', 'memUsed', 'memTotal', 'memPct', 'diskUsed', 'diskTotal', 'diskPct', 'uptimeSec', 'tcpConns', 'rxRate', 'txRate', 'tempC'];
23
+ /** 数值边界:超出即视为解析垃圾丢弃(远端脚本被改写、df 输出异常、计数器回绕)。 */
24
+ const LIMITS = {
25
+ cpuPct: { min: 0, max: 100, int: false },
26
+ cores: { min: 1, max: 4096, int: true },
27
+ memUsed: { min: 0, max: 2 ** 50, int: true },
28
+ memTotal: { min: 1, max: 2 ** 50, int: true },
29
+ memPct: { min: 0, max: 100, int: false },
30
+ diskUsed: { min: 0, max: 2 ** 50, int: true },
31
+ diskTotal: { min: 1, max: 2 ** 50, int: true },
32
+ diskPct: { min: 0, max: 100, int: false },
33
+ uptimeSec: { min: 0, max: 100 * 365 * 24 * 3600, int: true },
34
+ tcpConns: { min: 0, max: 10_000_000, int: true },
35
+ rxRate: { min: 0, max: 2 ** 40, int: true },
36
+ txRate: { min: 0, max: 2 ** 40, int: true },
37
+ tempC: { min: -100, max: 200, int: false },
38
+ };
39
+ /**
40
+ * 帧的白名单清洗:非对象/未知键/非有限数值/越界一律丢弃,pct 与温度保留
41
+ * 一位小数,字节与速率取整。返回 null = 这根本不是一帧 stats(调用方静默
42
+ * 忽略);返回 {} = 结构合法但一个指标都没拿到(调用方不发帧,让客户端按
43
+ * 「无数据」隐藏状态条)。
44
+ */
45
+ export function sanitizeStatsFrame(input) {
46
+ if (typeof input !== 'object' || input === null || Array.isArray(input))
47
+ return null;
48
+ const raw = input;
49
+ const out = {};
50
+ for (const key of STATS_KEYS) {
51
+ const value = raw[key];
52
+ if (typeof value !== 'number' || !Number.isFinite(value))
53
+ continue;
54
+ const limit = LIMITS[key];
55
+ if (value < limit.min || value > limit.max)
56
+ continue;
57
+ out[key] = limit.int ? Math.round(value) : Math.round(value * 10) / 10;
58
+ }
59
+ return out;
60
+ }
61
+ /** 清洗后是否至少有一个指标(决定要不要发帧)。 */
62
+ export function hasStatsData(frame) {
63
+ return STATS_KEYS.some((key) => frame[key] !== undefined);
64
+ }
65
+ /**
66
+ * 解析 /proc/stat 的聚合行与 cpuN 行。iowait 计入 idle——与 top/htop 的默认口径
67
+ * 一致(把等 IO 视为空闲,否则虚拟机上会长期虚高)。
68
+ */
69
+ export function parseProcStat(text) {
70
+ let counters = null;
71
+ let cores = 0;
72
+ for (const line of text.split('\n')) {
73
+ if (/^cpu[0-9]/.test(line)) {
74
+ cores += 1;
75
+ continue;
76
+ }
77
+ if (!/^cpu /.test(line))
78
+ continue;
79
+ const cols = line.trim().split(/\s+/);
80
+ let total = 0;
81
+ for (let i = 1; i < cols.length; i += 1) {
82
+ const value = Number(cols[i]);
83
+ if (Number.isFinite(value))
84
+ total += value;
85
+ }
86
+ const idle = (Number(cols[4]) || 0) + (Number(cols[5]) || 0);
87
+ if (total > 0)
88
+ counters = { total, idle, cores: 1 };
89
+ }
90
+ if (counters === null)
91
+ return null;
92
+ return { ...counters, cores: cores > 0 ? cores : 1 };
93
+ }
94
+ /** 两次累计计数器差值 → 使用率 0~100;窗口无效(无变化/回绕)返回 undefined。 */
95
+ export function cpuPctBetween(prev, next) {
96
+ const deltaTotal = next.total - prev.total;
97
+ const deltaIdle = next.idle - prev.idle;
98
+ if (!Number.isFinite(deltaTotal) || deltaTotal <= 0)
99
+ return undefined;
100
+ const value = ((deltaTotal - deltaIdle) * 100) / deltaTotal;
101
+ if (!Number.isFinite(value))
102
+ return undefined;
103
+ return Math.min(100, Math.max(0, Math.round(value * 10) / 10));
104
+ }
105
+ /** /proc/meminfo → 内存用量(bytes)。MemAvailable 优于 MemFree(缓存可回收)。 */
106
+ export function parseMeminfo(text) {
107
+ let totalKb = 0;
108
+ let availableKb = 0;
109
+ let freeKb = 0;
110
+ let buffersKb = 0;
111
+ let cachedKb = 0;
112
+ for (const line of text.split('\n')) {
113
+ const cols = line.trim().split(/\s+/);
114
+ const value = Number(cols[1]);
115
+ if (!Number.isFinite(value))
116
+ continue;
117
+ if (cols[0] === 'MemTotal:')
118
+ totalKb = value;
119
+ else if (cols[0] === 'MemAvailable:')
120
+ availableKb = value;
121
+ else if (cols[0] === 'MemFree:')
122
+ freeKb = value;
123
+ else if (cols[0] === 'Buffers:')
124
+ buffersKb = value;
125
+ else if (cols[0] === 'Cached:')
126
+ cachedKb = value;
127
+ }
128
+ if (totalKb <= 0)
129
+ return null;
130
+ // 老内核没有 MemAvailable:自由 + Buffers + Cached 是最接近的近似
131
+ const available = availableKb > 0 ? availableKb : freeKb + buffersKb + cachedKb;
132
+ const usedKb = Math.max(0, totalKb - available);
133
+ return { total: totalKb * 1024, used: usedKb * 1024, pct: Math.round((usedKb * 10000) / totalKb) / 100 };
134
+ }
135
+ /** /proc/net/dev → 非 lo 接口收发字节累计(速率由调用方差值)。 */
136
+ export function parseNetDev(text) {
137
+ let rx = 0;
138
+ let tx = 0;
139
+ for (const line of text.split('\n')) {
140
+ const colon = line.indexOf(':');
141
+ if (colon === -1)
142
+ continue;
143
+ const name = line.slice(0, colon).replace(/\s/g, '');
144
+ if (name === '' || name === 'lo')
145
+ continue;
146
+ const cols = line.slice(colon + 1).trim().split(/\s+/);
147
+ if (cols.length < 9)
148
+ continue;
149
+ const rxBytes = Number(cols[0]);
150
+ const txBytes = Number(cols[8]);
151
+ if (Number.isFinite(rxBytes))
152
+ rx += rxBytes;
153
+ if (Number.isFinite(txBytes))
154
+ tx += txBytes;
155
+ }
156
+ return { rx, tx };
157
+ }
158
+ /** /proc/loadavg → 1/5/15 分钟负载。 */
159
+ export function parseLoadavg(text) {
160
+ // Number("") 是 0:空文本必须显式挡掉,否则会被当成「负载 0」
161
+ if (text.trim() === '')
162
+ return null;
163
+ const cols = text.trim().split(/\s+/);
164
+ const load1 = Number(cols[0]);
165
+ if (!Number.isFinite(load1))
166
+ return null;
167
+ const load5 = Number(cols[1]);
168
+ const load15 = Number(cols[2]);
169
+ return { load1, load5: Number.isFinite(load5) ? load5 : load1, load15: Number.isFinite(load15) ? load15 : load1 };
170
+ }
171
+ /** /proc/uptime → 开机秒数(取整)。 */
172
+ export function parseProcUptime(text) {
173
+ if (text.trim() === '')
174
+ return null;
175
+ const value = Number(text.trim().split(/\s+/)[0]);
176
+ if (!Number.isFinite(value) || value < 0)
177
+ return null;
178
+ return Math.floor(value);
179
+ }
180
+ /**
181
+ * /proc/net/tcp{,6} 的 established 计数:数据行按空白切分后第 4 列是状态码,
182
+ * established = "01"(proc(5) 的 16 进制状态码),首行表头跳过。
183
+ */
184
+ export function countTcpEstablished(text) {
185
+ let count = 0;
186
+ const lines = text.split('\n');
187
+ for (let i = 1; i < lines.length; i += 1) {
188
+ const cols = lines[i].trim().split(/\s+/);
189
+ if (cols.length >= 4 && cols[3] === '01')
190
+ count += 1;
191
+ }
192
+ return count;
193
+ }
194
+ /** df -kP 输出(取最后一行数据行)→ 容量(bytes)。 */
195
+ export function parseDfKb(text) {
196
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
197
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
198
+ const cols = lines[i].trim().split(/\s+/);
199
+ if (cols.length < 3)
200
+ continue;
201
+ const totalKb = Number(cols[1]);
202
+ const usedKb = Number(cols[2]);
203
+ if (!Number.isFinite(totalKb) || totalKb <= 0)
204
+ continue;
205
+ if (!Number.isFinite(usedKb) || usedKb < 0)
206
+ continue;
207
+ return { total: totalKb * 1024, used: usedKb * 1024, pct: Math.round((usedKb * 10000) / totalKb) / 100 };
208
+ }
209
+ return null;
210
+ }
211
+ /**
212
+ * macOS/BSD netstat -ib → 非 lo 接口收发字节累计。
213
+ *
214
+ * 为什么取「每行最后 7 列」而不是固定下标:Link 行的 Address 列可能为空(lo0)
215
+ * 也可能是 MAC(en0),空白切分后字段数不同;而 Ipkts/Ierrs/Ibytes/Opkts/
216
+ * Oerrs/Obytes/Coll 恒定收尾,取尾部 7 列对两种行都成立(macOS 15 实测)。
217
+ */
218
+ export function parseNetstatIb(text) {
219
+ let rx = 0;
220
+ let tx = 0;
221
+ for (const line of text.split('\n')) {
222
+ const cols = line.trim().split(/\s+/);
223
+ if (cols.length < 9)
224
+ continue;
225
+ const name = cols[0];
226
+ if (name === 'Name' || name === 'lo0')
227
+ continue;
228
+ if (!cols.some((col) => col.startsWith('<Link#')))
229
+ continue;
230
+ const tail = cols.slice(-7);
231
+ const rxBytes = Number(tail[2]);
232
+ const txBytes = Number(tail[5]);
233
+ if (Number.isFinite(rxBytes))
234
+ rx += rxBytes;
235
+ if (Number.isFinite(txBytes))
236
+ tx += txBytes;
237
+ }
238
+ return { rx, tx };
239
+ }
240
+ /** macOS/BSD netstat -an -p tcp → established 计数。 */
241
+ export function countNetstatEstablished(text) {
242
+ let count = 0;
243
+ for (const line of text.split('\n')) {
244
+ if (line.includes('ESTABLISHED'))
245
+ count += 1;
246
+ }
247
+ return count;
248
+ }
249
+ /** sysfs 温度节点文本(毫摄氏度)→ 摄氏度;不可用返回 undefined。 */
250
+ export function parseThermalTemp(text) {
251
+ const value = Number(text.trim());
252
+ if (!Number.isFinite(value) || value === 0)
253
+ return undefined;
254
+ const celsius = value > 10000 ? value / 1000 : value;
255
+ if (celsius <= -100 || celsius >= 200)
256
+ return undefined;
257
+ return Math.round(celsius * 10) / 10;
258
+ }
259
+ export function parseVmStat(text) {
260
+ const header = text.match(/page size of (\d+) bytes/);
261
+ if (header === null)
262
+ return null;
263
+ const pageSize = Number(header[1]);
264
+ if (!Number.isFinite(pageSize) || pageSize <= 0)
265
+ return null;
266
+ const pages = (label) => {
267
+ const match = text.match(label);
268
+ if (match === null)
269
+ return 0;
270
+ const value = Number(match[1]);
271
+ return Number.isFinite(value) ? value : 0;
272
+ };
273
+ const free = pages(/Pages free:\s+(\d+)/);
274
+ const active = pages(/Pages active:\s+(\d+)/);
275
+ const inactive = pages(/Pages inactive:\s+(\d+)/);
276
+ const speculative = pages(/Pages speculative:\s+(\d+)/);
277
+ const wired = pages(/Pages wired down:\s+(\d+)/);
278
+ const compressed = pages(/Pages occupied by compressor:\s+(\d+)/);
279
+ if (active + wired + compressed === 0)
280
+ return null;
281
+ return { pageSize, free, active, inactive, speculative, wired, compressed };
282
+ }
283
+ /* ------------------------------------------------------------------ *
284
+ * 远端采集脚本(POSIX sh + awk,常驻循环)
285
+ * ------------------------------------------------------------------ */
286
+ /**
287
+ * 远端脚本的 awk 主体。设计要点:
288
+ * - **单进程常驻**:awk 自己死循环,每秒读一遍 /proc 再 system("sleep 1")。
289
+ * 比起 sh 里每秒起 6 个进程,开销可忽略(1 个常驻 awk,几百 KB 内存)。
290
+ * - **只依赖 POSIX awk**:getline/close/split/sprintf/system/fflush,无 gawk 扩展,
291
+ * busybox awk 亦可用。
292
+ * - **速率在远端算好**:CPU% = /proc/stat 差值,网速 = /proc/net/dev 差值 ÷
293
+ * /proc/uptime 差值(用 uptime 当单调钟,避开 systime 的整秒粒度)。
294
+ * - **数值一律 sprintf 成字符串**再拼接:awk 的字符串拼接走 OFMT("%.6g"),
295
+ * 直接拼 16 位字节数会输出 1.66654e+10(有效数字被截断)。
296
+ * - **stdout 只出 JSON**:诊断信息一律不输出(stderr 也被 ssh.ts 丢弃)。
297
+ */
298
+ const REMOTE_AWK = String.raw `function slurp(path, line, out) {
299
+ out = ""
300
+ while ((getline line < path) > 0) out = out line "\n"
301
+ close(path)
302
+ return out
303
+ }
304
+ function jput(key, val) {
305
+ if (val == "") return
306
+ if (j != "") j = j ","
307
+ j = j "\"" key "\":" val
308
+ }
309
+ # /proc/net/tcp{,6} 的 established 计数(状态码 01),读不到任何一份时返回 -1
310
+ # ——调用方据此省略字段(前端显示「无」),而不是报 0(0 会被当成「没有连接」)。
311
+ #
312
+ # 为什么必须先 sub() 去掉行首空白:这两份文件的 sl 列是右对齐的(行首有空格),
313
+ # 而 awk 的显式正则分隔符与默认 FS 不同——它不会吃掉行首空白,反而会为之产出一个
314
+ # 空首字段,于是状态码从 f[4] 滑到 f[5],f[4] == "01" 永不成立、计数恒为 0。
315
+ # (真实踩过:只有远端 TCP 一直是 0,CPU / 内存 / 磁盘 / 网速全都正常。)
316
+ function tcpest(base, fi, tp, td, n, L, i, m, f, tcp, have, ln) {
317
+ tcp = 0
318
+ have = 0
319
+ for (fi = 1; fi <= 2; fi++) {
320
+ tp = (fi == 1) ? base "/net/tcp" : base "/net/tcp6"
321
+ td = slurp(tp)
322
+ if (td == "") continue
323
+ have = 1
324
+ n = split(td, L, "\n")
325
+ for (i = 2; i <= n; i++) {
326
+ ln = L[i]
327
+ sub(/^[ \t]+/, "", ln)
328
+ m = split(ln, f, /[ \t]+/)
329
+ if (m >= 4 && f[4] == "01") tcp++
330
+ }
331
+ }
332
+ if (have == 0) return -1
333
+ return tcp
334
+ }
335
+ BEGIN {
336
+ seenCpu = 0
337
+ seenNet = 0
338
+ while (1) {
339
+ j = ""
340
+ st = slurp("/proc/stat")
341
+ cpuPct = ""
342
+ cores = 0
343
+ if (st != "") {
344
+ n = split(st, L, "\n")
345
+ for (i = 1; i <= n; i++) {
346
+ if (L[i] ~ /^cpu[0-9]/) { cores++ }
347
+ else if (L[i] ~ /^cpu /) {
348
+ m = split(L[i], c, /[ \t]+/)
349
+ tot = 0
350
+ for (k = 2; k <= m; k++) if (c[k] != "") tot = tot + c[k]
351
+ idle = c[5] + c[6]
352
+ if (seenCpu == 1) {
353
+ dt = tot - pTot
354
+ di = idle - pIdle
355
+ if (dt > 0) {
356
+ cpuPct = (dt - di) * 100 / dt
357
+ if (cpuPct < 0) cpuPct = 0
358
+ if (cpuPct > 100) cpuPct = 100
359
+ cpuPct = sprintf("%.1f", cpuPct)
360
+ }
361
+ }
362
+ pTot = tot
363
+ pIdle = idle
364
+ seenCpu = 1
365
+ }
366
+ }
367
+ }
368
+ jput("cpuPct", cpuPct)
369
+ if (cores > 0) jput("cores", sprintf("%d", cores))
370
+
371
+ mi = slurp("/proc/meminfo")
372
+ memTot = 0
373
+ memAvail = 0
374
+ if (mi != "") {
375
+ n = split(mi, L, "\n")
376
+ for (i = 1; i <= n; i++) {
377
+ m = split(L[i], f, /[ \t]+/)
378
+ if (f[1] == "MemTotal:") memTot = f[2] + 0
379
+ else if (f[1] == "MemAvailable:") memAvail = f[2] + 0
380
+ else if (f[1] == "MemFree:" && memAvail == 0) memAvail = f[2] + 0
381
+ }
382
+ }
383
+ if (memTot > 0) {
384
+ memUsed = memTot - memAvail
385
+ if (memUsed < 0) memUsed = 0
386
+ jput("memTotal", sprintf("%.0f", memTot * 1024))
387
+ jput("memUsed", sprintf("%.0f", memUsed * 1024))
388
+ jput("memPct", sprintf("%.1f", memUsed * 100 / memTot))
389
+ }
390
+
391
+ nd = slurp("/proc/net/dev")
392
+ rx = 0
393
+ tx = 0
394
+ haveNet = 0
395
+ if (nd != "") {
396
+ n = split(nd, L, "\n")
397
+ for (i = 1; i <= n; i++) {
398
+ p = index(L[i], ":")
399
+ if (p == 0) continue
400
+ ifn = substr(L[i], 1, p - 1)
401
+ gsub(/[ \t]/, "", ifn)
402
+ if (ifn == "" || ifn == "lo") continue
403
+ cols = substr(L[i], p + 1)
404
+ gsub(/^[ \t]+/, "", cols)
405
+ m = split(cols, f, /[ \t]+/)
406
+ if (m >= 9) {
407
+ rx = rx + f[1] + 0
408
+ tx = tx + f[9] + 0
409
+ haveNet = 1
410
+ }
411
+ }
412
+ }
413
+
414
+ up = slurp("/proc/uptime")
415
+ nowSec = 0
416
+ if (up != "") { split(up, f, /[ \t]+/); nowSec = f[1] + 0 }
417
+ if (nowSec > 0) jput("uptimeSec", sprintf("%.0f", nowSec))
418
+ if (haveNet == 1) {
419
+ if (seenNet == 1 && nowSec > pNow && rx >= pRx && tx >= pTx) {
420
+ el = nowSec - pNow
421
+ jput("rxRate", sprintf("%.0f", (rx - pRx) / el))
422
+ jput("txRate", sprintf("%.0f", (tx - pTx) / el))
423
+ }
424
+ pRx = rx
425
+ pTx = tx
426
+ pNow = nowSec
427
+ seenNet = 1
428
+ }
429
+
430
+ tcp = tcpest("/proc")
431
+ if (tcp >= 0) jput("tcpConns", sprintf("%d", tcp))
432
+
433
+ cmd = "df -kP / 2>/dev/null"
434
+ dn = 0
435
+ dfl = ""
436
+ while ((cmd | getline dline) > 0) { dn++; dfl = dline }
437
+ close(cmd)
438
+ if (dn > 0) {
439
+ m = split(dfl, f, /[ \t]+/)
440
+ if (m >= 3 && (f[2] + 0) > 0) {
441
+ dtot = (f[2] + 0) * 1024
442
+ dused = (f[3] + 0) * 1024
443
+ if (dused < 0) dused = 0
444
+ jput("diskTotal", sprintf("%.0f", dtot))
445
+ jput("diskUsed", sprintf("%.0f", dused))
446
+ jput("diskPct", sprintf("%.1f", dused * 100 / dtot))
447
+ }
448
+ }
449
+
450
+ if (tempf != "") {
451
+ if ((getline tline < tempf) > 0) {
452
+ close(tempf)
453
+ tv = tline + 0
454
+ if (tv > 10000) tv = tv / 1000
455
+ if (tv > -100 && tv < 200) jput("tempC", sprintf("%.1f", tv))
456
+ } else {
457
+ close(tempf)
458
+ }
459
+ }
460
+
461
+ printf "{%s}\n", j
462
+ fflush()
463
+ system("sleep 1")
464
+ }
465
+ }`;
466
+ /** 导出 awk 主体(测试用:把死循环换成 if(0) 后可做语法自检)。 */
467
+ export function remoteStatsAwk() {
468
+ return REMOTE_AWK;
469
+ }
470
+ /**
471
+ * 远端采集脚本(未转义的原文;导出仅供测试做语法检查)。
472
+ *
473
+ * 外层用 sh -c 显式包一层:sshd 是拿**用户的登录 shell** 执行 exec 请求的
474
+ * ($SHELL -c 命令串),fish/csh 账户下不保证能解析 POSIX 循环,所以不能把
475
+ * 脚本裸着交出去。脚本里也刻意不出现参数展开语法——保持能被单引号整体包裹,
476
+ * 经 shSingleQuote 内嵌后引号结构不可能被撑破。
477
+ */
478
+ export function remoteStatsScript() {
479
+ return "[ -r /proc/stat ] || exit 0\nexport LC_ALL=C\ntempf=''\nfallback=''\nfor z in /sys/class/thermal/thermal_zone*; do\n if [ -r \"$z/temp\" ]; then\n if [ -z \"$fallback\" ]; then fallback=\"$z/temp\"; fi\n t=$(cat \"$z/type\" 2>/dev/null)\n case \"$t\" in\n *x86_pkg_temp*|*cpu-thermal*|*coretemp*|*soc_thermal*) tempf=\"$z/temp\"; break ;;\n esac\n fi\ndone\nif [ -z \"$tempf\" ]; then tempf=\"$fallback\"; fi\nexec awk -v tempf=\"$tempf\" '" + REMOTE_AWK + "' /dev/null\n";
480
+ }
481
+ /**
482
+ * 交给 ssh2 conn.exec 的完整命令(已按 shSingleQuote 转义)。
483
+ * 远端命令拼接一律走它:命令里出现单引号/换行/美元符都不能破坏外层引号结构。
484
+ */
485
+ export function buildRemoteStatsCommand() {
486
+ return 'sh -c ' + shSingleQuote(remoteStatsScript());
487
+ }
488
+ /**
489
+ * Windows 远端的采集脚本(PowerShell 5.1+,随 Windows 自带)。
490
+ *
491
+ * 与 POSIX 分支同帧形状、同节奏:每秒一行 JSON 到 stdout。设计取舍:
492
+ * - **不用 ConvertTo-Json**:它把大整数写成科学计数法(1.7179869184E+10),
493
+ * 且随当前区域变(德语区小数点是逗号,直接毁 JSON)。这里手工拼串,
494
+ * 并且**只输出整数**——整数的隐式 ToString 在任何区域下都不带分隔符,
495
+ * 从根上避开文化差异(pct/温度取整对状态条足够)。
496
+ * - **速率在远端算好**:Get-NetAdapterStatistics 的收发字节是累计值,用
497
+ * Stopwatch 算差值(与 POSIX 分支拿 /proc/uptime 当单调钟同一个思路)。
498
+ * - **显式 Console.Out.Flush()**:stdout 是管道时 PowerShell 会缓冲,不刷就
499
+ * 要等缓冲满才过 channel(表现为前端迟迟收不到帧)。
500
+ * - **TCP 走 netstat**:Get-NetTCPConnection 要 Win8+/Server2012+,netstat
501
+ * 每个 Windows 都有,少一个分支也少一份体积。
502
+ * - **整段送 -EncodedCommand**:绕过 cmd.exe / PowerShell / Git Bash 的引号
503
+ * 与换行差异,也不出现反引号(PowerShell 的转义符);脚本压到 ~1.9KB,
504
+ * base64 后命令总长 ~5.2KB,远低于 cmd.exe 的 8191 字符上限。
505
+ * - 每轮都查 WMI(约几十毫秒/次),对监控循环可接受;拿不到的项一律省略。
506
+ */
507
+ const WINDOWS_STATS_SCRIPT = String.raw `$ErrorActionPreference = 'SilentlyContinue'
508
+ $pr = -1; $pt = -1; $pv = -1.0
509
+ $w = [Diagnostics.Stopwatch]::StartNew()
510
+ while (1) {
511
+ $p = @()
512
+ $o = Get-CimInstance Win32_OperatingSystem
513
+ if ($o) {
514
+ $p += '"cores":' + [int]$o.NumberOfLogicalProcessors
515
+ $mt = [double]$o.TotalVisibleMemorySize * 1024
516
+ $mu = ([double]$o.TotalVisibleMemorySize - [double]$o.FreePhysicalMemory) * 1024
517
+ if ($mt -gt 0) {
518
+ $p += '"memTotal":' + [long]$mt
519
+ $p += '"memUsed":' + [long]$mu
520
+ $p += '"memPct":' + [int][math]::Round($mu * 100 / $mt)
521
+ }
522
+ $u = ((Get-Date) - $o.LastBootUpTime).TotalSeconds
523
+ $p += '"uptimeSec":' + [long]$u
524
+ }
525
+ $c = (Get-CimInstance Win32_Processor | Measure-Object LoadPercentage -Average).Average
526
+ if ($null -ne $c) {
527
+ $v = [int][math]::Round([double]$c)
528
+ if ($v -lt 0) { $v = 0 }
529
+ if ($v -gt 100) { $v = 100 }
530
+ $p += '"cpuPct":' + $v
531
+ }
532
+ $d = $env:SystemDrive
533
+ if (-not $d) { $d = 'C:' }
534
+ $l = Get-CimInstance Win32_LogicalDisk -Filter ("DeviceID='" + $d + "'")
535
+ if ($l -and [double]$l.Size -gt 0) {
536
+ $dt = [double]$l.Size
537
+ $du = $dt - [double]$l.FreeSpace
538
+ if ($du -lt 0) { $du = 0 }
539
+ $p += '"diskTotal":' + [long]$dt
540
+ $p += '"diskUsed":' + [long]$du
541
+ $p += '"diskPct":' + [int][math]::Round($du * 100 / $dt)
542
+ }
543
+ $p += '"tcpConns":' + [int]@(netstat -an | Select-String 'ESTABLISHED').Count
544
+ $rx = 0; $tx = 0
545
+ $n = Get-NetAdapterStatistics
546
+ if ($n) {
547
+ foreach ($a in $n) { if ($a.Name -notmatch 'Loopback') { $rx += [double]$a.ReceivedBytes; $tx += [double]$a.SentBytes } }
548
+ $t = $w.Elapsed.TotalSeconds
549
+ if ($pv -ge 0 -and $t -gt $pv -and $rx -ge $pr -and $tx -ge $pt) {
550
+ $e = $t - $pv
551
+ $p += '"rxRate":' + [long](($rx - $pr) / $e)
552
+ $p += '"txRate":' + [long](($tx - $pt) / $e)
553
+ }
554
+ $pr = $rx; $pt = $tx; $pv = $t
555
+ }
556
+ $z = Get-CimInstance Win32_PerfFormattedData_Counters_ThermalZoneInformation | Select-Object -First 1
557
+ if ($z -and [double]$z.HighPrecisionTemperature -gt 0) {
558
+ $tv = [int][math]::Round(([double]$z.HighPrecisionTemperature / 10) - 273.15)
559
+ if ($tv -gt -100 -and $tv -lt 200) { $p += '"tempC":' + $tv }
560
+ }
561
+ [Console]::Out.WriteLine('{' + ($p -join ',') + '}')
562
+ [Console]::Out.Flush()
563
+ Start-Sleep 1
564
+ }`;
565
+ /**
566
+ * 交给 ssh2 conn.exec 的 Windows 命令。
567
+ *
568
+ * 用 -EncodedCommand(UTF-16LE base64,字母表 A-Za-z0-9+/=)而不是引号拼接:
569
+ * 这条命令可能由 cmd.exe、PowerShell 或 Git Bash 解释,base64 在这三者里都
570
+ * 无歧义——不需要(也不能)走 shSingleQuote。命令总长约 3KB,远低于 cmd.exe
571
+ * 的 8191 字符上限。
572
+ */
573
+ export function buildWindowsStatsCommand() {
574
+ const encoded = Buffer.from(WINDOWS_STATS_SCRIPT, 'utf16le').toString('base64');
575
+ return 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' + encoded;
576
+ }
577
+ /** 导出 PowerShell 原文(测试用:与 base64 解码结果逐字节比对)。 */
578
+ export function remoteWindowsStatsScript() {
579
+ return WINDOWS_STATS_SCRIPT;
580
+ }
581
+ /* ------------------------------------------------------------------ *
582
+ * 帧行缓冲(远端 stdout → 逐行 JSON)
583
+ * ------------------------------------------------------------------ */
584
+ /** 单行上限:远端往 stdout 灌垃圾时不能让服务端缓冲无界增长。 */
585
+ const MAX_STATS_LINE = 64 * 1024;
586
+ /** 一次 push 最多处理的行数(正常每秒 1 行;防突发灌爆主循环)。 */
587
+ const MAX_STATS_LINES_PER_PUSH = 64;
588
+ /** 跨 chunk 拼行;超限丢弃(远端已不正常,静默不吐数据即可)。 */
589
+ export class StatsLineBuffer {
590
+ buffer = '';
591
+ push(chunk) {
592
+ this.buffer += chunk;
593
+ const parts = this.buffer.split('\n');
594
+ this.buffer = parts.pop() ?? '';
595
+ if (this.buffer.length > MAX_STATS_LINE)
596
+ this.buffer = '';
597
+ if (parts.length > MAX_STATS_LINES_PER_PUSH)
598
+ return parts.slice(-MAX_STATS_LINES_PER_PUSH);
599
+ return parts;
600
+ }
601
+ reset() {
602
+ this.buffer = '';
603
+ }
604
+ }
605
+ /** 一行 stdout → 清洗后的帧;不是合法 JSON 对象时返回 null(静默跳过)。 */
606
+ export function parseStatsLine(line) {
607
+ const text = line.trim();
608
+ if (text === '' || text === '{}')
609
+ return null;
610
+ try {
611
+ return sanitizeStatsFrame(JSON.parse(text));
612
+ }
613
+ catch {
614
+ return null;
615
+ }
616
+ }
617
+ /** 同一秒内多个订阅者(多个本地标签)共享一次采样。 */
618
+ const LOCAL_MEMO_MS = 900;
619
+ /** 磁盘与 TCP 连接数变化慢,低频子进程刷新(1s 帧节奏不受影响)。 */
620
+ const SLOW_TTL_MS = 5000;
621
+ /** df/netstat 单次执行上限(卡住的挂载点不能拖住采集)。 */
622
+ const EXEC_TIMEOUT_MS = 3000;
623
+ function execFileText(command, args) {
624
+ return new Promise((resolve) => {
625
+ execFile(command, args, { timeout: EXEC_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, stdout) => {
626
+ // best-effort:命令缺失/超时/非零退出都当作「拿不到」,返回空串由解析层降级
627
+ resolve(error === null || error === undefined ? String(stdout) : '');
628
+ });
629
+ });
630
+ }
631
+ function readTextFile(path) {
632
+ try {
633
+ return readFileSync(path, 'utf8');
634
+ }
635
+ catch {
636
+ return '';
637
+ }
638
+ }
639
+ /** 读不到就是 undefined(不吞错误语义;调用方据此区分「没有这个文件」)。 */
640
+ function tryRead(path) {
641
+ try {
642
+ return readFileSync(path, 'utf8');
643
+ }
644
+ catch {
645
+ return undefined;
646
+ }
647
+ }
648
+ /** Linux 的 CPU 温度节点:优先 CPU 相关 zone(type 命中),否则第一个可读 zone。 */
649
+ function readThermalC() {
650
+ if (process.platform !== 'linux')
651
+ return undefined;
652
+ let names = [];
653
+ try {
654
+ names = readdirSync('/sys/class/thermal').filter((name) => name.startsWith('thermal_zone'));
655
+ }
656
+ catch {
657
+ return undefined;
658
+ }
659
+ let fallback;
660
+ for (const name of names) {
661
+ const raw = tryRead('/sys/class/thermal/' + name + '/temp');
662
+ if (raw === undefined)
663
+ continue;
664
+ const value = parseThermalTemp(raw);
665
+ if (value === undefined)
666
+ continue;
667
+ const type = readTextFile('/sys/class/thermal/' + name + '/type');
668
+ if (/x86_pkg_temp|cpu-thermal|coretemp|soc_thermal/.test(type))
669
+ return value;
670
+ if (fallback === undefined)
671
+ fallback = value;
672
+ }
673
+ return fallback;
674
+ }
675
+ /**
676
+ * 宿主本机采集器。平台分工:
677
+ * - Linux:/proc 直读(同步、零子进程),只有 df 走子进程、温度走 sysfs;
678
+ * - macOS/BSD:node:os 的 cpus/内存/uptime + df -kP + netstat(TCP 计数与网卡
679
+ * 累计字节)——macOS 没有 /proc、没有 ss、也没有 sysfs 温度,这几项天然缺席;
680
+ * - 其他平台:能拿多少拿多少(CPU/内存/uptime 来自 node:os)。
681
+ */
682
+ export function createLocalSampler() {
683
+ let prevCpu = null;
684
+ let prevNet = null;
685
+ let diskCache = null;
686
+ let tcpCache = null;
687
+ let vmCache = null;
688
+ let memo = null;
689
+ let inflight = null;
690
+ /** 首次采样没有差值窗口:退化为 load1/核心数(明确标注的 best-effort 近似)。 */
691
+ const loadApprox = (cores) => {
692
+ if (cores <= 0)
693
+ return undefined;
694
+ const fromProc = parseLoadavg(readTextFile('/proc/loadavg'));
695
+ let load1 = fromProc !== null ? fromProc.load1 : Number.NaN;
696
+ if (!Number.isFinite(load1)) {
697
+ const values = loadavg();
698
+ load1 = values.length > 0 ? values[0] : Number.NaN;
699
+ }
700
+ if (!Number.isFinite(load1))
701
+ return undefined;
702
+ return Math.min(100, Math.round((load1 / cores) * 1000) / 10);
703
+ };
704
+ const cpuCounters = () => {
705
+ const stat = tryRead('/proc/stat');
706
+ const fromProc = stat !== undefined ? parseProcStat(stat) : null;
707
+ if (fromProc !== null)
708
+ return fromProc;
709
+ const list = cpus();
710
+ if (list.length === 0)
711
+ return null;
712
+ let total = 0;
713
+ let idle = 0;
714
+ for (const cpu of list) {
715
+ const times = cpu.times;
716
+ total += times.user + times.nice + times.sys + times.idle + times.irq;
717
+ idle += times.idle;
718
+ }
719
+ return total > 0 ? { total, idle, cores: list.length } : null;
720
+ };
721
+ const cpuNow = () => {
722
+ const next = cpuCounters();
723
+ if (next === null)
724
+ return {};
725
+ const prev = prevCpu;
726
+ prevCpu = next;
727
+ const pct = prev !== null ? cpuPctBetween(prev, next) : loadApprox(next.cores);
728
+ return pct === undefined ? { cores: next.cores } : { pct, cores: next.cores };
729
+ };
730
+ /** macOS 的内存口径:vm_stat(active + wired + compressed),失败退回 os.freemem。 */
731
+ const vmStatMem = async (total) => {
732
+ const now = Date.now();
733
+ if (vmCache !== null && now - vmCache.at < SLOW_TTL_MS)
734
+ return vmCache.value;
735
+ const text = await execFileText('vm_stat', []);
736
+ const vm = text !== '' ? parseVmStat(text) : null;
737
+ const value = vm === null
738
+ ? null
739
+ : (() => {
740
+ const used = Math.min(total, (vm.active + vm.wired + vm.compressed) * vm.pageSize);
741
+ return { used, total, pct: Math.round((used * 10000) / total) / 100 };
742
+ })();
743
+ vmCache = { at: now, value };
744
+ return value;
745
+ };
746
+ const memNow = async () => {
747
+ const info = tryRead('/proc/meminfo');
748
+ const fromProc = info !== undefined ? parseMeminfo(info) : null;
749
+ if (fromProc !== null)
750
+ return fromProc;
751
+ const total = totalmem();
752
+ if (!Number.isFinite(total) || total <= 0)
753
+ return null;
754
+ if (process.platform === 'darwin') {
755
+ const fromVmStat = await vmStatMem(total);
756
+ if (fromVmStat !== null)
757
+ return fromVmStat;
758
+ }
759
+ const free = freemem();
760
+ const used = Math.max(0, total - (Number.isFinite(free) ? free : 0));
761
+ return { used, total, pct: Math.round((used * 10000) / total) / 100 };
762
+ };
763
+ const uptimeNow = () => {
764
+ const raw = tryRead('/proc/uptime');
765
+ const fromProc = raw !== undefined ? parseProcUptime(raw) : null;
766
+ if (fromProc !== null)
767
+ return fromProc;
768
+ const value = osUptime();
769
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
770
+ };
771
+ const netNow = async () => {
772
+ const dev = tryRead('/proc/net/dev');
773
+ if (dev !== undefined)
774
+ return { ...parseNetDev(dev), at: Date.now() };
775
+ if (process.platform === 'darwin') {
776
+ const text = await execFileText('netstat', ['-ib']);
777
+ if (text !== '')
778
+ return { ...parseNetstatIb(text), at: Date.now() };
779
+ }
780
+ return null;
781
+ };
782
+ const tcpNow = async () => {
783
+ const now = Date.now();
784
+ if (tcpCache !== null && now - tcpCache.at < SLOW_TTL_MS)
785
+ return tcpCache.value;
786
+ let value = null;
787
+ const v4 = tryRead('/proc/net/tcp');
788
+ const v6 = tryRead('/proc/net/tcp6');
789
+ if (v4 !== undefined || v6 !== undefined) {
790
+ value = countTcpEstablished(v4 ?? '') + countTcpEstablished(v6 ?? '');
791
+ }
792
+ else if (process.platform === 'darwin') {
793
+ const text = await execFileText('netstat', ['-an', '-p', 'tcp']);
794
+ if (text !== '')
795
+ value = countNetstatEstablished(text);
796
+ }
797
+ tcpCache = { at: now, value };
798
+ return value;
799
+ };
800
+ const diskNow = async () => {
801
+ const now = Date.now();
802
+ if (diskCache !== null && now - diskCache.at < SLOW_TTL_MS)
803
+ return diskCache.value;
804
+ const text = await execFileText('df', ['-kP', '/']);
805
+ const parsed = text !== '' ? parseDfKb(text) : null;
806
+ const value = parsed === null ? null : { used: parsed.used, total: parsed.total };
807
+ diskCache = { at: now, value };
808
+ return value;
809
+ };
810
+ const collect = async () => {
811
+ const raw = {};
812
+ const cpu = cpuNow();
813
+ if (cpu.pct !== undefined)
814
+ raw.cpuPct = cpu.pct;
815
+ if (cpu.cores !== undefined)
816
+ raw.cores = cpu.cores;
817
+ const mem = await memNow();
818
+ if (mem !== null) {
819
+ raw.memUsed = mem.used;
820
+ raw.memTotal = mem.total;
821
+ raw.memPct = mem.pct;
822
+ }
823
+ const up = uptimeNow();
824
+ if (up !== undefined)
825
+ raw.uptimeSec = up;
826
+ const net = await netNow();
827
+ if (net !== null) {
828
+ // 本地采样器自己就是「采集端」:速率在前后两次采样之间算,宿主不再二次差值
829
+ if (prevNet !== null) {
830
+ const elapsed = (net.at - prevNet.at) / 1000;
831
+ if (elapsed > 0.2) {
832
+ raw.rxRate = Math.max(0, Math.round((net.rx - prevNet.rx) / elapsed));
833
+ raw.txRate = Math.max(0, Math.round((net.tx - prevNet.tx) / elapsed));
834
+ }
835
+ }
836
+ prevNet = net;
837
+ }
838
+ const tcp = await tcpNow();
839
+ if (tcp !== null)
840
+ raw.tcpConns = tcp;
841
+ const disk = await diskNow();
842
+ if (disk !== null) {
843
+ raw.diskUsed = disk.used;
844
+ raw.diskTotal = disk.total;
845
+ raw.diskPct = Math.round((disk.used * 10000) / disk.total) / 100;
846
+ }
847
+ const temp = readThermalC();
848
+ if (temp !== undefined)
849
+ raw.tempC = temp;
850
+ return sanitizeStatsFrame(raw) ?? {};
851
+ };
852
+ return {
853
+ sample() {
854
+ const now = Date.now();
855
+ if (memo !== null && now - memo.at < LOCAL_MEMO_MS)
856
+ return Promise.resolve(memo.value);
857
+ if (inflight !== null)
858
+ return inflight;
859
+ inflight = collect().then((value) => {
860
+ memo = { at: Date.now(), value };
861
+ inflight = null;
862
+ return value;
863
+ }, () => {
864
+ inflight = null;
865
+ return {};
866
+ });
867
+ return inflight;
868
+ },
869
+ };
870
+ }
871
+ /**
872
+ * 进程级单例:所有本地标签采的是同一台机器,按标签各起一份采样器只会让
873
+ * df/netstat 成倍执行、数字还可能不一致。单例内的 memo 让同一秒的多个订阅者
874
+ * 共享结果,且 CPU/网速差值窗口连续(跨订阅更稳)。
875
+ */
876
+ let localSingleton = null;
877
+ export function localStatsSampler() {
878
+ if (localSingleton === null)
879
+ localSingleton = createLocalSampler();
880
+ return localSingleton;
881
+ }
882
+ //# sourceMappingURL=stats.js.map