@profullstack/hqtui-demo 0.1.9 → 0.1.11

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/src/format.ts CHANGED
@@ -1,8 +1,21 @@
1
+ /**
2
+ * Every value here arrives from a parser reading a file or a command that may
3
+ * be absent, truncated or in an unexpected shape, so this is the last place a
4
+ * non-finite number can be stopped before it reaches the screen. `nvidia-smi`
5
+ * prints "[N/A]", a partial `df` yields "-", and both become NaN.
6
+ */
7
+ const UNAVAILABLE = "\u2014";
8
+
9
+ function finite(value: number): number | null {
10
+ return Number.isFinite(value) ? value : null;
11
+ }
12
+
1
13
  /** Presentation helpers. Numbers in a dashboard must never jitter in width. */
2
14
 
3
15
  const UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
4
16
 
5
17
  export function bytes(value: number, digits = 2): string {
18
+ if (finite(value) === null) return UNAVAILABLE;
6
19
  let v = Math.max(0, value);
7
20
  let unit = 0;
8
21
  while (v >= 1024 && unit < UNITS.length - 1) {
@@ -13,6 +26,7 @@ export function bytes(value: number, digits = 2): string {
13
26
  }
14
27
 
15
28
  export function bitRate(bytesPerSecond: number): string {
29
+ if (finite(bytesPerSecond) === null) return UNAVAILABLE;
16
30
  const bits = Math.max(0, bytesPerSecond) * 8;
17
31
  if (bits >= 1e9) return `${(bits / 1e9).toFixed(1)} Gb/s`;
18
32
  if (bits >= 1e6) return `${(bits / 1e6).toFixed(1)} Mb/s`;
@@ -21,6 +35,7 @@ export function bitRate(bytesPerSecond: number): string {
21
35
  }
22
36
 
23
37
  export function byteRate(bytesPerSecond: number): string {
38
+ if (finite(bytesPerSecond) === null) return UNAVAILABLE;
24
39
  const v = Math.max(0, bytesPerSecond);
25
40
  if (v >= 1e9) return `${(v / 1e9).toFixed(1)} GB/s`;
26
41
  if (v >= 1e6) return `${(v / 1e6).toFixed(1)} MB/s`;
@@ -29,10 +44,12 @@ export function byteRate(bytesPerSecond: number): string {
29
44
  }
30
45
 
31
46
  export function percent(ratio: number, digits = 0): string {
47
+ if (finite(ratio) === null) return UNAVAILABLE;
32
48
  return `${(Math.max(0, Math.min(1, ratio)) * 100).toFixed(digits)}%`;
33
49
  }
34
50
 
35
51
  export function duration(seconds: number): string {
52
+ if (finite(seconds) === null) return UNAVAILABLE;
36
53
  const s = Math.max(0, Math.floor(seconds));
37
54
  const d = Math.floor(s / 86400);
38
55
  const h = Math.floor((s % 86400) / 3600);
package/src/main.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { createApp, themeList, themes, type KeyEvent } from "@profullstack/hqtui";
11
11
  import { createCollector } from "./system/index.ts";
12
+ import { intervalMs } from "./options.ts";
12
13
  import { createState, focusedPane, moveSelection, SCREENS, type ScreenName } from "./state.ts";
13
14
  import {
14
15
  componentsScreen, dashboardScreen, graphicsScreen, inputScreen, networkScreen, servicesScreen,
@@ -46,11 +47,11 @@ function parseArgs(argv: string[]): Options {
46
47
  case "--fps": options.fps = Number(value) || 30; i++; break;
47
48
  case "--theme": options.theme = value ?? "dark"; i++; break;
48
49
  case "--screen": options.screen = (value as ScreenName) ?? "dashboard"; i++; break;
49
- case "--interval": options.interval = Number(value) || 1000; i++; break;
50
+ case "--interval": options.interval = intervalMs(value); i++; break;
50
51
  case "-h":
51
52
  case "--help": printHelp(); process.exit(0);
52
53
  case "-v":
53
- case "--version": console.log("hqtui-demo 0.1.9"); process.exit(0);
54
+ case "--version": console.log("hqtui-demo 0.1.11"); process.exit(0);
54
55
  }
55
56
  }
56
57
  return options;
@@ -112,14 +113,33 @@ async function main(): Promise<void> {
112
113
  });
113
114
 
114
115
  // Metrics refresh on their own clock; rendering runs at the frame rate.
115
- const dt = options.interval / 1000;
116
+ // One refresh at a time. Collecting can outrun the interval the tick-15 path
117
+ // alone allows journalctl five seconds — and every concurrent call mutates the
118
+ // same sample, the same previous-counter state, and the same tick counter that
119
+ // drives the staggered cadences, while spawning its own ps, ss, df and
120
+ // journalctl.
121
+ let refreshing = false;
122
+ let lastRefreshAt = Date.now();
116
123
  const poll = setInterval(() => {
117
- if (state.paused) return;
118
- void collector.refresh(dt).then(() => {
119
- state.sample = collector.current();
120
- state.sensorNote = collector.sensorNote ?? state.sensorNote;
121
- app.invalidate();
122
- });
124
+ if (state.paused || refreshing) return;
125
+ // Every rate is a counter delta divided by this, so it has to be the time
126
+ // that actually passed. A fixed interval overstated every rate by the skip
127
+ // factor whenever a refresh outran its tick — and `sh()` alone allows four
128
+ // seconds per command.
129
+ const now = Date.now();
130
+ const elapsed = Math.max(0.001, (now - lastRefreshAt) / 1000);
131
+ lastRefreshAt = now;
132
+ refreshing = true;
133
+ void collector
134
+ .refresh(elapsed)
135
+ .then(() => {
136
+ state.sample = collector.current();
137
+ state.sensorNote = collector.sensorNote ?? state.sensorNote;
138
+ app.invalidate();
139
+ })
140
+ .finally(() => {
141
+ refreshing = false;
142
+ });
123
143
  }, options.interval);
124
144
  poll.unref?.();
125
145
 
package/src/options.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Option parsing that is worth testing on its own, kept out of main.ts because
3
+ * importing that module starts the dashboard.
4
+ */
5
+
6
+ /**
7
+ * A refresh interval `setInterval` will honour.
8
+ *
9
+ * Node resets any delay outside [1, 2^31-1] — and NaN, and Infinity — to *one
10
+ * millisecond*, so clamping only the floor leaves the failure wide open at the
11
+ * top. `--interval 1e12` asks for one refresh every thirty-one years and gets a
12
+ * one-millisecond loop forking ps, ss and journalctl. `--interval Infinity`
13
+ * additionally makes the elapsed time infinite and every rate derived from it
14
+ * NaN.
15
+ */
16
+ export function intervalMs(value: string | undefined): number {
17
+ const requested = Number(value);
18
+ if (!Number.isFinite(requested)) return 1000;
19
+ return Math.min(3_600_000, Math.max(100, Math.round(requested)));
20
+ }
@@ -119,8 +119,10 @@ function systemPanel(ui: Container, state: DemoState, theme: Theme): void {
119
119
  q.keyValues([
120
120
  { label: "Uptime", value: duration(s.system.uptime), color: theme.accent },
121
121
  { label: "Procs", value: String(s.system.processCount || s.processes.length), color: theme.accent },
122
- { label: "Threads", value: String(s.system.threadCount), color: theme.accent },
123
- { label: "Ctx/s", value: `${(s.system.contextSwitches / 1000).toFixed(1)}K`, color: theme.accent },
122
+ { label: "Threads", value: s.system.threadCount ? String(s.system.threadCount) : "—", color: theme.accent },
123
+ // contextSwitches is cumulative since boot; the per-second rate is
124
+ // what the label claims, and what the Services screen already uses.
125
+ { label: "Ctx/s", value: `${(s.telemetry.kernel.contextSwitchRate / 1000).toFixed(1)}K`, color: theme.accent },
124
126
  ]);
125
127
  });
126
128
  r.panel({ title: "Memory" }, (q) => {
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { open, stat } from "node:fs/promises";
2
3
  import { promisify } from "node:util";
3
4
  import os from "node:os";
4
5
  import type { SystemSample } from "../simulation.ts";
@@ -6,16 +7,112 @@ import { emptyTelemetry } from "./telemetry.ts";
6
7
 
7
8
  const run = promisify(execFile);
8
9
 
9
- /** Run a command, returning "" instead of throwing when it is unavailable. */
10
+ /**
11
+ * Run a command, returning "" instead of throwing when it is unavailable.
12
+ *
13
+ * Whatever it managed to write is kept even when it exits non-zero. `df` exits
14
+ * 1 if any single mount is unreadable while still reporting every other one, so
15
+ * discarding stdout on failure zeroed the capacity of every disk on the machine
16
+ * whenever one stale automount or a departed removable drive was listed.
17
+ */
10
18
  export async function sh(command: string, args: string[], timeout = 4000): Promise<string> {
11
19
  try {
12
20
  const { stdout } = await run(command, args, { timeout, maxBuffer: 8 * 1024 * 1024 });
13
21
  return stdout;
22
+ } catch (error) {
23
+ const partial = (error as { stdout?: string }).stdout;
24
+ return typeof partial === "string" ? partial : "";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * The last `bytes` of a file, as text.
30
+ *
31
+ * Log files are read for their tail, and reading one whole to keep 40 lines is
32
+ * a habit that only shows up in production: a brute-forced auth.log reaching a
33
+ * few hundred MB blocks the event loop for the better part of a second on every
34
+ * refresh, and above the maximum string length it throws and the panel silently
35
+ * empties.
36
+ */
37
+ export async function tailFile(path: string, bytes = 256 * 1024): Promise<string> {
38
+ try {
39
+ const info = await stat(path);
40
+ if (info.size === 0) return "";
41
+ const handle = await open(path, "r");
42
+ try {
43
+ const length = Math.min(bytes, info.size);
44
+ const buffer = Buffer.alloc(length);
45
+ // `bytesRead` matters: the file can be rotated or truncated between the
46
+ // stat and the read, and the untouched tail of the buffer is NUL bytes.
47
+ const { bytesRead } = await handle.read(buffer, 0, length, Math.max(0, info.size - length));
48
+ return buffer.subarray(0, bytesRead).toString("utf8");
49
+ } finally {
50
+ await handle.close();
51
+ }
14
52
  } catch {
15
53
  return "";
16
54
  }
17
55
  }
18
56
 
57
+ /**
58
+ * A process name from its command line, used when `comm` is unavailable.
59
+ *
60
+ * `args` is argv joined by spaces, so an executable whose own path contains a
61
+ * space is ambiguous: "/tmp/Google Chrome 60" could be argv0 "/tmp/Google"
62
+ * with an argument, or "/tmp/Google Chrome" with one. Nothing in the string
63
+ * resolves it, which is why `comm` is read separately and preferred.
64
+ */
65
+ export function processName(args: string): string {
66
+ const argv0 = args.trim().split(/\s+/)[0] ?? "";
67
+ // Kernel threads are already bracketed names, not paths.
68
+ if (argv0.startsWith("[")) return argv0;
69
+ return argv0.split("/").pop() || argv0 || "-";
70
+ }
71
+
72
+ /**
73
+ * Reconcile the two names a process has, neither of which is reliable alone.
74
+ *
75
+ * The accounting name (`comm` on Linux, `ucomm` on macOS) keeps spaces but is
76
+ * truncated — to 15 bytes on Linux, 16 on macOS. The name derived from argv[0]
77
+ * is untruncated but splits at the first space, because `args` is argv joined
78
+ * by spaces and nothing in it says where argv[0] ended.
79
+ *
80
+ * So: trust argv[0], and take the accounting name only when it *extends* it —
81
+ * which is exactly the case where argv[0] was cut at a space. Anything else the
82
+ * accounting name says is not corroborated, and it is not always a name at all:
83
+ * on macOS `ucomm` for one live process here reads "2.1.243".
84
+ *
85
+ * argv0 "Web" comm "Web Content" -> "Web Content"
86
+ * argv0 "StorageManagementService" ucomm "StorageManagemen" -> argv0's
87
+ * argv0 "claude" ucomm "2.1.243" -> argv0's
88
+ */
89
+ export function bestName(accounting: string | undefined, fromArgs: string): string {
90
+ if (!fromArgs || fromArgs === "-") return accounting || fromArgs;
91
+ if (!accounting) return fromArgs;
92
+ return accounting.length > fromArgs.length && accounting.startsWith(fromArgs)
93
+ ? accounting
94
+ : fromArgs;
95
+ }
96
+
97
+ /**
98
+ * pid -> accounting name, read with that name as the only free-form column so
99
+ * its spaces cannot shift anything. Parsing it out of a combined `ps` row is
100
+ * what corrupted every column after it.
101
+ */
102
+ export async function processNames(psArgs: string[]): Promise<Map<number, string>> {
103
+ const names = new Map<number, string>();
104
+ const text = await sh("ps", psArgs);
105
+ for (const line of text.trim().split("\n").slice(1)) {
106
+ const trimmed = line.trim();
107
+ const gap = trimmed.indexOf(" ");
108
+ if (gap < 0) continue;
109
+ const pid = Number(trimmed.slice(0, gap));
110
+ const comm = trimmed.slice(gap + 1).trim();
111
+ if (Number.isFinite(pid) && comm) names.set(pid, comm);
112
+ }
113
+ return names;
114
+ }
115
+
19
116
  export function push(history: number[], value: number, limit = 240): void {
20
117
  history.push(value);
21
118
  if (history.length > limit) history.shift();
@@ -1,6 +1,8 @@
1
1
  import os from "node:os";
2
2
  import type { Collector, SystemSample } from "./types.ts";
3
- import { baseSample, loadAverage, primaryInterface, push, ratePerSecond, sh } from "./common.ts";
3
+ import {
4
+ baseSample, loadAverage, primaryInterface, bestName, processName, processNames, push, ratePerSecond, sh,
5
+ } from "./common.ts";
4
6
 
5
7
  /**
6
8
  * macOS has no /proc, so everything comes from small command-line tools that
@@ -8,7 +10,7 @@ import { baseSample, loadAverage, primaryInterface, push, ratePerSecond, sh } fr
8
10
  */
9
11
  export class DarwinCollector implements Collector {
10
12
  source = "macOS sysctl";
11
- unavailable = ["temperatures", "fan speed"];
13
+ unavailable = ["temperatures", "fan speed", "per-core CPU", "thread count"];
12
14
  private sample = baseSample();
13
15
  private prevCpu: { idle: number; total: number } | null = null;
14
16
  private prevNet: [number, number] | null = null;
@@ -28,12 +30,12 @@ export class DarwinCollector implements Collector {
28
30
  }
29
31
  const load = loadAverage();
30
32
  s.cpu.load = load;
33
+ // `top` reports one aggregate figure. Per-core detail would need
34
+ // host_processor_info, so every core shows the measured total rather than
35
+ // a synthesised spread around it — the README promises that anything a
36
+ // platform cannot provide is reported as unavailable, not fabricated.
31
37
  const cores = Math.max(1, os.cpus().length);
32
- s.cpu.cores = Array.from({ length: cores }, (_, i) => {
33
- // Spread total load across cores with a stable per-core offset.
34
- const jitter = ((i * 37) % 17) / 100;
35
- return Math.max(0, Math.min(1, s.cpu.total + jitter - 0.08));
36
- });
38
+ s.cpu.cores = new Array(cores).fill(s.cpu.total);
37
39
  push(s.cpu.history, s.cpu.total * 100);
38
40
 
39
41
  // Memory via vm_stat page counts.
@@ -133,25 +135,36 @@ export class DarwinCollector implements Collector {
133
135
  }
134
136
 
135
137
  private async updateProcesses(): Promise<void> {
136
- const text = await sh("ps", ["-Ao", "pid,comm,pcpu,pmem,rss,user,state,args", "-r"]);
137
- if (!text) return;
138
- const lines = text.trim().split("\n").slice(1, 60);
139
- this.sample.processes = lines.map((line) => {
138
+ // `comm` is omitted: macOS truncates it to 16 characters, so taking the
139
+ // last path segment yields a fragment — "/System/Applicat" became
140
+ // "Applicat", and a 16-character path ending in "/" became "".
141
+ const text = await sh("ps", ["-Ao", "pid,pcpu,pmem,rss,user,state,args", "-r"]);
142
+ if (!text) {
143
+ if (!this.unavailable.includes("processes")) this.unavailable.push("processes");
144
+ return;
145
+ }
146
+ // `comm` in its own read, where its spaces cannot shift a column.
147
+ // macOS `comm` is the full path truncated to 16 characters; `ucomm` is the
148
+ // accounting name, which is what a process table wants.
149
+ const names = await processNames(["-Ao", "pid,ucomm"]);
150
+ const rows = text.trim().split("\n").slice(1);
151
+ this.sample.processes = rows.slice(0, 59).map((line) => {
140
152
  const parts = line.trim().split(/\s+/);
141
- const name = (parts[1] ?? "-").split("/").pop() ?? "-";
153
+ const command = parts.slice(6).join(" ");
142
154
  return {
143
155
  pid: Number(parts[0]),
144
- name,
145
- cpu: Number(parts[2]) || 0,
146
- mem: Number(parts[3]) || 0,
147
- rss: (Number(parts[4]) || 0) * 1024,
156
+ name: bestName(names.get(Number(parts[0])), processName(command)),
157
+ cpu: Number(parts[1]) || 0,
158
+ mem: Number(parts[2]) || 0,
159
+ rss: (Number(parts[3]) || 0) * 1024,
148
160
  threads: 1,
149
- user: parts[5] ?? "-",
150
- state: parts[6] ?? "-",
151
- command: parts.slice(7).join(" "),
161
+ user: parts[4] ?? "-",
162
+ state: parts[5] ?? "-",
163
+ command,
152
164
  };
153
165
  });
154
- this.sample.system.processCount = this.sample.processes.length;
166
+ // Every row, not the truncated table.
167
+ this.sample.system.processCount = rows.length;
155
168
  }
156
169
 
157
170
  current(): SystemSample {
@@ -1,6 +1,6 @@
1
1
  import { readFile, readdir } from "node:fs/promises";
2
2
  import os from "node:os";
3
- import { sh, push } from "./common.ts";
3
+ import { sh, push, tailFile } from "./common.ts";
4
4
  import type {
5
5
  Connection, Container, Filesystem, Interface, JournalEntry, KernelStats,
6
6
  Listener, LoginEvent, ProcessStates, PowerStats, ServiceUnit, Session, Telemetry, GpuStats,
@@ -155,6 +155,7 @@ export async function interfaces(dt: number, history: Map<string, Interface>): P
155
155
  if (!netdev) return [];
156
156
  const addresses = os.networkInterfaces();
157
157
  const out: Interface[] = [];
158
+ const seen = new Set<string>();
158
159
 
159
160
  for (const line of netdev.split("\n").slice(2)) {
160
161
  const match = /^\s*([\w.@-]+):\s*(.*)$/.exec(line);
@@ -172,6 +173,7 @@ export async function interfaces(dt: number, history: Map<string, Interface>): P
172
173
  const txRate = previous && txBytes >= previous[1] ? (txBytes - previous[1]) / dt : 0;
173
174
  previousInterfaces[name] = [rxBytes, txBytes];
174
175
 
176
+ seen.add(name);
175
177
  const address = (addresses[name] ?? []).find((a) => a.family === "IPv4");
176
178
  const existing = history.get(name);
177
179
  const entry: Interface = {
@@ -194,6 +196,16 @@ export async function interfaces(dt: number, history: Map<string, Interface>): P
194
196
  history.set(name, entry);
195
197
  out.push(entry);
196
198
  }
199
+ // Both maps are keyed by interface name and neither was ever pruned. On a
200
+ // container host the veth* names churn constantly, and each retained entry
201
+ // holds two 240-element history arrays — unbounded growth over an uptime.
202
+ for (const name of Object.keys(previousInterfaces)) {
203
+ if (!seen.has(name)) delete previousInterfaces[name];
204
+ }
205
+ for (const name of history.keys()) {
206
+ if (!seen.has(name)) history.delete(name);
207
+ }
208
+
197
209
  return out;
198
210
  }
199
211
 
@@ -542,7 +554,8 @@ export async function journal(limit = 60): Promise<JournalEntry[]> {
542
554
  }
543
555
 
544
556
  for (const path of ["/var/log/syslog", "/var/log/messages"]) {
545
- const raw = await read(path);
557
+ // Tailed, not read whole; only the last `limit` lines are used anyway.
558
+ const raw = await tailFile(path);
546
559
  if (!raw) continue;
547
560
  return raw.trim().split("\n").slice(-limit).map((line) => {
548
561
  const match = /^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+([^:[]+)/.exec(line);
@@ -1,6 +1,5 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
- import { open } from "node:fs/promises";
3
- import { sh } from "./common.ts";
2
+ import { sh, tailFile } from "./common.ts";
4
3
 
5
4
  /**
6
5
  * Protocol-level visibility without root: socket classification, kernel
@@ -224,7 +223,8 @@ export async function sshEvents(limit = 40): Promise<SshEvent[]> {
224
223
  const text = await sh("journalctl", [
225
224
  "-u", "ssh", "-u", "sshd", "-n", String(limit * 2), "--no-pager", "--output=short-iso",
226
225
  ], 5000);
227
- const source = text || (await read("/var/log/auth.log"));
226
+ // Tailed, not read whole: this file grows without bound under a brute force.
227
+ const source = text || (await tailFile("/var/log/auth.log"));
228
228
  if (!source) return [];
229
229
 
230
230
  const events: SshEvent[] = [];
@@ -302,17 +302,11 @@ export async function http(tailBytes = 256 * 1024): Promise<HttpStats | null> {
302
302
  }
303
303
  if (!path) return null;
304
304
 
305
- let text = "";
306
- try {
307
- const handle = await open(path, "r");
308
- const start = Math.max(0, size - tailBytes);
309
- const buffer = Buffer.alloc(Math.min(tailBytes, size));
310
- await handle.read(buffer, 0, buffer.length, start);
311
- await handle.close();
312
- text = buffer.toString("utf8");
313
- } catch {
314
- return null;
315
- }
305
+ // Shares `tailFile`'s handling of a rotation between the stat and the read,
306
+ // which otherwise leaves the tail of the buffer as NUL bytes — and those flow
307
+ // into `split("\n")` and into the request-rate denominator below.
308
+ const text = await tailFile(path, tailBytes);
309
+ if (!text) return null;
316
310
 
317
311
  const lines = text.split("\n").slice(1).filter(Boolean);
318
312
  const statusClasses = new Map<string, number>();
@@ -1,7 +1,10 @@
1
1
  import { readFile, readdir } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import type { Collector, SystemSample } from "./types.ts";
4
- import { baseSample, loadAverage, primaryInterface, push, ratePerSecond, sh } from "./common.ts";
4
+ import { bitRate } from "../format.ts";
5
+ import {
6
+ baseSample, loadAverage, primaryInterface, bestName, processName, processNames, push, ratePerSecond, sh,
7
+ } from "./common.ts";
5
8
  import type { Interface } from "./telemetry.ts";
6
9
  import * as telemetry from "./linux-telemetry.ts";
7
10
  import * as traffic from "./linux-traffic.ts";
@@ -92,7 +95,7 @@ export class LinuxCollector implements Collector {
92
95
  const ctxt = /^ctxt (\d+)/m.exec(stat);
93
96
  if (ctxt) s.system.contextSwitches = Number(ctxt[1]);
94
97
  const procs = /^procs_running (\d+)/m.exec(stat);
95
- if (procs) s.system.threadCount = Number(procs[1]);
98
+ // `procs_running` is runnable processes; the thread total comes from ps.
96
99
 
97
100
  // Memory.
98
101
  const mem = parseMeminfo(meminfo);
@@ -172,7 +175,10 @@ export class LinuxCollector implements Collector {
172
175
  s.network.downPeak = Math.max(s.network.downPeak, s.network.downRate);
173
176
  s.network.upPeak = Math.max(s.network.upPeak, s.network.upRate);
174
177
  const speed = await read(`/sys/class/net/${iface.name}/speed`);
175
- s.network.speed = speed.trim() && Number(speed) > 0 ? `${Number(speed) / 1000} Gb/s` : "-";
178
+ // /sys reports Mb/s. Dividing unconditionally rendered a 100 Mb/s NIC as
179
+ // "0.1 Gb/s"; bitRate picks the unit the number belongs in.
180
+ const mbps = Number(speed);
181
+ s.network.speed = speed.trim() && mbps > 0 ? bitRate((mbps * 1e6) / 8) : "-";
176
182
 
177
183
  await Promise.all([this.updateProcesses(), this.updateTemperatures()]);
178
184
  await this.updateTelemetry(dt);
@@ -294,27 +300,39 @@ export class LinuxCollector implements Collector {
294
300
  }
295
301
 
296
302
  private async updateProcesses(): Promise<void> {
297
- const text = await sh("ps", ["-eo", "pid,comm,pcpu,pmem,rss,nlwp,user,state,args", "--sort=-pcpu"]);
303
+ // `comm` is deliberately absent: it can contain spaces, which shifts every
304
+ // column parsed after it. Every field here is space-free, so `args` — which
305
+ // may contain anything — is the only free-form one and it comes last.
306
+ const text = await sh("ps", ["-eo", "pid,pcpu,pmem,rss,nlwp,user,state,args", "--sort=-pcpu"]);
298
307
  if (!text) {
299
308
  if (!this.unavailable.includes("processes")) this.unavailable.push("processes");
300
309
  return;
301
310
  }
302
- const lines = text.trim().split("\n").slice(1, 60);
303
- this.sample.processes = lines.map((line) => {
311
+ // `comm` in its own read, where its spaces cannot shift a column.
312
+ const names = await processNames(["-eo", "pid,comm"]);
313
+ const rows = text.trim().split("\n").slice(1);
314
+ this.sample.processes = rows.slice(0, 59).map((line) => {
304
315
  const parts = line.trim().split(/\s+/);
316
+ const command = parts.slice(7).join(" ");
305
317
  return {
306
318
  pid: Number(parts[0]),
307
- name: parts[1] ?? "-",
308
- cpu: Number(parts[2]) || 0,
309
- mem: Number(parts[3]) || 0,
310
- rss: (Number(parts[4]) || 0) * 1024,
311
- threads: Number(parts[5]) || 1,
312
- user: parts[6] ?? "-",
313
- state: parts[7] ?? "-",
314
- command: parts.slice(8).join(" "),
319
+ name: bestName(names.get(Number(parts[0])), processName(command)),
320
+ cpu: Number(parts[1]) || 0,
321
+ mem: Number(parts[2]) || 0,
322
+ rss: (Number(parts[3]) || 0) * 1024,
323
+ threads: Number(parts[4]) || 1,
324
+ user: parts[5] ?? "-",
325
+ state: parts[6] ?? "-",
326
+ command,
315
327
  };
316
328
  });
317
- this.sample.system.processCount = this.sample.processes.length;
329
+ // Count every row, not the truncated table: this used to overwrite the real
330
+ // total from /proc with at most 59, so the figure alternated every tick.
331
+ this.sample.system.processCount = rows.length;
332
+ this.sample.system.threadCount = rows.reduce(
333
+ (total, line) => total + (Number(line.trim().split(/\s+/)[4]) || 0),
334
+ 0,
335
+ );
318
336
  }
319
337
 
320
338
  private async updateTemperatures(): Promise<void> {