@pixlcore/xycli 1.0.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.
Files changed (63) hide show
  1. package/README.md +543 -0
  2. package/docs/help.md +2587 -0
  3. package/docs/screenshots.md +219 -0
  4. package/docs/sync.md +920 -0
  5. package/docs/tutorial.md +204 -0
  6. package/index.js +444 -0
  7. package/lib/alerts.js +735 -0
  8. package/lib/apikey.js +425 -0
  9. package/lib/buckets.js +607 -0
  10. package/lib/categories.js +323 -0
  11. package/lib/channels.js +290 -0
  12. package/lib/config.js +86 -0
  13. package/lib/dashboard.js +237 -0
  14. package/lib/doc.js +131 -0
  15. package/lib/events.js +1314 -0
  16. package/lib/groups.js +1038 -0
  17. package/lib/jobs.js +1254 -0
  18. package/lib/log.js +125 -0
  19. package/lib/marketplace.js +386 -0
  20. package/lib/monitors.js +412 -0
  21. package/lib/plugins.js +491 -0
  22. package/lib/secrets.js +441 -0
  23. package/lib/servers.js +1502 -0
  24. package/lib/snapshots.js +498 -0
  25. package/lib/sync.js +963 -0
  26. package/lib/system.js +612 -0
  27. package/lib/tags.js +250 -0
  28. package/lib/tickets.js +681 -0
  29. package/lib/transfer.js +447 -0
  30. package/lib/utils.js +2745 -0
  31. package/lib/webhook.js +414 -0
  32. package/logo.webp +0 -0
  33. package/package.json +38 -0
  34. package/test/README.md +97 -0
  35. package/test/api-validation.test.js +170 -0
  36. package/test/categories.test.js +246 -0
  37. package/test/channels.test.js +209 -0
  38. package/test/config.test.js +96 -0
  39. package/test/doc.test.js +84 -0
  40. package/test/groups.test.js +211 -0
  41. package/test/help.test.js +26 -0
  42. package/test/helpers/common.js +127 -0
  43. package/test/helpers/pagination-preload.cjs +18 -0
  44. package/test/log.test.js +103 -0
  45. package/test/markdown.test.js +61 -0
  46. package/test/marketplace.test.js +229 -0
  47. package/test/monitors.test.js +227 -0
  48. package/test/pagination.test.js +138 -0
  49. package/test/plugins.test.js +351 -0
  50. package/test/secrets.test.js +271 -0
  51. package/test/servers.test.js +303 -0
  52. package/test/snapshots.test.js +167 -0
  53. package/test/sync-unit.test.js +446 -0
  54. package/test/sync.test.js +542 -0
  55. package/test/system.test.js +94 -0
  56. package/test/tags.test.js +230 -0
  57. package/test/tickets.test.js +261 -0
  58. package/test/toast.test.js +73 -0
  59. package/test/transfer-apikey.test.js +96 -0
  60. package/test/transfer-workflow.test.js +122 -0
  61. package/test/transfer.test.js +314 -0
  62. package/test/webhook.test.js +332 -0
  63. package/test.js +49 -0
package/lib/utils.js ADDED
@@ -0,0 +1,2745 @@
1
+ // Utils
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const cp = require('child_process');
6
+ const Path = require('path');
7
+ const { PassThrough } = require('stream');
8
+
9
+ const { convert: convertHTMLToText } = require('html-to-text');
10
+ const { listLanguages: listHighlightLanguages } = require('cli-highlight');
11
+ const { marked } = require('marked');
12
+ const { markedTerminal } = require('marked-terminal');
13
+
14
+ const HIGHLIGHT_LANGUAGES = listHighlightLanguages();
15
+ const MARKDOWN_PADDING = 1;
16
+
17
+ const terminalRenderer = markedTerminal({
18
+ emoji: false,
19
+ showSectionPrefix: false,
20
+ reflowText: true,
21
+ tab: 2,
22
+ // Reserve one column on each side so rendered Markdown lines up with the
23
+ // rest of the CLI without allowing reflowed text to touch the right edge.
24
+ width: Math.max(20, Math.min(process.stdout.columns || 80, 120) - (MARKDOWN_PADDING * 2))
25
+ }, {
26
+ ignoreIllegals: true,
27
+ languageSubset: ['json', 'javascript', 'bash', 'shell']
28
+ });
29
+
30
+ // marked-terminal hardcodes an asterisk as its unordered-list marker. Wait
31
+ // until the outermost list is complete before swapping those markers, because
32
+ // its parent renderer still needs the original asterisks to identify and lay
33
+ // out nested lists correctly.
34
+ const renderTerminalList = terminalRenderer.renderer.list;
35
+ let terminalListDepth = 0;
36
+ terminalRenderer.renderer.list = function(...args) {
37
+ terminalListDepth++;
38
+ try {
39
+ var output = renderTerminalList.apply(this, args);
40
+ if (terminalListDepth == 1) output = output.replace(/^([ \t]*)\* /gm, '$1• ');
41
+ return output;
42
+ }
43
+ finally {
44
+ terminalListDepth--;
45
+ }
46
+ };
47
+
48
+ marked.use(terminalRenderer);
49
+
50
+ // render explicitly labeled plain-text blocks in gray
51
+ marked.use({
52
+ renderer: {
53
+ text(token) {
54
+ // Marked stores inline formatting inside child tokens for list-item text.
55
+ // marked-terminal 7.3.0 returns only token.text here, which exposes the
56
+ // original **strong** and `code` markup instead of rendering it. Parse
57
+ // those children ourselves, then fall through for ordinary text tokens.
58
+ if (token && Array.isArray(token.tokens)) {
59
+ return this.parser.parseInline(token.tokens);
60
+ }
61
+
62
+ return false;
63
+ },
64
+
65
+ code(token) {
66
+ if (token.lang === 'sh') {
67
+ var disp_value = String(token.text).trim().split(/\n/).map( line => ' ' + line ).join("\n");
68
+ return chalk.cyan(disp_value) + '\n\n';
69
+ }
70
+
71
+ // use marked-terminal for all other code blocks
72
+ return false;
73
+ }
74
+ }
75
+ });
76
+
77
+ const cli = require('pixl-cli');
78
+ const Tools = cli.Tools;
79
+ const chalk = cli.chalk;
80
+
81
+ const DEFAULT_CACHE_TTL = 300;
82
+ const DATE_FMT = '[yyyy]/[mm]/[dd] [hour12]:[mi] [AMPM]';
83
+ const DATE_FMT_SEC = '[yyyy]/[mm]/[dd] [hour12]:[mi]:[ss] [AMPM]';
84
+ const TIME_FMT = '[hour12]:[mi] [AMPM]';
85
+ const TIME_FMT_SEC = '[hour12]:[mi]:[ss] [AMPM]';
86
+
87
+ // CRUD APIs deliberately accept arbitrary top-level properties, which is useful
88
+ // for direct API integrations but dangerous for hand-typed CLI commands. These
89
+ // rules describe the final, normalized request objects that this CLI supports.
90
+ // Rule names match the real snake_case API endpoints and are regular expressions
91
+ // so create and update operations can share one property list.
92
+ const STANDARD_API_PROPERTY_RULES = {
93
+ '^(create_alert|update_alert)$': [
94
+ 'id', 'title', 'enabled', 'icon', 'expression', 'message', 'groups',
95
+ 'actions', 'monitor_id', 'samples', 'exclusive_actions', 'limit_jobs',
96
+ 'abort_jobs', 'notes', 'username', 'modified', 'created', 'revision'
97
+ ],
98
+ '^(create_api_key|update_api_key)$': [
99
+ 'id', 'key', 'mask', 'active', 'privileges', 'roles', 'title', 'icon',
100
+ 'description', 'expires', 'max_per_sec', 'username', 'modified',
101
+ 'created', 'revision'
102
+ ],
103
+ '^(create_bucket|update_bucket)$': [
104
+ 'id', 'title', 'enabled', 'icon', 'notes', 'username', 'modified',
105
+ 'created', 'revision', 'data'
106
+ ],
107
+ '^(create_category|update_category)$': [
108
+ 'id', 'title', 'enabled', 'icon', 'color', 'notes', 'actions', 'limits',
109
+ 'sort_order', 'username', 'modified', 'created', 'revision'
110
+ ],
111
+ '^(create_channel|update_channel)$': [
112
+ 'id', 'title', 'enabled', 'icon', 'users', 'email', 'web_hook',
113
+ 'run_event', 'sound', 'max_per_day', 'notes', 'username', 'modified',
114
+ 'created', 'revision'
115
+ ],
116
+ '^(create_event|update_event)$': [
117
+ 'id', 'title', 'enabled', 'icon', 'type', 'category', 'plugin', 'params',
118
+ 'fields', 'tags', 'targets', 'expression', 'algo', 'notes', 'actions',
119
+ 'limits', 'triggers', 'workflow', 'username', 'modified', 'created',
120
+ 'revision', 'update_state'
121
+ ],
122
+ '^(create_group|update_group)$': [
123
+ 'id', 'title', 'hostname_match', 'icon', 'alert_actions', 'notes',
124
+ 'max_jobs_per_server'
125
+ ],
126
+ '^(create_monitor|update_monitor)$': [
127
+ 'id', 'title', 'display', 'icon', 'groups', 'source', 'data_match',
128
+ 'data_type', 'min_vert_scale', 'suffix', 'delta', 'divide_by_delta',
129
+ 'delta_min_value', 'notes', 'sort_order', 'username', 'modified',
130
+ 'created', 'revision'
131
+ ],
132
+ '^(create_plugin|update_plugin)$': [
133
+ 'id', 'title', 'enabled', 'icon', 'type', 'command', 'script', 'params',
134
+ 'groups', 'format', 'uid', 'gid', 'kill', 'runner', 'quick', 'stock',
135
+ 'notes', 'marketplace', 'username', 'modified', 'created', 'revision'
136
+ ],
137
+ '^(create_secret|update_secret)$': [
138
+ 'id', 'title', 'enabled', 'icon', 'notes', 'events', 'categories',
139
+ 'plugins', 'web_hooks', 'fields'
140
+ ],
141
+ '^update_server$': [
142
+ 'id', 'title', 'enabled', 'icon', 'groups', 'autoGroup', 'maxJobs', 'userData'
143
+ ],
144
+ '^(create_tag|update_tag)$': [
145
+ 'id', 'title', 'icon', 'notes', 'username', 'modified', 'created', 'revision'
146
+ ],
147
+ '^(create_ticket|update_ticket)$': [
148
+ 'id', 'num', 'subject', 'body', 'type', 'status', 'category', 'server',
149
+ 'assignees', 'cc', 'notify', 'due', 'tags', 'events', 'files', 'changes',
150
+ 'username', 'modified', 'created', 'template', 'job', 'alert'
151
+ ],
152
+ '^(create_web_hook|update_web_hook)$': [
153
+ 'id', 'title', 'enabled', 'icon', 'url', 'method', 'headers', 'body',
154
+ 'timeout', 'retries', 'follow', 'ssl_cert_bypass', 'max_per_day', 'notes',
155
+ 'username', 'modified', 'created', 'revision'
156
+ ]
157
+ };
158
+
159
+ module.exports = {
160
+
161
+ async getMultiple(req) {
162
+ // only call once per run
163
+ if (this.gotMultiple) return;
164
+
165
+ if (!req) req = { lists: 'all', jobs: 1, alerts: 1, state: 1, stats: 1, servers: 1, serverCache: 1 };
166
+ var { err, data } = await this.api.getMultiple(req);
167
+ if (err) this.die(err);
168
+
169
+ ["api_keys", "groups", "plugins", "categories", "events", "channels", "web_hooks", "buckets", "secrets", "monitors", "alerts", "tags", "roles"].forEach( key => {
170
+ this[key] = data[key] || [];
171
+ } );
172
+
173
+ if (!this.invisible) {
174
+ for (var job_id in data.activeJobs) {
175
+ var job = data.activeJobs[job_id];
176
+ if (job.invisible) delete data.activeJobs[job_id];
177
+ }
178
+ }
179
+
180
+ this.activeJobs = data.activeJobs || {};
181
+ this.internalJobs = data.internalJobs || {};
182
+ this.jobRateLimits = data.jobRateLimits || {};
183
+ this.activeAlerts = data.activeAlerts || {};
184
+ this.state = data.state || {};
185
+ this.stats = data.stats || {};
186
+ this.servers = data.servers || {};
187
+ this.serverCache = data.serverCache || {};
188
+ this.epoch = data.epoch || Tools.timeNow();
189
+ this.gotMultiple = true;
190
+ },
191
+
192
+ async confirmSyncUpdate(type, id, thing) {
193
+ // Up-only filesystem syncs are authoritative for their managed objects.
194
+ // The composite type-ID key matches the state written by the sync engine.
195
+ await this.getMultiple();
196
+
197
+ if (('confirm' in this.args) && (typeof(this.args.confirm) != 'boolean')) {
198
+ return this.die("Update --confirm must be true or false.");
199
+ }
200
+
201
+ var confirmed = (this.args.confirm === true);
202
+ delete this.args.confirm;
203
+
204
+ var state_key = type + '-' + id;
205
+ if (!this.state || !this.state.sync || !this.state.sync[state_key]) return true;
206
+ if (confirmed) return true;
207
+
208
+ this.toast('⚠️', 'yellow', "This " + thing + " is under remote management, and should not be edited here. Add '--confirm' to continue.");
209
+ return false;
210
+ },
211
+
212
+ color(name) {
213
+ return this.colors[name] ? chalk.rgb(...this.colors[name]) : chalk.gray;
214
+ },
215
+
216
+ getNiceEvent(event) {
217
+ if (!event) return gray('(None)');
218
+ if (typeof(event) == 'string') {
219
+ event = Tools.findObject( this.events, { id: event } ) || { id: event, title: event };
220
+ }
221
+
222
+ if (!event.enabled) return this.raw ? gray(event.id) : gray(event.title);
223
+
224
+ var category = Tools.findObject( this.categories, { id: event.category } );
225
+ if (category && category.color && (category.color != 'plain')) {
226
+ return this.raw ? gray(event.id) : this.color(category.color).bold(event.title);
227
+ }
228
+
229
+ return this.raw ? gray(event.id) : event.title;
230
+ },
231
+
232
+ getNiceCategory(category) {
233
+ if (!category) return gray('(None)');
234
+ if (typeof(category) == 'string') {
235
+ category = Tools.findObject( this.categories, { id: category } ) || { id: category, title: category };
236
+ }
237
+
238
+ if (this.raw) return gray(category.id);
239
+ else if (category.color && (category.color != 'plain')) return this.color(category.color).bold(category.title);
240
+ else return bold(category.title);
241
+ },
242
+
243
+ getNicePlugin(plugin) {
244
+ if (!plugin) return gray('(None)');
245
+ if (plugin === '_workflow') return '(Workflow)';
246
+ if (typeof(plugin) == 'string') {
247
+ plugin = Tools.findObject( this.plugins, { id: plugin } ) || { id: plugin, title: plugin };
248
+ }
249
+ return this.raw ? gray(plugin.id) : plugin.title;
250
+ },
251
+
252
+ getLangFromBinary(bin) {
253
+ // Guess a cli-highlight language from an executable path and arguments.
254
+ // This mirrors the xyOps UI helper, while avoiding slow source auto-detection
255
+ // for common interpreters.
256
+ if ((typeof(bin) != 'string') || !bin.trim()) return null;
257
+ var cmd = Path.basename( bin.trim() ).replace(/\s+.+$/, '').replace(/\d+$/, '').toLowerCase();
258
+
259
+ switch (cmd) {
260
+ case 'sh':
261
+ case 'csh':
262
+ case 'ksh':
263
+ case 'tcsh':
264
+ case 'fish':
265
+ case 'zsh':
266
+ case 'bash':
267
+ cmd = 'shell';
268
+ break;
269
+
270
+ case 'node':
271
+ case 'deno':
272
+ case 'bun':
273
+ cmd = 'javascript';
274
+ break;
275
+
276
+ case 'pwsh':
277
+ cmd = 'powershell';
278
+ break;
279
+ }
280
+
281
+ return HIGHLIGHT_LANGUAGES.includes(cmd) ? cmd : null;
282
+ },
283
+
284
+ getNiceGroup(group) {
285
+ if (!group) return gray('(None)');
286
+ if (typeof(group) == 'string') {
287
+ group = Tools.findObject( this.groups, { id: group } ) || { id: group, title: group };
288
+ }
289
+ return this.raw ? gray(group.id) : group.title;
290
+ },
291
+
292
+ getNiceServer(server) {
293
+ if (!server) return gray('(None)');
294
+ if (typeof(server) == 'string') {
295
+ server = this.servers[server] || this.serverCache[server] || { id: server, hostname: server };
296
+ }
297
+ return this.raw ? gray(server.id) : (server.title || server.hostname);
298
+ },
299
+
300
+ getNiceTarget(target) {
301
+ // a target can be a group ID, or a server ID
302
+ if (!target) return gray('(None)');
303
+ if (Tools.findObject(this.groups, { id: target })) return this.getNiceGroup(target);
304
+ else return this.getNiceServer(target);
305
+ },
306
+
307
+ getNiceTargets(targets) {
308
+ // CSV list of targets
309
+ if (!targets || !targets.length) return gray('(None)');
310
+ return targets.map( this.getNiceTarget.bind(this) ).join(', ');
311
+ },
312
+
313
+ getNiceTag(tag) {
314
+ if (!tag) return gray('(None)');
315
+ if (typeof(tag) == 'string') {
316
+ tag = Tools.findObject( this.tags, { id: tag } ) || { id: tag, title: tag };
317
+ }
318
+ return this.raw ? gray(tag.id) : tag.title;
319
+ },
320
+
321
+ getNiceTagList(list) {
322
+ if (!list || !list.length) return gray('(None)');
323
+ list = list.filter( function(tag) { return !tag.match(/^_/); } ); // filter out system tags
324
+ if (!list.length) return '(None)';
325
+ return list.map( tag => this.getNiceTag(tag) ).join(', ');
326
+ },
327
+
328
+ getNiceAlgo(algo) {
329
+ if (!algo) return gray('(None)');
330
+ var def = Tools.findObject( this.config.ui.event_target_algo_menu, { id: algo } );
331
+ return def ? def.title : algo;
332
+ },
333
+
334
+ getNiceAlert(alert) {
335
+ if (!alert) return gray('(None)');
336
+ if (typeof(alert) == 'string') {
337
+ alert = Tools.findObject( this.alerts, { id: alert } ) || { id: alert, title: alert };
338
+ }
339
+ return this.raw ? gray(alert.id) : alert.title;
340
+ },
341
+
342
+ getNiceAlertStatus(alert) {
343
+ return alert.active ? `${cli.emoji('🚨')} ${red('Active')}` : `${gray('Cleared')}`;
344
+ },
345
+
346
+ getNiceAPIKeyStatus(api_key) {
347
+ // API Keys may be enabled but expired, so represent that as a distinct state.
348
+ if (!api_key.active) return red('Disabled');
349
+ if (api_key.expires && (this.epoch >= api_key.expires)) return yellow('Expired');
350
+ return green('Active');
351
+ },
352
+
353
+ getNicePrivileges(privileges) {
354
+ // Convert the privilege hash into the same friendly labels used by the UI.
355
+ var priv_list = (this.config.ui && this.config.ui.privilege_list) || [];
356
+ var ids = Object.keys(privileges || {}).filter( id => !!privileges[id] );
357
+ if (!ids.length) return gray('(None)');
358
+
359
+ return ids.map( function(id) {
360
+ var def = Tools.findObject(priv_list, { id: id });
361
+ return def ? def.title : id;
362
+ } ).join(', ');
363
+ },
364
+
365
+ getNiceRoles(roles) {
366
+ // Role IDs are safe to display, even if a referenced role was later deleted.
367
+ if (!roles || !roles.length) return gray('(None)');
368
+ return roles.map( id => {
369
+ var role = Tools.findObject(this.roles || [], { id: id });
370
+ return role ? role.title + gray(' (' + id + ')') : id;
371
+ } ).join(', ');
372
+ },
373
+
374
+ sanitizeAPIKey(api_key) {
375
+ // Never expose the stored salted hash through the friendly key commands.
376
+ // plain_key is only added explicitly by the create command at display time.
377
+ var clean = Tools.copyHash(api_key || {}, true);
378
+ delete clean.key;
379
+ delete clean.plain_key;
380
+ return clean;
381
+ },
382
+
383
+ getNiceDateTime(epoch, secs, ago) {
384
+ var str = Tools.formatDate(epoch, secs ? DATE_FMT_SEC : DATE_FMT);
385
+ if (ago) str += gray(` (${this.getTextFromSecondsShort(Math.max(0, this.epoch - epoch))} ago)`);
386
+ return str;
387
+ },
388
+
389
+ getNiceTime(epoch, secs, ago) {
390
+ var str = Tools.formatDate(epoch, secs ? TIME_FMT_SEC : TIME_FMT);
391
+ if (ago) str += gray(` (${this.getTextFromSecondsShort(Math.max(0, this.epoch - epoch))} ago)`);
392
+ return str;
393
+ },
394
+
395
+ getRelativeDateTime(epoch, secs) {
396
+ var dargs = Tools.getDateArgs(epoch);
397
+ var nargs = Tools.getDateArgs( this.epoch );
398
+
399
+ if (nargs.yyyy_mm_dd == dargs.yyyy_mm_dd) {
400
+ // today
401
+ return 'Today at ' + this.getNiceTime(epoch, secs);
402
+ }
403
+ else {
404
+ // some other day
405
+ return this.getNiceDateTime(epoch, secs);
406
+ }
407
+ },
408
+
409
+ getDateOptions(opts = {}) {
410
+ // get combined date/time options with user locale settings
411
+ var user = this.config;
412
+ var ropts = Intl.DateTimeFormat().resolvedOptions();
413
+ var [lang, reg] = ropts.locale.split(/\-/);
414
+ if (!reg) reg = lang.toUpperCase();
415
+
416
+ lang = user.language || lang;
417
+ reg = user.region || reg;
418
+
419
+ if (opts.locale === false) delete opts.locale;
420
+ else if (!opts.locale) opts.locale = lang + '-' + reg;
421
+
422
+ if (opts.timeZone === false) delete opts.timeZone;
423
+ else if (!opts.timeZone) opts.timeZone = user.timezone || ropts.timeZone;
424
+
425
+ if (opts.numberingSystem === false) delete opts.numberingSystem;
426
+ else if (!opts.numberingSystem) opts.numberingSystem = user.num_format || ropts.numberingSystem;
427
+
428
+ if (opts.hourCycle === false) delete opts.hourCycle;
429
+ else if (!opts.hourCycle) opts.hourCycle = user.hour_cycle || 'h12';
430
+
431
+ if (!opts.second) delete opts.second;
432
+ else {
433
+ // has seconds -- does user want milliseconds too?
434
+ if (user.milliseconds) opts.fractionalSecondDigits = 3;
435
+ else delete opts.fractionalSecondDigits;
436
+ }
437
+
438
+ return opts;
439
+ },
440
+
441
+ getNiceEnabled(enabled) {
442
+ return enabled ? `${cli.emoji('✅')} ${green('Enabled')}` : `${cli.emoji('🚫')} ${gray('Disabled')}`;
443
+ },
444
+
445
+ getNiceWebHook(hook) {
446
+ if (!hook) return gray('(None)');
447
+ if (typeof(hook) == 'string') {
448
+ hook = Tools.findObject( this.web_hooks, { id: hook } ) || { id: hook, title: hook };
449
+ }
450
+ return this.raw ? gray(hook.id) : hook.title;
451
+ },
452
+
453
+ getNiceChannel(channel) {
454
+ if (!channel) return gray('(None)');
455
+ if (typeof(channel) == 'string') {
456
+ channel = Tools.findObject( this.channels, { id: channel } ) || { id: channel, title: channel };
457
+ }
458
+ return this.raw ? gray(channel.id) : channel.title;
459
+ },
460
+
461
+ getNiceBucket(bucket) {
462
+ if (!bucket) return gray('(None)');
463
+ if (typeof(bucket) == 'string') {
464
+ bucket = Tools.findObject( this.buckets, { id: bucket } ) || { id: bucket, title: bucket };
465
+ }
466
+ return this.raw ? gray(bucket.id) : bucket.title;
467
+ },
468
+
469
+ getNiceJob(job) {
470
+ // get formatted job id
471
+ if (!job) return '(None)';
472
+ if (typeof(job) == 'string') {
473
+ if (this.activeJobs[job]) job = this.activeJobs[job];
474
+ else job = { id: job };
475
+ }
476
+
477
+ var nice_id = job.id;
478
+ if (job.label && ((job.type != 'adhoc') || ('plabel' in job))) {
479
+ nice_id = job.label + gray(' (' + job.id + ')');
480
+ }
481
+
482
+ var prefix = '';
483
+ if (job.workflow && job.workflow.job) prefix = gray.bold('➥ ');
484
+
485
+ var icon = '⏱️';
486
+ if (job.suspended) icon = '⏸️';
487
+ else if (job.invisible) icon = '🫥';
488
+ else if (job.type == 'workflow') icon = '📋';
489
+
490
+ return prefix + cli.emoji(icon) + ' ' + nice_id;
491
+ },
492
+
493
+ getNiceJobElapsedTime(job, abbrev, no_secondary) {
494
+ // render nice elapsed time display
495
+ var now = Math.floor( job.completed || this.epoch );
496
+ var elapsed = job.elapsed || Math.max( 0, now - Math.floor(job.started) ) || 0;
497
+ return Tools.getTextFromSeconds( elapsed, abbrev, no_secondary );
498
+ },
499
+
500
+ getNiceJobRemainingTime(job, abbrev) {
501
+ // get nice job remaining time, using elapsed and progress
502
+ var now = this.epoch;
503
+ var elapsed = Math.floor( Math.max( 0, now - job.started ) );
504
+ var progress = job.progress || 0;
505
+ if ((elapsed >= 10) && (progress > 0) && (progress < 1.0)) {
506
+ var sec_remain = Math.floor(((1.0 - progress) * elapsed) / progress);
507
+ return Tools.getTextFromSeconds( sec_remain, abbrev, true );
508
+ }
509
+ else return 'n/a';
510
+ },
511
+
512
+ getNiceJobState(job) {
513
+ // get nice job state given job
514
+ // (states: queued, start_delay, retry_delay, ready, starting, active, finishing, complete)
515
+ var nice_state = ucfirst(job.state || 'unknown');
516
+ var now = this.epoch;
517
+
518
+ switch (job.state) {
519
+ case 'queued':
520
+ if (job.position && job.priority) nice_state += ` (#${job.position} - Priority)`;
521
+ else if (job.position) nice_state += ` (#${job.position})`;
522
+ else if (job.priority) nice_state += ` (Priority)`;
523
+ break;
524
+
525
+ case 'start_delay':
526
+ nice_state = 'Start Delay';
527
+ if (job.until && (job.until > now)) nice_state += ' (' + Tools.getTextFromSeconds(job.until - now, true, true) + ')';
528
+ break;
529
+
530
+ case 'retry_delay':
531
+ nice_state = 'Retry Delay';
532
+ if (job.until && (job.until > now)) nice_state += ' (' + Tools.getTextFromSeconds(job.until - now, true, true) + ')';
533
+ break;
534
+ }
535
+
536
+ // special case: If job is complete but not final, it's actually still finishing
537
+ if ((job.state == 'complete') && !job.final) { nice_state = 'Finishing'; }
538
+
539
+ // special case for suspended job
540
+ if (job.suspended && !job.final) { nice_state = 'Suspended'; }
541
+
542
+ return nice_state;
543
+ },
544
+
545
+ getNiceJobEvent(job) {
546
+ // get nice event formatted as running job, supporting workflows etc.
547
+ if (job.type == 'adhoc') {
548
+ var plugin = Tools.findObject( this.plugins, { id: job.plugin } );
549
+ if (!plugin) return 'n/a'; // sanity
550
+
551
+ var title = '';
552
+
553
+ if ('plabel' in job) {
554
+ // new
555
+ title = job.plabel || plugin.title;
556
+ }
557
+ else {
558
+ // legacy
559
+ title = job.label || plugin.title;
560
+ }
561
+
562
+ return title;
563
+ }
564
+ else if ('plabel' in job) {
565
+ return job.plabel || this.getNiceEvent(job.event);
566
+ }
567
+ else return this.getNiceEvent(job.event);
568
+ },
569
+
570
+ getNiceJobSource(job) {
571
+ return Tools.ucfirst(job.source);
572
+ },
573
+
574
+ getJobResultArgs(job) {
575
+ var color = '';
576
+ var text = ucfirst( '' + job.code );
577
+ var icon = '';
578
+
579
+ if (!job.final) {
580
+ color = 'blue';
581
+ text = 'In Progress';
582
+ icon = '🔄';
583
+ }
584
+ else if (job.replay) {
585
+ color = 'cyan';
586
+ text = 'Replay';
587
+ icon = '⏮️';
588
+ }
589
+ else if (!job.code) {
590
+ color = 'green';
591
+ text = 'Success';
592
+ icon = '✅';
593
+ }
594
+ else if (job.retried) {
595
+ color = 'orange';
596
+ text = 'Retried';
597
+ icon = '✴️';
598
+ }
599
+ else switch(job.code) {
600
+ case 'warning': color = 'yellow'; icon = '⚠️'; break;
601
+ case 'critical': color = 'purple'; icon = '☢️'; break;
602
+ case 'abort': color = 'gray'; text = 'Aborted'; icon = '🚫'; break;
603
+
604
+ default:
605
+ color = 'red';
606
+ text = 'Error';
607
+ icon = '🛑';
608
+ break;
609
+ }
610
+
611
+ return { color, text, icon };
612
+ },
613
+
614
+ getNiceJobResult(job) {
615
+ // color label + icon for job result
616
+ var args = this.getJobResultArgs(job);
617
+ return cli.emoji(args.icon) + ' ' + this.color(args.color).bold(args.text);
618
+ },
619
+
620
+ getJobActionDisplayArgs(action, link) {
621
+ // get display args for job action
622
+ // returns: { condition, type, text, desc }
623
+ var disp = Tools.copyHash({
624
+ condition: Tools.findObject( this.config.ui.action_condition_menu, { id: action.condition } ) ||
625
+ Tools.findObject( this.config.ui.alert_action_condition_menu, { id: action.condition } )
626
+ }, true);
627
+
628
+ if (!disp.condition && action.condition.match(/^tag:(\w+)$/)) {
629
+ var tag_id = RegExp.$1;
630
+ var tag = Tools.findObject( this.tags, { id: tag_id } ) || { title: tag_id };
631
+ disp.condition = { id: action.condition, title: "On " + tag.title };
632
+ }
633
+ else if (!disp.condition && (action.condition == 'instant')) {
634
+ disp.condition = { id: action.condition, title: "Instant" };
635
+ }
636
+
637
+ disp.condition.color = this.condition_colors[action.condition] || 'gray';
638
+
639
+ disp.note = action.source ? `(Inherited from ${action.source})` : '';
640
+
641
+ switch (action.type) {
642
+ case 'email':
643
+ disp.type = "Send Email";
644
+ var parts = [];
645
+ if (action.users && action.users.length) parts.push( '' + Tools.commify(action.users.length) + ' ' + Tools.pluralize('user', action.users.length) );
646
+ if (action.email) parts.push( action.email );
647
+ if (!parts.length) parts = [ '(None)' ];
648
+ disp.text = disp.desc = parts.join(', ');
649
+ break;
650
+
651
+ case 'web_hook':
652
+ disp.type = "Web Hook";
653
+ var web_hook = Tools.findObject( this.web_hooks, { id: action.web_hook } );
654
+ disp.text = web_hook ? web_hook.title : "(Web Hook not found)";
655
+ disp.desc = this.getNiceWebHook(action.web_hook);
656
+ break;
657
+
658
+ case 'run_event':
659
+ disp.type = "Run Event";
660
+ var event = Tools.findObject( this.events, { id: action.event_id } );
661
+ disp.text = event ? event.title : "(Event not found)";
662
+ disp.desc = this.getNiceEvent(event);
663
+ break;
664
+
665
+ case 'channel':
666
+ disp.type = "Notify Channel";
667
+ var channel = Tools.findObject( this.channels, { id: action.channel_id } );
668
+ disp.text = channel ? channel.title : "(Channel not found)";
669
+ disp.desc = this.getNiceChannel(channel, link);
670
+ break;
671
+
672
+ case 'snapshot':
673
+ disp.type = "Take Snapshot";
674
+ disp.text = disp.desc = "(Current Server)";
675
+ break;
676
+
677
+ case 'store':
678
+ disp.type = "Store Bucket";
679
+ var bucket = Tools.findObject( this.buckets, { id: action.bucket_id } );
680
+ disp.text = bucket ? bucket.title : "(Bucket not found)";
681
+ disp.desc = this.getNiceBucket(bucket, link);
682
+ break;
683
+
684
+ case 'fetch':
685
+ disp.type = "Fetch Bucket";
686
+ var bucket = Tools.findObject( this.buckets, { id: action.bucket_id } );
687
+ disp.text = bucket ? bucket.title : "(Bucket not found)";
688
+ disp.desc = this.getNiceBucket(bucket, link);
689
+ break;
690
+
691
+ case 'ticket':
692
+ disp.type = 'Create Ticket';
693
+ var ticket_type = Tools.findObject( this.config.ui.ticket_types, { id: action.ticket_type } );
694
+ disp.text = ticket_type.title;
695
+ disp.desc = disp.text;
696
+ break;
697
+
698
+ case 'suspend':
699
+ disp.type = "Suspend Job";
700
+ var label = "";
701
+ if ((action.users && action.users.length) || action.email.length) label = "Send Email";
702
+ if (action.web_hook) label += (label.length ? ', ' : '') + "Web Hook";
703
+ disp.text = disp.desc = label || 'n/a';
704
+ break;
705
+
706
+ case 'tag':
707
+ disp.type = "Apply Tags";
708
+ disp.text = this.getNiceTagList(action.tags);
709
+ disp.desc = disp.text;
710
+ break;
711
+
712
+ case 'label':
713
+ disp.type = "Apply Label";
714
+ disp.text = '"' + action.label + '"';
715
+ disp.desc = disp.text;
716
+ break;
717
+
718
+ case 'disable':
719
+ disp.type = "Disable Event";
720
+ disp.text = disp.desc = "(Current Event)";
721
+ disp.icon = 'cancel';
722
+ break;
723
+
724
+ case 'delete':
725
+ disp.type = "Delete Event";
726
+ disp.text = disp.desc = "(Current Event)";
727
+ disp.icon = 'trash-can-outline';
728
+ break;
729
+
730
+ case 'plugin':
731
+ disp.type = "Plugin";
732
+ var plugin = Tools.findObject( this.plugins, { id: action.plugin_id, type: 'action' } );
733
+ disp.text = plugin ? plugin.title : "(Plugin not found)";
734
+ disp.desc = this.getNicePlugin(plugin, link);
735
+ disp.icon = 'power-plug';
736
+ break;
737
+
738
+ } // switch item.type
739
+
740
+ return disp;
741
+ },
742
+
743
+ getResLimitDisplayArgs(item) {
744
+ // get nice title and description for resource limit
745
+ var nice_title = '';
746
+ var nice_desc = '';
747
+ var short_desc = '';
748
+ var note = item.source ? `(Inherited from ${item.source})` : '';
749
+
750
+ switch (item.type) {
751
+ case 'mem':
752
+ nice_title = "Max Memory";
753
+ nice_desc = Tools.getTextFromBytes(item.amount) + " for " + Tools.getTextFromSeconds(item.duration, false, true);
754
+ short_desc = Tools.getTextFromBytes(item.amount);
755
+ break;
756
+
757
+ case 'cpu':
758
+ nice_title = "Max CPU %";
759
+ nice_desc = item.amount + "% for " + Tools.getTextFromSeconds(item.duration, false, true);
760
+ short_desc = item.amount + '%';
761
+ break;
762
+
763
+ case 'log':
764
+ nice_title = "Max Log Size";
765
+ nice_desc = short_desc = Tools.getTextFromBytes(item.amount);
766
+ break;
767
+
768
+ case 'time':
769
+ nice_title = "Max Run Time";
770
+ nice_desc = Tools.getTextFromSeconds(item.duration, false, false);
771
+ short_desc = Tools.getTextFromSeconds(item.duration, true, false);
772
+ break;
773
+
774
+ case 'job':
775
+ nice_title = "Max Jobs";
776
+ if (!item.amount) nice_desc = short_desc = "Unlimited";
777
+ else {
778
+ nice_desc = "Up to " + Tools.commify(item.amount) + " concurrent " + Tools.pluralize("job", item.amount);
779
+ short_desc = Tools.commify(item.amount) + ' ' + Tools.pluralize("job", item.amount);
780
+ }
781
+ break;
782
+
783
+ case 'retry':
784
+ nice_title = "Max Retries";
785
+ if (!item.amount) {
786
+ nice_desc = "No retries will be attempted";
787
+ short_desc = "None";
788
+ }
789
+ else {
790
+ nice_desc = short_desc = "Up to " + Tools.commify(item.amount);
791
+ if (item.duration) nice_desc += " (" + Tools.getTextFromSeconds(item.duration, false, true) + " delay)";
792
+ }
793
+ break;
794
+
795
+ case 'queue':
796
+ nice_title = "Max Queue";
797
+ if (!item.amount) {
798
+ nice_desc = "No jobs allowed in queue";
799
+ short_desc = "None";
800
+ }
801
+ else {
802
+ nice_desc = "Up to " + Tools.commify(item.amount) + " " + Tools.pluralize("job", item.amount) + " allowed in queue";
803
+ short_desc = Tools.commify(item.amount) + ' ' + Tools.pluralize("job", item.amount);
804
+ }
805
+ break;
806
+
807
+ case 'file':
808
+ nice_title = "Max Files";
809
+ if (!item.amount) {
810
+ nice_desc = "No files allowed";
811
+ short_desc = "None";
812
+ }
813
+ else {
814
+ nice_desc = "Up to " + Tools.commify(item.amount) + " " + Tools.pluralize("file", item.amount);
815
+ if (item.size) nice_desc += " (" + Tools.getTextFromBytes(item.size) + " total)";
816
+
817
+ short_desc = Tools.commify(item.amount) + ' ' + Tools.pluralize("file", item.amount);
818
+ if (item.size) short_desc += ", " + Tools.getTextFromBytes(item.size);
819
+ }
820
+ break;
821
+
822
+ case 'day':
823
+ nice_title = "Max Daily";
824
+ nice_desc = "Up to " + Tools.commify(item.amount) + " &ldquo;" + item.condition + "&rdquo; " + Tools.pluralize("condition", item.amount);
825
+ short_desc = Tools.commify(item.amount) + " x " + item.condition;
826
+ break;
827
+
828
+ case 'tag':
829
+ nice_title = "Max Tags";
830
+ if (!item.amount) {
831
+ nice_desc = "No tags allowed";
832
+ short_desc = "None";
833
+ }
834
+ else {
835
+ nice_desc = "Up to " + Tools.commify(item.amount) + " " + Tools.pluralize("tag", item.amount);
836
+ short_desc = Tools.commify(item.amount) + ' ' + Tools.pluralize("tag", item.amount);
837
+ }
838
+ break;
839
+ } // switch item.type
840
+
841
+ return { nice_title, nice_desc, short_desc, note };
842
+ },
843
+
844
+ setPath: function(target, path, value) {
845
+ // set path using dir/slash/syntax or dot.path.syntax
846
+ // support inline dots and slashes if backslash-escaped
847
+ // implement strict array indexing
848
+ var parts = (path.indexOf("\\") > -1) ? path.replace(/\\\./g, '__PXDOT__').replace(/\\\//g, '__PXSLASH__').split(/[\.\/]/).map( function(elem) {
849
+ return elem.replace(/__PXDOT__/g, '.').replace(/__PXSLASH__/g, '/');
850
+ } ) : path.split(/[\.\/]/);
851
+
852
+ var key = parts.pop();
853
+ if (key.match(Tools.MATCH_BAD_KEY)) return false;
854
+
855
+ // traverse path
856
+ while (parts.length) {
857
+ var part = parts.shift();
858
+ if (part) {
859
+ if (part.match(Tools.MATCH_BAD_KEY)) {
860
+ // do not traverse into any bad keys
861
+ return false;
862
+ }
863
+ if (Array.isArray(target)) {
864
+ if (!part.match(/^(0|[1-9]\d*)$/)) return false;
865
+ if (!(parseInt(part) in target)) return false;
866
+ }
867
+ if (!(part in target)) {
868
+ // auto-create nodes
869
+ target[part] = {};
870
+ }
871
+ if (!target[part] || (typeof(target[part]) != 'object')) {
872
+ // path runs into null or a non-object
873
+ return false;
874
+ }
875
+ target = target[part];
876
+ }
877
+ }
878
+
879
+ if (Array.isArray(target)) {
880
+ if (!key.match(/^(0|[1-9]\d*)$/)) return false;
881
+ if (!(parseInt(key) in target)) return false;
882
+ }
883
+
884
+ target[key] = value;
885
+ return true;
886
+ },
887
+
888
+ deletePath: function(target, path) {
889
+ // Delete an object property using dir/slash/syntax or dot.path.syntax.
890
+ // This intentionally tightens pixl-tools' implementation for untrusted CLI
891
+ // paths, just as our local setPath() does for strict array traversal.
892
+ if (!target || (typeof(target) != 'object') || (typeof(path) != 'string') || !path.length) return false;
893
+
894
+ // Support literal dots and slashes when they are backslash-escaped.
895
+ var parts = (path.indexOf("\\") > -1) ? path.replace(/\\\./g, '__PXDOT__').replace(/\\\//g, '__PXSLASH__').split(/[\.\/]/).map( function(elem) {
896
+ return elem.replace(/__PXDOT__/g, '.').replace(/__PXSLASH__/g, '/');
897
+ } ) : path.split(/[\.\/]/);
898
+
899
+ var key = parts.pop();
900
+ if (!key || key.match(Tools.MATCH_BAD_KEY)) return false;
901
+
902
+ // Traverse existing containers only. Arrays may be crossed using a strict,
903
+ // existing numeric index, but they are never modified by this helper.
904
+ while (parts.length) {
905
+ var part = parts.shift();
906
+ if (!part) continue;
907
+ if (part.match(Tools.MATCH_BAD_KEY)) return false;
908
+
909
+ if (Array.isArray(target) && !part.match(/^(0|[1-9]\d*)$/)) return false;
910
+ if (!Object.prototype.hasOwnProperty.call(target, part)) return false;
911
+ if (!target[part] || (typeof(target[part]) != 'object')) return false;
912
+ target = target[part];
913
+ }
914
+
915
+ // Deleting an array index would leave a sparse hole. The final container
916
+ // must be an object with the requested property as its own key.
917
+ if (Array.isArray(target)) return false;
918
+ if (!Object.prototype.hasOwnProperty.call(target, key)) return false;
919
+ delete target[key];
920
+ return true;
921
+ },
922
+
923
+ mergeDotArgs(target, source) {
924
+ // merge dot paths into target cleanly
925
+ // target and source may be the same object
926
+ for (var key in source) {
927
+ if (key.match(/\./)) {
928
+ // resolve dotted paths
929
+ if (!this.setPath(target, key, source[key])) {
930
+ this.die("Invalid argument path: " + key);
931
+ }
932
+
933
+ // remove the original dotted key when operating in-place
934
+ if (target === source) delete target[key];
935
+ }
936
+ else if (target !== source) {
937
+ // copy ordinary top-level arguments
938
+ target[key] = source[key];
939
+ }
940
+ }
941
+ },
942
+
943
+ applyDeleteArgs(target, source) {
944
+ // Apply one or more repeated `--delete PATH` options to an update object.
945
+ // The caller should merge ordinary updates first so explicit deletes win.
946
+ if (!Object.prototype.hasOwnProperty.call(source, 'delete')) return;
947
+ var paths = Tools.alwaysArray(source.delete);
948
+
949
+ // `delete` is a CLI instruction, not part of the outgoing API request.
950
+ delete source.delete;
951
+ if (target !== source) delete target.delete;
952
+
953
+ paths.forEach( path => {
954
+ path = String(path);
955
+
956
+ // xyOps update APIs shallow-merge top-level properties. Require a nested
957
+ // path so deleting the leaf and sending its parent has real server effect.
958
+ var path_parts = path.replace(/\\\./g, '').replace(/\\\//g, '').split(/[\.\/]/).filter( part => !!part );
959
+ if (path_parts.length < 2) {
960
+ this.die("Delete path must identify a nested object property: " + path);
961
+ }
962
+ if (!this.deletePath(target, path)) {
963
+ this.die("Invalid or missing delete path: " + path);
964
+ }
965
+ });
966
+ },
967
+
968
+ jsonOutput(data) {
969
+ // output formatted or compact json
970
+ if (this.format && this.format.match(/jsonc/)) {
971
+ print("\n");
972
+ process.stdout.write( JSON.stringify(data) + "\n" );
973
+ }
974
+ else {
975
+ var disp_value = JSON.stringify(data, null, "\t");
976
+
977
+ if (this.format && this.format.match(/json/)) disp_value = String(disp_value).trim() + "\n";
978
+ else disp_value = String(disp_value).trim().split(/\n/).map( line => ' ' + line ).join("\n") + "\n";
979
+
980
+ process.stdout.write( "\n" + gray( this.highlight(disp_value, { language: 'json', ignoreIllegals: true }) ) );
981
+ }
982
+ },
983
+
984
+ async cacheConfig() {
985
+ // load client config from xyops and cache for a while
986
+ var { err, data } = await this.cacheAPI('config', { format: 'json' }, { cache_ttl: 3600 });
987
+ if (err) {
988
+ println( "\n" + bold.red("Failed to contact xyOps at: ") + this.config.base_url );
989
+ this.die(err);
990
+ }
991
+
992
+ // merge, but do not clobber keys already in local config
993
+ for (var key in data.config) {
994
+ if (!(key in this.config)) this.config[key] = data.config[key];
995
+ }
996
+
997
+ this.masters = data.masters;
998
+ this.xyopsVersion = data.version;
999
+ },
1000
+
1001
+ async cacheAPI(name, req, opts) {
1002
+ // use cached API response, or call origin and update cache
1003
+ if (!opts) opts = {};
1004
+
1005
+ // vary cache filename based on hash of base_url
1006
+ var filename = name + '-' + Tools.digestHex(this.config.base_url, 'sha256', 16) + '.json';
1007
+
1008
+ var cache_file = Path.join( this.tempDir, filename );
1009
+ var ttl = opts.cache_ttl || this.config.cache_ttl || DEFAULT_CACHE_TTL;
1010
+ var stats = null;
1011
+
1012
+ try { stats = fs.statSync(cache_file); }
1013
+ catch (err) { stats = null; }
1014
+
1015
+ if (stats && (stats.mtimeMs / 1000 > Tools.timeNow() - ttl)) {
1016
+ // cache object still fresh
1017
+ return { data: JSON.parse( fs.readFileSync(cache_file, 'utf8') ) };
1018
+ }
1019
+
1020
+ // fetch from origin
1021
+ delete opts.cache_ttl;
1022
+ var res = await this.api[name](req, opts);
1023
+ if (res.err) return res;
1024
+
1025
+ // store in cache
1026
+ Tools.writeFileAtomicSync( cache_file, JSON.stringify(res.data) );
1027
+
1028
+ return res;
1029
+ },
1030
+
1031
+ async callStandardAPI(name, req, opts = {}) {
1032
+ // wrapper around APIs that make a change
1033
+ // indeterminate progress w/text, handle dry run, verbose req/res, etc.
1034
+ this.validateStandardAPIRequest(name, req);
1035
+
1036
+ // Sensitive callers may supply a redacted display copy while the original
1037
+ // request continues through validation and transport unchanged.
1038
+ var filter_request = opts.filterRequest;
1039
+ delete opts.filterRequest;
1040
+
1041
+ if (this.dry || this.verbose) {
1042
+ println( "\n " + this.color('theme').bold( this.dry ? `API REQUEST PREVIEW: ${name}` : `API REQUEST: ${name}` ) );
1043
+ this.jsonOutput(filter_request ? filter_request(req) : req);
1044
+ }
1045
+
1046
+ if (this.dry) return;
1047
+
1048
+ var text = opts.text || '';
1049
+ if (text) text = gray('→ ' + text);
1050
+ delete opts.text;
1051
+
1052
+ // Callers handling one-time secrets can redact the verbose response while
1053
+ // retaining the original response object for their final, controlled output.
1054
+ var filter_response = opts.filterResponse;
1055
+ delete opts.filterResponse;
1056
+
1057
+ cli.progress.start({ amount: 1, pct: false, text: text });
1058
+ var { err, data } = await this.api[name](req, opts);
1059
+ cli.progress.end();
1060
+
1061
+ if (err) {
1062
+ if (opts.throw) throw err;
1063
+ else this.die(err);
1064
+ }
1065
+
1066
+ if (this.verbose) {
1067
+ println( "\n " + this.color('theme').bold("API RESPONSE: " + name) );
1068
+ this.jsonOutput(filter_response ? filter_response(data) : data);
1069
+ }
1070
+
1071
+ return data;
1072
+ },
1073
+
1074
+ compareVersions(left, right) {
1075
+ // Compare semantic version components without adding a dependency. Build
1076
+ // metadata does not affect precedence; prereleases sort below releases.
1077
+ var parse = value => {
1078
+ var match = typeof(value) == 'string' && value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?(?:\+[\w.-]+)?$/);
1079
+ if (!match) this.die("Invalid version: " + value);
1080
+ return match;
1081
+ };
1082
+
1083
+ var a = parse(left), b = parse(right);
1084
+
1085
+ for (var idx = 1; idx <= 3; idx++) {
1086
+ if (Number(a[idx]) != Number(b[idx])) return Number(a[idx]) > Number(b[idx]) ? 1 : -1;
1087
+ }
1088
+
1089
+ if (a[4] === b[4]) return 0;
1090
+ if (!a[4]) return 1;
1091
+ if (!b[4]) return -1;
1092
+
1093
+ var ap = a[4].split('.'), bp = b[4].split('.');
1094
+
1095
+ for (var idx = 0; idx < Math.max(ap.length, bp.length); idx++) {
1096
+ if (ap[idx] === bp[idx]) continue;
1097
+ if (ap[idx] === undefined) return -1;
1098
+ if (bp[idx] === undefined) return 1;
1099
+
1100
+ var an = /^\d+$/.test(ap[idx]), bn = /^\d+$/.test(bp[idx]);
1101
+ if (an && bn) return Number(ap[idx]) > Number(bp[idx]) ? 1 : -1;
1102
+ if (an != bn) return an ? -1 : 1;
1103
+
1104
+ return ap[idx] > bp[idx] ? 1 : -1;
1105
+ }
1106
+
1107
+ return 0;
1108
+ },
1109
+
1110
+ getLevenshteinDistance(left, right) {
1111
+ // Compute edit distance using two rows instead of allocating a full matrix.
1112
+ // CRUD property names are short, but keeping memory linear makes this helper
1113
+ // safe and generally useful for other CLI suggestions in the future.
1114
+ left = String(left);
1115
+ right = String(right);
1116
+ if (left === right) return 0;
1117
+ if (!left.length) return right.length;
1118
+ if (!right.length) return left.length;
1119
+
1120
+ var previous = Array.from({ length: right.length + 1 }, (unused, idx) => idx);
1121
+
1122
+ for (var left_idx = 1; left_idx <= left.length; left_idx++) {
1123
+ var current = [left_idx];
1124
+
1125
+ for (var right_idx = 1; right_idx <= right.length; right_idx++) {
1126
+ var cost = left[left_idx - 1] == right[right_idx - 1] ? 0 : 1;
1127
+ current[right_idx] = Math.min(
1128
+ current[right_idx - 1] + 1,
1129
+ previous[right_idx] + 1,
1130
+ previous[right_idx - 1] + cost
1131
+ );
1132
+ }
1133
+
1134
+ previous = current;
1135
+ }
1136
+
1137
+ return previous[right.length];
1138
+ },
1139
+
1140
+ findClosestString(value, candidates) {
1141
+ // Avoid noisy or misleading guesses. Longer names tolerate a few more
1142
+ // edits, and tied candidates produce no suggestion at all.
1143
+ value = String(value).toLowerCase();
1144
+ var max_distance = Math.max(1, Math.min(3, Math.ceil(value.length / 3)));
1145
+ var best = '';
1146
+ var best_distance = Infinity;
1147
+ var tied = false;
1148
+
1149
+ candidates.forEach( candidate => {
1150
+ var distance = this.getLevenshteinDistance(value, String(candidate).toLowerCase());
1151
+ if (distance < best_distance) {
1152
+ best = candidate;
1153
+ best_distance = distance;
1154
+ tied = false;
1155
+ }
1156
+ else if (distance == best_distance) tied = true;
1157
+ });
1158
+
1159
+ return (!tied && (best_distance <= max_distance)) ? best : '';
1160
+ },
1161
+
1162
+ validateStandardAPIRequest(name, req) {
1163
+ // Convert the SDK's camelCase method name to the documented API endpoint.
1164
+ // APIs without a CRUD property rule retain their normal pass-through behavior.
1165
+ var api_name = String(name).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
1166
+ var pattern = Object.keys(STANDARD_API_PROPERTY_RULES).find( pattern => {
1167
+ return new RegExp(pattern).test(api_name);
1168
+ });
1169
+ if (!pattern || !req || (typeof(req) != 'object') || Array.isArray(req)) return;
1170
+
1171
+ var allowed = STANDARD_API_PROPERTY_RULES[pattern];
1172
+ var unexpected = Object.keys(req).find( key => !allowed.includes(key) );
1173
+ if (!unexpected) return;
1174
+
1175
+ var suggestion = this.findClosestString(unexpected, allowed);
1176
+ var message = 'Unsupported property for ' + api_name + ': "' + unexpected + '".';
1177
+ if (suggestion) message += ' Did you mean "' + suggestion + '"?';
1178
+ this.die(message);
1179
+ },
1180
+
1181
+ findObjectsFuzzy(arr, criteria, crit_count = 0) {
1182
+ // find objects using fuzzy matching (lower-case substring)
1183
+ crit_count = crit_count || Tools.numKeys(criteria);
1184
+
1185
+ return arr.filter( item => {
1186
+ var num_matched = 0;
1187
+ for (var key in criteria) {
1188
+ if (String(item[key]).toLowerCase().includes(String(criteria[key]).toLowerCase())) num_matched++;
1189
+ }
1190
+ return (num_matched >= crit_count);
1191
+ } );
1192
+ },
1193
+
1194
+ findObjectFuzzy(arr, criteria, crit_count = 0) {
1195
+ // find object using fuzzy matching (lower-case substring)
1196
+ crit_count = crit_count || Tools.numKeys(criteria);
1197
+
1198
+ return arr.find( item => {
1199
+ var num_matched = 0;
1200
+ for (var key in criteria) {
1201
+ if (String(item[key]).toLowerCase().includes(String(criteria[key]).toLowerCase())) num_matched++;
1202
+ }
1203
+ return (num_matched >= crit_count);
1204
+ } );
1205
+ },
1206
+
1207
+ includesAny: function(haystack, needles) {
1208
+ // return true if haystack contains any needles, false otherwise
1209
+ for (var idx = 0, len = needles.length; idx < len; idx++) {
1210
+ if (haystack.includes(needles[idx])) return true;
1211
+ }
1212
+ return false;
1213
+ },
1214
+
1215
+ includesAll: function(haystack, needles) {
1216
+ // return true if haystack contains ALL needles, false otherwise
1217
+ for (var idx = 0, len = needles.length; idx < len; idx++) {
1218
+ if (!haystack.includes(needles[idx])) return false;
1219
+ }
1220
+ return true;
1221
+ },
1222
+
1223
+ getTextFromSecondsShort(sec) {
1224
+ if (sec >= 31536000) return '' + Math.floor(sec / 31536000) + 'y';
1225
+ if (sec >= 2592000) return '' + Math.floor(sec / 2592000) + 'mo';
1226
+ if (sec >= 86400) return '' + Math.floor(sec / 86400) + 'd';
1227
+ if (sec >= 3600) return '' + Math.floor(sec / 3600) + 'h';
1228
+ if (sec >= 60) return '' + Math.floor(sec / 60) + 'm';
1229
+ return '' + Math.floor(sec / 1) + 's';
1230
+ },
1231
+
1232
+ toast(emoji, color, text) {
1233
+ // Toasts wrap at the visible terminal width. Redirected output has no TTY
1234
+ // width, so use the same conventional 80-column fallback as other displays.
1235
+ var final_text = cli.emoji(emoji) + ' ' + this.color(color).bold( text );
1236
+
1237
+ // Account for the indent, two borders, and two spaces of padding per side.
1238
+ // cli.box performs word wrapping at this content width and never ellipsizes.
1239
+ var available_width = Math.max( 1, (cli.width() || 80) - 7 );
1240
+ var max_width = Math.min( available_width, cli.stringWidth(final_text) );
1241
+ println( "\n" + cli.box(final_text, { width: max_width, hspace: 2, vspace: 1, indent: 1, styles: [this.color(color)] }) );
1242
+ },
1243
+
1244
+ printMutationSummary(opts) {
1245
+ // Mutation summaries are UX for interactive users. Keep explicit JSON
1246
+ // formats machine-readable by omitting all decorative terminal output.
1247
+ if (this.format && this.format.match(/json/)) return;
1248
+ this.printBoxList(opts);
1249
+ },
1250
+
1251
+ printUpdateData(data) {
1252
+ // Show the parsed CLI input before resource-specific normalization or merging,
1253
+ // so the user can verify exactly how their update arguments were understood.
1254
+ if (this.format && this.format.match(/json/)) return;
1255
+ print( "\n " + this.color('theme').bold('UPDATE DATA') );
1256
+ this.jsonOutput(data || {});
1257
+ },
1258
+
1259
+ printUserNotes(title, notes) {
1260
+ // print user notes (plain text, multi-line)
1261
+ println( "\n " + this.color('theme').bold(title) );
1262
+ if (cli.width()) {
1263
+ println( cli.box(String(notes).trim(), { indent: 1, hspace: 1, width: Math.min( cli.width() - 6, cli.widestLine(notes) ) } ) );
1264
+ }
1265
+ else {
1266
+ println( String(notes).trim() );
1267
+ }
1268
+ },
1269
+
1270
+ table(rows, opts) {
1271
+ // wrapper around cli.table, drop columns based on screen width
1272
+ if (!opts) opts = {};
1273
+ if (!('indent' in opts)) opts.indent = 1;
1274
+ // if (!('autoFit' in opts)) opts.autoFit = true;
1275
+ opts.autoFit = false;
1276
+
1277
+ var width = cli.width();
1278
+ if (!width) return cli.table(rows, opts);
1279
+
1280
+ // chop columns if table is too wide
1281
+ var orig_rows = [ ...rows ];
1282
+ var chopped = false;
1283
+
1284
+ while ((cli.widestLine(cli.table(Tools.copyHash(rows, true), opts)) > width) && rows[0].length) {
1285
+ rows.forEach( function(row) { row.pop(); } );
1286
+ chopped = true;
1287
+ }
1288
+
1289
+ if (!rows[0].length) {
1290
+ // oops, we killed all the rows, add first one back in (will be auto-fit)
1291
+ rows.forEach( function(row, idx) { row.push( orig_rows[idx] ); } );
1292
+ }
1293
+
1294
+ if (chopped) {
1295
+ rows.forEach( function(row) { row.push('...'); } );
1296
+ opts.autoFit = true;
1297
+ }
1298
+
1299
+ return cli.table(rows, opts);
1300
+ },
1301
+
1302
+ printSuggestedCommands(opts) {
1303
+ // print formatted suggested commands for the user to copy & paste
1304
+ if (('suggest' in this.config) && !this.config.suggest) return;
1305
+
1306
+ var cmds = Object.keys(opts).filter( label => !!opts[label] ).map( label => {
1307
+ return [ label, opts[label] ];
1308
+ } );
1309
+ if (!cmds.length) return;
1310
+
1311
+ println("\n " + this.color('theme').bold("Other Commands:") );
1312
+ cmds.forEach( pair => {
1313
+ var [ label, cmd ] = pair;
1314
+ println( " " + this.color('green').bold('• ' + label) + ": " + bold(cmd) );
1315
+ } );
1316
+ },
1317
+
1318
+ printBoxList(opts) {
1319
+ // convenience wrapper around cli.defList with title and colors
1320
+ // args: { title, rows }
1321
+ println( "\n " + this.color('theme').bold( opts.title.toUpperCase() ) + "\n" +
1322
+ cli.defList(opts.rows.filter( row => !!row ), { labelStyles: ['yellow', 'bold'], textStyles: [], indent: 1, gap: 2 }) );
1323
+ },
1324
+
1325
+ printBoxTable(opts) {
1326
+ // convenience wrapper around cli.table with title and colors
1327
+ println( "\n " + this.color('theme').bold( opts.title.toUpperCase() ) + (opts.title_suffix || '') );
1328
+ var rows = [ opts.header ].concat( opts.rows.filter( row => !!row ) );
1329
+ if (rows.length < 2) {
1330
+ println( gray(` No ${opts.title.toLowerCase()} found.`) );
1331
+ return;
1332
+ }
1333
+ println( "" + this.table(rows, {}) );
1334
+ },
1335
+
1336
+ prepSearchArgs() {
1337
+ // Call only from a paginated command or variant, before building filters.
1338
+ // Other commands may use these names as API or resource properties.
1339
+ this.offset = this.args.offset || 0;
1340
+ this.limit = this.args.limit || this.config.items_per_page;
1341
+ if (this.args.page) this.offset = (this.args.page - 1) * this.limit;
1342
+
1343
+ // Keep pagination out of fuzzy criteria and server search query strings.
1344
+ delete this.args.offset;
1345
+ delete this.args.limit;
1346
+ delete this.args.page;
1347
+ },
1348
+
1349
+ printPaginatedBoxTable(opts, callback) {
1350
+ // print paginated box table
1351
+ var results = {
1352
+ limit: opts.limit,
1353
+ offset: opts.offset || 0,
1354
+ total: opts.list.length
1355
+ };
1356
+
1357
+ var num_pages = Math.floor( results.total / results.limit ) + 1;
1358
+ if (results.total % results.limit == 0) num_pages--;
1359
+ var current_page = Math.floor( results.offset / results.limit ) + 1;
1360
+
1361
+ var data_type = opts.data_type || opts.title.toLowerCase();
1362
+ var nice_total = Tools.commify(results.total) + ' items found';
1363
+ if (num_pages > 1) nice_total += ', page ' + Tools.commify(current_page) + ' of ' + Tools.commify(num_pages);
1364
+
1365
+ if (results.total) opts.title_suffix = gray(` (${nice_total})`);
1366
+ opts.rows = opts.rows.map( callback );
1367
+
1368
+ this.printBoxTable(opts);
1369
+
1370
+ if ((current_page == 1) && (current_page < num_pages)) {
1371
+ println( ' ' + gray(`(Add ${cyan.bold('--page 2')} to view the next page.)`) );
1372
+ }
1373
+ },
1374
+
1375
+ printActiveJobs(opts) {
1376
+ // print table of running jobs, with optional criteria
1377
+ if (!opts) opts = {};
1378
+ if (!opts.criteria) opts.criteria = {};
1379
+
1380
+ opts.title = opts.title || 'Active Jobs';
1381
+ opts.header = [ 'Job ID', 'Event', 'Category', 'Server', 'State', 'Elapsed', 'Progress', 'Remaining' ];
1382
+
1383
+ var jobs = opts.rows || Tools.sortBy( Tools.findObjects( Object.values(this.activeJobs), opts.criteria ), 'started', { type: 'number', dir: -1 } );
1384
+ var now = this.epoch;
1385
+
1386
+ opts.rows = jobs.map( job => {
1387
+ return [
1388
+ this.color('theme').bold( this.getNiceJob(job) ),
1389
+ this.getNiceEvent(job.event),
1390
+ this.getNiceCategory(job.category),
1391
+ this.getNiceServer(job.server),
1392
+ this.getNiceJobState(job),
1393
+ Tools.getTextFromSeconds( now - job.started, true, true ),
1394
+ Tools.pct( (job.progress || 0), 1.0, true ),
1395
+ this.getNiceJobRemainingTime(job, true)
1396
+ ];
1397
+ } );
1398
+
1399
+ this.printBoxTable(opts);
1400
+ },
1401
+
1402
+ printInternalJobs(opts) {
1403
+ // print table of internal jobs, with optional criteria
1404
+ if (!opts) opts = {};
1405
+ if (!opts.criteria) opts.criteria = {};
1406
+
1407
+ // hide section entirely if no jobs
1408
+ if (!Tools.firstKey(this.internalJobs)) return;
1409
+
1410
+ opts.title = opts.title || 'Internal Jobs';
1411
+ opts.header = [ 'Job ID', 'Title', 'Type', 'Username', 'Progress', 'Elapsed', 'Remaining' ];
1412
+
1413
+ var jobs = Tools.sortBy( Tools.findObjects( Object.values(this.internalJobs), opts.criteria ), 'started', { type: 'number', dir: -1 } );
1414
+ var now = this.epoch;
1415
+
1416
+ opts.rows = jobs.map( job => {
1417
+ return [
1418
+ this.color('theme').bold(job.id),
1419
+ bold(job.title),
1420
+ job.type,
1421
+ job.username,
1422
+ Tools.pct( (job.progress || 0), 1.0, true ),
1423
+ Tools.getTextFromSeconds( now - job.started, true, true ),
1424
+ this.getNiceJobRemainingTime(job, true)
1425
+ ];
1426
+ } );
1427
+
1428
+ this.printBoxTable(opts);
1429
+ },
1430
+
1431
+ printActiveAlerts(opts) {
1432
+ // print table of running alerts, with optional criteria
1433
+ if (!opts) opts = {};
1434
+ if (!opts.criteria) opts.criteria = {};
1435
+
1436
+ opts.title = opts.title || 'Active Alerts';
1437
+ opts.header = [ "Alert ID", "Title", "Message", "Server", "Status", "Started", "Duration" ];
1438
+
1439
+ var alerts = Tools.sortBy( Tools.findObjects( Object.values(this.activeAlerts), opts.criteria ), 'date', { type: 'number', dir: -1 } );
1440
+ var now = this.epoch;
1441
+
1442
+ opts.rows = alerts.map( alert => {
1443
+ return [
1444
+ cli.emoji('🔔') + ' ' + this.color('theme').bold(alert.id),
1445
+ this.getNiceAlert(alert.alert, false),
1446
+ alert.message,
1447
+ this.getNiceServer(alert.server, false),
1448
+ this.getNiceAlertStatus(alert),
1449
+ this.getNiceDateTime(alert.date),
1450
+ Tools.getTextFromSeconds( now - alert.date, true, true )
1451
+ ];
1452
+ } );
1453
+
1454
+ this.printBoxTable(opts);
1455
+ },
1456
+
1457
+ async printUpcomingJobs(opts) {
1458
+ // predict and print upcoming jobs
1459
+ if (!opts) opts = {};
1460
+
1461
+ cli.progress.start();
1462
+
1463
+ this.upcomingJobs = await new Promise((resolve, reject) => {
1464
+ this.predictUpcomingJobs({
1465
+ events: opts.events || this.events,
1466
+ duration: 86400 * 32,
1467
+ burn: 16,
1468
+ max: 1000,
1469
+ progress: function(amount) { cli.progress.update( amount ); },
1470
+ callback: resolve
1471
+ });
1472
+ }); // promise
1473
+
1474
+ cli.progress.end();
1475
+
1476
+ opts.title = opts.title || 'Upcoming Jobs';
1477
+ opts.header = [ 'Event', 'Category', 'Targets', 'Source', 'Scheduled Time', 'Countdown' ];
1478
+
1479
+ opts.offset = this.offset || 0;
1480
+ opts.limit = this.limit || this.config.items_per_page;
1481
+ opts.rows = this.upcomingJobs.slice( opts.offset, opts.offset + opts.limit );
1482
+ opts.list = { length: this.upcomingJobs.length };
1483
+
1484
+ this.printPaginatedBoxTable( opts, job => {
1485
+ var type_info = Tools.findObject( this.config.ui.event_trigger_type_menu, { id: job.type } ) || { title: "Scheduler", icon: 'update' };
1486
+ var nice_source = type_info.title;
1487
+ var event = Tools.findObject( this.events, { id: job.event } ) || {};
1488
+ var precision = event.triggers ? Tools.findObject(event.triggers, { type: 'precision', enabled: true }) : null;
1489
+ var nice_date_time = '';
1490
+ var nice_countdown = '';
1491
+ var nice_skip = '';
1492
+
1493
+ // take over source if plugin modifier
1494
+ if (job.plugin) {
1495
+ var plugin = Tools.findObject( this.plugins, { id: job.plugin } ) || { title: job.plugin };
1496
+ nice_source = plugin.title;
1497
+ }
1498
+
1499
+ // optionally vary date/time prediction display based on precision or interval
1500
+ var countdown = 0;
1501
+
1502
+ if (precision && precision.seconds && precision.seconds.length) {
1503
+ job.seconds = precision.seconds;
1504
+ }
1505
+
1506
+ if (job.seconds) {
1507
+ // precision or interval job
1508
+ nice_date_time = this.getRelativeDateTime( job.epoch + job.seconds[0], true );
1509
+ if (job.seconds.length > 1) nice_date_time += ' (+' + Math.floor(job.seconds.length - 1) + ')';
1510
+ countdown = Math.max( 60, Math.abs((job.epoch + job.seconds[0]) - app.epoch) );
1511
+ }
1512
+ else {
1513
+ // normal scheduled job
1514
+ nice_date_time = this.getRelativeDateTime( job.epoch );
1515
+ countdown = Math.max( 60, Math.abs(job.epoch - app.epoch) );
1516
+ }
1517
+
1518
+ nice_countdown = get_text_from_seconds_round( countdown, true );
1519
+
1520
+ return [
1521
+ this.color('theme').bold( this.getNiceEvent(job.event, true) ),
1522
+ this.getNiceCategory(event.category),
1523
+ this.getNiceTargets(event.targets),
1524
+ nice_source,
1525
+ nice_date_time,
1526
+ nice_countdown
1527
+ ];
1528
+ } );
1529
+ },
1530
+
1531
+ async printCompletedJobs(opts) {
1532
+ // search for jobs, and print results
1533
+ if (!opts) opts = {};
1534
+
1535
+ var has_ids = Array.isArray(opts.ids);
1536
+ var query = opts.query || '*';
1537
+ var offset = ('offset' in opts) ? opts.offset : this.offset;
1538
+ var limit = opts.limit || this.limit || this.config.items_per_page;
1539
+ var rows = [];
1540
+ var list = { length: 0 };
1541
+
1542
+ if (has_ids) {
1543
+ if (!opts.ids.length) return;
1544
+ cli.progress.start();
1545
+ var { err, data } = await this.api.getJobs({
1546
+ ids: opts.ids
1547
+ });
1548
+ if (err) this.die(err);
1549
+ cli.progress.end();
1550
+
1551
+ var all_rows = data.jobs || [];
1552
+ list = { length: all_rows.length };
1553
+ rows = all_rows.slice(offset, offset + limit);
1554
+ }
1555
+ else {
1556
+ cli.progress.start();
1557
+ var { err, data } = await this.api.searchJobs({
1558
+ query: opts.query || '*',
1559
+ offset: this.offset,
1560
+ limit: this.limit
1561
+ });
1562
+ if (err) this.die(err);
1563
+ rows = data.rows;
1564
+ list = data.list;
1565
+ cli.progress.end();
1566
+ }
1567
+
1568
+ // Embedded detail screens should omit the section when there are no associated tickets.
1569
+ if (opts.hide_empty && !rows.length) return;
1570
+
1571
+ opts.title = opts.title || 'Completed Jobs';
1572
+ opts.header = [ 'Job ID', 'Event', 'Category', 'Server', 'Source', 'Tags', 'Completed', 'Elapsed', 'Result' ];
1573
+
1574
+ opts.offset = this.offset || 0;
1575
+ opts.limit = this.limit || this.config.items_per_page;
1576
+ opts.rows = rows;
1577
+ opts.list = list;
1578
+
1579
+ this.printPaginatedBoxTable( opts, job => {
1580
+ if (!job) return false;
1581
+ return [
1582
+ this.color('theme').bold( this.getNiceJob(job) ),
1583
+ this.getNiceJobEvent(job, true),
1584
+ this.getNiceCategory(job.category, true),
1585
+ this.getNiceServer(job.server, true),
1586
+ this.getNiceJobSource(job),
1587
+ this.getNiceTagList( job.tags, false ),
1588
+ this.getRelativeDateTime( job.completed, true ),
1589
+ this.getNiceJobElapsedTime(job, true, true),
1590
+ this.getNiceJobResult(job)
1591
+ ];
1592
+ });
1593
+ },
1594
+
1595
+ async printQueuedJobs(opts) {
1596
+ // Fetch queued jobs from the server and print a paginated table.
1597
+ if (!opts) opts = {};
1598
+
1599
+ var offset = ('offset' in opts) ? opts.offset : this.offset;
1600
+ var limit = opts.limit || this.limit || this.config.items_per_page;
1601
+
1602
+ // Start with any optional caller-supplied criteria, but always restrict this
1603
+ // helper to queued jobs. An event ID may be supplied for event detail views.
1604
+ var params = Object.assign({}, opts.criteria || {}, {
1605
+ state: 'queued',
1606
+ offset: offset,
1607
+ limit: limit,
1608
+ sort_by: opts.sort_by || 'started',
1609
+ sort_dir: ('sort_dir' in opts) ? opts.sort_dir : -1
1610
+ });
1611
+
1612
+ if (opts.event) params.event = opts.event;
1613
+
1614
+ cli.progress.start();
1615
+ var { err, data } = await this.api.getActiveJobs(params);
1616
+ if (err) this.die(err);
1617
+ cli.progress.end();
1618
+
1619
+ var rows = data.rows || [];
1620
+
1621
+ // Allow this helper to be embedded in larger detail screens without
1622
+ // displaying an empty section.
1623
+ if (opts.hide_empty && !rows.length) return;
1624
+
1625
+ opts.title = opts.title || 'Queued Jobs';
1626
+ opts.header = [
1627
+ 'Job ID',
1628
+ 'State',
1629
+ 'Source',
1630
+ 'Targets',
1631
+ 'Queued',
1632
+ 'Elapsed'
1633
+ ];
1634
+
1635
+ opts.offset = offset;
1636
+ opts.limit = limit;
1637
+ opts.rows = rows;
1638
+ opts.list = data.list || { length: rows.length };
1639
+
1640
+ this.printPaginatedBoxTable(opts, job => {
1641
+ if (!job) return false;
1642
+
1643
+ return [
1644
+ this.color('theme').bold(this.getNiceJob(job)),
1645
+ this.getNiceJobState(job),
1646
+ this.getNiceJobSource(job),
1647
+ this.getNiceTargets(job.targets),
1648
+ this.getRelativeDateTime(job.started, true),
1649
+ this.getNiceJobElapsedTime(job, true)
1650
+ ];
1651
+ });
1652
+ },
1653
+
1654
+ async printAlertInvocations(opts) {
1655
+ // Search historical or active alert invocations and print the results.
1656
+ // By default this shows all invocations, with the most recent first.
1657
+ if (!opts) opts = {};
1658
+
1659
+ var query = opts.query || '*';
1660
+ var offset = ('offset' in opts) ? opts.offset : this.offset;
1661
+ var limit = opts.limit || this.limit || this.config.items_per_page;
1662
+
1663
+ cli.progress.start();
1664
+ var { err, data } = await this.api.searchAlerts({
1665
+ query: query,
1666
+ offset: offset,
1667
+ limit: limit,
1668
+ sort_by: opts.sort_by || '_id',
1669
+ sort_dir: ('sort_dir' in opts) ? opts.sort_dir : -1
1670
+ });
1671
+ if (err) this.die(err);
1672
+ cli.progress.end();
1673
+
1674
+ var rows = data.rows || [];
1675
+
1676
+ // Some embedded detail screens, such as completed jobs, should omit the
1677
+ // section entirely when no related alerts exist.
1678
+ if (opts.hide_empty && !rows.length) return;
1679
+
1680
+ var is_filtered = query && (query != '*');
1681
+
1682
+ if (this.format.match(/json/)) return this.jsonOutput(rows);
1683
+
1684
+ opts.title = opts.title || (
1685
+ is_filtered ? 'Alert Invocation Search Results' : 'All Alert Invocations'
1686
+ );
1687
+
1688
+ opts.header = [
1689
+ 'Invocation ID',
1690
+ 'Title',
1691
+ 'Message',
1692
+ 'Server',
1693
+ 'Status',
1694
+ 'Started',
1695
+ 'Duration'
1696
+ ];
1697
+
1698
+ opts.offset = offset;
1699
+ opts.limit = limit;
1700
+ opts.rows = rows;
1701
+ opts.list = data.list || { length: rows.length };
1702
+
1703
+ this.printPaginatedBoxTable(opts, alert => {
1704
+ var icon = alert.active ? '🚨' : '🔔';
1705
+
1706
+ // Active alerts run through the current server time. Cleared alerts
1707
+ // use their final modified timestamp as the ending time.
1708
+ var completed = alert.active ?
1709
+ this.epoch :
1710
+ (alert.modified || alert.date);
1711
+
1712
+ var elapsed = Math.floor(
1713
+ Math.max(0, completed - alert.date)
1714
+ );
1715
+
1716
+ // Keep multiline alert messages from breaking the table layout.
1717
+ var message = String(alert.message || '')
1718
+ .replace(/\s+/g, ' ')
1719
+ .trim();
1720
+
1721
+ return [
1722
+ this.color('theme').bold(alert.id),
1723
+ this.getNiceAlert(alert.alert),
1724
+ message,
1725
+ this.getNiceServer(alert.server),
1726
+ this.getNiceAlertStatus(alert),
1727
+ this.getRelativeDateTime(alert.date, true),
1728
+ Tools.getTextFromSeconds(elapsed, true, true)
1729
+ ];
1730
+ });
1731
+ },
1732
+
1733
+ async printSnapshots(opts) {
1734
+ // Search server or group snapshots and print the results.
1735
+ // By default this shows all snapshots, with the most recent first.
1736
+ if (!opts) opts = {};
1737
+
1738
+ var query = opts.query || '*';
1739
+ var offset = ('offset' in opts) ? opts.offset : this.offset;
1740
+ var limit = opts.limit || this.limit || this.config.items_per_page;
1741
+
1742
+ cli.progress.start();
1743
+ var { err, data } = await this.api.searchSnapshots({
1744
+ query: query,
1745
+ offset: offset,
1746
+ limit: limit,
1747
+ sort_by: opts.sort_by || '_id',
1748
+ sort_dir: ('sort_dir' in opts) ? opts.sort_dir : -1,
1749
+ verbose: !!opts.verbose
1750
+ });
1751
+ if (err) this.die(err);
1752
+ cli.progress.end();
1753
+
1754
+ var rows = data.rows || [];
1755
+ if (this.format.match(/json/)) return this.jsonOutput(rows);
1756
+
1757
+ // Embedded detail screens should omit this section when no related
1758
+ // snapshots were found.
1759
+ if (opts.hide_empty && !rows.length) return;
1760
+
1761
+ var is_filtered = query && (query != '*');
1762
+
1763
+ opts.title = opts.title || (
1764
+ is_filtered ? 'Snapshot Search Results' : 'All Snapshots'
1765
+ );
1766
+
1767
+ opts.header = [
1768
+ 'Snapshot ID',
1769
+ 'Source',
1770
+ 'Target',
1771
+ 'Uptime',
1772
+ 'Load Avg',
1773
+ 'Mem Avail',
1774
+ 'Date / Time'
1775
+ ];
1776
+
1777
+ opts.offset = offset;
1778
+ opts.limit = limit;
1779
+ opts.rows = rows;
1780
+ opts.list = data.list || { length: rows.length };
1781
+
1782
+ this.printPaginatedBoxTable(opts, snapshot => {
1783
+ var is_group = (snapshot.type == 'group');
1784
+ var source = String(snapshot.source || 'unknown').toLowerCase();
1785
+ var nice_source = Tools.ucfirst(source);
1786
+
1787
+ if ((source == 'user') && snapshot.username) {
1788
+ nice_source += ' (' + snapshot.username + ')';
1789
+ }
1790
+
1791
+ var nice_target = 'n/a';
1792
+ var nice_uptime = 'n/a';
1793
+ var nice_load = 'n/a';
1794
+ var nice_memory = 'n/a';
1795
+
1796
+ if (is_group) {
1797
+ // Group snapshots identify their target using the first group ID.
1798
+ if (snapshot.groups && snapshot.groups.length) {
1799
+ nice_target = this.getNiceGroup(snapshot.groups[0]);
1800
+ }
1801
+ }
1802
+ else {
1803
+ // Server snapshots contain a lightweight monitor-data object even
1804
+ // when verbose mode is disabled.
1805
+ var snap_data = snapshot.data || {};
1806
+ var memory = snap_data.memory || {};
1807
+ var load = Array.isArray(snap_data.load) ?
1808
+ snap_data.load : [];
1809
+
1810
+ nice_target = this.getNiceServer(snapshot.server);
1811
+ nice_uptime = Tools.getTextFromSeconds(
1812
+ snap_data.uptime_sec || 0,
1813
+ true,
1814
+ true
1815
+ );
1816
+
1817
+ if (load.length) {
1818
+ nice_load = load.map( function(value) {
1819
+ return Tools.shortFloat(value);
1820
+ } ).join(', ');
1821
+ }
1822
+
1823
+ nice_memory = Tools.getTextFromBytes(
1824
+ memory.available || 0
1825
+ );
1826
+ }
1827
+
1828
+ return [
1829
+ this.color('theme').bold(snapshot.id),
1830
+ nice_source,
1831
+ nice_target,
1832
+ nice_uptime,
1833
+ nice_load,
1834
+ nice_memory,
1835
+ this.getRelativeDateTime(snapshot.date, true)
1836
+ ];
1837
+ });
1838
+ },
1839
+
1840
+ async printTickets(opts) {
1841
+ // Fetch or search tickets and print the results. Explicit IDs are useful
1842
+ // for embedded sections such as completed-job details, while query mode
1843
+ // supports general ticket searches.
1844
+ if (!opts) opts = {};
1845
+
1846
+ var has_ids = Array.isArray(opts.ids);
1847
+ var query = opts.query || '*';
1848
+ var offset = ('offset' in opts) ? opts.offset : this.offset;
1849
+ var limit = opts.limit || this.limit || this.config.items_per_page;
1850
+ var rows = [];
1851
+ var list = { length: 0 };
1852
+
1853
+ if (has_ids) {
1854
+ // An explicitly empty ID list means there are no related tickets.
1855
+ if (opts.ids.length) {
1856
+ cli.progress.start();
1857
+ var { err, data } = await this.api.getTickets({
1858
+ ids: opts.ids
1859
+ });
1860
+ if (err) this.die(err);
1861
+ cli.progress.end();
1862
+
1863
+ var all_rows = data.tickets || [];
1864
+ list = { length: all_rows.length };
1865
+ rows = all_rows.slice(offset, offset + limit);
1866
+ }
1867
+ }
1868
+ else {
1869
+ // No explicit IDs were supplied, so perform a normal ticket search.
1870
+ cli.progress.start();
1871
+ var { err, data } = await this.api.searchTickets({
1872
+ query: query,
1873
+ offset: offset,
1874
+ limit: limit,
1875
+ sort_by: opts.sort_by || '_id',
1876
+ sort_dir: ('sort_dir' in opts) ? opts.sort_dir : -1,
1877
+ compact: ('compact' in opts) ? !!opts.compact : true
1878
+ });
1879
+ if (err) this.die(err);
1880
+ cli.progress.end();
1881
+
1882
+ rows = data.rows || [];
1883
+ list = data.list || { length: rows.length };
1884
+ }
1885
+
1886
+ // Embedded detail screens should omit the section when there are no associated tickets.
1887
+ if (opts.hide_empty && !rows.length) return;
1888
+
1889
+ var is_filtered = !has_ids && query && (query != '*');
1890
+
1891
+ opts.title = opts.title || (
1892
+ has_ids ?
1893
+ 'Tickets' :
1894
+ (is_filtered ? 'Ticket Search Results' : 'All Tickets')
1895
+ );
1896
+
1897
+ opts.header = [
1898
+ '#',
1899
+ 'Subject',
1900
+ 'Type',
1901
+ 'Status',
1902
+ 'Assignees',
1903
+ 'Tags',
1904
+ 'Created'
1905
+ ];
1906
+
1907
+ opts.offset = offset;
1908
+ opts.limit = limit;
1909
+ opts.rows = rows;
1910
+ opts.list = list;
1911
+
1912
+ this.printPaginatedBoxTable(opts, ticket => {
1913
+ // getTickets() returns an error stub when a requested ticket was
1914
+ // deleted or could not be loaded.
1915
+ if (ticket.err) {
1916
+ return [
1917
+ cli.emoji('🗑️') + ' ' + gray(ticket.id || '#'),
1918
+ gray('(Ticket was deleted)'),
1919
+ 'n/a',
1920
+ 'n/a',
1921
+ 'n/a',
1922
+ 'n/a',
1923
+ 'n/a'
1924
+ ];
1925
+ }
1926
+
1927
+ var status_icons = {
1928
+ draft: '📝',
1929
+ open: '🟢',
1930
+ closed: '🔴'
1931
+ };
1932
+
1933
+ var ticket_types =
1934
+ (this.config.ui && this.config.ui.ticket_types) || [];
1935
+ var ticket_statuses =
1936
+ (this.config.ui && this.config.ui.ticket_statuses) || [];
1937
+
1938
+ var type_def = Tools.findObject(
1939
+ ticket_types,
1940
+ { id: ticket.type }
1941
+ ) || {
1942
+ title: Tools.ucfirst(ticket.type || 'Other')
1943
+ };
1944
+
1945
+ var status_def = Tools.findObject(
1946
+ ticket_statuses,
1947
+ { id: ticket.status }
1948
+ ) || {
1949
+ title: Tools.ucfirst(ticket.status || 'Unknown'),
1950
+ color: 'gray'
1951
+ };
1952
+
1953
+ var status_icon = status_icons[ticket.status] || '⚪';
1954
+ var nice_type = type_def.title;
1955
+
1956
+ var nice_status =
1957
+ cli.emoji(status_icon) + ' ' +
1958
+ this.color(status_def.color || 'gray').bold(status_def.title);
1959
+
1960
+ var assignees = (ticket.assignees || []).join(', ');
1961
+ if (!assignees) assignees = gray('(None)');
1962
+
1963
+ // Keep unexpected whitespace out of the table layout.
1964
+ var subject = String(ticket.subject || '(No Subject)')
1965
+ .replace(/\s+/g, ' ')
1966
+ .trim();
1967
+
1968
+ return [
1969
+ this.color('theme').bold('#' + ticket.num),
1970
+ cli.emoji('🗒️') + ' ' + subject,
1971
+ nice_type,
1972
+ nice_status,
1973
+ assignees,
1974
+ this.getNiceTagList(ticket.tags || []),
1975
+ this.getRelativeDateTime(ticket.created, true)
1976
+ ];
1977
+ });
1978
+ },
1979
+
1980
+ printParamValues(opts) {
1981
+ // print param value with values
1982
+ // opts: { title, fields, values }
1983
+ if (!opts.fields.length) {
1984
+ println( "\n " + this.color('theme').bold( opts.title.toUpperCase() ) );
1985
+ println( gray(" No parameter definitions found.") );
1986
+ return;
1987
+ }
1988
+
1989
+ opts.header = opts.header || ['Param Title', 'ID', 'Type', 'Value'];
1990
+ opts.rows = [];
1991
+
1992
+ opts.fields.forEach( field => {
1993
+ var nice_id = field.id;
1994
+ var value = (field.id in opts.values) ? opts.values[field.id] : field.value;
1995
+ var disp_value = value;
1996
+ var nice_type = this.config.ui.control_type_labels[field.type] || Tools.ucfirst(field.type);
1997
+
1998
+ switch (field.type) {
1999
+ case 'checkbox':
2000
+ disp_value = value ? `${cli.emoji('✅')} ${green('Checked')}` : `${gray('Unchecked')}`
2001
+ break;
2002
+
2003
+ case 'text':
2004
+ if ((field.variant == 'password') && !this.verbose) disp_value = yellow('(Shown in verbose mode)');
2005
+ else disp_value = green( disp_value );
2006
+ break;
2007
+
2008
+ case 'textarea':
2009
+ case 'code':
2010
+ case 'json':
2011
+ disp_value = this.verbose ? green('(See below)') : yellow('(Shown in verbose mode)');
2012
+ break;
2013
+
2014
+ case 'hidden':
2015
+ if (!opts.hidden) return false;
2016
+ disp_value = green( disp_value );
2017
+ break;
2018
+
2019
+ case 'group':
2020
+ if (!opts.hidden) return false;
2021
+ nice_id = '-';
2022
+ disp_value = '-';
2023
+ break;
2024
+
2025
+ case 'select':
2026
+ if (field.multiple) nice_type += ' (Multi)';
2027
+ disp_value = green( disp_value );
2028
+ break;
2029
+
2030
+ case 'bucket':
2031
+ disp_value = this.getNiceBucket(field.bucket_id);
2032
+ if (field.multiple) nice_type += ' (Multi)';
2033
+ break;
2034
+
2035
+ case 'system':
2036
+ disp_value = this.getNiceSystemList(field.list_id);
2037
+ if (field.multiple) nice_type += ' (Multi)';
2038
+ break;
2039
+
2040
+ case 'toolset':
2041
+ if (opts.hidden) {
2042
+ if (field.data && field.data.tools && field.data.tools.length) disp_value = Tools.commify(field.data.tools.length) + " tools in set";
2043
+ else disp_value = "(No tools in set)";
2044
+ }
2045
+ else {
2046
+ disp_value = green( disp_value );
2047
+ }
2048
+ break;
2049
+
2050
+ default:
2051
+ disp_value = green( disp_value );
2052
+ break;
2053
+ } // switch field.type
2054
+
2055
+ opts.rows.push([
2056
+ bold(field.title),
2057
+ gray(nice_id),
2058
+ nice_type,
2059
+ disp_value
2060
+ ]);
2061
+ } );
2062
+
2063
+ this.printBoxTable(opts);
2064
+
2065
+ opts.fields.filter( field => !!field.type.match(/^(toolset)$/) ).forEach( toolset => {
2066
+ var tools = (toolset.data && toolset.data.tools) || [];
2067
+ if (!tools.length) return;
2068
+
2069
+ // Definition views need to describe every available tool. Value views
2070
+ // only need the selected tool, because the others have no active values.
2071
+ var display_tools = tools;
2072
+ if (!opts.hidden) {
2073
+ var tool_sel = opts.values[toolset.id] || tools[0].id;
2074
+ if (tool_sel && (typeof(tool_sel) == 'object')) tool_sel = tool_sel.id;
2075
+ display_tools = [ Tools.findObject( tools, { id: tool_sel } ) || tools[0] ];
2076
+ }
2077
+
2078
+ display_tools.forEach( tool => {
2079
+ this.printParamValues({
2080
+ title: toolset.title + ": " + tool.title,
2081
+ fields: tool.fields || [],
2082
+ values: opts.values,
2083
+ hidden: opts.hidden
2084
+ });
2085
+ });
2086
+ });
2087
+
2088
+ if (this.verbose) {
2089
+ opts.fields.filter( field => !!field.type.match(/^(textarea|code|json)$/) ).forEach( field => {
2090
+ var value = (field.id in opts.values) ? opts.values[field.id] : field.value;
2091
+ var disp_value = (typeof(value) == 'object') ? JSON.stringify(value, null, "\t") : value;
2092
+ println( "\n " + this.color('theme').bold( 'PARAMETER: ' + field.title.toUpperCase() ) );
2093
+
2094
+ disp_value = String(disp_value).trim().split(/\n/).map( line => ' ' + line ).join("\n");
2095
+
2096
+ if (field.type == 'json') println( this.highlight(disp_value, { language: 'json', ignoreIllegals: true }) );
2097
+ else println( disp_value );
2098
+ } );
2099
+ }
2100
+ },
2101
+
2102
+ printParamFields(opts) {
2103
+ // print param field definitions
2104
+ // opts: { title, fields }
2105
+ opts.header = ['Param Title', 'ID', 'Type', 'Default Value'];
2106
+ opts.values = {};
2107
+ opts.hidden = true;
2108
+
2109
+ this.printParamValues(opts);
2110
+ },
2111
+
2112
+ printPluginScript(plugin, opts) {
2113
+ // Print embedded Plugin source consistently wherever a full definition is
2114
+ // shown. Prefer the executable language, then inspect the script shebang,
2115
+ // and only fall back to cli-highlight's slower automatic detection.
2116
+ opts = opts || {};
2117
+ var script = plugin.script || '';
2118
+ if (!script) return;
2119
+
2120
+ println( "\n " + this.color('theme').bold('PLUGIN SCRIPT') );
2121
+ if (opts.verboseOnly && !this.verbose) {
2122
+ println( " " + gray("(Shown in verbose mode)") );
2123
+ return;
2124
+ }
2125
+
2126
+ var language = this.getLangFromBinary(plugin.command);
2127
+ if (!language && script.match(/^\#\!(\/\S+)/)) language = this.getLangFromBinary(RegExp.$1);
2128
+
2129
+ var source_lines = script.trim().split(/\r?\n/).map( line => ' ' + line ).join("\n");
2130
+ var highlight_opts = { ignoreIllegals: true };
2131
+ if (language) highlight_opts.language = language;
2132
+ println( "\n" + this.highlight(source_lines, highlight_opts) );
2133
+ },
2134
+
2135
+ getNiceSystemList(list_id) {
2136
+ // get formatted list title with icon
2137
+ var item = Tools.findObject( this.getSystemListMenuItems(), { id: list_id } );
2138
+ if (!item) return '(None)';
2139
+ return item.title;
2140
+ },
2141
+
2142
+ getSystemListMenuItems() {
2143
+ // get menu of all lists (excluding a few)
2144
+ var items = this.config.ui.list_list.filter( function(item) {
2145
+ return !item.id.match(/^(api_keys|secrets)$/);
2146
+ } );
2147
+
2148
+ items = items.concat([
2149
+ {
2150
+ "id": "servers",
2151
+ "title": "Servers"
2152
+ },
2153
+ {
2154
+ "id": "groups",
2155
+ "title": "Groups"
2156
+ },
2157
+ {
2158
+ "id": "targets",
2159
+ "title": "Targets"
2160
+ },
2161
+ {
2162
+ "id": "algos",
2163
+ "title": "Algorithms"
2164
+ },
2165
+ {
2166
+ "id": "icons",
2167
+ "title": "Icons"
2168
+ }
2169
+ ]);
2170
+
2171
+ Tools.sortBy( items, 'title' );
2172
+ return items;
2173
+ },
2174
+
2175
+ markdown(text) {
2176
+ // Convert GitHub-style Markdown into ANSI terminal output. The renderer
2177
+ // width above already reserves both margins, so only the visible left-side
2178
+ // padding needs to be added to each completed output line.
2179
+ var output = marked.parse(String(text)).trimEnd();
2180
+ if (!cli.chalk.enabled) output = Tools.stripANSI(output);
2181
+ return output.split('\n').map( line => ' '.repeat(MARKDOWN_PADDING) + line ).join('\n');
2182
+ },
2183
+
2184
+ ellipsis(text, max_len) {
2185
+ // add ellipsis to text if over N length
2186
+ text = '' + text;
2187
+ if (cli.stringWidth(text) >= max_len) {
2188
+ return text.substring(0, max_len - 1) + '…';
2189
+ }
2190
+ else return text;
2191
+ },
2192
+
2193
+ getLastDayInMonth: function(year, month) {
2194
+ // compute the last day in the month, and cache in RAM
2195
+ var cache_key = '' + year + '/' + month;
2196
+ if (cache_key in this.lastMonthDayCache) return this.lastMonthDayCache[cache_key];
2197
+
2198
+ var last_day = new Date(year, month, 0).getDate();
2199
+ this.lastMonthDayCache[cache_key] = last_day;
2200
+
2201
+ return last_day;
2202
+ },
2203
+
2204
+ predictUpcomingJobs(opts) {
2205
+ // simulate schedule for predicting future jobs
2206
+ // opts: { events, duration, burn, max, progress, callback }
2207
+ if (!opts.start) opts.start = Tools.normalizeTime( Math.floor(this.epoch), { sec: 0 } ) + 60; // next minute
2208
+ if (!opts.duration) opts.duration = 86400; // default 24 hours
2209
+ if (!opts.burn) opts.burn = 1000; // default cpu time per chunk
2210
+ if (!opts.max) opts.max = 1000; // max 1k jobs in array
2211
+
2212
+ opts.epoch = opts.start; // starting time
2213
+ opts.end = opts.start + opts.duration; // where to stop (inclusive)
2214
+ opts.jobs = []; // output jobs
2215
+ opts.formatters = {}; // intl formatters
2216
+
2217
+ opts.events = Tools.copyHash(opts.events, true).filter( event => {
2218
+ if (!event.enabled) return false;
2219
+ if (!event.triggers || !event.triggers.length) return false; // on-demand
2220
+
2221
+ // check for disabled category
2222
+ var category = Tools.findObject( this.categories, { id: event.category } );
2223
+ if (!category.enabled) return false;
2224
+
2225
+ // check for disabled plugin
2226
+ if (event.plugin && !event.plugin.match(/^_/)) {
2227
+ var plugin = Tools.findObject( this.plugins, { id: event.plugin } );
2228
+ if (plugin && !plugin.enabled) return false;
2229
+ }
2230
+
2231
+ // process triggers
2232
+ var triggers = event.triggers.filter( function(trigger) { return trigger.enabled; } );
2233
+ var schedules = triggers.filter( function(trigger) { return trigger.type.match(/^(schedule|single|interval)$/); } );
2234
+ if (!schedules.length) return false;
2235
+
2236
+ // quiet (invisible) mode
2237
+ var quiet = Tools.findObject( triggers, { type: 'quiet' } );
2238
+ if (quiet && quiet.invisible) {
2239
+ if (!this.invisible) return false;
2240
+ event.invisible = true;
2241
+ }
2242
+
2243
+ // setup all unique timezones (intl formatters)
2244
+ schedules.forEach( trigger => {
2245
+ if (trigger.type != 'schedule') return;
2246
+ var tz = trigger.timezone || this.config.tz;
2247
+ if (tz in opts.formatters) return; // already setup
2248
+
2249
+ opts.formatters[tz] = new Intl.DateTimeFormat('en-US',
2250
+ { year: 'numeric', month: '2-digit', day: 'numeric', weekday: 'long', hour: 'numeric', minute: '2-digit', hourCycle: 'h23', timeZone: tz }
2251
+ );
2252
+ } );
2253
+
2254
+ // store some props for fast access below
2255
+ event.schedules = schedules;
2256
+ event.ranges = triggers.filter( function(trigger) { return (trigger.type == 'range') || (trigger.type == 'blackout'); } );
2257
+ event.plugin_trigger = Tools.findObject( triggers, { type: 'plugin', enabled: true } );
2258
+ event.nth_trigger = Tools.findObject( triggers, { type: 'nth', enabled: true } );
2259
+ event.day_limits = Tools.findObjects( event.limits || [], { type: 'day', enabled: true } ) || [];
2260
+
2261
+ // add deep copies of event stats and state, so we can simulate mutations
2262
+ event.stats = Tools.copyHash( Tools.getPath( this.stats.currentDay, 'events.' + event.id ) || {}, true );
2263
+ event.state = Tools.copyHash( Tools.getPath( this.state, 'events.' + event.id ) || {}, true );
2264
+
2265
+ // if we have day_limits, we need a formatter for the server timezone (whew!)
2266
+ if (event.day_limits.length && !opts.formatters[ this.config.tz ]) {
2267
+ opts.formatters[ this.config.tz ] = new Intl.DateTimeFormat('en-US',
2268
+ { year: 'numeric', month: '2-digit', day: 'numeric', weekday: 'long', hour: 'numeric', minute: '2-digit', hourCycle: 'h23', timeZone: this.config.tz }
2269
+ );
2270
+ }
2271
+
2272
+ return true;
2273
+ } ); // filter events
2274
+
2275
+ if (!opts.events.length) return opts.callback([]);
2276
+
2277
+ this.currentPrediction = opts;
2278
+ this.predictNextChunk();
2279
+ },
2280
+
2281
+ predictNextChunk() {
2282
+ // predict one chunk of upcoming jobs (limit CPU burn)
2283
+ var self = this;
2284
+ var opts = this.currentPrediction;
2285
+ if (!opts) return; // sanity
2286
+
2287
+ var pstart = performance.now();
2288
+ var date = new Date();
2289
+ var tzargs = {};
2290
+ var days = { Sunday: 0, Monday: 1, Tuesday: 2, Wednesday: 3, Thursday: 4, Friday: 5, Saturday: 6 };
2291
+
2292
+ do {
2293
+ // predict jobs for current minute
2294
+ date.setTime( opts.epoch * 1000 );
2295
+
2296
+ // convert date to all unique timezones we care about and argify it
2297
+ // { month: 11, day: 29, weekday: 2, year: 2022, hour: 22, minute: 29 }
2298
+ for (var tz in opts.formatters) {
2299
+ if (!tzargs[tz]) tzargs[tz] = {};
2300
+
2301
+ opts.formatters[tz].formatToParts(date).forEach( function(part) {
2302
+ if (part.type == 'literal') return;
2303
+ if (part.type == 'weekday') tzargs[tz][ part.type ] = days[ part.value ];
2304
+ else tzargs[tz][ part.type ] = parseInt( part.value );
2305
+ } );
2306
+
2307
+ // include reverse-month-day (rday): -1 is last day of month, -2 is 2nd-to-last day, etc.
2308
+ tzargs[tz].rday = (tzargs[tz].day - this.getLastDayInMonth( tzargs[tz].year, tzargs[tz].month )) - 1;
2309
+ }
2310
+
2311
+ // keep track of day rollovers for day_limits
2312
+ var is_server_midnight = false;
2313
+ if (tzargs[this.config.tz] && (tzargs[this.config.tz].hour == 0) && (tzargs[this.config.tz].minute == 0)) is_server_midnight = true;
2314
+
2315
+ // do any events need to run this minute?
2316
+ // { "type": "schedule", "enabled": true, "years": [2023], "months": [3, 4, 5], "days": [1, 15], "weekdays": [1, 2, 3, 4, 5], "hours": [6, 7, 8, 9, 10], "minutes": [15, 45] }
2317
+ opts.events.forEach( event => {
2318
+ var scheduled = false;
2319
+ var extras = {};
2320
+
2321
+ // reset stats at server midnight (simulated)
2322
+ if (is_server_midnight) event.stats = {};
2323
+
2324
+ event.schedules.forEach( trigger => {
2325
+ if ((trigger.type == 'single') && (trigger.epoch == opts.epoch)) {
2326
+ scheduled = 'single';
2327
+ return;
2328
+ }
2329
+ if (trigger.type == 'interval') {
2330
+ var hits = interval_hits_per_minute(trigger, opts.epoch);
2331
+ if (hits.length) {
2332
+ scheduled = 'interval';
2333
+ extras.seconds = hits;
2334
+ return;
2335
+ }
2336
+ }
2337
+
2338
+ if (trigger.type != 'schedule') return; // sanity
2339
+ var tz = trigger.timezone || this.config.tz;
2340
+ var dargs = tzargs[tz];
2341
+
2342
+ if (trigger.years && trigger.years.length && !trigger.years.includes(dargs.year)) return;
2343
+ if (trigger.months && trigger.months.length && !trigger.months.includes(dargs.month)) return;
2344
+ if (trigger.days && trigger.days.length && !trigger.days.includes(dargs.day) && !trigger.days.includes(dargs.rday)) return;
2345
+ if (trigger.weekdays && trigger.weekdays.length && !trigger.weekdays.includes(dargs.weekday)) return;
2346
+ if (trigger.hours && trigger.hours.length && !trigger.hours.includes(dargs.hour)) return;
2347
+ if (trigger.minutes && trigger.minutes.length && !trigger.minutes.includes(dargs.minute)) return;
2348
+
2349
+ scheduled = 'schedule';
2350
+ } ); // foreach schedule
2351
+
2352
+ if (!scheduled) return;
2353
+
2354
+ // check ranges
2355
+ // (both start/end dates are INCLUSIVE)
2356
+ event.ranges.forEach( function(trigger) {
2357
+ switch (trigger.type) {
2358
+ case 'range':
2359
+ if (trigger.start && (opts.epoch < trigger.start)) scheduled = false;
2360
+ else if (trigger.end && (opts.epoch > trigger.end)) scheduled = false;
2361
+ break;
2362
+
2363
+ case 'blackout':
2364
+ if ((opts.epoch >= trigger.start) && (opts.epoch <= trigger.end)) scheduled = false;
2365
+ break;
2366
+ }
2367
+ } );
2368
+
2369
+ if (!scheduled) return;
2370
+
2371
+ // check day limits
2372
+ event.day_limits.forEach( function(limit) {
2373
+ var count = event.stats[ 'job_' + limit.condition ] || 0;
2374
+ if (limit.amount && (count >= limit.amount)) scheduled = false;
2375
+ } );
2376
+
2377
+ if (!scheduled) return;
2378
+
2379
+ // check every nth
2380
+ if (event.nth_trigger) {
2381
+ var nth_counter = (event.state.nth || 0) - 1;
2382
+ if (nth_counter <= 0) nth_counter = event.nth_trigger.every;
2383
+ else scheduled = false;
2384
+ event.state.nth = nth_counter;
2385
+ }
2386
+
2387
+ if (!scheduled) return;
2388
+
2389
+ // add plugin modifier if applicable
2390
+ if (event.plugin_trigger) extras.plugin = event.plugin_trigger.plugin_id;
2391
+
2392
+ // add invisible flag if applicable
2393
+ if (event.invisible) extras.invisible = true;
2394
+
2395
+ // add job!
2396
+ opts.jobs.push({ event: event.id, epoch: opts.epoch, type: scheduled, ...extras });
2397
+
2398
+ // simulate event stat increments (used by day_limits)
2399
+ event.stats.job_start = (event.stats.job_start || 0) + 1;
2400
+ event.stats.job_complete = (event.stats.job_complete || 0) + 1;
2401
+ event.stats.job_success = (event.stats.job_success || 0) + 1;
2402
+ } ); // foreach event
2403
+
2404
+ opts.epoch += 60; // skip to next minute
2405
+ }
2406
+ while ((performance.now() - pstart < opts.burn) && (opts.epoch <= opts.end));
2407
+
2408
+ if ((opts.epoch > opts.end) || (opts.jobs.length >= opts.max)) {
2409
+ // all done, reached target epoch (inclusive)
2410
+ if (opts.jobs.length > opts.max) opts.jobs.splice( opts.max );
2411
+ delete this.currentPrediction;
2412
+ return opts.callback(opts.jobs);
2413
+ }
2414
+ else {
2415
+ // not done, optionally call progress handler
2416
+ if (opts.progress) opts.progress( (opts.epoch - opts.start) / opts.duration, opts.jobs );
2417
+
2418
+ // schedule next chunk after a frame of sleep
2419
+ setTimeout( function() { self.predictNextChunk(); }, 1 );
2420
+ }
2421
+ },
2422
+
2423
+ getDateRangeQuery(key, value) {
2424
+ // get formatted epoch/3600 date range for DB queries
2425
+ // now, lasthour, today, yesterday, month, lastmonth, year, lastyear, older
2426
+ var query = '';
2427
+ var now = Math.floor(this.epoch);
2428
+ var dargs = get_date_args( now );
2429
+
2430
+ switch (value) {
2431
+ case 'now':
2432
+ case 'hour':
2433
+ query += '' + key + ':' + now;
2434
+ break;
2435
+
2436
+ case 'lasthour':
2437
+ var date_code = now - 3600;
2438
+ query += '' + key + ':' + date_code;
2439
+ break;
2440
+
2441
+ case 'today':
2442
+ var epoch = parse_date( dargs.year + '-' + dargs.month + '-' + dargs.day + ' 00:00:00' );
2443
+ query += '' + key + ':>=' + epoch;
2444
+ break;
2445
+
2446
+ case 'yesterday':
2447
+ var midnight = parse_date( dargs.year + '-' + dargs.month + '-' + dargs.day + ' 00:00:00' ); // get epoch of midnight today
2448
+ var noon = parse_date( dargs.year + '-' + dargs.month + '-' + dargs.day + ' 12:00:00' ); // get epoch of noon today
2449
+ var yesterday = noon - 86400; // subtract 1d for yesterday -- can be +/- 1 hour off
2450
+ dargs = get_date_args( yesterday ); // get dargs for yesterday
2451
+ var yesterday_midnight = parse_date( dargs.year + '-' + dargs.month + '-' + dargs.day + ' 00:00:00' ); // get epoch of midnight yesterday
2452
+ query += '' + key + ':' + yesterday_midnight + '..' + Math.floor(midnight - 1);
2453
+ break;
2454
+
2455
+ case 'month':
2456
+ var cur_month = parse_date( dargs.year + '-' + dargs.month + '-01 00:00:00' ); // get epoch of midnight on first month day
2457
+ query += '' + key + ':>=' + cur_month;
2458
+ break;
2459
+
2460
+ case 'lastmonth':
2461
+ var cur_month = parse_date( dargs.year + '-' + dargs.month + '-01 00:00:00' ); // get epoch of midnight on first month day
2462
+ var before = cur_month - (86400 * 15); // sometime in last month -- does not need to be exact
2463
+ dargs = get_date_args( before ); // get dargs for last month
2464
+ var last_month = parse_date( dargs.year + '-' + dargs.month + '-01 00:00:00' ); // get epoch of midnight on first day of last month
2465
+ query += '' + key + ':' + last_month + '..' + Math.floor(cur_month - 1);
2466
+ break;
2467
+
2468
+ case 'year':
2469
+ var cur_year = parse_date( dargs.year + '-01-01 00:00:00' ); // get epoch of midnight on first year day
2470
+ query += '' + key + ':>=' + cur_year;
2471
+ break;
2472
+
2473
+ case 'lastyear':
2474
+ var cur_year = parse_date( dargs.year + '-01-01 00:00:00' ); // get epoch of midnight on first year day
2475
+ var before = cur_year - (86400 * 180); // sometime in last year -- does not need to be exact
2476
+ dargs = get_date_args( before ); // get dargs for last year
2477
+ var last_year = parse_date( dargs.year + '-01-01 00:00:00' ); // get epoch of midnight on first day of last year
2478
+ query += '' + key + ':' + last_year + '..' + Math.floor(cur_year - 1);
2479
+ break;
2480
+
2481
+ case 'older':
2482
+ var cur_year = parse_date( dargs.year + '-01-01 00:00:00' ); // get epoch of midnight on first year day
2483
+ var before = cur_year - (86400 * 180); // sometime in last year -- does not need to be exact
2484
+ dargs = get_date_args( before ); // get dargs for last year
2485
+ var last_year = parse_date( dargs.year + '-01-01 00:00:00' ); // get epoch of midnight on first day of last year
2486
+ query += '' + key + ':<' + last_year;
2487
+ break;
2488
+ } // switch
2489
+
2490
+ return query;
2491
+ },
2492
+
2493
+ maskValue(value) {
2494
+ // mask value for display purposes
2495
+ value = String(value);
2496
+ if (value.length < 16) return '********';
2497
+ if (value.length < 32) return value.substring(0, 2) + ('*').repeat(8) + value.substring(value.length - 2);
2498
+ return value.substring(0, 4) + ('*').repeat(8) + value.substring(value.length - 4);
2499
+ },
2500
+
2501
+ printNiceHTML(html) {
2502
+ // Convert user-generated HTML into terminal-friendly plain text.
2503
+ if (!html) return;
2504
+
2505
+ var text = convertHTMLToText(String(html), {
2506
+ wordwrap: false,
2507
+ preserveNewlines: false
2508
+ }).trim();
2509
+
2510
+ println( text.split(/\n/).map( line => ' ' + line ).join("\n") );
2511
+ },
2512
+
2513
+ getOutputStream() {
2514
+ var download = new PassThrough();
2515
+ download.pipe(process.stdout, {
2516
+ end: false
2517
+ });
2518
+ return download;
2519
+ },
2520
+
2521
+ getTextFromSecondsRound(sec, abbrev) {
2522
+ // Public wrapper around the local duration formatting helper.
2523
+ return get_text_from_seconds_round(sec, abbrev);
2524
+ },
2525
+
2526
+ parseCrontab(raw, rand_seed) {
2527
+ // alias for parse_crontab
2528
+ return parse_crontab(raw, rand_seed);
2529
+ }
2530
+
2531
+ }; // module.exports
2532
+
2533
+ function interval_hits_per_minute(trigger, epoch) {
2534
+ // calculate when an interval should hit in the current minute (epoch)
2535
+ // return an array of second offsets, similar to precision.seconds
2536
+ // trigger: { start, duration }
2537
+ if (trigger.start > epoch) return []; // trigger starts in future
2538
+
2539
+ var first_idx = Math.ceil((epoch - trigger.start) / trigger.duration);
2540
+ var first_hit = trigger.start + first_idx * trigger.duration;
2541
+ var hits = [];
2542
+
2543
+ for (var t = first_hit; t < epoch + 60; t += trigger.duration) {
2544
+ if (t >= epoch) hits.push(t - epoch);
2545
+ }
2546
+
2547
+ return hits;
2548
+ };
2549
+
2550
+ function get_text_from_seconds_round(sec, abbrev) {
2551
+ // convert raw seconds to human-readable relative time
2552
+ // round to nearest instead of floor
2553
+ var neg = '';
2554
+ sec = Math.round(sec);
2555
+ if (sec < 0) { sec =- sec; neg = '-'; }
2556
+
2557
+ var suffix = abbrev ? "sec" : "second";
2558
+ var amt = sec;
2559
+
2560
+ if (sec > 59) {
2561
+ var min = Math.round(sec / 60);
2562
+ suffix = abbrev ? "min" : "minute";
2563
+ amt = min;
2564
+
2565
+ if (min > 59) {
2566
+ var hour = Math.round(min / 60);
2567
+ suffix = abbrev ? "hr" : "hour";
2568
+ amt = hour;
2569
+
2570
+ if (hour > 23) {
2571
+ var day = Math.round(hour / 24);
2572
+ suffix = "day";
2573
+ amt = day;
2574
+ } // hour>23
2575
+ } // min>59
2576
+ } // sec>59
2577
+
2578
+ if (abbrev === 2) suffix = suffix.substring(0, 1);
2579
+
2580
+ var text = "" + amt + " " + suffix;
2581
+ if ((amt != 1) && !abbrev) text += "s";
2582
+ if (abbrev === 2) text = text.replace(/\s+/g, '');
2583
+
2584
+ return(neg + text);
2585
+ };
2586
+
2587
+ function parse_date(str) {
2588
+ // parse date into epoch
2589
+ return Math.floor( ((new Date(str)).getTime() / 1000) );
2590
+ };
2591
+
2592
+ function get_date_args(now) {
2593
+ // convenience wrapper with month / day aliases
2594
+ var dargs = Tools.getDateArgs( now );
2595
+ dargs.month = dargs.mon;
2596
+ dargs.day = dargs.mday;
2597
+ return dargs;
2598
+ };
2599
+
2600
+ // Crontab Parsing Tools
2601
+ // by Joseph Huckaby, (c) 2015, BSD 3-Clause License
2602
+
2603
+ var cron_aliases = {
2604
+ jan: 1,
2605
+ feb: 2,
2606
+ mar: 3,
2607
+ apr: 4,
2608
+ may: 5,
2609
+ jun: 6,
2610
+ jul: 7,
2611
+ aug: 8,
2612
+ sep: 9,
2613
+ oct: 10,
2614
+ nov: 11,
2615
+ dec: 12,
2616
+
2617
+ sun: 0,
2618
+ mon: 1,
2619
+ tue: 2,
2620
+ wed: 3,
2621
+ thu: 4,
2622
+ fri: 5,
2623
+ sat: 6
2624
+ };
2625
+ var cron_alias_re = new RegExp("\\b(" + Object.keys(cron_aliases).join('|') + ")\\b", "g");
2626
+
2627
+ function string_hash(str) {
2628
+ // DJB2 algorithm
2629
+ var hash = 5381, i = str.length;
2630
+ while (i) { hash = (hash * 33) ^ str.charCodeAt(--i); }
2631
+ return hash >>> 0;
2632
+ };
2633
+
2634
+ function parse_crontab_part(trigger, raw, key, min, max, rand_seed) {
2635
+ // parse one crontab part, e.g. 1,2,3,5,20-25,30-35,59
2636
+ // can contain single number, and/or list and/or ranges and/or these things: */5 or 10-50/5
2637
+ if (raw == '*') { return; } // wildcard
2638
+ if (raw == 'h') {
2639
+ // unique value over accepted range, but locked to random seed
2640
+ // https://github.com/jhuckaby/Cronicle/issues/6
2641
+ raw = min + (string_hash(rand_seed) % ((max - min) + 1));
2642
+ raw = '' + raw;
2643
+ }
2644
+ if (!raw.match(/^[\w\-\,\/\*]+$/)) { throw new Error("Invalid crontab format: " + raw); }
2645
+ var values = {};
2646
+ var bits = raw.split(/\,/);
2647
+
2648
+ for (var idx = 0, len = bits.length; idx < len; idx++) {
2649
+ var bit = bits[idx];
2650
+ if (bit.match(/^\d+$/)) {
2651
+ // simple number, easy
2652
+ values[bit] = 1;
2653
+ }
2654
+ else if (bit.match(/^(\d+)\-(\d+)$/)) {
2655
+ // simple range, e.g. 25-30
2656
+ var start = parseInt( RegExp.$1 );
2657
+ var end = parseInt( RegExp.$2 );
2658
+ for (var idy = start; idy <= end; idy++) { values[idy] = 1; }
2659
+ }
2660
+ else if (bit.match(/^\*\/(\d+)$/)) {
2661
+ // simple step interval, e.g. */5
2662
+ var step = parseInt( RegExp.$1 );
2663
+ var start = min;
2664
+ var end = max;
2665
+ for (var idy = start; idy <= end; idy += step) { values[idy] = 1; }
2666
+ }
2667
+ else if (bit.match(/^(\d+)\-(\d+)\/(\d+)$/)) {
2668
+ // range step inverval, e.g. 1-31/5
2669
+ var start = parseInt( RegExp.$1 );
2670
+ var end = parseInt( RegExp.$2 );
2671
+ var step = parseInt( RegExp.$3 );
2672
+ for (var idy = start; idy <= end; idy += step) { values[idy] = 1; }
2673
+ }
2674
+ else {
2675
+ throw new Error("Invalid crontab format: " + bit + " (" + raw + ")");
2676
+ }
2677
+ }
2678
+
2679
+ // min max
2680
+ var to_add = {};
2681
+ var to_del = {};
2682
+ for (var value in values) {
2683
+ value = parseInt( value );
2684
+ if (value < min) {
2685
+ to_del[value] = 1;
2686
+ to_add[min] = 1;
2687
+ }
2688
+ else if (value > max) {
2689
+ to_del[value] = 1;
2690
+ value -= min;
2691
+ value = value % ((max - min) + 1); // max is inclusive
2692
+ value += min;
2693
+ to_add[value] = 1;
2694
+ }
2695
+ }
2696
+ for (var value in to_del) delete values[value];
2697
+ for (var value in to_add) values[value] = 1;
2698
+
2699
+ // convert to sorted array
2700
+ var list = Object.keys(values);
2701
+ for (var idx = 0, len = list.length; idx < len; idx++) {
2702
+ list[idx] = parseInt( list[idx] );
2703
+ }
2704
+ list = list.sort( function(a, b) { return a - b; } );
2705
+ if (list.length) trigger[key] = list;
2706
+ };
2707
+
2708
+ function parse_crontab(raw, rand_seed) {
2709
+ // parse standard crontab syntax, return trigger object
2710
+ // e.g. 1,2,3,5,20-25,30-35,59 23 31 12 * *
2711
+ // optional 6th element == years
2712
+ if (!rand_seed) rand_seed = Tools.generateShortID();
2713
+ var trigger = {};
2714
+
2715
+ // resolve all @shortcuts
2716
+ raw = String(raw).trim().toLowerCase();
2717
+ if (raw.match(/\@(yearly|annually)/)) raw = '0 0 1 1 *';
2718
+ else if (raw == '@monthly') raw = '0 0 1 * *';
2719
+ else if (raw == '@weekly') raw = '0 0 * * 0';
2720
+ else if (raw == '@daily') raw = '0 0 * * *';
2721
+ else if (raw == '@hourly') raw = '0 * * * *';
2722
+
2723
+ // expand all month/wday aliases
2724
+ raw = raw.replace(cron_alias_re, function(m_all, m_g1) {
2725
+ return cron_aliases[m_g1];
2726
+ } );
2727
+
2728
+ // at this point string should not contain any alpha characters or '@', except for 'h'
2729
+ if (raw.match(/([a-gi-z\@]+)/i)) throw new Error("Invalid crontab keyword: " + RegExp.$1);
2730
+
2731
+ // split into parts
2732
+ var parts = raw.split(/\s+/);
2733
+ if (parts.length > 6) throw new Error("Invalid crontab format: " + parts.slice(6).join(' '));
2734
+ if (!parts[0].length) throw new Error("Invalid crontab format");
2735
+
2736
+ // parse each part
2737
+ if ((parts.length > 0) && parts[0].length) parse_crontab_part( trigger, parts[0], 'minutes', 0, 59, rand_seed );
2738
+ if ((parts.length > 1) && parts[1].length) parse_crontab_part( trigger, parts[1], 'hours', 0, 23, rand_seed );
2739
+ if ((parts.length > 2) && parts[2].length) parse_crontab_part( trigger, parts[2], 'days', 1, 31, rand_seed );
2740
+ if ((parts.length > 3) && parts[3].length) parse_crontab_part( trigger, parts[3], 'months', 1, 12, rand_seed );
2741
+ if ((parts.length > 4) && parts[4].length) parse_crontab_part( trigger, parts[4], 'weekdays', 0, 6, rand_seed );
2742
+ if ((parts.length > 5) && parts[5].length) parse_crontab_part( trigger, parts[5], 'years', 1970, 3000, rand_seed );
2743
+
2744
+ return trigger;
2745
+ };