@jacebenson/jsn 4.2.3 → 4.3.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 +12 -0
- package/package.json +2 -2
- package/src/app.js +9 -0
- package/src/auth.js +411 -96
- package/src/cli.js +18 -11
- package/src/commands/auth.js +273 -73
- package/src/commands/decisiontables.js +178 -0
- package/src/commands/dev/_simple.js +1 -2
- package/src/commands/dev/flows.js +92 -838
- package/src/commands/docs/db.js +60 -2
- package/src/commands/perf.js +143 -0
- package/src/decision-table-crud.js +266 -0
- package/src/decision-table-inspection.js +260 -0
- package/src/errors.js +1 -1
- package/src/flow-inspection.js +844 -0
- package/src/flow-publish.js +357 -0
- package/src/helpers.js +7 -1
- package/src/perf-capture.js +112 -0
- package/src/perf.js +335 -0
- package/src/sdk.js +69 -18
- package/src/session.js +14 -0
- package/src/commands/instance.js +0 -362
package/src/perf.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import Database from 'better-sqlite3';
|
|
6
|
+
import { getVersion } from './commands/version.js';
|
|
7
|
+
import { capturePerformance } from './perf-capture.js';
|
|
8
|
+
|
|
9
|
+
export const PERF_SCHEMA_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
function dataHome() {
|
|
12
|
+
return process.env.JSN_DATA_HOME || path.join(os.homedir(), '.jsn');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getPerfDir() {
|
|
16
|
+
return path.join(dataHome(), 'perf');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getPerfDbPath() {
|
|
20
|
+
return path.join(getPerfDir(), 'perf.db');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function openPerfDb(opts = {}) {
|
|
24
|
+
const dir = opts.dir || getPerfDir();
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
const db = new Database(opts.dbPath || path.join(dir, 'perf.db'));
|
|
27
|
+
db.pragma('journal_mode = WAL');
|
|
28
|
+
db.exec(`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
30
|
+
run_id TEXT PRIMARY KEY,
|
|
31
|
+
label TEXT,
|
|
32
|
+
profile TEXT,
|
|
33
|
+
instance TEXT NOT NULL,
|
|
34
|
+
username TEXT,
|
|
35
|
+
command_options TEXT NOT NULL,
|
|
36
|
+
start_time TEXT NOT NULL,
|
|
37
|
+
finish_time TEXT,
|
|
38
|
+
status TEXT NOT NULL,
|
|
39
|
+
jsn_version TEXT NOT NULL,
|
|
40
|
+
capture_schema_version INTEGER NOT NULL
|
|
41
|
+
);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS collectors (
|
|
43
|
+
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
44
|
+
name TEXT NOT NULL,
|
|
45
|
+
status TEXT NOT NULL,
|
|
46
|
+
reason TEXT,
|
|
47
|
+
start_time TEXT NOT NULL,
|
|
48
|
+
finish_time TEXT,
|
|
49
|
+
data TEXT,
|
|
50
|
+
PRIMARY KEY (run_id, name)
|
|
51
|
+
);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_perf_runs_instance ON runs(instance, profile, start_time);
|
|
53
|
+
`);
|
|
54
|
+
return db;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function closePerfDb(db) {
|
|
58
|
+
try { db.close(); } catch { /* already closed */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function nextRunId(db, now = new Date()) {
|
|
62
|
+
const base = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
63
|
+
let id = base;
|
|
64
|
+
let n = 1;
|
|
65
|
+
while (db.prepare('SELECT 1 FROM runs WHERE run_id = ?').get(id)) id = `${base}-${n++}`;
|
|
66
|
+
return id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function lockKey(profile, instance) {
|
|
70
|
+
return crypto.createHash('sha256').update(`${profile || ''}\0${instance}`).digest('hex').slice(0, 24);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function acquireCaptureLock(profile, instance, opts = {}) {
|
|
74
|
+
const dir = opts.dir || getPerfDir();
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
76
|
+
const file = path.join(dir, `capture-${lockKey(profile, instance)}.lock`);
|
|
77
|
+
try {
|
|
78
|
+
const fd = fs.openSync(file, 'wx', 0o600);
|
|
79
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, profile: profile || '', instance, started_at: new Date().toISOString() }));
|
|
80
|
+
return { file, release: () => {
|
|
81
|
+
try { fs.closeSync(fd); } catch { /* already closed */ }
|
|
82
|
+
try { fs.unlinkSync(file); } catch { /* already removed */ }
|
|
83
|
+
} };
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err.code === 'EEXIST') {
|
|
86
|
+
const e = new Error(`A performance capture is already running for profile "${profile || '(default)'}" and instance ${instance}`);
|
|
87
|
+
e.code = 'perf_capture_overlap';
|
|
88
|
+
e.hint = 'Wait for the existing capture to finish, or remove the lock only after confirming that process is gone.';
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function captureRun({ sdk, instance, profile = '', username = '', label = '', options = {} }) {
|
|
96
|
+
if (!sdk || !instance) throw new Error('An authenticated instance is required for perf capture');
|
|
97
|
+
const lock = acquireCaptureLock(profile, instance);
|
|
98
|
+
let db;
|
|
99
|
+
try {
|
|
100
|
+
db = openPerfDb();
|
|
101
|
+
const start = new Date().toISOString();
|
|
102
|
+
const runId = nextRunId(db);
|
|
103
|
+
const capture = await capturePerformance({ sdk, instance, profile, username, label, options });
|
|
104
|
+
const { collectors, status } = capture;
|
|
105
|
+
const finish = new Date().toISOString();
|
|
106
|
+
db.prepare(`INSERT INTO runs (run_id,label,profile,instance,username,command_options,start_time,finish_time,status,jsn_version,capture_schema_version) VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
|
|
107
|
+
.run(runId, label || null, profile || null, instance, username || null, JSON.stringify(options), start, finish, status, getVersion(), PERF_SCHEMA_VERSION);
|
|
108
|
+
const insert = db.prepare('INSERT INTO collectors (run_id,name,status,reason,start_time,finish_time,data) VALUES (?,?,?,?,?,?,?)');
|
|
109
|
+
const save = db.transaction(items => { for (const c of items) insert.run(runId, c.name, c.status, c.reason, c.start_time, c.finish_time, c.data == null ? null : JSON.stringify(c.data)); });
|
|
110
|
+
save(collectors);
|
|
111
|
+
return getRun(runId, db);
|
|
112
|
+
} finally {
|
|
113
|
+
if (db) closePerfDb(db);
|
|
114
|
+
lock.release();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseRun(row, collectors) {
|
|
119
|
+
return {
|
|
120
|
+
run_id: row.run_id, label: row.label, profile: row.profile, instance: row.instance, username: row.username,
|
|
121
|
+
command_options: JSON.parse(row.command_options || '{}'), start_time: row.start_time, finish_time: row.finish_time,
|
|
122
|
+
status: row.status, jsn_version: row.jsn_version, capture_schema_version: row.capture_schema_version,
|
|
123
|
+
collectors: collectors.map(c => ({ ...c, data: c.data ? JSON.parse(c.data) : null })),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getRun(runId, db = null) {
|
|
128
|
+
const ownsDb = !db;
|
|
129
|
+
const database = db || openPerfDb();
|
|
130
|
+
try {
|
|
131
|
+
const row = database.prepare('SELECT * FROM runs WHERE run_id = ?').get(runId);
|
|
132
|
+
if (!row) return null;
|
|
133
|
+
const collectors = database.prepare('SELECT name,status,reason,start_time,finish_time,data FROM collectors WHERE run_id = ? ORDER BY name').all(runId);
|
|
134
|
+
return parseRun(row, collectors);
|
|
135
|
+
} finally {
|
|
136
|
+
if (ownsDb) closePerfDb(database);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function listRuns({ profile, instance, limit = 50 } = {}) {
|
|
141
|
+
const db = openPerfDb();
|
|
142
|
+
try {
|
|
143
|
+
let sql = 'SELECT * FROM runs WHERE 1=1';
|
|
144
|
+
const args = [];
|
|
145
|
+
if (profile) { sql += ' AND profile = ?'; args.push(profile); }
|
|
146
|
+
if (instance) { sql += ' AND instance = ?'; args.push(instance); }
|
|
147
|
+
sql += ' ORDER BY start_time DESC LIMIT ?'; args.push(Math.max(1, Math.min(1000, Number(limit) || 50)));
|
|
148
|
+
return db.prepare(sql).all(...args).map(row => ({ run_id: row.run_id, label: row.label, profile: row.profile, instance: row.instance, start_time: row.start_time, finish_time: row.finish_time, status: row.status, jsn_version: row.jsn_version, capture_schema_version: row.capture_schema_version }));
|
|
149
|
+
} finally { closePerfDb(db); }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function arrayIdentity(item, index) {
|
|
153
|
+
if (!item || typeof item !== 'object') return String(index);
|
|
154
|
+
if (item.source != null || item.severity != null) return `source=${item.source ?? 'unknown'},severity=${item.severity ?? 'unknown'}`;
|
|
155
|
+
for (const field of ['type', 'state', 'name', 'sys_id']) if (item[field] != null) return `${field}=${item[field]}`;
|
|
156
|
+
return String(index);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function flatten(value, prefix = '', out = new Map()) {
|
|
160
|
+
if (value == null) return out;
|
|
161
|
+
if (typeof value === 'number' && Number.isFinite(value)) out.set(prefix, value);
|
|
162
|
+
else if (Array.isArray(value)) value.forEach((v, i) => flatten(v, `${prefix}[${arrayIdentity(v, i)}]`, out));
|
|
163
|
+
else if (typeof value === 'object') Object.entries(value).forEach(([k, v]) => flatten(v, prefix ? `${prefix}.${k}` : k, out));
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function runContext(run) {
|
|
168
|
+
return {
|
|
169
|
+
run_id: run.run_id,
|
|
170
|
+
label: run.label,
|
|
171
|
+
instance: run.instance,
|
|
172
|
+
profile: run.profile,
|
|
173
|
+
start_time: run.start_time,
|
|
174
|
+
finish_time: run.finish_time,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function compareRuns(baseline, newer) {
|
|
179
|
+
const a = new Map();
|
|
180
|
+
const b = new Map();
|
|
181
|
+
for (const c of baseline.collectors) flatten(c.data?.metrics, c.name, a);
|
|
182
|
+
for (const c of newer.collectors) flatten(c.data?.metrics, c.name, b);
|
|
183
|
+
const names = [...new Set([...a.keys(), ...b.keys()])].sort();
|
|
184
|
+
const metrics = names.map(metric => {
|
|
185
|
+
if (!a.has(metric)) return { metric, availability: 'missing_from_baseline', baseline: null, new: b.get(metric), delta: null, percent_change: null };
|
|
186
|
+
if (!b.has(metric)) return { metric, availability: 'missing_from_new', baseline: a.get(metric), new: null, delta: null, percent_change: null };
|
|
187
|
+
const delta = b.get(metric) - a.get(metric);
|
|
188
|
+
return { metric, availability: 'available', baseline: a.get(metric), new: b.get(metric), delta, percent_change: a.get(metric) === 0 ? null : (delta / a.get(metric)) * 100 };
|
|
189
|
+
});
|
|
190
|
+
return { baseline: runContext(baseline), new: runContext(newer), baseline_run_id: baseline.run_id, new_run_id: newer.run_id, status: metrics.some(m => m.availability !== 'available') ? 'incomplete' : 'complete', metrics };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function formatRun(run) {
|
|
194
|
+
const lines = [`Run ${run.run_id}${run.label ? ` (${run.label})` : ''}`, `Status: ${run.status}`, `Instance: ${run.instance}`, `Started: ${run.start_time}`, 'COLLECTOR STATUS REASON'];
|
|
195
|
+
for (const c of run.collectors) lines.push(`${c.name.padEnd(25)} ${c.status.padEnd(18)} ${c.reason || ''}`);
|
|
196
|
+
return `${lines.join('\n')}\n`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function formatRunDetailed(run) {
|
|
200
|
+
const lines = [`Run ${run.run_id}${run.label ? ` (${run.label})` : ''}`, `Status: ${run.status}`, `Instance: ${run.instance}`, `Profile: ${run.profile || 'default'}`, `Started: ${run.start_time}`, 'COLLECTOR STATUS REASON'];
|
|
201
|
+
for (const c of run.collectors) lines.push(`${c.name.padEnd(25)} ${c.status.padEnd(18)} ${c.reason || ''}`);
|
|
202
|
+
lines.push('', 'CAPTURED DETAILS');
|
|
203
|
+
for (const c of run.collectors) {
|
|
204
|
+
lines.push(` ${c.name}`);
|
|
205
|
+
if (c.status !== 'success' || !c.data) {
|
|
206
|
+
lines.push(` ${c.reason || 'No data captured'}`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const metrics = c.data.metrics || {};
|
|
210
|
+
if (c.name === 'transactions') {
|
|
211
|
+
for (const row of metrics.transaction_types || []) lines.push(` ${row.type}: ${row.count ?? 'unavailable'} requests, avg ${row.avg_response_time_ms ?? 'unavailable'} ms, max ${row.max_response_time_ms ?? 'unavailable'} ms`);
|
|
212
|
+
} else if (c.name === 'platform_health') {
|
|
213
|
+
lines.push(` Nodes: ${metrics.node_count ?? 'unavailable'}`);
|
|
214
|
+
for (const node of metrics.nodes || []) lines.push(` ${node.status || '?'} node ${node.sys_id || ''}: queue ${node.stats?.queue?.length ?? 'unavailable'}, sessions ${node.stats?.active_sessions ?? 'unavailable'}`);
|
|
215
|
+
} else if (c.name === 'error_warning_summary') {
|
|
216
|
+
const totals = {};
|
|
217
|
+
for (const row of metrics.log_groups || []) totals[row.severity || 'unknown'] = (totals[row.severity || 'unknown'] || 0) + (row.count || 0);
|
|
218
|
+
lines.push(` Groups: ${(metrics.log_groups || []).length}, by severity: ${Object.entries(totals).map(([severity, count]) => `${severity}=${count}`).join(', ') || 'none'}`);
|
|
219
|
+
} else if (c.name === 'flow_executions') {
|
|
220
|
+
for (const row of metrics.flow_states || []) lines.push(` ${row.state}: ${row.count ?? 'unavailable'} executions, avg ${row.avg_duration ?? 'unavailable'} ms`);
|
|
221
|
+
} else if (c.name === 'event_queue') {
|
|
222
|
+
lines.push(` States: ${(metrics.event_queue || []).length}`);
|
|
223
|
+
for (const row of metrics.event_queue || []) lines.push(` ${row.groupby_fields?.[0]?.value || 'unknown'}: ${row.stats?.count ?? 'unavailable'}`);
|
|
224
|
+
} else if (metrics.counts) {
|
|
225
|
+
for (const [name, value] of Object.entries(metrics.counts)) lines.push(` ${name}: ${value ?? 'unavailable'}`);
|
|
226
|
+
} else {
|
|
227
|
+
lines.push(` ${JSON.stringify(metrics)}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const newline = String.fromCharCode(10);
|
|
231
|
+
return lines.join(newline) + newline;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function formatRunList(runs) {
|
|
235
|
+
const lines = ['RUN ID STATUS LABEL INSTANCE'];
|
|
236
|
+
lines.push('------------------------- ---------- -------------------- ------------------------------');
|
|
237
|
+
for (const run of runs) lines.push(`${run.run_id.padEnd(25)} ${run.status.padEnd(10)} ${String(run.label || '').slice(0, 20).padEnd(20)} ${run.instance}`);
|
|
238
|
+
if (runs.length === 0) lines.push('(no performance captures)');
|
|
239
|
+
return `${lines.join('\n')}\n`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function formatComparisonDetailed(result) {
|
|
243
|
+
const shortMetric = (metric) => metric
|
|
244
|
+
.replace(/error_warning_summary\.log_groups\[source=([^,\]]+),severity=([^\]]+)\]/g, 'logs[$1,sev=$2]')
|
|
245
|
+
.replace(/transactions\.transaction_types\[type=([^\]]+)\]/g, 'tx[$1]')
|
|
246
|
+
.replace('error_warning_summary', 'logs')
|
|
247
|
+
.replace('platform_health', 'platform')
|
|
248
|
+
.replace('flow_executions', 'flows')
|
|
249
|
+
.replace('scheduled_and_cleanup', 'scheduled')
|
|
250
|
+
.replace('record_counts', 'records')
|
|
251
|
+
.replace(/nodes\[sys_id=([^\]]+)\]/g, (_, id) => `node[${id.slice(0, 8)}]`)
|
|
252
|
+
.replace(/semaphores\[name=([^\]]+)\]/g, 'sem[$1]')
|
|
253
|
+
.replace('.stats.active_sessions', '.sessions')
|
|
254
|
+
.replace('.stats.queue.length', '.queue')
|
|
255
|
+
.replace('.stats.queue.age', '.queue_age')
|
|
256
|
+
.replace('.stats.semaphores', '.semaphores')
|
|
257
|
+
.replace('.stats.transactions.daily', '.daily_tx')
|
|
258
|
+
.replace('.avg_response_time_ms', '.avg_ms')
|
|
259
|
+
.replace('.min_response_time_ms', '.min_ms')
|
|
260
|
+
.replace('.max_response_time_ms', '.max_ms')
|
|
261
|
+
.replace('.stats.', '.');
|
|
262
|
+
const displayMetric = metric => shortMetric(metric);
|
|
263
|
+
const displayValue = value => typeof value === 'number' && !Number.isInteger(value) ? value.toFixed(2) : String(value);
|
|
264
|
+
const semaphoreFields = ['available', 'borrowed', 'maximum_concurrency', 'queue_depth', 'queue_depth_limit'];
|
|
265
|
+
const semaphoreLabels = ['avail', 'borrow', 'max', 'qd', 'qlim'];
|
|
266
|
+
const semaphorePattern = /^(.*\.semaphores\[name=[^\]]+\])\.(available|borrowed|maximum_concurrency|queue_depth|queue_depth_limit)$/;
|
|
267
|
+
const grouped = new Map();
|
|
268
|
+
const regular = [];
|
|
269
|
+
for (const metric of result.metrics) {
|
|
270
|
+
const match = metric.metric.match(semaphorePattern);
|
|
271
|
+
if (!match) {
|
|
272
|
+
regular.push(metric);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!grouped.has(match[1])) grouped.set(match[1], new Map());
|
|
276
|
+
grouped.get(match[1]).set(match[2], metric);
|
|
277
|
+
}
|
|
278
|
+
const formatValue = value => value == null ? '?' : displayValue(value);
|
|
279
|
+
const formatSemaphore = (name, fields, width) => {
|
|
280
|
+
const values = side => semaphoreFields.map(field => formatValue(fields.get(field)?.[side])).join(' ');
|
|
281
|
+
const delta = semaphoreFields.map(field => formatValue(fields.get(field)?.delta)).join(' ');
|
|
282
|
+
const label = shortMetric(name).replace(/^platform\./, '');
|
|
283
|
+
return `${label.padEnd(width)} ${values('baseline').padStart(16)} | ${values('new').padStart(16)} | ${delta.padStart(16)}`;
|
|
284
|
+
};
|
|
285
|
+
const sectionFor = metric => {
|
|
286
|
+
if (metric.startsWith('ecc_queue.')) return 'ECC queue';
|
|
287
|
+
if (metric.startsWith('logs[')) return 'Logs';
|
|
288
|
+
if (metric.startsWith('flows.')) return 'Flows';
|
|
289
|
+
if (metric.startsWith('platform.')) return 'Platform';
|
|
290
|
+
if (metric.startsWith('records.')) return 'Records';
|
|
291
|
+
if (metric.startsWith('scheduled.')) return 'Scheduled';
|
|
292
|
+
if (metric.startsWith('tx[')) return 'Transactions';
|
|
293
|
+
return 'Other';
|
|
294
|
+
};
|
|
295
|
+
const lines = [
|
|
296
|
+
`Performance comparison: ${result.status}`,
|
|
297
|
+
`Baseline: ${result.baseline?.profile || 'default'} @ ${result.baseline?.instance || 'unknown'} (${result.baseline_run_id})`,
|
|
298
|
+
`New: ${result.new?.profile || 'default'} @ ${result.new?.instance || 'unknown'} (${result.new_run_id})`,
|
|
299
|
+
];
|
|
300
|
+
const addMetric = (m, width) => {
|
|
301
|
+
const metric = displayMetric(m.metric);
|
|
302
|
+
if (m.availability === 'missing_from_baseline') lines.push(`${metric.padEnd(width)} Unavailable: missing from baseline`);
|
|
303
|
+
else if (m.availability === 'missing_from_new') lines.push(`${metric.padEnd(width)} Unavailable: missing from new result`);
|
|
304
|
+
else lines.push(`${metric.padEnd(width)} ${displayValue(m.baseline).padStart(12)} ${displayValue(m.new).padStart(12)} ${displayValue(m.delta).padStart(12)}`);
|
|
305
|
+
};
|
|
306
|
+
const sections = new Map();
|
|
307
|
+
for (const metric of regular) {
|
|
308
|
+
const section = sectionFor(displayMetric(metric.metric));
|
|
309
|
+
if (!sections.has(section)) sections.set(section, []);
|
|
310
|
+
sections.get(section).push(metric);
|
|
311
|
+
}
|
|
312
|
+
for (const [section, metrics] of sections) {
|
|
313
|
+
const width = Math.max(34, ...metrics.map(metric => displayMetric(metric.metric).length));
|
|
314
|
+
lines.push('', section.toUpperCase(), `${'METRIC'.padEnd(width)} BASELINE NEW DELTA`);
|
|
315
|
+
for (const metric of metrics) addMetric(metric, width);
|
|
316
|
+
}
|
|
317
|
+
if (grouped.size) {
|
|
318
|
+
const semaphoreNames = [...grouped.keys()].map(name => shortMetric(name).replace(/^platform\./, ''));
|
|
319
|
+
const width = Math.max(36, ...semaphoreNames.map(name => name.length));
|
|
320
|
+
lines.push('', 'SEMAPHORES', `${'NAME'.padEnd(width)} BASELINE [${semaphoreLabels.join(' ')}] | NEW [${semaphoreLabels.join(' ')}] | DELTA [${semaphoreLabels.join(' ')}]`);
|
|
321
|
+
for (const [name, fields] of grouped) lines.push(formatSemaphore(name, fields, width));
|
|
322
|
+
}
|
|
323
|
+
const newline = String.fromCharCode(10);
|
|
324
|
+
return lines.join(newline) + newline;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function formatComparison(result) {
|
|
328
|
+
const lines = [`Compare ${result.baseline_run_id} -> ${result.new_run_id}`, `Overall: ${result.status}`, 'METRIC BASELINE NEW DELTA'];
|
|
329
|
+
for (const m of result.metrics) {
|
|
330
|
+
if (m.availability === 'missing_from_baseline') lines.push(`${m.metric.padEnd(42)} Unavailable: missing from baseline`);
|
|
331
|
+
else if (m.availability === 'missing_from_new') lines.push(`${m.metric.padEnd(42)} Unavailable: missing from new result`);
|
|
332
|
+
else lines.push(`${m.metric.padEnd(42)} ${String(m.baseline).padStart(12)} ${String(m.new).padStart(12)} ${String(m.delta).padStart(12)}`);
|
|
333
|
+
}
|
|
334
|
+
return `${lines.join('\n')}\n`;
|
|
335
|
+
}
|
package/src/sdk.js
CHANGED
|
@@ -47,19 +47,17 @@ export class SDKClient {
|
|
|
47
47
|
case 'oauth':
|
|
48
48
|
req.headers.set('Authorization', `Bearer ${creds.access_token}`);
|
|
49
49
|
break;
|
|
50
|
+
case 'gck':
|
|
51
|
+
req.headers.set('X-UserToken', creds.access_token);
|
|
52
|
+
if (creds.cookies) req.headers.set('Cookie', creds.cookies);
|
|
53
|
+
break;
|
|
50
54
|
default:
|
|
51
|
-
|
|
52
|
-
req.headers.set('Authorization', 'Basic ' + Buffer.from(`${creds.username}:${creds.password}`).toString('base64'));
|
|
53
|
-
} else if (creds.access_token) {
|
|
54
|
-
req.headers.set('Authorization', `Bearer ${creds.access_token}`);
|
|
55
|
-
} else {
|
|
56
|
-
throw errAuth('No valid credentials');
|
|
57
|
-
}
|
|
55
|
+
throw errAuth('Unsupported authentication method');
|
|
58
56
|
}
|
|
59
57
|
}
|
|
60
58
|
|
|
61
59
|
async request(endpoint, opts = {}) {
|
|
62
|
-
const { timeout = this.timeout, ...requestOptions } = opts;
|
|
60
|
+
const { timeout = this.timeout, touchLastSeen = true, ...requestOptions } = opts;
|
|
63
61
|
const controller = new AbortController();
|
|
64
62
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
65
63
|
|
|
@@ -90,7 +88,7 @@ export class SDKClient {
|
|
|
90
88
|
|
|
91
89
|
// Stamp last_seen on successful API call
|
|
92
90
|
try {
|
|
93
|
-
if (this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
|
|
91
|
+
if (touchLastSeen && this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
|
|
94
92
|
this.authProvider.touchLastSeen(this.baseURL);
|
|
95
93
|
}
|
|
96
94
|
} catch { /* non-fatal */ }
|
|
@@ -122,8 +120,11 @@ export class SDKClient {
|
|
|
122
120
|
}
|
|
123
121
|
}
|
|
124
122
|
|
|
125
|
-
//
|
|
126
|
-
|
|
123
|
+
// Public raw-response seam for callers that must inspect non-2xx bodies.
|
|
124
|
+
async fetchResponse(endpoint, opts = {}) {
|
|
125
|
+
return this._fetchWithAuth(endpoint, opts);
|
|
126
|
+
}
|
|
127
|
+
|
|
127
128
|
async rawRequest(endpoint, opts = {}) {
|
|
128
129
|
const controller = new AbortController();
|
|
129
130
|
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
@@ -159,10 +160,10 @@ export class SDKClient {
|
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
async list(table, params = {}) {
|
|
163
|
+
async list(table, params = {}, opts = {}) {
|
|
163
164
|
const query = new URLSearchParams(params).toString();
|
|
164
165
|
const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
|
|
165
|
-
const result = await this.request(endpoint, { method: 'GET' });
|
|
166
|
+
const result = await this.request(endpoint, { method: 'GET', ...opts });
|
|
166
167
|
return result?.result || [];
|
|
167
168
|
}
|
|
168
169
|
|
|
@@ -244,13 +245,13 @@ export class SDKClient {
|
|
|
244
245
|
return result?.result || null;
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
async getCurrentUser() {
|
|
248
|
+
async getCurrentUser({ touchLastSeen = true } = {}) {
|
|
248
249
|
const params = new URLSearchParams();
|
|
249
250
|
params.set('sysparm_query', 'user_name=javascript:gs.getUserName()');
|
|
250
251
|
params.set('sysparm_limit', '1');
|
|
251
252
|
params.set('sysparm_display_value', 'all');
|
|
252
253
|
params.set('sysparm_fields', 'sys_id,user_name,name');
|
|
253
|
-
const records = await this.list('sys_user', params);
|
|
254
|
+
const records = await this.list('sys_user', params, { touchLastSeen });
|
|
254
255
|
if (records.length === 0) return null;
|
|
255
256
|
const r = records[0];
|
|
256
257
|
return {
|
|
@@ -326,7 +327,7 @@ export class SDKClient {
|
|
|
326
327
|
try {
|
|
327
328
|
const pfResponse = await this.request(`${this.baseURL}/api/now/processflow/flow/${flowSysID}`, { method: 'GET' });
|
|
328
329
|
const pfData = pfResponse?.result?.data;
|
|
329
|
-
if (pfData &&
|
|
330
|
+
if (isUsableFlowPayload(pfData) && !pfResponse?.result?.errorMessage) {
|
|
330
331
|
inspection.payload = pfData;
|
|
331
332
|
if (!inspection.flow.version) {
|
|
332
333
|
const pfVersion = getStringField(pfData, 'version');
|
|
@@ -359,8 +360,10 @@ export class SDKClient {
|
|
|
359
360
|
if (payload) {
|
|
360
361
|
try {
|
|
361
362
|
const payloadData = JSON.parse(payload);
|
|
362
|
-
|
|
363
|
-
|
|
363
|
+
if (isUsableFlowPayload(payloadData)) {
|
|
364
|
+
inspection.payload = payloadData;
|
|
365
|
+
extractPayloadData(inspection, payloadData);
|
|
366
|
+
}
|
|
364
367
|
} catch {
|
|
365
368
|
// ignore parse error
|
|
366
369
|
}
|
|
@@ -458,9 +461,49 @@ export class SDKClient {
|
|
|
458
461
|
if (records) inspection.flowVariables = records;
|
|
459
462
|
}
|
|
460
463
|
|
|
464
|
+
await this._loadFlowCatalogRecords(inspection);
|
|
461
465
|
return inspection;
|
|
462
466
|
}
|
|
463
467
|
|
|
468
|
+
async _loadFlowCatalogRecords(inspection) {
|
|
469
|
+
const refs = new Map();
|
|
470
|
+
const tokenRe = /([0-9a-f]{32}):([a-z_]+)/g;
|
|
471
|
+
const addInputs = inputs => {
|
|
472
|
+
if (!Array.isArray(inputs)) return;
|
|
473
|
+
for (const input of inputs) {
|
|
474
|
+
const value = getStringField(input, 'displayValue') || getStringField(input, 'value');
|
|
475
|
+
for (const [, id, table] of String(value).matchAll(tokenRe)) {
|
|
476
|
+
if (!refs.has(`${table}:${id}`)) refs.set(`${table}:${id}`, { id, table });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
const walk = items => {
|
|
481
|
+
if (!Array.isArray(items)) return;
|
|
482
|
+
for (const item of items) {
|
|
483
|
+
addInputs(item?.inputs);
|
|
484
|
+
walk(item?.flowBlock);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
walk(inspection.actionInstances);
|
|
488
|
+
walk(inspection.flowLogicInstances);
|
|
489
|
+
for (const logic of inspection.flowLogicInstances) addInputs(logic?._decodedValues?.inputs);
|
|
490
|
+
Object.defineProperty(inspection, 'catalogRecords', {
|
|
491
|
+
value: [],
|
|
492
|
+
enumerable: false,
|
|
493
|
+
configurable: true,
|
|
494
|
+
writable: true,
|
|
495
|
+
});
|
|
496
|
+
for (const { id, table } of refs.values()) {
|
|
497
|
+
try {
|
|
498
|
+
const params = new URLSearchParams({ sysparm_query: `sys_id=${id}`, sysparm_fields: 'sys_id,name,question_text', sysparm_display_value: 'all', sysparm_limit: '1' });
|
|
499
|
+
const record = (await this.list(table, params))[0];
|
|
500
|
+
if (record) inspection.catalogRecords.push({ ...record, table });
|
|
501
|
+
} catch {
|
|
502
|
+
// Catalog labels are optional enrichment.
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
464
507
|
/**
|
|
465
508
|
* Resolve a custom action and return its internal step instances.
|
|
466
509
|
* The action reference on sys_hub_step_instance points at the shared
|
|
@@ -800,7 +843,15 @@ function getBoolField(record, field) {
|
|
|
800
843
|
return false;
|
|
801
844
|
}
|
|
802
845
|
|
|
846
|
+
function isUsableFlowPayload(payload) {
|
|
847
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
|
|
848
|
+
return ['actionInstances', 'flowLogicInstances', 'subFlowInstances']
|
|
849
|
+
.some(key => Array.isArray(payload[key]) && payload[key].some(item => item && typeof item === 'object' && !Array.isArray(item)));
|
|
850
|
+
}
|
|
851
|
+
|
|
803
852
|
function extractPayloadData(inspection, payload) {
|
|
853
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return;
|
|
854
|
+
|
|
804
855
|
const actions = toMapSlice(payload.actionInstances);
|
|
805
856
|
if (actions) inspection.actionInstances = actions;
|
|
806
857
|
|
package/src/session.js
CHANGED
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
// --instance flag > --profile reference > active profile >
|
|
26
26
|
// default profile > legacy bare instanceURL
|
|
27
27
|
//
|
|
28
|
+
// When both explicit selectors are supplied, they must resolve to the same
|
|
29
|
+
// normalized instance; otherwise the command fails with a usage error.
|
|
30
|
+
//
|
|
28
31
|
// Deliberately NOT here: env var loading (loadConfig already folds
|
|
29
32
|
// SERVICENOW_INSTANCE_URL into cfg.instanceURL before the resolver runs)
|
|
30
33
|
// and credential storage I/O (auth.js owns the keyring/file stores).
|
|
@@ -76,6 +79,17 @@ export function resolveSession(argv = {}, config) {
|
|
|
76
79
|
|
|
77
80
|
const profile = profileName ? profiles[profileName] : null;
|
|
78
81
|
|
|
82
|
+
// Explicit selectors must identify the same target. Without this guard,
|
|
83
|
+
// --profile supplies the credentials while --instance silently points the
|
|
84
|
+
// SDK at another identity's instance.
|
|
85
|
+
if (argv.instance && argv.profile && profile?.instance_url) {
|
|
86
|
+
const flagInstance = normalizeInstanceURL(argv.instance);
|
|
87
|
+
const profileInstance = normalizeInstanceURL(profile.instance_url);
|
|
88
|
+
if (flagInstance !== profileInstance) {
|
|
89
|
+
throw errUsage(`Conflicting selectors: --profile "${argv.profile}" targets ${profileInstance}, but --instance targets ${flagInstance}.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
// Instance: --instance flag > profile reference > active/default
|
|
80
94
|
// profile > legacy bare instanceURL. The override is normalized here so
|
|
81
95
|
// every downstream consumer (SDK client, context header links) sees the
|