@volter/supercode-health 0.0.0-stage → 0.1.0
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 +66 -2
- package/bin/health.mjs +70 -0
- package/index.mjs +6 -0
- package/lib/chrome.mjs +68 -0
- package/lib/darwin.mjs +171 -0
- package/lib/features.mjs +36 -0
- package/lib/format.mjs +58 -0
- package/lib/linux.mjs +148 -0
- package/lib/mail.mjs +64 -0
- package/lib/paths.mjs +26 -0
- package/lib/probe.mjs +168 -0
- package/lib/run.mjs +33 -0
- package/lib/rusage.py +17 -0
- package/lib/sample.mjs +492 -0
- package/lib/thresholds.mjs +103 -0
- package/lib/win32.mjs +37 -0
- package/package.json +15 -4
package/lib/sample.mjs
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// The sampler: reads every feature that is on, turns cumulative counters into rates against the previous
|
|
2
|
+
// sample, and states each alarm's observation. It keeps only what a rate or a window needs between samples.
|
|
3
|
+
import { cpus, homedir, loadavg } from 'node:os';
|
|
4
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { run } from './run.mjs';
|
|
6
|
+
import { rendererOrigins, originOf, localPort } from './chrome.mjs';
|
|
7
|
+
|
|
8
|
+
const MB = 1024 * 1024;
|
|
9
|
+
const GB = 1024 * MB;
|
|
10
|
+
const round = (n, d = 1) => (n == null || Number.isNaN(n) ? null : Math.round(n * 10 ** d) / 10 ** d);
|
|
11
|
+
const short = (command) => String(command ?? '').slice(0, 160);
|
|
12
|
+
const base = (command) => {
|
|
13
|
+
const first = String(command ?? '').split(' ')[0];
|
|
14
|
+
return first.slice(first.lastIndexOf('/') + 1).replace(/^\[|\]$/g, '');
|
|
15
|
+
};
|
|
16
|
+
const AGENT = /^(claude|codex|gemini|goose|opencode|grok|hermes|pi|aider|cursor-agent)$/;
|
|
17
|
+
const HARNESS = { claude: 'claude-code', codex: 'codex', gemini: 'gemini', goose: 'goose', opencode: 'opencode', grok: 'grok', hermes: 'hermes', pi: 'pi' };
|
|
18
|
+
const RENDERER = /Helper \(Renderer\)|--type=renderer/;
|
|
19
|
+
const BROWSER_MAIN = /(Google Chrome|Chromium|chrome-headless-shell|chrome)(\s|$)/i;
|
|
20
|
+
const LOOPBACK = /^(127\.|::1$|localhost$)/;
|
|
21
|
+
// The slow set's readings and alarm families, by the feature that produces them.
|
|
22
|
+
const SLOW_FEATURE = { disk: 'disk', services: 'services', service: 'services', supercode: 'supercode', browserTabs: 'browser-tabs', tabs: 'browser-tabs', power: 'power' };
|
|
23
|
+
// Every alarm family, by the feature that observes it.
|
|
24
|
+
const FAMILY_FEATURE = { load: 'load', memory: 'memory', process: 'processes', browser: 'renderers', daemon: 'daemons', 'disk-writer': 'disk-io', files: 'files', tcp: 'tcp', disk: 'disk', service: 'services', supercode: 'supercode', tabs: 'browser-tabs', power: 'power' };
|
|
25
|
+
|
|
26
|
+
export class Sampler {
|
|
27
|
+
constructor({ reader, platform = process.platform, machine, env = process.env, config }) {
|
|
28
|
+
Object.assign(this, { reader, platform, machine, env, config });
|
|
29
|
+
this.prev = null; // the previous fast sample's counters
|
|
30
|
+
this.procPrev = new Map(); // pid → { cpuSec, command, written }
|
|
31
|
+
this.footHistory = new Map(); // pid → [[at, bytes]] for growth per minute
|
|
32
|
+
this.swapRing = []; // [[at, swapouts]] for the 30-minute sum
|
|
33
|
+
this.diskRing = new Map(); // mount → [[at, freeBytes]] for the fill rate
|
|
34
|
+
this.sessionCache = new Map(); // pid:command → session
|
|
35
|
+
this.topFoot = { at: 0, map: new Map() };
|
|
36
|
+
this.slow = { at: 0, readings: {}, observations: [], families: new Set(), errors: {} };
|
|
37
|
+
this.slowStarted = 0;
|
|
38
|
+
this.slowRunning = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One cycle. `features` is resolveFeatures's output; the slow set runs when its interval has passed. */
|
|
42
|
+
async sample(features, now = Date.now(), { wait = false } = {}) {
|
|
43
|
+
const on = new Set(features.filter((f) => f.on).map((f) => f.id));
|
|
44
|
+
const cfg = this.config;
|
|
45
|
+
this.procs = null;
|
|
46
|
+
const readings = {};
|
|
47
|
+
const observations = [];
|
|
48
|
+
const families = new Set();
|
|
49
|
+
const errors = {};
|
|
50
|
+
const dt = this.prev ? (now - this.prev.at) / 1000 : null;
|
|
51
|
+
const attempt = async (id, fn) => {
|
|
52
|
+
try { await fn(); } catch (error) { errors[id] = error.message; }
|
|
53
|
+
};
|
|
54
|
+
const alarm = (name) => cfg.alarms[name];
|
|
55
|
+
|
|
56
|
+
if (on.has('load')) await attempt('load', async () => {
|
|
57
|
+
const [one, five, fifteen] = loadavg();
|
|
58
|
+
const cores = cpus().length;
|
|
59
|
+
readings.load = { one: round(one, 2), five: round(five, 2), fifteen: round(fifteen, 2), cores, perCore: round(one / cores, 2) };
|
|
60
|
+
families.add('load');
|
|
61
|
+
if (this.platform !== 'win32') observations.push({ key: 'load.perCore', family: 'load', def: alarm('load.perCore'), value: one / cores, unit: '× cores', summary: `load ${round(one, 1)} on ${cores} cores` });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let memory = null;
|
|
65
|
+
if (on.has('memory')) await attempt('memory', async () => {
|
|
66
|
+
memory = await this.reader.memory();
|
|
67
|
+
const c = memory.counters;
|
|
68
|
+
const p = this.prev?.memory?.counters;
|
|
69
|
+
const rate = (k) => (p && dt && c[k] != null && p[k] != null && c[k] >= p[k] ? (c[k] - p[k]) / dt : null);
|
|
70
|
+
if (c.swapouts != null) {
|
|
71
|
+
this.swapRing.push([now, c.swapouts]);
|
|
72
|
+
while (this.swapRing.length && now - this.swapRing[0][0] > 31 * 60_000) this.swapRing.shift();
|
|
73
|
+
}
|
|
74
|
+
const oldest = this.swapRing[0];
|
|
75
|
+
const swapped30 = oldest && c.swapouts != null && now - oldest[0] >= 60_000 ? ((c.swapouts - oldest[1]) * memory.pageSize * 100) / memory.totalBytes : null;
|
|
76
|
+
readings.memory = {
|
|
77
|
+
pressureLevel: memory.pressureLevel, freePct: memory.freePct, totalGB: round(memory.totalBytes / GB),
|
|
78
|
+
swapoutsPerSec: round(rate('swapouts')), swapinsPerSec: round(rate('swapins')), pageinsPerSec: round(rate('pageins')),
|
|
79
|
+
compressionsPerSec: round(rate('compressions'), 0), swapUsedGB: round(memory.swapUsedBytes / GB, 2), swapTotalGB: round(memory.swapTotalBytes / GB, 2),
|
|
80
|
+
swappedPctRamWindow: round(swapped30, 2), swappedWindowMin: oldest ? round((now - oldest[0]) / 60_000, 0) : null,
|
|
81
|
+
...(memory.psi ? { psi: memory.psi } : {}),
|
|
82
|
+
};
|
|
83
|
+
families.add('memory');
|
|
84
|
+
if (memory.pressureLevel) observations.push({ key: 'memory.pressure', family: 'memory', def: alarm('memory.pressure'), value: memory.pressureLevel, met: { warn: memory.pressureLevel !== 'normal', crit: memory.pressureLevel === 'critical', clears: memory.pressureLevel === 'normal' }, summary: `kernel memory pressure ${memory.pressureLevel}` });
|
|
85
|
+
if (memory.freePct != null) observations.push({ key: 'memory.free', family: 'memory', def: alarm('memory.free'), value: memory.freePct, unit: '%', summary: `memory free ${memory.freePct}%` });
|
|
86
|
+
if (rate('swapouts') != null) observations.push({ key: 'memory.swapping', family: 'memory', def: alarm('memory.swapping'), value: rate('swapouts'), unit: 'pages/s', summary: `swapping out ${round(rate('swapouts'))} pages/s` });
|
|
87
|
+
if (swapped30 != null) observations.push({ key: 'memory.swapped30m', family: 'memory', def: alarm('memory.swapped30m'), value: swapped30, unit: '% of RAM', summary: `${round(swapped30, 2)}% of RAM swapped out in the last ${readings.memory.swappedWindowMin} min` });
|
|
88
|
+
if (rate('compressions') != null) observations.push({ key: 'memory.compressions', family: 'memory', def: alarm('memory.compressions'), value: rate('compressions'), unit: 'pages/s', summary: `${round(rate('compressions'), 0)} compressions/s` });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---- processes, renderers, daemons, writers ------------------------------------------------------
|
|
92
|
+
let procs = null;
|
|
93
|
+
if (on.has('processes')) await attempt('processes', async () => {
|
|
94
|
+
const [rows, rus] = await Promise.all([
|
|
95
|
+
this.reader.processes(),
|
|
96
|
+
on.has('process-footprint') ? this.reader.rusage().catch((error) => { errors['process-footprint'] = error.message; return new Map(); }) : new Map(),
|
|
97
|
+
]);
|
|
98
|
+
if (this.platform === 'darwin' && now - this.topFoot.at >= cfg.footprintTopEverySec * 1000) {
|
|
99
|
+
this.topFoot = { at: now, map: await this.reader.topFootprints().catch((error) => { errors.daemons = error.message; return this.topFoot.map; }) };
|
|
100
|
+
}
|
|
101
|
+
const byPid = new Map(rows.map((r) => [r.pid, r]));
|
|
102
|
+
const nextPrev = new Map();
|
|
103
|
+
for (const r of rows) {
|
|
104
|
+
const prev = this.procPrev.get(r.pid);
|
|
105
|
+
const same = prev && prev.command === r.command && r.cpuSec >= prev.cpuSec;
|
|
106
|
+
r.cpuPct = same && dt ? ((r.cpuSec - prev.cpuSec) / dt) * 100 : null;
|
|
107
|
+
const ru = rus.get(r.pid);
|
|
108
|
+
const top = this.topFoot.map.get(r.pid);
|
|
109
|
+
r.footprintBytes = ru?.footprintBytes ?? top ?? r.rssBytes;
|
|
110
|
+
r.footprintSource = ru ? 'footprint' : top != null ? 'footprint (top)' : 'rss';
|
|
111
|
+
r.writeBps = same && dt && ru && prev.written != null && ru.diskWrittenBytes >= prev.written ? (ru.diskWrittenBytes - prev.written) / dt : null;
|
|
112
|
+
nextPrev.set(r.pid, { cpuSec: r.cpuSec, command: r.command, written: ru?.diskWrittenBytes ?? null });
|
|
113
|
+
const hist = same ? (this.footHistory.get(r.pid) ?? []) : [];
|
|
114
|
+
hist.push([now, r.footprintBytes]);
|
|
115
|
+
while (hist.length && now - hist[0][0] > 130_000) hist.shift();
|
|
116
|
+
this.footHistory.set(r.pid, hist);
|
|
117
|
+
const ago = hist.findLast?.(([at]) => now - at >= 55_000) ?? [...hist].reverse().find(([at]) => now - at >= 55_000);
|
|
118
|
+
r.growthMBPerMin = ago ? ((r.footprintBytes - ago[1]) / MB) * (60_000 / (now - ago[0])) : null;
|
|
119
|
+
}
|
|
120
|
+
for (const pid of this.footHistory.keys()) if (!nextPrev.has(pid)) this.footHistory.delete(pid);
|
|
121
|
+
this.procPrev = nextPrev;
|
|
122
|
+
procs = { rows, byPid };
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (procs) {
|
|
126
|
+
const { rows, byPid } = procs;
|
|
127
|
+
const self = process.pid;
|
|
128
|
+
const listed = new Set();
|
|
129
|
+
const view = (r) => { listed.add(r.pid); return { pid: r.pid, cpuPct: round(r.cpuPct, 0), footprintMB: Math.round(r.footprintBytes / MB), footprintSource: r.footprintSource, growthMBPerMin: round(r.growthMBPerMin, 0), command: short(r.command) }; };
|
|
130
|
+
const byCpu = rows.filter((r) => r.cpuPct != null && r.pid !== self).sort((a, b) => b.cpuPct - a.cpuPct);
|
|
131
|
+
const byMem = [...rows].sort((a, b) => b.footprintBytes - a.footprintBytes);
|
|
132
|
+
const topCpu = byCpu.slice(0, 10).map(view);
|
|
133
|
+
const topMemory = byMem.slice(0, 10).map(view);
|
|
134
|
+
readings.processes = { count: rows.length, topCpu, topMemory };
|
|
135
|
+
families.add('process');
|
|
136
|
+
const daemonNames = new Set(cfg.daemons?.[this.platform] ?? []);
|
|
137
|
+
const isDaemon = (r) => daemonNames.has(base(r.command));
|
|
138
|
+
// Every process down to the lower clear line is observed, so a raised alarm holds through a brief dip
|
|
139
|
+
// instead of clearing and raising again (hysteresis); one that falls below it is no longer watched.
|
|
140
|
+
for (const r of byCpu) {
|
|
141
|
+
if (r.cpuPct < Math.min(alarm('process.cpu').clear ?? alarm('process.cpu').warn, alarm('renderer.cpu').clear ?? alarm('renderer.cpu').warn)) break;
|
|
142
|
+
if (isDaemon(r) || AGENT.test(base(r.command))) continue;
|
|
143
|
+
const renderer = RENDERER.test(r.command);
|
|
144
|
+
const name = renderer ? 'renderer.cpu' : 'process.cpu';
|
|
145
|
+
const tab = renderer ? this.rendererTabs?.get(r.pid) ?? null : null;
|
|
146
|
+
observations.push({ key: `${name}:${r.pid}`, family: 'process', def: alarm(name), value: r.cpuPct, unit: '% CPU', summary: `${renderer ? 'renderer' : 'process'} ${r.pid} at ${round(r.cpuPct, 0)}% CPU${tab ? `, serving ${tab.url.slice(0, 80)} (${tab.owner})` : ''}`, detail: { ...view(r), ...(renderer ? { tab } : {}) } });
|
|
147
|
+
}
|
|
148
|
+
// Runaway growth: one reading against the reading a minute earlier. Every process over the size line is
|
|
149
|
+
// observed; a raised one holds until its growth falls under a quarter of the line, so a leak that pauses
|
|
150
|
+
// between samples is one alarm, not a raise and a clear each minute.
|
|
151
|
+
for (const r of rows) {
|
|
152
|
+
if (r.growthMBPerMin == null) continue;
|
|
153
|
+
const renderer = RENDERER.test(r.command);
|
|
154
|
+
const tab = renderer ? this.rendererTabs?.get(r.pid) ?? null : null;
|
|
155
|
+
const def = alarm(renderer ? 'renderer.growth' : 'process.growth');
|
|
156
|
+
if (r.footprintBytes / MB < def.minMB) continue;
|
|
157
|
+
observations.push({ key: `${renderer ? 'renderer' : 'process'}.growth:${r.pid}`, family: 'process', def: { forSec: 0 }, value: r.growthMBPerMin, met: { warn: r.growthMBPerMin > def.mbPerMin, crit: false, clears: r.growthMBPerMin < def.mbPerMin / 4 }, unit: 'MB/min', summary: `${renderer ? 'renderer' : 'process'} ${r.pid} at ${Math.round(r.footprintBytes / MB)} MB, growing ${Math.round(r.growthMBPerMin)} MB/min${tab ? `, serving ${tab.url.slice(0, 80)} (${tab.owner})` : ''}`, detail: { ...view(r), ...(renderer ? { tab } : {}) } });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (on.has('renderers')) {
|
|
161
|
+
const renderers = rows.filter((r) => RENDERER.test(r.command));
|
|
162
|
+
const orphaned = rows.filter((r) => r.ppid === 1 && /--headless/.test(r.command) && !/--type=/.test(r.command) && BROWSER_MAIN.test(r.command));
|
|
163
|
+
this.renderers = renderers.map((r) => ({ pid: r.pid, footprintBytes: r.footprintBytes, cpuPct: r.cpuPct }));
|
|
164
|
+
// The tab each renderer serves, as the browser-tabs feature last matched it (null: not matched).
|
|
165
|
+
const tabbed = (v) => ({ ...v, tab: this.rendererTabs?.get(v.pid) ?? null });
|
|
166
|
+
readings.renderers = {
|
|
167
|
+
count: renderers.length,
|
|
168
|
+
totalMB: Math.round(renderers.reduce((n, r) => n + r.footprintBytes, 0) / MB),
|
|
169
|
+
top: renderers.sort((a, b) => (b.cpuPct ?? 0) - (a.cpuPct ?? 0) || b.footprintBytes - a.footprintBytes).slice(0, 5).map(view).map(tabbed),
|
|
170
|
+
orphanedHeadless: orphaned.map(view),
|
|
171
|
+
};
|
|
172
|
+
families.add('browser');
|
|
173
|
+
for (const r of orphaned) observations.push({ key: `browser.orphan:${r.pid}`, family: 'browser', def: alarm('browser.orphan'), value: 1, met: { warn: true, crit: false, clears: false }, summary: `headless browser ${r.pid} left running with launchd as its parent`, detail: view(r) });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (on.has('daemons')) {
|
|
177
|
+
const daemons = rows.filter(isDaemon);
|
|
178
|
+
readings.daemons = daemons.sort((a, b) => (b.cpuPct ?? 0) - (a.cpuPct ?? 0)).map((r) => ({ name: base(r.command), ...view(r) }));
|
|
179
|
+
families.add('daemon');
|
|
180
|
+
const footDef = alarm('daemon.footprint');
|
|
181
|
+
for (const r of daemons) {
|
|
182
|
+
const name = base(r.command);
|
|
183
|
+
if (r.cpuPct != null) observations.push({ key: `daemon.cpu:${name}:${r.pid}`, family: 'daemon', def: alarm('daemon.cpu'), value: r.cpuPct, unit: '% CPU', summary: `${name} at ${round(r.cpuPct, 0)}% CPU`, detail: view(r) });
|
|
184
|
+
const line = footDef.perDaemonMB?.[name] ?? footDef.warnMB;
|
|
185
|
+
if (r.footprintSource !== 'rss' || this.platform !== 'darwin') observations.push({ key: `daemon.footprint:${name}:${r.pid}`, family: 'daemon', def: { warn: line, forSec: 0 }, value: r.footprintBytes / MB, unit: 'MB', summary: `${name} holds ${Math.round(r.footprintBytes / MB)} MB`, detail: view(r) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (on.has('disk-io')) {
|
|
190
|
+
const writers = rows.filter((r) => r.writeBps > 0).sort((a, b) => b.writeBps - a.writeBps).slice(0, 5);
|
|
191
|
+
readings.diskWriters = writers.map((r) => ({ ...view(r), writeMBps: round(r.writeBps / MB, 1) }));
|
|
192
|
+
families.add('disk-writer');
|
|
193
|
+
for (const r of rows) if (r.writeBps != null && r.writeBps / MB > alarm('disk.writer').warn * 0.5) observations.push({ key: `disk.writer:${r.pid}`, family: 'disk-writer', def: alarm('disk.writer'), value: r.writeBps / MB, unit: 'MB/s', summary: `process ${r.pid} writing ${round(r.writeBps / MB, 1)} MB/s`, detail: view(r) });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Who each listed process belongs to: the agent session above it, read from the inherited environment.
|
|
197
|
+
this.procs = procs;
|
|
198
|
+
this.listed = listed;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (on.has('disk-io')) await attempt('disk-io', async () => {
|
|
202
|
+
const io = await this.reader.diskIO();
|
|
203
|
+
const p = this.prev?.io;
|
|
204
|
+
readings.diskIO = Object.fromEntries(Object.entries(io).map(([name, v]) => [name, { MBps: p?.[name] && dt ? round((v.bytes - p[name].bytes) / dt / MB, 1) : null }]));
|
|
205
|
+
this.io = io;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (on.has('files')) await attempt('files', async () => {
|
|
209
|
+
const { open, max } = await this.reader.files();
|
|
210
|
+
readings.files = { open, max, pct: round((open / max) * 100) };
|
|
211
|
+
families.add('files');
|
|
212
|
+
observations.push({ key: 'files.open', family: 'files', def: alarm('files.open'), value: (open / max) * 100, unit: '%', summary: `${open} of ${max} files open` });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (on.has('tcp')) await attempt('tcp', async () => {
|
|
216
|
+
const { sockets, range, timeWaitSec } = await this.reader.tcp();
|
|
217
|
+
this.sockets = sockets;
|
|
218
|
+
const size = range.last - range.first + 1;
|
|
219
|
+
const states = {};
|
|
220
|
+
const listening = new Set();
|
|
221
|
+
const local = new Set();
|
|
222
|
+
for (const s of sockets) {
|
|
223
|
+
states[s.state] = (states[s.state] ?? 0) + 1;
|
|
224
|
+
if (s.state === 'LISTEN') listening.add(s.local.port);
|
|
225
|
+
if (!['*', '0.0.0.0', '::', '[::]'].includes(s.local.addr)) local.add(s.local.addr);
|
|
226
|
+
}
|
|
227
|
+
const inRange = (port) => port >= range.first && port <= range.last;
|
|
228
|
+
const wildcard = new Set();
|
|
229
|
+
const used = new Map();
|
|
230
|
+
const add = (addr, port) => { if (!used.has(addr)) used.set(addr, new Set()); used.get(addr).add(port); };
|
|
231
|
+
const targets = new Map();
|
|
232
|
+
for (const s of sockets) {
|
|
233
|
+
if (inRange(s.local.port)) {
|
|
234
|
+
if (['*', '0.0.0.0', '::', '[::]'].includes(s.local.addr)) wildcard.add(s.local.port); else add(s.local.addr, s.local.port);
|
|
235
|
+
}
|
|
236
|
+
if (local.has(s.foreign.addr) && inRange(s.foreign.port)) add(s.foreign.addr, s.foreign.port);
|
|
237
|
+
if (s.state !== 'TIME_WAIT') continue;
|
|
238
|
+
// Group each TIME_WAIT by the endpoint it churned against: a listener here (either side), else the remote.
|
|
239
|
+
let target;
|
|
240
|
+
if (listening.has(s.local.port)) target = `:${s.local.port}`;
|
|
241
|
+
else if ((local.has(s.foreign.addr) || LOOPBACK.test(s.foreign.addr)) && listening.has(s.foreign.port)) target = `:${s.foreign.port}`;
|
|
242
|
+
else target = `${s.foreign.addr}:${s.foreign.port}`;
|
|
243
|
+
targets.set(target, (targets.get(target) ?? 0) + 1);
|
|
244
|
+
}
|
|
245
|
+
const ephemeral = [...used].map(([addr, ports]) => {
|
|
246
|
+
for (const port of wildcard) ports.add(port);
|
|
247
|
+
return { addr, used: ports.size, pct: round((ports.size / size) * 100) };
|
|
248
|
+
}).sort((a, b) => b.used - a.used).slice(0, 5);
|
|
249
|
+
const top = [...targets].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
250
|
+
let owners = new Map();
|
|
251
|
+
const def = alarm('tcp.timeWaitTarget');
|
|
252
|
+
if (top.some(([target, n]) => target.startsWith(':') && (n / size) * 100 >= def.warn / 2) || now - (this.listenersAt ?? 0) >= cfg.slowIntervalSec * 1000) {
|
|
253
|
+
owners = this.reader.listeners ? await this.reader.listeners(sockets).catch(() => new Map()) : new Map();
|
|
254
|
+
this.owners = owners; this.listenersAt = now;
|
|
255
|
+
} else owners = this.owners ?? new Map();
|
|
256
|
+
const timeWaitByTarget = top.map(([target, count]) => {
|
|
257
|
+
const owner = target.startsWith(':') ? owners.get(Number(target.slice(1))) : null;
|
|
258
|
+
return { target, count, pct: round((count / size) * 100), ...(owner ? { listener: { pid: owner.pid, command: short(owner.command) } } : {}) };
|
|
259
|
+
});
|
|
260
|
+
readings.tcp = { states, timeWait: states.TIME_WAIT ?? 0, range: { ...range, size }, timeWaitSec, ephemeral, timeWaitByTarget };
|
|
261
|
+
families.add('tcp');
|
|
262
|
+
for (const e of ephemeral) observations.push({ key: `tcp.ephemeral:${e.addr}`, family: 'tcp', def: alarm('tcp.ephemeral'), value: e.pct, unit: '% of ports', summary: `${e.used} of ${size} ephemeral ports in use on ${e.addr}` });
|
|
263
|
+
for (const t of timeWaitByTarget) observations.push({ key: `tcp.timeWaitTarget:${t.target}`, family: 'tcp', def, value: t.pct, unit: '% of ports', summary: `${t.count} TIME_WAIT against ${t.target.startsWith(':') ? `listener ${t.target}` : t.target}${t.listener ? ` (pid ${t.listener.pid})` : ''}; range ${size}, TIME_WAIT lasts ${timeWaitSec} s`, detail: t });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ---- the slow set ---------------------------------------------------------------------------------
|
|
267
|
+
// It runs beside the fast loop (the connector can take 20 s to answer) and is merged when it lands;
|
|
268
|
+
// `wait` (a single look) waits for it.
|
|
269
|
+
if (!this.slowRunning && now - this.slowStarted >= cfg.slowIntervalSec * 1000) {
|
|
270
|
+
this.slowStarted = now;
|
|
271
|
+
const slow = { at: now, readings: {}, observations: [], families: new Set(), errors: {} };
|
|
272
|
+
this.slowRunning = this.#slow(on, slow, slow.errors, now).then(() => { this.slow = slow; }).finally(() => { this.slowRunning = null; });
|
|
273
|
+
}
|
|
274
|
+
if (wait && this.slowRunning) await this.slowRunning;
|
|
275
|
+
// A slow reading lands up to a minute after it was taken: one whose feature has since been turned off is dropped.
|
|
276
|
+
for (const [key, value] of Object.entries(this.slow.readings)) if (on.has(SLOW_FEATURE[key])) readings[key] = value;
|
|
277
|
+
for (const [id, error] of Object.entries(this.slow.errors ?? {})) if (on.has(id)) errors[id] = error;
|
|
278
|
+
for (const o of this.slow.observations) if (on.has(SLOW_FEATURE[o.family])) observations.push(o);
|
|
279
|
+
for (const f of this.slow.families ?? []) if (on.has(SLOW_FEATURE[f])) families.add(f);
|
|
280
|
+
|
|
281
|
+
// A feature turned off clears its raised alarms: its family is judged with nothing observed.
|
|
282
|
+
for (const [family, feature] of Object.entries(FAMILY_FEATURE)) if (!on.has(feature)) families.add(family);
|
|
283
|
+
|
|
284
|
+
await this.#sessions(readings, observations);
|
|
285
|
+
this.prev = { at: now, memory, io: this.io ?? null };
|
|
286
|
+
return { readings, observations, families, errors };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async #slow(on, slow, errors, now) {
|
|
290
|
+
const cfg = this.config;
|
|
291
|
+
const alarm = (name) => cfg.alarms[name];
|
|
292
|
+
const attempt = async (id, fn) => { try { await fn(); } catch (error) { errors[id] = error.message; } };
|
|
293
|
+
|
|
294
|
+
if (on.has('disk')) await attempt('disk', async () => {
|
|
295
|
+
const def = alarm('disk.free');
|
|
296
|
+
const fill = alarm('disk.filling');
|
|
297
|
+
const vols = await this.reader.volumes();
|
|
298
|
+
slow.families.add('disk');
|
|
299
|
+
slow.readings.disk = vols.map((v) => {
|
|
300
|
+
const ring = this.diskRing.get(v.mount) ?? [];
|
|
301
|
+
ring.push([now, v.freeBytes]);
|
|
302
|
+
while (ring.length && now - ring[0][0] > (fill.lookbackMin + 1) * 60_000) ring.shift();
|
|
303
|
+
this.diskRing.set(v.mount, ring);
|
|
304
|
+
const [t0, f0] = ring[0];
|
|
305
|
+
const hours = (now - t0) / 3_600_000;
|
|
306
|
+
const perHour = hours >= 10 / 60 ? (f0 - v.freeBytes) / hours : null; // bytes consumed per hour
|
|
307
|
+
const hoursToFull = perHour > 0 ? v.freeBytes / perHour : null;
|
|
308
|
+
const freePct = (v.freeBytes / v.totalBytes) * 100;
|
|
309
|
+
const per = cfg.volumes?.[v.mount] ?? {};
|
|
310
|
+
const row = { mount: v.mount, freeGB: round(v.freeBytes / GB), totalGB: round(v.totalBytes / GB), freePct: round(freePct), fillGBPerHour: perHour == null ? null : round(perHour / GB, 2), hoursToFull: round(hoursToFull) };
|
|
311
|
+
const byGB = per.warnGB != null || per.critGB != null;
|
|
312
|
+
slow.observations.push(byGB
|
|
313
|
+
? { key: `disk.free:${v.mount}`, family: 'disk', def: { below: true, warn: per.warnGB, crit: per.critGB, clear: per.clearGB, forSec: per.forSec ?? def.forSec }, value: v.freeBytes / GB, unit: 'GB', summary: `${v.mount} has ${round(v.freeBytes / GB)} GB free` }
|
|
314
|
+
: { key: `disk.free:${v.mount}`, family: 'disk', def: { below: true, warn: per.warnPct ?? def.warnPct, crit: per.critPct ?? def.critPct, forSec: per.forSec ?? def.forSec }, value: freePct, unit: '%', summary: `${v.mount} has ${round(freePct)}% (${round(v.freeBytes / GB)} GB) free` });
|
|
315
|
+
if (hoursToFull != null) slow.observations.push({ key: `disk.filling:${v.mount}`, family: 'disk', def: { forSec: 0 }, value: hoursToFull, unit: 'h to full', met: { crit: hoursToFull < fill.critHours, warn: hoursToFull < fill.critHours || (hoursToFull < fill.warnHours && freePct < fill.warnBelowPct), clears: hoursToFull >= fill.warnHours || freePct >= fill.warnBelowPct }, summary: `${v.mount} fills in ${round(hoursToFull)} h at ${round(perHour / GB, 2)} GB/h` });
|
|
316
|
+
return row;
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
if (on.has('services')) await attempt('services', async () => {
|
|
321
|
+
const list = cfg.services ?? [];
|
|
322
|
+
const jobs = await this.reader.services(list.map((s) => s.label));
|
|
323
|
+
slow.families.add('service');
|
|
324
|
+
slow.readings.services = list.map((s) => {
|
|
325
|
+
const job = jobs.get(s.label);
|
|
326
|
+
let logAgeMin = null;
|
|
327
|
+
const log = s.log?.replace(/^~(?=\/)/, homedir());
|
|
328
|
+
if (log) try { logAgeMin = Math.max(0, round((now - statSync(log).mtimeMs) / 60_000)); } catch { logAgeMin = null; }
|
|
329
|
+
const running = Boolean(job?.pid);
|
|
330
|
+
const down = !job || (!running && (job.lastExit !== 0 || s.keepAlive !== false));
|
|
331
|
+
const why = !job ? 'not loaded' : !running ? `not running (last exit ${job.lastExit})` : null;
|
|
332
|
+
if (down) slow.observations.push({ key: `service.down:${s.label}`, family: 'service', def: alarm('service.down'), value: 1, met: { warn: true, crit: false, clears: false }, summary: `service ${s.name ?? s.label} is ${why}` });
|
|
333
|
+
else slow.observations.push({ key: `service.down:${s.label}`, family: 'service', def: alarm('service.down'), value: 0, met: { warn: false, crit: false, clears: true }, summary: `service ${s.name ?? s.label} running` });
|
|
334
|
+
if (s.log && s.maxSilentMin) slow.observations.push({ key: `service.deaf:${s.label}`, family: 'service', def: { warn: s.maxSilentMin, forSec: 0 }, value: logAgeMin ?? Infinity, unit: 'min silent', summary: logAgeMin == null ? `service ${s.name ?? s.label}: its log ${log} is missing` : `service ${s.name ?? s.label} has written nothing to its log for ${logAgeMin} min` });
|
|
335
|
+
return { label: s.label, name: s.name ?? null, running, pid: job?.pid ?? null, lastExit: job?.lastExit ?? null, ...(s.log ? { logAgeMin } : {}) };
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (on.has('supercode')) await attempt('supercode', async () => {
|
|
340
|
+
const bin = this.env.SUPERCODE_BIN || 'supercode';
|
|
341
|
+
slow.families.add('supercode');
|
|
342
|
+
const t0 = Date.now();
|
|
343
|
+
// The server's live view of this machine comes from the machine list: `teams status` carries the
|
|
344
|
+
// enrollment record, whose connection field is not kept current.
|
|
345
|
+
// Liveness is the daemon's cheapest door (`describe`); `launches.list` walks every pane and can be slow
|
|
346
|
+
// on a busy machine without the daemon being down, so its timeout is a reading, not an alarm.
|
|
347
|
+
const [status, describe, launches, machines] = await Promise.all([
|
|
348
|
+
run(bin, ['teams', 'status', '--json'], { timeoutMs: 20_000, env: this.env }),
|
|
349
|
+
run(bin, ['teams', 'describe'], { timeoutMs: 15_000, env: this.env }).then((r) => ({ ...r, ms: Date.now() - t0 })),
|
|
350
|
+
run(bin, ['teams', 'launches', 'list'], { timeoutMs: 45_000, env: this.env }),
|
|
351
|
+
run(bin, ['teams', 'machines', 'list', '--json'], { timeoutMs: 20_000, env: this.env }),
|
|
352
|
+
]);
|
|
353
|
+
let described = null; try { described = JSON.parse(describe.stdout); } catch { /* the daemon did not answer */ }
|
|
354
|
+
let data = null; try { data = JSON.parse(status.stdout).data; } catch { /* unreadable */ }
|
|
355
|
+
const connector = data?.connector ?? null;
|
|
356
|
+
let listed = null;
|
|
357
|
+
try { const body = JSON.parse(machines.stdout); listed = (body.items ?? body.data?.items ?? []).find((m) => m.id === connector?.machine?.id) ?? null; } catch { /* unreadable */ }
|
|
358
|
+
const connection = listed?.connection ?? null;
|
|
359
|
+
let rows = null; try { rows = JSON.parse(launches.stdout); } catch { /* the daemon did not answer */ }
|
|
360
|
+
const def = alarm('supercode.launch');
|
|
361
|
+
const recent = Array.isArray(rows) ? rows.filter((r) => r.at && now - r.at < 24 * 3_600_000) : [];
|
|
362
|
+
const stuck = recent.filter((r) => (r.status === 'issued' && now - r.at > def.stuckMin * 60_000) || (r.live && r.inputDelivered === false && r.inputError));
|
|
363
|
+
const failed = recent.filter((r) => r.status === 'failed' && now - r.at < def.failedWithinMin * 60_000);
|
|
364
|
+
slow.readings.supercode = {
|
|
365
|
+
connector: connector ? { enrolled: connector.enrolled, running: connector.running, connection, notSyncing: connector.sync?.not_syncing?.length ?? 0, lastError: connector.sync?.last_error?.message ?? null } : null,
|
|
366
|
+
statusError: status.ok ? null : status.error,
|
|
367
|
+
daemon: { answered: Boolean(described), error: described ? null : (describe.error || 'no answer'), ms: describe.ms },
|
|
368
|
+
launches: { read: Array.isArray(rows), ...(Array.isArray(rows) ? {} : { error: launches.error || 'no answer' }), recent: recent.length, stuck: stuck.map((r) => ({ key: r.key, pane: r.pane, kind: r.kind, status: r.status, at: new Date(r.at).toISOString(), inputError: r.inputError ?? null })), failed: failed.map((r) => ({ key: r.key, kind: r.kind, at: new Date(r.at).toISOString() })) },
|
|
369
|
+
};
|
|
370
|
+
const cdef = alarm('supercode.connector');
|
|
371
|
+
const link = connector?.enrolled ? (connector.running ? (connection == null ? null : connection === 'online') : false) : true;
|
|
372
|
+
slow.observations.push({ key: 'supercode.daemon', family: 'supercode', def: cdef, value: described ? 0 : 1, met: { warn: !described, crit: false, clears: Boolean(described) }, summary: described ? 'machine daemon answering' : `machine daemon did not answer describe (${describe.error || 'no answer'})` });
|
|
373
|
+
if (connector && link !== null) slow.observations.push({ key: 'supercode.link', family: 'supercode', def: cdef, value: link ? 0 : 1, met: { warn: !link, crit: false, clears: link }, summary: link ? 'connector linked to its server' : `connector ${connector.running ? `running but the server sees this machine ${connection}` : 'not running'}` });
|
|
374
|
+
for (const r of stuck) slow.observations.push({ key: `supercode.launch:${r.key}`, family: 'supercode', def: { forSec: 0 }, value: 1, met: { warn: true, crit: false, clears: false }, summary: r.status === 'issued' ? `launch ${r.key} (${r.kind ?? 'pane'}) issued ${Math.round((now - r.at) / 60_000)} min ago and never became ready` : `launch ${r.key} is live but its first input was not delivered: ${r.inputError}` });
|
|
375
|
+
for (const r of failed) slow.observations.push({ key: `supercode.launchFailed:${r.key}`, family: 'supercode', def: { forSec: 0 }, value: 1, met: { warn: true, crit: false, clears: false }, summary: `launch ${r.key} (${r.kind ?? 'pane'}) failed at ${new Date(r.at).toISOString()}` });
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (on.has('browser-tabs')) await attempt('browser-tabs', async () => {
|
|
379
|
+
const c = cfg.integrations.browserController;
|
|
380
|
+
let url = c.url;
|
|
381
|
+
if (!url && c.portFile) {
|
|
382
|
+
if (!existsSync(c.portFile)) throw new Error(`browser controller not running (${c.portFile} missing)`);
|
|
383
|
+
url = `http://127.0.0.1:${readFileSync(c.portFile, 'utf8').trim()}`;
|
|
384
|
+
}
|
|
385
|
+
const response = await fetch(`${url}/tabs`, { signal: AbortSignal.timeout(5000) });
|
|
386
|
+
const body = await response.json();
|
|
387
|
+
const tabs = body.result ?? body.tabs ?? [];
|
|
388
|
+
const byHolder = {};
|
|
389
|
+
for (const t of tabs) for (const h of [...(t.holders ?? []), ...(t.openedFor ? [`openedFor:${t.openedFor}`] : [])]) byHolder[h] = (byHolder[h] ?? 0) + 1;
|
|
390
|
+
// Who owns each tab: the agent holding it through the controller, else whoever it was opened for, else (a
|
|
391
|
+
// tab on a local port, such as an editor that opened its own tab) the process serving that port.
|
|
392
|
+
const listeners = this.reader.listeners ? await this.reader.listeners(this.sockets ?? []).catch(() => new Map()) : new Map();
|
|
393
|
+
const rows = tabs.map((t) => {
|
|
394
|
+
const port = localPort(t.url);
|
|
395
|
+
const server = port ? listeners.get(port) : null;
|
|
396
|
+
const owner = t.holders?.length ? `held by ${t.holders.join(', ')}` : t.openedFor ? `opened for ${t.openedFor}` : server ? `served by pid ${server.pid} (${server.command})` : port ? `local port ${port} with no listener: a leftover` : 'no owner known';
|
|
397
|
+
return { title: String(t.title ?? '').slice(0, 80), url: String(t.url ?? '').slice(0, 200), origin: originOf(t.url), holders: t.holders ?? [], openedFor: t.openedFor ?? null, owner, ...(server ? { server: { pid: server.pid, command: short(server.command) } } : {}), renderers: [] };
|
|
398
|
+
});
|
|
399
|
+
// Which renderer serves which tab, from the storage buckets each large or busy renderer holds open.
|
|
400
|
+
const candidates = (this.renderers ?? []).filter((r) => r.footprintBytes >= 300 * MB || (r.cpuPct ?? 0) >= 20).slice(0, 12).map((r) => r.pid);
|
|
401
|
+
const origins = await rendererOrigins(candidates, this.platform).catch(() => new Map());
|
|
402
|
+
const rendererTabs = new Map();
|
|
403
|
+
for (const [pid, set] of origins) for (const row of rows) if (row.origin && set.has(row.origin)) { row.renderers.push(pid); rendererTabs.set(pid, { url: row.url, title: row.title, owner: row.owner, ...(row.server ? { server: row.server } : {}) }); }
|
|
404
|
+
this.rendererTabs = rendererTabs;
|
|
405
|
+
slow.readings.browserTabs = { count: tabs.length, byHolder, tabs: rows };
|
|
406
|
+
slow.families.add('tabs');
|
|
407
|
+
const def = alarm('browser.tabs');
|
|
408
|
+
if (def.warn != null) slow.observations.push({ key: 'browser.tabs', family: 'tabs', def, value: tabs.length, unit: 'tabs', summary: `${tabs.length} browser tabs open` });
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
if (on.has('power')) await attempt('power', async () => {
|
|
412
|
+
const r = await run('/usr/bin/powermetrics', ['-n', '1', '-i', '1000', '--samplers', 'thermal'], { timeoutMs: 10_000 });
|
|
413
|
+
if (!r.ok) throw new Error(r.error);
|
|
414
|
+
const level = /pressure level:\s*(\w+)/i.exec(r.stdout)?.[1] ?? null;
|
|
415
|
+
slow.readings.power = { thermalPressure: level };
|
|
416
|
+
slow.families.add('power');
|
|
417
|
+
if (level) slow.observations.push({ key: 'power.thermal', family: 'power', def: alarm('power.thermal'), value: level, met: { warn: level !== 'Nominal', crit: /Heavy|Trapping|Sleeping/.test(level), clears: level === 'Nominal' }, summary: `thermal pressure ${level}` });
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Names the agent session that owns each process a reading or an alarm names. */
|
|
422
|
+
async #sessions(readings, observations) {
|
|
423
|
+
if (!this.procs) return;
|
|
424
|
+
const { byPid } = this.procs;
|
|
425
|
+
const wanted = new Set();
|
|
426
|
+
const views = [];
|
|
427
|
+
const collect = (v) => { if (v?.pid) { wanted.add(v.pid); views.push(v); } };
|
|
428
|
+
for (const v of [...(readings.processes?.topCpu ?? []), ...(readings.processes?.topMemory ?? []), ...(readings.renderers?.top ?? []), ...(readings.renderers?.orphanedHeadless ?? []), ...(readings.diskWriters ?? [])]) collect(v);
|
|
429
|
+
for (const t of readings.tcp?.timeWaitByTarget ?? []) collect(t.listener);
|
|
430
|
+
for (const t of readings.browserTabs?.tabs ?? []) collect(t.server);
|
|
431
|
+
for (const r of readings.renderers?.top ?? []) collect(r.tab?.server);
|
|
432
|
+
for (const o of observations) collect(o.detail?.pid ? o.detail : o.detail?.listener);
|
|
433
|
+
const chain = (pid) => {
|
|
434
|
+
const out = []; let p = pid;
|
|
435
|
+
for (let i = 0; i < 40 && p > 1; i += 1) { const r = byPid.get(p); if (!r) break; out.push(r); p = r.ppid; }
|
|
436
|
+
return out;
|
|
437
|
+
};
|
|
438
|
+
const need = new Set();
|
|
439
|
+
const plan = new Map();
|
|
440
|
+
for (const pid of wanted) {
|
|
441
|
+
const r = byPid.get(pid);
|
|
442
|
+
if (!r) continue;
|
|
443
|
+
const cacheKey = `${pid}:${r.command}`;
|
|
444
|
+
if (this.sessionCache.has(cacheKey)) { plan.set(pid, { cacheKey }); continue; }
|
|
445
|
+
const up = chain(pid);
|
|
446
|
+
const agentIndex = up.findIndex((x) => AGENT.test(base(x.command)));
|
|
447
|
+
const lineage = agentIndex >= 0 ? up.slice(0, agentIndex + 1) : up.slice(0, 3);
|
|
448
|
+
for (const x of lineage) need.add(x.pid);
|
|
449
|
+
plan.set(pid, { cacheKey, lineage, agent: agentIndex >= 0 ? up[agentIndex] : null });
|
|
450
|
+
}
|
|
451
|
+
const env = need.size ? await this.#environments([...need]) : new Map();
|
|
452
|
+
for (const [pid, p] of plan) {
|
|
453
|
+
if (!this.sessionCache.has(p.cacheKey)) {
|
|
454
|
+
let found = null;
|
|
455
|
+
for (const x of p.lineage) { found = env.get(x.pid); if (found) break; }
|
|
456
|
+
const harness = p.agent ? (HARNESS[base(p.agent.command)] ?? base(p.agent.command)) : found?.harness ?? null;
|
|
457
|
+
const session = found ? { harness: found.harness ?? harness, id: found.id, address: found.address ?? `sc:${this.machine}:${found.harness ?? harness}:${found.id}`, agentPid: p.agent?.pid ?? null }
|
|
458
|
+
: p.agent ? { harness, id: null, address: null, agentPid: p.agent.pid } : null;
|
|
459
|
+
this.sessionCache.set(p.cacheKey, session);
|
|
460
|
+
if (this.sessionCache.size > 5000) this.sessionCache.delete(this.sessionCache.keys().next().value);
|
|
461
|
+
}
|
|
462
|
+
const session = this.sessionCache.get(p.cacheKey);
|
|
463
|
+
for (const v of views) if (v.pid === pid) v.session = session;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** The session ids a process inherited, and nothing else from its environment. */
|
|
468
|
+
async #environments(pids) {
|
|
469
|
+
const map = new Map();
|
|
470
|
+
const pick = (text) => {
|
|
471
|
+
const supercode = /(?:^|\s|\0)SUPERCODE_CALLER=(sc:[^\s\0]+)/.exec(text)?.[1];
|
|
472
|
+
const claude = /(?:^|\s|\0)CLAUDE_CODE_SESSION_ID=([0-9a-f-]{36})/.exec(text)?.[1];
|
|
473
|
+
const codex = /(?:^|\s|\0)CODEX_THREAD_ID=([0-9a-zA-Z-]{8,})/.exec(text)?.[1];
|
|
474
|
+
if (claude) return { harness: 'claude-code', id: claude };
|
|
475
|
+
if (codex) return { harness: 'codex', id: codex };
|
|
476
|
+
if (supercode) { const [, , harness, id] = supercode.split(':'); return { harness, id, address: supercode }; }
|
|
477
|
+
return null;
|
|
478
|
+
};
|
|
479
|
+
if (this.platform === 'linux') {
|
|
480
|
+
for (const pid of pids) { try { const found = pick(readFileSync(`/proc/${pid}/environ`, 'utf8')); if (found) map.set(pid, found); } catch { /* another user's */ } }
|
|
481
|
+
return map;
|
|
482
|
+
}
|
|
483
|
+
const r = await run('/bin/ps', ['eww', '-o', 'pid=,command=', '-p', pids.join(',')], { timeoutMs: 10_000 });
|
|
484
|
+
for (const line of r.stdout.split('\n')) {
|
|
485
|
+
const m = /^\s*(\d+)\s(.*)$/.exec(line);
|
|
486
|
+
if (!m) continue;
|
|
487
|
+
const found = pick(m[2]);
|
|
488
|
+
if (found) map.set(Number(m[1]), found);
|
|
489
|
+
}
|
|
490
|
+
return map;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Default alert windows (docs/adr/0006-machine-health-pack.md has each one's basis). A machine's config
|
|
2
|
+
// overrides any value: `alarms.<name>.<field>` merges over these, and `volumes.<mount>` sets per-volume lines.
|
|
3
|
+
export const DEFAULTS = {
|
|
4
|
+
intervalSec: 15, // fast readings (Prometheus's common scrape interval)
|
|
5
|
+
slowIntervalSec: 60, // disk space, services, the connector, tabs
|
|
6
|
+
footprintTopEverySec: 300, // macOS: every user's largest footprints through top (about 2 s of top per run)
|
|
7
|
+
repeatMin: 60, // a raised alarm mails again this often (netdata's repeat cap)
|
|
8
|
+
historyHours: 24,
|
|
9
|
+
daemons: {
|
|
10
|
+
darwin: ['dasd', 'fseventsd', 'mds', 'mds_stores', 'mdworker_shared', 'WindowServer', 'kernel_task', 'backupd', 'softwareupdated', 'photoanalysisd', 'cloudd', 'bird', 'nsurlsessiond', 'trustd', 'syspolicyd', 'XprotectService', 'logd', 'runningboardd', 'launchservicesd', 'coreduetd', 'spotlightknowledged'],
|
|
11
|
+
linux: ['systemd-journald', 'kswapd0', 'dockerd', 'containerd', 'snapd', 'packagekitd', 'tracker-miner-fs-3', 'baloo_file', 'updatedb'],
|
|
12
|
+
},
|
|
13
|
+
alarms: {
|
|
14
|
+
'memory.pressure': { forSec: 30, critForSec: 0 },
|
|
15
|
+
'memory.free': { below: true, warn: 20, crit: 10, clear: 25, forSec: 30 },
|
|
16
|
+
'memory.swapping': { warn: 0, forSec: 15 },
|
|
17
|
+
'memory.swapped30m': { crit: 5 },
|
|
18
|
+
'memory.compressions': { warn: 30_000, clear: 15_000, forSec: 30 },
|
|
19
|
+
'load.perCore': { warn: 2, crit: 8, forSec: 900, critForSec: 60 },
|
|
20
|
+
'process.cpu': { warn: 100, crit: 200, clear: 50, forSec: 600 },
|
|
21
|
+
'renderer.cpu': { warn: 80, clear: 40, forSec: 600 },
|
|
22
|
+
'renderer.growth': { minMB: 2048, mbPerMin: 500 },
|
|
23
|
+
'process.growth': { minMB: 4096, mbPerMin: 1024 },
|
|
24
|
+
'browser.orphan': { forSec: 600 },
|
|
25
|
+
'daemon.cpu': { warn: 40, crit: 100, clear: 20, forSec: 120 },
|
|
26
|
+
'daemon.footprint': { warnMB: 4096, perDaemonMB: { fseventsd: 1024 } },
|
|
27
|
+
'disk.free': { below: true, warnPct: 10, critPct: 5, forSec: 1800 },
|
|
28
|
+
'disk.filling': { warnHours: 24, warnBelowPct: 40, critHours: 4, lookbackMin: 60 },
|
|
29
|
+
'disk.writer': { warn: 50, forSec: 300 },
|
|
30
|
+
'tcp.ephemeral': { warn: 50, crit: 70, forSec: 30 },
|
|
31
|
+
'tcp.timeWaitTarget': { warn: 25, crit: 50, forSec: 30 },
|
|
32
|
+
'files.open': { warn: 70, crit: 90, forSec: 900 },
|
|
33
|
+
'service.down': { forSec: 60 },
|
|
34
|
+
'supercode.connector': { forSec: 60 },
|
|
35
|
+
'supercode.launch': { stuckMin: 5, failedWithinMin: 30 },
|
|
36
|
+
'browser.tabs': { warn: null },
|
|
37
|
+
'power.thermal': { forSec: 60 },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
42
|
+
export function merge(base, over) {
|
|
43
|
+
if (!isObject(over)) return over === undefined ? base : over;
|
|
44
|
+
const out = { ...base };
|
|
45
|
+
for (const [k, v] of Object.entries(over)) out[k] = isObject(v) && isObject(base?.[k]) ? merge(base[k], v) : v;
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Which levels a numeric value meets under a threshold definition. */
|
|
50
|
+
export function meets(value, def) {
|
|
51
|
+
if (value == null || Number.isNaN(value)) return { warn: false, crit: false, clears: true };
|
|
52
|
+
const past = (line) => line != null && (def.below ? value < line : value > line);
|
|
53
|
+
const crit = past(def.crit);
|
|
54
|
+
const warn = crit || past(def.warn);
|
|
55
|
+
const clears = def.clear == null ? !warn : (def.below ? value >= def.clear : value <= def.clear);
|
|
56
|
+
return { warn, crit, clears };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const RANK = { none: 0, warn: 1, crit: 2 };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One step of every alarm. `observations`: [{ key, family, def, value, met?, unit, summary, detail }] for the
|
|
63
|
+
* families read this cycle. A key of an evaluated family that was not observed has cleared (its entity is gone).
|
|
64
|
+
* Returns the transitions; `state` (key → record) is updated in place.
|
|
65
|
+
*/
|
|
66
|
+
export function judge(state, observations, families, now) {
|
|
67
|
+
const transitions = [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const o of observations) {
|
|
70
|
+
seen.add(o.key);
|
|
71
|
+
const m = o.met ?? meets(o.value, o.def);
|
|
72
|
+
const s = state[o.key] ?? (state[o.key] = { key: o.key, family: o.family, level: 'none', warnSince: null, critSince: null, raisedAt: null, lastMailAt: null });
|
|
73
|
+
s.warnSince = m.warn ? (s.warnSince ?? now) : null;
|
|
74
|
+
s.critSince = m.crit ? (s.critSince ?? now) : null;
|
|
75
|
+
const warnFor = (o.def.forSec ?? 0) * 1000;
|
|
76
|
+
const critFor = (o.def.critForSec ?? o.def.forSec ?? 0) * 1000;
|
|
77
|
+
let target = 'none';
|
|
78
|
+
if (m.crit && now - s.critSince >= critFor) target = 'crit';
|
|
79
|
+
else if (m.warn && now - s.warnSince >= warnFor) target = 'warn';
|
|
80
|
+
// Hysteresis: a raised alarm holds at warn until the value is past its clear line.
|
|
81
|
+
if (target === 'none' && s.level !== 'none' && !m.clears) target = 'warn';
|
|
82
|
+
Object.assign(s, { value: o.value, unit: o.unit ?? null, summary: o.summary, detail: o.detail ?? null, at: now });
|
|
83
|
+
if (target !== s.level) {
|
|
84
|
+
transitions.push({ key: o.key, from: s.level, to: target, value: o.value, unit: o.unit ?? null, summary: o.summary, detail: o.detail ?? null });
|
|
85
|
+
if (RANK[target] > RANK[s.level] && s.level === 'none') s.raisedAt = now;
|
|
86
|
+
s.level = target;
|
|
87
|
+
if (target === 'none') s.raisedAt = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const [key, s] of Object.entries(state)) {
|
|
91
|
+
if (seen.has(key) || !families.has(s.family)) continue;
|
|
92
|
+
// An alarm restored from before a restart is not cleared by the first samples, which have no rates yet.
|
|
93
|
+
if (s.restoredUntil && now < s.restoredUntil) continue;
|
|
94
|
+
if (s.level !== 'none') transitions.push({ key, from: s.level, to: 'none', value: null, summary: `${s.summary} — cleared: no longer over its line, or the process exited`, detail: s.detail, gone: true });
|
|
95
|
+
delete state[key];
|
|
96
|
+
}
|
|
97
|
+
return transitions;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function raised(state) {
|
|
101
|
+
return Object.values(state).filter((s) => s.level !== 'none').sort((a, b) => RANK[b.level] - RANK[a.level] || a.key.localeCompare(b.key))
|
|
102
|
+
.map(({ key, family, level, value, unit, summary, detail, raisedAt }) => ({ key, family, level, value, unit, summary, detail, since: raisedAt ? new Date(raisedAt).toISOString() : null }));
|
|
103
|
+
}
|
package/lib/win32.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Windows readers: what Node and netstat give without a native module. Other features report themselves off.
|
|
2
|
+
import { freemem, totalmem } from 'node:os';
|
|
3
|
+
import { statfsSync } from 'node:fs';
|
|
4
|
+
import { run } from './run.mjs';
|
|
5
|
+
|
|
6
|
+
export async function memory() {
|
|
7
|
+
const total = totalmem();
|
|
8
|
+
return { pressureLevel: null, totalBytes: total, freePct: Math.round((freemem() / total) * 100), swapUsedBytes: null, swapTotalBytes: null, counters: {}, pageSize: 4096 };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function splitAddress(text) {
|
|
12
|
+
const at = text.lastIndexOf(':');
|
|
13
|
+
return { addr: text.slice(0, at).replace(/^\[|\]$/g, ''), port: Number(text.slice(at + 1)) || 0 };
|
|
14
|
+
}
|
|
15
|
+
export async function tcp() {
|
|
16
|
+
const r = await run('netstat', ['-an', '-p', 'TCP'], { timeoutMs: 15_000 });
|
|
17
|
+
if (!r.ok) throw new Error(r.error);
|
|
18
|
+
const sockets = [];
|
|
19
|
+
for (const line of r.stdout.split(/\r?\n/)) {
|
|
20
|
+
const f = line.trim().split(/\s+/);
|
|
21
|
+
if (f[0] !== 'TCP' || f.length < 4) continue;
|
|
22
|
+
sockets.push({ local: splitAddress(f[1]), foreign: splitAddress(f[2]), state: f[3] === 'LISTENING' ? 'LISTEN' : f[3] });
|
|
23
|
+
}
|
|
24
|
+
// The Windows default dynamic range (netsh int ipv4 show dynamicport tcp): 49152-65535, 120 s TIME_WAIT.
|
|
25
|
+
return { sockets, range: { first: 49152, last: 65535 }, timeWaitSec: 120 };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function volumes() {
|
|
29
|
+
const rows = [];
|
|
30
|
+
for (const letter of 'CDEFGHIJ') {
|
|
31
|
+
try { const s = statfsSync(`${letter}:\\`); rows.push({ mount: `${letter}:`, device: `${letter}:`, totalBytes: s.blocks * s.bsize, freeBytes: s.bavail * s.bsize }); } catch { /* no such drive */ }
|
|
32
|
+
}
|
|
33
|
+
return rows;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const isRoot = () => false;
|
|
37
|
+
export const powermetricsAvailable = () => false;
|