@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/groups.js ADDED
@@ -0,0 +1,1038 @@
1
+ // Server Group Management Layer
2
+
3
+ const cli = require('pixl-cli');
4
+ const Tools = cli.Tools;
5
+
6
+ const GROUP_REQUEST_CONCURRENCY = 6;
7
+
8
+ const GROUP_VIEW_FILTERS = [
9
+ 'id', 'title', 'hostname', 'ip', 'os', 'platform', 'os_platform',
10
+ 'distro', 'os_distro', 'release', 'os_release', 'arch', 'os_arch',
11
+ 'cpu', 'satellite', 'group', 'groups', 'status', 'online', 'enabled'
12
+ ];
13
+
14
+ const GROUP_MERGE_MODES = {
15
+ avg: 'average',
16
+ average: 'average',
17
+ max: 'maximum',
18
+ maximum: 'maximum',
19
+ min: 'minimum',
20
+ minimum: 'minimum',
21
+ total: 'total'
22
+ };
23
+
24
+ const GROUP_MERGE_TITLES = {
25
+ average: 'Average',
26
+ maximum: 'Maximum',
27
+ minimum: 'Minimum',
28
+ total: 'Total'
29
+ };
30
+
31
+ module.exports = {
32
+
33
+ async cmd_groups() {
34
+ // The plural command lists the cached Group definitions and never needs a
35
+ // second API request beyond getMultiple.
36
+ await this.cmd_get_groups();
37
+ },
38
+
39
+ async cmd_group() {
40
+ // Named subcommands handle CRUD and history. A bare ID or title opens the
41
+ // combined live monitoring view.
42
+ var cmd = this.args.other.shift();
43
+ if (!cmd) return this.dieUsage('group');
44
+
45
+ switch (cmd) {
46
+ case 'list': await this.cmd_get_groups(); break;
47
+ case 'get': await this.cmd_get_group(); break;
48
+ case 'create': await this.cmd_create_group(); break;
49
+ case 'update': await this.cmd_update_group(); break;
50
+ case 'delete': await this.cmd_delete_group(); break;
51
+ case 'history': await this.cmd_group_history(); break;
52
+ default:
53
+ this.args.other.unshift(cmd);
54
+ await this.cmd_get_group();
55
+ break;
56
+ }
57
+ },
58
+
59
+ async cmd_get_groups() {
60
+ this.prepSearchArgs();
61
+ await this.getMultiple();
62
+ var groups = this.groups.slice(0);
63
+ var is_filtered = !!this.args.other.length;
64
+
65
+ if (is_filtered) {
66
+ var search = this.args.other.join(' ');
67
+ groups = this.findObjectsFuzzy(groups, {
68
+ id: search,
69
+ title: search,
70
+ hostname_match: search,
71
+ notes: search
72
+ }, 1);
73
+ }
74
+ delete this.args.other;
75
+
76
+ if (Tools.numKeys(this.args)) {
77
+ groups = this.findObjectsFuzzy(groups, this.args);
78
+ is_filtered = true;
79
+ }
80
+
81
+ groups.sort( function(a, b) {
82
+ return ((a.sort_order || 0) - (b.sort_order || 0)) || String(a.title).localeCompare(String(b.title));
83
+ });
84
+ if (this.format.match(/json/)) return this.jsonOutput(groups);
85
+
86
+ var server_counts = {};
87
+ Object.values(this.servers || {}).forEach( server => {
88
+ (server.groups || []).forEach( id => { server_counts[id] = (server_counts[id] || 0) + 1; } );
89
+ });
90
+
91
+ this.printPaginatedBoxTable({
92
+ title: is_filtered ? 'Filtered Server Groups' : 'Server Groups',
93
+ header: ['Group ID', 'Title', 'Servers', 'Hostname Pattern', 'Alert Actions', 'Author', 'Modified'],
94
+ rows: groups.slice(this.offset, this.offset + this.limit),
95
+ list: { length: groups.length },
96
+ offset: this.offset,
97
+ limit: this.limit
98
+ }, group => [
99
+ this.color('theme').bold(group.id),
100
+ bold(group.title),
101
+ Tools.commify(server_counts[group.id] || 0),
102
+ group.hostname_match == '(?!)' ? gray('(None)') : group.hostname_match,
103
+ Tools.commify((group.alert_actions || []).length),
104
+ group.username || gray('(Unknown)'),
105
+ this.getRelativeDateTime(group.modified, true)
106
+ ]);
107
+
108
+ this.printSuggestedCommands({
109
+ 'View group details': 'xy group GROUP_ID_OR_TITLE',
110
+ 'Create a server group': 'xy group create --title "My Group" --hostname_match ".+"',
111
+ 'Filter server groups': 'xy groups SEARCH_TEXT'
112
+ });
113
+ },
114
+
115
+ async cmd_get_group() {
116
+ // Pagination applies to every list section on the page. Server filters are
117
+ // evaluated locally before any per-server monitoring requests are started.
118
+ this.prepSearchArgs();
119
+ var selector = this.args.other.shift();
120
+ if (!selector) return this.dieUsage('group get');
121
+ var search = this.args.other.join(' ').trim();
122
+ delete this.args.other;
123
+
124
+ var merge = this.parseGroupMergeMode(this.args.merge);
125
+ var show_monitors = this.verbose || !!this.args.monitors;
126
+ var show_processes = this.verbose || !!this.args.processes;
127
+ var show_connections = this.verbose || !!this.args.connections;
128
+ var upcoming = !!this.args.upcoming;
129
+
130
+ ['monitors', 'processes', 'connections', 'upcoming'].forEach( key => {
131
+ if ((key in this.args) && (typeof(this.args[key]) != 'boolean')) this.die('Group --' + key + ' must be true or false.');
132
+ delete this.args[key];
133
+ });
134
+ delete this.args.merge;
135
+
136
+ await this.getMultiple();
137
+ var group = this.findGroup(selector);
138
+ var all_servers = this.getGroupServers(group);
139
+ var servers = all_servers.slice(0);
140
+ var is_filtered = !!search;
141
+
142
+ if (search) {
143
+ servers = servers.filter( server => this.getServerSearchText(server).includes(search.toLowerCase()) );
144
+ }
145
+ Object.keys(this.args).forEach( key => {
146
+ if (!GROUP_VIEW_FILTERS.includes(key)) {
147
+ var choices = GROUP_VIEW_FILTERS.concat(['merge', 'monitors', 'processes', 'connections', 'upcoming']);
148
+ var suggestion = this.findClosestString(key, choices);
149
+ this.die('Unsupported group view option: "--' + key + '".' + (suggestion ? ' Did you mean "--' + suggestion + '"?' : ''));
150
+ }
151
+ servers = this.filterActiveServers(servers, key, this.args[key]);
152
+ is_filtered = true;
153
+ });
154
+
155
+ // Upcoming mode is deliberately focused. It avoids every monitoring API
156
+ // call and shows only the stable Group summary and prediction table.
157
+ if (upcoming) {
158
+ if (this.format.match(/json/)) {
159
+ var upcoming_jobs = await this.getGroupUpcomingJobs(group);
160
+ return this.jsonOutput({ group: group, servers: all_servers, upcoming: upcoming_jobs });
161
+ }
162
+
163
+ println('\n ' + green.bold('LIVE GROUP VIEW - Real-time'));
164
+ this.printGroupSummary(group, all_servers);
165
+ await this.printUpcomingJobs({
166
+ title: 'Upcoming Group Jobs',
167
+ events: this.getGroupEvents(group)
168
+ });
169
+ return;
170
+ }
171
+
172
+ var quick_result = { servers: {} };
173
+ var quick_request = servers.some( server => !server.offline ) ? this.api.getQuickmonData({ group: group.id }) : null;
174
+ var snapshots = await this.fetchGroupLatestData(servers);
175
+
176
+ if (quick_request) {
177
+ var quick_response = await quick_request;
178
+ if (quick_response.err) this.die(quick_response.err);
179
+ quick_result = quick_response.data || quick_result;
180
+ }
181
+
182
+ var output = {
183
+ group: group,
184
+ merge: merge,
185
+ servers: servers,
186
+ snapshots: snapshots,
187
+ quickmon: quick_result.servers || {}
188
+ };
189
+ if (this.format.match(/json/)) return this.jsonOutput(output);
190
+
191
+ println('\n ' + green.bold('LIVE GROUP VIEW - Real-time'));
192
+ this.printGroupSummary(group, all_servers);
193
+ this.printGroupServers(servers, is_filtered);
194
+ this.printGroupAlerts(group, servers);
195
+ this.printGroupJobs(group, servers);
196
+ this.printGroupQuickmonCharts(group, servers, quick_result.servers || {}, merge);
197
+ this.printGroupMemory(snapshots, merge);
198
+ this.printGroupCPU(snapshots, merge);
199
+
200
+ if (show_monitors) this.printGroupMonitorCharts(group, snapshots, merge, 'live');
201
+ else this.printServerExpansionNotice('Group Monitors', '--monitors');
202
+
203
+ this.printGroupContainers(snapshots);
204
+
205
+ if (show_processes) this.printGroupProcesses(snapshots);
206
+ else this.printServerExpansionNotice('Group Processes', '--processes');
207
+
208
+ if (show_connections) this.printGroupConnections(snapshots);
209
+ else this.printServerExpansionNotice('Group Network Connections', '--connections');
210
+
211
+ this.printSuggestedCommands({
212
+ 'Show group monitors': show_monitors ? '' : 'xy group ' + group.id + ' --monitors',
213
+ 'Show group processes': show_processes ? '' : 'xy group ' + group.id + ' --processes',
214
+ 'Show group connections': show_connections ? '' : 'xy group ' + group.id + ' --connections',
215
+ 'Show all group details': this.verbose ? '' : 'xy group ' + group.id + ' --verbose',
216
+ 'View upcoming group jobs': 'xy group ' + group.id + ' --upcoming',
217
+ 'View group history': 'xy group history ' + group.id + ' YYYY/MM/DD',
218
+ 'Update this group': 'xy group update ' + group.id + ' --icon baguette',
219
+ 'Delete this group': 'xy group delete ' + group.id
220
+ });
221
+ },
222
+
223
+ async cmd_create_group() {
224
+ if (this.args.other.length) return this.dieUsage('group create');
225
+ delete this.args.other;
226
+ var params = this.prepareGroupParams({
227
+ hostname_match: '(?!)',
228
+ icon: '',
229
+ alert_actions: [],
230
+ notes: '',
231
+ max_jobs_per_server: 0
232
+ }, this.args, true);
233
+ if (!params.title) return this.dieUsage('group create');
234
+
235
+ var data = await this.callStandardAPI('createGroup', params, { text: 'Creating server group...' });
236
+ if (this.dry) return;
237
+ if (this.format.match(/json/)) return this.jsonOutput(data.group);
238
+ this.toast('✅', 'green', 'Successfully created server group: #' + data.group.id);
239
+ this.printSuggestedCommands({
240
+ 'View group details': 'xy group ' + data.group.id,
241
+ 'Update this group': 'xy group update ' + data.group.id + ' --icon baguette',
242
+ 'List server groups': 'xy groups'
243
+ });
244
+ },
245
+
246
+ async cmd_update_group() {
247
+ var id = this.consumeGroupID();
248
+ var group = await this.fetchGroup(id);
249
+
250
+ this.printMutationSummary({
251
+ title: 'Update Server Group',
252
+ rows: [
253
+ ['Group ID', gray(group.id)],
254
+ ['Title', this.color('theme').bold(group.title)]
255
+ ]
256
+ });
257
+ if (!await this.confirmSyncUpdate('group', group.id, 'server group')) return;
258
+ if (!Tools.numKeys(this.args)) return this.die('No updates specified for server group.');
259
+ this.printUpdateData(this.args);
260
+
261
+ var params = this.prepareGroupParams(group, this.args, false);
262
+ params.id = id;
263
+ var data = await this.callStandardAPI('updateGroup', params, { text: 'Updating server group...' });
264
+ if (this.dry) return;
265
+ if (this.format.match(/json/)) return this.jsonOutput(data);
266
+ this.toast('✅', 'green', 'Successfully updated server group: #' + id);
267
+ },
268
+
269
+ async cmd_delete_group() {
270
+ var id = this.consumeGroupID();
271
+ var group = await this.fetchGroup(id);
272
+
273
+ this.printMutationSummary({
274
+ title: 'Delete Server Group',
275
+ rows: [
276
+ ['Group ID', gray(group.id)],
277
+ ['Title', this.color('theme').bold(group.title)]
278
+ ]
279
+ });
280
+
281
+ if (this.args.confirm !== true) {
282
+ this.toast('⚠️', 'orange', "Please confirm the server group delete by adding '--confirm'.");
283
+ return;
284
+ }
285
+ delete this.args.confirm;
286
+ if (Tools.numKeys(this.args)) return this.die('Unsupported server group delete option: --' + Tools.firstKey(this.args));
287
+
288
+ var data = await this.callStandardAPI('deleteGroup', { id: id }, { text: 'Deleting server group...' });
289
+ if (this.dry) return;
290
+ if (this.format.match(/json/)) return this.jsonOutput(data);
291
+ this.toast('✅', 'green', 'Successfully deleted server group: #' + id);
292
+ },
293
+
294
+ async cmd_group_history() {
295
+ // Historical membership comes from the Server index for the selected range,
296
+ // blended with currently active matching Servers and de-duplicated by ID.
297
+ this.prepSearchArgs();
298
+ var selector = this.args.other.shift();
299
+ var date_spec = this.args.other.shift();
300
+ if (!selector || !date_spec || this.args.other.length) return this.dieUsage('group history');
301
+ delete this.args.other;
302
+
303
+ var merge = this.parseGroupMergeMode(this.args.merge);
304
+ delete this.args.merge;
305
+ if (Tools.numKeys(this.args)) this.die('Unsupported group history option: "--' + Tools.firstKey(this.args) + '".');
306
+
307
+ var range = this.parseServerHistoryRange(date_spec, 'Group');
308
+ await this.getMultiple();
309
+ var group = this.findGroup(selector);
310
+ var active_servers = Object.values(this.servers || {}).filter( server => {
311
+ return (server.groups || []).includes(group.id) && (!server.created || (server.created < range.end));
312
+ });
313
+ var search_query = 'groups:' + group.id + ' created:<' + range.end + ' modified:>=' + range.start;
314
+
315
+ cli.progress.start({ amount: 1, pct: false, text: gray('→ Finding historical group servers...') });
316
+ var [server_result, alert_result, job_result] = await Promise.all([
317
+ this.api.searchServers({
318
+ query: search_query,
319
+ offset: 0,
320
+ limit: this.config.max_servers_per_group || 1000
321
+ }),
322
+ this.api.searchAlerts({
323
+ query: 'groups:' + group.id + ' start:<' + range.end + ' end:>=' + range.start,
324
+ offset: this.offset,
325
+ limit: this.limit,
326
+ sort_by: '_id',
327
+ sort_dir: -1,
328
+ ttl: 1
329
+ }),
330
+ this.api.searchJobs({
331
+ query: 'groups:' + group.id + ' date:>=' + range.start + ' date:<' + range.end,
332
+ offset: this.offset,
333
+ limit: this.limit
334
+ })
335
+ ]);
336
+ cli.progress.end();
337
+
338
+ var failed = [server_result, alert_result, job_result].find( result => result.err );
339
+ if (failed) this.die(failed.err);
340
+
341
+ var server_map = {};
342
+ (server_result.data.rows || []).concat(active_servers).forEach( server => {
343
+ // Active records are appended last, so their current labels and hardware
344
+ // metadata win over an older indexed copy of the same Server.
345
+ server_map[server.id] = Object.assign({}, server, { offline: false });
346
+ });
347
+ var servers = Object.values(server_map).sort( this.sortGroupServers );
348
+ var snapshots = await this.fetchGroupHistoricalData(servers, range);
349
+ var alerts = {
350
+ rows: alert_result.data.rows || [],
351
+ list: alert_result.data.list || { length: (alert_result.data.rows || []).length }
352
+ };
353
+ var jobs = {
354
+ rows: job_result.data.rows || [],
355
+ list: job_result.data.list || { length: (job_result.data.rows || []).length }
356
+ };
357
+
358
+ var output = {
359
+ group: group,
360
+ merge: merge,
361
+ range: range,
362
+ servers: servers,
363
+ monitors: snapshots,
364
+ alerts: alerts,
365
+ jobs: jobs
366
+ };
367
+ if (this.format.match(/json/)) return this.jsonOutput(output);
368
+
369
+ println('\n ' + green.bold(range.mode.toUpperCase() + ' GROUP HISTORY - ' + range.title));
370
+ this.printGroupHistoricalSummary(group, servers, range);
371
+ this.printGroupHistoricalServers(servers, range);
372
+ this.printGroupMonitorCharts(group, snapshots, merge, 'history', range);
373
+ this.printGroupHistoricalAlerts(alerts, range);
374
+ this.printGroupHistoricalJobs(jobs, range);
375
+
376
+ var prefix = 'xy group history ' + group.id + ' ';
377
+ this.printSuggestedCommands({
378
+ 'View live group state': 'xy group ' + group.id,
379
+ 'View hourly history': range.mode == 'hourly' ? '' : prefix + range.examples.hourly,
380
+ 'View daily history': range.mode == 'daily' ? '' : prefix + range.examples.daily,
381
+ 'View monthly history': range.mode == 'monthly' ? '' : prefix + range.examples.monthly,
382
+ 'View yearly history': range.mode == 'yearly' ? '' : prefix + range.examples.yearly
383
+ });
384
+ },
385
+
386
+ async fetchGroup(id) {
387
+ cli.progress.start({ amount: 1, pct: false, text: gray('→ Loading server group...') });
388
+ var { err, data } = await this.api.getGroup({ id: id });
389
+ cli.progress.end();
390
+ if (err) this.die(err);
391
+ return data.group;
392
+ },
393
+
394
+ consumeGroupID() {
395
+ // Mutations require an exact safe ID so a fuzzy title can never target an
396
+ // unintended Group.
397
+ var id = this.args.other.shift() || this.args.id;
398
+ if (!id) this.die('Missing required Group ID argument.');
399
+ if (this.args.other.length) this.die('Unexpected argument after Group ID: ' + this.args.other[0]);
400
+ if (this.args.id && (this.args.id !== id)) this.die('Conflicting Group ID arguments.');
401
+ if ((typeof(id) != 'string') || !id.match(/^[a-z0-9_]+$/)) this.die('Invalid Group ID: ' + id);
402
+ delete this.args.other;
403
+ delete this.args.id;
404
+ return id;
405
+ },
406
+
407
+ prepareGroupParams(group, input, creating) {
408
+ // Whole-array replacement happens before dotted edits, and singular
409
+ // --action values append to alert_actions just like Category actions.
410
+ var params = creating ? Tools.copyHash(group, true) : {};
411
+ var fields = ['title', 'hostname_match', 'icon', 'notes', 'max_jobs_per_server', 'alert_actions', 'action'];
412
+ if (creating) fields.push('id');
413
+
414
+ Object.keys(input).forEach( key => {
415
+ var root = key.split('.')[0];
416
+ if (!fields.includes(root)) this.die('Unsupported server group option: --' + key);
417
+ if (key.includes('.') && !['alert_actions', 'action'].includes(root)) this.die('Invalid argument path: ' + key);
418
+ var field = root == 'action' ? 'alert_actions' : root;
419
+ if (!creating && (field in group)) params[field] = Tools.copyHash({ value: group[field] }, true).value;
420
+ });
421
+
422
+ Object.keys(input).filter( key => !key.includes('.') ).forEach( key => { params[key] = input[key]; } );
423
+ var dotted = {};
424
+ Object.keys(input).filter( key => key.includes('.') ).forEach( key => { dotted[key] = input[key]; } );
425
+ this.mergeDotArgs(params, dotted);
426
+
427
+ if ('action' in params) {
428
+ if (!('alert_actions' in params)) params.alert_actions = [];
429
+ if (!Array.isArray(params.alert_actions)) this.die('Server Group alert_actions must be a JSON array.');
430
+ Tools.alwaysArray(params.action).forEach( action => {
431
+ if (typeof(action) == 'string') {
432
+ try { action = JSON.parse(action); }
433
+ catch (err) { this.die('Server Group --action must contain a JSON object.'); }
434
+ }
435
+ if (!Tools.isaHash(action)) this.die('Server Group --action must contain a JSON object.');
436
+ params.alert_actions.push(action);
437
+ });
438
+ delete params.action;
439
+ }
440
+ if ('alert_actions' in params) {
441
+ if (!Array.isArray(params.alert_actions)) this.die('Server Group alert_actions must be a JSON array.');
442
+ params.alert_actions.forEach( (action, idx) => {
443
+ if (!Tools.isaHash(action)) this.die('Server Group alert_actions.' + idx + ' must be a JSON object.');
444
+ });
445
+ }
446
+
447
+ if ('title' in params) {
448
+ if ((typeof(params.title) != 'string') || !params.title.trim()) this.die('Server Group title cannot be empty and must be a string.');
449
+ params.title = params.title.trim();
450
+ }
451
+ if ('hostname_match' in params) {
452
+ if (typeof(params.hostname_match) != 'string') this.die('Server Group hostname_match must be a string.');
453
+ params.hostname_match = params.hostname_match || '(?!)';
454
+ try { new RegExp(params.hostname_match); }
455
+ catch (err) { this.die('Invalid server Group hostname_match: ' + err.message); }
456
+ }
457
+ ['icon', 'notes'].forEach( key => {
458
+ if ((key in params) && (typeof(params[key]) != 'string')) this.die('Server Group ' + key + ' must be a string.');
459
+ });
460
+ if ('icon' in params) params.icon = params.icon.replace(/^mdi\-/, '');
461
+ if (('id' in params) && ((typeof(params.id) != 'string') || !params.id.match(/^[a-z0-9_]+$/))) this.die('Invalid Group ID: ' + params.id);
462
+ if (('max_jobs_per_server' in params) && (!Number.isInteger(params.max_jobs_per_server) || (params.max_jobs_per_server < 0))) {
463
+ this.die('Server Group max_jobs_per_server must be a non-negative integer.');
464
+ }
465
+ return params;
466
+ },
467
+
468
+ findGroup(selector) {
469
+ var group = Tools.findObject(this.groups, { id: selector }) || this.findObjectFuzzy(this.groups, { title: selector });
470
+ if (!group) this.die('Could not find server Group based on your criteria: ' + selector);
471
+ return group;
472
+ },
473
+
474
+ getGroupServers(group) {
475
+ // Match the web view exactly: all active members plus cached members updated
476
+ // within the last hour, with active records winning de-duplication.
477
+ var servers = [];
478
+ var active_ids = {};
479
+ Object.values(this.servers || {}).forEach( server => {
480
+ active_ids[server.id] = true;
481
+ if ((server.groups || []).includes(group.id)) servers.push(Object.assign({}, server, { offline: false }));
482
+ });
483
+ Object.values(this.serverCache || {}).forEach( server => {
484
+ if (active_ids[server.id]) return;
485
+ if ((server.modified || 0) <= this.epoch - 3600) return;
486
+ if ((server.groups || []).includes(group.id)) servers.push(Object.assign({}, server, { offline: true }));
487
+ });
488
+ return servers.sort(this.sortGroupServers);
489
+ },
490
+
491
+ parseGroupMergeMode(value) {
492
+ var input = String(value || 'average').toLowerCase();
493
+ var mode = GROUP_MERGE_MODES[input];
494
+ if (!mode) this.die('Group --merge must be one of: avg, average, max, maximum, min, minimum, total.');
495
+ return mode;
496
+ },
497
+
498
+ sortGroupServers(a, b) {
499
+ var left = String(a.title || a.hostname || a.id).toLowerCase();
500
+ var right = String(b.title || b.hostname || b.id).toLowerCase();
501
+ return left.localeCompare(right) || String(a.id).localeCompare(String(b.id));
502
+ },
503
+
504
+ async mapGroupServersLimit(servers, callback, progress_text) {
505
+ // A fixed worker pool prevents large Groups from producing an unbounded
506
+ // number of HTTP requests. Progress advances only after successful loads.
507
+ if (!servers.length) return [];
508
+ var results = new Array(servers.length);
509
+ var next_idx = 0;
510
+ var completed = 0;
511
+ var failed = null;
512
+ var worker = async () => {
513
+ while (!failed) {
514
+ var idx = next_idx++;
515
+ if (idx >= servers.length) return;
516
+ try {
517
+ results[idx] = await callback(servers[idx], idx);
518
+ completed++;
519
+ cli.progress.update(completed);
520
+ }
521
+ catch (err) {
522
+ failed = err;
523
+ }
524
+ }
525
+ };
526
+
527
+ cli.progress.start({ amount: 0, max: servers.length, text: gray('→ ' + progress_text) });
528
+ await Promise.all(Array.from({ length: Math.min(GROUP_REQUEST_CONCURRENCY, servers.length) }, worker));
529
+ cli.progress.end();
530
+ if (failed) this.die(failed);
531
+ return results;
532
+ },
533
+
534
+ async fetchGroupLatestData(servers) {
535
+ return this.mapGroupServersLimit(servers, async server => {
536
+ var result = await this.api.getLatestMonitorData({ server: server.id, sys: 'hourly', limit: 60 });
537
+ if (result.err) throw result.err;
538
+ return {
539
+ server: server,
540
+ data: (result.data.data && result.data.data.data) || {},
541
+ rows: (result.data.rows || []).filter( row => row && row.date && row.totals )
542
+ };
543
+ }, 'Loading group servers...');
544
+ },
545
+
546
+ async fetchGroupHistoricalData(servers, range) {
547
+ return this.mapGroupServersLimit(servers, async server => {
548
+ var result = await this.api.getHistoricalMonitorData({
549
+ server: server.id,
550
+ sys: range.mode,
551
+ date: range.monitor_start,
552
+ limit: range.monitor_limit
553
+ });
554
+ if (result.err) throw result.err;
555
+ return {
556
+ server: server,
557
+ rows: (result.data.rows || []).filter( row => row && row.date && row.totals )
558
+ };
559
+ }, 'Loading historical group monitors...');
560
+ },
561
+
562
+ mergeGroupValues(values, mode) {
563
+ values = values.map( value => Number(value) ).filter( value => Number.isFinite(value) );
564
+ if (!values.length) return 0;
565
+ switch (mode) {
566
+ case 'total': return values.reduce( (total, value) => total + value, 0 );
567
+ case 'minimum': return Math.min(...values);
568
+ case 'maximum': return Math.max(...values);
569
+ default: return values.reduce( (total, value) => total + value, 0 ) / values.length;
570
+ }
571
+ },
572
+
573
+ mergeGroupSeries(layers, mode) {
574
+ // Merge by sample timestamp rather than array index, because individual
575
+ // Server samples may begin or end on different seconds.
576
+ var samples = {};
577
+ layers.forEach( layer => {
578
+ (layer || []).forEach( point => {
579
+ if (!point || !Number.isFinite(Number(point.x)) || !Number.isFinite(Number(point.y))) return;
580
+ var key = String(Number(point.x));
581
+ if (!samples[key]) samples[key] = [];
582
+ samples[key].push(Number(point.y));
583
+ });
584
+ });
585
+ return Object.keys(samples).map(Number).sort( (a, b) => a - b ).map( x => {
586
+ return { x: x, y: this.mergeGroupValues(samples[String(x)], mode) };
587
+ });
588
+ },
589
+
590
+ getGroupMergeTitle(mode) {
591
+ return GROUP_MERGE_TITLES[mode] || GROUP_MERGE_TITLES.average;
592
+ },
593
+
594
+ getGroupChartTitle(title, mode) {
595
+ // Avoid awkward labels such as "Total Total Processes" and "Average CPU
596
+ // Load Average" when the original Monitor title already names an aggregate.
597
+ title = String(title || '').replace(/^Total\s+/i, '').replace(/\s+Average$/i, '');
598
+ return this.getGroupMergeTitle(mode) + ' ' + title;
599
+ },
600
+
601
+ getVisibleGroupMonitors(group) {
602
+ return (this.monitors || []).filter( def => {
603
+ return def.display && (!def.groups || !def.groups.length || def.groups.includes(group.id));
604
+ }).sort( (a, b) => (a.sort_order || 0) - (b.sort_order || 0) || String(a.title).localeCompare(String(b.title)) );
605
+ },
606
+
607
+ printGroupSummary(group, servers) {
608
+ var stats = ((((this.stats || {}).currentDay || {}).groups || {})[group.id]) || {};
609
+ var info = servers.map( server => this.getServerRecordInfo(server) );
610
+ var unique = values => Array.from(new Set(values.filter( value => !!value ))).join(', ') || gray('(None)');
611
+ var oses = info.map( item => [item.distro || item.platform, item.release].filter( value => !!value ).join(' ') );
612
+ var cpu_types = info.map( item => item.cpu.combo || item.cpu.brand || item.cpu.vendor );
613
+ var virts = info.map( item => item.virt.vendor || item.virt.type || 'None' );
614
+ var completed = stats.job_complete || 0;
615
+
616
+ this.printBoxList({
617
+ title: 'Group Summary',
618
+ rows: [
619
+ ['Group ID', this.color('theme').bold(group.id)],
620
+ ['Group Title', bold(group.title)],
621
+ ['Created', this.getRelativeDateTime(group.created, true)],
622
+ ['Modified', this.getRelativeDateTime(group.modified, true)],
623
+ ['Hostname Match', group.hostname_match == '(?!)' ? gray('(None)') : group.hostname_match],
624
+ ['Servers', Tools.commify(servers.length)],
625
+ ['Alert Actions', Tools.commify((group.alert_actions || []).length)],
626
+ ['Max Jobs per Server', group.max_jobs_per_server ? Tools.commify(group.max_jobs_per_server) : gray('(Unlimited)')],
627
+ ['Author', group.username || gray('(Unknown)')],
628
+ ['Architectures', unique(info.map( item => item.arch ))],
629
+ ['Operating Systems', unique(oses)],
630
+ ['CPU Types', unique(cpu_types)],
631
+ ['Virtualization', unique(virts)],
632
+ ['Alerts Today', Tools.commify(stats.alert_new || 0)],
633
+ ['Jobs Today', Tools.commify(completed)],
634
+ ['Jobs Failed Today', Tools.commify(stats.job_error || 0)],
635
+ ['Job Success Rate', completed ? Tools.pct((stats.job_success || 0) / completed) : gray('(n/a)')]
636
+ ]
637
+ });
638
+ if (group.notes) this.printUserNotes('GROUP NOTES', group.notes);
639
+ },
640
+
641
+ printGroupServers(servers, filtered) {
642
+ this.printPaginatedBoxTable({
643
+ title: filtered ? 'Filtered Group Servers' : 'Group Servers',
644
+ header: ['Server ID', 'Server', 'Status', 'IP Address', 'Groups', 'CPUs', 'RAM', 'Arch', 'OS', 'xySat', 'Uptime', 'Jobs', 'Alerts'],
645
+ rows: servers.slice(this.offset, this.offset + this.limit),
646
+ list: { length: servers.length },
647
+ offset: this.offset,
648
+ limit: this.limit
649
+ }, server => {
650
+ var info = this.getServerRecordInfo(server);
651
+ var os = [info.distro || info.platform, info.release].filter( item => !!item ).join(' ') || gray('(Unknown)');
652
+ var groups = (server.groups || []).map( id => this.getNiceGroup(id) ).join(', ') || gray('(None)');
653
+ var jobs = Object.values(this.activeJobs || {}).filter( job => job.server == server.id ).length;
654
+ var alerts = Object.values(this.activeAlerts || {}).filter( alert => alert.server == server.id ).length;
655
+ var status = server.offline ? gray('Offline') : (server.enabled ? green('Online') : yellow('Disabled'));
656
+ var uptime = (!server.offline && server.info && server.info.booted) ? Tools.getTextFromSeconds(this.epoch - server.info.booted, false, true) : gray('(Offline)');
657
+ return [
658
+ this.color('theme').bold(server.id),
659
+ bold(server.title || server.hostname || server.id),
660
+ status,
661
+ info.ip || gray('(Unknown)'),
662
+ groups,
663
+ Tools.commify(info.cpu.cores || 0),
664
+ this.formatServerBytes(info.memory.total || 0),
665
+ info.arch || gray('(Unknown)'),
666
+ os,
667
+ info.satellite ? 'v' + String(info.satellite).replace(/^v/, '') : gray('(Unknown)'),
668
+ uptime,
669
+ jobs ? Tools.commify(jobs) : gray('Idle'),
670
+ alerts ? Tools.commify(alerts) : gray('None')
671
+ ];
672
+ });
673
+ },
674
+
675
+ printGroupAlerts(group, servers) {
676
+ var ids = new Set(servers.map( server => server.id ));
677
+ var alerts = Object.values(this.activeAlerts || {}).filter( alert => ids.has(alert.server) );
678
+ if (!alerts.length) return;
679
+ alerts.sort( (a, b) => (b.date || 0) - (a.date || 0) );
680
+ this.printActiveAlerts({ title: 'Group Alerts', rows: alerts });
681
+ },
682
+
683
+ printGroupJobs(group, servers) {
684
+ var ids = new Set(servers.map( server => server.id ));
685
+ var jobs = Object.values(this.activeJobs || {}).filter( job => ids.has(job.server) );
686
+ jobs.sort( (a, b) => (b.started || 0) - (a.started || 0) );
687
+ this.printActiveJobs({ title: 'Group Jobs', rows: jobs.slice(this.offset, this.offset + this.limit) });
688
+ },
689
+
690
+ printGroupQuickmonCharts(group, servers, quickmon, mode, heading, include_offline) {
691
+ var ids = new Set(servers.filter( server => include_offline || !server.offline ).map( server => server.id ));
692
+ var definitions = (this.config.quick_monitors || []).filter( def => {
693
+ return !def.groups || !def.groups.length || def.groups.includes(group.id);
694
+ });
695
+ var has_data = Object.keys(quickmon).some( id => ids.has(id) && (quickmon[id] || []).length );
696
+ if (!definitions.length || !has_data) return;
697
+
698
+ println('\n ' + this.color('theme').bold(heading || 'GROUP QUICK LOOK - LAST MINUTE'));
699
+ definitions.forEach( (def, idx) => {
700
+ var layers = [];
701
+ ids.forEach( id => {
702
+ var data = (quickmon[id] || []).map( row => {
703
+ var value = Tools.getPath(row, def.source);
704
+ if (typeof(value) != 'number') value = row[def.id];
705
+ return { x: row.date, y: typeof(value) == 'number' ? value : 0 };
706
+ });
707
+ if (data.length) layers.push(data);
708
+ });
709
+ println('\n' + cli.chart({
710
+ title: this.getGroupChartTitle(def.title, mode),
711
+ data: this.mergeGroupSeries(layers, mode),
712
+ dataType: def.type,
713
+ dataSuffix: def.suffix || '',
714
+ minVertScale: def.min_vert_scale || 0,
715
+ delta: !!def.delta,
716
+ deltaMinValue: def.delta_min_value ?? false,
717
+ divideByDelta: !!def.divide_by_delta,
718
+ height: 10,
719
+ indent: 1,
720
+ color: this.getServerChartColor(idx)
721
+ }));
722
+ });
723
+ },
724
+
725
+ printGroupMemory(snapshots, mode) {
726
+ var keys = [
727
+ ['Memory Used', 'used'],
728
+ ['Memory Active', 'active'],
729
+ ['Memory Available', 'available'],
730
+ ['Memory Free', 'free'],
731
+ ['Memory Buffered', 'buffers'],
732
+ ['Memory Cached', 'cached']
733
+ ];
734
+ var memories = snapshots.map( item => item.data.memory || item.data.mem ).filter( item => !!item );
735
+ var total = this.mergeGroupValues(memories.map( item => item.total || 0 ), mode);
736
+ var prefix = this.getGroupMergeTitle(mode);
737
+
738
+ this.printBoxTable({
739
+ title: prefix + ' Group Memory Details',
740
+ header: ['Metric', 'Value', 'Visual'],
741
+ rows: total ? keys.map( item => {
742
+ var value = this.mergeGroupValues(memories.map( memory => Math.max(0, memory[item[1]] || 0) ), mode);
743
+ return [prefix + ' ' + item[0], this.formatServerBytes(value), cli.progress.bar({ amount: value, max: total || 1, width: 30, pct: false })];
744
+ }) : []
745
+ });
746
+ },
747
+
748
+ printGroupCPU(snapshots, mode) {
749
+ var keys = [
750
+ ['CPU User', 'user'],
751
+ ['CPU System', 'system'],
752
+ ['CPU Nice', 'nice'],
753
+ ['CPU I/O Wait', 'iowait'],
754
+ ['CPU Hard IRQ', 'irq'],
755
+ ['CPU Soft IRQ', 'softirq']
756
+ ];
757
+ var cpus = snapshots.map( item => (item.data.cpu || {}).totals ).filter( item => !!item );
758
+ var total = this.mergeGroupValues(cpus.map( item => 100 ), mode);
759
+ var prefix = this.getGroupMergeTitle(mode);
760
+
761
+ this.printBoxTable({
762
+ title: prefix + ' Group CPU Details',
763
+ header: ['Metric', 'Value', 'Visual'],
764
+ rows: cpus.length ? keys.map( item => {
765
+ var value = this.mergeGroupValues(cpus.map( cpu => Math.max(0, cpu[item[1]] || 0) ), mode);
766
+ return [prefix + ' ' + item[0], this.formatServerPercent(value), cli.progress.bar({ amount: value, max: total || 1, width: 30, pct: false })];
767
+ }) : []
768
+ });
769
+ },
770
+
771
+ printGroupMonitorGrid(group, snapshots, mode) {
772
+ // Merge each Server's saved point-in-time values using the same semantics
773
+ // as the Group charts. This is primarily used by Group Snapshot views.
774
+ var monitors = this.getVisibleGroupMonitors(group);
775
+ if (!monitors.length) return;
776
+ var prefix = this.getGroupMergeTitle(mode);
777
+ var units = monitors.map( def => {
778
+ var values = snapshots.map( item => {
779
+ return def.delta ?
780
+ ((item.data.deltas || {})[def.id] || 0) :
781
+ ((item.data.monitors || {})[def.id] || 0);
782
+ });
783
+ var value = this.mergeGroupValues(values, mode);
784
+ return [this.getServerMonitorLabel(prefix + ' ' + def.title), this.formatServerMetric(value, def.data_type, def.suffix)];
785
+ });
786
+
787
+ println('\n ' + this.color('theme').bold('GROUP MONITOR SUMMARY'));
788
+ println('' + cli.dashGrid(units, {
789
+ minCols: 3,
790
+ maxCols: 6,
791
+ gap: 1,
792
+ indent: 1,
793
+ valueStyles: ['bold', 'green']
794
+ }));
795
+ },
796
+
797
+ printGroupMonitorCharts(group, snapshots, mode, view, range, heading) {
798
+ var monitors = this.getVisibleGroupMonitors(group);
799
+ heading = heading || (view == 'history' ? 'GROUP MONITORS - ' + range.title.toUpperCase() : 'GROUP MONITORS - LAST HOUR');
800
+ println('\n ' + this.color('theme').bold(heading));
801
+ if (!monitors.length) {
802
+ println(' ' + gray('No visible monitors apply to this server group.'));
803
+ return;
804
+ }
805
+ if (!snapshots.some( item => item.rows.length )) {
806
+ println(' ' + gray(view == 'history' ? 'No monitoring data found in the selected range.' : 'No recent monitoring data found.'));
807
+ return;
808
+ }
809
+
810
+ monitors.forEach( (def, idx) => {
811
+ var layers = snapshots.map( item => this.getServerMonitorChartData(item.rows, def) ).filter( data => data.length );
812
+ println('\n' + cli.chart({
813
+ title: this.getGroupChartTitle(def.title, mode),
814
+ data: this.mergeGroupSeries(layers, mode),
815
+ dataType: def.data_type,
816
+ dataSuffix: def.suffix || '',
817
+ minVertScale: def.min_vert_scale || 0,
818
+ delta: !!def.delta,
819
+ deltaMinValue: def.delta_min_value ?? false,
820
+ divideByDelta: !!def.divide_by_delta,
821
+ height: 10,
822
+ indent: 1,
823
+ color: this.getServerChartColor(idx)
824
+ }));
825
+ });
826
+ },
827
+
828
+ printGroupContainers(snapshots) {
829
+ var rows = [];
830
+ snapshots.filter( item => !item.server.offline ).forEach( item => {
831
+ (Tools.getPath(item.data, 'docker.containers') || []).forEach( container => {
832
+ rows.push(Object.assign({}, container, { server: item.server.id }));
833
+ });
834
+ });
835
+ if (!rows.length) return;
836
+ rows.sort( (a, b) => (b.cpu || 0) - (a.cpu || 0) );
837
+
838
+ this.printPaginatedBoxTable({
839
+ title: 'Group Containers',
840
+ header: ['Container Name', 'ID', 'Server', 'CPU', 'Mem Usage', 'Mem Total', 'Net In', 'Net Out', 'Disk Read', 'Disk Write'],
841
+ rows: rows.slice(this.offset, this.offset + this.limit),
842
+ list: { length: rows.length },
843
+ offset: this.offset,
844
+ limit: this.limit
845
+ }, item => [
846
+ bold(item.name || '(Unknown)'),
847
+ item.id || '',
848
+ this.getNiceServer(item.server),
849
+ this.formatServerPercent(item.cpu),
850
+ this.formatServerBytes(item.mem_usage || 0),
851
+ this.formatServerBytes(item.mem_total || 0),
852
+ this.formatServerBytes(item.net_read || 0),
853
+ this.formatServerBytes(item.net_write || 0),
854
+ this.formatServerBytes(item.disk_read || 0),
855
+ this.formatServerBytes(item.disk_write || 0)
856
+ ]);
857
+ },
858
+
859
+ printGroupProcesses(snapshots) {
860
+ var rows = [];
861
+ snapshots.filter( item => !item.server.offline ).forEach( item => {
862
+ (((item.data.processes || {}).list) || []).forEach( process => {
863
+ rows.push(Object.assign({}, process, { server: item.server.id }));
864
+ });
865
+ });
866
+ rows.sort( (a, b) => (b.cpu || 0) - (a.cpu || 0) );
867
+
868
+ this.printPaginatedBoxTable({
869
+ title: 'Group Processes',
870
+ header: ['Command', 'Server', 'User', 'PID', 'Parent', 'CPU', 'Memory', 'Age', 'State'],
871
+ rows: rows.slice(this.offset, this.offset + this.limit),
872
+ list: { length: rows.length },
873
+ offset: this.offset,
874
+ limit: this.limit
875
+ }, process => [
876
+ bold(this.getServerProcessName(process)),
877
+ this.getNiceServer(process.server),
878
+ process.user || '-',
879
+ process.pid,
880
+ process.parentPid || 'n/a',
881
+ this.formatServerPercent(process.cpu),
882
+ this.formatServerBytes(process.memRss || 0),
883
+ Tools.getTextFromSeconds(process.age || 0, true, true),
884
+ this.getServerTitleCase(process.state || 'unknown')
885
+ ]);
886
+ },
887
+
888
+ printGroupConnections(snapshots) {
889
+ var rows = [];
890
+ snapshots.filter( item => !item.server.offline ).forEach( item => {
891
+ var processes = ((item.data.processes || {}).list) || [];
892
+ (item.data.conns || []).forEach( connection => {
893
+ var process = connection.pid ? Tools.findObject(processes, { pid: connection.pid }) : null;
894
+ rows.push(Object.assign({}, connection, {
895
+ server: item.server.id,
896
+ process_name: process ? this.getServerProcessName(process) : (connection.pid || '')
897
+ }));
898
+ });
899
+ });
900
+ rows.sort( (a, b) => String(a.state || '').localeCompare(String(b.state || '')) );
901
+
902
+ this.printPaginatedBoxTable({
903
+ title: 'Group Network Connections',
904
+ header: ['State', 'Server', 'Protocol', 'Local Address', 'Remote Address', 'Process', 'Bytes In', 'Bytes Out'],
905
+ rows: rows.slice(this.offset, this.offset + this.limit),
906
+ list: { length: rows.length },
907
+ offset: this.offset,
908
+ limit: this.limit
909
+ }, connection => [
910
+ this.getServerTitleCase(connection.state || 'unknown'),
911
+ this.getNiceServer(connection.server),
912
+ String(connection.type || '').toUpperCase(),
913
+ connection.local_addr || '',
914
+ connection.remote_addr || '',
915
+ connection.process_name || gray('(None)'),
916
+ this.formatServerBytes(connection.bytes_in || 0),
917
+ this.formatServerBytes(connection.bytes_out || 0)
918
+ ]);
919
+ },
920
+
921
+ printGroupHistoricalSummary(group, servers, range) {
922
+ var info = servers.map( server => this.getServerRecordInfo(server) );
923
+ var unique = values => Array.from(new Set(values.filter( value => !!value ))).join(', ') || gray('(None)');
924
+ var oses = info.map( item => [item.distro || item.platform, item.release].filter( value => !!value ).join(' ') );
925
+ var cpu_types = info.map( item => item.cpu.combo || item.cpu.brand || item.cpu.vendor );
926
+ var virts = info.map( item => item.virt.vendor || item.virt.type || 'None' );
927
+
928
+ this.printBoxList({
929
+ title: 'Group Summary - ' + range.title,
930
+ rows: [
931
+ ['Group ID', this.color('theme').bold(group.id)],
932
+ ['Group Title', bold(group.title)],
933
+ ['Created', this.getRelativeDateTime(group.created, true)],
934
+ ['Modified', this.getRelativeDateTime(group.modified, true)],
935
+ ['Hostname Match', group.hostname_match == '(?!)' ? gray('(None)') : group.hostname_match],
936
+ ['Servers', Tools.commify(servers.length)],
937
+ ['Alert Actions', Tools.commify((group.alert_actions || []).length)],
938
+ ['Author', group.username || gray('(Unknown)')],
939
+ ['Architectures', unique(info.map( item => item.arch ))],
940
+ ['Operating Systems', unique(oses)],
941
+ ['CPU Types', unique(cpu_types)],
942
+ ['Virtualization', unique(virts)]
943
+ ]
944
+ });
945
+ },
946
+
947
+ printGroupHistoricalServers(servers, range) {
948
+ this.printPaginatedBoxTable({
949
+ title: 'Group Servers - ' + range.title,
950
+ header: ['Server ID', 'Server', 'IP Address', 'Groups', 'CPUs', 'RAM', 'Arch', 'OS', 'xySat', 'Uptime', 'Jobs', 'Alerts'],
951
+ rows: servers.slice(this.offset, this.offset + this.limit),
952
+ list: { length: servers.length },
953
+ offset: this.offset,
954
+ limit: this.limit
955
+ }, server => {
956
+ var info = this.getServerRecordInfo(server);
957
+ var os = [info.distro || info.platform, info.release].filter( item => !!item ).join(' ') || gray('(Unknown)');
958
+ var groups = (server.groups || []).map( id => this.getNiceGroup(id) ).join(', ') || gray('(None)');
959
+ var uptime = (server.info && server.info.booted) ? Tools.getTextFromSeconds(this.epoch - server.info.booted, false, true) : gray('(n/a)');
960
+ return [
961
+ this.color('theme').bold(server.id),
962
+ bold(server.title || server.hostname || server.id),
963
+ info.ip || gray('(Unknown)'),
964
+ groups,
965
+ Tools.commify(info.cpu.cores || 0),
966
+ this.formatServerBytes(info.memory.total || 0),
967
+ info.arch || gray('(Unknown)'),
968
+ os,
969
+ info.satellite ? 'v' + String(info.satellite).replace(/^v/, '') : gray('(Unknown)'),
970
+ uptime,
971
+ gray('n/a'),
972
+ gray('n/a')
973
+ ];
974
+ });
975
+ },
976
+
977
+ printGroupHistoricalAlerts(result, range) {
978
+ this.printPaginatedBoxTable({
979
+ title: 'Group Alerts - ' + range.title,
980
+ header: ['Alert ID', 'Title', 'Message', 'Server', 'Status', 'Started', 'Duration'],
981
+ rows: result.rows,
982
+ list: result.list,
983
+ offset: this.offset,
984
+ limit: this.limit
985
+ }, alert => {
986
+ var completed = alert.active ? this.epoch : (alert.modified || alert.date);
987
+ return [
988
+ this.color('theme').bold(alert.id),
989
+ this.getNiceAlert(alert.alert),
990
+ String(alert.message || '').replace(/\s+/g, ' ').trim(),
991
+ this.getNiceServer(alert.server),
992
+ this.getNiceAlertStatus(alert),
993
+ this.getRelativeDateTime(alert.date, true),
994
+ Tools.getTextFromSeconds(Math.max(0, completed - alert.date), true, true)
995
+ ];
996
+ });
997
+ },
998
+
999
+ printGroupHistoricalJobs(result, range) {
1000
+ this.printPaginatedBoxTable({
1001
+ title: 'Group Jobs - ' + range.title,
1002
+ header: ['Job ID', 'Server', 'Source', 'Started', 'Elapsed', 'Avg CPU/Mem', 'Result'],
1003
+ rows: result.rows,
1004
+ list: result.list,
1005
+ offset: this.offset,
1006
+ limit: this.limit
1007
+ }, job => {
1008
+ var avg_cpu = (job.cpu && job.cpu.count) ? (job.cpu.total / job.cpu.count) : 0;
1009
+ var avg_mem = (job.mem && job.mem.count) ? (job.mem.total / job.mem.count) : 0;
1010
+ return [
1011
+ this.color('theme').bold(this.getNiceJob(job)),
1012
+ this.getNiceServer(job.server),
1013
+ this.getNiceJobSource(job),
1014
+ this.getRelativeDateTime(job.started, true),
1015
+ this.getNiceJobElapsedTime(job, true, true),
1016
+ this.formatServerPercent(avg_cpu) + ' / ' + this.formatServerBytes(avg_mem),
1017
+ this.getNiceJobResult(job)
1018
+ ];
1019
+ });
1020
+ },
1021
+
1022
+ getGroupEvents(group) {
1023
+ return (this.events || []).filter( event => (event.targets || []).includes(group.id) );
1024
+ },
1025
+
1026
+ async getGroupUpcomingJobs(group) {
1027
+ return new Promise( resolve => {
1028
+ this.predictUpcomingJobs({
1029
+ events: this.getGroupEvents(group),
1030
+ duration: 86400 * 32,
1031
+ burn: 16,
1032
+ max: 1000,
1033
+ callback: resolve
1034
+ });
1035
+ });
1036
+ }
1037
+
1038
+ }; // module.exports