@profullstack/hqtui-demo 0.1.9 → 0.1.12
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/LICENSE +1 -1
- package/README.md +30 -3
- package/dist/format.js +20 -0
- package/dist/format.js.map +1 -1
- package/dist/main.js +25 -5
- package/dist/main.js.map +1 -1
- package/dist/options.js +21 -0
- package/dist/options.js.map +1 -0
- package/dist/screens/dashboard.js +4 -2
- package/dist/screens/dashboard.js.map +1 -1
- package/dist/simulation.js.map +1 -1
- package/dist/state.js.map +1 -1
- package/dist/system/common.js +102 -1
- package/dist/system/common.js.map +1 -1
- package/dist/system/darwin.js +31 -20
- package/dist/system/darwin.js.map +1 -1
- package/dist/system/index.js.map +1 -1
- package/dist/system/linux-telemetry.js +16 -2
- package/dist/system/linux-telemetry.js.map +1 -1
- package/dist/system/linux-traffic.js +8 -14
- package/dist/system/linux-traffic.js.map +1 -1
- package/dist/system/linux.js +28 -16
- package/dist/system/linux.js.map +1 -1
- package/package.json +2 -2
- package/src/format.ts +17 -0
- package/src/main.ts +29 -9
- package/src/options.ts +20 -0
- package/src/screens/dashboard.ts +4 -2
- package/src/system/common.ts +98 -1
- package/src/system/darwin.ts +33 -20
- package/src/system/linux-telemetry.ts +15 -2
- package/src/system/linux-traffic.ts +8 -14
- package/src/system/linux.ts +33 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/hqtui-demo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "The HQTUI reference dashboard: a btop-grade terminal system monitor. Runs on real system metrics or a deterministic simulation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"audit:scroll": "bun scripts/scrollaudit.ts"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@profullstack/hqtui": "^0.1.
|
|
30
|
+
"@profullstack/hqtui": "^0.1.12"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
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 =
|
|
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.
|
|
54
|
+
case "--version": console.log("hqtui-demo 0.1.12"); 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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
}
|
package/src/screens/dashboard.ts
CHANGED
|
@@ -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
|
-
|
|
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) => {
|
package/src/system/common.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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();
|
package/src/system/darwin.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import type { Collector, SystemSample } from "./types.ts";
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
153
|
+
const command = parts.slice(6).join(" ");
|
|
142
154
|
return {
|
|
143
155
|
pid: Number(parts[0]),
|
|
144
|
-
name,
|
|
145
|
-
cpu: Number(parts[
|
|
146
|
-
mem: Number(parts[
|
|
147
|
-
rss: (Number(parts[
|
|
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[
|
|
150
|
-
state: parts[
|
|
151
|
-
command
|
|
161
|
+
user: parts[4] ?? "-",
|
|
162
|
+
state: parts[5] ?? "-",
|
|
163
|
+
command,
|
|
152
164
|
};
|
|
153
165
|
});
|
|
154
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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>();
|
package/src/system/linux.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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[
|
|
308
|
-
cpu: Number(parts[
|
|
309
|
-
mem: Number(parts[
|
|
310
|
-
rss: (Number(parts[
|
|
311
|
-
threads: Number(parts[
|
|
312
|
-
user: parts[
|
|
313
|
-
state: parts[
|
|
314
|
-
command
|
|
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
|
|
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> {
|