@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/jobs.js ADDED
@@ -0,0 +1,1254 @@
1
+ // Job Search Layer
2
+
3
+ const cli = require('pixl-cli');
4
+ const Tools = cli.Tools;
5
+ const chalk = cli.chalk;
6
+
7
+ module.exports = {
8
+
9
+ async cmd_jobs() {
10
+ // alias for cmd_search_jobs
11
+ await this.cmd_search_jobs();
12
+ },
13
+
14
+ async cmd_job() {
15
+ // router, e.g. `xy job ID`
16
+ var cmd = this.args.other.shift();
17
+ switch (cmd) {
18
+ case 'search': await this.cmd_search_jobs(); break;
19
+ case 'get': await this.cmd_get_job(); break;
20
+ case 'log': await this.cmd_get_job_log(); break;
21
+ case 'stream': await this.cmd_stream_job(); break;
22
+
23
+ case 'run': await this.cmd_run_job_again(); break;
24
+ case 'resume': await this.cmd_resume_job(); break;
25
+ case 'abort': await this.cmd_abort_job(); break;
26
+ case 'delete': await this.cmd_delete_job(); break;
27
+
28
+ default:
29
+ // assume cmd_get_job, and arg was the id
30
+ this.args.other.unshift(cmd);
31
+ await this.cmd_get_job();
32
+ break;
33
+ } // switch cmd
34
+ },
35
+
36
+ async cmd_search_jobs() {
37
+ // search completed jobs
38
+ this.prepSearchArgs();
39
+ await this.getMultiple();
40
+
41
+ // args become search criteria, other[0] becomes raw query
42
+ if (this.args.other.length) this.args.query = this.args.other.join(' ');
43
+ delete this.args.other;
44
+ var is_filtered = !!Tools.firstKey(this.args);
45
+
46
+ // active jobs
47
+ if (!is_filtered) this.printActiveJobs({
48
+ rows: Object.values(this.activeJobs).sort( function(a, b) {
49
+ // keep workflow parent jobs directly above their sub-jobs
50
+ if (b.workflow && (b.workflow.job == a.id)) return -1;
51
+ if (a.workflow && (a.workflow.job == b.id)) return 1;
52
+ return (a.started < b.started) ? 1 : -1;
53
+ } )
54
+ });
55
+
56
+ // upcoming jobs
57
+ if (this.args.upcoming || (this.args.query === "upcoming")) {
58
+ await this.printUpcomingJobs();
59
+ return;
60
+ }
61
+
62
+ // job search
63
+ if (this.args.event) {
64
+ var event = this.findObjectFuzzy( this.events, { id: this.args.event, title: this.args.event }, 1 );
65
+ if (!event) return this.die("Could not find event based on your criteria: " + this.args.event);
66
+ this.args.event = event.id;
67
+ }
68
+ if (this.args.category) {
69
+ var category = this.findObjectFuzzy( this.categories, { id: this.args.category, title: this.args.category }, 1 );
70
+ if (!category) return this.die("Could not find category based on your criteria: " + this.args.category);
71
+ this.args.category = category.id;
72
+ }
73
+ if (this.args.plugin && !this.args.plugin.match(/^_/)) {
74
+ var plugin = this.findObjectFuzzy( this.plugins, { id: this.args.plugin, title: this.args.plugin }, 1 );
75
+ if (!plugin) return this.die("Could not find plugin based on your criteria: " + this.args.plugin);
76
+ this.args.plugin = plugin.id;
77
+ }
78
+ if (this.args.server) {
79
+ var server = this.findObjectFuzzy( Object.values(this.servers), { id: this.args.server, title: this.args.server, hostname: this.args.server, ip: this.args.server }, 1 );
80
+ if (!server) return this.die("Could not find server based on your criteria: " + this.args.server);
81
+ this.args.server = server.id;
82
+ }
83
+ if (this.args.group) {
84
+ var group = this.findObjectFuzzy( this.groups, { id: this.args.group, title: this.args.group }, 1 );
85
+ if (!group) return this.die("Could not find group based on your criteria: " + this.args.group);
86
+ this.args.groups = group.id;
87
+ delete this.args.group;
88
+ }
89
+ if (this.args.tag && !this.args.tag.match(/^_/)) {
90
+ var tag = this.findObjectFuzzy( this.tags, { id: this.args.tag, title: this.args.tag }, 1 );
91
+ if (!tag) return this.die("Could not find tag based on your criteria: " + this.args.tag);
92
+ this.args.tags = tag.id;
93
+ delete this.args.tag;
94
+ }
95
+
96
+ if (this.args.success) {
97
+ this.args.tags = '_success';
98
+ delete this.args.success;
99
+ }
100
+ else if (this.args.error) {
101
+ this.args.tags = '_error';
102
+ delete this.args.error;
103
+ }
104
+ else if (this.args.files) {
105
+ this.args.tags = '_files';
106
+ delete this.args.files;
107
+ }
108
+
109
+ if (this.args.warning) {
110
+ this.args.code = 'warning';
111
+ delete this.args.warning;
112
+ }
113
+ else if (this.args.critical) {
114
+ this.args.code = 'critical';
115
+ delete this.args.critical;
116
+ }
117
+ else if (this.args.abort) {
118
+ this.args.code = 'abort';
119
+ delete this.args.abort;
120
+ }
121
+
122
+ if (this.args.workflow || this.args.workflows) {
123
+ this.args.plugin = '_workflow';
124
+ delete this.args.workflow;
125
+ delete this.args.workflows;
126
+ }
127
+
128
+ var date_range = this.args.date || '';
129
+ delete this.args.date;
130
+
131
+ var query = this.args.query || '';
132
+ delete this.args.query;
133
+ query += ' ' + Object.keys(this.args).map( key => `${key}:${this.args[key]}` ).join(' ');
134
+
135
+ // date (keyword or raw expression)
136
+ if (date_range) {
137
+ var criteron = this.getDateRangeQuery('date', date_range) || date_range;
138
+ query += ' ' + criteron;
139
+ }
140
+
141
+ await this.printCompletedJobs({
142
+ title: is_filtered ? 'Job Search Results' : 'All Completed Jobs',
143
+ query: query.trim()
144
+ });
145
+
146
+ this.printSuggestedCommands({
147
+ "Show successful jobs": "xy jobs --success",
148
+ "Show failed jobs": "xy jobs --error",
149
+ "Show workflows": "xy jobs --workflows",
150
+ "Specific category": "xy jobs --category ID_OR_TITLE",
151
+ "Completed today": "xy jobs --date today",
152
+ "Job details": "xy job JOB_ID_HERE"
153
+ // "Get help": "xy jobs --help"
154
+ });
155
+ },
156
+
157
+ async cmd_stream_job() {
158
+ // stream job progress and print result
159
+ if (this.args.other && this.args.other.length && !this.args.id) {
160
+ this.args.id = this.args.other.shift();
161
+ }
162
+ if (!this.args.id) this.die("Missing required 'id' argument.");
163
+
164
+ // load everything
165
+ await this.getMultiple();
166
+
167
+ // load job directly, as it may be queued (not in AJ)
168
+ var { err, data } = await this.api.getJob({ id: this.args.id, remove: 'timelines' });
169
+ if (err) this.die(err);
170
+ if (!data.job) this.die("Job not found: " + this.args.id);
171
+ var job = data.job;
172
+
173
+ // check if job is still active
174
+ if (job.final) {
175
+ // job must have completed, hop over to job details
176
+ return await this.cmd_get_job();
177
+ }
178
+
179
+ // print def list of job summary data
180
+ this.printBoxList({
181
+ title: "Job Summary",
182
+ rows: [
183
+ [ 'Job ID', this.color('theme').bold( this.getNiceJob(job) ) ],
184
+ [ 'Event', this.getNiceJobEvent(job) ],
185
+ [ 'Category', this.getNiceCategory(job.category) + ` ${gray('(' + job.category + ')')}` ],
186
+ [ 'Plugin', this.getNicePlugin(job.plugin) + (job.plugin != '_workflow' ? ` ${gray('(' + job.plugin + ')')}` : '') ],
187
+ (job.type != 'workflow') ? [ 'Targets', this.getNiceTargets(job.targets) ] : null,
188
+ (job.type != 'workflow') ? [ 'Server', this.getNiceServer(job.server) + ` ${gray('(' + job.server + ')')}` ] : null,
189
+ (job.type != 'workflow') ? [ 'Algorithm', this.getNiceAlgo(job.algo) ] : null,
190
+ [ 'Source', this.getNiceJobSource(job) ],
191
+ (job.workflow && job.workflow.job) ? [ 'Parent Workflow', this.getNiceJob(job.workflow.job) ] : null,
192
+ (job.parent && job.parent.job) ? [ 'Linked Job', this.getNiceJob(job.parent.job) ] : null,
193
+ [ 'Started', this.getRelativeDateTime(job.started, true) ]
194
+ ]
195
+ });
196
+
197
+ // println( "\n " + this.color('theme').bold("Streaming Job: ") + '#' + this.args.id );
198
+ print("\n");
199
+
200
+ // print meta log up to current
201
+ (job.activity || []).forEach( row => {
202
+ if (!row.msg) return;
203
+ if (!row.server) row.server = 'n/a';
204
+ cli.println( green('[' + this.getNiceServer(String(row.server).replace(/^m:/, '')) + '] ') + gray( String(row.msg).trim() ) );
205
+ } );
206
+
207
+ cli.progress.start();
208
+ var job = {};
209
+ var { err } = await this.api.streamJob({ id: this.args.id, output: true, meta: true }, data => {
210
+ // called repeatedly for each streaming job update
211
+ if ((data.type === 'output') && ('text' in data) && (typeof(data.text) == 'string')) {
212
+ if (!data.text.match(/\n$/)) data.text += "\n";
213
+ cli.print( data.text );
214
+ }
215
+ else if ((data.type === 'meta') && data.row && data.row.msg) {
216
+ if (!data.row.server) data.row.server = 'n/a';
217
+ cli.println( green('[' + this.getNiceServer(String(data.row.server).replace(/^m:/, '')) + '] ') + gray( String(data.row.msg).trim() ) );
218
+ }
219
+ else {
220
+ Tools.mergeHashInto( job, data );
221
+ cli.progress.update({
222
+ amount: job.progress || 0,
223
+ text: '[' + (job.status || this.getNiceJobState(job)) + ']'
224
+ });
225
+ }
226
+ });
227
+ if (err) this.die(err);
228
+ cli.progress.end();
229
+
230
+ verboseln( "\n Stream is complete." );
231
+
232
+ // this.printJobCompletionBanner(job);
233
+ // delete this.activeJobs[this.args.id];
234
+ await this.cmd_get_job();
235
+ },
236
+
237
+ async cmd_get_job() {
238
+ // get job progress or result
239
+ if (this.args.other && this.args.other.length && !this.args.id) {
240
+ this.args.id = this.args.other.shift();
241
+ }
242
+ if (!this.args.id) this.die("Missing required 'id' argument.");
243
+
244
+ // load everything
245
+ await this.getMultiple();
246
+
247
+ // load job data
248
+ var { err, data } = await this.api.getJob({ id: this.args.id, remove: 'timelines' });
249
+ if (err) this.die(err);
250
+ if (!data.job) this.die("Job not found: " + this.args.id);
251
+ var job = data.job;
252
+
253
+ if (!job.final) {
254
+ // job is still in progress, hop over to cmd_stream_job
255
+ return await this.cmd_stream_job();
256
+ }
257
+
258
+ if (this.format.match(/json/)) {
259
+ return this.jsonOutput(job);
260
+ }
261
+
262
+ this.printJobCompletionBanner(job);
263
+
264
+ // The Job result code may be a number, a string such as "warning", or a
265
+ // falsey success value. Normalize a missing code to zero for display.
266
+ var result_code = ('code' in job) ? job.code : 0;
267
+ var result_args = this.getJobResultArgs(job);
268
+ var description = job.description || (
269
+ result_code ?
270
+ 'Unknown Error (no description provided).' :
271
+ 'Job completed successfully.'
272
+ );
273
+
274
+ // Summarize retry lineage. retry_count is zero or absent for the initial
275
+ // attempt, and retry_prev points to the immediately preceding attempt.
276
+ var nice_attempt = 'Initial';
277
+ if (job.retry_count) {
278
+ nice_attempt = 'Retry #' + Tools.commify(job.retry_count);
279
+ if (job.retry_prev) {
280
+ nice_attempt += gray(' (Previous: ') +
281
+ this.getNiceJob(job.retry_prev) + gray(')');
282
+ }
283
+ }
284
+
285
+ // CPU is expressed in percentage points
286
+ var has_cpu = !!(job.cpu && job.cpu.count && ('total' in job.cpu));
287
+ var avg_cpu = has_cpu ?
288
+ Math.round(job.cpu.total / job.cpu.count) : 0;
289
+
290
+ // Memory samples are expressed in bytes.
291
+ var has_mem = !!(job.mem && job.mem.count && ('total' in job.mem));
292
+ var avg_mem = has_mem ?
293
+ Math.floor(job.mem.total / job.mem.count) : 0;
294
+
295
+ this.printBoxList({
296
+ title: job.test ? "Test Summary" : "Job Summary",
297
+ rows: [
298
+ [ 'Job ID', this.color('theme').bold(this.getNiceJob(job)) ],
299
+ [ 'Event', this.getNiceJobEvent(job) ],
300
+ [ 'Category', this.getNiceCategory(job.category) + ` ${gray('(' + job.category + ')')}` ],
301
+ [ 'Plugin', this.getNicePlugin(job.plugin) + (job.plugin != '_workflow' ? ` ${gray('(' + job.plugin + ')')}` : '') ],
302
+ [ 'Targets', this.getNiceTargets(job.targets) ],
303
+ job.algo ? [ 'Algorithm', this.getNiceAlgo(job.algo) ] : null,
304
+ job.server ? [ 'Server', this.getNiceServer(job.server) + ` ${gray('(' + job.server + ')')}` ] : null,
305
+
306
+ // [ 'Result', this.getNiceJobResult(job) ],
307
+ // [ 'Result Code', this.color(result_args.color).bold(String(result_code)) ],
308
+ // [ 'Description', this.color(result_args.color)(description) ],
309
+
310
+ [ 'Source', this.getNiceJobSource(job) ],
311
+ (job.workflow && job.workflow.job) ? [ 'Parent Workflow', this.getNiceJob(job.workflow.job) ] : null,
312
+ (job.parent && job.parent.job) ? [ 'Linked Job', this.getNiceJob(job.parent.job) ] : null,
313
+ [ 'Attempt', nice_attempt ],
314
+
315
+ [ 'Started', this.getRelativeDateTime(job.started, true) ],
316
+ [ 'Completed', this.getRelativeDateTime(job.completed, true) ],
317
+ [ 'Elapsed', this.getNiceJobElapsedTime(job) ],
318
+
319
+ has_cpu ? [ 'Avg CPU', Tools.pct(avg_cpu, 100) ] : null,
320
+ has_mem ? [ 'Avg Memory', Tools.getTextFromBytes(avg_mem) ] : null,
321
+ (job.type != 'workflow') ? [ 'Job Output', Tools.getTextFromBytes(job.log_file_size || 0) ] : null,
322
+
323
+ [ 'Tags', this.getNiceTagList(job.tags || []) ]
324
+ ]
325
+ });
326
+
327
+ // workflow jobs
328
+ if (job.type == 'workflow') {
329
+ this.printWorkflowJobs(job);
330
+ }
331
+
332
+ // actions
333
+ this.printJobActions(job);
334
+
335
+ // limits
336
+ this.printJobLimits(job);
337
+
338
+ // Related alerts, snapshots, and tickets share the requested page settings.
339
+ this.prepSearchArgs();
340
+
341
+ // alerts
342
+ await this.printAlertInvocations({
343
+ title: 'Alert Invocations',
344
+ query: 'jobs:' + job.id,
345
+ hide_empty: true
346
+ });
347
+
348
+ // snapshots
349
+ await this.printSnapshots({
350
+ title: 'Server Snapshots',
351
+ query: 'jobs:' + job.id,
352
+ hide_empty: true
353
+ });
354
+
355
+ // tickets
356
+ await this.printTickets({
357
+ title: 'Job Tickets',
358
+ ids: job.tickets || [],
359
+ hide_empty: true
360
+ });
361
+
362
+ // additional jobs
363
+ await this.printAdditionalJobs({
364
+ title: 'Additional Jobs',
365
+ jobs: job.jobs || [],
366
+ hide_empty: true
367
+ });
368
+
369
+ // user params
370
+ this.printParamValues({
371
+ title: `User Parameters`,
372
+ fields: job.fields || [],
373
+ values: job.params || {}
374
+ });
375
+
376
+ // plugin params
377
+ if (job.type != 'workflow') {
378
+ var plugin = Tools.findObject( this.plugins, { id: job.plugin } ) || { title: "Plugin" };
379
+ this.printParamValues({
380
+ title: `${plugin.title} Parameters`,
381
+ fields: plugin.params || [],
382
+ values: job.params || {}
383
+ });
384
+ }
385
+
386
+ // user content
387
+ if (job.table && Array.isArray(job.table.header) && Array.isArray(job.table.rows)) {
388
+ var table_header = job.table.header.map( function(value) {
389
+ return (value == null) ? '' : String(value);
390
+ } );
391
+
392
+ var table_rows = job.table.rows.map( function(row) {
393
+ return row.map( function(value) {
394
+ if (value == null) return '';
395
+ if (typeof(value) == 'object') return JSON.stringify(value);
396
+ return String(value);
397
+ } );
398
+ } );
399
+
400
+ this.printBoxTable({
401
+ title: job.table.title || 'User Table',
402
+ header: table_header,
403
+ rows: table_rows
404
+ });
405
+
406
+ if (job.table.caption) {
407
+ println( " " + gray(job.table.caption) );
408
+ }
409
+ } // job.table
410
+
411
+ // job.html
412
+ if (job.html && job.html.content) {
413
+ println( "\n " + this.color('theme').bold( String( job.html.title || "Custom Content" ).toUpperCase() ) );
414
+
415
+ this.printNiceHTML( job.html.content );
416
+
417
+ if (job.table.caption) {
418
+ println( " " + gray(job.table.caption) );
419
+ }
420
+ } // job.html
421
+
422
+ // perf metrics
423
+ if (job.perf) {
424
+ println( "\n " + this.color('theme').bold( 'PERFORMANCE METRICS' ) );
425
+ var disp_value = JSON.stringify(job.perf, null, "\t").trim().split(/\n/).map( line => ' ' + line ).join("\n");
426
+ println( this.highlight(disp_value, { language: 'json', ignoreIllegals: true }) );
427
+ }
428
+
429
+ // files
430
+ this.printJobFiles(job);
431
+
432
+ // data
433
+ this.printJobData( job.input?.data, "Input Data" );
434
+ this.printJobData( job.data, "Output Data" );
435
+
436
+ // job output
437
+ this.printJobOutput(job);
438
+
439
+ // meta log
440
+ this.printMetaLog(job);
441
+
442
+ // suggested commands
443
+ var has_output = !!(job.output || job.log_file_size);
444
+ var has_event = !!(job.event && Tools.findObject(this.events, { id: job.event }));
445
+
446
+ this.printSuggestedCommands({
447
+ "Show details": this.verbose ? "" : `xy job ${job.id} --verbose`,
448
+ "View job output": has_output ? `xy job log ${job.id}` : "",
449
+ "Download job output": has_output ? `xy job log ${job.id} --download ${job.id}.log` : "",
450
+ "Parent workflow": (job.workflow && job.workflow.job) ? `xy job ${job.workflow.job}` : "",
451
+ "Linked job": (job.parent && job.parent.job) ? `xy job ${job.parent.job}` : "",
452
+ "Previous attempt": job.retry_prev ? `xy job ${job.retry_prev}` : "",
453
+ "Event details": has_event ? `xy event ${job.event}` : "",
454
+ "Run event again": has_event ? `xy run ${job.event}` : ""
455
+ });
456
+ },
457
+
458
+ async cmd_get_job_log() {
459
+ // print raw job log
460
+ // alias: xy job log JOB_ID
461
+ if (this.args.other && this.args.other.length && !this.args.id) {
462
+ this.args.id = this.args.other.shift();
463
+ }
464
+ if (!this.args.id) this.die("Missing required 'id' argument.");
465
+
466
+ println( "\n " + this.color('theme').bold( 'JOB OUTPUT: #' + this.args.id ) );
467
+ if (this.args.download) println( " " + gray("Downloading to file: ") + this.args.download );
468
+ else println("");
469
+
470
+ var download = this.args.download || this.getOutputStream();
471
+ var { err } = await this.api.getJobLog({ id: this.args.id }, { download });
472
+ if (err) this.die(err);
473
+
474
+ if (this.args.download) println( " " + gray("Download complete." ) );
475
+ },
476
+
477
+ async cmd_run_job_again() {
478
+ // run completed job again
479
+ if (this.args.other && this.args.other.length && !this.args.id) {
480
+ this.args.id = this.args.other.shift();
481
+ }
482
+ if (!this.args.id) this.die("Missing required 'id' argument.");
483
+
484
+ var do_follow = this.args.follow;
485
+ delete this.args.follow;
486
+
487
+ // load everything
488
+ await this.getMultiple();
489
+
490
+ // load job data
491
+ var { err, data } = await this.api.getJob({ id: this.args.id, remove: 'timelines' });
492
+ if (err) this.die(err);
493
+ if (!data.job) this.die("Job not found: " + this.args.id);
494
+ var job = data.job;
495
+
496
+ if (!job.final) this.die("Job is still in progress.");
497
+ if (!job.event) this.die("Cannot rerun adhoc jobs (need to rerun the workflow).");
498
+
499
+ if (job.workflow && (job.type == 'adhoc')) {
500
+ return this.die("Adhoc workflow jobs cannot run independently. Please rerun the parent workflow job instead.");
501
+ }
502
+
503
+ // clense new job of previous running context
504
+ var new_job = Tools.copyHash(job, true);
505
+ for (var key in new_job) {
506
+ if (!key.match(/^(type|event|category|plugin|targets|algo|workflow|input|params|parent|source|username|api_key|icon|label|test|retry_count|tags|now)$/)) delete new_job[key];
507
+ }
508
+
509
+ // run_event API expects event ID in "id" prop
510
+ new_job.id = job.event;
511
+
512
+ // locate original event
513
+ var event = Tools.findObject( this.events, { id: job.event } );
514
+ if (!event) this.die("The original event could not be found: " + job.event);
515
+
516
+ // we must reset actions and limits to the event versions, as inherited ones are added server-side
517
+ new_job.actions = Tools.copyHash( event.actions || [], true );
518
+ new_job.limits = Tools.copyHash( event.limits || [], true );
519
+
520
+ // remove workflow running context (state, etc.)
521
+ if (new_job.workflow) {
522
+ for (var key in new_job.workflow) {
523
+ if (!key.match(/^(nodes|connections|start)$/)) delete new_job.workflow[key];
524
+ }
525
+ if (!Tools.numKeys(new_job.workflow)) delete new_job.workflow;
526
+ }
527
+
528
+ // remove system tags from new job
529
+ // FUTURE: tags that were added dynamically during the previous job run will be included in the new job
530
+ new_job.tags = (new_job.tags || []).filter( function(tag) { return !tag.match(/^_/); } );
531
+
532
+ var data = await this.callStandardAPI('runEvent', new_job, {
533
+ text: 'Running job again...'
534
+ });
535
+ if (this.dry) return;
536
+
537
+ this.toast('✅', 'green', "Successfully launched job: #" + data.id );
538
+
539
+ if (do_follow) {
540
+ this.gotMultiple = false;
541
+ this.args = { id: data.id };
542
+ await this.cmd_stream_job();
543
+ }
544
+ else if (!this.verbose) {
545
+ println( "\n " + green.bold("Watch job live: ") + bold("xy job " + data.id) );
546
+ }
547
+ },
548
+
549
+ async cmd_resume_job() {
550
+ // resume suspended job
551
+ // xy job resume JOB_ID --params.foo "bar"
552
+ // xy job resume JOB_ID --input.data.bar "baz"
553
+ if (this.args.other && this.args.other.length && !this.args.id) {
554
+ this.args.id = this.args.other.shift();
555
+ }
556
+ if (!this.args.id) this.die("Missing required 'id' argument.");
557
+ delete this.args.other;
558
+
559
+ // load everything
560
+ await this.getMultiple();
561
+
562
+ // resolve dot paths in args
563
+ this.mergeDotArgs( this.args, this.args );
564
+
565
+ // load job directly, as it may be queued (not in AJ)
566
+ var { err, data } = await this.api.getJob({ id: this.args.id, remove: 'timelines' });
567
+ if (err) this.die(err);
568
+ if (!data.job) this.die("Job not found: " + this.args.id);
569
+ var job = data.job;
570
+
571
+ // check if job is still active
572
+ if (job.final) this.die("Job is already completed.");
573
+ if (!job.suspended) this.die("Job is not currently suspended.");
574
+
575
+ var data = await this.callStandardAPI('resumeJob', this.args, {
576
+ text: 'Resuming job...'
577
+ });
578
+ if (this.dry) return;
579
+
580
+ this.toast('✅', 'green', "Successfully resumed job: #" + this.args.id );
581
+ },
582
+
583
+ async cmd_abort_job() {
584
+ // abort active job
585
+ // xy job abort JOB_ID
586
+ if (this.args.other && this.args.other.length && !this.args.id) {
587
+ this.args.id = this.args.other.shift();
588
+ }
589
+ if (!this.args.id) this.die("Missing required 'id' argument.");
590
+ delete this.args.other;
591
+
592
+ // load everything
593
+ await this.getMultiple();
594
+
595
+ // load job directly, as it may be queued (not in AJ)
596
+ var { err, data } = await this.api.getJob({ id: this.args.id, remove: 'timelines' });
597
+ if (err) this.die(err);
598
+ if (!data.job) this.die("Job not found: " + this.args.id);
599
+ var job = data.job;
600
+
601
+ // check if job is still active
602
+ if (job.final) this.die("Job is already completed.");
603
+
604
+ this.printMutationSummary({
605
+ title: 'Abort Job',
606
+ rows: [
607
+ [ 'Job ID', this.color('theme').bold(this.getNiceJob(job)) ],
608
+ [ 'Event', this.getNiceJobEvent(job) ]
609
+ ]
610
+ });
611
+
612
+ var data = await this.callStandardAPI('abortJob', this.args, {
613
+ text: 'Aborting job...'
614
+ });
615
+ if (this.dry) return;
616
+
617
+ this.toast('✅', 'green', "Successfully aborted job: #" + this.args.id );
618
+ },
619
+
620
+ async cmd_delete_job() {
621
+ // delete completed job
622
+ // xy job delete JOB_ID --confirm
623
+ if (this.args.other && this.args.other.length && !this.args.id) {
624
+ this.args.id = this.args.other.shift();
625
+ }
626
+ if (!this.args.id) this.die("Missing required 'id' argument.");
627
+ delete this.args.other;
628
+
629
+ // load everything
630
+ await this.getMultiple();
631
+
632
+ // load job directly
633
+ var { err, data } = await this.api.getJob({ id: this.args.id, remove: 'timelines' });
634
+ if (err) this.die(err);
635
+ if (!data.job) this.die("Job not found: " + this.args.id);
636
+ var job = data.job;
637
+
638
+ // check if job is still active
639
+ if (!job.final) this.die("Cannot delete active job.");
640
+
641
+ this.printMutationSummary({
642
+ title: 'Delete Job',
643
+ rows: [
644
+ [ 'Job ID', this.color('theme').bold(this.getNiceJob(job)) ],
645
+ [ 'Event', this.getNiceJobEvent(job) ]
646
+ ]
647
+ });
648
+ if (this.args.confirm !== true) {
649
+ this.toast('⚠️', 'orange', "Please confirm the delete by adding '--confirm'.");
650
+ return;
651
+ }
652
+ delete this.args.confirm;
653
+
654
+ var data = await this.callStandardAPI('deleteJob', this.args, {
655
+ text: 'Deleting job...'
656
+ });
657
+ if (this.dry) return;
658
+
659
+ this.toast('✅', 'green', "Successfully deleted job: #" + this.args.id );
660
+ },
661
+
662
+ printJobData(data, title) {
663
+ // print job input or output data in verbose mode
664
+ if (!data) return;
665
+
666
+ println( "\n " + this.color('theme').bold( String(title).toUpperCase() ) );
667
+ if (!this.verbose) {
668
+ println( " " + gray("(Shown in verbose mode)") );
669
+ return;
670
+ }
671
+
672
+ var disp_value = JSON.stringify(data, null, "\t").trim().split(/\n/).map( line => ' ' + line ).join("\n");
673
+ println( this.highlight(disp_value, { language: 'json', ignoreIllegals: true }) );
674
+ },
675
+
676
+ printJobOutput(job) {
677
+ // print job output in verbose mode
678
+ if (!job.output && !job.log_file_size) return; // zero output
679
+
680
+ println( "\n " + this.color('theme').bold( 'JOB OUTPUT (' + Tools.getTextFromBytes(job.log_file_size) + ')' ) );
681
+
682
+ if (!job.output && job.log_file_size) {
683
+ // too large, requires separate API call
684
+ println( " " + gray("View using: ") + bold("xy job log " + this.args.id) );
685
+ return;
686
+ }
687
+
688
+ if (!this.verbose) {
689
+ println( " " + gray("(Shown in verbose mode)") );
690
+ return;
691
+ }
692
+
693
+ println( "\n" + job.output.trim() );
694
+ },
695
+
696
+ printMetaLog(job) {
697
+ // Print the chronological job metadata activity log.
698
+ var activity = job.activity || [];
699
+ if (!activity.length) return;
700
+
701
+ var is_workflow = (job.type == 'workflow');
702
+ var title = is_workflow ? 'WORKFLOW LOG' : 'METADATA LOG';
703
+
704
+ // Keep potentially noisy metadata behind verbose mode.
705
+ if (!this.verbose) {
706
+ println( "\n " + this.color('theme').bold(title) );
707
+ println( " " + gray("(Shown in verbose mode)") );
708
+ return;
709
+ }
710
+
711
+ var rows = activity.map( row => {
712
+ var nice_timestamp = row.epoch ? this.getRelativeDateTime(row.epoch, true) : '-';
713
+ var nice_server = '-';
714
+
715
+ if (row.server) {
716
+ var server_id = String(row.server);
717
+
718
+ // Conductor rows use the special "m:hostname" notation.
719
+ if (server_id.match(/^\w+$/)) nice_server = this.getNiceServer(server_id);
720
+ else nice_server = server_id.replace(/^m\:/, '');
721
+ }
722
+ else if (row.username) {
723
+ nice_server = row.username;
724
+ }
725
+
726
+ // Meta-log messages should be single-line table cells.
727
+ var message = String(row.msg || '').replace(/\s+/g, ' ').trim();
728
+ var is_warning = message.match(/^WARNING\:/);
729
+
730
+ // Match the web UI by highlighting IDs and state names.
731
+ var nice_message = message
732
+ .replace(/\#(\w+)/g, function(match) {
733
+ return cyan.bold(match);
734
+ })
735
+ .replace(/\{(.+?)\}/g, function(match, state) {
736
+ return cyan(state);
737
+ });
738
+
739
+ if (is_warning) {
740
+ nice_message = nice_message.replace(/^WARNING\:/, yellow.bold('WARNING:'));
741
+ }
742
+
743
+ if (!is_workflow) {
744
+ return [
745
+ nice_timestamp,
746
+ nice_server,
747
+ nice_message
748
+ ];
749
+ }
750
+
751
+ // Workflow meta rows may also identify the associated node.
752
+ var nice_node_id = '-';
753
+ var nice_node_type = '-';
754
+
755
+ if (row.node) {
756
+ nice_node_id = gray('#' + row.node);
757
+
758
+ var workflow = job.workflow || {};
759
+ var nodes = workflow.nodes || [];
760
+ var node = Tools.findObject( nodes, { id: row.node } );
761
+
762
+ if (node) {
763
+ var node_types = (this.config.ui && this.config.ui.workflow_node_types) || [];
764
+ var type_def = Tools.findObject( node_types, { id: node.type } );
765
+
766
+ nice_node_type = type_def ? type_def.title : Tools.ucfirst(node.type || 'Unknown');
767
+ }
768
+ }
769
+
770
+ return [
771
+ nice_timestamp,
772
+ nice_server,
773
+ nice_node_id,
774
+ nice_node_type,
775
+ nice_message
776
+ ];
777
+ } );
778
+
779
+ this.printBoxTable({
780
+ title: title,
781
+ header: is_workflow ? [ 'Date / Time', 'Server', 'Node ID', 'Type', 'Message' ] : [ 'Date / Time', 'Server', 'Message' ],
782
+ rows: rows
783
+ });
784
+ },
785
+
786
+ async printAdditionalJobs(opts) {
787
+ // print additional jobs for job (with reason column)
788
+ if (!opts) opts = {};
789
+
790
+ var rows = [];
791
+ var list = { length: 0 };
792
+
793
+ if (!opts.jobs || !opts.jobs.length) return;
794
+ var ids = opts.jobs.map( stub => stub.id );
795
+
796
+ cli.progress.start();
797
+ var { err, data } = await this.api.getJobs({
798
+ ids: ids
799
+ });
800
+ if (err) this.die(err);
801
+ cli.progress.end();
802
+
803
+ var rows = data.jobs || [];
804
+ list = { length: rows.length };
805
+
806
+ // Embedded detail screens should omit the section when there are no associated tickets.
807
+ if (opts.hide_empty && !rows.length) return;
808
+
809
+ rows.forEach( function(row, idx) {
810
+ row.reason = opts.jobs[idx].reason;
811
+ } );
812
+
813
+ opts.title = opts.title || 'Additional Jobs';
814
+ opts.header = [ 'Job ID', 'Reason', 'Event', 'Category', 'Plugin', 'Server', 'Result' ];
815
+ opts.rows = rows.map( job => {
816
+ if (!job) return false;
817
+ return [
818
+ this.color('theme').bold( this.getNiceJob(job) ),
819
+ Tools.ucfirst(job.reason),
820
+ this.getNiceJobEvent(job, true),
821
+ this.getNiceCategory(job.category, true),
822
+ this.getNicePlugin(job.plugin, true),
823
+ this.getNiceServer(job.server, true),
824
+ // this.getNiceJobSource(job),
825
+ // this.getNiceTagList( job.tags, false ),
826
+ // this.getRelativeDateTime( job.completed, true ),
827
+ // this.getNiceJobElapsedTime(job, true, true),
828
+ this.getNiceJobResult(job)
829
+ ];
830
+ } );
831
+ opts.list = list;
832
+
833
+ this.printBoxTable(opts);
834
+ },
835
+
836
+ printJobCompletionBanner(job) {
837
+ // print toast for job completion
838
+ var disp = this.getJobResultArgs(job);
839
+
840
+ if (job.code) {
841
+ if (!job.description) job.description = 'Unknown Error (no description provided).';
842
+ }
843
+ else {
844
+ if (!job.description) job.description = 'Job completed successfully.';
845
+ }
846
+
847
+ this.toast(disp.icon, disp.color, job.description );
848
+ },
849
+
850
+ printWorkflowJobs(job) {
851
+ // Render all completed sub-jobs and controller nodes belonging to a completed workflow.
852
+ var workflow = job.workflow || {};
853
+ var nodes = workflow.nodes || [];
854
+ var states = workflow.state || {};
855
+ var workflow_jobs = workflow.jobs || {};
856
+ var items = [];
857
+
858
+ // Controller nodes do not launch real jobs, so construct synthetic rows from their workflow state.
859
+ nodes.filter( node => node.type == 'controller' ).forEach( function(node) {
860
+ var state = states[node.id];
861
+ if (!state || !state.started) return;
862
+ if (!state.completed && !state.error) return;
863
+
864
+ items.push({
865
+ type: 'controller',
866
+ node: node,
867
+ state: state,
868
+ sort_epoch: state.started // for table sort
869
+ });
870
+ } );
871
+
872
+ // Reconstruct job-compatible objects from the compact completed-job stubs.
873
+ // Each workflow node may have launched multiple jobs.
874
+ for (var node_id in workflow_jobs) {
875
+ var node = Tools.findObject( nodes, { id: node_id } );
876
+ if (!node) continue;
877
+
878
+ var node_data = node.data || {};
879
+ var event = node_data.event ?
880
+ Tools.findObject( this.events, { id: node_data.event } ) : null;
881
+
882
+ (workflow_jobs[node_id] || []).forEach( function(job_stub) {
883
+ var sub_job = {
884
+ ...job_stub,
885
+ state: 'complete',
886
+ final: true,
887
+ workflow: {
888
+ node: node_id,
889
+ job: job.id
890
+ }
891
+ };
892
+
893
+ if (event) {
894
+ // Standard event node.
895
+ sub_job.event = event.id;
896
+ sub_job.category = event.category;
897
+ sub_job.plugin = event.plugin;
898
+ sub_job.type = event.type;
899
+ sub_job.plabel = node_data.label;
900
+ }
901
+ else {
902
+ // Ad-hoc job node.
903
+ sub_job.type = 'adhoc';
904
+ sub_job.category = node_data.category;
905
+ sub_job.plugin = node_data.plugin;
906
+ sub_job.plabel = node_data.label;
907
+ sub_job.picon = node_data.icon;
908
+ }
909
+
910
+ items.push({
911
+ type: 'job',
912
+ job: sub_job,
913
+ sort_epoch: sub_job.completed || 0
914
+ });
915
+ } );
916
+ }
917
+
918
+ // Match the web UI by displaying completed workflow activity in chronological order.
919
+ items.sort( function(a, b) {
920
+ return a.sort_epoch - b.sort_epoch;
921
+ } );
922
+
923
+ var rows = items.map( item => {
924
+ if (item.type == 'controller') {
925
+ var node = item.node;
926
+ var state = item.state;
927
+ var node_data = node.data || {};
928
+ var controller_id = node_data.controller || 'controller';
929
+ var controller_menu = (this.config.ui && this.config.ui.workflow_controller_type_menu) || [];
930
+ var controller_def = Tools.findObject( controller_menu, { id: controller_id } ) || { title: Tools.ucfirst(controller_id) };
931
+
932
+ // Failed controller states do not currently receive a completed
933
+ // timestamp, so use the parent workflow completion as their end.
934
+ var completed = state.completed || job.completed || this.epoch;
935
+ var elapsed = this.getNiceJobElapsedTime({
936
+ started: state.started,
937
+ completed: completed
938
+ });
939
+
940
+ var result = state.error ?
941
+ this.color('red').bold('Error') :
942
+ this.color('blue').bold('Complete');
943
+
944
+ return [
945
+ this.color('theme').bold(this.getNiceWorkflowNode(node)),
946
+ controller_def.title,
947
+ gray('(Controller)'),
948
+ '-',
949
+ result,
950
+ elapsed
951
+ ];
952
+ } // controller
953
+
954
+ var sub_job = item.job;
955
+ var result = sub_job.deleted ?
956
+ gray('Deleted (' + this.getJobResultArgs(sub_job).text + ')') :
957
+ this.getNiceJobResult(sub_job);
958
+
959
+ return [
960
+ this.color('theme').bold(this.getNiceJob(sub_job)),
961
+ this.getNiceJobEvent(sub_job),
962
+ this.getNiceCategory(sub_job.category),
963
+ this.getNiceServer(sub_job.server),
964
+ result,
965
+ this.getNiceJobElapsedTime(sub_job)
966
+ ];
967
+ } ); // foreach row
968
+
969
+ this.printBoxTable({
970
+ title: 'Workflow Jobs',
971
+ header: [
972
+ 'Job / Node ID',
973
+ 'Event / Plugin',
974
+ 'Category',
975
+ 'Server',
976
+ 'Result',
977
+ 'Elapsed'
978
+ ],
979
+ rows: rows
980
+ });
981
+ },
982
+
983
+ printJobFiles(job) {
984
+ // Combine input and output files into one table, while preserving their source.
985
+ var files = [];
986
+
987
+ if (job.input && job.input.files) {
988
+ files = files.concat( job.input.files.map( function(file) {
989
+ return { ...file, source: 'input' };
990
+ } ) );
991
+ }
992
+
993
+ if (job.files) {
994
+ files = files.concat( job.files.map( function(file) {
995
+ return { ...file, source: 'output' };
996
+ } ) );
997
+ }
998
+
999
+ // Match the web UI by omitting malformed or incomplete file records.
1000
+ files = files.filter( function(file) {
1001
+ return !!file.filename;
1002
+ } );
1003
+
1004
+ // The web UI hides the entire section when the job has no files.
1005
+ if (!files.length) return;
1006
+
1007
+ var rows = files.map( file => {
1008
+ var nice_source = '';
1009
+
1010
+ if (file.source == 'input') {
1011
+ if (file.bucket) nice_source = this.getNiceBucket(file.bucket);
1012
+ else if (file.plugin) nice_source = this.getNicePlugin(file.plugin);
1013
+ else if (file.ticket) nice_source = 'Ticket ' + file.ticket;
1014
+ else if (file.magic) nice_source = 'Magic Link';
1015
+ else if (file.username) nice_source = file.username;
1016
+ else nice_source = 'Input';
1017
+ }
1018
+ else {
1019
+ nice_source = 'Output';
1020
+ }
1021
+
1022
+ return [
1023
+ cli.emoji('📄') + ' ' + bold(file.filename),
1024
+ Tools.getTextFromBytes(file.size || 0),
1025
+ file.date ? this.getRelativeDateTime(file.date, true) : gray('(Unknown)'),
1026
+ nice_source,
1027
+ this.getNiceJob({ id: file.job || job.id }),
1028
+ file.server ? this.getNiceServer(file.server) : gray('(None)')
1029
+ ];
1030
+ } );
1031
+
1032
+ this.printBoxTable({
1033
+ title: 'Job Files',
1034
+ header: [ 'Filename', 'Size', 'Modified', 'Source', 'Job', 'Server' ],
1035
+ rows: rows
1036
+ });
1037
+
1038
+ // In verbose mode, print full file URLs beneath the table.
1039
+ if (this.verbose) {
1040
+ var base_url = String(this.config.base_url || '').replace(/\/+$/, '');
1041
+
1042
+ var linked_files = files.filter( function(file) {
1043
+ return !!file.path;
1044
+ } );
1045
+
1046
+ if (linked_files.length) {
1047
+ println( "\n " + this.color('theme').bold('FILE URLS') );
1048
+
1049
+ linked_files.forEach( function(file) {
1050
+ var file_path = String(file.path).replace(/^\/+/, '');
1051
+ var url = base_url + '/' + file_path;
1052
+
1053
+ println( ' - ' + url );
1054
+ } );
1055
+ }
1056
+ }
1057
+ },
1058
+
1059
+ getNiceWorkflowNode(node) {
1060
+ // Get a terminal-friendly icon for any workflow node type.
1061
+ if (!node) return '(None)';
1062
+
1063
+ var node_icons = {
1064
+ trigger: '🚀',
1065
+ event: '📅',
1066
+ job: '🔌',
1067
+ action: '⚡',
1068
+ limit: '🚧',
1069
+ controller: '🎛️',
1070
+ note: '📝'
1071
+ };
1072
+
1073
+ // Controller nodes get more specific icons based on their behavior.
1074
+ var controller_icons = {
1075
+ decision: '❓',
1076
+ join: '🔗',
1077
+ multiplex: '📡',
1078
+ repeat: '🔁',
1079
+ split: '🔀',
1080
+ wait: '⏳'
1081
+ };
1082
+
1083
+ var icon = node_icons[node.type] || '🧩';
1084
+
1085
+ if (node.type == 'controller') {
1086
+ var controller_type = node.data && node.data.controller;
1087
+ icon = controller_icons[controller_type] || icon;
1088
+ }
1089
+
1090
+ return cli.emoji(icon) + ' ' + (node.id || '(Unknown)');
1091
+ },
1092
+
1093
+ printJobActions(job) {
1094
+ // Only display actions that actually fired and are not hidden.
1095
+ var actions = (job.actions || []).filter( function(action) {
1096
+ return !!(action.date && !action.hidden);
1097
+ } );
1098
+
1099
+ var workflow = job.workflow;
1100
+
1101
+ // Workflow action nodes store their execution results in the workflow
1102
+ // state object instead of the parent job's normal actions array.
1103
+ if (workflow && workflow.state && workflow.nodes) {
1104
+ workflow.nodes.filter( function(node) {
1105
+ return node.type == 'action';
1106
+ } ).forEach( function(node) {
1107
+ var state = workflow.state[node.id];
1108
+ if (!state || !state.date) return;
1109
+
1110
+ // Copy the state so we do not mutate the original job object.
1111
+ actions.push({
1112
+ ...state,
1113
+ source: 'workflow'
1114
+ });
1115
+ } );
1116
+ }
1117
+
1118
+ // Match the web UI by displaying actions chronologically.
1119
+ actions.sort( function(a, b) {
1120
+ return a.date - b.date;
1121
+ } );
1122
+
1123
+ // The web UI hides this entire section when no actions fired.
1124
+ if (!actions.length) return;
1125
+
1126
+ var rows = actions.map( item => {
1127
+ var disp = this.getJobActionDisplayArgs(item);
1128
+ var source = String(item.source || 'event').toLowerCase();
1129
+ var nice_source = Tools.ucfirst(source);
1130
+
1131
+ if (item.count > 1) {
1132
+ nice_source += ' (x' + Tools.commify(item.count) + ')';
1133
+ }
1134
+
1135
+ // Preserve millisecond precision for quick actions, then switch to the
1136
+ // normal duration formatter for actions lasting one second or longer.
1137
+ var elapsed_ms = Math.max(0, Math.floor(item.elapsed_ms || 0));
1138
+ var nice_elapsed = (elapsed_ms < 1000) ?
1139
+ Tools.commify(elapsed_ms) + ' ms' :
1140
+ Tools.getTextFromSeconds(elapsed_ms / 1000, true, true);
1141
+
1142
+ var nice_result = '';
1143
+ if (item.code) {
1144
+ nice_result =
1145
+ cli.emoji('🛑') + ' ' + this.color('red').bold('Error');
1146
+
1147
+ // The web UI puts this behind a details link. Show it inline in
1148
+ // the CLI so an action failure remains immediately actionable.
1149
+ if (item.description) {
1150
+ var error_text = String(item.description)
1151
+ .replace(/\s+/g, ' ')
1152
+ .trim();
1153
+
1154
+ nice_result += gray(' (' + error_text + ')');
1155
+ }
1156
+ }
1157
+ else {
1158
+ nice_result =
1159
+ cli.emoji('✅') + ' ' + this.color('green').bold('Success');
1160
+ }
1161
+
1162
+ return [
1163
+ this.color(disp.condition.color).bold(disp.condition.title),
1164
+ (disp.type || Tools.ucfirst(item.type || 'Unknown')),
1165
+ nice_source,
1166
+ disp.desc || 'n/a',
1167
+ this.getRelativeDateTime(item.date, true),
1168
+ nice_result,
1169
+ nice_elapsed
1170
+ ];
1171
+ } );
1172
+
1173
+ this.printBoxTable({
1174
+ title: 'Job Actions',
1175
+ header: [
1176
+ 'Condition',
1177
+ 'Type',
1178
+ 'Source',
1179
+ 'Description',
1180
+ 'Date / Time',
1181
+ 'Result',
1182
+ 'Elapsed'
1183
+ ],
1184
+ rows: rows
1185
+ });
1186
+ },
1187
+
1188
+ printJobLimits(job) {
1189
+ // Only display limits that actually triggered and are not hidden.
1190
+ var limits = (job.limits || []).filter( function(limit) {
1191
+ return !!(limit.date && !limit.hidden);
1192
+ } );
1193
+
1194
+ // The web UI hides this entire section when no limits were exceeded.
1195
+ if (!limits.length) return;
1196
+
1197
+ // Limits should normally already be chronological, but explicitly sort
1198
+ // them in case their configuration order differs from trigger order.
1199
+ limits.sort( function(a, b) {
1200
+ return a.date - b.date;
1201
+ } );
1202
+
1203
+ var rows = limits.map( item => {
1204
+ var disp = this.getResLimitDisplayArgs(item);
1205
+ var source = String(item.source || 'event').toLowerCase();
1206
+ var nice_type = (disp.nice_title || Tools.ucfirst(item.type || 'Unknown'));
1207
+ var nice_source = Tools.ucfirst(source);
1208
+
1209
+ // The triggered-limit message describes what was actually exceeded.
1210
+ // Fall back to the configured limit description for older records.
1211
+ var nice_description =
1212
+ item.msg || disp.nice_desc || 'n/a';
1213
+
1214
+ var nice_result = '';
1215
+ if (item.code) {
1216
+ nice_result =
1217
+ cli.emoji('🛑') + ' ' + this.color('red').bold('Error');
1218
+
1219
+ if (item.description) {
1220
+ var error_text = String(item.description)
1221
+ .replace(/\s+/g, ' ')
1222
+ .trim();
1223
+
1224
+ nice_result += gray(' (' + error_text + ')');
1225
+ }
1226
+ }
1227
+ else {
1228
+ nice_result =
1229
+ cli.emoji('✅') + ' ' + this.color('green').bold('OK');
1230
+ }
1231
+
1232
+ return [
1233
+ this.color('theme').bold(nice_type),
1234
+ nice_source,
1235
+ nice_description,
1236
+ this.getRelativeDateTime(item.date, true),
1237
+ nice_result
1238
+ ];
1239
+ } );
1240
+
1241
+ this.printBoxTable({
1242
+ title: 'Job Limits Exceeded',
1243
+ header: [
1244
+ 'Limit Type',
1245
+ 'Source',
1246
+ 'Description',
1247
+ 'Date / Time',
1248
+ 'Result'
1249
+ ],
1250
+ rows: rows
1251
+ });
1252
+ }
1253
+
1254
+ };