@profullstack/hqtui-demo 0.1.2 → 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.
Files changed (44) hide show
  1. package/README.md +51 -8
  2. package/dist/main.js +34 -11
  3. package/dist/main.js.map +1 -1
  4. package/dist/screens/index.js +4 -0
  5. package/dist/screens/index.js.map +1 -1
  6. package/dist/screens/network.js +86 -0
  7. package/dist/screens/network.js.map +1 -0
  8. package/dist/screens/services.js +121 -0
  9. package/dist/screens/services.js.map +1 -0
  10. package/dist/screens/sessions.js +80 -0
  11. package/dist/screens/sessions.js.map +1 -0
  12. package/dist/screens/traffic.js +183 -0
  13. package/dist/screens/traffic.js.map +1 -0
  14. package/dist/simulation.js +6 -0
  15. package/dist/simulation.js.map +1 -1
  16. package/dist/state.js +4 -1
  17. package/dist/state.js.map +1 -1
  18. package/dist/system/common.js +2 -0
  19. package/dist/system/common.js.map +1 -1
  20. package/dist/system/linux-telemetry.js +420 -0
  21. package/dist/system/linux-telemetry.js.map +1 -0
  22. package/dist/system/linux-traffic.js +282 -0
  23. package/dist/system/linux-traffic.js.map +1 -0
  24. package/dist/system/linux.js +90 -26
  25. package/dist/system/linux.js.map +1 -1
  26. package/dist/system/simulated-telemetry.js +267 -0
  27. package/dist/system/simulated-telemetry.js.map +1 -0
  28. package/dist/system/telemetry.js +44 -0
  29. package/dist/system/telemetry.js.map +1 -0
  30. package/package.json +4 -3
  31. package/src/main.ts +26 -11
  32. package/src/screens/index.ts +4 -0
  33. package/src/screens/network.ts +91 -0
  34. package/src/screens/services.ts +136 -0
  35. package/src/screens/sessions.ts +85 -0
  36. package/src/screens/traffic.ts +199 -0
  37. package/src/simulation.ts +12 -0
  38. package/src/state.ts +7 -2
  39. package/src/system/common.ts +2 -0
  40. package/src/system/linux-telemetry.ts +431 -0
  41. package/src/system/linux-traffic.ts +375 -0
  42. package/src/system/linux.ts +95 -21
  43. package/src/system/simulated-telemetry.ts +280 -0
  44. package/src/system/telemetry.ts +263 -0
@@ -0,0 +1,375 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { open } from "node:fs/promises";
3
+ import { sh } from "./common.ts";
4
+
5
+ /**
6
+ * Protocol-level visibility without root: socket classification, kernel
7
+ * TCP/UDP counters, sshd auth events from the journal, and HTTP access logs.
8
+ * Packet inspection would need privileges; none of this does.
9
+ */
10
+
11
+ async function read(path: string): Promise<string> {
12
+ try {
13
+ return await readFile(path, "utf8");
14
+ } catch {
15
+ return "";
16
+ }
17
+ }
18
+
19
+ const PORT_PROTOCOLS: Record<string, string> = {
20
+ "20": "FTP", "21": "FTP", "22": "SSH", "23": "Telnet", "25": "SMTP",
21
+ "53": "DNS", "67": "DHCP", "68": "DHCP", "80": "HTTP", "110": "POP3",
22
+ "111": "RPC", "123": "NTP", "143": "IMAP", "161": "SNMP", "389": "LDAP",
23
+ "443": "HTTPS", "445": "SMB", "465": "SMTPS", "514": "Syslog", "587": "SMTP",
24
+ "631": "IPP", "636": "LDAPS", "993": "IMAPS", "995": "POP3S",
25
+ "1194": "OpenVPN", "1433": "MSSQL", "1521": "Oracle", "2049": "NFS",
26
+ "2379": "etcd", "3000": "HTTP-dev", "3306": "MySQL", "3389": "RDP",
27
+ "4000": "HTTP-dev", "5000": "HTTP-dev", "5432": "Postgres", "5672": "AMQP",
28
+ "5900": "VNC", "6379": "Redis", "8000": "HTTP-alt", "8080": "HTTP-alt",
29
+ "8443": "HTTPS-alt", "9000": "HTTP-alt", "9090": "Prometheus",
30
+ "9200": "Elasticsearch", "11211": "Memcached", "27017": "MongoDB",
31
+ "41641": "Tailscale", "51820": "WireGuard",
32
+ };
33
+
34
+ export interface ProtocolBucket {
35
+ protocol: string;
36
+ inbound: number;
37
+ outbound: number;
38
+ total: number;
39
+ }
40
+
41
+ export interface RemoteHost {
42
+ host: string;
43
+ connections: number;
44
+ protocols: string;
45
+ }
46
+
47
+ function portOf(address: string): string {
48
+ const index = address.lastIndexOf(":");
49
+ return index === -1 ? "" : address.slice(index + 1);
50
+ }
51
+
52
+ function hostOf(address: string): string {
53
+ const index = address.lastIndexOf(":");
54
+ return index === -1 ? address : address.slice(0, index);
55
+ }
56
+
57
+ export function classify(port: string): string {
58
+ return PORT_PROTOCOLS[port] ?? (Number(port) >= 32768 ? "ephemeral" : `port ${port}`);
59
+ }
60
+
61
+ export interface SocketBreakdown {
62
+ protocols: ProtocolBucket[];
63
+ remotes: RemoteHost[];
64
+ inbound: number;
65
+ outbound: number;
66
+ }
67
+
68
+ /**
69
+ * Split live sockets by protocol. A connection whose *local* port is a known
70
+ * service is inbound; otherwise the remote port names the service we called.
71
+ */
72
+ export function breakdown(
73
+ connections: { local: string; remote: string; proto: string }[],
74
+ listeners: { port: string }[],
75
+ ): SocketBreakdown {
76
+ const listening = new Set(listeners.map((l) => l.port));
77
+ const buckets = new Map<string, ProtocolBucket>();
78
+ const remotes = new Map<string, { count: number; protocols: Set<string> }>();
79
+ let inbound = 0;
80
+ let outbound = 0;
81
+
82
+ for (const connection of connections) {
83
+ const localPort = portOf(connection.local);
84
+ const remotePort = portOf(connection.remote);
85
+ const isInbound = listening.has(localPort);
86
+ const protocol = classify(isInbound ? localPort : remotePort);
87
+
88
+ const bucket = buckets.get(protocol) ?? { protocol, inbound: 0, outbound: 0, total: 0 };
89
+ if (isInbound) {
90
+ bucket.inbound++;
91
+ inbound++;
92
+ } else {
93
+ bucket.outbound++;
94
+ outbound++;
95
+ }
96
+ bucket.total++;
97
+ buckets.set(protocol, bucket);
98
+
99
+ const host = hostOf(connection.remote);
100
+ if (host && host !== "*" && host !== "0.0.0.0") {
101
+ const entry = remotes.get(host) ?? { count: 0, protocols: new Set<string>() };
102
+ entry.count++;
103
+ entry.protocols.add(protocol);
104
+ remotes.set(host, entry);
105
+ }
106
+ }
107
+
108
+ return {
109
+ protocols: [...buckets.values()].sort((a, b) => b.total - a.total),
110
+ remotes: [...remotes.entries()]
111
+ .map(([host, entry]) => ({
112
+ host,
113
+ connections: entry.count,
114
+ protocols: [...entry.protocols].slice(0, 3).join(", "),
115
+ }))
116
+ .sort((a, b) => b.connections - a.connections)
117
+ .slice(0, 12),
118
+ inbound,
119
+ outbound,
120
+ };
121
+ }
122
+
123
+ export interface NetCounters {
124
+ tcpActiveOpens: number;
125
+ tcpPassiveOpens: number;
126
+ tcpEstablished: number;
127
+ tcpInSegs: number;
128
+ tcpOutSegs: number;
129
+ tcpRetransSegs: number;
130
+ tcpInErrs: number;
131
+ tcpOutRsts: number;
132
+ udpInDatagrams: number;
133
+ udpOutDatagrams: number;
134
+ udpInErrors: number;
135
+ icmpInMsgs: number;
136
+ icmpOutMsgs: number;
137
+ /** Per-second rates, computed between refreshes. */
138
+ rates: {
139
+ inSegs: number;
140
+ outSegs: number;
141
+ retrans: number;
142
+ passiveOpens: number;
143
+ activeOpens: number;
144
+ udpIn: number;
145
+ udpOut: number;
146
+ };
147
+ /** Retransmits as a share of outbound segments. */
148
+ retransRatio: number;
149
+ }
150
+
151
+ function parseSnmp(text: string): Record<string, Record<string, number>> {
152
+ const out: Record<string, Record<string, number>> = {};
153
+ const lines = text.trim().split("\n");
154
+ for (let i = 0; i < lines.length - 1; i += 2) {
155
+ const [headerName, ...headers] = lines[i].split(/\s+/);
156
+ const [valueName, ...values] = lines[i + 1].split(/\s+/);
157
+ if (headerName !== valueName) continue;
158
+ const section = headerName.replace(":", "");
159
+ out[section] = {};
160
+ headers.forEach((key, index) => {
161
+ out[section][key] = Number(values[index] ?? 0);
162
+ });
163
+ }
164
+ return out;
165
+ }
166
+
167
+ let previousCounters: { values: NetCounters; at: number } | null = null;
168
+
169
+ /** TCP/UDP/ICMP counters from /proc/net/snmp, with rates. */
170
+ export async function counters(): Promise<NetCounters> {
171
+ const snmp = parseSnmp(await read("/proc/net/snmp"));
172
+ const tcp = snmp.Tcp ?? {};
173
+ const udp = snmp.Udp ?? {};
174
+ const icmp = snmp.Icmp ?? {};
175
+
176
+ const values: NetCounters = {
177
+ tcpActiveOpens: tcp.ActiveOpens ?? 0,
178
+ tcpPassiveOpens: tcp.PassiveOpens ?? 0,
179
+ tcpEstablished: tcp.CurrEstab ?? 0,
180
+ tcpInSegs: tcp.InSegs ?? 0,
181
+ tcpOutSegs: tcp.OutSegs ?? 0,
182
+ tcpRetransSegs: tcp.RetransSegs ?? 0,
183
+ tcpInErrs: tcp.InErrs ?? 0,
184
+ tcpOutRsts: tcp.OutRsts ?? 0,
185
+ udpInDatagrams: udp.InDatagrams ?? 0,
186
+ udpOutDatagrams: udp.OutDatagrams ?? 0,
187
+ udpInErrors: udp.InErrors ?? 0,
188
+ icmpInMsgs: icmp.InMsgs ?? 0,
189
+ icmpOutMsgs: icmp.OutMsgs ?? 0,
190
+ rates: { inSegs: 0, outSegs: 0, retrans: 0, passiveOpens: 0, activeOpens: 0, udpIn: 0, udpOut: 0 },
191
+ retransRatio: 0,
192
+ };
193
+
194
+ const now = Date.now();
195
+ if (previousCounters) {
196
+ const dt = Math.max(0.001, (now - previousCounters.at) / 1000);
197
+ const previous = previousCounters.values;
198
+ const delta = (a: number, b: number) => Math.max(0, (a - b) / dt);
199
+ values.rates = {
200
+ inSegs: delta(values.tcpInSegs, previous.tcpInSegs),
201
+ outSegs: delta(values.tcpOutSegs, previous.tcpOutSegs),
202
+ retrans: delta(values.tcpRetransSegs, previous.tcpRetransSegs),
203
+ passiveOpens: delta(values.tcpPassiveOpens, previous.tcpPassiveOpens),
204
+ activeOpens: delta(values.tcpActiveOpens, previous.tcpActiveOpens),
205
+ udpIn: delta(values.udpInDatagrams, previous.udpInDatagrams),
206
+ udpOut: delta(values.udpOutDatagrams, previous.udpOutDatagrams),
207
+ };
208
+ }
209
+ previousCounters = { values, at: now };
210
+ values.retransRatio = values.tcpOutSegs > 0 ? values.tcpRetransSegs / values.tcpOutSegs : 0;
211
+ return values;
212
+ }
213
+
214
+ export interface SshEvent {
215
+ time: string;
216
+ action: "accepted" | "failed" | "invalid" | "disconnect";
217
+ user: string;
218
+ from: string;
219
+ method: string;
220
+ }
221
+
222
+ /** sshd authentication events, straight out of the journal. */
223
+ export async function sshEvents(limit = 40): Promise<SshEvent[]> {
224
+ const text = await sh("journalctl", [
225
+ "-u", "ssh", "-u", "sshd", "-n", String(limit * 2), "--no-pager", "--output=short-iso",
226
+ ], 5000);
227
+ const source = text || (await read("/var/log/auth.log"));
228
+ if (!source) return [];
229
+
230
+ const events: SshEvent[] = [];
231
+ for (const line of source.trim().split("\n")) {
232
+ if (!/sshd/.test(line)) continue;
233
+ const time = (/T(\d{2}:\d{2}:\d{2})/.exec(line)?.[1]) ?? (/(\d{2}:\d{2}:\d{2})/.exec(line)?.[1]) ?? "";
234
+
235
+ let match = /Accepted (\S+) for (\S+) from (\S+)/.exec(line);
236
+ if (match) {
237
+ events.push({ time, action: "accepted", user: match[2], from: match[3], method: match[1] });
238
+ continue;
239
+ }
240
+ match = /Failed (\S+) for (?:invalid user )?(\S+) from (\S+)/.exec(line);
241
+ if (match) {
242
+ events.push({
243
+ time,
244
+ action: /invalid user/.test(line) ? "invalid" : "failed",
245
+ user: match[2],
246
+ from: match[3],
247
+ method: match[1],
248
+ });
249
+ continue;
250
+ }
251
+ match = /Disconnected from (?:authenticating )?user (\S+) (\S+)/.exec(line);
252
+ if (match) {
253
+ events.push({ time, action: "disconnect", user: match[1], from: match[2], method: "-" });
254
+ }
255
+ }
256
+ return events.slice(-limit);
257
+ }
258
+
259
+ export interface HttpStats {
260
+ /** Which log file these came from. */
261
+ source: string;
262
+ requestsPerSecond: number;
263
+ total: number;
264
+ statusClasses: { class: string; count: number }[];
265
+ topPaths: { path: string; count: number }[];
266
+ topClients: { client: string; count: number }[];
267
+ methods: { method: string; count: number }[];
268
+ /** HTTP 101 responses: WebSocket and other protocol upgrades. */
269
+ upgrades: number;
270
+ recent: { time: string; method: string; path: string; status: string; client: string; bytes: number }[];
271
+ history: number[];
272
+ }
273
+
274
+ const ACCESS_LOGS = [
275
+ "/var/log/nginx/access.log",
276
+ "/var/log/apache2/access.log",
277
+ "/var/log/httpd/access_log",
278
+ "/var/log/caddy/access.log",
279
+ ];
280
+
281
+ const COMBINED =
282
+ /^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+)[^"]*" (\d{3}) (\d+|-)/;
283
+
284
+ let previousLog: { path: string; size: number; at: number } | null = null;
285
+ const requestHistory: number[] = [];
286
+
287
+ /** Parse the tail of an HTTP access log into live request statistics. */
288
+ export async function http(tailBytes = 256 * 1024): Promise<HttpStats | null> {
289
+ let path = "";
290
+ let size = 0;
291
+ for (const candidate of ACCESS_LOGS) {
292
+ try {
293
+ const info = await stat(candidate);
294
+ if (info.size > 0) {
295
+ path = candidate;
296
+ size = info.size;
297
+ break;
298
+ }
299
+ } catch {
300
+ // Not present, or not readable by this user.
301
+ }
302
+ }
303
+ if (!path) return null;
304
+
305
+ let text = "";
306
+ try {
307
+ const handle = await open(path, "r");
308
+ const start = Math.max(0, size - tailBytes);
309
+ const buffer = Buffer.alloc(Math.min(tailBytes, size));
310
+ await handle.read(buffer, 0, buffer.length, start);
311
+ await handle.close();
312
+ text = buffer.toString("utf8");
313
+ } catch {
314
+ return null;
315
+ }
316
+
317
+ const lines = text.split("\n").slice(1).filter(Boolean);
318
+ const statusClasses = new Map<string, number>();
319
+ const paths = new Map<string, number>();
320
+ const clients = new Map<string, number>();
321
+ const methods = new Map<string, number>();
322
+ const recent: HttpStats["recent"] = [];
323
+ let upgrades = 0;
324
+
325
+ for (const line of lines) {
326
+ const match = COMBINED.exec(line);
327
+ if (!match) continue;
328
+ const [, client, stamp, method, requestPath, status, sizeField] = match;
329
+ const cls = `${status[0]}xx`;
330
+ statusClasses.set(cls, (statusClasses.get(cls) ?? 0) + 1);
331
+ // Query strings explode the cardinality; group by path.
332
+ const bare = requestPath.split("?")[0].slice(0, 60);
333
+ paths.set(bare, (paths.get(bare) ?? 0) + 1);
334
+ clients.set(client, (clients.get(client) ?? 0) + 1);
335
+ methods.set(method, (methods.get(method) ?? 0) + 1);
336
+ if (status === "101") upgrades++;
337
+ recent.push({
338
+ time: (/:(\d{2}:\d{2}:\d{2})/.exec(stamp)?.[1]) ?? "",
339
+ method,
340
+ path: bare,
341
+ status,
342
+ client,
343
+ bytes: Number(sizeField) || 0,
344
+ });
345
+ }
346
+
347
+ // Requests per second from how much the file grew since the last read.
348
+ let requestsPerSecond = 0;
349
+ const now = Date.now();
350
+ if (previousLog && previousLog.path === path && size > previousLog.size) {
351
+ const grew = size - previousLog.size;
352
+ const averageLine = text.length / Math.max(1, lines.length);
353
+ const seconds = Math.max(0.001, (now - previousLog.at) / 1000);
354
+ requestsPerSecond = grew / Math.max(1, averageLine) / seconds;
355
+ }
356
+ previousLog = { path, size, at: now };
357
+ requestHistory.push(requestsPerSecond);
358
+ if (requestHistory.length > 240) requestHistory.shift();
359
+
360
+ const top = (map: Map<string, number>, n: number) =>
361
+ [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, n);
362
+
363
+ return {
364
+ source: path,
365
+ requestsPerSecond,
366
+ total: lines.length,
367
+ statusClasses: top(statusClasses, 6).map(([cls, count]) => ({ class: cls, count })),
368
+ topPaths: top(paths, 10).map(([p, count]) => ({ path: p, count })),
369
+ topClients: top(clients, 8).map(([client, count]) => ({ client, count })),
370
+ methods: top(methods, 6).map(([method, count]) => ({ method, count })),
371
+ upgrades,
372
+ recent: recent.slice(-40).reverse(),
373
+ history: [...requestHistory],
374
+ };
375
+ }
@@ -2,6 +2,9 @@ import { readFile, readdir } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import type { Collector, SystemSample } from "./types.ts";
4
4
  import { baseSample, loadAverage, primaryInterface, push, ratePerSecond, sh } from "./common.ts";
5
+ import type { Interface } from "./telemetry.ts";
6
+ import * as telemetry from "./linux-telemetry.ts";
7
+ import * as traffic from "./linux-traffic.ts";
5
8
 
6
9
  async function read(path: string): Promise<string> {
7
10
  try {
@@ -49,6 +52,8 @@ export class LinuxCollector implements Collector {
49
52
  private prevNet: [number, number] | null = null;
50
53
  private sectorSize = 512;
51
54
  private mounts: { device: string; mount: string }[] = [];
55
+ private ticks = 0;
56
+ private interfaceHistory = new Map<string, Interface>();
52
57
 
53
58
  async refresh(dt: number): Promise<void> {
54
59
  const s = this.sample;
@@ -168,6 +173,93 @@ export class LinuxCollector implements Collector {
168
173
  s.network.speed = speed.trim() && Number(speed) > 0 ? `${Number(speed) / 1000} Gb/s` : "-";
169
174
 
170
175
  await Promise.all([this.updateProcesses(), this.updateTemperatures()]);
176
+ await this.updateTelemetry(dt);
177
+ }
178
+
179
+ /**
180
+ * Cheap counters every tick; anything that shells out runs on a slower
181
+ * cadence so the dashboard never stalls waiting on `systemctl` or `last`.
182
+ */
183
+ private async updateTelemetry(dt: number): Promise<void> {
184
+ const t = this.sample.telemetry;
185
+ this.ticks++;
186
+
187
+ // Every tick: pure /proc reads.
188
+ const [kernel, interfaces] = await Promise.all([
189
+ telemetry.kernel(dt),
190
+ telemetry.interfaces(dt, this.interfaceHistory),
191
+ ]);
192
+ t.kernel = kernel;
193
+ t.interfaces = interfaces;
194
+
195
+ // TCP/UDP/ICMP counters are a single /proc read, so every tick.
196
+ t.net = await traffic.counters();
197
+ push(t.netInHistory, t.net.rates.inSegs);
198
+ push(t.netOutHistory, t.net.rates.outSegs);
199
+ push(t.retransHistory, t.net.rates.retrans);
200
+ this.sample.system.contextSwitches = kernel.contextSwitches;
201
+
202
+ // Every other tick: one cheap command each.
203
+ if (this.ticks % 2 === 1) {
204
+ const [sockets, states] = await Promise.all([telemetry.sockets(), telemetry.processStates()]);
205
+ t.connections = sockets.connections;
206
+ t.listeners = sockets.listeners;
207
+ t.states = states;
208
+ push(t.connectionHistory, sockets.connections.length);
209
+
210
+ const split = traffic.breakdown(sockets.connections, sockets.listeners);
211
+ t.protocols = split.protocols;
212
+ t.remotes = split.remotes;
213
+ t.inboundConnections = split.inbound;
214
+ t.outboundConnections = split.outbound;
215
+ this.sample.system.processCount = states.total;
216
+ }
217
+
218
+ // Every 5 ticks: sessions, journal and filesystems.
219
+ if (this.ticks % 5 === 1) {
220
+ const [sessions, journal, filesystems] = await Promise.all([
221
+ telemetry.sessions(),
222
+ telemetry.journal(80),
223
+ telemetry.filesystems(),
224
+ ]);
225
+ t.sessions = sessions;
226
+ t.journal = journal;
227
+ t.filesystems = filesystems;
228
+ push(t.sessionHistory, sessions.length);
229
+ // The dashboard log panel reads sample.logs, so mirror the real journal in.
230
+ this.sample.logs = journal.map((entry) => ({
231
+ time: entry.time,
232
+ level: entry.level,
233
+ message: entry.message,
234
+ meta: entry.unit,
235
+ }));
236
+ }
237
+
238
+ // Every 15 ticks: the slow ones.
239
+ if (this.ticks % 15 === 1) {
240
+ const [services, containers, logins, failed, gpus, power] = await Promise.all([
241
+ telemetry.services(),
242
+ telemetry.containers(),
243
+ telemetry.logins(20),
244
+ telemetry.failedLogins(15),
245
+ telemetry.gpus(),
246
+ telemetry.power(),
247
+ ]);
248
+ t.services = services;
249
+ t.containers = containers;
250
+ t.logins = logins;
251
+ t.failedLogins = failed;
252
+ t.gpus = gpus;
253
+ t.power = power;
254
+ const [ssh, http] = await Promise.all([traffic.sshEvents(40), traffic.http()]);
255
+ t.ssh = ssh;
256
+ t.http = http;
257
+ if (!http && !this.unavailable.includes("http access logs")) {
258
+ this.unavailable.push("http access logs");
259
+ }
260
+ if (gpus.length === 0 && !this.unavailable.includes("gpu")) this.unavailable.push("gpu");
261
+ if (!power && !this.unavailable.includes("battery")) this.unavailable.push("battery");
262
+ }
171
263
  }
172
264
 
173
265
  private async loadMounts(): Promise<void> {
@@ -224,29 +316,11 @@ export class LinuxCollector implements Collector {
224
316
  }
225
317
 
226
318
  private async updateTemperatures(): Promise<void> {
227
- const out: { label: string; value: number; max: number }[] = [];
228
- try {
229
- const zones = await readdir("/sys/class/hwmon");
230
- for (const zone of zones) {
231
- const base = `/sys/class/hwmon/${zone}`;
232
- const chip = (await read(`${base}/name`)).trim();
233
- const entries = await readdir(base).catch(() => [] as string[]);
234
- for (const entry of entries) {
235
- if (!/^temp\d+_input$/.test(entry)) continue;
236
- const raw = await read(`${base}/${entry}`);
237
- const value = Number(raw) / 1000;
238
- if (!Number.isFinite(value) || value <= 0 || value > 150) continue;
239
- const label = (await read(`${base}/${entry.replace("_input", "_label")}`)).trim();
240
- out.push({ label: label || `${chip} ${entry.replace("_input", "")}`, value, max: 100 });
241
- if (out.length >= 10) break;
242
- }
243
- if (out.length >= 10) break;
244
- }
245
- } catch {
246
- // hwmon is optional; containers and VMs frequently have none.
247
- }
319
+ const out = await telemetry.temperatures();
248
320
  if (out.length === 0 && !this.unavailable.includes("temperatures")) {
249
321
  this.unavailable.push("temperatures");
322
+ } else if (out.length > 0) {
323
+ this.unavailable = this.unavailable.filter((u) => u !== "temperatures");
250
324
  }
251
325
  this.sample.temperatures = out;
252
326
  this.sample.sensors = out.slice(0, 6).map((t) => ({ label: t.label, value: `${t.value.toFixed(1)} °C` }));