@profullstack/hqtui-demo 0.1.8 → 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/LICENSE +1 -1
- package/README.md +4 -0
- package/dist/format.js +20 -0
- package/dist/format.js.map +1 -1
- package/dist/main.js +32 -21
- package/dist/main.js.map +1 -1
- package/dist/options.js +21 -0
- package/dist/options.js.map +1 -0
- package/dist/screens/components.js +22 -2
- package/dist/screens/components.js.map +1 -1
- package/dist/screens/dashboard.js +18 -3
- package/dist/screens/dashboard.js.map +1 -1
- package/dist/screens/services.js +16 -0
- package/dist/screens/services.js.map +1 -1
- package/dist/screens/sessions.js +16 -0
- package/dist/screens/sessions.js.map +1 -1
- package/dist/screens/traffic.js +16 -0
- package/dist/screens/traffic.js.map +1 -1
- package/dist/simulation.js.map +1 -1
- package/dist/state.js +21 -3
- 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 +36 -25
- package/src/options.ts +20 -0
- package/src/screens/components.ts +22 -2
- package/src/screens/dashboard.ts +18 -3
- package/src/screens/services.ts +16 -0
- package/src/screens/sessions.ts +16 -0
- package/src/screens/traffic.ts +16 -0
- package/src/state.ts +25 -3
- 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
|
@@ -103,8 +103,14 @@ export function componentsScreen(ui: Container, state: DemoState, theme: Theme):
|
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
left.panel({ title: "Log Viewer", size: 11 }, (p) => {
|
|
106
|
+
const clogs = pane(state, "components.logs", state.sample.logs.length, "log");
|
|
106
107
|
p.log({
|
|
107
108
|
entries: state.sample.logs.map((l) => ({ time: l.time, level: l.level, message: l.message, meta: `{${l.meta}}` })),
|
|
109
|
+
// Lines scrolled back from the newest; 0 keeps it tailing.
|
|
110
|
+
fromEnd: clogs.offset,
|
|
111
|
+
scrollbar: true,
|
|
112
|
+
onScroll: (delta) => scrollPane(clogs, -delta),
|
|
113
|
+
onFocus: () => focusPane(state, "components.logs"),
|
|
108
114
|
});
|
|
109
115
|
});
|
|
110
116
|
});
|
|
@@ -115,7 +121,15 @@ export function componentsScreen(ui: Container, state: DemoState, theme: Theme):
|
|
|
115
121
|
r.text("Name", { fg: theme.muted, bold: true });
|
|
116
122
|
r.text("CPU% MEM%", { fg: theme.muted, bold: true, align: "right" });
|
|
117
123
|
});
|
|
118
|
-
|
|
124
|
+
const tree = pane(state, "components.tree", 8);
|
|
125
|
+
p.tree({
|
|
126
|
+
nodes: TREE,
|
|
127
|
+
selected: tree.selected,
|
|
128
|
+
offset: tree.offset,
|
|
129
|
+
followSelection: true,
|
|
130
|
+
onScroll: (delta) => scrollPane(tree, delta),
|
|
131
|
+
onFocus: () => focusPane(state, "components.tree"),
|
|
132
|
+
});
|
|
119
133
|
});
|
|
120
134
|
|
|
121
135
|
right.panel({ title: "Sparklines & Gauges", size: 12 }, (p) => {
|
|
@@ -142,6 +156,7 @@ export function componentsScreen(ui: Container, state: DemoState, theme: Theme):
|
|
|
142
156
|
r.spacer("fill");
|
|
143
157
|
});
|
|
144
158
|
p.spacer(1);
|
|
159
|
+
const paths = pane(state, "components.list", 4);
|
|
145
160
|
p.list({
|
|
146
161
|
items: [
|
|
147
162
|
{ label: "apps/demo", color: theme.primary },
|
|
@@ -149,7 +164,12 @@ export function componentsScreen(ui: Container, state: DemoState, theme: Theme):
|
|
|
149
164
|
{ label: "apps/web" },
|
|
150
165
|
{ label: "docs" },
|
|
151
166
|
],
|
|
152
|
-
selected:
|
|
167
|
+
selected: paths.selected,
|
|
168
|
+
offset: paths.offset,
|
|
169
|
+
followSelection: true,
|
|
170
|
+
onScroll: (delta) => scrollPane(paths, delta),
|
|
171
|
+
onFocus: () => focusPane(state, "components.list"),
|
|
172
|
+
onSelectRow: (row) => { paths.selected = paths.offset + row; },
|
|
153
173
|
bullet: "▸",
|
|
154
174
|
scrollbar: true,
|
|
155
175
|
});
|
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) => {
|
|
@@ -268,7 +270,20 @@ function sensorsPanel(ui: Container, state: DemoState, theme: Theme): void {
|
|
|
268
270
|
|
|
269
271
|
function logsPanel(ui: Container, state: DemoState): void {
|
|
270
272
|
ui.panel({ title: "Logs" }, (p) => {
|
|
271
|
-
|
|
273
|
+
const logs = pane(state, "dashboard.logs", state.sample.logs.length, "log");
|
|
274
|
+
p.log({
|
|
275
|
+
entries: state.sample.logs.map((l) => ({
|
|
276
|
+
time: l.time,
|
|
277
|
+
level: l.level,
|
|
278
|
+
message: l.message,
|
|
279
|
+
meta: `{${l.meta}}`,
|
|
280
|
+
})),
|
|
281
|
+
// Lines scrolled back from the newest; 0 keeps it tailing.
|
|
282
|
+
fromEnd: logs.offset,
|
|
283
|
+
scrollbar: true,
|
|
284
|
+
onScroll: (delta) => scrollPane(logs, -delta),
|
|
285
|
+
onFocus: () => focusPane(state, "dashboard.logs"),
|
|
286
|
+
});
|
|
272
287
|
});
|
|
273
288
|
}
|
|
274
289
|
|
package/src/screens/services.ts
CHANGED
|
@@ -70,8 +70,16 @@ export function servicesScreen(ui: Container, state: DemoState, theme: Theme): v
|
|
|
70
70
|
p.label("(docker not installed or not reachable)", { size: 1 });
|
|
71
71
|
return;
|
|
72
72
|
}
|
|
73
|
+
const containers = pane(state, "services.containers", t.containers.length);
|
|
73
74
|
p.table({
|
|
74
75
|
rows: t.containers,
|
|
76
|
+
selected: containers.selected,
|
|
77
|
+
offset: containers.offset,
|
|
78
|
+
followSelection: true,
|
|
79
|
+
scrollbar: true,
|
|
80
|
+
onScroll: (delta) => scrollPane(containers, delta),
|
|
81
|
+
onFocus: () => focusPane(state, "services.containers"),
|
|
82
|
+
onSelectRow: (row) => { containers.selected = containers.offset + row; },
|
|
75
83
|
zebra: true,
|
|
76
84
|
columns: [
|
|
77
85
|
{ key: "name", title: "Name", min: 12, color: theme.primary },
|
|
@@ -110,8 +118,16 @@ export function servicesScreen(ui: Container, state: DemoState, theme: Theme): v
|
|
|
110
118
|
p.label("No filesystems reported.");
|
|
111
119
|
return;
|
|
112
120
|
}
|
|
121
|
+
const filesystems = pane(state, "services.filesystems", t.filesystems.length);
|
|
113
122
|
p.table({
|
|
114
123
|
rows: t.filesystems,
|
|
124
|
+
selected: filesystems.selected,
|
|
125
|
+
offset: filesystems.offset,
|
|
126
|
+
followSelection: true,
|
|
127
|
+
scrollbar: true,
|
|
128
|
+
onScroll: (delta) => scrollPane(filesystems, delta),
|
|
129
|
+
onFocus: () => focusPane(state, "services.filesystems"),
|
|
130
|
+
onSelectRow: (row) => { filesystems.selected = filesystems.offset + row; },
|
|
115
131
|
zebra: true,
|
|
116
132
|
columns: [
|
|
117
133
|
{ key: "mount", title: "Mount", min: 14, color: theme.primary },
|
package/src/screens/sessions.ts
CHANGED
|
@@ -12,8 +12,16 @@ export function sessionsScreen(ui: Container, state: DemoState, theme: Theme): v
|
|
|
12
12
|
p.label("(`who` reports nothing on this host)", { size: 1 });
|
|
13
13
|
return;
|
|
14
14
|
}
|
|
15
|
+
const active = pane(state, "sessions.active", t.sessions.length);
|
|
15
16
|
p.table({
|
|
16
17
|
rows: t.sessions,
|
|
18
|
+
selected: active.selected,
|
|
19
|
+
offset: active.offset,
|
|
20
|
+
followSelection: true,
|
|
21
|
+
scrollbar: true,
|
|
22
|
+
onScroll: (delta) => scrollPane(active, delta),
|
|
23
|
+
onFocus: () => focusPane(state, "sessions.active"),
|
|
24
|
+
onSelectRow: (row) => { active.selected = active.offset + row; },
|
|
17
25
|
columns: [
|
|
18
26
|
{ key: "user", title: "User", width: 12, color: theme.primary },
|
|
19
27
|
{ key: "tty", title: "TTY", width: 10 },
|
|
@@ -73,8 +81,16 @@ export function sessionsScreen(ui: Container, state: DemoState, theme: Theme): v
|
|
|
73
81
|
p.label("(btmp is usually root-only)", { size: 1 });
|
|
74
82
|
return;
|
|
75
83
|
}
|
|
84
|
+
const failed = pane(state, "sessions.failed", t.failedLogins.length);
|
|
76
85
|
p.table({
|
|
77
86
|
rows: t.failedLogins,
|
|
87
|
+
selected: failed.selected,
|
|
88
|
+
offset: failed.offset,
|
|
89
|
+
followSelection: true,
|
|
90
|
+
scrollbar: true,
|
|
91
|
+
onScroll: (delta) => scrollPane(failed, delta),
|
|
92
|
+
onFocus: () => focusPane(state, "sessions.failed"),
|
|
93
|
+
onSelectRow: (row) => { failed.selected = failed.offset + row; },
|
|
78
94
|
columns: [
|
|
79
95
|
{ key: "user", title: "User", width: 12, color: theme.danger },
|
|
80
96
|
{ key: "from", title: "From", min: 12 },
|
package/src/screens/traffic.ts
CHANGED
|
@@ -116,8 +116,16 @@ export function trafficScreen(ui: Container, state: DemoState, theme: Theme): vo
|
|
|
116
116
|
{ labelWidth: 5, valueWidth: 7 },
|
|
117
117
|
);
|
|
118
118
|
p.divider({ label: "top paths" });
|
|
119
|
+
const paths = pane(state, "traffic.paths", http.topPaths.length);
|
|
119
120
|
p.table({
|
|
120
121
|
rows: http.topPaths,
|
|
122
|
+
selected: paths.selected,
|
|
123
|
+
offset: paths.offset,
|
|
124
|
+
followSelection: true,
|
|
125
|
+
scrollbar: true,
|
|
126
|
+
onScroll: (delta) => scrollPane(paths, delta),
|
|
127
|
+
onFocus: () => focusPane(state, "traffic.paths"),
|
|
128
|
+
onSelectRow: (row) => { paths.selected = paths.offset + row; },
|
|
121
129
|
header: false,
|
|
122
130
|
columns: [
|
|
123
131
|
{ key: "path", title: "Path", min: 20, color: theme.primary },
|
|
@@ -167,8 +175,16 @@ export function trafficScreen(ui: Container, state: DemoState, theme: Theme): vo
|
|
|
167
175
|
p.label("No remote peers.");
|
|
168
176
|
return;
|
|
169
177
|
}
|
|
178
|
+
const remotes = pane(state, "traffic.remotes", t.remotes.length);
|
|
170
179
|
p.table({
|
|
171
180
|
rows: t.remotes,
|
|
181
|
+
selected: remotes.selected,
|
|
182
|
+
offset: remotes.offset,
|
|
183
|
+
followSelection: true,
|
|
184
|
+
scrollbar: true,
|
|
185
|
+
onScroll: (delta) => scrollPane(remotes, delta),
|
|
186
|
+
onFocus: () => focusPane(state, "traffic.remotes"),
|
|
187
|
+
onSelectRow: (row) => { remotes.selected = remotes.offset + row; },
|
|
172
188
|
zebra: true,
|
|
173
189
|
columns: [
|
|
174
190
|
{ key: "host", title: "Host", min: 16, color: theme.accent },
|
package/src/state.ts
CHANGED
|
@@ -56,19 +56,24 @@ export interface Pane {
|
|
|
56
56
|
offset: number;
|
|
57
57
|
/** Row count, refreshed by the screen each frame so keys can clamp. */
|
|
58
58
|
total: number;
|
|
59
|
+
/**
|
|
60
|
+
* A log has no selected row: the arrows move its window instead, and it
|
|
61
|
+
* counts backwards from the newest line rather than forwards from the first.
|
|
62
|
+
*/
|
|
63
|
+
kind: "list" | "log";
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
/**
|
|
62
67
|
* The cursor for one scrollable, created on first use. Screens call this while
|
|
63
68
|
* drawing, which is also what registers the pane as existing.
|
|
64
69
|
*/
|
|
65
|
-
export function pane(state: DemoState, id: string, total: number): Pane {
|
|
70
|
+
export function pane(state: DemoState, id: string, total: number, kind: Pane["kind"] = "list"): Pane {
|
|
66
71
|
const existing = state.panes[id];
|
|
67
72
|
if (existing) {
|
|
68
73
|
existing.total = total;
|
|
69
74
|
return existing;
|
|
70
75
|
}
|
|
71
|
-
const created: Pane = { selected: 0, offset: 0, total };
|
|
76
|
+
const created: Pane = { selected: 0, offset: 0, total, kind };
|
|
72
77
|
state.panes[id] = created;
|
|
73
78
|
// The first pane a screen draws is the one the arrows drive by default.
|
|
74
79
|
if (!state.focused[state.screen]) state.focused[state.screen] = id;
|
|
@@ -89,7 +94,24 @@ export function focusPane(state: DemoState, id: string): void {
|
|
|
89
94
|
export function scrollPane(p: Pane, delta: number, rows = 3): void {
|
|
90
95
|
const max = Math.max(0, p.total - 1);
|
|
91
96
|
p.offset = Math.max(0, Math.min(p.offset + delta * rows, max));
|
|
92
|
-
p.selected = Math.max(p.offset, Math.min(p.selected, max));
|
|
97
|
+
if (p.kind !== "log") p.selected = Math.max(p.offset, Math.min(p.selected, max));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What the arrow keys do to the focused pane. Lists move the selection and let
|
|
102
|
+
* the widget scroll to follow it; logs have nothing to select, so they move
|
|
103
|
+
* their own window.
|
|
104
|
+
*/
|
|
105
|
+
export function moveSelection(state: DemoState, delta: number): void {
|
|
106
|
+
const p = focusedPane(state);
|
|
107
|
+
if (!p || p.total === 0) return;
|
|
108
|
+
if (p.kind === "log") {
|
|
109
|
+
// Down means newer, which is a smaller distance from the end.
|
|
110
|
+
p.offset = Math.max(0, Math.min(p.total - 1, p.offset - delta));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
p.selected = Math.max(0, Math.min(p.total - 1, p.selected + delta));
|
|
114
|
+
p.offset = Math.max(0, Math.min(p.offset, Math.max(0, p.total - 1)));
|
|
93
115
|
}
|
|
94
116
|
|
|
95
117
|
export function createState(
|
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> {
|