@yourfam/yf-vitals 0.1.0 → 0.2.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/README.md CHANGED
@@ -37,12 +37,14 @@ One live dashboard. Refresh every **1.0s** (or `--interval`, or **2.0s** with `-
37
37
  | **CPU** | Overall utilization 0–100%, plus physical / logical core counts |
38
38
  | **RAM** | Percent used, plus used / total (`GiB` / `MiB`) |
39
39
  | **DSK** | Read and write **rates** (KiB/s, MiB/s, GiB/s) |
40
- | **NET** | Send ↑ and receive ↓ in **Mbps** (1 Mbps = 1e6 bit/s) |
40
+ | **NET** | Send ↑ and receive ↓ in **kbps / Mbps / Gbps** (decimal bits; 1 Mbps = 1e6 bit/s) |
41
41
  | **GPU** | Utilization and VRAM when telemetry exists; **the row is omitted** when it does not |
42
42
 
43
43
  Header: `yf-vitals` · hostname · OS · `NP/NL` cores · RAM total · tick interval.
44
44
 
45
- Each percent row has a 20-cell bar and a sparkline of the last 60 ticks. Disk and net sparklines scale to the **max in that buffer** (rates have no 100% ceiling). CPU / RAM / GPU sparklines are percent 0–100.
45
+ Each percent row has a 20-cell bar and a sparkline of the last 60 ticks. Unused spark slots use the lowest tick (`▁` / `_`), not blank space. Bar fill stays the row color below 50%, turns **yellow at 50%**, **red at 80%**. Sparklines stay the row color.
46
+
47
+ Disk and net each show **two** sparks (`R`/`W`, `↑`/`↓`) scaled to the **max of that pair** in the buffer (rates have no 100% ceiling). CPU / RAM / GPU sparks are percent 0–100.
46
48
 
47
49
  First disk/net tick may show `—` while counters settle.
48
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yourfam/yf-vitals",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Live terminal dashboard for CPU, RAM, disk, net, and optional GPU",
5
5
  "type": "module",
6
6
  "bin": {
package/src/args.js CHANGED
@@ -98,7 +98,8 @@ Usage:
98
98
  --version, -V Package version
99
99
 
100
100
  q / Q / Ctrl+C quit. GPU row is omitted when the OS has no GPU telemetry.
101
- Disk rates are MiB/s; network rates are Mbps.
102
- Disk / net sparklines scale to the max in the last 60 ticks (no 100% ceiling).
101
+ Disk rates are KiB/s–GiB/s; network rates are kbps/Mbps/Gbps.
102
+ Disk / net sparks are split (R/W, ↑/↓) and scale to the pair max in the last 60 ticks.
103
+ Bar fill turns yellow ≥50% and red ≥80%. Unused spark slots use the lowest tick.
103
104
  Needs a terminal (no pipes). Node 20+.`;
104
105
  }
package/src/bar.js CHANGED
@@ -55,8 +55,9 @@ export function sparkline(values, width, opts = {}) {
55
55
  if (w === 0) return "";
56
56
  const charset = opts.ascii ? ASC_SPARK : UNI_SPARK;
57
57
  const list = Array.isArray(values) ? values : [];
58
+ const floor = charset[0];
58
59
  if (list.length === 0) {
59
- return " ".repeat(w);
60
+ return floor.repeat(w);
60
61
  }
61
62
  const slice = list.slice(-w);
62
63
  const pad = w - slice.length;
@@ -64,7 +65,21 @@ export function sparkline(values, width, opts = {}) {
64
65
  const dataMax = slice.reduce((m, v) => (v > m ? v : m), 0);
65
66
  const max = explicitMax != null ? explicitMax : dataMax;
66
67
  const chars = slice.map((v) => sparkChar(v, max, charset));
67
- return " ".repeat(pad) + chars.join("");
68
+ return floor.repeat(pad) + chars.join("");
69
+ }
70
+
71
+ export const HEAT_WARM = 50;
72
+ export const HEAT_HOT = 80;
73
+
74
+ /**
75
+ * @param {number} percent
76
+ * @returns {"ok" | "warm" | "hot"}
77
+ */
78
+ export function heatLevel(percent) {
79
+ const n = Number(percent);
80
+ if (!Number.isFinite(n) || n < HEAT_WARM) return "ok";
81
+ if (n < HEAT_HOT) return "warm";
82
+ return "hot";
68
83
  }
69
84
 
70
85
  /**
package/src/color.js CHANGED
@@ -12,6 +12,7 @@ export function createColor(opts) {
12
12
  magenta: (value) => pc.magenta(String(value)),
13
13
  green: (value) => pc.green(String(value)),
14
14
  yellow: (value) => pc.yellow(String(value)),
15
+ red: (value) => pc.red(String(value)),
15
16
  dim: (value) => pc.dim(String(value)),
16
17
  };
17
18
  }
package/src/disk.js CHANGED
@@ -1,23 +1,43 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
- const PS_DISK = `
3
+ const PS_COUNTERS = `
4
4
  $ErrorActionPreference = 'SilentlyContinue'
5
5
  while (($line = [Console]::In.ReadLine()) -ne $null) {
6
6
  if ($line -eq 'q') { break }
7
+ $dr = [int64]0
8
+ $dw = [int64]0
9
+ $rx = [int64]0
10
+ $tx = [int64]0
7
11
  $d = Get-CimInstance -ClassName Win32_PerfRawData_PerfDisk_PhysicalDisk -Filter "Name='_Total'"
8
- if ($d) { Write-Output ("{0} {1}" -f $d.DiskReadBytesPersec, $d.DiskWriteBytesPersec) }
9
- else { Write-Output "n n" }
12
+ if ($d) {
13
+ $dr = [int64]$d.DiskReadBytesPersec
14
+ $dw = [int64]$d.DiskWriteBytesPersec
15
+ }
16
+ Get-NetAdapterStatistics | ForEach-Object {
17
+ $name = [string]$_.Name
18
+ if ($name -match 'vEthernet|Loopback|Bluetooth|WSL|Hyper-V|Virtual|Pseudo|isatap|Teredo') { return }
19
+ $rx += [int64]$_.ReceivedBytes
20
+ $tx += [int64]$_.SentBytes
21
+ }
22
+ Write-Output ("{0} {1} {2} {3}" -f $dr, $dw, $rx, $tx)
10
23
  }
11
24
  `.trim();
12
25
 
13
26
  /**
14
27
  * @param {string} line
15
- * @returns {{ rx: number, wx: number } | null}
28
+ * @returns {{ rx: number, wx: number, netRx: number, netTx: number } | null}
16
29
  */
17
30
  export function parseDiskCounterLine(line) {
18
- const m = String(line || "").trim().match(/^(\d+)\s+(\d+)$/);
31
+ const m = String(line || "")
32
+ .trim()
33
+ .match(/^(\d+)\s+(\d+)(?:\s+(\d+)\s+(\d+))?$/);
19
34
  if (!m) return null;
20
- return { rx: Number(m[1]), wx: Number(m[2]) };
35
+ return {
36
+ rx: Number(m[1]),
37
+ wx: Number(m[2]),
38
+ netRx: m[3] != null ? Number(m[3]) : 0,
39
+ netTx: m[4] != null ? Number(m[4]) : 0,
40
+ };
21
41
  }
22
42
 
23
43
  function encodedCommand(script) {
@@ -26,11 +46,12 @@ function encodedCommand(script) {
26
46
 
27
47
  /**
28
48
  * Long-lived PowerShell so WMI stays warm (~10ms/tick after first query).
49
+ * One round-trip: disk bytes + physical NIC byte counters.
29
50
  */
30
51
  export function createWindowsDiskReader() {
31
52
  let child = null;
32
53
  let buf = "";
33
- /** @type {{ resolve: (v: { rx: number, wx: number } | null) => void }[]} */
54
+ /** @type {{ resolve: (v: ReturnType<typeof parseDiskCounterLine>) => void }[]} */
34
55
  let pending = [];
35
56
  let closed = false;
36
57
 
@@ -43,7 +64,7 @@ export function createWindowsDiskReader() {
43
64
  if (child || closed) return;
44
65
  child = spawn(
45
66
  "powershell.exe",
46
- ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand(PS_DISK)],
67
+ ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand(PS_COUNTERS)],
47
68
  { windowsHide: true, stdio: ["pipe", "pipe", "ignore"] },
48
69
  );
49
70
  child.stdout.setEncoding("utf8");
@@ -67,7 +88,7 @@ export function createWindowsDiskReader() {
67
88
 
68
89
  return {
69
90
  /**
70
- * @returns {Promise<{ rx: number, wx: number } | null>}
91
+ * @returns {Promise<ReturnType<typeof parseDiskCounterLine>>}
71
92
  */
72
93
  read() {
73
94
  if (closed) return Promise.resolve(null);
package/src/format.js CHANGED
@@ -39,15 +39,19 @@ export function formatByteRate(bytesPerSec) {
39
39
  }
40
40
 
41
41
  /**
42
- * Network throughput in decimal Mbps (1 Mbps = 1e6 bit/s).
42
+ * Network throughput in decimal bit/s, auto-scaled (kbps / Mbps / Gbps).
43
+ * 1 kbps = 1e3 bit/s, 1 Mbps = 1e6 bit/s.
43
44
  * @param {number | null | undefined} bytesPerSec
44
45
  */
45
46
  export function formatBitRate(bytesPerSec) {
46
47
  if (bytesPerSec == null) return "—";
47
48
  const n = Number(bytesPerSec);
48
49
  if (!Number.isFinite(n) || n < 0) return "—";
49
- const mbps = (n * 8) / 1e6;
50
- return `${mbps.toFixed(1)} Mbps`;
50
+ const bits = n * 8;
51
+ if (bits >= 1e9) return `${(bits / 1e9).toFixed(2)} Gbps`;
52
+ if (bits >= 1e6) return `${(bits / 1e6).toFixed(1)} Mbps`;
53
+ if (bits >= 1e3) return `${(bits / 1e3).toFixed(1)} kbps`;
54
+ return `${bits.toFixed(0)} bps`;
51
55
  }
52
56
 
53
57
  /**
package/src/history.js CHANGED
@@ -5,12 +5,14 @@ import { HISTORY_SIZE } from "./bar.js";
5
5
  * @property {number[]} cpu
6
6
  * @property {number[]} ram
7
7
  * @property {number[]} gpu
8
- * @property {number[]} dsk
9
- * @property {number[]} net
8
+ * @property {number[]} dskR
9
+ * @property {number[]} dskW
10
+ * @property {number[]} netUp
11
+ * @property {number[]} netDn
10
12
  */
11
13
 
12
14
  export function createHistory() {
13
- return { cpu: [], ram: [], gpu: [], dsk: [], net: [] };
15
+ return { cpu: [], ram: [], gpu: [], dskR: [], dskW: [], netUp: [], netDn: [] };
14
16
  }
15
17
 
16
18
  /**
@@ -33,26 +35,13 @@ export function pushSample(buf, value, size = HISTORY_SIZE) {
33
35
  * @returns {History}
34
36
  */
35
37
  export function appendHistory(history, snap) {
36
- const cpu = snap.cpu?.percent;
37
- const ram = snap.ram?.percent;
38
- const gpu = snap.gpu?.percent;
39
- const read = snap.disk?.readBps;
40
- const write = snap.disk?.writeBps;
41
- const tx = snap.net?.txBps;
42
- const rx = snap.net?.rxBps;
43
- let dsk;
44
- if (read != null || write != null) {
45
- dsk = (read ?? 0) + (write ?? 0);
46
- }
47
- let net;
48
- if (tx != null || rx != null) {
49
- net = (tx ?? 0) + (rx ?? 0);
50
- }
51
38
  return {
52
- cpu: pushSample(history.cpu, cpu),
53
- ram: pushSample(history.ram, ram),
54
- gpu: pushSample(history.gpu, gpu),
55
- dsk: pushSample(history.dsk, dsk),
56
- net: pushSample(history.net, net),
39
+ cpu: pushSample(history.cpu, snap.cpu?.percent),
40
+ ram: pushSample(history.ram, snap.ram?.percent),
41
+ gpu: pushSample(history.gpu, snap.gpu?.percent),
42
+ dskR: pushSample(history.dskR, snap.disk?.readBps),
43
+ dskW: pushSample(history.dskW, snap.disk?.writeBps),
44
+ netUp: pushSample(history.netUp, snap.net?.txBps),
45
+ netDn: pushSample(history.netDn, snap.net?.rxBps),
57
46
  };
58
47
  }
package/src/render.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { useAscii } from "./ascii.js";
2
- import { BAR_WIDTH, bar, barCharset, sparkWidth, sparkline } from "./bar.js";
2
+ import {
3
+ BAR_WIDTH,
4
+ bar,
5
+ barCharset,
6
+ heatLevel,
7
+ sparkWidth,
8
+ sparkline,
9
+ } from "./bar.js";
3
10
  import { createColor, stripAnsi } from "./color.js";
4
11
  import {
5
12
  formatBitRate,
@@ -39,6 +46,58 @@ export function fitLine(line, columns) {
39
46
  return out;
40
47
  }
41
48
 
49
+ /**
50
+ * @param {ReturnType<typeof createColor>} color
51
+ * @param {number} percent
52
+ * @param {"cyan" | "magenta" | "green"} row
53
+ */
54
+ export function heatPaint(color, percent, row) {
55
+ const level = heatLevel(percent);
56
+ if (level === "hot") return color.red;
57
+ if (level === "warm") return color.yellow;
58
+ return color[row];
59
+ }
60
+
61
+ /**
62
+ * Percent bar: fill takes heat color, empty stays dim. Sparkline stays the row color.
63
+ *
64
+ * @param {number} percent
65
+ * @param {ReturnType<typeof createColor>} color
66
+ * @param {"cyan" | "magenta" | "green"} row
67
+ * @param {boolean} ascii
68
+ */
69
+ export function paintPercentBar(percent, color, row, ascii) {
70
+ const cs = barCharset(ascii);
71
+ const raw = bar(percent, BAR_WIDTH, cs);
72
+ const filled = raw.replaceAll(cs.empty, "").length;
73
+ const fill = raw.slice(0, filled);
74
+ const empty = raw.slice(filled);
75
+ const paint = heatPaint(color, percent, row);
76
+ return `[${paint(fill)}${color.dim(empty)}]`;
77
+ }
78
+
79
+ /**
80
+ * Two labeled sparks on one line, sharing a max so the pair is comparable.
81
+ *
82
+ * @param {string} leftLabel
83
+ * @param {number[]} leftVals
84
+ * @param {string} rightLabel
85
+ * @param {number[]} rightVals
86
+ * @param {number} sparkW
87
+ * @param {boolean} ascii
88
+ */
89
+ export function splitSparkLine(leftLabel, leftVals, rightLabel, rightVals, sparkW, ascii) {
90
+ const leftTag = `${leftLabel} `;
91
+ const mid = ` ${rightLabel} `;
92
+ const overhead = leftTag.length + mid.length;
93
+ const inner = Math.max(overhead + 8, Number(sparkW) || 16);
94
+ const each = Math.max(4, Math.floor((inner - overhead) / 2));
95
+ const pairMax = Math.max(0, ...leftVals, ...rightVals);
96
+ const left = sparkline(leftVals, each, { ascii, max: pairMax || undefined });
97
+ const right = sparkline(rightVals, each, { ascii, max: pairMax || undefined });
98
+ return `${leftTag}${left}${mid}${right}`;
99
+ }
100
+
42
101
  /**
43
102
  * @param {import("./sample.js").GpuSample | null | undefined} gpu
44
103
  * @param {{ history: number[], ascii: boolean, color: ReturnType<typeof createColor>, sparkW: number }} opts
@@ -47,18 +106,15 @@ export function fitLine(line, columns) {
47
106
  export function buildGpuRow(gpu, opts) {
48
107
  if (gpu == null) return null;
49
108
  const { ascii, color, sparkW, history } = opts;
50
- const cs = barCharset(ascii);
51
109
  const pct = gpu.percent;
52
- const barStr =
53
- pct == null ? null : bar(pct, BAR_WIDTH, cs);
54
110
  const pctStr = pct == null ? "n/a" : formatPercent(pct);
55
111
  const mem =
56
112
  gpu.used != null && gpu.total != null ? formatBytePair(gpu.used, gpu.total) : "";
57
113
  const label = color.green("GPU");
58
114
  const head =
59
- barStr == null
115
+ pct == null
60
116
  ? `${label} ${pctStr}${mem ? ` ${mem}` : ""}`
61
- : `${label} [${color.green(barStr)}] ${pctStr}${mem ? ` ${mem}` : ""}`;
117
+ : `${label} ${paintPercentBar(pct, color, "green", ascii)} ${pctStr}${mem ? ` ${mem}` : ""}`;
62
118
  const spark = color.green(sparkline(history, sparkW, { ascii, max: 100 }));
63
119
  return [head, ` ${spark}`];
64
120
  }
@@ -74,7 +130,6 @@ export function renderFrame(snap, history, opts = {}) {
74
130
  const ascii = useAscii(env);
75
131
  const color = createColor({ isTTY: Boolean(opts.isTTY), env });
76
132
  const sparkW = sparkWidth(columns);
77
- const cs = barCharset(ascii);
78
133
  const interval = formatInterval(opts.intervalSec ?? 1);
79
134
  const cores = formatCores(snap.physical, snap.logical);
80
135
  const ramTot = formatBytes(snap.ramTotal);
@@ -91,10 +146,9 @@ export function renderFrame(snap, history, opts = {}) {
91
146
  const lines = [header, ""];
92
147
 
93
148
  if (snap.cpu) {
94
- const b = color.cyan(bar(snap.cpu.percent, BAR_WIDTH, cs));
95
149
  const detail = formatCoresDetail(snap.physical, snap.logical);
96
150
  lines.push(
97
- `${color.cyan("CPU")} [${b}] ${formatPercent(snap.cpu.percent)} ${detail}`,
151
+ `${color.cyan("CPU")} ${paintPercentBar(snap.cpu.percent, color, "cyan", ascii)} ${formatPercent(snap.cpu.percent)} ${detail}`,
98
152
  );
99
153
  lines.push(` ${color.cyan(sparkline(history.cpu, sparkW, { ascii, max: 100 }))}`);
100
154
  } else {
@@ -104,9 +158,8 @@ export function renderFrame(snap, history, opts = {}) {
104
158
  lines.push("");
105
159
 
106
160
  if (snap.ram) {
107
- const b = color.magenta(bar(snap.ram.percent, BAR_WIDTH, cs));
108
161
  lines.push(
109
- `${color.magenta("RAM")} [${b}] ${formatPercent(snap.ram.percent)} ${formatBytePair(snap.ram.used, snap.ram.total)}`,
162
+ `${color.magenta("RAM")} ${paintPercentBar(snap.ram.percent, color, "magenta", ascii)} ${formatPercent(snap.ram.percent)} ${formatBytePair(snap.ram.used, snap.ram.total)}`,
110
163
  );
111
164
  lines.push(` ${color.magenta(sparkline(history.ram, sparkW, { ascii, max: 100 }))}`);
112
165
  } else {
@@ -130,21 +183,29 @@ export function renderFrame(snap, history, opts = {}) {
130
183
  const r = formatByteRate(snap.disk.readBps);
131
184
  const w = formatByteRate(snap.disk.writeBps);
132
185
  lines.push(`${color.green("DSK")} R ${r.padStart(11)} W ${w.padStart(11)}`);
133
- lines.push(` ${color.green(sparkline(history.dsk, sparkW, { ascii }))}`);
186
+ lines.push(
187
+ ` ${color.green(splitSparkLine("R", history.dskR, "W", history.dskW, sparkW, ascii))}`,
188
+ );
134
189
  } else {
135
190
  lines.push(`${color.green("DSK")} n/a`);
136
- lines.push(` ${color.green(sparkline(history.dsk, sparkW, { ascii }))}`);
191
+ lines.push(
192
+ ` ${color.green(splitSparkLine("R", history.dskR, "W", history.dskW, sparkW, ascii))}`,
193
+ );
137
194
  }
138
195
  lines.push("");
139
196
 
140
197
  if (snap.net) {
141
198
  const up = formatBitRate(snap.net.txBps);
142
199
  const down = formatBitRate(snap.net.rxBps);
143
- lines.push(`${color.yellow("NET")} ↑ ${up.padStart(10)} ↓ ${down.padStart(10)}`);
144
- lines.push(` ${color.yellow(sparkline(history.net, sparkW, { ascii }))}`);
200
+ lines.push(`${color.yellow("NET")} ↑ ${up.padStart(12)} ↓ ${down.padStart(12)}`);
201
+ lines.push(
202
+ ` ${color.yellow(splitSparkLine("↑", history.netUp, "↓", history.netDn, sparkW, ascii))}`,
203
+ );
145
204
  } else {
146
205
  lines.push(`${color.yellow("NET")} n/a`);
147
- lines.push(` ${color.yellow(sparkline(history.net, sparkW, { ascii }))}`);
206
+ lines.push(
207
+ ` ${color.yellow(splitSparkLine("↑", history.netUp, "↓", history.netDn, sparkW, ascii))}`,
208
+ );
148
209
  }
149
210
  lines.push("");
150
211
  lines.push("q quit");
package/src/sample.js CHANGED
@@ -100,23 +100,22 @@ export function gpuBytes(n, vramMb) {
100
100
  */
101
101
  export function pickGpu(controllers) {
102
102
  if (!Array.isArray(controllers) || controllers.length === 0) return null;
103
- const usable = controllers.filter((c) => {
103
+ /** @type {GpuSample[]} */
104
+ const usable = [];
105
+ for (const c of controllers) {
104
106
  const util = num(c.utilizationGpu);
105
- const total = gpuBytes(num(c.memoryTotal), num(c.vram));
106
107
  const used = gpuBytes(num(c.memoryUsed), null);
107
- return util != null || (total != null && total > 0) || used != null;
108
- });
108
+ const total = gpuBytes(num(c.memoryTotal), null);
109
+ if (util == null && (used == null || total == null)) continue;
110
+ usable.push({
111
+ percent: util ?? 0,
112
+ used,
113
+ total,
114
+ });
115
+ }
109
116
  if (usable.length === 0) return null;
110
- const c =
111
- usable.find((x) => num(x.utilizationGpu) != null) || usable[0];
112
- const percent = num(c.utilizationGpu);
113
- const total = gpuBytes(num(c.memoryTotal), num(c.vram));
114
- const used = gpuBytes(num(c.memoryUsed), null);
115
- return {
116
- percent,
117
- used,
118
- total,
119
- };
117
+ usable.sort((a, b) => (b.total ?? 0) - (a.total ?? 0));
118
+ return usable[0];
120
119
  }
121
120
 
122
121
  /**
@@ -174,15 +173,16 @@ function mergePair(next, last, a, b) {
174
173
  /**
175
174
  * @param {os.CpuInfo[]} cpus
176
175
  */
177
- export function cpuIdleTotal(cpus) {
176
+ export function cpuIdleTotal(cpus, windows = process.platform === "win32") {
178
177
  let idle = 0;
179
178
  let total = 0;
180
179
  for (const c of cpus) {
181
180
  const t = c.times;
181
+ const irq = t.irq || 0;
182
+ const sys = windows ? Math.max(0, (t.sys || 0) - irq) : t.sys || 0;
182
183
  const i = t.idle || 0;
183
- const sum = (t.user || 0) + (t.nice || 0) + (t.sys || 0) + (t.irq || 0) + i;
184
184
  idle += i;
185
- total += sum;
185
+ total += (t.user || 0) + (t.nice || 0) + sys + irq + i;
186
186
  }
187
187
  return { idle, total };
188
188
  }
@@ -204,6 +204,16 @@ export function cpuPercentFromDelta(prev, curr) {
204
204
  /**
205
205
  * @param {Array<{ iface?: string, rx_bytes?: number, tx_bytes?: number, operstate?: string, internal?: boolean }>} stats
206
206
  */
207
+ const VIRTUAL_IFACE =
208
+ /^(lo|lo0)$|Loopback|vEthernet|Hyper-V|WSL|Bluetooth|Virtual|VPN|TAP|TUN|Tailscale|Pseudo|isatap|Teredo/i;
209
+
210
+ /**
211
+ * @param {string} name
212
+ */
213
+ export function isVirtualIface(name) {
214
+ return VIRTUAL_IFACE.test(String(name || ""));
215
+ }
216
+
207
217
  export function sumNetBytes(stats) {
208
218
  let rx = 0;
209
219
  let tx = 0;
@@ -211,12 +221,8 @@ export function sumNetBytes(stats) {
211
221
  if (!Array.isArray(stats)) return { rx: null, tx: null };
212
222
  for (const n of stats) {
213
223
  const name = String(n.iface || "");
214
- if (n.internal || name === "lo" || name === "lo0" || /^Loopback/i.test(name)) {
215
- continue;
216
- }
217
- if (n.operstate && n.operstate !== "up" && n.operstate !== "unknown") {
218
- continue;
219
- }
224
+ if (n.internal || isVirtualIface(name)) continue;
225
+ if (n.operstate && n.operstate !== "up") continue;
220
226
  const r = num(n.rx_bytes);
221
227
  const t = num(n.tx_bytes);
222
228
  if (r == null && t == null) continue;
@@ -282,34 +288,28 @@ export function createSampler(si) {
282
288
  }
283
289
 
284
290
  async function sampleCpu(lib) {
285
- const curr = cpuIdleTotal(os.cpus());
286
- if (!prevCpu) {
287
- prevCpu = curr;
288
- await new Promise((r) => setTimeout(r, 50));
289
- const curr2 = cpuIdleTotal(os.cpus());
290
- const pctFast = cpuPercentFromDelta(prevCpu, curr2);
291
- prevCpu = curr2;
292
- if (pctFast != null) return { percent: pctFast };
293
- } else {
294
- const pct = cpuPercentFromDelta(prevCpu, curr);
295
- prevCpu = curr;
296
- if (pct != null) return { percent: pct };
297
- }
298
291
  try {
299
292
  const load = await lib.currentLoad();
300
293
  const pct = num(load?.currentLoad);
301
294
  if (pct != null) return { percent: Math.min(100, Math.max(0, pct)) };
302
295
  } catch {
303
- // keep n/a
296
+ // fall through to os.cpus() idle-delta
304
297
  }
305
- return null;
298
+ const curr = cpuIdleTotal(os.cpus());
299
+ const pct = cpuPercentFromDelta(prevCpu, curr);
300
+ prevCpu = curr;
301
+ if (pct == null) return null;
302
+ return { percent: pct };
306
303
  }
307
304
 
308
305
  async function sampleRam(lib, ramTotal) {
309
306
  try {
310
307
  const mem = await lib.mem();
311
308
  const total = num(mem?.total) ?? ramTotal;
312
- const used = num(mem?.used) ?? num(mem?.active);
309
+ const available = num(mem?.available) ?? num(mem?.free);
310
+ let used = null;
311
+ if (total != null && available != null) used = Math.max(0, total - available);
312
+ else used = num(mem?.used) ?? num(mem?.active);
313
313
  if (used == null || total == null || total <= 0) return null;
314
314
  return {
315
315
  percent: Math.min(100, Math.max(0, (used / total) * 100)),
@@ -329,21 +329,20 @@ export function createSampler(si) {
329
329
  }
330
330
  }
331
331
 
332
- async function sampleDisk(lib, ts) {
333
- let rx = null;
334
- let wx = null;
335
- try {
336
- const fs = await lib.fsStats();
337
- rx = num(fs?.rx);
338
- wx = num(fs?.wx);
339
- } catch {
340
- // Windows fsStats is always null; fall through
341
- }
342
- if (rx == null && wx == null && winDisk) {
343
- const w = await winDisk.read();
344
- if (w) {
345
- rx = w.rx;
346
- wx = w.wx;
332
+ /**
333
+ * @param {number} ts
334
+ * @param {{ rx: number, wx: number } | null} win
335
+ */
336
+ async function sampleDisk(lib, ts, win) {
337
+ let rx = win ? num(win.rx) : null;
338
+ let wx = win ? num(win.wx) : null;
339
+ if (rx == null && wx == null) {
340
+ try {
341
+ const fs = await lib.fsStats();
342
+ rx = num(fs?.rx);
343
+ wx = num(fs?.wx);
344
+ } catch {
345
+ // Windows fsStats is always null
347
346
  }
348
347
  }
349
348
  if (rx == null && wx == null) return { readBps: null, writeBps: null };
@@ -353,17 +352,27 @@ export function createSampler(si) {
353
352
  return { readBps, writeBps };
354
353
  }
355
354
 
356
- async function sampleNet(lib, ts) {
357
- try {
358
- const stats = await lib.networkStats("*");
359
- const { rx, tx } = sumNetBytes(Array.isArray(stats) ? stats : stats ? [stats] : []);
360
- const rxBps = rateFromCounters(prevNet?.rx, rx, prevTs, ts);
361
- const txBps = rateFromCounters(prevNet?.tx, tx, prevTs, ts);
362
- if (rx != null && tx != null) prevNet = { rx, tx };
363
- return { txBps, rxBps };
364
- } catch {
365
- return { txBps: null, rxBps: null };
355
+ /**
356
+ * @param {number} ts
357
+ * @param {{ netRx?: number, netTx?: number } | null} win
358
+ */
359
+ async function sampleNet(lib, ts, win) {
360
+ let rx = win ? num(win.netRx) : null;
361
+ let tx = win ? num(win.netTx) : null;
362
+ if (rx == null && tx == null) {
363
+ try {
364
+ const stats = await lib.networkStats("*");
365
+ const summed = sumNetBytes(Array.isArray(stats) ? stats : stats ? [stats] : []);
366
+ rx = summed.rx;
367
+ tx = summed.tx;
368
+ } catch {
369
+ return { txBps: null, rxBps: null };
370
+ }
366
371
  }
372
+ const rxBps = rateFromCounters(prevNet?.rx, rx, prevTs, ts);
373
+ const txBps = rateFromCounters(prevNet?.tx, tx, prevTs, ts);
374
+ if (rx != null && tx != null) prevNet = { rx, tx };
375
+ return { txBps, rxBps };
367
376
  }
368
377
 
369
378
  async function sampleGpu(lib) {
@@ -383,12 +392,13 @@ export function createSampler(si) {
383
392
  const lib = await loadSi();
384
393
  if (!staticP) staticP = loadStatic();
385
394
  const meta = await staticP;
395
+ const win = winDisk ? await winDisk.read().catch(() => null) : null;
386
396
  const ts = Date.now();
387
397
  const [cpu, ram, disk, net, gpu] = await Promise.all([
388
398
  sampleCpu(lib).catch(() => last?.cpu ?? null),
389
399
  sampleRam(lib, meta.ramTotal).catch(() => last?.ram ?? null),
390
- sampleDisk(lib, ts).catch(() => last?.disk ?? { readBps: null, writeBps: null }),
391
- sampleNet(lib, ts).catch(() => last?.net ?? { txBps: null, rxBps: null }),
400
+ sampleDisk(lib, ts, win).catch(() => last?.disk ?? { readBps: null, writeBps: null }),
401
+ sampleNet(lib, ts, win).catch(() => last?.net ?? { txBps: null, rxBps: null }),
392
402
  sampleGpu(lib).catch(() => last?.gpu ?? null),
393
403
  ]);
394
404
  prevTs = ts;