scripts-orchestrator 2.15.1 → 3.5.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 +109 -3
- package/index.js +92 -0
- package/lib/index.js +10 -0
- package/lib/orchestrator.aggregate.test.js +83 -0
- package/lib/orchestrator.js +98 -3
- package/lib/orchestrator.prefix.test.js +60 -0
- package/lib/process-manager.js +21 -9
- package/lib/process-manager.test.js +58 -0
- package/lib/report-html.js +103 -7
- package/lib/report-html.test.js +120 -0
- package/lib/workspaces.js +353 -0
- package/lib/workspaces.test.js +303 -0
- package/package.json +1 -1
- package/scripts-orchestrator.config.js +12 -0
package/lib/process-manager.js
CHANGED
|
@@ -46,6 +46,7 @@ export class ProcessManager {
|
|
|
46
46
|
startedByScript,
|
|
47
47
|
process_tracking,
|
|
48
48
|
kill_command,
|
|
49
|
+
prefix = 'npm run',
|
|
49
50
|
}) {
|
|
50
51
|
this.logger.verbose(`Adding background process: ${command} (${url})`);
|
|
51
52
|
this.backgroundProcessesDetails.push({
|
|
@@ -54,6 +55,7 @@ export class ProcessManager {
|
|
|
54
55
|
startedByScript,
|
|
55
56
|
process_tracking,
|
|
56
57
|
kill_command,
|
|
58
|
+
prefix,
|
|
57
59
|
});
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -93,7 +95,13 @@ export class ProcessManager {
|
|
|
93
95
|
env = null,
|
|
94
96
|
reportTime = false,
|
|
95
97
|
reportMemory = false,
|
|
98
|
+
prefix = 'npm run',
|
|
96
99
|
}) {
|
|
100
|
+
// Resolve how the command is invoked. A non-empty prefix (e.g. 'npm run') is
|
|
101
|
+
// prepended to the command name; an empty/false prefix runs the command verbatim
|
|
102
|
+
// as a regular shell command. `displayCmd` is what we surface in logs.
|
|
103
|
+
const commandPrefix = prefix ? String(prefix).trim() : '';
|
|
104
|
+
const displayCmd = commandPrefix ? `${commandPrefix} ${cmd}` : cmd;
|
|
97
105
|
const baseDir = this.logFolder
|
|
98
106
|
? path.resolve(this.logFolder)
|
|
99
107
|
: process.cwd();
|
|
@@ -131,12 +139,12 @@ export class ProcessManager {
|
|
|
131
139
|
const startTime = Date.now();
|
|
132
140
|
let timeOutputPath = null;
|
|
133
141
|
// Build command with environment variables if provided
|
|
134
|
-
let fullCommand =
|
|
142
|
+
let fullCommand = displayCmd;
|
|
135
143
|
if (env && Object.keys(env).length > 0) {
|
|
136
144
|
const envStr = Object.entries(env)
|
|
137
145
|
.map(([key, value]) => `${key}=${value}`)
|
|
138
146
|
.join(' ');
|
|
139
|
-
fullCommand = `${envStr}
|
|
147
|
+
fullCommand = `${envStr} ${displayCmd}`;
|
|
140
148
|
}
|
|
141
149
|
const useTimeWrapper =
|
|
142
150
|
reportMemory && !background && (process.platform === 'linux' || process.platform === 'darwin');
|
|
@@ -286,6 +294,7 @@ export class ProcessManager {
|
|
|
286
294
|
url: healthCheck?.url,
|
|
287
295
|
startedByScript: true,
|
|
288
296
|
kill_command,
|
|
297
|
+
prefix: commandPrefix,
|
|
289
298
|
});
|
|
290
299
|
|
|
291
300
|
this.logger.verbose(`Unreferencing process ${processGroupId}`);
|
|
@@ -293,7 +302,7 @@ export class ProcessManager {
|
|
|
293
302
|
|
|
294
303
|
this.logger.stopTask(cmd);
|
|
295
304
|
this.logger.verbose(
|
|
296
|
-
`Background process started:
|
|
305
|
+
`Background process started: ${displayCmd} (PGID: ${processGroupId})`,
|
|
297
306
|
);
|
|
298
307
|
return {
|
|
299
308
|
success: true,
|
|
@@ -304,7 +313,7 @@ export class ProcessManager {
|
|
|
304
313
|
} catch (error) {
|
|
305
314
|
if (attempt === maxAttempts) {
|
|
306
315
|
this.logger.error(
|
|
307
|
-
`Failed to start background process:
|
|
316
|
+
`Failed to start background process: ${displayCmd}`,
|
|
308
317
|
);
|
|
309
318
|
this.logger.verbose(
|
|
310
319
|
`Final verification attempt failed: ${error.message}`,
|
|
@@ -382,7 +391,7 @@ export class ProcessManager {
|
|
|
382
391
|
|
|
383
392
|
if (code !== 0) {
|
|
384
393
|
this.logger.error(
|
|
385
|
-
`Failed:
|
|
394
|
+
`Failed: ${displayCmd} ❌${durationStr} (exit code: ${code})`,
|
|
386
395
|
);
|
|
387
396
|
this.logger.verbose(`Process output: ${output}`);
|
|
388
397
|
resolve({
|
|
@@ -392,7 +401,7 @@ export class ProcessManager {
|
|
|
392
401
|
memoryKb,
|
|
393
402
|
});
|
|
394
403
|
} else {
|
|
395
|
-
this.logger.success(`Completed:
|
|
404
|
+
this.logger.success(`Completed: ${displayCmd} ✅${durationStr}`);
|
|
396
405
|
resolve({
|
|
397
406
|
success: true,
|
|
398
407
|
output,
|
|
@@ -507,13 +516,14 @@ export class ProcessManager {
|
|
|
507
516
|
);
|
|
508
517
|
|
|
509
518
|
const killPromises = commandProcesses.map(
|
|
510
|
-
async ({ command, pgid, url, startedByScript, kill_command }) => {
|
|
519
|
+
async ({ command, pgid, url, startedByScript, kill_command, prefix }) => {
|
|
511
520
|
await this.cleanupProcess({
|
|
512
521
|
command,
|
|
513
522
|
pgid,
|
|
514
523
|
url,
|
|
515
524
|
startedByScript,
|
|
516
525
|
kill_command,
|
|
526
|
+
prefix,
|
|
517
527
|
});
|
|
518
528
|
},
|
|
519
529
|
);
|
|
@@ -529,7 +539,7 @@ export class ProcessManager {
|
|
|
529
539
|
);
|
|
530
540
|
}
|
|
531
541
|
|
|
532
|
-
async cleanupProcess({ command, pgid, url, startedByScript, kill_command }) {
|
|
542
|
+
async cleanupProcess({ command, pgid, url, startedByScript, kill_command, prefix = 'npm run' }) {
|
|
533
543
|
if (!startedByScript) {
|
|
534
544
|
this.logger.verbose(
|
|
535
545
|
`- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
|
|
@@ -544,13 +554,15 @@ export class ProcessManager {
|
|
|
544
554
|
// Try custom kill command first if specified
|
|
545
555
|
if (kill_command) {
|
|
546
556
|
try {
|
|
557
|
+
const killDisplay = prefix ? `${String(prefix).trim()} ${kill_command}` : kill_command;
|
|
547
558
|
this.logger.verbose(
|
|
548
|
-
`- Using custom kill command:
|
|
559
|
+
`- Using custom kill command: ${killDisplay}`,
|
|
549
560
|
);
|
|
550
561
|
const result = await this.runCommand({
|
|
551
562
|
cmd: kill_command,
|
|
552
563
|
logFile: null,
|
|
553
564
|
background: false,
|
|
565
|
+
prefix,
|
|
554
566
|
});
|
|
555
567
|
if (result.success) {
|
|
556
568
|
this.logger.verbose(
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
2
4
|
import { ProcessManager } from './process-manager.js';
|
|
3
5
|
|
|
4
6
|
describe('ProcessManager.getLogPath', () => {
|
|
@@ -20,3 +22,59 @@ describe('ProcessManager.getLogPath', () => {
|
|
|
20
22
|
expect(result).toBe(path.resolve(override));
|
|
21
23
|
});
|
|
22
24
|
});
|
|
25
|
+
|
|
26
|
+
describe('ProcessManager.runCommand prefix handling', () => {
|
|
27
|
+
let tmpDir;
|
|
28
|
+
let prevCwd;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'so-prefix-'));
|
|
32
|
+
prevCwd = process.cwd();
|
|
33
|
+
process.chdir(tmpDir);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
process.chdir(prevCwd);
|
|
38
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('runs a regular bash command verbatim when prefix is disabled', async () => {
|
|
42
|
+
const pm = new ProcessManager();
|
|
43
|
+
pm.setLogFolder(tmpDir);
|
|
44
|
+
const marker = 'orchestrator-raw-bash-ok';
|
|
45
|
+
const result = await pm.runCommand({
|
|
46
|
+
cmd: `echo ${marker}`,
|
|
47
|
+
background: false,
|
|
48
|
+
prefix: '',
|
|
49
|
+
});
|
|
50
|
+
expect(result.success).toBe(true);
|
|
51
|
+
const logPath = pm.getLogPath(`echo ${marker}`);
|
|
52
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain(marker);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('honors a custom prefix by invoking it (failure surfaces a non-zero exit)', async () => {
|
|
56
|
+
const pm = new ProcessManager();
|
|
57
|
+
pm.setLogFolder(tmpDir);
|
|
58
|
+
// With prefix 'npm run' and no package.json script, the command must fail —
|
|
59
|
+
// proving the prefix is actually prepended rather than the command run raw.
|
|
60
|
+
const result = await pm.runCommand({
|
|
61
|
+
cmd: 'definitely-not-a-script',
|
|
62
|
+
background: false,
|
|
63
|
+
prefix: 'npm run',
|
|
64
|
+
});
|
|
65
|
+
expect(result.success).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('supports multi-token shell commands (pipes, &&) when run raw', async () => {
|
|
69
|
+
const pm = new ProcessManager();
|
|
70
|
+
pm.setLogFolder(tmpDir);
|
|
71
|
+
const result = await pm.runCommand({
|
|
72
|
+
cmd: 'printf "a\\nb\\nc\\n" | grep b',
|
|
73
|
+
background: false,
|
|
74
|
+
prefix: '',
|
|
75
|
+
});
|
|
76
|
+
expect(result.success).toBe(true);
|
|
77
|
+
const logPath = pm.getLogPath('printf');
|
|
78
|
+
expect(fs.readFileSync(logPath, 'utf8').trim()).toBe('b');
|
|
79
|
+
});
|
|
80
|
+
});
|
package/lib/report-html.js
CHANGED
|
@@ -27,6 +27,45 @@ export function formatMs(ms) {
|
|
|
27
27
|
return `${s}s`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Compact memory formatting (input in KB): MB up to ~1 GB, then GB.
|
|
31
|
+
export function formatMem(kb) {
|
|
32
|
+
if (kb == null) return '—';
|
|
33
|
+
const mb = kb / 1024;
|
|
34
|
+
if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
|
|
35
|
+
return `${(mb / 1024).toFixed(1)} GB`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Default heat thresholds (fraction of the heaviest command in the run).
|
|
39
|
+
export const DEFAULT_HEAT_THRESHOLDS = { mid: 0.33, high: 0.66 };
|
|
40
|
+
|
|
41
|
+
// Normalize caller-supplied thresholds, clamping to (0,1) and ensuring mid < high.
|
|
42
|
+
// Falls back to a default for any missing/invalid value.
|
|
43
|
+
export function normalizeHeatThresholds(t) {
|
|
44
|
+
const def = DEFAULT_HEAT_THRESHOLDS;
|
|
45
|
+
const clamp = (v, fallback) => (Number.isFinite(v) && v > 0 && v < 1 ? v : fallback);
|
|
46
|
+
let mid = clamp(t?.mid, def.mid);
|
|
47
|
+
let high = clamp(t?.high, def.high);
|
|
48
|
+
if (mid >= high) { mid = def.mid; high = def.high; }
|
|
49
|
+
return { mid, high };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Classify a value relative to the maximum in the run (low / mid / high), so heavy or
|
|
53
|
+
// slow commands can be flagged green→amber→red. Thresholds (fractions of the run's max)
|
|
54
|
+
// are configurable; defaults are used otherwise. Returns null when no value was recorded.
|
|
55
|
+
export function heatLevel(value, max, thresholds = DEFAULT_HEAT_THRESHOLDS) {
|
|
56
|
+
if (value == null || !(max > 0)) return null;
|
|
57
|
+
const frac = value / max;
|
|
58
|
+
if (frac >= thresholds.high) return 'high';
|
|
59
|
+
if (frac >= thresholds.mid) return 'mid';
|
|
60
|
+
return 'low';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Classify a command's memory use relative to the heaviest in the run, so the
|
|
64
|
+
// Gantt can flag which bars are too memory-hungry to run alongside others.
|
|
65
|
+
export function memoryHeat(memoryKb, maxMemory, thresholds = DEFAULT_HEAT_THRESHOLDS) {
|
|
66
|
+
return heatLevel(memoryKb, maxMemory, thresholds);
|
|
67
|
+
}
|
|
68
|
+
|
|
30
69
|
// Keys that are folded into the synthetic Status column or the Gantt rather than shown raw.
|
|
31
70
|
const HANDLED_KEYS = new Set(['success', 'startedAt', 'skipReason']);
|
|
32
71
|
|
|
@@ -83,6 +122,24 @@ function barCellWrap(text, percent, kind) {
|
|
|
83
122
|
return `<div class="cellbar"><span>${text}</span><div class="bar ${kind}" style="width:${percent.toFixed(1)}%"></div></div>`;
|
|
84
123
|
}
|
|
85
124
|
|
|
125
|
+
// Duration cell: green→amber→red heat scale relative to the slowest command in the run,
|
|
126
|
+
// applied to both the bar and the value text, so the slowest commands stand out at a glance.
|
|
127
|
+
function durCellWrap(durationMs, maxDuration, thresholds) {
|
|
128
|
+
const heat = heatLevel(durationMs, maxDuration, thresholds);
|
|
129
|
+
const heatCls = heat ? ` dur-${heat}` : '';
|
|
130
|
+
const percent = pct(durationMs, maxDuration);
|
|
131
|
+
return `<div class="cellbar"><span class="durval${heatCls}">${formatMs(durationMs)}</span><div class="bar dur${heatCls}" style="width:${percent.toFixed(1)}%"></div></div>`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Memory cell: same green→amber→red heat scale as the Gantt, applied to both the
|
|
135
|
+
// bar and the value text, so the table and the Gantt read identically.
|
|
136
|
+
function memCellWrap(memoryKb, maxMemory, thresholds) {
|
|
137
|
+
const heat = memoryHeat(memoryKb, maxMemory, thresholds);
|
|
138
|
+
const heatCls = heat ? ` mem-${heat}` : '';
|
|
139
|
+
const percent = pct(memoryKb, maxMemory);
|
|
140
|
+
return `<div class="cellbar"><span class="memval${heatCls}">${formatMem(memoryKb)}</span><div class="bar mem${heatCls}" style="width:${percent.toFixed(1)}%"></div></div>`;
|
|
141
|
+
}
|
|
142
|
+
|
|
86
143
|
// Render a single command cell for a given column key.
|
|
87
144
|
function renderCell(key, c, ctx) {
|
|
88
145
|
const v = c[key];
|
|
@@ -92,9 +149,9 @@ function renderCell(key, c, ctx) {
|
|
|
92
149
|
case 'phase':
|
|
93
150
|
return v != null ? escapeHtml(v) : '—';
|
|
94
151
|
case 'durationMs':
|
|
95
|
-
return v != null ?
|
|
152
|
+
return v != null ? durCellWrap(v, ctx.maxDuration, ctx.durationHeatThresholds) : '—';
|
|
96
153
|
case 'memoryKb':
|
|
97
|
-
return v != null ?
|
|
154
|
+
return v != null ? memCellWrap(v, ctx.maxMemory, ctx.heatThresholds) : '—';
|
|
98
155
|
case 'logFile':
|
|
99
156
|
return v
|
|
100
157
|
? `<a class="logref" href="file://${escapeHtml(path.resolve(process.cwd(), v))}" title="${escapeHtml(v)}"><code>${escapeHtml(v)}</code></a>`
|
|
@@ -130,7 +187,7 @@ function renderPhasesTable(phases, ctx) {
|
|
|
130
187
|
}
|
|
131
188
|
|
|
132
189
|
// Critical-path Gantt for one command list (uses observed startedAt + durationMs).
|
|
133
|
-
function renderGantt(commands, overallDurationMs, hasPhases) {
|
|
190
|
+
function renderGantt(commands, overallDurationMs, hasPhases, ctx = {}) {
|
|
134
191
|
const timed = (commands || [])
|
|
135
192
|
.filter((c) => c.startedAt && c.durationMs != null)
|
|
136
193
|
.map((c) => ({ ...c, _start: Date.parse(c.startedAt) }))
|
|
@@ -160,15 +217,29 @@ function renderGantt(commands, overallDurationMs, hasPhases) {
|
|
|
160
217
|
}
|
|
161
218
|
const criticalTotal = timed.filter((c) => criticalKeys.has(c.command)).reduce((sum, c) => sum + c.durationMs, 0);
|
|
162
219
|
|
|
220
|
+
const anyMemory = timed.some((c) => c.memoryKb != null);
|
|
221
|
+
|
|
163
222
|
const ganttRow = (c) => {
|
|
164
223
|
const offsetPct = ((c._start - runStart) / spanMs) * 100;
|
|
165
224
|
const widthPct = Math.max((c.durationMs / spanMs) * 100, 0.5);
|
|
166
225
|
const crit = criticalKeys.has(c.command);
|
|
167
226
|
const cls = c.success === false ? 'failed' : crit ? 'crit' : '';
|
|
168
|
-
|
|
227
|
+
const heat = memoryHeat(c.memoryKb, ctx.maxMemory, ctx.heatThresholds);
|
|
228
|
+
const heatCls = heat ? ` mem-${heat}` : '';
|
|
229
|
+
const memTitle = c.memoryKb != null ? ` · ${formatMem(c.memoryKb)}` : '';
|
|
230
|
+
const memChip = anyMemory
|
|
231
|
+
? `<div class="gantt-mem${heat ? ` mem-${heat}` : ''}">${c.memoryKb != null ? formatMem(c.memoryKb) : '—'}</div>`
|
|
232
|
+
: '';
|
|
233
|
+
return `<div class="gantt-row"><div class="gantt-label" title="${escapeHtml(c.command)}${c.phase ? ` — ${escapeHtml(c.phase)}` : ''}">${crit ? '★ ' : ''}${escapeHtml(c.command)}</div>${memChip}<div class="gantt-track"><div class="gantt-bar ${cls}${heatCls}" style="left:${offsetPct.toFixed(2)}%;width:${widthPct.toFixed(2)}%" title="${escapeHtml(c.command)} — ${formatMs(c.durationMs)}${memTitle}"><span class="gantt-dur">${formatMs(c.durationMs)}</span></div></div></div>`;
|
|
169
234
|
};
|
|
170
235
|
|
|
171
|
-
|
|
236
|
+
const t = ctx.heatThresholds || DEFAULT_HEAT_THRESHOLDS;
|
|
237
|
+
const memLegend = anyMemory
|
|
238
|
+
? ' Bar outline + memory column show peak RSS (green→red, relative to the heaviest command:' +
|
|
239
|
+
` amber ≥ ${Math.round(t.mid * 100)}%, red ≥ ${Math.round(t.high * 100)}%) so you can spot which` +
|
|
240
|
+
' parallel commands are too memory-hungry to overlap.'
|
|
241
|
+
: '';
|
|
242
|
+
return `<section><h3>Actual Critical Path</h3><p class="muted">★ marks each phase's bottleneck — the chain that drives wall-clock time. Critical path ≈ <strong>${formatMs(criticalTotal)}</strong>${overallDurationMs != null ? ` of ${formatMs(overallDurationMs)} total` : ''}.${memLegend}</p><div class="gantt">${timed.map(ganttRow).join('')}</div></section>`;
|
|
172
243
|
}
|
|
173
244
|
|
|
174
245
|
function renderMeta(meta) {
|
|
@@ -188,7 +259,7 @@ function renderSection(section, columns, ctx) {
|
|
|
188
259
|
const inner =
|
|
189
260
|
renderMeta(section.meta) +
|
|
190
261
|
renderPhasesTable(section.phases, ctx) +
|
|
191
|
-
renderGantt(section.commands, section.overallDurationMs, hasPhases) +
|
|
262
|
+
renderGantt(section.commands, section.overallDurationMs, hasPhases, ctx) +
|
|
192
263
|
renderCommandsTable(section.commands, columns, ctx) +
|
|
193
264
|
(section.sections || []).map((s) => renderSection(s, columns, ctx)).join('');
|
|
194
265
|
return `<details class="section" open><summary><span class="badge ${st.kind}">${escapeHtml(st.label)}</span> <span class="section-title">${escapeHtml(section.title || 'Section')}</span><span class="section-dur">${dur}</span></summary><div class="section-body">${inner}</div></details>`;
|
|
@@ -224,6 +295,20 @@ tr.running { background: rgba(59,130,246,0.08); }
|
|
|
224
295
|
.cellbar span { font-size: 0.85em; }
|
|
225
296
|
.bar { height: 6px; background: #3b82f6; border-radius: 4px; min-width: 2px; margin-top: 2px; }
|
|
226
297
|
.bar.mem { background: #8b5cf6; }
|
|
298
|
+
/* Duration heat: same green→amber→red scale, relative to the slowest command. */
|
|
299
|
+
.bar.dur.dur-low { background: #22c55e; }
|
|
300
|
+
.bar.dur.dur-mid { background: #f59e0b; }
|
|
301
|
+
.bar.dur.dur-high { background: #ef4444; }
|
|
302
|
+
.durval.dur-low { color: #22c55e; }
|
|
303
|
+
.durval.dur-mid { color: #f59e0b; }
|
|
304
|
+
.durval.dur-high { color: #ef4444; font-weight: 600; }
|
|
305
|
+
/* Memory heat: same green→amber→red scale as the Gantt's bar outline. */
|
|
306
|
+
.bar.mem.mem-low { background: #22c55e; }
|
|
307
|
+
.bar.mem.mem-mid { background: #f59e0b; }
|
|
308
|
+
.bar.mem.mem-high { background: #ef4444; }
|
|
309
|
+
.memval.mem-low { color: #22c55e; }
|
|
310
|
+
.memval.mem-mid { color: #f59e0b; }
|
|
311
|
+
.memval.mem-high { color: #ef4444; font-weight: 600; }
|
|
227
312
|
code { font-size: 0.9em; background: #333; padding: 0.1rem 0.3rem; border-radius: 4px; }
|
|
228
313
|
a.logref { color: #60a5fa; text-decoration: none; }
|
|
229
314
|
a.logref:hover { text-decoration: underline; }
|
|
@@ -235,6 +320,14 @@ a.logref:hover { text-decoration: underline; }
|
|
|
235
320
|
.gantt-bar { position: absolute; top: 0; height: 16px; background: #3b82f6; border-radius: 4px; min-width: 2px; display: flex; align-items: center; overflow: hidden; }
|
|
236
321
|
.gantt-bar.crit { background: #f59e0b; }
|
|
237
322
|
.gantt-bar.failed { background: #ef4444; }
|
|
323
|
+
/* Memory heat: an inset ring on the bar (doesn't change layout or overlap neighbours). */
|
|
324
|
+
.gantt-bar.mem-low { box-shadow: inset 0 0 0 2px #22c55e; }
|
|
325
|
+
.gantt-bar.mem-mid { box-shadow: inset 0 0 0 2px #f59e0b; }
|
|
326
|
+
.gantt-bar.mem-high { box-shadow: inset 0 0 0 2px #ef4444; }
|
|
327
|
+
.gantt-mem { width: 64px; flex: 0 0 64px; text-align: right; font-family: ui-monospace, monospace; font-size: 0.7rem; color: #888; }
|
|
328
|
+
.gantt-mem.mem-low { color: #22c55e; }
|
|
329
|
+
.gantt-mem.mem-mid { color: #f59e0b; }
|
|
330
|
+
.gantt-mem.mem-high { color: #ef4444; font-weight: 600; }
|
|
238
331
|
.gantt-dur { font-size: 0.65rem; color: #0f0f0f; padding: 0 4px; white-space: nowrap; }
|
|
239
332
|
details.section { background: #232323; border-radius: 8px; margin-bottom: 0.6rem; padding: 0.25rem 0.75rem; }
|
|
240
333
|
details.section > summary { cursor: pointer; padding: 0.5rem 0; display: flex; align-items: center; gap: 0.6rem; }
|
|
@@ -263,6 +356,9 @@ export function renderReportHtml(payload) {
|
|
|
263
356
|
const ctx = {
|
|
264
357
|
maxDuration: Math.max(0, ...allCommands.map((c) => c.durationMs || 0)),
|
|
265
358
|
maxMemory: Math.max(0, ...allCommands.map((c) => c.memoryKb || 0)),
|
|
359
|
+
// Heat thresholds travel in the payload so re-rendering a saved JSON (--render) honours them.
|
|
360
|
+
heatThresholds: normalizeHeatThresholds(payload.memoryHeat),
|
|
361
|
+
durationHeatThresholds: normalizeHeatThresholds(payload.durationHeat),
|
|
266
362
|
};
|
|
267
363
|
|
|
268
364
|
const top = statusOf({ success });
|
|
@@ -270,7 +366,7 @@ export function renderReportHtml(payload) {
|
|
|
270
366
|
|
|
271
367
|
const topBlocks =
|
|
272
368
|
renderPhasesTable(payload.phases, ctx) +
|
|
273
|
-
renderGantt(topCommands, overallDurationMs, hasTopPhases) +
|
|
369
|
+
renderGantt(topCommands, overallDurationMs, hasTopPhases, ctx) +
|
|
274
370
|
(topCommands.length > 0
|
|
275
371
|
? `<section><h3>Commands</h3>${renderCommandsTable(topCommands, columns, ctx)}</section>`
|
|
276
372
|
: '');
|
package/lib/report-html.test.js
CHANGED
|
@@ -85,4 +85,124 @@ describe('renderReportHtml', () => {
|
|
|
85
85
|
expect(html).toContain('badge warn');
|
|
86
86
|
expect(html).toContain('NX CACHE');
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
test('Memory table cell uses the same green→red heat scale as the Gantt', () => {
|
|
90
|
+
const html = renderReportHtml({
|
|
91
|
+
success: true,
|
|
92
|
+
timestamp: 't',
|
|
93
|
+
overallDurationMs: 10000,
|
|
94
|
+
commands: [
|
|
95
|
+
{ command: 'light', success: true, durationMs: 2000, memoryKb: 100 * 1024 },
|
|
96
|
+
{ command: 'heavy', success: true, durationMs: 3000, memoryKb: 2048 * 1024 },
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
// heaviest cell heat-coloured red; lightest green — both bar and value text
|
|
100
|
+
expect(html).toContain('bar mem mem-high');
|
|
101
|
+
expect(html).toContain('bar mem mem-low');
|
|
102
|
+
expect(html).toContain('memval mem-high');
|
|
103
|
+
expect(html).toContain('memval mem-low');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('memoryHeat thresholds are configurable via payload.memoryHeat', () => {
|
|
107
|
+
// With a low high-threshold (0.4), a command at 50% of peak should read as high.
|
|
108
|
+
const payload = {
|
|
109
|
+
success: true,
|
|
110
|
+
timestamp: 't',
|
|
111
|
+
overallDurationMs: 10000,
|
|
112
|
+
memoryHeat: { mid: 0.2, high: 0.4 },
|
|
113
|
+
commands: [
|
|
114
|
+
{ command: 'mid', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 1000, memoryKb: 500 * 1024 }, // 50% of peak
|
|
115
|
+
{ command: 'peak', success: true, startedAt: '2026-06-17T00:00:01.000Z', durationMs: 1000, memoryKb: 1000 * 1024 },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
const html = renderReportHtml(payload);
|
|
119
|
+
// 50% ≥ configured high (40%) → high; default thresholds (66%) would have made it 'mid'.
|
|
120
|
+
expect(html).toContain('bar mem mem-high');
|
|
121
|
+
expect(html).not.toContain('bar mem mem-mid');
|
|
122
|
+
// legend reflects the configured thresholds
|
|
123
|
+
expect(html).toContain('amber ≥ 20%, red ≥ 40%');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('invalid memoryHeat thresholds fall back to defaults', () => {
|
|
127
|
+
const html = renderReportHtml({
|
|
128
|
+
success: true,
|
|
129
|
+
timestamp: 't',
|
|
130
|
+
overallDurationMs: 10000,
|
|
131
|
+
memoryHeat: { mid: 0.9, high: 0.1 }, // mid >= high → invalid, use defaults
|
|
132
|
+
commands: [
|
|
133
|
+
{ command: 'a', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 1000, memoryKb: 500 * 1024 },
|
|
134
|
+
{ command: 'b', success: true, startedAt: '2026-06-17T00:00:01.000Z', durationMs: 1000, memoryKb: 1000 * 1024 },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
// default thresholds restored: 50% of peak → mid (≥33%, <66%)
|
|
138
|
+
expect(html).toContain('amber ≥ 33%, red ≥ 66%');
|
|
139
|
+
expect(html).toContain('bar mem mem-mid');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('Duration table cell uses a green→red heat scale relative to the slowest command', () => {
|
|
143
|
+
const html = renderReportHtml({
|
|
144
|
+
success: true,
|
|
145
|
+
timestamp: 't',
|
|
146
|
+
overallDurationMs: 10000,
|
|
147
|
+
commands: [
|
|
148
|
+
{ command: 'quick', success: true, durationMs: 500, memoryKb: 100 * 1024 },
|
|
149
|
+
{ command: 'slow', success: true, durationMs: 9000, memoryKb: 200 * 1024 },
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
// slowest cell heat-coloured red; quickest green — both bar and value text
|
|
153
|
+
expect(html).toContain('bar dur dur-high');
|
|
154
|
+
expect(html).toContain('bar dur dur-low');
|
|
155
|
+
expect(html).toContain('durval dur-high');
|
|
156
|
+
expect(html).toContain('durval dur-low');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('durationHeat thresholds are configurable via payload.durationHeat', () => {
|
|
160
|
+
// With a low high-threshold (0.4), a command at 50% of the slowest should read as high.
|
|
161
|
+
const html = renderReportHtml({
|
|
162
|
+
success: true,
|
|
163
|
+
timestamp: 't',
|
|
164
|
+
overallDurationMs: 10000,
|
|
165
|
+
durationHeat: { mid: 0.2, high: 0.4 },
|
|
166
|
+
commands: [
|
|
167
|
+
{ command: 'mid', success: true, durationMs: 500, memoryKb: 100 * 1024 }, // 50% of slowest
|
|
168
|
+
{ command: 'slow', success: true, durationMs: 1000, memoryKb: 100 * 1024 },
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
// 50% ≥ configured high (40%) → high; default thresholds (66%) would have made it 'mid'.
|
|
172
|
+
expect(html).toContain('bar dur dur-high');
|
|
173
|
+
expect(html).not.toContain('bar dur dur-mid');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('Gantt flags memory-heavy commands with a heat ring and memory chip', () => {
|
|
177
|
+
const html = renderReportHtml({
|
|
178
|
+
success: true,
|
|
179
|
+
timestamp: 't',
|
|
180
|
+
overallDurationMs: 10000,
|
|
181
|
+
commands: [
|
|
182
|
+
{ command: 'light', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 2000, memoryKb: 100 * 1024 },
|
|
183
|
+
{ command: 'heavy', success: true, startedAt: '2026-06-17T00:00:01.000Z', durationMs: 3000, memoryKb: 2048 * 1024 },
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
// heaviest command gets the high-heat ring + bold chip; lightest gets low
|
|
187
|
+
expect(html).toContain('mem-high');
|
|
188
|
+
expect(html).toContain('mem-low');
|
|
189
|
+
// memory value surfaced on the Gantt row itself (no table lookup needed)
|
|
190
|
+
expect(html).toContain('class="gantt-mem');
|
|
191
|
+
expect(html).toContain('2.0 GB');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('Gantt omits the memory chip entirely when no command reports memory', () => {
|
|
195
|
+
const html = renderReportHtml({
|
|
196
|
+
success: true,
|
|
197
|
+
timestamp: 't',
|
|
198
|
+
overallDurationMs: 10000,
|
|
199
|
+
commands: [
|
|
200
|
+
{ command: 'a', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 2000 },
|
|
201
|
+
],
|
|
202
|
+
});
|
|
203
|
+
// no rendered chip element and no heat ring on the bar
|
|
204
|
+
expect(html).not.toContain('class="gantt-mem');
|
|
205
|
+
expect(html).not.toContain('gantt-bar mem-');
|
|
206
|
+
expect(html).not.toContain('gantt-bar crit mem-');
|
|
207
|
+
});
|
|
88
208
|
});
|