Mona commited on
Commit Β·
7323d61
1
Parent(s): dfd5fc7
feat: extended device metrics (cpu/mem/disk percent)
Browse filesdevice.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
- src/control.js +43 -6
- 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 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
};
|
| 162 |
this.#send('device.metrics', metrics);
|
| 163 |
this.emit('metrics', metrics);
|
| 164 |
-
},
|
| 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 |
});
|