@jacebenson/jsn 4.2.4 → 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 +386 -100
- package/src/cli.js +18 -11
- package/src/commands/auth.js +208 -70
- 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 +65 -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
|
@@ -52,18 +52,12 @@ export class SDKClient {
|
|
|
52
52
|
if (creds.cookies) req.headers.set('Cookie', creds.cookies);
|
|
53
53
|
break;
|
|
54
54
|
default:
|
|
55
|
-
|
|
56
|
-
req.headers.set('Authorization', 'Basic ' + Buffer.from(`${creds.username}:${creds.password}`).toString('base64'));
|
|
57
|
-
} else if (creds.access_token) {
|
|
58
|
-
req.headers.set('Authorization', `Bearer ${creds.access_token}`);
|
|
59
|
-
} else {
|
|
60
|
-
throw errAuth('No valid credentials');
|
|
61
|
-
}
|
|
55
|
+
throw errAuth('Unsupported authentication method');
|
|
62
56
|
}
|
|
63
57
|
}
|
|
64
58
|
|
|
65
59
|
async request(endpoint, opts = {}) {
|
|
66
|
-
const { timeout = this.timeout, ...requestOptions } = opts;
|
|
60
|
+
const { timeout = this.timeout, touchLastSeen = true, ...requestOptions } = opts;
|
|
67
61
|
const controller = new AbortController();
|
|
68
62
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
69
63
|
|
|
@@ -94,7 +88,7 @@ export class SDKClient {
|
|
|
94
88
|
|
|
95
89
|
// Stamp last_seen on successful API call
|
|
96
90
|
try {
|
|
97
|
-
if (this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
|
|
91
|
+
if (touchLastSeen && this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
|
|
98
92
|
this.authProvider.touchLastSeen(this.baseURL);
|
|
99
93
|
}
|
|
100
94
|
} catch { /* non-fatal */ }
|
|
@@ -126,8 +120,11 @@ export class SDKClient {
|
|
|
126
120
|
}
|
|
127
121
|
}
|
|
128
122
|
|
|
129
|
-
//
|
|
130
|
-
|
|
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
|
+
|
|
131
128
|
async rawRequest(endpoint, opts = {}) {
|
|
132
129
|
const controller = new AbortController();
|
|
133
130
|
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
@@ -163,10 +160,10 @@ export class SDKClient {
|
|
|
163
160
|
}
|
|
164
161
|
}
|
|
165
162
|
|
|
166
|
-
async list(table, params = {}) {
|
|
163
|
+
async list(table, params = {}, opts = {}) {
|
|
167
164
|
const query = new URLSearchParams(params).toString();
|
|
168
165
|
const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
|
|
169
|
-
const result = await this.request(endpoint, { method: 'GET' });
|
|
166
|
+
const result = await this.request(endpoint, { method: 'GET', ...opts });
|
|
170
167
|
return result?.result || [];
|
|
171
168
|
}
|
|
172
169
|
|
|
@@ -248,13 +245,13 @@ export class SDKClient {
|
|
|
248
245
|
return result?.result || null;
|
|
249
246
|
}
|
|
250
247
|
|
|
251
|
-
async getCurrentUser() {
|
|
248
|
+
async getCurrentUser({ touchLastSeen = true } = {}) {
|
|
252
249
|
const params = new URLSearchParams();
|
|
253
250
|
params.set('sysparm_query', 'user_name=javascript:gs.getUserName()');
|
|
254
251
|
params.set('sysparm_limit', '1');
|
|
255
252
|
params.set('sysparm_display_value', 'all');
|
|
256
253
|
params.set('sysparm_fields', 'sys_id,user_name,name');
|
|
257
|
-
const records = await this.list('sys_user', params);
|
|
254
|
+
const records = await this.list('sys_user', params, { touchLastSeen });
|
|
258
255
|
if (records.length === 0) return null;
|
|
259
256
|
const r = records[0];
|
|
260
257
|
return {
|
|
@@ -330,7 +327,7 @@ export class SDKClient {
|
|
|
330
327
|
try {
|
|
331
328
|
const pfResponse = await this.request(`${this.baseURL}/api/now/processflow/flow/${flowSysID}`, { method: 'GET' });
|
|
332
329
|
const pfData = pfResponse?.result?.data;
|
|
333
|
-
if (pfData &&
|
|
330
|
+
if (isUsableFlowPayload(pfData) && !pfResponse?.result?.errorMessage) {
|
|
334
331
|
inspection.payload = pfData;
|
|
335
332
|
if (!inspection.flow.version) {
|
|
336
333
|
const pfVersion = getStringField(pfData, 'version');
|
|
@@ -363,8 +360,10 @@ export class SDKClient {
|
|
|
363
360
|
if (payload) {
|
|
364
361
|
try {
|
|
365
362
|
const payloadData = JSON.parse(payload);
|
|
366
|
-
|
|
367
|
-
|
|
363
|
+
if (isUsableFlowPayload(payloadData)) {
|
|
364
|
+
inspection.payload = payloadData;
|
|
365
|
+
extractPayloadData(inspection, payloadData);
|
|
366
|
+
}
|
|
368
367
|
} catch {
|
|
369
368
|
// ignore parse error
|
|
370
369
|
}
|
|
@@ -462,9 +461,49 @@ export class SDKClient {
|
|
|
462
461
|
if (records) inspection.flowVariables = records;
|
|
463
462
|
}
|
|
464
463
|
|
|
464
|
+
await this._loadFlowCatalogRecords(inspection);
|
|
465
465
|
return inspection;
|
|
466
466
|
}
|
|
467
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
|
+
|
|
468
507
|
/**
|
|
469
508
|
* Resolve a custom action and return its internal step instances.
|
|
470
509
|
* The action reference on sys_hub_step_instance points at the shared
|
|
@@ -804,7 +843,15 @@ function getBoolField(record, field) {
|
|
|
804
843
|
return false;
|
|
805
844
|
}
|
|
806
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
|
+
|
|
807
852
|
function extractPayloadData(inspection, payload) {
|
|
853
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return;
|
|
854
|
+
|
|
808
855
|
const actions = toMapSlice(payload.actionInstances);
|
|
809
856
|
if (actions) inspection.actionInstances = actions;
|
|
810
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
|