@jacebenson/jsn 4.2.1 → 4.2.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/README.md +20 -0
- package/package.json +1 -1
- package/src/app.js +2 -2
- package/src/auth.js +12 -7
- package/src/cli.js +6 -0
- package/src/commands/auth.js +36 -14
- package/src/commands/dev/flows.js +120 -2
- package/src/commands/dev/rest.js +8 -1
- package/src/commands/instance.js +362 -0
- package/src/commands/platform.js +127 -0
- package/src/commands/records.js +46 -1
- package/src/commands/transactions.js +100 -0
- package/src/flow-context.js +184 -0
- package/src/records/inspect.js +14 -13
- package/src/sdk.js +68 -6
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
2
|
+
|
|
3
|
+
// The report separates activity in the selected window from current totals.
|
|
4
|
+
// Missing or protected tables stay visible as unavailable instead of aborting
|
|
5
|
+
// the whole report.
|
|
6
|
+
const DATA_VOLUME = [
|
|
7
|
+
['Task records', 'task'],
|
|
8
|
+
['Incidents', 'incident'],
|
|
9
|
+
['Catalog tasks', 'sc_task'],
|
|
10
|
+
['Requested items', 'sc_req_item'],
|
|
11
|
+
['Requests', 'sc_request'],
|
|
12
|
+
['Problems', 'problem'],
|
|
13
|
+
['Change requests', 'change_request'],
|
|
14
|
+
['CMDB CI records', 'cmdb_ci'],
|
|
15
|
+
];
|
|
16
|
+
const DATA_VOLUME_CONCURRENCY = 2;
|
|
17
|
+
const DATA_VOLUME_TIMEOUT = 120000;
|
|
18
|
+
|
|
19
|
+
const CONFIGURED_AUTOMATION = [
|
|
20
|
+
['Scripted REST APIs', 'sys_ws_definition'],
|
|
21
|
+
['Scripted REST operations', 'sys_ws_operation'],
|
|
22
|
+
['Inbound email actions', 'sysevent_in_email_action'],
|
|
23
|
+
['SOAP messages', 'sys_soap_message'],
|
|
24
|
+
['Scheduled scripts', 'sysauto_script'],
|
|
25
|
+
['Async business rules', 'sys_script', 'when=async'],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
declareCapabilities('instance', {});
|
|
29
|
+
|
|
30
|
+
function nextDate(date) {
|
|
31
|
+
const next = new Date(`${date}T00:00:00Z`);
|
|
32
|
+
if (Number.isNaN(next.getTime())) return '';
|
|
33
|
+
next.setUTCDate(next.getUTCDate() + 1);
|
|
34
|
+
return next.toISOString().slice(0, 10);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildActivityQuery({ query, since, until } = {}) {
|
|
38
|
+
if (query) return String(query);
|
|
39
|
+
const clauses = [];
|
|
40
|
+
if (since) clauses.push(`sys_created_on>=${since}`);
|
|
41
|
+
if (until) clauses.push(`sys_created_on<${nextDate(until) || until}`);
|
|
42
|
+
if (clauses.length) return clauses.join('^');
|
|
43
|
+
return 'sys_created_on>=javascript:gs.daysAgoStart(1)';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function addQuery(base, extra) {
|
|
47
|
+
return [base, extra].filter(Boolean).join('^');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function numeric(value) {
|
|
51
|
+
const number = Number(String(value ?? '').replaceAll(',', ''));
|
|
52
|
+
return Number.isFinite(number) ? number : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function groupField(group, field) {
|
|
56
|
+
return group?.groupby_fields?.find(item => item?.field === field)?.value || 'unknown';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function groupCount(group) {
|
|
60
|
+
return numeric(group?.stats?.count ?? group?.count) ?? 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function metricValue(stats, metric, field) {
|
|
64
|
+
const value = stats?.[metric];
|
|
65
|
+
return numeric(typeof value === 'object' ? value?.[field] : value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function safeCount(sdk, label, table, query = '', options = {}) {
|
|
69
|
+
try {
|
|
70
|
+
return { label, table, query, count: await sdk.aggregateCount(table, query, options) };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const message = err.message || 'request failed';
|
|
73
|
+
const deadline = options.timeout && message === 'Request timed out'
|
|
74
|
+
? ` after ${Math.round(options.timeout / 1000)} seconds`
|
|
75
|
+
: '';
|
|
76
|
+
return { label, table, query, count: null, unavailable: `${message}${deadline}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function mapWithConcurrency(items, limit, worker) {
|
|
81
|
+
const results = new Array(items.length);
|
|
82
|
+
let nextIndex = 0;
|
|
83
|
+
const run = async () => {
|
|
84
|
+
while (nextIndex < items.length) {
|
|
85
|
+
const index = nextIndex;
|
|
86
|
+
nextIndex += 1;
|
|
87
|
+
results[index] = await worker(items[index]);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function safeAggregate(sdk, label, table, options) {
|
|
95
|
+
try {
|
|
96
|
+
const result = await sdk.aggregate(table, options);
|
|
97
|
+
return {
|
|
98
|
+
label,
|
|
99
|
+
table,
|
|
100
|
+
groups: Array.isArray(result?.groups) ? result.groups : (Array.isArray(result?.stats) ? result.stats : []),
|
|
101
|
+
stats: result?.stats && !Array.isArray(result.stats) ? result.stats : null,
|
|
102
|
+
};
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return { label, table, groups: [], stats: null, unavailable: err.message || 'request failed' };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function safeStatsResult(result) {
|
|
109
|
+
const stats = result.stats || result.groups[0]?.stats || {};
|
|
110
|
+
return {
|
|
111
|
+
avg_ms: metricValue(stats, 'avg', 'response_time'),
|
|
112
|
+
max_ms: metricValue(stats, 'max', 'response_time'),
|
|
113
|
+
...(result.unavailable ? { unavailable: result.unavailable } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildDateList(since, until) {
|
|
118
|
+
if (!since || !until || !nextDate(until)) return [];
|
|
119
|
+
const dates = [];
|
|
120
|
+
let current = since;
|
|
121
|
+
for (let i = 0; i < 366 && current && current <= until; i += 1) {
|
|
122
|
+
dates.push(current);
|
|
123
|
+
current = nextDate(current);
|
|
124
|
+
}
|
|
125
|
+
return dates;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatCount(value) {
|
|
129
|
+
if (value == null) return 'unavailable';
|
|
130
|
+
return Number(value).toLocaleString('en-US');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function formatDuration(ms) {
|
|
134
|
+
if (ms == null) return 'unavailable';
|
|
135
|
+
if (ms >= 3600000) return `${(ms / 3600000).toFixed(1)} hours`;
|
|
136
|
+
if (ms >= 60000) return `${(ms / 60000).toFixed(1)} minutes`;
|
|
137
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(1)} seconds`;
|
|
138
|
+
return `${Math.round(ms * 100) / 100} ms`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatMetricRows(rows) {
|
|
142
|
+
return rows.map(row => ` ${row.label}: ${formatCount(row.count)}${row.unavailable ? ` (${row.unavailable})` : ''}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatFlowRows(flow) {
|
|
146
|
+
const lines = [` Flow executions: ${formatCount(flow.total_count)}`];
|
|
147
|
+
for (const row of flow.states) lines.push(` ${row.state}: ${formatCount(row.count)}`);
|
|
148
|
+
if (flow.unavailable) lines.push(` Unavailable: ${flow.unavailable}`);
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatTransactions(transactions) {
|
|
153
|
+
const lines = [` Total transactions: ${formatCount(transactions.total_count)}`];
|
|
154
|
+
if (transactions.types.length) {
|
|
155
|
+
for (const row of transactions.types) {
|
|
156
|
+
lines.push('');
|
|
157
|
+
lines.push(` ${row.type}`);
|
|
158
|
+
lines.push(` Count: ${formatCount(row.count)}`);
|
|
159
|
+
lines.push(` Average response: ${formatDuration(row.avg_response_time_ms)}`);
|
|
160
|
+
lines.push(` Maximum response: ${formatDuration(row.max_response_time_ms)}`);
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
lines.push(' (no transaction types)');
|
|
164
|
+
}
|
|
165
|
+
if (transactions.unavailable) lines.push(` Unavailable: ${transactions.unavailable}`);
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatDailyFlows(rows) {
|
|
170
|
+
if (!rows.length) return [];
|
|
171
|
+
return [
|
|
172
|
+
'',
|
|
173
|
+
' Daily flow executions',
|
|
174
|
+
' Date Count',
|
|
175
|
+
...rows.map(row => ` ${row.date} ${formatCount(row.count)}`),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function formatDailyImports(rows) {
|
|
180
|
+
if (!rows.length) return [];
|
|
181
|
+
return [
|
|
182
|
+
'',
|
|
183
|
+
' Daily import activity',
|
|
184
|
+
' Date Import sets Import rows',
|
|
185
|
+
...rows.map(row => ` ${row.date} ${formatCount(row.import_sets).padStart(11)} ${formatCount(row.import_rows).padStart(12)}`),
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function formatInstanceGlance(data) {
|
|
190
|
+
const lines = [
|
|
191
|
+
'INSTANCE AT A GLANCE',
|
|
192
|
+
`Instance: ${data.context.instance_url}`,
|
|
193
|
+
`Activity window: ${data.window.since || 'last 24 hours'} through ${data.window.until || 'now'}`,
|
|
194
|
+
'Configuration and data totals: current',
|
|
195
|
+
'',
|
|
196
|
+
'1. INBOUND AUTOMATION',
|
|
197
|
+
...formatMetricRows(data.inbound_automation.metrics),
|
|
198
|
+
' Scripted REST separation: not separated from other REST transactions',
|
|
199
|
+
'',
|
|
200
|
+
'2. ASYNCHRONOUS AUTOMATION',
|
|
201
|
+
...formatFlowRows(data.asynchronous_automation),
|
|
202
|
+
` Async business rules configured: ${formatCount(data.asynchronous_automation.async_business_rules_configured)}`,
|
|
203
|
+
...formatDailyFlows(data.asynchronous_automation.daily_flow_executions),
|
|
204
|
+
'',
|
|
205
|
+
'3. SCHEDULED WORK',
|
|
206
|
+
` Scheduled scripts configured: ${formatCount(data.scheduled_work.scheduled_scripts_configured)}`,
|
|
207
|
+
` Scheduler transactions: ${formatCount(data.scheduled_work.scheduler_transactions)}`,
|
|
208
|
+
` Average scheduler transaction time: ${formatDuration(data.scheduled_work.scheduler_transaction_time.avg_ms)}`,
|
|
209
|
+
` Maximum scheduler transaction time: ${formatDuration(data.scheduled_work.scheduler_transaction_time.max_ms)}`,
|
|
210
|
+
` Import sets: ${formatCount(data.scheduled_work.import_sets)}`,
|
|
211
|
+
` Import rows: ${formatCount(data.scheduled_work.import_rows)}`,
|
|
212
|
+
...formatDailyImports(data.scheduled_work.daily_import_activity),
|
|
213
|
+
'',
|
|
214
|
+
'4. QUERY AND SECURITY CONTROLS',
|
|
215
|
+
...formatMetricRows(data.query_and_security_controls),
|
|
216
|
+
'',
|
|
217
|
+
'5. DATA VOLUME',
|
|
218
|
+
...formatMetricRows(data.data_volume),
|
|
219
|
+
'',
|
|
220
|
+
'6. TRANSACTION DETAIL',
|
|
221
|
+
...formatTransactions(data.transactions),
|
|
222
|
+
];
|
|
223
|
+
return `${lines.join('\n')}\n`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function summarizeTransactions(groups) {
|
|
227
|
+
return groups.map(group => {
|
|
228
|
+
const stats = group?.stats || {};
|
|
229
|
+
return {
|
|
230
|
+
type: groupField(group, 'type'),
|
|
231
|
+
count: groupCount(group),
|
|
232
|
+
avg_response_time_ms: metricValue(stats, 'avg', 'response_time'),
|
|
233
|
+
max_response_time_ms: metricValue(stats, 'max', 'response_time'),
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function stateRows(groups) {
|
|
239
|
+
return groups.map(group => ({ state: groupField(group, 'state'), count: groupCount(group) }));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function datesFor(argv) {
|
|
243
|
+
return argv.daily === false ? [] : buildDateList(argv.since, argv.until);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function instanceCmd(wrap) {
|
|
247
|
+
return {
|
|
248
|
+
command: 'instance [subcommand]',
|
|
249
|
+
aliases: ['overview'],
|
|
250
|
+
describe: 'Inspect the current ServiceNow instance',
|
|
251
|
+
builder: y => y.command({
|
|
252
|
+
command: 'glance',
|
|
253
|
+
aliases: ['at-a-glance', 'summary'],
|
|
254
|
+
describe: 'Show a readable instance health and activity report',
|
|
255
|
+
builder: yargs => yargs
|
|
256
|
+
.option('query', { type: 'string', describe: 'Encoded query for activity metrics' })
|
|
257
|
+
.option('since', { type: 'string', describe: 'Activity start date (YYYY-MM-DD)' })
|
|
258
|
+
.option('until', { type: 'string', describe: 'Activity end date (YYYY-MM-DD)' })
|
|
259
|
+
.option('daily', { type: 'boolean', default: true, describe: 'Include daily flow and import rows when dates are supplied' }),
|
|
260
|
+
handler: wrap(async (argv, app) => {
|
|
261
|
+
app.requireInstance();
|
|
262
|
+
const query = buildActivityQuery(argv);
|
|
263
|
+
const dailyDates = datesFor(argv);
|
|
264
|
+
const schedulerQuery = addQuery(query, 'urlLIKEscheduler');
|
|
265
|
+
const [
|
|
266
|
+
dataVolume,
|
|
267
|
+
configured,
|
|
268
|
+
flowTotal,
|
|
269
|
+
flowStates,
|
|
270
|
+
transactions,
|
|
271
|
+
restTransactions,
|
|
272
|
+
tableApi,
|
|
273
|
+
graphql,
|
|
274
|
+
inboundEmail,
|
|
275
|
+
soap,
|
|
276
|
+
schedulerTransactions,
|
|
277
|
+
schedulerStats,
|
|
278
|
+
asyncBusinessRules,
|
|
279
|
+
beforeQueryRules,
|
|
280
|
+
scriptedAcls,
|
|
281
|
+
readAcls,
|
|
282
|
+
writeAcls,
|
|
283
|
+
deleteAcls,
|
|
284
|
+
createAcls,
|
|
285
|
+
executeAcls,
|
|
286
|
+
importSets,
|
|
287
|
+
importRows,
|
|
288
|
+
dailyFlows,
|
|
289
|
+
dailyImportSets,
|
|
290
|
+
dailyImportRows,
|
|
291
|
+
] = await Promise.all([
|
|
292
|
+
mapWithConcurrency(DATA_VOLUME, DATA_VOLUME_CONCURRENCY, ([label, table]) => safeCount(app.sdk, label, table, '', { timeout: DATA_VOLUME_TIMEOUT })),
|
|
293
|
+
Promise.all(CONFIGURED_AUTOMATION.map(([label, table, tableQuery]) => safeCount(app.sdk, label, table, tableQuery))),
|
|
294
|
+
safeCount(app.sdk, 'flow_executions', 'sys_flow_context', query),
|
|
295
|
+
safeAggregate(app.sdk, 'flow_states', 'sys_flow_context', { query, groupBy: ['state'], count: true }),
|
|
296
|
+
safeAggregate(app.sdk, 'transactions', 'syslog_transaction', { query, groupBy: ['type'], count: true, averageFields: ['response_time'], maximumFields: ['response_time'] }),
|
|
297
|
+
safeCount(app.sdk, 'REST transactions', 'syslog_transaction', addQuery(query, 'type=rest')),
|
|
298
|
+
safeCount(app.sdk, 'Table API URL matches', 'syslog_transaction', addQuery(query, 'urlLIKE/api/now/table/')),
|
|
299
|
+
safeCount(app.sdk, 'GraphQL URL matches', 'syslog_transaction', addQuery(query, 'urlLIKEgraphql')),
|
|
300
|
+
safeCount(app.sdk, 'Inbound email records', 'sys_email', addQuery(query, 'type=received')),
|
|
301
|
+
safeCount(app.sdk, 'SOAP URL matches', 'syslog_transaction', addQuery(query, 'urlLIKEsoap')),
|
|
302
|
+
safeCount(app.sdk, 'Scheduler transactions', 'syslog_transaction', schedulerQuery),
|
|
303
|
+
safeAggregate(app.sdk, 'scheduler_transaction_time', 'syslog_transaction', { query: schedulerQuery, averageFields: ['response_time'], maximumFields: ['response_time'] }),
|
|
304
|
+
safeCount(app.sdk, 'async_business_rules_configured', 'sys_script', 'when=async'),
|
|
305
|
+
safeCount(app.sdk, 'Scripted before-query business rules', 'sys_script', 'when=before^action_query=true'),
|
|
306
|
+
safeCount(app.sdk, 'Scripted access controls', 'sys_security_acl', 'scriptISNOTEMPTY'),
|
|
307
|
+
safeCount(app.sdk, 'Scripted read ACLs', 'sys_security_acl', 'operation=read^scriptISNOTEMPTY'),
|
|
308
|
+
safeCount(app.sdk, 'Scripted write ACLs', 'sys_security_acl', 'operation=write^scriptISNOTEMPTY'),
|
|
309
|
+
safeCount(app.sdk, 'Scripted delete ACLs', 'sys_security_acl', 'operation=delete^scriptISNOTEMPTY'),
|
|
310
|
+
safeCount(app.sdk, 'Scripted create ACLs', 'sys_security_acl', 'operation=create^scriptISNOTEMPTY'),
|
|
311
|
+
safeCount(app.sdk, 'Scripted execute ACLs', 'sys_security_acl', 'operation=execute^scriptISNOTEMPTY'),
|
|
312
|
+
safeCount(app.sdk, 'import_sets', 'sys_import_set', query),
|
|
313
|
+
safeCount(app.sdk, 'import_rows', 'sys_import_set_row', query),
|
|
314
|
+
Promise.all(dailyDates.map(date => safeCount(app.sdk, 'flow_executions', 'sys_flow_context', buildActivityQuery({ since: date, until: date })))),
|
|
315
|
+
Promise.all(dailyDates.map(date => safeCount(app.sdk, 'import_sets', 'sys_import_set', buildActivityQuery({ since: date, until: date })))),
|
|
316
|
+
Promise.all(dailyDates.map(date => safeCount(app.sdk, 'import_rows', 'sys_import_set_row', buildActivityQuery({ since: date, until: date })))),
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
const inboundMetrics = [restTransactions, tableApi, graphql, inboundEmail, soap];
|
|
320
|
+
const data = {
|
|
321
|
+
window: { query, since: argv.since || null, until: argv.until || null },
|
|
322
|
+
inbound_automation: {
|
|
323
|
+
metrics: inboundMetrics,
|
|
324
|
+
configured: configured.slice(0, 4),
|
|
325
|
+
},
|
|
326
|
+
asynchronous_automation: {
|
|
327
|
+
total_count: flowTotal.count,
|
|
328
|
+
states: stateRows(flowStates.groups),
|
|
329
|
+
async_business_rules_configured: asyncBusinessRules.count,
|
|
330
|
+
daily_flow_executions: dailyDates.map((date, index) => ({ date, count: dailyFlows[index].count })),
|
|
331
|
+
},
|
|
332
|
+
scheduled_work: {
|
|
333
|
+
scheduled_scripts_configured: configured.find(row => row.label === 'Scheduled scripts')?.count ?? null,
|
|
334
|
+
scheduler_transactions: schedulerTransactions.count,
|
|
335
|
+
scheduler_transaction_time: safeStatsResult(schedulerStats),
|
|
336
|
+
import_sets: importSets.count,
|
|
337
|
+
import_rows: importRows.count,
|
|
338
|
+
daily_import_activity: dailyDates.map((date, index) => ({ date, import_sets: dailyImportSets[index].count, import_rows: dailyImportRows[index].count })),
|
|
339
|
+
},
|
|
340
|
+
query_and_security_controls: [beforeQueryRules, scriptedAcls, readAcls, writeAcls, deleteAcls, createAcls, executeAcls],
|
|
341
|
+
data_volume: dataVolume,
|
|
342
|
+
transactions: {
|
|
343
|
+
total_count: transactions.groups.reduce((total, group) => total + groupCount(group), 0),
|
|
344
|
+
types: summarizeTransactions(transactions.groups),
|
|
345
|
+
...(transactions.unavailable ? { unavailable: transactions.unavailable } : {}),
|
|
346
|
+
},
|
|
347
|
+
context: { instance_url: app.getEffectiveInstance(), timezone: 'UTC' },
|
|
348
|
+
};
|
|
349
|
+
app.ok({ ...data, _formatted: formatInstanceGlance(data) }, {
|
|
350
|
+
summary: `Instance glance for ${app.getEffectiveInstance()}`,
|
|
351
|
+
});
|
|
352
|
+
}),
|
|
353
|
+
}),
|
|
354
|
+
handler: wrap(async (_argv, app) => {
|
|
355
|
+
app.ok({
|
|
356
|
+
command: 'instance',
|
|
357
|
+
subcommands: ['glance'],
|
|
358
|
+
_formatted: 'Run "jsn instance glance" for an instance report.\n',
|
|
359
|
+
}, { summary: 'Instance inspection commands' });
|
|
360
|
+
}),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
function numberOrString(value) {
|
|
2
|
+
const n = Number(value);
|
|
3
|
+
return value !== '' && Number.isFinite(n) ? n : value;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function tagText(xml, tag) {
|
|
7
|
+
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
8
|
+
const match = xml.match(new RegExp(`<${escaped}>([^<]*)</${escaped}>`));
|
|
9
|
+
return match ? match[1].trim() : undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function tagAttributes(xml, tag) {
|
|
13
|
+
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
14
|
+
const match = xml.match(new RegExp(`<${escaped}\\b([^>]*)\\/>`));
|
|
15
|
+
if (!match) return undefined;
|
|
16
|
+
const attrs = {};
|
|
17
|
+
for (const [, key, value] of match[1].matchAll(/([\w.-]+)="([^"]*)"/g)) attrs[key] = numberOrString(value);
|
|
18
|
+
return attrs;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function statsForWindow(xml, parent, window) {
|
|
22
|
+
const parentMatch = xml.match(new RegExp(`<${parent}>([\\s\\S]*?)</${parent}>`));
|
|
23
|
+
if (!parentMatch) return undefined;
|
|
24
|
+
return tagAttributes(parentMatch[1], window);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseNodeStatsXml(xml) {
|
|
28
|
+
const source = String(xml || '');
|
|
29
|
+
const result = {};
|
|
30
|
+
const root = source.match(/<xmlstats\b([^>]*)>/);
|
|
31
|
+
const rootAttrs = {};
|
|
32
|
+
for (const [, key, value] of (root?.[1] || '').matchAll(/([\w.-]+)="([^"]*)"/g)) rootAttrs[key] = value;
|
|
33
|
+
if (rootAttrs.created) result.created = rootAttrs.created;
|
|
34
|
+
|
|
35
|
+
const queueLength = tagText(source, 'queue.length');
|
|
36
|
+
const queueAge = tagText(source, 'queue.age');
|
|
37
|
+
if (queueLength !== undefined || queueAge !== undefined) {
|
|
38
|
+
result.queue = {};
|
|
39
|
+
if (queueLength !== undefined) result.queue.length = numberOrString(queueLength);
|
|
40
|
+
if (queueAge !== undefined) result.queue.age = numberOrString(queueAge);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const activeSessions = tagText(source, 'servlet.active.sessions');
|
|
44
|
+
if (activeSessions !== undefined) result.active_sessions = numberOrString(activeSessions);
|
|
45
|
+
const memoryPressure = tagText(source, 'memory_pressure');
|
|
46
|
+
if (memoryPressure !== undefined) result.memory_pressure = memoryPressure;
|
|
47
|
+
|
|
48
|
+
result.semaphores = [];
|
|
49
|
+
for (const match of source.matchAll(/<semaphores\b([^>]*)\/>/g)) {
|
|
50
|
+
const attrs = {};
|
|
51
|
+
for (const [, key, value] of match[1].matchAll(/([\w.-]+)="([^"]*)"/g)) {
|
|
52
|
+
if (['name', 'available', 'borrowed', 'queue_depth', 'queue_depth_limit', 'maximum_concurrency'].includes(key)) attrs[key] = numberOrString(value);
|
|
53
|
+
}
|
|
54
|
+
if (attrs.name) result.semaphores.push(attrs);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const daily = statsForWindow(source, 'all_transactions', 'daily');
|
|
58
|
+
if (daily) {
|
|
59
|
+
result.transactions = { daily: {} };
|
|
60
|
+
for (const key of ['count', 'mean', 'median', 'ninetypercent', 'max']) {
|
|
61
|
+
if (daily[key] !== undefined) result.transactions.daily[key] = daily[key];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function displayValue(value) {
|
|
68
|
+
if (value && typeof value === 'object') return value.display_value ?? value.value ?? '';
|
|
69
|
+
return value ?? '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function valueOf(value) {
|
|
73
|
+
return value && typeof value === 'object' ? value.value : value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function selectClusterRows(rows, nodeId, allNodes) {
|
|
77
|
+
if (!nodeId || allNodes) return rows;
|
|
78
|
+
return rows.filter(row => valueOf(row.sys_id) === nodeId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatHealth(nodes) {
|
|
82
|
+
const lines = ['NODE STATUS TYPE QUEUE AGE SESSIONS DEFAULT SEM DB'];
|
|
83
|
+
lines.push('----------- --------------- ----- --- -------- ----------- --');
|
|
84
|
+
for (const node of nodes) {
|
|
85
|
+
const sem = node.stats.semaphores.find(s => s.name === 'Default');
|
|
86
|
+
const db = node.stats.transactions?.daily;
|
|
87
|
+
lines.push(`${String(displayValue(node.status) || '?').padEnd(11)} ${String(displayValue(node.node_type) || '?').padEnd(15)} ${String(node.stats.queue?.length ?? '-').padStart(5)} ${String(node.stats.queue?.age ?? '-').padStart(3)} ${String(node.stats.active_sessions ?? '-').padStart(8)} ${sem ? `${sem.borrowed}/${sem.maximum_concurrency}`.padStart(11) : '-'.padStart(11)} ${db ? `${db.mean} ms` : '-'}`);
|
|
88
|
+
}
|
|
89
|
+
if (nodes.length === 0) lines.push('(no nodes)');
|
|
90
|
+
return `${lines.join('\n')}\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function platformCmd(wrap) {
|
|
94
|
+
return {
|
|
95
|
+
command: 'platform',
|
|
96
|
+
describe: 'Inspect read-only platform health and node statistics',
|
|
97
|
+
builder: (y) => y.command({
|
|
98
|
+
command: 'health',
|
|
99
|
+
describe: 'Show cluster, queue, semaphore, and node health',
|
|
100
|
+
builder: (y) => y
|
|
101
|
+
.option('node', {
|
|
102
|
+
type: 'string',
|
|
103
|
+
describe: 'Inspect one sys_cluster_state record by sys_id',
|
|
104
|
+
})
|
|
105
|
+
.option('all-nodes', {
|
|
106
|
+
type: 'boolean',
|
|
107
|
+
default: false,
|
|
108
|
+
describe: 'Explicitly inspect every cluster node (the default)',
|
|
109
|
+
}),
|
|
110
|
+
handler: wrap(async (argv, app) => {
|
|
111
|
+
app.requireInstance();
|
|
112
|
+
const cluster = await app.sdk.list('sys_cluster_state', {
|
|
113
|
+
sysparm_fields: 'sys_id,status,node_type,node_stats',
|
|
114
|
+
sysparm_limit: '100',
|
|
115
|
+
});
|
|
116
|
+
const nodes = [];
|
|
117
|
+
for (const row of selectClusterRows(cluster, argv.node, argv.allNodes)) {
|
|
118
|
+
const id = typeof row.node_stats === 'object' ? row.node_stats.value : row.node_stats;
|
|
119
|
+
if (!id) continue;
|
|
120
|
+
const stats = await app.sdk.get('sys_cluster_node_stats', id);
|
|
121
|
+
nodes.push({ sys_id: valueOf(row.sys_id), status: row.status, node_type: row.node_type, stats: parseNodeStatsXml(stats?.stats || '') });
|
|
122
|
+
}
|
|
123
|
+
app.ok({ table: 'sys_cluster_state', node_count: nodes.length, nodes, _formatted: formatHealth(nodes), security: { raw_xml: 'excluded', sensitive_fields: 'excluded' }, context: { instance_url: app.getEffectiveInstance() } }, { summary: `Platform health: ${nodes.length} node(s)` });
|
|
124
|
+
}),
|
|
125
|
+
}),
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/commands/records.js
CHANGED
|
@@ -351,6 +351,50 @@ export function recordsCmd(wrap) {
|
|
|
351
351
|
})
|
|
352
352
|
.demandCommand(1, 'Specify an attachment action: list, get, or add'),
|
|
353
353
|
})
|
|
354
|
+
.command({
|
|
355
|
+
command: 'aggregate',
|
|
356
|
+
describe: 'Run ServiceNow Stats API aggregation with optional grouping',
|
|
357
|
+
builder: (y) => y
|
|
358
|
+
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
359
|
+
.option('query', { type: 'string', describe: 'Encoded query to constrain the aggregation' })
|
|
360
|
+
.option('group-by', { type: 'string', describe: 'Comma-separated fields to group by' })
|
|
361
|
+
.option('count', { type: 'boolean', default: true, describe: 'Include record counts' })
|
|
362
|
+
.option('avg-fields', { type: 'string', describe: 'Comma-separated fields to average' })
|
|
363
|
+
.option('sum-fields', { type: 'string', describe: 'Comma-separated fields to sum' })
|
|
364
|
+
.option('min-fields', { type: 'string', describe: 'Comma-separated fields for minimum values' })
|
|
365
|
+
.option('max-fields', { type: 'string', describe: 'Comma-separated fields for maximum values' })
|
|
366
|
+
.option('order-by', { type: 'string', describe: 'Comma-separated fields to order by' }),
|
|
367
|
+
handler: wrap(async (argv, app) => {
|
|
368
|
+
app.requireInstance();
|
|
369
|
+
const split = (value) => value ? value.split(',').map(v => v.trim()).filter(Boolean) : [];
|
|
370
|
+
const options = {
|
|
371
|
+
query: argv.query || '',
|
|
372
|
+
groupBy: split(argv['group-by']),
|
|
373
|
+
count: argv.count !== false,
|
|
374
|
+
averageFields: split(argv['avg-fields']),
|
|
375
|
+
sumFields: split(argv['sum-fields']),
|
|
376
|
+
minimumFields: split(argv['min-fields']),
|
|
377
|
+
maximumFields: split(argv['max-fields']),
|
|
378
|
+
orderBy: split(argv['order-by']),
|
|
379
|
+
};
|
|
380
|
+
const result = await app.sdk.aggregate(argv.table, options);
|
|
381
|
+
app.ok({
|
|
382
|
+
table: argv.table,
|
|
383
|
+
query: options.query,
|
|
384
|
+
group_by: options.groupBy,
|
|
385
|
+
aggregate: {
|
|
386
|
+
count: options.count,
|
|
387
|
+
average_fields: options.averageFields,
|
|
388
|
+
sum_fields: options.sumFields,
|
|
389
|
+
minimum_fields: options.minimumFields,
|
|
390
|
+
maximum_fields: options.maximumFields,
|
|
391
|
+
order_by: options.orderBy,
|
|
392
|
+
},
|
|
393
|
+
...result,
|
|
394
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
395
|
+
}, { summary: `Aggregated ${argv.table}${options.groupBy.length ? ` by ${options.groupBy.join(', ')}` : ''}` });
|
|
396
|
+
}),
|
|
397
|
+
})
|
|
354
398
|
.command({
|
|
355
399
|
command: 'bulk',
|
|
356
400
|
describe: 'Bulk-update records matching a query. Dry-run by default — pass --execute to commit',
|
|
@@ -480,7 +524,8 @@ export function recordsCmd(wrap) {
|
|
|
480
524
|
console.log(' create Create a record');
|
|
481
525
|
console.log(' update Update a record');
|
|
482
526
|
console.log(' delete Delete a record');
|
|
483
|
-
console.log('
|
|
527
|
+
console.log(' aggregate Run Stats API aggregation with optional grouping');
|
|
528
|
+
console.log(' inspect Inspect table schema and statistics');
|
|
484
529
|
console.log('');
|
|
485
530
|
console.log('Run "jsn records <command> --help" for details.');
|
|
486
531
|
},
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export function normalizeTransactionQuery(query) {
|
|
2
|
+
const raw = String(query || '').trim();
|
|
3
|
+
if (!raw) return 'sys_created_on>=javascript:gs.daysAgoStart(1)';
|
|
4
|
+
return raw.replace(/(^|\^)sys_created_on=(\d{4}-\d{2}-\d{2})(?=\^|$)/g, (_, prefix, date) => {
|
|
5
|
+
const nextDay = new Date(`${date}T00:00:00Z`);
|
|
6
|
+
nextDay.setUTCDate(nextDay.getUTCDate() + 1);
|
|
7
|
+
const next = nextDay.toISOString().slice(0, 10);
|
|
8
|
+
return `${prefix}sys_created_on>=${date}^sys_created_on<${next}`;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function displayMetric(value) {
|
|
13
|
+
if (value == null) return '-';
|
|
14
|
+
const rounded = Math.round(value * 100) / 100;
|
|
15
|
+
return `${rounded} ms`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatTransactionTable(types) {
|
|
19
|
+
const lines = [
|
|
20
|
+
'CLASS TYPE COUNT AVG MIN MAX',
|
|
21
|
+
'---------- ------------- ----- -------- -------- --------',
|
|
22
|
+
];
|
|
23
|
+
for (const row of types) {
|
|
24
|
+
const value = (metric) => displayMetric(row[metric]);
|
|
25
|
+
lines.push(`${row.class.padEnd(10)} ${row.type.padEnd(13)} ${String(row.count).padStart(5)} ${value('avg_response_time_ms').padEnd(8)} ${value('min_response_time_ms').padEnd(8)} ${value('max_response_time_ms')}`);
|
|
26
|
+
}
|
|
27
|
+
if (types.length === 0) lines.push('(no transactions)');
|
|
28
|
+
return `${lines.join('\n')}\n`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function classifyTransactionType(type, url = '') {
|
|
32
|
+
const normalized = String(type || '').toLowerCase();
|
|
33
|
+
const path = String(url || '').toLowerCase();
|
|
34
|
+
if (normalized === 'scheduler' || path.startsWith('job:')) return 'job';
|
|
35
|
+
if (['rest', 'batch_rest'].includes(normalized)) return 'api';
|
|
36
|
+
if (['form', 'list'].includes(normalized)) return 'ui';
|
|
37
|
+
if (['export', 'instance_scan'].includes(normalized)) return 'background';
|
|
38
|
+
return 'unknown';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function numericValue(value) {
|
|
42
|
+
const n = Number(String(value ?? '').replaceAll(',', ''));
|
|
43
|
+
return Number.isFinite(n) ? n : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function groupValue(group, field) {
|
|
47
|
+
return group?.groupby_fields?.find(item => item?.field === field)?.value || '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function metricValue(stats, metric, field) {
|
|
51
|
+
const value = stats?.[metric];
|
|
52
|
+
return typeof value === 'object' ? numericValue(value[field]) : numericValue(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function summarizeGroup(group) {
|
|
56
|
+
const type = groupValue(group, 'type') || 'unknown';
|
|
57
|
+
const stats = group?.stats || {};
|
|
58
|
+
return {
|
|
59
|
+
type,
|
|
60
|
+
class: classifyTransactionType(type),
|
|
61
|
+
count: numericValue(stats.count) ?? 0,
|
|
62
|
+
avg_response_time_ms: metricValue(stats, 'avg', 'response_time'),
|
|
63
|
+
min_response_time_ms: metricValue(stats, 'min', 'response_time'),
|
|
64
|
+
max_response_time_ms: metricValue(stats, 'max', 'response_time'),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function transactionsCmd(wrap) {
|
|
69
|
+
return {
|
|
70
|
+
command: 'transactions',
|
|
71
|
+
describe: 'Summarize syslog_transaction response times by transaction type',
|
|
72
|
+
builder: (y) => y
|
|
73
|
+
.option('query', { type: 'string', describe: 'Encoded query; date-only sys_created_on equality expands to the full day' })
|
|
74
|
+
.option('order-by', { type: 'string', describe: 'Stats API order-by fields' }),
|
|
75
|
+
handler: wrap(async (argv, app) => {
|
|
76
|
+
app.requireInstance();
|
|
77
|
+
const query = normalizeTransactionQuery(argv.query);
|
|
78
|
+
const result = await app.sdk.aggregate('syslog_transaction', {
|
|
79
|
+
query,
|
|
80
|
+
groupBy: ['type'],
|
|
81
|
+
count: true,
|
|
82
|
+
averageFields: ['response_time'],
|
|
83
|
+
minimumFields: ['response_time'],
|
|
84
|
+
maximumFields: ['response_time'],
|
|
85
|
+
orderBy: argv['order-by'] || '',
|
|
86
|
+
});
|
|
87
|
+
const groups = Array.isArray(result.groups) ? result.groups : [];
|
|
88
|
+
const types = groups.map(summarizeGroup);
|
|
89
|
+
app.ok({
|
|
90
|
+
table: 'syslog_transaction',
|
|
91
|
+
query,
|
|
92
|
+
group_by: ['type'],
|
|
93
|
+
response_time_unit: 'ms',
|
|
94
|
+
types,
|
|
95
|
+
_formatted: formatTransactionTable(types),
|
|
96
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
97
|
+
}, { summary: `Transactions by type: ${types.length} type(s)` });
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
}
|