@profullstack/hqtui-demo 0.1.1 → 0.1.3
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 +51 -8
- package/dist/main.js +34 -11
- package/dist/main.js.map +1 -1
- package/dist/screens/index.js +4 -0
- package/dist/screens/index.js.map +1 -1
- package/dist/screens/network.js +86 -0
- package/dist/screens/network.js.map +1 -0
- package/dist/screens/services.js +121 -0
- package/dist/screens/services.js.map +1 -0
- package/dist/screens/sessions.js +80 -0
- package/dist/screens/sessions.js.map +1 -0
- package/dist/screens/traffic.js +183 -0
- package/dist/screens/traffic.js.map +1 -0
- package/dist/simulation.js +6 -0
- package/dist/simulation.js.map +1 -1
- package/dist/state.js +4 -1
- package/dist/state.js.map +1 -1
- package/dist/system/common.js +2 -0
- package/dist/system/common.js.map +1 -1
- package/dist/system/linux-telemetry.js +420 -0
- package/dist/system/linux-telemetry.js.map +1 -0
- package/dist/system/linux-traffic.js +282 -0
- package/dist/system/linux-traffic.js.map +1 -0
- package/dist/system/linux.js +90 -26
- package/dist/system/linux.js.map +1 -1
- package/dist/system/simulated-telemetry.js +267 -0
- package/dist/system/simulated-telemetry.js.map +1 -0
- package/dist/system/telemetry.js +44 -0
- package/dist/system/telemetry.js.map +1 -0
- package/package.json +4 -3
- package/src/main.ts +26 -11
- package/src/screens/index.ts +4 -0
- package/src/screens/network.ts +91 -0
- package/src/screens/services.ts +136 -0
- package/src/screens/sessions.ts +85 -0
- package/src/screens/traffic.ts +199 -0
- package/src/simulation.ts +12 -0
- package/src/state.ts +7 -2
- package/src/system/common.ts +2 -0
- package/src/system/linux-telemetry.ts +431 -0
- package/src/system/linux-traffic.ts +375 -0
- package/src/system/linux.ts +95 -21
- package/src/system/simulated-telemetry.ts +280 -0
- package/src/system/telemetry.ts +263 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { sh, push } from "./common.ts";
|
|
4
|
+
import type {
|
|
5
|
+
Connection, Container, Filesystem, Interface, JournalEntry, KernelStats,
|
|
6
|
+
Listener, LoginEvent, ProcessStates, PowerStats, ServiceUnit, Session, Telemetry, GpuStats,
|
|
7
|
+
} from "./telemetry.ts";
|
|
8
|
+
|
|
9
|
+
async function read(path: string): Promise<string> {
|
|
10
|
+
try {
|
|
11
|
+
return await readFile(path, "utf8");
|
|
12
|
+
} catch {
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `who -u`: who is logged in right now, and how idle they are. */
|
|
18
|
+
export async function sessions(): Promise<Session[]> {
|
|
19
|
+
const text = await sh("who", ["-u"]);
|
|
20
|
+
if (!text) return [];
|
|
21
|
+
return text.trim().split("\n").filter(Boolean).map((line) => {
|
|
22
|
+
const parts = line.trim().split(/\s+/);
|
|
23
|
+
return {
|
|
24
|
+
user: parts[0] ?? "-",
|
|
25
|
+
tty: parts[1] ?? "-",
|
|
26
|
+
loginAt: `${parts[2] ?? ""} ${parts[3] ?? ""}`.trim(),
|
|
27
|
+
idle: parts[4] ?? ".",
|
|
28
|
+
what: parts[5] ?? "",
|
|
29
|
+
// `who -u` puts the origin host in parentheses at the end when there is one.
|
|
30
|
+
from: /\(([^)]+)\)/.exec(line)?.[1] ?? "local",
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseLastLine(line: string, status: LoginEvent["status"]): LoginEvent | null {
|
|
36
|
+
if (!line.trim() || /^(wtmp|btmp|reboot|$)/.test(line)) return null;
|
|
37
|
+
const parts = line.trim().split(/\s+/);
|
|
38
|
+
if (parts.length < 4) return null;
|
|
39
|
+
const stillLoggedIn = /still logged in/.test(line);
|
|
40
|
+
return {
|
|
41
|
+
user: parts[0],
|
|
42
|
+
tty: parts[1],
|
|
43
|
+
from: /^\d+\.\d+\.\d+\.\d+$/.test(parts[2]) || parts[2].includes(".") ? parts[2] : "local",
|
|
44
|
+
when: parts.slice(-7, -3).join(" ") || parts.slice(3, 7).join(" "),
|
|
45
|
+
status: stillLoggedIn ? "still" : status,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Successful logins from wtmp. */
|
|
50
|
+
export async function logins(limit = 20): Promise<LoginEvent[]> {
|
|
51
|
+
const text = await sh("last", ["-n", String(limit), "-w"]);
|
|
52
|
+
if (!text) return [];
|
|
53
|
+
return text.split("\n").map((l) => parseLastLine(l, "ok")).filter((e): e is LoginEvent => e !== null);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Failed logins from btmp. Usually root-only, so an empty list is normal. */
|
|
57
|
+
export async function failedLogins(limit = 15): Promise<LoginEvent[]> {
|
|
58
|
+
const text = await sh("lastb", ["-n", String(limit), "-w"]);
|
|
59
|
+
if (!text) return [];
|
|
60
|
+
return text.split("\n").map((l) => parseLastLine(l, "failed")).filter((e): e is LoginEvent => e !== null);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function splitAddress(address: string): { host: string; port: string } {
|
|
64
|
+
const index = address.lastIndexOf(":");
|
|
65
|
+
if (index === -1) return { host: address, port: "" };
|
|
66
|
+
return { host: address.slice(0, index), port: address.slice(index + 1) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Sockets {
|
|
70
|
+
connections: Connection[];
|
|
71
|
+
listeners: Listener[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `ss -tunap`: established connections and listening sockets. */
|
|
75
|
+
export async function sockets(): Promise<Sockets> {
|
|
76
|
+
const text = await sh("ss", ["-tunap"]);
|
|
77
|
+
if (!text) return { connections: [], listeners: [] };
|
|
78
|
+
|
|
79
|
+
const connections: Connection[] = [];
|
|
80
|
+
const listeners: Listener[] = [];
|
|
81
|
+
for (const line of text.trim().split("\n").slice(1)) {
|
|
82
|
+
const parts = line.trim().split(/\s+/);
|
|
83
|
+
if (parts.length < 5) continue;
|
|
84
|
+
const [proto, state, , , local, remote] = parts;
|
|
85
|
+
const process = /users:\(\("([^"]+)",pid=(\d+)/.exec(line);
|
|
86
|
+
const label = process ? `${process[1]}/${process[2]}` : "-";
|
|
87
|
+
if (state === "LISTEN") {
|
|
88
|
+
const { host, port } = splitAddress(local);
|
|
89
|
+
listeners.push({ proto, address: host, port, process: label });
|
|
90
|
+
} else if (state === "ESTAB" || proto.startsWith("udp")) {
|
|
91
|
+
connections.push({ proto, local, remote: remote ?? "-", state, process: label });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { connections, listeners };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** systemd units that are loaded, with failures first. */
|
|
98
|
+
export async function services(limit = 60): Promise<ServiceUnit[]> {
|
|
99
|
+
const text = await sh("systemctl", [
|
|
100
|
+
"list-units", "--type=service", "--all", "--no-pager", "--no-legend", "--plain",
|
|
101
|
+
]);
|
|
102
|
+
if (!text) return [];
|
|
103
|
+
const units = text.trim().split("\n").map((line) => {
|
|
104
|
+
const parts = line.trim().split(/\s+/);
|
|
105
|
+
if (parts.length < 4) return null;
|
|
106
|
+
return {
|
|
107
|
+
name: parts[0].replace(/\.service$/, ""),
|
|
108
|
+
active: parts[2],
|
|
109
|
+
sub: parts[3],
|
|
110
|
+
description: parts.slice(4).join(" "),
|
|
111
|
+
};
|
|
112
|
+
}).filter((u): u is ServiceUnit => u !== null);
|
|
113
|
+
|
|
114
|
+
const rank = (unit: ServiceUnit) =>
|
|
115
|
+
unit.active === "failed" ? 0 : unit.active === "active" ? 1 : 2;
|
|
116
|
+
units.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
|
|
117
|
+
return units.slice(0, limit);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Running docker containers, when docker is reachable without a password. */
|
|
121
|
+
export async function containers(): Promise<Container[]> {
|
|
122
|
+
const text = await sh("docker", [
|
|
123
|
+
"ps", "--no-trunc", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}",
|
|
124
|
+
], 3000);
|
|
125
|
+
if (!text) return [];
|
|
126
|
+
return text.trim().split("\n").filter(Boolean).map((line) => {
|
|
127
|
+
const [id, name, image, status] = line.split("\t");
|
|
128
|
+
return { id: (id ?? "").slice(0, 12), name: name ?? "-", image: image ?? "-", status: status ?? "-", cpu: "-", memory: "-" };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface Counters {
|
|
133
|
+
[name: string]: [number, number];
|
|
134
|
+
}
|
|
135
|
+
const previousInterfaces: Counters = {};
|
|
136
|
+
|
|
137
|
+
/** Every network interface with its own throughput history. */
|
|
138
|
+
export async function interfaces(dt: number, history: Map<string, Interface>): Promise<Interface[]> {
|
|
139
|
+
const netdev = await read("/proc/net/dev");
|
|
140
|
+
if (!netdev) return [];
|
|
141
|
+
const addresses = os.networkInterfaces();
|
|
142
|
+
const out: Interface[] = [];
|
|
143
|
+
|
|
144
|
+
for (const line of netdev.split("\n").slice(2)) {
|
|
145
|
+
const match = /^\s*([\w.@-]+):\s*(.*)$/.exec(line);
|
|
146
|
+
if (!match) continue;
|
|
147
|
+
const name = match[1];
|
|
148
|
+
if (name === "lo") continue;
|
|
149
|
+
const values = match[2].trim().split(/\s+/).map(Number);
|
|
150
|
+
const [rxBytes, , rxErrs, rxDrop] = values;
|
|
151
|
+
const txBytes = values[8];
|
|
152
|
+
const txErrs = values[10] ?? 0;
|
|
153
|
+
const txDrop = values[11] ?? 0;
|
|
154
|
+
|
|
155
|
+
const previous = previousInterfaces[name];
|
|
156
|
+
const rxRate = previous && rxBytes >= previous[0] ? (rxBytes - previous[0]) / dt : 0;
|
|
157
|
+
const txRate = previous && txBytes >= previous[1] ? (txBytes - previous[1]) / dt : 0;
|
|
158
|
+
previousInterfaces[name] = [rxBytes, txBytes];
|
|
159
|
+
|
|
160
|
+
const address = (addresses[name] ?? []).find((a) => a.family === "IPv4");
|
|
161
|
+
const existing = history.get(name);
|
|
162
|
+
const entry: Interface = {
|
|
163
|
+
name,
|
|
164
|
+
ip: address?.address ?? "-",
|
|
165
|
+
mac: address?.mac ?? "-",
|
|
166
|
+
state: (await read(`/sys/class/net/${name}/operstate`)).trim() || "unknown",
|
|
167
|
+
mtu: Number((await read(`/sys/class/net/${name}/mtu`)).trim()) || 0,
|
|
168
|
+
rxRate,
|
|
169
|
+
txRate,
|
|
170
|
+
rxTotal: rxBytes,
|
|
171
|
+
txTotal: txBytes,
|
|
172
|
+
rxHistory: existing?.rxHistory ?? [],
|
|
173
|
+
txHistory: existing?.txHistory ?? [],
|
|
174
|
+
errors: (rxErrs ?? 0) + txErrs,
|
|
175
|
+
drops: (rxDrop ?? 0) + txDrop,
|
|
176
|
+
};
|
|
177
|
+
push(entry.rxHistory, rxRate);
|
|
178
|
+
push(entry.txHistory, txRate);
|
|
179
|
+
history.set(name, entry);
|
|
180
|
+
out.push(entry);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Mounted filesystems with capacity and inode usage. */
|
|
186
|
+
export async function filesystems(): Promise<Filesystem[]> {
|
|
187
|
+
const [sizes, inodes] = await Promise.all([
|
|
188
|
+
sh("df", ["-kPT", "-x", "tmpfs", "-x", "devtmpfs", "-x", "squashfs", "-x", "overlay"]),
|
|
189
|
+
sh("df", ["-iP", "-x", "tmpfs", "-x", "devtmpfs", "-x", "squashfs", "-x", "overlay"]),
|
|
190
|
+
]);
|
|
191
|
+
if (!sizes) return [];
|
|
192
|
+
|
|
193
|
+
const inodeByMount = new Map<string, [number, number]>();
|
|
194
|
+
for (const line of inodes.trim().split("\n").slice(1)) {
|
|
195
|
+
const parts = line.trim().split(/\s+/);
|
|
196
|
+
if (parts.length < 6) continue;
|
|
197
|
+
inodeByMount.set(parts[parts.length - 1], [Number(parts[2]), Number(parts[1])]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return sizes.trim().split("\n").slice(1).map((line) => {
|
|
201
|
+
const parts = line.trim().split(/\s+/);
|
|
202
|
+
if (parts.length < 7) return null;
|
|
203
|
+
const mount = parts[parts.length - 1];
|
|
204
|
+
const [inodesUsed, inodesTotal] = inodeByMount.get(mount) ?? [0, 0];
|
|
205
|
+
return {
|
|
206
|
+
device: parts[0],
|
|
207
|
+
type: parts[1],
|
|
208
|
+
size: Number(parts[2]) * 1024,
|
|
209
|
+
used: Number(parts[3]) * 1024,
|
|
210
|
+
mount,
|
|
211
|
+
inodesUsed,
|
|
212
|
+
inodesTotal,
|
|
213
|
+
};
|
|
214
|
+
}).filter((f): f is Filesystem => f !== null).slice(0, 8);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let previousKernel: { ctxt: number; intr: number; forks: number; at: number } | null = null;
|
|
218
|
+
|
|
219
|
+
/** Kernel counters: context switches, interrupts, forks, entropy, fd usage. */
|
|
220
|
+
export async function kernel(dt: number): Promise<KernelStats> {
|
|
221
|
+
const [stat, vmstat, entropy, fileNr] = await Promise.all([
|
|
222
|
+
read("/proc/stat"),
|
|
223
|
+
read("/proc/vmstat"),
|
|
224
|
+
read("/proc/sys/kernel/random/entropy_avail"),
|
|
225
|
+
read("/proc/sys/fs/file-nr"),
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
const number = (source: string, pattern: RegExp): number => Number(pattern.exec(source)?.[1] ?? 0);
|
|
229
|
+
const ctxt = number(stat, /^ctxt (\d+)/m);
|
|
230
|
+
const intr = Number(/^intr (\d+)/m.exec(stat)?.[1] ?? 0);
|
|
231
|
+
const forks = number(stat, /^processes (\d+)/m);
|
|
232
|
+
|
|
233
|
+
let contextSwitchRate = 0;
|
|
234
|
+
let interruptRate = 0;
|
|
235
|
+
let forkRate = 0;
|
|
236
|
+
if (previousKernel && dt > 0) {
|
|
237
|
+
contextSwitchRate = Math.max(0, (ctxt - previousKernel.ctxt) / dt);
|
|
238
|
+
interruptRate = Math.max(0, (intr - previousKernel.intr) / dt);
|
|
239
|
+
forkRate = Math.max(0, (forks - previousKernel.forks) / dt);
|
|
240
|
+
}
|
|
241
|
+
previousKernel = { ctxt, intr, forks, at: Date.now() };
|
|
242
|
+
|
|
243
|
+
const fd = fileNr.trim().split(/\s+/).map(Number);
|
|
244
|
+
return {
|
|
245
|
+
contextSwitches: ctxt,
|
|
246
|
+
contextSwitchRate,
|
|
247
|
+
interrupts: intr,
|
|
248
|
+
interruptRate,
|
|
249
|
+
forks,
|
|
250
|
+
forkRate,
|
|
251
|
+
procsRunning: number(stat, /^procs_running (\d+)/m),
|
|
252
|
+
procsBlocked: number(stat, /^procs_blocked (\d+)/m),
|
|
253
|
+
entropy: Number(entropy.trim()) || 0,
|
|
254
|
+
openFiles: fd[0] ?? 0,
|
|
255
|
+
maxFiles: fd[2] ?? 0,
|
|
256
|
+
bootTime: number(stat, /^btime (\d+)/m),
|
|
257
|
+
pageIn: number(vmstat, /^pgpgin (\d+)/m),
|
|
258
|
+
pageOut: number(vmstat, /^pgpgout (\d+)/m),
|
|
259
|
+
swapIn: number(vmstat, /^pswpin (\d+)/m),
|
|
260
|
+
swapOut: number(vmstat, /^pswpout (\d+)/m),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Temperatures from hwmon and, when that is empty, the thermal zones. */
|
|
265
|
+
export async function temperatures(): Promise<{ label: string; value: number; max: number }[]> {
|
|
266
|
+
const out: { label: string; value: number; max: number }[] = [];
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
for (const chip of await readdir("/sys/class/hwmon")) {
|
|
270
|
+
const base = `/sys/class/hwmon/${chip}`;
|
|
271
|
+
const name = (await read(`${base}/name`)).trim();
|
|
272
|
+
for (const entry of await readdir(base).catch(() => [] as string[])) {
|
|
273
|
+
if (!/^temp\d+_input$/.test(entry)) continue;
|
|
274
|
+
const value = Number(await read(`${base}/${entry}`)) / 1000;
|
|
275
|
+
if (!Number.isFinite(value) || value <= 0 || value > 150) continue;
|
|
276
|
+
const label = (await read(`${base}/${entry.replace("_input", "_label")}`)).trim();
|
|
277
|
+
const max = Number(await read(`${base}/${entry.replace("_input", "_crit")}`)) / 1000;
|
|
278
|
+
out.push({
|
|
279
|
+
label: label || `${name} ${entry.replace("_input", "")}`,
|
|
280
|
+
value,
|
|
281
|
+
max: Number.isFinite(max) && max > 0 ? max : 100,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
// No hwmon: containers and most VMs.
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Thermal zones are present on many machines that expose no hwmon at all.
|
|
290
|
+
if (out.length === 0) {
|
|
291
|
+
try {
|
|
292
|
+
for (const zone of await readdir("/sys/class/thermal")) {
|
|
293
|
+
if (!zone.startsWith("thermal_zone")) continue;
|
|
294
|
+
const value = Number(await read(`/sys/class/thermal/${zone}/temp`)) / 1000;
|
|
295
|
+
if (!Number.isFinite(value) || value <= 0 || value > 150) continue;
|
|
296
|
+
const type = (await read(`/sys/class/thermal/${zone}/type`)).trim();
|
|
297
|
+
out.push({ label: type || zone, value, max: 100 });
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
// Neither interface exists; the UI reports temperatures as unavailable.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return out.slice(0, 12);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** GPU stats, when the NVIDIA tools are installed. */
|
|
307
|
+
export async function gpus(): Promise<GpuStats[]> {
|
|
308
|
+
const text = await sh("nvidia-smi", [
|
|
309
|
+
"--query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw",
|
|
310
|
+
"--format=csv,noheader,nounits",
|
|
311
|
+
], 3000);
|
|
312
|
+
if (!text) return [];
|
|
313
|
+
return text.trim().split("\n").filter(Boolean).map((line) => {
|
|
314
|
+
const [name, utilization, used, total, temperature, power] = line.split(",").map((v) => v.trim());
|
|
315
|
+
return {
|
|
316
|
+
name,
|
|
317
|
+
utilization: Number(utilization) / 100,
|
|
318
|
+
memoryUsed: Number(used) * 1024 ** 2,
|
|
319
|
+
memoryTotal: Number(total) * 1024 ** 2,
|
|
320
|
+
temperature: Number(temperature),
|
|
321
|
+
power: Number(power),
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Battery and AC state from /sys/class/power_supply. */
|
|
327
|
+
export async function power(): Promise<PowerStats | null> {
|
|
328
|
+
try {
|
|
329
|
+
const supplies = await readdir("/sys/class/power_supply");
|
|
330
|
+
let battery: PowerStats | null = null;
|
|
331
|
+
let acConnected = false;
|
|
332
|
+
|
|
333
|
+
for (const supply of supplies) {
|
|
334
|
+
const base = `/sys/class/power_supply/${supply}`;
|
|
335
|
+
const type = (await read(`${base}/type`)).trim();
|
|
336
|
+
if (type === "Mains") {
|
|
337
|
+
acConnected = (await read(`${base}/online`)).trim() === "1";
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (type !== "Battery") continue;
|
|
341
|
+
const capacity = Number((await read(`${base}/capacity`)).trim());
|
|
342
|
+
const status = (await read(`${base}/status`)).trim();
|
|
343
|
+
const currentNow = Number((await read(`${base}/current_now`)).trim());
|
|
344
|
+
const voltageNow = Number((await read(`${base}/voltage_now`)).trim());
|
|
345
|
+
battery = {
|
|
346
|
+
battery: Number.isFinite(capacity) ? capacity : 0,
|
|
347
|
+
charging: status === "Charging",
|
|
348
|
+
timeRemaining: status,
|
|
349
|
+
powerDraw: Number.isFinite(currentNow * voltageNow) ? (currentNow * voltageNow) / 1e12 : 0,
|
|
350
|
+
acConnected,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (battery) battery.acConnected = acConnected;
|
|
354
|
+
return battery;
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const LEVELS = ["EMERG", "ALERT", "CRIT", "ERROR", "WARN", "NOTICE", "INFO", "DEBUG"];
|
|
361
|
+
|
|
362
|
+
/** Recent journald entries, falling back to syslog and then dmesg. */
|
|
363
|
+
export async function journal(limit = 60): Promise<JournalEntry[]> {
|
|
364
|
+
const text = await sh("journalctl", [
|
|
365
|
+
"-n", String(limit), "--no-pager", "--output=json", "--output-fields=MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER",
|
|
366
|
+
], 5000);
|
|
367
|
+
|
|
368
|
+
if (text) {
|
|
369
|
+
const entries: JournalEntry[] = [];
|
|
370
|
+
for (const line of text.trim().split("\n")) {
|
|
371
|
+
if (!line.startsWith("{")) continue;
|
|
372
|
+
try {
|
|
373
|
+
const row = JSON.parse(line) as Record<string, string>;
|
|
374
|
+
const micros = Number(row.__REALTIME_TIMESTAMP ?? 0);
|
|
375
|
+
const time = micros
|
|
376
|
+
? new Date(micros / 1000).toTimeString().slice(0, 8)
|
|
377
|
+
: new Date().toTimeString().slice(0, 8);
|
|
378
|
+
entries.push({
|
|
379
|
+
time,
|
|
380
|
+
level: LEVELS[Number(row.PRIORITY ?? 6)] ?? "INFO",
|
|
381
|
+
unit: (row._SYSTEMD_UNIT ?? row.SYSLOG_IDENTIFIER ?? "-").replace(/\.service$/, ""),
|
|
382
|
+
message: String(row.MESSAGE ?? "").slice(0, 200),
|
|
383
|
+
});
|
|
384
|
+
} catch {
|
|
385
|
+
// Skip malformed lines rather than losing the whole log.
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (entries.length) return entries;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const path of ["/var/log/syslog", "/var/log/messages"]) {
|
|
392
|
+
const raw = await read(path);
|
|
393
|
+
if (!raw) continue;
|
|
394
|
+
return raw.trim().split("\n").slice(-limit).map((line) => {
|
|
395
|
+
const match = /^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+([^:[]+)/.exec(line);
|
|
396
|
+
return {
|
|
397
|
+
time: (match?.[1] ?? "").slice(-8),
|
|
398
|
+
level: /error|fail/i.test(line) ? "ERROR" : /warn/i.test(line) ? "WARN" : "INFO",
|
|
399
|
+
unit: (match?.[2] ?? "system").trim(),
|
|
400
|
+
message: line.slice(match?.[0]?.length ?? 0).replace(/^[:\s]+/, "").slice(0, 200),
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const dmesg = await sh("dmesg", ["--time-format", "iso", "-l", "err,warn,info"], 3000);
|
|
406
|
+
if (!dmesg) return [];
|
|
407
|
+
return dmesg.trim().split("\n").slice(-limit).map((line) => ({
|
|
408
|
+
time: (/T(\d{2}:\d{2}:\d{2})/.exec(line)?.[1]) ?? "",
|
|
409
|
+
level: /error/i.test(line) ? "ERROR" : /warn/i.test(line) ? "WARN" : "INFO",
|
|
410
|
+
unit: "kernel",
|
|
411
|
+
message: line.replace(/^\S+\s+/, "").slice(0, 200),
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Process counts by state, straight from /proc. */
|
|
416
|
+
export async function processStates(): Promise<ProcessStates> {
|
|
417
|
+
const text = await sh("ps", ["-eo", "state", "--no-headers"]);
|
|
418
|
+
const states: ProcessStates = { running: 0, sleeping: 0, stopped: 0, zombie: 0, total: 0 };
|
|
419
|
+
if (!text) return states;
|
|
420
|
+
for (const raw of text.trim().split("\n")) {
|
|
421
|
+
const state = raw.trim()[0];
|
|
422
|
+
states.total++;
|
|
423
|
+
if (state === "R") states.running++;
|
|
424
|
+
else if (state === "S" || state === "D" || state === "I") states.sleeping++;
|
|
425
|
+
else if (state === "T" || state === "t") states.stopped++;
|
|
426
|
+
else if (state === "Z") states.zombie++;
|
|
427
|
+
}
|
|
428
|
+
return states;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export type { Telemetry };
|