fullstack-critic 1.0.2 → 1.0.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.
- package/package.json +1 -1
- package/src/sysinfo.js +395 -0
- package/src/ui.js +152 -6
- package/tests/sysinfo.test.js +121 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstack-critic",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "A free, universal principal-engineer critic. Attach it to any project or run it as a background watcher over any in-progress workflow. Reviews 100% of resources — code, packages, and dependencies — across 12 dimensions and emits an evidence-based report with a fix / optimize / delete / add action plan.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"code-review",
|
package/src/sysinfo.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Zero-dependency (Node core only) OS-level telemetry for the built-in critic
|
|
4
|
+
* dashboard. It answers one question the report never could: "what is actually
|
|
5
|
+
* running on this machine right now, app-by-app, and when will the critic finish?"
|
|
6
|
+
*
|
|
7
|
+
* Design rules (same discipline as the rest of the tool):
|
|
8
|
+
* - Never crash the workflow. Every external probe is wrapped; a platform or
|
|
9
|
+
* tool we cannot read degrades to an empty/partial snapshot, not an error.
|
|
10
|
+
* - Cross-platform: Windows (Get-Process), macOS/Linux (ps). CPU% per process
|
|
11
|
+
* uses deltas between samples so the numbers are real, not lifetime averages.
|
|
12
|
+
* - "Project / app wise" grouping: processes are folded into apps and tagged
|
|
13
|
+
* with a coarse category (project, runtime, browser, terminal, database,
|
|
14
|
+
* system, other) so the live-task table is structured, not a raw dump.
|
|
15
|
+
* - The critic's OWN process is highlighted — that is the concrete "live task"
|
|
16
|
+
* this app is running — with its CPU/RSS and a scan-rate-based ETA.
|
|
17
|
+
*/
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const { spawnSync, exec } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const IS_WIN = process.platform === 'win32';
|
|
22
|
+
|
|
23
|
+
// The machine-wide per-process probe is expensive on Windows (PowerShell cold
|
|
24
|
+
// start ~3s) — it must therefore run ASYNC + throttled in the live dashboard and
|
|
25
|
+
// is skipped entirely for one-shot `review`. System/self CPU, by contrast, is
|
|
26
|
+
// read from Node core and is always instant.
|
|
27
|
+
|
|
28
|
+
/* ------------------------------- primitives ------------------------------- */
|
|
29
|
+
|
|
30
|
+
// Block the thread for `ms` without busy-spinning (used only for the one-shot
|
|
31
|
+
// static snapshot, where there is no sample history to diff CPU against).
|
|
32
|
+
function sleepMs(ms) {
|
|
33
|
+
try {
|
|
34
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
35
|
+
} catch { /* SharedArrayBuffer disabled → just skip the wait */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
|
|
39
|
+
function round(n, d) { const m = Math.pow(10, d || 0); return Math.round((Number(n) || 0) * m) / m; }
|
|
40
|
+
|
|
41
|
+
// Aggregate instantaneous system CPU% from two os.cpus() readings.
|
|
42
|
+
function cpuPctFrom(prev, cur) {
|
|
43
|
+
if (!prev || !cur || prev.length !== cur.length) return null;
|
|
44
|
+
let prevIdle = 0, prevTotal = 0, curIdle = 0, curTotal = 0;
|
|
45
|
+
for (let i = 0; i < cur.length; i++) {
|
|
46
|
+
const p = prev[i].times, c = cur[i].times;
|
|
47
|
+
prevIdle += p.idle; curIdle += c.idle;
|
|
48
|
+
prevTotal += p.user + p.nice + p.sys + p.idle + p.irq;
|
|
49
|
+
curTotal += c.user + c.nice + c.sys + c.idle + c.irq;
|
|
50
|
+
}
|
|
51
|
+
const dt = curTotal - prevTotal;
|
|
52
|
+
if (dt <= 0) return null;
|
|
53
|
+
return clamp((1 - (curIdle - prevIdle) / dt) * 100, 0, 100);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function memInfo() {
|
|
57
|
+
const total = os.totalmem();
|
|
58
|
+
const free = os.freemem();
|
|
59
|
+
const used = Math.max(0, total - free);
|
|
60
|
+
return { memTotal: total, memFree: free, memUsed: used, memPct: total ? round((used / total) * 100, 1) : 0 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function systemStatic() {
|
|
64
|
+
const cpus = os.cpus();
|
|
65
|
+
const load = os.loadavg ? os.loadavg() : [0, 0, 0];
|
|
66
|
+
return {
|
|
67
|
+
cores: cpus.length,
|
|
68
|
+
cpuModel: (cpus[0] && cpus[0].model ? cpus[0].model : '').trim(),
|
|
69
|
+
load1: round(load[0] || 0, 2),
|
|
70
|
+
uptimeSec: Math.round(os.uptime() || 0),
|
|
71
|
+
hostname: os.hostname(),
|
|
72
|
+
platform: process.platform,
|
|
73
|
+
arch: os.arch(),
|
|
74
|
+
nodeVersion: process.version,
|
|
75
|
+
pid: process.pid,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ------------------------------ process list ------------------------------ */
|
|
80
|
+
|
|
81
|
+
// Coarse, honest categorisation so the live-task view is structured.
|
|
82
|
+
const CAT_RULES = [
|
|
83
|
+
['project', /fullstack-critic|critic[\\/]|bin\/fullstack/i],
|
|
84
|
+
['runtime', /^(node|deno|bun|python|python3|java|ruby|php|dotnet|go|rustc|cargo|perl|jl)\b/i],
|
|
85
|
+
['package', /^(npm|pnpm|yarn|npx|pip|pipenv|poetry|composer|bundle|gem|nuget)\b/i],
|
|
86
|
+
['browser', /(chrome|chromium|msedge|edge|firefox|safari|brave|opera|vivaldi)/i],
|
|
87
|
+
['terminal', /(powershell|pwsh|\bcmd\b|conhost|wt\b|terminal|iterm|\bbash\b|\bzsh\b|\bfish\b|codium)/i],
|
|
88
|
+
['editor', /(code|cursor|idea|webstorm|pycharm|goland|clion|rubymine|phpstorm|sublime|atom|nvim|\bvim\b|emacs|eclipse|devenv)/i],
|
|
89
|
+
['database', /(postgres|psql|mysqld|\bmariadb\b|mongod|redis|sqlite|clickhouse|influx|etcd)/i],
|
|
90
|
+
['container', /(docker|com\.docker|kubelet|kubectl|containerd|podman|orbstack)/i],
|
|
91
|
+
['vcs', /\bgit\b|git-lfs/i],
|
|
92
|
+
['system', /(svchost|csrss|services|lsass|wininit|winlogon|dwm|explorer|ntoskrnl|system\b|\bsmss\b|\bwin32\b|taskhostw|\bsearch\b|fontdrvhost|memory\.diagnostic|registry|wmiprvse)/i],
|
|
93
|
+
];
|
|
94
|
+
function classify(name) {
|
|
95
|
+
const n = String(name || '');
|
|
96
|
+
for (const [cat, re] of CAT_RULES) if (re.test(n)) return cat;
|
|
97
|
+
return 'other';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function execCapture(cmd, args, timeoutMs) {
|
|
101
|
+
try {
|
|
102
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutMs || 1500, windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
|
|
103
|
+
if (r.error || r.status == null) return null;
|
|
104
|
+
return r.stdout || '';
|
|
105
|
+
} catch { return null; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Return raw process rows: { pid, name, memBytes, cpuSec, path }. cpuSec is total
|
|
109
|
+
// CPU-seconds consumed (POSIX returns an instantaneous % which we pass through as
|
|
110
|
+
// cpuPctRaw instead). Callers diff successive samples to get a real CPU%.
|
|
111
|
+
function processes() {
|
|
112
|
+
return IS_WIN ? winProcesses() : posixProcesses();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Non-blocking process probe. cb(err, rows) — rows is [] on any failure so the
|
|
116
|
+
// live dashboard simply keeps showing the last successful app table.
|
|
117
|
+
function processesAsync(cb) {
|
|
118
|
+
if (IS_WIN) return runAsync('powershell.exe', WIN_ARGS, parseWinJson, cb);
|
|
119
|
+
return runAsync('ps', ['-axo', 'pid=,pcpu=,rss=,comm='], parsePosixText, cb);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const WIN_SCRIPT =
|
|
123
|
+
'$ErrorActionPreference="SilentlyContinue";' +
|
|
124
|
+
'Get-Process | ForEach-Object {' +
|
|
125
|
+
'$cpu=0; try{$cpu=$_.CPU}catch{$cpu=0}' +
|
|
126
|
+
'$ws=0; try{$ws=$_.WS}catch{$ws=0}' +
|
|
127
|
+
'[pscustomobject]@{Id=$_.Id;Name=$_.ProcessName;WS=$ws;CPU=$cpu}' +
|
|
128
|
+
'} | ConvertTo-Json -Compress';
|
|
129
|
+
const WIN_ARGS = ['-NoProfile', '-NonInteractive', '-Command', WIN_SCRIPT];
|
|
130
|
+
|
|
131
|
+
function runAsync(cmd, args, parser, cb) {
|
|
132
|
+
const { spawn } = require('child_process');
|
|
133
|
+
let child;
|
|
134
|
+
try { child = spawn(cmd, args, { windowsHide: true }); } catch (e) { return cb(e, []); }
|
|
135
|
+
let out = '';
|
|
136
|
+
let done = false;
|
|
137
|
+
const finish = (err) => { if (done) return; done = true; clearTimeout(timer); cb(err, err ? [] : parseSafe(parser, out)); };
|
|
138
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } finish(new Error('probe-timeout')); }, 15000);
|
|
139
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
140
|
+
child.on('error', (e) => finish(e));
|
|
141
|
+
child.on('close', () => finish(null));
|
|
142
|
+
}
|
|
143
|
+
function parseSafe(parser, out) { try { return parser(out) || []; } catch { return []; } }
|
|
144
|
+
|
|
145
|
+
function parseWinJson(stdout) {
|
|
146
|
+
if (!stdout) return [];
|
|
147
|
+
let rows; try { rows = JSON.parse(stdout); } catch { return []; }
|
|
148
|
+
if (!Array.isArray(rows)) rows = [rows];
|
|
149
|
+
return rows.filter((r) => r && r.Id != null).map((r) => ({
|
|
150
|
+
pid: Number(r.Id), name: String(r.Name || '?'),
|
|
151
|
+
memBytes: Number(r.WS) || 0, cpuSec: Number(r.CPU) || 0, path: '',
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
function parsePosixText(stdout) { return parsePsText(stdout); }
|
|
155
|
+
|
|
156
|
+
// Shared ps -text parser (POSIX). pcpu is an instantaneous-ish %; rss is KB.
|
|
157
|
+
function parsePsText(stdout) {
|
|
158
|
+
if (!stdout) return [];
|
|
159
|
+
const out = [];
|
|
160
|
+
for (const line of stdout.split('\n')) {
|
|
161
|
+
const m = line.match(/^\s*(\d+)\s+([\d.]+)\s+(\d+)\s+(.*)$/);
|
|
162
|
+
if (!m) continue;
|
|
163
|
+
out.push({
|
|
164
|
+
pid: Number(m[1]),
|
|
165
|
+
name: String(m[4]).trim().split(/[\\/]/).pop(),
|
|
166
|
+
memBytes: (Number(m[3]) || 0) * 1024,
|
|
167
|
+
cpuSec: 0,
|
|
168
|
+
cpuPctRaw: Number(m[2]) || 0,
|
|
169
|
+
path: '',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Synchronous variants — used only by tests and anywhere a blocking read is fine.
|
|
176
|
+
function winProcesses() {
|
|
177
|
+
const stdout = execCapture('powershell.exe', WIN_ARGS, 6000);
|
|
178
|
+
return parseWinJson(stdout);
|
|
179
|
+
}
|
|
180
|
+
function posixProcesses() {
|
|
181
|
+
const stdout = execCapture('ps', ['-axo', 'pid=,pcpu=,rss=,comm='], 1500);
|
|
182
|
+
return parsePsText(stdout);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Fold processes into apps and compute a real CPU% from the previous sample.
|
|
186
|
+
// prevByPid: Map<pid,{cpuSec,ts}> | null. nowMs: current epoch ms.
|
|
187
|
+
function toAppRows(procs, prevByPid, nowMs, cores) {
|
|
188
|
+
const apps = new Map();
|
|
189
|
+
const updated = new Map();
|
|
190
|
+
for (const p of procs) {
|
|
191
|
+
let cpuPct = 0;
|
|
192
|
+
if (typeof p.cpuPctRaw === 'number') {
|
|
193
|
+
cpuPct = clamp(p.cpuPctRaw, 0, (cores || 1) * 100);
|
|
194
|
+
} else if (prevByPid) {
|
|
195
|
+
const prev = prevByPid.get(p.pid);
|
|
196
|
+
if (prev && nowMs > prev.ts) {
|
|
197
|
+
const dtSec = (nowMs - prev.ts) / 1000;
|
|
198
|
+
cpuPct = clamp(((p.cpuSec - prev.cpuSec) / dtSec) * 100, 0, (cores || 1) * 100);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
updated.set(p.pid, { cpuSec: p.cpuSec, ts: nowMs });
|
|
202
|
+
|
|
203
|
+
const key = (p.name || '?').toLowerCase();
|
|
204
|
+
let a = apps.get(key);
|
|
205
|
+
if (!a) { a = { app: p.name || '?', category: classify(p.name), count: 0, cpuPct: 0, memBytes: 0, pids: [] }; apps.set(key, a); }
|
|
206
|
+
a.count++;
|
|
207
|
+
a.cpuPct += cpuPct;
|
|
208
|
+
a.memBytes += p.memBytes;
|
|
209
|
+
if (a.pids.length < 8) a.pids.push(p.pid);
|
|
210
|
+
if (!a.category || a.category === 'other') a.category = classify(p.name);
|
|
211
|
+
}
|
|
212
|
+
const rows = [...apps.values()].map((a) => ({ ...a, cpuPct: round(a.cpuPct, 1), memBytes: a.memBytes }));
|
|
213
|
+
return { rows, prevByPid: updated };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function byCategory(rows) {
|
|
217
|
+
const out = {};
|
|
218
|
+
for (const r of rows) {
|
|
219
|
+
const c = (out[r.category] || (out[r.category] = { count: 0, apps: 0, cpuPct: 0, memBytes: 0 }));
|
|
220
|
+
c.apps += 1; c.count += r.count; c.cpuPct += r.cpuPct; c.memBytes += r.memBytes;
|
|
221
|
+
}
|
|
222
|
+
for (const k of Object.keys(out)) { out[k].cpuPct = round(out[k].cpuPct, 1); }
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* ------------------------------ self (critic) ----------------------------- */
|
|
227
|
+
|
|
228
|
+
// CPU% of THIS critic process between samples, plus its resident memory.
|
|
229
|
+
function selfUsage(prev) {
|
|
230
|
+
const cpu = process.cpuUsage(prev && prev.cpuUsage); // microseconds since last
|
|
231
|
+
const wallMs = prev ? Date.now() - prev.ts : 0;
|
|
232
|
+
const cpuUs = (cpu.user || 0) + (cpu.system || 0);
|
|
233
|
+
const cpuPct = wallMs > 0 ? clamp((cpuUs / 1000 / (wallMs / 1000)) * 100, 0, (os.cpus().length || 1) * 100) : 0;
|
|
234
|
+
const mem = process.memoryUsage();
|
|
235
|
+
return {
|
|
236
|
+
cpuPct: round(cpuPct, 1),
|
|
237
|
+
rss: Math.round(mem.rss || 0),
|
|
238
|
+
heapUsed: Math.round(mem.heapUsed || 0),
|
|
239
|
+
rssMb: round((mem.rss || 0) / 1048576, 1),
|
|
240
|
+
next: { cpuUsage: process.cpuUsage(), ts: Date.now() },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* -------------------------------- estimate -------------------------------- */
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Expected-completion math for the critic's own scan. Fully pure & testable:
|
|
248
|
+
* it turns the analyzer's measured throughput and pass history into an honest
|
|
249
|
+
* ETA — and refuses to overclaim when there is not enough signal yet.
|
|
250
|
+
*/
|
|
251
|
+
function computeEstimate(input) {
|
|
252
|
+
const o = input || {};
|
|
253
|
+
const now = o.now || Date.now();
|
|
254
|
+
const cores = o.cores || os.cpus().length || 1;
|
|
255
|
+
const passNo = Number(o.passNo) || 0;
|
|
256
|
+
const durationsMs = Array.isArray(o.durationsMs) ? o.durationsMs.filter((x) => x > 0) : [];
|
|
257
|
+
const lastPassMs = Number(o.lastPassMs) || (durationsMs.length ? durationsMs[durationsMs.length - 1] : 0);
|
|
258
|
+
const avgPassMs = durationsMs.length ? Math.round(durationsMs.reduce((s, x) => s + x, 0) / durationsMs.length) : lastPassMs;
|
|
259
|
+
const filesTotal = Number(o.filesTotal) || 0;
|
|
260
|
+
const totalLines = Number(o.totalLines) || 0;
|
|
261
|
+
|
|
262
|
+
const scanRateFps = lastPassMs > 0 ? round(filesTotal / (lastPassMs / 1000), 1) : 0;
|
|
263
|
+
const linesPerSec = lastPassMs > 0 ? round(totalLines / (lastPassMs / 1000), 0) : 0;
|
|
264
|
+
const timeOnTaskSec = o.startedAt ? round((now - o.startedAt) / 1000, 1) : 0;
|
|
265
|
+
|
|
266
|
+
// Under heavy system load the next pass will stretch; scale the estimate.
|
|
267
|
+
const loadFactor = o.cpuPct != null ? round(1 + Math.max(0, (o.cpuPct - 60) / 100), 2) : 1;
|
|
268
|
+
const estPassMs = avgPassMs ? Math.round(avgPassMs * loadFactor) : 0;
|
|
269
|
+
|
|
270
|
+
let etaLabel;
|
|
271
|
+
let complete = true;
|
|
272
|
+
if (o.mode === 'watch') {
|
|
273
|
+
const nextSec = round((estPassMs / 1000) + (Number(o.debounceMs) || 400) / 1000, 1);
|
|
274
|
+
etaLabel = `each re-scan completes in ~${nextSec}s after a change`;
|
|
275
|
+
complete = false; // watch never "finishes"; it waits for the next edit
|
|
276
|
+
} else if (estPassMs > 0) {
|
|
277
|
+
etaLabel = `full review completed in ~${round(estPassMs / 1000, 1)}s`;
|
|
278
|
+
} else {
|
|
279
|
+
etaLabel = 'measuring throughput…';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
mode: o.mode || 'review',
|
|
284
|
+
passNo, cores, filesTotal, totalLines,
|
|
285
|
+
lastPassMs, avgPassMs, estPassMs,
|
|
286
|
+
scanRateFps, linesPerSec, loadFactor,
|
|
287
|
+
timeOnTaskSec, serverNow: now, startedAt: o.startedAt || null,
|
|
288
|
+
etaLabel, complete,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* -------------------------------- monitor --------------------------------- */
|
|
293
|
+
|
|
294
|
+
// Stateful sampler used by the live dashboard: keeps system-CPU, per-process-CPU
|
|
295
|
+
// and critic-process deltas between calls, plus a rolling history for the charts.
|
|
296
|
+
function createMonitor(root) {
|
|
297
|
+
const state = {
|
|
298
|
+
root: root || process.cwd(),
|
|
299
|
+
prevCpus: os.cpus(),
|
|
300
|
+
prevTs: Date.now(),
|
|
301
|
+
prevByPid: null,
|
|
302
|
+
prevSelf: { cpuUsage: process.cpuUsage(), ts: Date.now() },
|
|
303
|
+
history: [],
|
|
304
|
+
maxHistory: 60,
|
|
305
|
+
lastRows: [],
|
|
306
|
+
lastProcs: 0,
|
|
307
|
+
lastErr: null,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// Cheap, always-current reading: system CPU% (Node-core delta), memory, and
|
|
311
|
+
// this critic process. Safe to call on every poll / history tick — never blocks.
|
|
312
|
+
function sample() {
|
|
313
|
+
const now = Date.now();
|
|
314
|
+
const sysStatic = systemStatic();
|
|
315
|
+
let cpuPct = cpuPctFrom(state.prevCpus, os.cpus());
|
|
316
|
+
if (cpuPct == null) cpuPct = state.history.length ? state.history[state.history.length - 1].cpu : 0;
|
|
317
|
+
state.prevCpus = os.cpus();
|
|
318
|
+
state.prevTs = now;
|
|
319
|
+
const mem = memInfo();
|
|
320
|
+
const self = selfUsage(state.prevSelf);
|
|
321
|
+
state.prevSelf = self.next;
|
|
322
|
+
delete self.next;
|
|
323
|
+
state.history.push({ t: now, cpu: round(cpuPct, 1), mem: mem.memPct, procCount: state.lastProcs, selfCpu: self.cpuPct });
|
|
324
|
+
if (state.history.length > state.maxHistory) state.history.shift();
|
|
325
|
+
return buildSnapshot(sysStatic, cpuPct, mem, self, now);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function buildSnapshot(sysStatic, cpuPct, mem, self, now) {
|
|
329
|
+
const byCpu = state.lastRows.slice().sort((a, b) => b.cpuPct - a.cpuPct || b.memBytes - a.memBytes);
|
|
330
|
+
const topMem = state.lastRows.slice().sort((a, b) => b.memBytes - a.memBytes).slice(0, 8);
|
|
331
|
+
return {
|
|
332
|
+
ts: now,
|
|
333
|
+
system: { ...sysStatic, cpuPct: round(cpuPct, 1), ...mem },
|
|
334
|
+
self,
|
|
335
|
+
apps: byCpu.slice(0, 24),
|
|
336
|
+
totals: {
|
|
337
|
+
procCount: state.lastProcs, appCount: state.lastRows.length, topMem,
|
|
338
|
+
byCategory: byCategory(state.lastRows), refreshing: !!state.refreshing,
|
|
339
|
+
},
|
|
340
|
+
history: state.history.slice(),
|
|
341
|
+
error: state.lastErr || null,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Expensive: the machine-wide per-process table. Runs async so it never blocks
|
|
346
|
+
// the HTTP server or the watch loop; cb() fires once the rows are cached.
|
|
347
|
+
function refresh(cb) {
|
|
348
|
+
if (state.refreshing) { if (cb) cb(null); return; }
|
|
349
|
+
state.refreshing = true;
|
|
350
|
+
processesAsync((err, procs) => {
|
|
351
|
+
state.refreshing = false;
|
|
352
|
+
const list = Array.isArray(procs) ? procs : [];
|
|
353
|
+
if (err) { state.lastErr = err.message; if (cb) cb(err); return; }
|
|
354
|
+
const now = Date.now();
|
|
355
|
+
const folded = toAppRows(list, state.prevByPid, now, systemStatic().cores);
|
|
356
|
+
state.prevByPid = folded.prevByPid;
|
|
357
|
+
state.lastRows = folded.rows;
|
|
358
|
+
state.lastProcs = list.length;
|
|
359
|
+
state.lastErr = null;
|
|
360
|
+
if (cb) cb(null);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { sample, refresh, get historyLen() { return state.history.length; } };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// One-shot snapshot for a single (non-looping) `critic review`. Only the cheap,
|
|
368
|
+
// always-available readings (system CPU over a 60ms window, memory, this
|
|
369
|
+
// process). The machine-wide per-app table is deliberately omitted here because
|
|
370
|
+
// it needs the slow async probe — that lives in the live `watch` dashboard.
|
|
371
|
+
function snapshotOnce(root) {
|
|
372
|
+
const p1 = os.cpus();
|
|
373
|
+
const self1 = { cpuUsage: process.cpuUsage(), ts: Date.now() };
|
|
374
|
+
sleepMs(60);
|
|
375
|
+
const cpuPct = cpuPctFrom(p1, os.cpus()) || 0;
|
|
376
|
+
const selfRaw = selfUsage(self1);
|
|
377
|
+
const self = { cpuPct: selfRaw.cpuPct, rss: selfRaw.rss, heapUsed: selfRaw.heapUsed, rssMb: selfRaw.rssMb };
|
|
378
|
+
const mem = memInfo();
|
|
379
|
+
const now = Date.now();
|
|
380
|
+
return {
|
|
381
|
+
ts: now,
|
|
382
|
+
system: { ...systemStatic(), cpuPct: round(cpuPct, 1), ...mem },
|
|
383
|
+
self,
|
|
384
|
+
apps: [],
|
|
385
|
+
totals: { procCount: 0, appCount: 0, topMem: [], byCategory: {}, refreshing: false },
|
|
386
|
+
history: [{ t: now, cpu: round(cpuPct, 1), mem: mem.memPct, procCount: 0, selfCpu: self.cpuPct }],
|
|
387
|
+
live: false,
|
|
388
|
+
error: null,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
module.exports = {
|
|
393
|
+
createMonitor, snapshotOnce, processes, processesAsync, toAppRows, byCategory, classify,
|
|
394
|
+
selfUsage, computeEstimate, cpuPctFrom, systemStatic, memInfo, sleepMs, IS_WIN,
|
|
395
|
+
};
|
package/src/ui.js
CHANGED
|
@@ -26,6 +26,7 @@ const net = require('net');
|
|
|
26
26
|
const { DIMENSIONS } = require('./analyzer');
|
|
27
27
|
const { execSummary } = require('./report');
|
|
28
28
|
const { openBrowser } = require('./observatory');
|
|
29
|
+
const sysinfo = require('./sysinfo');
|
|
29
30
|
|
|
30
31
|
const SEV_ORDER = ['BLOCKER', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
|
|
31
32
|
const UI_BASE_PORT = 4781; // distinct from the observatory's 4780
|
|
@@ -81,6 +82,9 @@ function buildSnapshot(result, extra = {}) {
|
|
|
81
82
|
file: f.file, line: f.line, severity: f.severity, category: f.category,
|
|
82
83
|
dimension: f.dimension, title: f.title, problem: f.problem, fix: f.fix, evidence: f.evidence,
|
|
83
84
|
})),
|
|
85
|
+
// OS-level live telemetry + expected-completion estimate (see sysinfo.js).
|
|
86
|
+
sys: extra.sys || null,
|
|
87
|
+
estimate: extra.estimate || null,
|
|
84
88
|
};
|
|
85
89
|
}
|
|
86
90
|
|
|
@@ -133,6 +137,36 @@ function renderHtml(data, opts = {}) {
|
|
|
133
137
|
footer{color:var(--mut);font-size:11.5px;padding:20px 26px;border-top:1px solid var(--line);margin-top:24px}
|
|
134
138
|
.pulse{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--ok);margin-right:6px;animation:p 1.4s infinite}
|
|
135
139
|
@keyframes p{0%,100%{opacity:.3}50%{opacity:1}}
|
|
140
|
+
/* --- OS live-task + ETA panels --- */
|
|
141
|
+
.row2{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:18px}
|
|
142
|
+
@media(max-width:860px){.row2{grid-template-columns:1fr}}
|
|
143
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
|
|
144
|
+
.card h2{margin-top:0}
|
|
145
|
+
.gauges{display:flex;gap:18px;flex-wrap:wrap;margin-bottom:8px}
|
|
146
|
+
.gauge{min-width:120px;flex:1}
|
|
147
|
+
.gauge .top{display:flex;justify-content:space-between;align-items:baseline;color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.5px}
|
|
148
|
+
.gauge .top b{font-size:22px;color:var(--ink)}
|
|
149
|
+
.track{height:8px;background:#0c1226;border:1px solid var(--line);border-radius:999px;margin-top:6px;overflow:hidden}
|
|
150
|
+
.track>i{display:block;height:100%;border-radius:999px;transition:width .5s ease}
|
|
151
|
+
#charts{display:grid;grid-template-columns:1fr;gap:10px;margin-top:10px}
|
|
152
|
+
.chart{background:#0c1226;border:1px solid var(--line);border-radius:8px;padding:8px 10px}
|
|
153
|
+
.chart .ct{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
|
|
154
|
+
.chart svg{display:block;width:100%;height:46px}
|
|
155
|
+
.eta-barbox{height:14px;background:#0c1226;border:1px solid var(--line);border-radius:999px;overflow:hidden;margin-bottom:8px}
|
|
156
|
+
.eta-bar{height:100%;width:0;background:linear-gradient(90deg,var(--ok),var(--info));transition:width .15s linear}
|
|
157
|
+
.eta-line{font-size:15px;font-weight:600}
|
|
158
|
+
.grid2{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-top:12px}
|
|
159
|
+
@media(max-width:860px){.grid2{grid-template-columns:1fr 1fr}}
|
|
160
|
+
.kv{background:#0c1226;border:1px solid var(--line);border-radius:8px;padding:8px 10px}
|
|
161
|
+
.kv .k{color:var(--mut);font-size:10.5px;text-transform:uppercase;letter-spacing:.5px}
|
|
162
|
+
.kv .v{font-size:14px;font-weight:600;margin-top:2px}
|
|
163
|
+
.bar{position:relative;height:20px;min-width:90px;background:#0c1226;border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
|
164
|
+
.bar>i{display:block;height:100%;opacity:.5;transition:width .5s ease}
|
|
165
|
+
.bar>span{position:absolute;left:6px;top:0;line-height:20px;font-size:11.5px;color:#fff;text-shadow:0 0 3px rgba(0,0,0,.85)}
|
|
166
|
+
th.sortable{cursor:pointer} th.sortable:hover{color:var(--ink)} th.act{color:var(--ink)}
|
|
167
|
+
.c-project{color:var(--ok)} .c-runtime{color:var(--info)} .c-browser{color:var(--high)} .c-terminal{color:var(--med)}
|
|
168
|
+
.c-editor{color:#c792ea} .c-database{color:var(--critic)} .c-container{color:#7fdbca} .c-package{color:#ffd166}
|
|
169
|
+
.c-vcs{color:#93a0c2} .c-system{color:var(--mut)} .c-other{color:var(--mut)}
|
|
136
170
|
</style></head>
|
|
137
171
|
<body>
|
|
138
172
|
<header>
|
|
@@ -142,6 +176,12 @@ function renderHtml(data, opts = {}) {
|
|
|
142
176
|
<main>
|
|
143
177
|
<div class="verdict" id="verdict"></div>
|
|
144
178
|
<div class="grid" id="counts"></div>
|
|
179
|
+
<div class="row2">
|
|
180
|
+
<section class="card"><h2>Estimated Completion</h2><div id="eta"></div></section>
|
|
181
|
+
<section class="card"><h2>OS Activity <span class="sub" id="os-meta"></span></h2><div id="gauges"></div><div id="charts"></div></section>
|
|
182
|
+
</div>
|
|
183
|
+
<h2>Live Tasks — running processes, grouped app-wise</h2>
|
|
184
|
+
<div id="tasks" class="card"></div>
|
|
145
185
|
<h2>12-Dimension Executive Summary</h2>
|
|
146
186
|
<table id="dims"><thead><tr><th>Dimension</th><th>Status</th><th>Findings</th><th>Worst</th></tr></thead><tbody></tbody></table>
|
|
147
187
|
<h2>Prioritised Action Plan</h2>
|
|
@@ -158,6 +198,51 @@ var LIVE = ${live};
|
|
|
158
198
|
var SEV = ['BLOCKER','CRITICAL','HIGH','MEDIUM','LOW','INFO'];
|
|
159
199
|
var CATS = [['fix','FIX — correctness & security'],['optimize','OPTIMIZE — performance & structure'],['delete','DELETE — dead code & debug'],['add','ADD — tests, config & docs']];
|
|
160
200
|
function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!=null)e.textContent=x;return e;}
|
|
201
|
+
|
|
202
|
+
/* ---- OS live-task + expected-completion rendering (zero-dep SVG charts) ---- */
|
|
203
|
+
var LASTSYS=null; var SORT='cpu'; var LASTEST=null; var PASS={no:-1,start:0};
|
|
204
|
+
var SVGNS='http://www.w3.org/2000/svg';
|
|
205
|
+
function fmtBytes(n){n=Number(n)||0;if(n>=1073741824)return (n/1073741824).toFixed(1)+' GB';if(n>=1048576)return (n/1048576).toFixed(0)+' MB';if(n>=1024)return (n/1024).toFixed(0)+' KB';return n+' B';}
|
|
206
|
+
function fmtPct(n){return (Math.round((Number(n)||0)*10)/10)+'%';}
|
|
207
|
+
function round1(n){return Math.round((Number(n)||0)*10)/10;}
|
|
208
|
+
function dur(sec){sec=Math.max(0,Math.floor(Number(sec)||0));var h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60),s=sec%60;if(h)return h+'h '+m+'m';if(m)return m+'m '+s+'s';return s+'s';}
|
|
209
|
+
function maxOf(hist,key){var mx=0;for(var i=0;i<hist.length;i++){var v=Number(hist[i][key])||0;if(v>mx)mx=v;}return mx;}
|
|
210
|
+
function bar(val,max,color,text){var b=el('div','bar');var f=el('i');f.style.width=(max>0?Math.min(100,(val/max)*100):0)+'%';if(color)f.style.background=color;b.appendChild(f);if(text!=null)b.appendChild(el('span',null,text));return b;}
|
|
211
|
+
function kv(k,v){var d=el('div','kv');d.appendChild(el('div','k',k));d.appendChild(el('div','v',String(v)));return d;}
|
|
212
|
+
function gaugeCard(label,pct,color){var g=el('div','gauge');var top=el('div','top');top.appendChild(el('span',null,label));top.appendChild(el('b',null,fmtPct(pct)));g.appendChild(top);var tr=el('div','track');var f=el('i');f.style.width=Math.min(100,Math.max(0,Number(pct)||0))+'%';f.style.background=color;tr.appendChild(f);g.appendChild(tr);return g;}
|
|
213
|
+
function chart(hist,key,label,color,max){var box=el('div','chart');box.appendChild(el('div','ct',label));var W=300,H=46;var svg=document.createElementNS(SVGNS,'svg');svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.setAttribute('preserveAspectRatio','none');box.appendChild(svg);var n=hist.length;if(!n||n<2){box.appendChild(el('div','sub','collecting samples…'));return box;}var mx=max||maxOf(hist,key)||1;var line='';var area='0,'+H+' ';for(var i=0;i<n;i++){var v=Number(hist[i][key])||0;var x=(i/(n-1))*W;var y=H-(Math.min(v,mx)/mx)*H;line+=(i?' ':'')+x.toFixed(1)+','+y.toFixed(1);area+=x.toFixed(1)+','+y.toFixed(1)+' ';}area+=W+','+H;var pg=document.createElementNS(SVGNS,'polygon');pg.setAttribute('points',area);pg.setAttribute('fill',color);pg.setAttribute('opacity','0.12');var pl=document.createElementNS(SVGNS,'polyline');pl.setAttribute('points',line);pl.setAttribute('fill','none');pl.setAttribute('stroke',color);pl.setAttribute('stroke-width','1.6');svg.appendChild(pg);svg.appendChild(pl);var cap=el('div','sub');cap.textContent='now '+fmtPct(hist[n-1][key])+' · peak '+fmtPct(maxOf(hist,key))+' · scale '+round1(mx)+'%';box.appendChild(cap);return box;}
|
|
214
|
+
function renderOs(sys){var box=document.getElementById('gauges');var ch=document.getElementById('charts');if(!box)return;box.textContent='';if(ch)ch.textContent='';if(!sys||!sys.system){box.appendChild(el('span','sub','OS telemetry unavailable'));return;}
|
|
215
|
+
var meta=document.getElementById('os-meta');if(meta)meta.textContent=sys.system.platform+'/'+sys.system.arch+' · '+sys.system.cores+' cores · '+sys.system.hostname;
|
|
216
|
+
var g=el('div','gauges');g.appendChild(gaugeCard('System CPU',sys.system.cpuPct,'var(--high)'));g.appendChild(gaugeCard('Memory',sys.system.memPct,'var(--info)'));g.appendChild(gaugeCard('Critic CPU',sys.self?sys.self.cpuPct:0,'var(--critic)'));box.appendChild(g);
|
|
217
|
+
var st=el('div','sub');st.textContent='Processes: '+sys.totals.procCount+' · Apps: '+sys.totals.appCount+' · RAM '+fmtBytes(sys.system.memUsed)+' / '+fmtBytes(sys.system.memTotal)+' · Critic RSS '+(sys.self?fmtBytes(sys.self.rss):'?')+' · uptime '+dur(sys.system.uptimeSec);box.appendChild(st);
|
|
218
|
+
if(ch){if(sys.history)ch.appendChild(chart(sys.history,'cpu','CPU %','var(--high)',100));if(sys.history)ch.appendChild(chart(sys.history,'mem','Memory %','var(--info)',100));if(sys.history)ch.appendChild(chart(sys.history,'selfCpu','Critic CPU %','var(--critic)',Math.max(40,maxOf(sys.history,'selfCpu'))));}
|
|
219
|
+
}
|
|
220
|
+
function th(label,key){var t=el('th','sortable'+(SORT===key?' act':''),label);if(key)t.onclick=function(){SORT=key;renderTasks(LASTSYS);};return t;}
|
|
221
|
+
function renderTasks(sys){var box=document.getElementById('tasks');if(!box)return;box.textContent='';LASTSYS=sys;if(!sys||!sys.apps||!sys.apps.length){box.appendChild(el('p','sub',(sys&&sys.totals&&sys.totals.refreshing)?'sampling running processes…':'Per-app live tasks stream in here a few seconds after the dashboard starts.'));return;}
|
|
222
|
+
var maxcpu=1,maxmem=1;sys.apps.forEach(function(a){if(a.cpuPct>maxcpu)maxcpu=a.cpuPct;if(a.memBytes>maxmem)maxmem=a.memBytes;});
|
|
223
|
+
var rows=sys.apps.slice();if(SORT==='mem')rows.sort(function(a,b){return b.memBytes-a.memBytes;});else if(SORT==='name')rows.sort(function(a,b){return String(a.app).localeCompare(String(b.app));});else rows.sort(function(a,b){return b.cpuPct-a.cpuPct;});
|
|
224
|
+
var t=el('table');var thead=el('thead');var hr=el('tr');hr.appendChild(th('App / Process','name'));hr.appendChild(th('Category',''));hr.appendChild(th('Instances',''));hr.appendChild(th('CPU %','cpu'));hr.appendChild(th('Memory','mem'));thead.appendChild(hr);t.appendChild(thead);
|
|
225
|
+
var body=el('tbody');rows.forEach(function(a){var tr=el('tr');var nm=el('td');nm.appendChild(el('code',null,a.app));tr.appendChild(nm);tr.appendChild(el('td')).appendChild(el('span','tag c-'+a.category,a.category));tr.appendChild(el('td',null,String(a.count)));var cc=el('td');cc.appendChild(bar(a.cpuPct,maxcpu,'var(--high)',fmtPct(a.cpuPct)));tr.appendChild(cc);var mc=el('td');mc.appendChild(bar(a.memBytes,maxmem,'var(--info)',fmtBytes(a.memBytes)));tr.appendChild(mc);body.appendChild(tr);});
|
|
226
|
+
t.appendChild(body);box.appendChild(t);
|
|
227
|
+
var cats=Object.keys(sys.totals.byCategory||{});if(cats.length){var leg=el('div','sub');leg.style.marginTop='10px';leg.textContent='By category: '+cats.sort().map(function(k){return k+' ('+(sys.totals.byCategory[k].count)+' proc, '+fmtBytes(sys.totals.byCategory[k].memBytes)+')';}).join(' · ');box.appendChild(leg);}
|
|
228
|
+
}
|
|
229
|
+
function renderEta(e,d){var box=document.getElementById('eta');if(!box)return;box.textContent='';if(!e){box.appendChild(el('p','sub','Estimating expected completion…'));return;}LASTEST=e;if(e.passNo!==PASS.no){PASS.no=e.passNo;PASS.start=Date.now();}
|
|
230
|
+
var bb=el('div','eta-barbox');var b=el('div','eta-bar');b.id='eta-bar';if(e.complete)b.style.width='100%';bb.appendChild(b);box.appendChild(bb);
|
|
231
|
+
box.appendChild(el('div','eta-line',e.etaLabel||''));
|
|
232
|
+
var cnt=el('div','sub');cnt.id='eta-count';box.appendChild(cnt);
|
|
233
|
+
var g=el('div','grid2');
|
|
234
|
+
g.appendChild(kv('Scan rate',(e.scanRateFps||0)+' files/s'));
|
|
235
|
+
g.appendChild(kv('Lines/s',(e.linesPerSec||0).toLocaleString?String((e.linesPerSec||0).toLocaleString()):String(e.linesPerSec||0)));
|
|
236
|
+
g.appendChild(kv('Files scanned',String(e.filesTotal||0)));
|
|
237
|
+
g.appendChild(kv('Last pass',(e.lastPassMs||0)+' ms'));
|
|
238
|
+
g.appendChild(kv('Avg pass',(e.avgPassMs||0)+' ms'));
|
|
239
|
+
g.appendChild(kv('Passes','×'+(e.passNo||0)));
|
|
240
|
+
g.appendChild(kv('Time on task',dur(e.timeOnTaskSec)));
|
|
241
|
+
g.appendChild(kv('System load','×'+(e.loadFactor||1)));
|
|
242
|
+
g.appendChild(kv('Cores',String(e.cores||0)));
|
|
243
|
+
box.appendChild(g);tickEta();
|
|
244
|
+
}
|
|
245
|
+
function tickEta(){if(!LASTEST)return;var e=LASTEST;var b=document.getElementById('eta-bar');var c=document.getElementById('eta-count');var total=(e.estPassMs||0)/1000;if(e.complete){if(b)b.style.width='100%';if(c)c.textContent=(e.mode==='watch')?'idle — watching for your next edit':('done in ~'+round1(total)+'s');return;}var secs=(Date.now()-PASS.start)/1000;var pct=total>0?Math.min(100,(secs/total)*100):0;if(b)b.style.width=pct+'%';if(c)c.textContent='this pass: '+round1(secs)+'s / ~'+round1(total)+'s expected';}
|
|
161
246
|
function render(d){
|
|
162
247
|
document.getElementById('h-proj').textContent = '— ' + (d.project||'');
|
|
163
248
|
var sub = (d.root||'') + ' · ' + (d.generatedAt? new Date(d.generatedAt).toLocaleString():'');
|
|
@@ -165,6 +250,8 @@ function render(d){
|
|
|
165
250
|
else if(LIVE){ sub += ' · live pass #' + d.passNo + ' (+'+d.addedCount+' new / -'+d.resolvedCount+' fixed)'; }
|
|
166
251
|
document.getElementById('h-sub').textContent = sub;
|
|
167
252
|
|
|
253
|
+
renderEta(d.estimate,d); renderOs(d.sys); renderTasks(d.sys);
|
|
254
|
+
|
|
168
255
|
var v=document.getElementById('verdict'); v.textContent='';
|
|
169
256
|
if(d.empty){ v.appendChild(el('span',null,'Analysis starting…')); return; }
|
|
170
257
|
v.appendChild(el('span','pulse'));
|
|
@@ -209,6 +296,7 @@ function render(d){
|
|
|
209
296
|
render(INITIAL);
|
|
210
297
|
function offline(){ var v=document.getElementById('verdict'); if(v){ v.textContent=''; v.appendChild(el('span',null,'Dashboard disconnected - the critic watch process is no longer serving. Restart it to resume live updates.')); } }
|
|
211
298
|
if(LIVE){ setInterval(function(){ fetch('/data',{cache:'no-store'}).then(function(r){return r.json();}).then(render).catch(offline); }, 1500); }
|
|
299
|
+
setInterval(tickEta, 150);
|
|
212
300
|
function esc(s){return String(s==null?'':s);}
|
|
213
301
|
</script>
|
|
214
302
|
</body></html>`;
|
|
@@ -239,7 +327,19 @@ function pickFreePort(base, span) {
|
|
|
239
327
|
|
|
240
328
|
function writeStaticAndOpen(result, opts = {}) {
|
|
241
329
|
try {
|
|
242
|
-
const
|
|
330
|
+
const cov = result.coverage || {};
|
|
331
|
+
let sys = null;
|
|
332
|
+
let estimate = null;
|
|
333
|
+
try {
|
|
334
|
+
sys = sysinfo.snapshotOnce((result.meta && result.meta.root) || process.cwd());
|
|
335
|
+
estimate = sysinfo.computeEstimate({
|
|
336
|
+
mode: 'review', passNo: 1,
|
|
337
|
+
lastPassMs: (result.meta && result.meta.durationMs) || 0,
|
|
338
|
+
filesTotal: cov.filesScanned || 0, totalLines: cov.totalLines || 0,
|
|
339
|
+
cores: sys.system.cores, cpuPct: sys.system.cpuPct,
|
|
340
|
+
});
|
|
341
|
+
} catch { /* OS probe is best-effort; the report still renders without it */ }
|
|
342
|
+
const data = buildSnapshot(result, Object.assign({ sys, estimate }, opts.extra || {}));
|
|
243
343
|
const dir = path.join(os.tmpdir(), 'fullstack-critic');
|
|
244
344
|
fs.mkdirSync(dir, { recursive: true });
|
|
245
345
|
const stamp = data.generatedAt.replace(/[:.]/g, '-');
|
|
@@ -257,23 +357,60 @@ function writeStaticAndOpen(result, opts = {}) {
|
|
|
257
357
|
|
|
258
358
|
function startLive(root, opts = {}) {
|
|
259
359
|
let data = emptySnapshot(root);
|
|
360
|
+
|
|
361
|
+
// Live OS telemetry. The cheap system/self reading advances on a timer so the
|
|
362
|
+
// charts keep moving between HTTP polls; the expensive machine-wide process
|
|
363
|
+
// table refreshes async on its own slower cadence and never blocks requests.
|
|
364
|
+
const monitor = sysinfo.createMonitor(root);
|
|
365
|
+
const startedAt = Date.now();
|
|
366
|
+
const timing = { passNo: 0, lastPassMs: 0, durations: [] };
|
|
367
|
+
try { monitor.sample(); monitor.refresh(() => {}); } catch { /* best-effort */ }
|
|
368
|
+
const timers = [
|
|
369
|
+
setInterval(() => { try { monitor.sample(); } catch { /* ignore */ } }, 1200),
|
|
370
|
+
setInterval(() => { try { monitor.refresh(() => {}); } catch { /* ignore */ } }, 3500),
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
function currentEstimate(sys) {
|
|
374
|
+
const cov = data.coverage || {};
|
|
375
|
+
try {
|
|
376
|
+
return sysinfo.computeEstimate({
|
|
377
|
+
mode: 'watch', passNo: timing.passNo, lastPassMs: timing.lastPassMs, durationsMs: timing.durations,
|
|
378
|
+
filesTotal: cov.filesScanned || 0, totalLines: cov.totalLines || 0,
|
|
379
|
+
cores: sys && sys.system ? sys.system.cores : undefined, cpuPct: sys && sys.system ? sys.system.cpuPct : null,
|
|
380
|
+
startedAt, now: Date.now(), debounceMs: opts.debounceMs,
|
|
381
|
+
});
|
|
382
|
+
} catch { return null; }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function withSys(d) {
|
|
386
|
+
let sys = null;
|
|
387
|
+
try { sys = monitor.sample(); } catch { /* ignore */ }
|
|
388
|
+
return Object.assign({}, d, { sys, estimate: currentEstimate(sys), startedAt, passNo: timing.passNo });
|
|
389
|
+
}
|
|
390
|
+
|
|
260
391
|
const server = http.createServer((req, res) => {
|
|
261
392
|
if (req.url === '/data') {
|
|
262
393
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
263
|
-
return res.end(JSON.stringify(data));
|
|
394
|
+
return res.end(JSON.stringify(withSys(data)));
|
|
395
|
+
}
|
|
396
|
+
if (req.url === '/sys') {
|
|
397
|
+
let sys = null; try { sys = monitor.sample(); } catch { /* ignore */ }
|
|
398
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
399
|
+
return res.end(JSON.stringify({ sys, estimate: currentEstimate(sys) }));
|
|
264
400
|
}
|
|
265
401
|
if (req.url === '/health') {
|
|
266
402
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
267
403
|
return res.end(JSON.stringify({ product: 'critic-ui', port: server.address() ? server.address().port : 0 }));
|
|
268
404
|
}
|
|
269
405
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
270
|
-
res.end(renderHtml(data, { live: true }));
|
|
406
|
+
res.end(renderHtml(withSys(data), { live: true }));
|
|
271
407
|
});
|
|
272
408
|
|
|
273
409
|
return pickFreePort(opts.port).then((port) => {
|
|
274
|
-
if (!port) return null; // no free port
|
|
410
|
+
if (!port) { for (const t of timers) clearInterval(t); return null; } // no free port → skip UI, never break the workflow
|
|
275
411
|
return new Promise((resolve) => {
|
|
276
412
|
server.once('error', (e) => {
|
|
413
|
+
for (const t of timers) clearInterval(t);
|
|
277
414
|
process.stderr.write(`critic UI server unavailable (${e.code || e.message}); watch continues without the dashboard.\n`);
|
|
278
415
|
resolve(null);
|
|
279
416
|
});
|
|
@@ -282,8 +419,17 @@ function startLive(root, opts = {}) {
|
|
|
282
419
|
resolve({
|
|
283
420
|
port: bound,
|
|
284
421
|
url: `http://127.0.0.1:${bound}/`,
|
|
285
|
-
set: (r, extra) => {
|
|
286
|
-
|
|
422
|
+
set: (r, extra) => {
|
|
423
|
+
data = buildSnapshot(r, extra);
|
|
424
|
+
timing.passNo = (extra && extra.passNo) || timing.passNo + 1;
|
|
425
|
+
const d = (r.meta && r.meta.durationMs) || 0;
|
|
426
|
+
timing.lastPassMs = d;
|
|
427
|
+
if (d > 0) { timing.durations.push(d); if (timing.durations.length > 12) timing.durations.shift(); }
|
|
428
|
+
},
|
|
429
|
+
close: () => {
|
|
430
|
+
for (const t of timers) clearInterval(t);
|
|
431
|
+
try { server.close(); } catch { /* already closed */ }
|
|
432
|
+
},
|
|
287
433
|
});
|
|
288
434
|
});
|
|
289
435
|
server.listen(port, '127.0.0.1');
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* sysinfo unit tests — deliberately exercise the PURE, deterministic parts of the
|
|
4
|
+
* OS telemetry (grouping, CPU-delta, categorisation, ETA math) so they pass on
|
|
5
|
+
* any machine, plus a bounded check that the live process probe never throws.
|
|
6
|
+
*/
|
|
7
|
+
const test = require('node:test');
|
|
8
|
+
const assert = require('node:assert');
|
|
9
|
+
|
|
10
|
+
const sys = require('../src/sysinfo');
|
|
11
|
+
|
|
12
|
+
test('classify() folds process names into structured categories', () => {
|
|
13
|
+
assert.strictEqual(sys.classify('node'), 'runtime');
|
|
14
|
+
assert.strictEqual(sys.classify('chrome'), 'browser');
|
|
15
|
+
assert.strictEqual(sys.classify('MicrosoftEdge'), 'browser');
|
|
16
|
+
assert.strictEqual(sys.classify('powershell'), 'terminal');
|
|
17
|
+
assert.strictEqual(sys.classify('Code'), 'editor');
|
|
18
|
+
assert.strictEqual(sys.classify('postgres'), 'database');
|
|
19
|
+
assert.strictEqual(sys.classify('com.docker.backend'), 'container');
|
|
20
|
+
assert.strictEqual(sys.classify('git'), 'vcs');
|
|
21
|
+
assert.strictEqual(sys.classify('svchost'), 'system');
|
|
22
|
+
assert.strictEqual(sys.classify('mystery-thing'), 'other');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('toAppRows() computes real per-app CPU% from a sample delta', () => {
|
|
26
|
+
const procs = [
|
|
27
|
+
{ pid: 1, name: 'node', cpuSec: 2, memBytes: 1000 },
|
|
28
|
+
{ pid: 2, name: 'node', cpuSec: 1, memBytes: 500 },
|
|
29
|
+
{ pid: 3, name: 'chrome', cpuSec: 0.5, memBytes: 4000 },
|
|
30
|
+
];
|
|
31
|
+
// One second earlier: node pid1 used 1s CPU, pid2 1s, chrome 0.5s (no change).
|
|
32
|
+
const prev = new Map([
|
|
33
|
+
[1, { cpuSec: 1, ts: 0 }],
|
|
34
|
+
[2, { cpuSec: 1, ts: 0 }],
|
|
35
|
+
[3, { cpuSec: 0.5, ts: 0 }],
|
|
36
|
+
]);
|
|
37
|
+
const { rows } = sys.toAppRows(procs, prev, 1000, /* cores */ 8);
|
|
38
|
+
const node = rows.find((r) => r.app === 'node');
|
|
39
|
+
const chrome = rows.find((r) => r.app === 'chrome');
|
|
40
|
+
assert.strictEqual(node.count, 2, 'two node processes folded into one app');
|
|
41
|
+
assert.ok(Math.abs(node.cpuPct - 100) < 1, 'pid1 grew 1 CPU-sec in 1 wall-sec = ~100% (pid2 flat)');
|
|
42
|
+
assert.strictEqual(chrome.cpuPct, 0, 'no CPU-sec delta → 0%');
|
|
43
|
+
assert.strictEqual(node.category, 'runtime');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('toAppRows() uses ps instantaneous % on POSIX-style rows and clamps to cores', () => {
|
|
47
|
+
const procs = [{ pid: 9, name: 'python', memBytes: 10, cpuPctRaw: 1600 }]; // absurd value
|
|
48
|
+
const { rows } = sys.toAppRows(procs, null, 1000, /* cores */ 8);
|
|
49
|
+
assert.strictEqual(rows[0].cpuPct, 800, 'clamped to cores*100');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('byCategory() aggregates counts and memory per category', () => {
|
|
53
|
+
const rows = [
|
|
54
|
+
{ app: 'node', category: 'runtime', count: 2, cpuPct: 10, memBytes: 1000 },
|
|
55
|
+
{ app: 'deno', category: 'runtime', count: 1, cpuPct: 5, memBytes: 1000 },
|
|
56
|
+
{ app: 'chrome', category: 'browser', count: 4, cpuPct: 20, memBytes: 9999 },
|
|
57
|
+
];
|
|
58
|
+
const by = sys.byCategory(rows);
|
|
59
|
+
assert.strictEqual(by.runtime.count, 3);
|
|
60
|
+
assert.strictEqual(by.runtime.apps, 2);
|
|
61
|
+
assert.strictEqual(by.browser.memBytes, 9999);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('cpuPctFrom() turns two os.cpus() readings into a 0-100 percentage', () => {
|
|
65
|
+
const prev = [{ times: { user: 0, nice: 0, sys: 0, idle: 100, irq: 0 } }];
|
|
66
|
+
const cur = [{ times: { user: 50, nice: 0, sys: 0, idle: 150, irq: 0 } }];
|
|
67
|
+
assert.strictEqual(sys.cpuPctFrom(prev, cur), 50);
|
|
68
|
+
assert.strictEqual(sys.cpuPctFrom(null, cur), null, 'no previous reading → null (caller degrades)');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('computeEstimate(): a finished review pass reports throughput + a completed ETA', () => {
|
|
72
|
+
const e = sys.computeEstimate({ mode: 'review', passNo: 1, lastPassMs: 420, filesTotal: 30, totalLines: 1500, cores: 8 });
|
|
73
|
+
assert.strictEqual(e.complete, true);
|
|
74
|
+
assert.match(e.etaLabel, /completed in/);
|
|
75
|
+
assert.ok(e.scanRateFps > 70 && e.scanRateFps < 72, 'files/sec from measured duration (' + e.scanRateFps + ')');
|
|
76
|
+
assert.strictEqual(e.lastPassMs, 420);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('computeEstimate(): watch mode never claims completion and stretches under load', () => {
|
|
80
|
+
const calm = sys.computeEstimate({ mode: 'watch', passNo: 3, durationsMs: [200, 300, 400], filesTotal: 50, cores: 4, cpuPct: 20 });
|
|
81
|
+
assert.strictEqual(calm.complete, false);
|
|
82
|
+
assert.match(calm.etaLabel, /re-scan/);
|
|
83
|
+
assert.strictEqual(calm.avgPassMs, 300, 'rolling average of prior passes');
|
|
84
|
+
assert.strictEqual(calm.loadFactor, 1, 'no load penalty below 60% CPU');
|
|
85
|
+
|
|
86
|
+
const hot = sys.computeEstimate({ mode: 'watch', passNo: 3, durationsMs: [300], filesTotal: 50, cores: 4, cpuPct: 100 });
|
|
87
|
+
assert.strictEqual(hot.loadFactor, 1.4, 'ETA scales up under heavy system load');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('systemStatic() / memInfo() always return sane, bounded numbers', () => {
|
|
91
|
+
const s = sys.systemStatic();
|
|
92
|
+
assert.ok(s.cores >= 1);
|
|
93
|
+
assert.ok(typeof s.platform === 'string' && s.platform.length);
|
|
94
|
+
const m = sys.memInfo();
|
|
95
|
+
assert.ok(m.memPct >= 0 && m.memPct <= 100, 'memory % bounded');
|
|
96
|
+
assert.ok(m.memUsed >= 0 && m.memUsed <= m.memTotal);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('createMonitor().sample() advances a bounded rolling history', () => {
|
|
100
|
+
const mon = sys.createMonitor('.');
|
|
101
|
+
const a = mon.sample();
|
|
102
|
+
const b = mon.sample();
|
|
103
|
+
assert.ok(a.system.cpuPct >= 0 && a.system.cpuPct <= 100);
|
|
104
|
+
assert.ok(b.history.length >= a.history.length, 'each sample appends a point');
|
|
105
|
+
assert.ok(mon.historyLen <= 60, 'history capped');
|
|
106
|
+
assert.ok(a.self && typeof a.self.rss === 'number' && a.self.rss > 0, 'critic self memory is real');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('processesAsync() resolves with an array and never throws (bounded, offline-safe)', (_t, done) => {
|
|
110
|
+
const to = setTimeout(() => assert.fail('processesAsync did not call back in 12s'), 12000);
|
|
111
|
+
sys.processesAsync((err, rows) => {
|
|
112
|
+
clearTimeout(to);
|
|
113
|
+
assert.ok(Array.isArray(rows) || err, 'either an array (possibly empty on a locked-down box) or an err');
|
|
114
|
+
if (Array.isArray(rows) && rows.length) {
|
|
115
|
+
const p = rows[0];
|
|
116
|
+
assert.ok(Number.isInteger(p.pid) && p.pid > 0, 'a process row has an integer pid');
|
|
117
|
+
assert.ok(typeof p.name === 'string' && p.name.length, 'and a name');
|
|
118
|
+
}
|
|
119
|
+
done();
|
|
120
|
+
});
|
|
121
|
+
});
|