Mona commited on
Commit
7323d61
Β·
1 Parent(s): dfd5fc7

feat: extended device metrics (cpu/mem/disk percent)

Browse files

device.metrics now carries cpuPercent (sampled), cpuModel,
mem.used/percent, diskPercent (statfs on home volume) and
uptimeSeconds β€” matching the control-plane devices table. TUI fields
(cpuLoad, mem.total/free, uptime) unchanged. Metrics interval is now
injectable for tests.

- control.js: async cpu sampling, statfs disk %, options param
- tests: metrics payload shape + backward compat

Files changed (2) hide show
  1. src/control.js +43 -6
  2. test/control.test.mjs +27 -0
src/control.js CHANGED
@@ -6,6 +6,7 @@
6
  import { EventEmitter } from 'node:events';
7
  import { WebSocket } from 'ws';
8
  import os from 'node:os';
 
9
  import { CLOUD, DEFAULTS } from './config.js';
10
  import { log } from './log.js';
11
 
@@ -17,6 +18,29 @@ import { log } from './log.js';
17
  */
18
  const TERMINAL_CLOSE_CODES = new Set([4001, 4003]);
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  export class ControlChannel extends EventEmitter {
21
  #apiKey;
22
  #agentId;
@@ -24,16 +48,18 @@ export class ControlChannel extends EventEmitter {
24
  #ws = null;
25
  #queue = [];
26
  #metricsTimer = null;
 
27
  #backoff = DEFAULTS.reconnectMinMs;
28
  #reconnectTimer = null;
29
  #closing = false;
30
  #stopped = false;
31
 
32
- constructor(apiKey, agentId, capabilities = null) {
33
  super();
34
  this.#apiKey = apiKey;
35
  this.#agentId = agentId;
36
  this.#capabilities = capabilities;
 
37
  }
38
 
39
  /** Connect (or reconnect) to the cloud. Returns this for chaining. */
@@ -152,16 +178,27 @@ export class ControlChannel extends EventEmitter {
152
 
153
  // ── Device metrics stream ──────────────────────────────────────
154
  #startMetrics() {
155
- this.#metricsTimer = setInterval(() => {
 
 
156
  const metrics = {
157
  cpuLoad: os.loadavg(),
158
- mem: { total: os.totalmem(), free: os.freemem() },
159
- uptime: os.uptime(),
160
- cpus: os.cpus().length,
 
 
 
 
 
 
 
 
 
161
  };
162
  this.#send('device.metrics', metrics);
163
  this.emit('metrics', metrics);
164
- }, DEFAULTS.metricsIntervalMs);
165
  }
166
 
167
  #stopMetrics() {
 
6
  import { EventEmitter } from 'node:events';
7
  import { WebSocket } from 'ws';
8
  import os from 'node:os';
9
+ import { statfsSync } from 'node:fs';
10
  import { CLOUD, DEFAULTS } from './config.js';
11
  import { log } from './log.js';
12
 
 
18
  */
19
  const TERMINAL_CLOSE_CODES = new Set([4001, 4003]);
20
 
21
+ /** Sampled CPU busy ratio β€” two os.cpus() readings 100ms apart. */
22
+ async function cpuPercent() {
23
+ const a = os.cpus();
24
+ await new Promise((r) => setTimeout(r, 100));
25
+ const b = os.cpus();
26
+ let idle = 0, total = 0;
27
+ for (let i = 0; i < a.length; i++) {
28
+ const ta = a[i].times, tb = b[i].times;
29
+ idle += tb.idle - ta.idle;
30
+ for (const k of Object.keys(tb)) total += tb[k] - ta[k];
31
+ }
32
+ return total > 0 ? Math.round((1 - idle / total) * 1000) / 10 : 0;
33
+ }
34
+
35
+ /** Used disk % on the device's home volume. */
36
+ function diskPercent() {
37
+ try {
38
+ const s = statfsSync(os.homedir());
39
+ const total = s.blocks * s.bsize, free = s.bavail * s.bsize;
40
+ return total > 0 ? Math.round((1 - free / total) * 1000) / 10 : 0;
41
+ } catch { return null; }
42
+ }
43
+
44
  export class ControlChannel extends EventEmitter {
45
  #apiKey;
46
  #agentId;
 
48
  #ws = null;
49
  #queue = [];
50
  #metricsTimer = null;
51
+ #metricsIntervalMs;
52
  #backoff = DEFAULTS.reconnectMinMs;
53
  #reconnectTimer = null;
54
  #closing = false;
55
  #stopped = false;
56
 
57
+ constructor(apiKey, agentId, capabilities = null, { metricsIntervalMs } = {}) {
58
  super();
59
  this.#apiKey = apiKey;
60
  this.#agentId = agentId;
61
  this.#capabilities = capabilities;
62
+ this.#metricsIntervalMs = metricsIntervalMs || DEFAULTS.metricsIntervalMs;
63
  }
64
 
65
  /** Connect (or reconnect) to the cloud. Returns this for chaining. */
 
178
 
179
  // ── Device metrics stream ──────────────────────────────────────
180
  #startMetrics() {
181
+ this.#metricsTimer = setInterval(async () => {
182
+ const totalMem = os.totalmem(), freeMem = os.freemem();
183
+ const cpus = os.cpus();
184
  const metrics = {
185
  cpuLoad: os.loadavg(),
186
+ cpuPercent: await cpuPercent(),
187
+ cpuModel: cpus[0]?.model || 'unknown',
188
+ mem: {
189
+ total: totalMem,
190
+ free: freeMem,
191
+ used: totalMem - freeMem,
192
+ percent: Math.round((1 - freeMem / totalMem) * 1000) / 10,
193
+ },
194
+ diskPercent: diskPercent(),
195
+ uptime: os.uptime(),
196
+ uptimeSeconds: os.uptime(),
197
+ cpus: cpus.length,
198
  };
199
  this.#send('device.metrics', metrics);
200
  this.emit('metrics', metrics);
201
+ }, this.#metricsIntervalMs);
202
  }
203
 
204
  #stopMetrics() {
test/control.test.mjs CHANGED
@@ -151,4 +151,31 @@ describe('control channel', () => {
151
  assert.ok(lastHello.data.version);
152
  ch.close();
153
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  });
 
151
  assert.ok(lastHello.data.version);
152
  ch.close();
153
  });
154
+
155
+ it('streams extended metrics (cpu/mem/disk percent)', async () => {
156
+ reset();
157
+ closeCode = null;
158
+ const ch = new ControlChannel('test-key', 'agent-1', null, { metricsIntervalMs: 120 });
159
+ quiet(ch);
160
+ const got = new Promise((resolve) => ch.on('metrics', resolve));
161
+ ch.connect();
162
+
163
+ const m = await Promise.race([
164
+ got,
165
+ new Promise((_, reject) => setTimeout(() => reject(new Error('no metrics within 3s')), 3000)),
166
+ ]);
167
+
168
+ assert.equal(typeof m.cpuPercent, 'number');
169
+ assert.ok(m.cpuPercent >= 0 && m.cpuPercent <= 100);
170
+ assert.ok(m.mem.percent >= 0 && m.mem.percent <= 100);
171
+ assert.equal(typeof m.mem.used, 'number');
172
+ assert.equal(typeof m.diskPercent, 'number');
173
+ assert.equal(typeof m.uptimeSeconds, 'number');
174
+ assert.ok(m.cpuModel);
175
+ // Backward-compatible fields the TUI depends on:
176
+ assert.equal(typeof m.mem.total, 'number');
177
+ assert.equal(typeof m.mem.free, 'number');
178
+ assert.ok(Array.isArray(m.cpuLoad));
179
+ ch.close();
180
+ });
181
  });