@profullstack/hqtui-demo 0.1.2 → 0.1.4

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.
Files changed (48) hide show
  1. package/README.md +74 -8
  2. package/dist/main.js +36 -12
  3. package/dist/main.js.map +1 -1
  4. package/dist/screens/dashboard.js +11 -3
  5. package/dist/screens/dashboard.js.map +1 -1
  6. package/dist/screens/index.js +4 -0
  7. package/dist/screens/index.js.map +1 -1
  8. package/dist/screens/network.js +86 -0
  9. package/dist/screens/network.js.map +1 -0
  10. package/dist/screens/services.js +121 -0
  11. package/dist/screens/services.js.map +1 -0
  12. package/dist/screens/sessions.js +80 -0
  13. package/dist/screens/sessions.js.map +1 -0
  14. package/dist/screens/traffic.js +183 -0
  15. package/dist/screens/traffic.js.map +1 -0
  16. package/dist/simulation.js +6 -0
  17. package/dist/simulation.js.map +1 -1
  18. package/dist/state.js +6 -2
  19. package/dist/state.js.map +1 -1
  20. package/dist/system/common.js +2 -0
  21. package/dist/system/common.js.map +1 -1
  22. package/dist/system/linux-telemetry.js +568 -0
  23. package/dist/system/linux-telemetry.js.map +1 -0
  24. package/dist/system/linux-traffic.js +282 -0
  25. package/dist/system/linux-traffic.js.map +1 -0
  26. package/dist/system/linux.js +119 -28
  27. package/dist/system/linux.js.map +1 -1
  28. package/dist/system/simulated-telemetry.js +267 -0
  29. package/dist/system/simulated-telemetry.js.map +1 -0
  30. package/dist/system/telemetry.js +44 -0
  31. package/dist/system/telemetry.js.map +1 -0
  32. package/package.json +4 -3
  33. package/src/main.ts +28 -12
  34. package/src/screens/dashboard.ts +9 -3
  35. package/src/screens/index.ts +4 -0
  36. package/src/screens/network.ts +91 -0
  37. package/src/screens/services.ts +136 -0
  38. package/src/screens/sessions.ts +85 -0
  39. package/src/screens/traffic.ts +199 -0
  40. package/src/simulation.ts +12 -0
  41. package/src/state.ts +16 -3
  42. package/src/system/common.ts +2 -0
  43. package/src/system/linux-telemetry.ts +584 -0
  44. package/src/system/linux-traffic.ts +375 -0
  45. package/src/system/linux.ts +128 -24
  46. package/src/system/simulated-telemetry.ts +280 -0
  47. package/src/system/telemetry.ts +263 -0
  48. package/src/system/types.ts +2 -0
@@ -0,0 +1,584 @@
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
+ /**
10
+ * Prefix for every sysfs path. Empty in production; tests point it at a
11
+ * fixture tree, because the hwmon layout cannot be exercised on a machine that
12
+ * has no sensors — which includes every virtual machine.
13
+ */
14
+ const SYSFS = process.env.HQTUI_SYSFS_ROOT ?? "";
15
+
16
+ async function read(path: string): Promise<string> {
17
+ try {
18
+ return await readFile(path.startsWith("/sys") ? SYSFS + path : path, "utf8");
19
+ } catch {
20
+ return "";
21
+ }
22
+ }
23
+
24
+ async function list(path: string): Promise<string[]> {
25
+ try {
26
+ return await readdir(path.startsWith("/sys") ? SYSFS + path : path);
27
+ } catch {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ /** `who -u`: who is logged in right now, and how idle they are. */
33
+ export async function sessions(): Promise<Session[]> {
34
+ const text = await sh("who", ["-u"]);
35
+ if (!text) return [];
36
+ return text.trim().split("\n").filter(Boolean).map((line) => {
37
+ const parts = line.trim().split(/\s+/);
38
+ return {
39
+ user: parts[0] ?? "-",
40
+ tty: parts[1] ?? "-",
41
+ loginAt: `${parts[2] ?? ""} ${parts[3] ?? ""}`.trim(),
42
+ idle: parts[4] ?? ".",
43
+ what: parts[5] ?? "",
44
+ // `who -u` puts the origin host in parentheses at the end when there is one.
45
+ from: /\(([^)]+)\)/.exec(line)?.[1] ?? "local",
46
+ };
47
+ });
48
+ }
49
+
50
+ function parseLastLine(line: string, status: LoginEvent["status"]): LoginEvent | null {
51
+ if (!line.trim() || /^(wtmp|btmp|reboot|$)/.test(line)) return null;
52
+ const parts = line.trim().split(/\s+/);
53
+ if (parts.length < 4) return null;
54
+ const stillLoggedIn = /still logged in/.test(line);
55
+ return {
56
+ user: parts[0],
57
+ tty: parts[1],
58
+ from: /^\d+\.\d+\.\d+\.\d+$/.test(parts[2]) || parts[2].includes(".") ? parts[2] : "local",
59
+ when: parts.slice(-7, -3).join(" ") || parts.slice(3, 7).join(" "),
60
+ status: stillLoggedIn ? "still" : status,
61
+ };
62
+ }
63
+
64
+ /** Successful logins from wtmp. */
65
+ export async function logins(limit = 20): Promise<LoginEvent[]> {
66
+ const text = await sh("last", ["-n", String(limit), "-w"]);
67
+ if (!text) return [];
68
+ return text.split("\n").map((l) => parseLastLine(l, "ok")).filter((e): e is LoginEvent => e !== null);
69
+ }
70
+
71
+ /** Failed logins from btmp. Usually root-only, so an empty list is normal. */
72
+ export async function failedLogins(limit = 15): Promise<LoginEvent[]> {
73
+ const text = await sh("lastb", ["-n", String(limit), "-w"]);
74
+ if (!text) return [];
75
+ return text.split("\n").map((l) => parseLastLine(l, "failed")).filter((e): e is LoginEvent => e !== null);
76
+ }
77
+
78
+ function splitAddress(address: string): { host: string; port: string } {
79
+ const index = address.lastIndexOf(":");
80
+ if (index === -1) return { host: address, port: "" };
81
+ return { host: address.slice(0, index), port: address.slice(index + 1) };
82
+ }
83
+
84
+ interface Sockets {
85
+ connections: Connection[];
86
+ listeners: Listener[];
87
+ }
88
+
89
+ /** `ss -tunap`: established connections and listening sockets. */
90
+ export async function sockets(): Promise<Sockets> {
91
+ const text = await sh("ss", ["-tunap"]);
92
+ if (!text) return { connections: [], listeners: [] };
93
+
94
+ const connections: Connection[] = [];
95
+ const listeners: Listener[] = [];
96
+ for (const line of text.trim().split("\n").slice(1)) {
97
+ const parts = line.trim().split(/\s+/);
98
+ if (parts.length < 5) continue;
99
+ const [proto, state, , , local, remote] = parts;
100
+ const process = /users:\(\("([^"]+)",pid=(\d+)/.exec(line);
101
+ const label = process ? `${process[1]}/${process[2]}` : "-";
102
+ if (state === "LISTEN") {
103
+ const { host, port } = splitAddress(local);
104
+ listeners.push({ proto, address: host, port, process: label });
105
+ } else if (state === "ESTAB" || proto.startsWith("udp")) {
106
+ connections.push({ proto, local, remote: remote ?? "-", state, process: label });
107
+ }
108
+ }
109
+ return { connections, listeners };
110
+ }
111
+
112
+ /** systemd units that are loaded, with failures first. */
113
+ export async function services(limit = 60): Promise<ServiceUnit[]> {
114
+ const text = await sh("systemctl", [
115
+ "list-units", "--type=service", "--all", "--no-pager", "--no-legend", "--plain",
116
+ ]);
117
+ if (!text) return [];
118
+ const units = text.trim().split("\n").map((line) => {
119
+ const parts = line.trim().split(/\s+/);
120
+ if (parts.length < 4) return null;
121
+ return {
122
+ name: parts[0].replace(/\.service$/, ""),
123
+ active: parts[2],
124
+ sub: parts[3],
125
+ description: parts.slice(4).join(" "),
126
+ };
127
+ }).filter((u): u is ServiceUnit => u !== null);
128
+
129
+ const rank = (unit: ServiceUnit) =>
130
+ unit.active === "failed" ? 0 : unit.active === "active" ? 1 : 2;
131
+ units.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
132
+ return units.slice(0, limit);
133
+ }
134
+
135
+ /** Running docker containers, when docker is reachable without a password. */
136
+ export async function containers(): Promise<Container[]> {
137
+ const text = await sh("docker", [
138
+ "ps", "--no-trunc", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}",
139
+ ], 3000);
140
+ if (!text) return [];
141
+ return text.trim().split("\n").filter(Boolean).map((line) => {
142
+ const [id, name, image, status] = line.split("\t");
143
+ return { id: (id ?? "").slice(0, 12), name: name ?? "-", image: image ?? "-", status: status ?? "-", cpu: "-", memory: "-" };
144
+ });
145
+ }
146
+
147
+ interface Counters {
148
+ [name: string]: [number, number];
149
+ }
150
+ const previousInterfaces: Counters = {};
151
+
152
+ /** Every network interface with its own throughput history. */
153
+ export async function interfaces(dt: number, history: Map<string, Interface>): Promise<Interface[]> {
154
+ const netdev = await read("/proc/net/dev");
155
+ if (!netdev) return [];
156
+ const addresses = os.networkInterfaces();
157
+ const out: Interface[] = [];
158
+
159
+ for (const line of netdev.split("\n").slice(2)) {
160
+ const match = /^\s*([\w.@-]+):\s*(.*)$/.exec(line);
161
+ if (!match) continue;
162
+ const name = match[1];
163
+ if (name === "lo") continue;
164
+ const values = match[2].trim().split(/\s+/).map(Number);
165
+ const [rxBytes, , rxErrs, rxDrop] = values;
166
+ const txBytes = values[8];
167
+ const txErrs = values[10] ?? 0;
168
+ const txDrop = values[11] ?? 0;
169
+
170
+ const previous = previousInterfaces[name];
171
+ const rxRate = previous && rxBytes >= previous[0] ? (rxBytes - previous[0]) / dt : 0;
172
+ const txRate = previous && txBytes >= previous[1] ? (txBytes - previous[1]) / dt : 0;
173
+ previousInterfaces[name] = [rxBytes, txBytes];
174
+
175
+ const address = (addresses[name] ?? []).find((a) => a.family === "IPv4");
176
+ const existing = history.get(name);
177
+ const entry: Interface = {
178
+ name,
179
+ ip: address?.address ?? "-",
180
+ mac: address?.mac ?? "-",
181
+ state: (await read(`/sys/class/net/${name}/operstate`)).trim() || "unknown",
182
+ mtu: Number((await read(`/sys/class/net/${name}/mtu`)).trim()) || 0,
183
+ rxRate,
184
+ txRate,
185
+ rxTotal: rxBytes,
186
+ txTotal: txBytes,
187
+ rxHistory: existing?.rxHistory ?? [],
188
+ txHistory: existing?.txHistory ?? [],
189
+ errors: (rxErrs ?? 0) + txErrs,
190
+ drops: (rxDrop ?? 0) + txDrop,
191
+ };
192
+ push(entry.rxHistory, rxRate);
193
+ push(entry.txHistory, txRate);
194
+ history.set(name, entry);
195
+ out.push(entry);
196
+ }
197
+ return out;
198
+ }
199
+
200
+ /** Mounted filesystems with capacity and inode usage. */
201
+ export async function filesystems(): Promise<Filesystem[]> {
202
+ const [sizes, inodes] = await Promise.all([
203
+ sh("df", ["-kPT", "-x", "tmpfs", "-x", "devtmpfs", "-x", "squashfs", "-x", "overlay"]),
204
+ sh("df", ["-iP", "-x", "tmpfs", "-x", "devtmpfs", "-x", "squashfs", "-x", "overlay"]),
205
+ ]);
206
+ if (!sizes) return [];
207
+
208
+ const inodeByMount = new Map<string, [number, number]>();
209
+ for (const line of inodes.trim().split("\n").slice(1)) {
210
+ const parts = line.trim().split(/\s+/);
211
+ if (parts.length < 6) continue;
212
+ inodeByMount.set(parts[parts.length - 1], [Number(parts[2]), Number(parts[1])]);
213
+ }
214
+
215
+ return sizes.trim().split("\n").slice(1).map((line) => {
216
+ const parts = line.trim().split(/\s+/);
217
+ if (parts.length < 7) return null;
218
+ const mount = parts[parts.length - 1];
219
+ const [inodesUsed, inodesTotal] = inodeByMount.get(mount) ?? [0, 0];
220
+ return {
221
+ device: parts[0],
222
+ type: parts[1],
223
+ size: Number(parts[2]) * 1024,
224
+ used: Number(parts[3]) * 1024,
225
+ mount,
226
+ inodesUsed,
227
+ inodesTotal,
228
+ };
229
+ }).filter((f): f is Filesystem => f !== null).slice(0, 8);
230
+ }
231
+
232
+ let previousKernel: { ctxt: number; intr: number; forks: number; at: number } | null = null;
233
+
234
+ /** Kernel counters: context switches, interrupts, forks, entropy, fd usage. */
235
+ export async function kernel(dt: number): Promise<KernelStats> {
236
+ const [stat, vmstat, entropy, fileNr] = await Promise.all([
237
+ read("/proc/stat"),
238
+ read("/proc/vmstat"),
239
+ read("/proc/sys/kernel/random/entropy_avail"),
240
+ read("/proc/sys/fs/file-nr"),
241
+ ]);
242
+
243
+ const number = (source: string, pattern: RegExp): number => Number(pattern.exec(source)?.[1] ?? 0);
244
+ const ctxt = number(stat, /^ctxt (\d+)/m);
245
+ const intr = Number(/^intr (\d+)/m.exec(stat)?.[1] ?? 0);
246
+ const forks = number(stat, /^processes (\d+)/m);
247
+
248
+ let contextSwitchRate = 0;
249
+ let interruptRate = 0;
250
+ let forkRate = 0;
251
+ if (previousKernel && dt > 0) {
252
+ contextSwitchRate = Math.max(0, (ctxt - previousKernel.ctxt) / dt);
253
+ interruptRate = Math.max(0, (intr - previousKernel.intr) / dt);
254
+ forkRate = Math.max(0, (forks - previousKernel.forks) / dt);
255
+ }
256
+ previousKernel = { ctxt, intr, forks, at: Date.now() };
257
+
258
+ const fd = fileNr.trim().split(/\s+/).map(Number);
259
+ return {
260
+ contextSwitches: ctxt,
261
+ contextSwitchRate,
262
+ interrupts: intr,
263
+ interruptRate,
264
+ forks,
265
+ forkRate,
266
+ procsRunning: number(stat, /^procs_running (\d+)/m),
267
+ procsBlocked: number(stat, /^procs_blocked (\d+)/m),
268
+ entropy: Number(entropy.trim()) || 0,
269
+ openFiles: fd[0] ?? 0,
270
+ maxFiles: fd[2] ?? 0,
271
+ bootTime: number(stat, /^btime (\d+)/m),
272
+ pageIn: number(vmstat, /^pgpgin (\d+)/m),
273
+ pageOut: number(vmstat, /^pgpgout (\d+)/m),
274
+ swapIn: number(vmstat, /^pswpin (\d+)/m),
275
+ swapOut: number(vmstat, /^pswpout (\d+)/m),
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Why this host reports no thermal hardware. A hypervisor does not pass the
281
+ * physical machine's sensors through, so on a guest there is no package to
282
+ * install and no configuration to fix — the hardware simply is not there.
283
+ */
284
+ export async function sensorDiagnosis(): Promise<string> {
285
+ const virt = (await sh("systemd-detect-virt", [], 3000)).trim();
286
+ if (virt && virt !== "none") {
287
+ return `virtualised (${virt}) — hypervisors do not expose thermal hardware to guests`;
288
+ }
289
+ const hasSensorsDetect = (await sh("which", ["sensors-detect"], 2000)).trim();
290
+ if (!hasSensorsDetect) {
291
+ return "no sensor chips found — install lm-sensors and run `sudo sensors-detect --auto`";
292
+ }
293
+ return "lm-sensors found no supported chips on this machine";
294
+ }
295
+
296
+ export interface SensorReading {
297
+ label: string;
298
+ value: string;
299
+ /** Where it came from, so the UI can explain an empty panel. */
300
+ kind: "fan" | "voltage" | "power" | "current" | "battery" | "gpu" | "frequency";
301
+ }
302
+
303
+ /** Directories that may hold sensor files, including the older device/ layout. */
304
+ async function hwmonDirs(): Promise<{ dir: string; chip: string }[]> {
305
+ const out: { dir: string; chip: string }[] = [];
306
+ {
307
+ for (const entry of await list("/sys/class/hwmon")) {
308
+ const base = `/sys/class/hwmon/${entry}`;
309
+ const chip = (await read(`${base}/name`)).trim() || (await read(`${base}/device/name`)).trim() || entry;
310
+ out.push({ dir: base, chip });
311
+ // Kernels before ~4.x put the inputs one level down.
312
+ const nested = `${base}/device`;
313
+ const files = await list(nested);
314
+ if (files.some((f) => /^(temp|fan|in|power|curr)\d+_input$/.test(f))) {
315
+ out.push({ dir: nested, chip });
316
+ }
317
+ }
318
+ }
319
+ return out;
320
+ }
321
+
322
+ async function labelled(dir: string, entry: string, chip: string, fallback: string): Promise<string> {
323
+ const label = (await read(`${dir}/${entry.replace("_input", "_label").replace("_average", "_label")}`)).trim();
324
+ if (label) return label;
325
+ return chip ? `${chip} ${fallback}` : fallback;
326
+ }
327
+
328
+ /** Temperatures from hwmon and, when that is empty, the thermal zones. */
329
+ export async function temperatures(): Promise<{ label: string; value: number; max: number }[]> {
330
+ const out: { label: string; value: number; max: number }[] = [];
331
+
332
+ for (const { dir, chip } of await hwmonDirs()) {
333
+ for (const entry of await list(dir)) {
334
+ if (!/^temp\d+_input$/.test(entry)) continue;
335
+ const value = Number(await read(`${dir}/${entry}`)) / 1000;
336
+ if (!Number.isFinite(value) || value <= 0 || value > 150) continue;
337
+ const max = Number(await read(`${dir}/${entry.replace("_input", "_crit")}`)) / 1000;
338
+ out.push({
339
+ label: await labelled(dir, entry, chip, entry.replace("_input", "")),
340
+ value,
341
+ max: Number.isFinite(max) && max > 0 ? max : 100,
342
+ });
343
+ }
344
+ }
345
+
346
+ // Thermal zones are present on many machines that expose no hwmon at all.
347
+ if (out.length === 0) {
348
+ {
349
+ for (const zone of await list("/sys/class/thermal")) {
350
+ if (!zone.startsWith("thermal_zone")) continue;
351
+ const value = Number(await read(`/sys/class/thermal/${zone}/temp`)) / 1000;
352
+ if (!Number.isFinite(value) || value <= 0 || value > 150) continue;
353
+ const type = (await read(`/sys/class/thermal/${zone}/type`)).trim();
354
+ out.push({ label: type || zone, value, max: 100 });
355
+ }
356
+ }
357
+ }
358
+
359
+ // lm-sensors reaches chips the sysfs walk can miss, and names them properly.
360
+ if (out.length === 0) {
361
+ const json = await sh("sensors", ["-j"], 4000);
362
+ if (json) {
363
+ try {
364
+ const parsed = JSON.parse(json) as Record<string, Record<string, Record<string, number>>>;
365
+ for (const [chip, features] of Object.entries(parsed)) {
366
+ for (const [feature, values] of Object.entries(features)) {
367
+ if (typeof values !== "object" || values === null) continue;
368
+ const input = Object.entries(values).find(([key]) => /_input$/.test(key) && key.startsWith("temp"));
369
+ if (!input) continue;
370
+ const value = Number(input[1]);
371
+ if (!Number.isFinite(value) || value <= 0 || value > 150) continue;
372
+ out.push({ label: `${chip.split("-")[0]} ${feature}`, value, max: 100 });
373
+ }
374
+ }
375
+ } catch {
376
+ // Older lm-sensors without -j support.
377
+ }
378
+ }
379
+ }
380
+
381
+ return out.slice(0, 12);
382
+ }
383
+
384
+ /**
385
+ * Hardware sensors other than temperature: fans, voltage rails, power draw and
386
+ * current. These are independent of the temperature probes, so a machine with
387
+ * no thermal sensors can still report fans, and vice versa.
388
+ */
389
+ export async function hardwareSensors(): Promise<SensorReading[]> {
390
+ const out: SensorReading[] = [];
391
+
392
+ for (const { dir, chip } of await hwmonDirs()) {
393
+ for (const entry of (await list(dir)).sort()) {
394
+ const raw = Number(await read(`${dir}/${entry}`));
395
+ if (!Number.isFinite(raw)) continue;
396
+
397
+ if (/^fan\d+_input$/.test(entry)) {
398
+ if (raw <= 0) continue;
399
+ out.push({
400
+ label: await labelled(dir, entry, chip, entry.replace("_input", "").replace("fan", "Fan ")),
401
+ value: `${Math.round(raw)} RPM`,
402
+ kind: "fan",
403
+ });
404
+ } else if (/^in\d+_input$/.test(entry)) {
405
+ if (raw <= 0) continue;
406
+ out.push({
407
+ label: await labelled(dir, entry, chip, entry.replace("_input", "")),
408
+ value: `${(raw / 1000).toFixed(2)} V`,
409
+ kind: "voltage",
410
+ });
411
+ } else if (/^power\d+_(average|input)$/.test(entry)) {
412
+ if (raw <= 0) continue;
413
+ out.push({
414
+ label: await labelled(dir, entry, chip, entry.replace(/_(average|input)$/, "")),
415
+ value: `${(raw / 1e6).toFixed(1)} W`,
416
+ kind: "power",
417
+ });
418
+ } else if (/^curr\d+_input$/.test(entry)) {
419
+ if (raw <= 0) continue;
420
+ out.push({
421
+ label: await labelled(dir, entry, chip, entry.replace("_input", "")),
422
+ value: `${(raw / 1000).toFixed(2)} A`,
423
+ kind: "current",
424
+ });
425
+ }
426
+ if (out.length >= 14) return out;
427
+ }
428
+ }
429
+ return out;
430
+ }
431
+
432
+ /** Current clock speed per core, which every Linux host reports. */
433
+ export async function cpuFrequencies(): Promise<SensorReading[]> {
434
+ const out: SensorReading[] = [];
435
+ {
436
+ const cpus = (await list("/sys/devices/system/cpu"))
437
+ .filter((entry) => /^cpu\d+$/.test(entry))
438
+ .sort((a, b) => Number(a.slice(3)) - Number(b.slice(3)));
439
+ for (const cpu of cpus.slice(0, 4)) {
440
+ const khz = Number((await read(`/sys/devices/system/cpu/${cpu}/cpufreq/scaling_cur_freq`)).trim());
441
+ if (!Number.isFinite(khz) || khz <= 0) continue;
442
+ out.push({ label: `${cpu} clock`, value: `${(khz / 1e6).toFixed(2)} GHz`, kind: "frequency" });
443
+ }
444
+ }
445
+
446
+ if (out.length === 0) {
447
+ // /proc/cpuinfo still reports a measured MHz where cpufreq is missing.
448
+ const info = await read("/proc/cpuinfo");
449
+ const speeds = [...info.matchAll(/^cpu MHz\s*:\s*([\d.]+)/gm)].map((m) => Number(m[1]));
450
+ speeds.slice(0, 4).forEach((mhz, i) => {
451
+ if (Number.isFinite(mhz) && mhz > 0) {
452
+ out.push({ label: `cpu${i} clock`, value: `${(mhz / 1000).toFixed(2)} GHz`, kind: "frequency" });
453
+ }
454
+ });
455
+ }
456
+ return out;
457
+ }
458
+
459
+ /** GPU stats, when the NVIDIA tools are installed. */
460
+ export async function gpus(): Promise<GpuStats[]> {
461
+ const text = await sh("nvidia-smi", [
462
+ "--query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw",
463
+ "--format=csv,noheader,nounits",
464
+ ], 3000);
465
+ if (!text) return [];
466
+ return text.trim().split("\n").filter(Boolean).map((line) => {
467
+ const [name, utilization, used, total, temperature, power] = line.split(",").map((v) => v.trim());
468
+ return {
469
+ name,
470
+ utilization: Number(utilization) / 100,
471
+ memoryUsed: Number(used) * 1024 ** 2,
472
+ memoryTotal: Number(total) * 1024 ** 2,
473
+ temperature: Number(temperature),
474
+ power: Number(power),
475
+ };
476
+ });
477
+ }
478
+
479
+ /** Battery and AC state from /sys/class/power_supply. */
480
+ export async function power(): Promise<PowerStats | null> {
481
+ try {
482
+ const supplies = await readdir("/sys/class/power_supply");
483
+ let battery: PowerStats | null = null;
484
+ let acConnected = false;
485
+
486
+ for (const supply of supplies) {
487
+ const base = `/sys/class/power_supply/${supply}`;
488
+ const type = (await read(`${base}/type`)).trim();
489
+ if (type === "Mains") {
490
+ acConnected = (await read(`${base}/online`)).trim() === "1";
491
+ continue;
492
+ }
493
+ if (type !== "Battery") continue;
494
+ const capacity = Number((await read(`${base}/capacity`)).trim());
495
+ const status = (await read(`${base}/status`)).trim();
496
+ const currentNow = Number((await read(`${base}/current_now`)).trim());
497
+ const voltageNow = Number((await read(`${base}/voltage_now`)).trim());
498
+ battery = {
499
+ battery: Number.isFinite(capacity) ? capacity : 0,
500
+ charging: status === "Charging",
501
+ timeRemaining: status,
502
+ powerDraw: Number.isFinite(currentNow * voltageNow) ? (currentNow * voltageNow) / 1e12 : 0,
503
+ acConnected,
504
+ };
505
+ }
506
+ if (battery) battery.acConnected = acConnected;
507
+ return battery;
508
+ } catch {
509
+ return null;
510
+ }
511
+ }
512
+
513
+ const LEVELS = ["EMERG", "ALERT", "CRIT", "ERROR", "WARN", "NOTICE", "INFO", "DEBUG"];
514
+
515
+ /** Recent journald entries, falling back to syslog and then dmesg. */
516
+ export async function journal(limit = 60): Promise<JournalEntry[]> {
517
+ const text = await sh("journalctl", [
518
+ "-n", String(limit), "--no-pager", "--output=json", "--output-fields=MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER",
519
+ ], 5000);
520
+
521
+ if (text) {
522
+ const entries: JournalEntry[] = [];
523
+ for (const line of text.trim().split("\n")) {
524
+ if (!line.startsWith("{")) continue;
525
+ try {
526
+ const row = JSON.parse(line) as Record<string, string>;
527
+ const micros = Number(row.__REALTIME_TIMESTAMP ?? 0);
528
+ const time = micros
529
+ ? new Date(micros / 1000).toTimeString().slice(0, 8)
530
+ : new Date().toTimeString().slice(0, 8);
531
+ entries.push({
532
+ time,
533
+ level: LEVELS[Number(row.PRIORITY ?? 6)] ?? "INFO",
534
+ unit: (row._SYSTEMD_UNIT ?? row.SYSLOG_IDENTIFIER ?? "-").replace(/\.service$/, ""),
535
+ message: String(row.MESSAGE ?? "").slice(0, 200),
536
+ });
537
+ } catch {
538
+ // Skip malformed lines rather than losing the whole log.
539
+ }
540
+ }
541
+ if (entries.length) return entries;
542
+ }
543
+
544
+ for (const path of ["/var/log/syslog", "/var/log/messages"]) {
545
+ const raw = await read(path);
546
+ if (!raw) continue;
547
+ return raw.trim().split("\n").slice(-limit).map((line) => {
548
+ const match = /^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+([^:[]+)/.exec(line);
549
+ return {
550
+ time: (match?.[1] ?? "").slice(-8),
551
+ level: /error|fail/i.test(line) ? "ERROR" : /warn/i.test(line) ? "WARN" : "INFO",
552
+ unit: (match?.[2] ?? "system").trim(),
553
+ message: line.slice(match?.[0]?.length ?? 0).replace(/^[:\s]+/, "").slice(0, 200),
554
+ };
555
+ });
556
+ }
557
+
558
+ const dmesg = await sh("dmesg", ["--time-format", "iso", "-l", "err,warn,info"], 3000);
559
+ if (!dmesg) return [];
560
+ return dmesg.trim().split("\n").slice(-limit).map((line) => ({
561
+ time: (/T(\d{2}:\d{2}:\d{2})/.exec(line)?.[1]) ?? "",
562
+ level: /error/i.test(line) ? "ERROR" : /warn/i.test(line) ? "WARN" : "INFO",
563
+ unit: "kernel",
564
+ message: line.replace(/^\S+\s+/, "").slice(0, 200),
565
+ }));
566
+ }
567
+
568
+ /** Process counts by state, straight from /proc. */
569
+ export async function processStates(): Promise<ProcessStates> {
570
+ const text = await sh("ps", ["-eo", "state", "--no-headers"]);
571
+ const states: ProcessStates = { running: 0, sleeping: 0, stopped: 0, zombie: 0, total: 0 };
572
+ if (!text) return states;
573
+ for (const raw of text.trim().split("\n")) {
574
+ const state = raw.trim()[0];
575
+ states.total++;
576
+ if (state === "R") states.running++;
577
+ else if (state === "S" || state === "D" || state === "I") states.sleeping++;
578
+ else if (state === "T" || state === "t") states.stopped++;
579
+ else if (state === "Z") states.zombie++;
580
+ }
581
+ return states;
582
+ }
583
+
584
+ export type { Telemetry };