diff --git a/ui/.prettierrc b/ui/.prettierrc
index 7f9eaa64f7f..546369158eb 100644
--- a/ui/.prettierrc
+++ b/ui/.prettierrc
@@ -1,3 +1,13 @@
-printWidth: 100
-singleQuote: true
-trailingComma: es5
+{
+ "printWidth": 100,
+ "singleQuote": true,
+ "trailingComma": "es5",
+ "overrides": [
+ {
+ "files": "*.hbs",
+ "options": {
+ "singleQuote": false
+ }
+ }
+ ]
+}
diff --git a/ui/app/components/allocation-row.js b/ui/app/components/allocation-row.js
index 0a9fb1c0a0a..a92c618adae 100644
--- a/ui/app/components/allocation-row.js
+++ b/ui/app/components/allocation-row.js
@@ -65,7 +65,9 @@ export default class AllocationRow extends Component {
do {
if (this.stats) {
try {
- yield this.get('stats.poll').perform();
+ yield this.get('stats.poll')
+ .linked()
+ .perform();
this.set('statsError', false);
} catch (error) {
this.set('statsError', true);
diff --git a/ui/app/components/distribution-bar.js b/ui/app/components/distribution-bar.js
index ee9af8456d6..50cd5b5c664 100644
--- a/ui/app/components/distribution-bar.js
+++ b/ui/app/components/distribution-bar.js
@@ -22,6 +22,7 @@ const sumAggregate = (total, val) => total + val;
export default class DistributionBar extends Component.extend(WindowResizable) {
chart = null;
@overridable(() => null) data;
+ onSliceClick = null;
activeDatum = null;
isNarrow = false;
@@ -33,11 +34,13 @@ export default class DistributionBar extends Component.extend(WindowResizable) {
const data = copy(this.data, true);
const sum = data.mapBy('value').reduce(sumAggregate, 0);
- return data.map(({ label, value, className, layers }, index) => ({
+ return data.map(({ label, value, className, layers, legendLink, help }, index) => ({
label,
value,
className,
layers,
+ legendLink,
+ help,
index,
percent: value / sum,
offset:
@@ -121,8 +124,14 @@ export default class DistributionBar extends Component.extend(WindowResizable) {
const activeDatum = this.activeDatum;
const isActive = activeDatum && activeDatum.label === d.label;
const isInactive = activeDatum && activeDatum.label !== d.label;
- return [ className, isActive && 'active', isInactive && 'inactive' ].compact().join(' ');
- });
+ const isClickable = !!this.onSliceClick;
+ return [
+ className,
+ isActive && 'active',
+ isInactive && 'inactive',
+ isClickable && 'clickable'
+ ].compact().join(' ');
+ }).attr('data-test-slice-label', d => d.className);
this.set('slices', slices);
@@ -172,6 +181,10 @@ export default class DistributionBar extends Component.extend(WindowResizable) {
.attr('height', '6px')
.attr('y', '50%');
}
+
+ if (this.onSliceClick) {
+ slices.on('click', this.onSliceClick);
+ }
}
/* eslint-enable */
diff --git a/ui/app/components/job-client-status-bar.js b/ui/app/components/job-client-status-bar.js
new file mode 100644
index 00000000000..9f3690b01f2
--- /dev/null
+++ b/ui/app/components/job-client-status-bar.js
@@ -0,0 +1,120 @@
+import { computed } from '@ember/object';
+import DistributionBar from './distribution-bar';
+import classic from 'ember-classic-decorator';
+
+@classic
+export default class JobClientStatusBar extends DistributionBar {
+ layoutName = 'components/distribution-bar';
+
+ 'data-test-job-client-status-bar' = true;
+ job = null;
+ jobClientStatus = null;
+
+ @computed('job.namespace', 'jobClientStatus.byStatus')
+ get data() {
+ const {
+ queued,
+ starting,
+ running,
+ complete,
+ degraded,
+ failed,
+ lost,
+ notScheduled,
+ } = this.jobClientStatus.byStatus;
+
+ return [
+ {
+ label: 'Queued',
+ value: queued.length,
+ className: 'queued',
+ legendLink: {
+ queryParams: {
+ status: JSON.stringify(['queued']),
+ namespace: this.job.namespace.get('id'),
+ },
+ },
+ },
+ {
+ label: 'Starting',
+ value: starting.length,
+ className: 'starting',
+ legendLink: {
+ queryParams: {
+ status: JSON.stringify(['starting']),
+ namespace: this.job.namespace.get('id'),
+ },
+ },
+ layers: 2,
+ },
+ {
+ label: 'Running',
+ value: running.length,
+ className: 'running',
+ legendLink: {
+ queryParams: {
+ status: JSON.stringify(['running']),
+ namespace: this.job.namespace.get('id'),
+ },
+ },
+ },
+ {
+ label: 'Complete',
+ value: complete.length,
+ className: 'complete',
+ legendLink: {
+ queryParams: {
+ status: JSON.stringify(['complete']),
+ namespace: this.job.namespace.get('id'),
+ },
+ },
+ },
+ {
+ label: 'Degraded',
+ value: degraded.length,
+ className: 'degraded',
+ legendLink: {
+ queryParams: {
+ status: JSON.stringify(['degraded']),
+ namespace: this.job.namespace.get('id'),
+ },
+ },
+ help: 'Some allocations for this job were not successfull or did not run.',
+ },
+ {
+ label: 'Failed',
+ value: failed.length,
+ className: 'failed',
+ legendLink: {
+ queryParams: {
+ status: JSON.stringify(['failed']),
+ namespace: this.job.namespace.get('id'),
+ },
+ },
+ },
+ {
+ label: 'Lost',
+ value: lost.length,
+ className: 'lost',
+ legendLink: {
+ queryParams: {
+ status: JSON.stringify(['lost']),
+ namespace: this.job.namespace.get('id'),
+ },
+ },
+ },
+ {
+ label: 'Not Scheduled',
+ value: notScheduled.length,
+ className: 'not-scheduled',
+ legendLink: {
+ queryParams: {
+ status: JSON.stringify(['notScheduled']),
+ namespace: this.job.namespace.get('id'),
+ },
+ },
+ help: 'No allocations for this job were scheduled into these clients.',
+ },
+ ];
+ }
+}
diff --git a/ui/app/components/job-client-status-row.js b/ui/app/components/job-client-status-row.js
new file mode 100644
index 00000000000..dc467fbf5d8
--- /dev/null
+++ b/ui/app/components/job-client-status-row.js
@@ -0,0 +1,89 @@
+import EmberObject from '@ember/object';
+import Component from '@glimmer/component';
+
+export default class ClientRow extends Component {
+ // Attribute set in the template as @onClick.
+ onClick() {}
+
+ get row() {
+ return this.args.row.model;
+ }
+
+ get shouldDisplayAllocationSummary() {
+ return this.args.row.model.jobStatus !== 'notScheduled';
+ }
+
+ get allocationSummaryPlaceholder() {
+ switch (this.args.row.model.jobStatus) {
+ case 'notScheduled':
+ return 'Not Scheduled';
+ default:
+ return '';
+ }
+ }
+
+ get humanizedJobStatus() {
+ switch (this.args.row.model.jobStatus) {
+ case 'notScheduled':
+ return 'not scheduled';
+ default:
+ return this.args.row.model.jobStatus;
+ }
+ }
+
+ get jobStatusClass() {
+ switch (this.args.row.model.jobStatus) {
+ case 'notScheduled':
+ return 'not-scheduled';
+ default:
+ return this.args.row.model.jobStatus;
+ }
+ }
+
+ get allocationContainer() {
+ const statusSummary = {
+ queuedAllocs: 0,
+ completeAllocs: 0,
+ failedAllocs: 0,
+ runningAllocs: 0,
+ startingAllocs: 0,
+ lostAllocs: 0,
+ };
+
+ switch (this.args.row.model.jobStatus) {
+ case 'notSchedule':
+ break;
+ case 'queued':
+ statusSummary.queuedAllocs = this.args.row.model.allocations.length;
+ break;
+ case 'starting':
+ statusSummary.startingAllocs = this.args.row.model.allocations.length;
+ break;
+ default:
+ for (const alloc of this.args.row.model.allocations) {
+ switch (alloc.clientStatus) {
+ case 'running':
+ statusSummary.runningAllocs++;
+ break;
+ case 'lost':
+ statusSummary.lostAllocs++;
+ break;
+ case 'failed':
+ statusSummary.failedAllocs++;
+ break;
+ case 'complete':
+ statusSummary.completeAllocs++;
+ break;
+ case 'starting':
+ statusSummary.startingAllocs++;
+ break;
+ }
+ }
+ }
+
+ const Allocations = EmberObject.extend({
+ ...statusSummary,
+ });
+ return Allocations.create();
+ }
+}
diff --git a/ui/app/components/job-page/parameterized-child.js b/ui/app/components/job-page/parameterized-child.js
index 3f941067c06..daf0e418340 100644
--- a/ui/app/components/job-page/parameterized-child.js
+++ b/ui/app/components/job-page/parameterized-child.js
@@ -1,11 +1,14 @@
import { computed } from '@ember/object';
import { alias } from '@ember/object/computed';
+import { inject as service } from '@ember/service';
import PeriodicChildJobPage from './periodic-child';
import classic from 'ember-classic-decorator';
+import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class ParameterizedChild extends PeriodicChildJobPage {
@alias('job.decodedPayload') payload;
+ @service store;
@computed('payload')
get payloadJSON() {
@@ -17,4 +20,10 @@ export default class ParameterizedChild extends PeriodicChildJobPage {
}
return json;
}
+
+ @jobClientStatus('nodes', 'job') jobClientStatus;
+
+ get nodes() {
+ return this.store.peekAll('node');
+ }
}
diff --git a/ui/app/components/job-page/parts/job-client-status-summary.js b/ui/app/components/job-page/parts/job-client-status-summary.js
new file mode 100644
index 00000000000..209d5258b9e
--- /dev/null
+++ b/ui/app/components/job-page/parts/job-client-status-summary.js
@@ -0,0 +1,28 @@
+import Component from '@ember/component';
+import { action, computed } from '@ember/object';
+import { classNames } from '@ember-decorators/component';
+import classic from 'ember-classic-decorator';
+
+@classic
+@classNames('boxed-section')
+export default class JobClientStatusSummary extends Component {
+ job = null;
+ jobClientStatus = null;
+ gotoClients() {}
+
+ @computed
+ get isExpanded() {
+ const storageValue = window.localStorage.nomadExpandJobClientStatusSummary;
+ return storageValue != null ? JSON.parse(storageValue) : true;
+ }
+
+ @action
+ onSliceClick(slice) {
+ this.gotoClients([slice.className.camelize()]);
+ }
+
+ persist(item, isOpen) {
+ window.localStorage.nomadExpandJobClientStatusSummary = isOpen;
+ this.notifyPropertyChange('isExpanded');
+ }
+}
diff --git a/ui/app/components/job-page/parts/summary.js b/ui/app/components/job-page/parts/summary.js
index 87ca0a4c38a..0cb821cc834 100644
--- a/ui/app/components/job-page/parts/summary.js
+++ b/ui/app/components/job-page/parts/summary.js
@@ -7,9 +7,12 @@ import classic from 'ember-classic-decorator';
@classNames('boxed-section')
export default class Summary extends Component {
job = null;
+ forceCollapsed = false;
- @computed
+ @computed('forceCollapsed')
get isExpanded() {
+ if (this.forceCollapsed) return false;
+
const storageValue = window.localStorage.nomadExpandJobSummary;
return storageValue != null ? JSON.parse(storageValue) : true;
}
diff --git a/ui/app/components/job-page/periodic-child.js b/ui/app/components/job-page/periodic-child.js
index dfe42225dc3..d581d88dc29 100644
--- a/ui/app/components/job-page/periodic-child.js
+++ b/ui/app/components/job-page/periodic-child.js
@@ -1,9 +1,13 @@
import AbstractJobPage from './abstract';
import { computed } from '@ember/object';
+import { inject as service } from '@ember/service';
import classic from 'ember-classic-decorator';
+import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class PeriodicChild extends AbstractJobPage {
+ @service store;
+
@computed('job.{name,id}', 'job.parent.{name,id}')
get breadcrumbs() {
const job = this.job;
@@ -21,4 +25,10 @@ export default class PeriodicChild extends AbstractJobPage {
},
];
}
+
+ @jobClientStatus('nodes', 'job') jobClientStatus;
+
+ get nodes() {
+ return this.store.peekAll('node');
+ }
}
diff --git a/ui/app/components/job-page/sysbatch.js b/ui/app/components/job-page/sysbatch.js
new file mode 100644
index 00000000000..0819ed49426
--- /dev/null
+++ b/ui/app/components/job-page/sysbatch.js
@@ -0,0 +1,15 @@
+import AbstractJobPage from './abstract';
+import classic from 'ember-classic-decorator';
+import { inject as service } from '@ember/service';
+import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
+
+@classic
+export default class Sysbatch extends AbstractJobPage {
+ @service store;
+
+ @jobClientStatus('nodes', 'job') jobClientStatus;
+
+ get nodes() {
+ return this.store.peekAll('node');
+ }
+}
diff --git a/ui/app/components/job-page/system.js b/ui/app/components/job-page/system.js
index bf2c0444246..5909c8f163d 100644
--- a/ui/app/components/job-page/system.js
+++ b/ui/app/components/job-page/system.js
@@ -1,5 +1,15 @@
import AbstractJobPage from './abstract';
import classic from 'ember-classic-decorator';
+import { inject as service } from '@ember/service';
+import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
-export default class System extends AbstractJobPage {}
+export default class System extends AbstractJobPage {
+ @service store;
+
+ @jobClientStatus('nodes', 'job') jobClientStatus;
+
+ get nodes() {
+ return this.store.peekAll('node');
+ }
+}
diff --git a/ui/app/components/task-row.js b/ui/app/components/task-row.js
index cde652fa7ab..e47ff85883d 100644
--- a/ui/app/components/task-row.js
+++ b/ui/app/components/task-row.js
@@ -54,7 +54,9 @@ export default class TaskRow extends Component {
do {
if (this.stats) {
try {
- yield this.get('stats.poll').perform();
+ yield this.get('stats.poll')
+ .linked()
+ .perform();
this.set('statsError', false);
} catch (error) {
this.set('statsError', true);
diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js
index 12c05fbd1f9..4dcf47d6227 100644
--- a/ui/app/controllers/jobs/index.js
+++ b/ui/app/controllers/jobs/index.js
@@ -83,6 +83,7 @@ export default class IndexController extends Controller.extend(Sortable, Searcha
{ key: 'periodic', label: 'Periodic' },
{ key: 'service', label: 'Service' },
{ key: 'system', label: 'System' },
+ { key: 'sysbatch', label: 'System Batch' },
];
}
diff --git a/ui/app/controllers/jobs/job/clients.js b/ui/app/controllers/jobs/job/clients.js
new file mode 100644
index 00000000000..2e14798bf56
--- /dev/null
+++ b/ui/app/controllers/jobs/job/clients.js
@@ -0,0 +1,192 @@
+/* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */
+import Controller from '@ember/controller';
+import { action, computed } from '@ember/object';
+import { scheduleOnce } from '@ember/runloop';
+import intersection from 'lodash.intersection';
+import { alias } from '@ember/object/computed';
+import SortableFactory from 'nomad-ui/mixins/sortable-factory';
+import Searchable from 'nomad-ui/mixins/searchable';
+import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting';
+import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
+import { serialize, deserializedQueryParam as selection } from 'nomad-ui/utils/qp-serialize';
+import classic from 'ember-classic-decorator';
+
+@classic
+export default class ClientsController extends Controller.extend(
+ SortableFactory(['id', 'name', 'jobStatus']),
+ Searchable,
+ WithNamespaceResetting
+ ) {
+ queryParams = [
+ {
+ currentPage: 'page',
+ },
+ {
+ searchTerm: 'search',
+ },
+ {
+ qpStatus: 'status',
+ },
+ {
+ qpDatacenter: 'dc',
+ },
+ {
+ qpClientClass: 'clientclass',
+ },
+ {
+ sortProperty: 'sort',
+ },
+ {
+ sortDescending: 'desc',
+ },
+ ];
+
+ qpStatus = '';
+ qpDatacenter = '';
+ qpClientClass = '';
+
+ currentPage = 1;
+ pageSize = 25;
+
+ sortProperty = 'jobStatus';
+ sortDescending = false;
+
+ @selection('qpStatus') selectionStatus;
+ @selection('qpDatacenter') selectionDatacenter;
+ @selection('qpClientClass') selectionClientClass;
+
+ @alias('model') job;
+ @jobClientStatus('allNodes', 'job') jobClientStatus;
+
+ @alias('filteredNodes') listToSort;
+ @alias('listSorted') listToSearch;
+ @alias('listSearched') sortedClients;
+
+ @computed('store')
+ get allNodes() {
+ return this.store.peekAll('node').length
+ ? this.store.peekAll('node')
+ : this.store.findAll('node');
+ }
+
+ @computed('allNodes', 'jobClientStatus.byNode')
+ get nodes() {
+ return this.allNodes.filter(node => this.jobClientStatus.byNode[node.id]);
+ }
+
+ @computed
+ get searchProps() {
+ return ['node.id', 'node.name'];
+ }
+
+ @computed(
+ 'nodes',
+ 'job.allocations',
+ 'jobClientStatus.byNode',
+ 'selectionStatus',
+ 'selectionDatacenter',
+ 'selectionClientClass'
+ )
+ get filteredNodes() {
+ const {
+ selectionStatus: statuses,
+ selectionDatacenter: datacenters,
+ selectionClientClass: clientClasses,
+ } = this;
+
+ return this.nodes
+ .filter(node => {
+ if (statuses.length && !statuses.includes(this.jobClientStatus.byNode[node.id])) {
+ return false;
+ }
+ if (datacenters.length && !datacenters.includes(node.datacenter)) {
+ return false;
+ }
+ if (clientClasses.length && !clientClasses.includes(node.nodeClass)) {
+ return false;
+ }
+
+ return true;
+ })
+ .map(node => {
+ const allocations = this.job.allocations.filter(alloc => alloc.get('node.id') == node.id);
+
+ return {
+ node,
+ jobStatus: this.jobClientStatus.byNode[node.id],
+ allocations,
+ createTime: eldestCreateTime(allocations),
+ modifyTime: mostRecentModifyTime(allocations),
+ };
+ });
+ }
+
+ @computed
+ get optionsJobStatus() {
+ return [
+ { key: 'queued', label: 'Queued' },
+ { key: 'notScheduled', label: 'Not Scheduled' },
+ { key: 'starting', label: 'Starting' },
+ { key: 'running', label: 'Running' },
+ { key: 'complete', label: 'Complete' },
+ { key: 'degraded', label: 'Degraded' },
+ { key: 'failed', label: 'Failed' },
+ { key: 'lost', label: 'Lost' },
+ ];
+ }
+
+ @computed('selectionDatacenter', 'nodes')
+ get optionsDatacenter() {
+ const datacenters = Array.from(new Set(this.nodes.mapBy('datacenter'))).compact();
+
+ // Update query param when the list of datacenters changes.
+ scheduleOnce('actions', () => {
+ // eslint-disable-next-line ember/no-side-effects
+ this.set('qpDatacenter', serialize(intersection(datacenters, this.selectionDatacenter)));
+ });
+
+ return datacenters.sort().map(dc => ({ key: dc, label: dc }));
+ }
+
+ @computed('selectionClientClass', 'nodes')
+ get optionsClientClass() {
+ const clientClasses = Array.from(new Set(this.nodes.mapBy('nodeClass'))).compact();
+
+ // Update query param when the list of datacenters changes.
+ scheduleOnce('actions', () => {
+ // eslint-disable-next-line ember/no-side-effects
+ this.set('qpClientClass', serialize(intersection(clientClasses, this.selectionClientClass)));
+ });
+
+ return clientClasses.sort().map(clientClass => ({ key: clientClass, label: clientClass }));
+ }
+
+ @action
+ gotoClient(client) {
+ this.transitionToRoute('clients.client', client);
+ }
+
+ setFacetQueryParam(queryParam, selection) {
+ this.set(queryParam, serialize(selection));
+ }
+}
+
+function eldestCreateTime(allocations) {
+ let eldest = null;
+ for (const alloc of allocations) {
+ if (!eldest || alloc.createTime < eldest) {
+ eldest = alloc.createTime;
+ }
+ }
+ return eldest;
+}
+
+function mostRecentModifyTime(allocations) {
+ let mostRecent = null;
+ for (const alloc of allocations) {
+ if (!mostRecent || alloc.modifyTime > mostRecent) {
+ mostRecent = alloc.modifyTime;
+ }
+ }
+ return mostRecent;
+}
diff --git a/ui/app/controllers/jobs/job/index.js b/ui/app/controllers/jobs/job/index.js
index eae91858f7a..2b663959f97 100644
--- a/ui/app/controllers/jobs/job/index.js
+++ b/ui/app/controllers/jobs/job/index.js
@@ -39,4 +39,14 @@ export default class IndexController extends Controller.extend(WithNamespaceRese
queryParams: { jobNamespace: job.get('namespace.name') },
});
}
+
+ @action
+ gotoClients(statusFilter) {
+ this.transitionToRoute('jobs.job.clients', this.job, {
+ queryParams: {
+ status: JSON.stringify(statusFilter),
+ namespace: this.job.get('namespace.name'),
+ },
+ });
+ }
}
diff --git a/ui/app/models/job.js b/ui/app/models/job.js
index 79323d280ae..8182546dfde 100644
--- a/ui/app/models/job.js
+++ b/ui/app/models/job.js
@@ -7,7 +7,7 @@ import RSVP from 'rsvp';
import { assert } from '@ember/debug';
import classic from 'ember-classic-decorator';
-const JOB_TYPES = ['service', 'batch', 'system'];
+const JOB_TYPES = ['service', 'batch', 'system', 'sysbatch'];
@classic
export default class Job extends Model {
@@ -39,6 +39,11 @@ export default class Job extends Model {
return this.periodic || (this.parameterized && !this.dispatched);
}
+ @computed('type')
+ get hasClientStatus() {
+ return this.type === 'system' || this.type === 'sysbatch';
+ }
+
@belongsTo('job', { inverse: 'children' }) parent;
@hasMany('job', { inverse: 'parent' }) children;
diff --git a/ui/app/router.js b/ui/app/router.js
index 072291fe673..8e5c2070895 100644
--- a/ui/app/router.js
+++ b/ui/app/router.js
@@ -23,6 +23,7 @@ Router.map(function() {
this.route('dispatch');
this.route('evaluations');
this.route('allocations');
+ this.route('clients');
});
});
diff --git a/ui/app/routes/jobs/job/clients.js b/ui/app/routes/jobs/job/clients.js
new file mode 100644
index 00000000000..71b9d23e869
--- /dev/null
+++ b/ui/app/routes/jobs/job/clients.js
@@ -0,0 +1,30 @@
+import Route from '@ember/routing/route';
+import WithWatchers from 'nomad-ui/mixins/with-watchers';
+import { watchRecord, watchRelationship, watchAll } from 'nomad-ui/utils/properties/watch';
+import { collect } from '@ember/object/computed';
+
+export default class ClientsRoute extends Route.extend(WithWatchers) {
+ async model() {
+ await this.store.findAll('node');
+ return this.modelFor('jobs.job');
+ }
+
+ startWatchers(controller, model) {
+ if (!model) {
+ return;
+ }
+
+ controller.set('watchers', {
+ model: this.watch.perform(model),
+ allocations: this.watchAllocations.perform(model),
+ nodes: this.watchNodes.perform(),
+ });
+ }
+
+ @watchRecord('job') watch;
+ @watchAll('node') watchNodes;
+ @watchRelationship('allocations') watchAllocations;
+
+ @collect('watch', 'watchNodes', 'watchAllocations')
+ watchers;
+}
diff --git a/ui/app/routes/jobs/job/index.js b/ui/app/routes/jobs/job/index.js
index 8f1cf1827fe..463edee0101 100644
--- a/ui/app/routes/jobs/job/index.js
+++ b/ui/app/routes/jobs/job/index.js
@@ -1,9 +1,20 @@
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
-import { watchRecord, watchRelationship, watchQuery } from 'nomad-ui/utils/properties/watch';
+import {
+ watchRecord,
+ watchRelationship,
+ watchAll,
+ watchQuery,
+} from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default class IndexRoute extends Route.extend(WithWatchers) {
+ async model() {
+ // Optimizing future node look ups by preemptively loading everything
+ await this.store.findAll('node');
+ return this.modelFor('jobs.job');
+ }
+
startWatchers(controller, model) {
if (!model) {
return;
@@ -18,6 +29,7 @@ export default class IndexRoute extends Route.extend(WithWatchers) {
list:
model.get('hasChildren') &&
this.watchAllJobs.perform({ namespace: model.namespace.get('name') }),
+ nodes: model.get('hasClientStatus') && this.watchNodes.perform(),
});
}
@@ -35,6 +47,7 @@ export default class IndexRoute extends Route.extend(WithWatchers) {
@watchRecord('job') watch;
@watchQuery('job') watchAllJobs;
+ @watchAll('node') watchNodes;
@watchRecord('job-summary') watchSummary;
@watchRelationship('allocations') watchAllocations;
@watchRelationship('evaluations') watchEvaluations;
@@ -46,7 +59,8 @@ export default class IndexRoute extends Route.extend(WithWatchers) {
'watchSummary',
'watchAllocations',
'watchEvaluations',
- 'watchLatestDeployment'
+ 'watchLatestDeployment',
+ 'watchNodes'
)
watchers;
}
diff --git a/ui/app/styles/charts/colors.scss b/ui/app/styles/charts/colors.scss
index 50f370607f6..1676fdc8937 100644
--- a/ui/app/styles/charts/colors.scss
+++ b/ui/app/styles/charts/colors.scss
@@ -4,6 +4,8 @@ $running: $primary;
$complete: $nomad-green-dark;
$failed: $danger;
$lost: $dark;
+$not-scheduled: $blue-200;
+$degraded: $warning;
.chart {
.queued {
@@ -37,6 +39,14 @@ $lost: $dark;
.lost {
fill: $lost;
}
+
+ .not-scheduled {
+ fill: $not-scheduled;
+ }
+
+ .degraded {
+ fill: $degraded;
+ }
}
.color-swatch {
@@ -102,6 +112,14 @@ $lost: $dark;
background: $lost;
}
+ &.not-scheduled {
+ background: $not-scheduled;
+ }
+
+ &.degraded {
+ background: $degraded;
+ }
+
@each $name, $pair in $colors {
$color: nth($pair, 1);
diff --git a/ui/app/styles/charts/distribution-bar.scss b/ui/app/styles/charts/distribution-bar.scss
index fcd4a782425..507f8bdb93f 100644
--- a/ui/app/styles/charts/distribution-bar.scss
+++ b/ui/app/styles/charts/distribution-bar.scss
@@ -13,6 +13,10 @@
opacity: 1;
}
+ .clickable {
+ cursor: pointer;
+ }
+
.inactive {
opacity: 0.2;
}
@@ -63,16 +67,48 @@
// Ensure two columns, but don't use the full width
width: 35%;
- .label,
- .value {
- display: inline;
- font-weight: $weight-normal;
+ .legend-item {
+ display: flex;
+ align-items: center;
+
+ .color-swatch {
+ margin-right: 0.5rem;
+ }
+
+ .text {
+ flex-grow: 1;
+
+ .label,
+ .value {
+ display: inline;
+ font-weight: $weight-normal;
+ }
+ }
+
+ .icon {
+ width: 1.2rem;
+ height: 1.2rem;
+ }
}
&.is-active {
background-color: rgba($info, 0.1);
}
+ &.is-clickable {
+ a {
+ display: block;
+ text-decoration: none;
+ color: inherit;
+ }
+
+ &:not(.is-empty) {
+ &:hover {
+ background-color: rgba($info, 0.1);
+ }
+ }
+ }
+
&.is-empty {
color: darken($grey-blue, 20%);
border: none;
diff --git a/ui/app/styles/components/accordion.scss b/ui/app/styles/components/accordion.scss
index 2b59864a2cd..7f21d5d83db 100644
--- a/ui/app/styles/components/accordion.scss
+++ b/ui/app/styles/components/accordion.scss
@@ -33,6 +33,11 @@
.accordion-head-content {
width: 100%;
margin-right: 1.5em;
+
+ .tooltip {
+ margin-left: 0.5rem;
+ margin-right: 0.5rem;
+ }
}
.accordion-toggle {
diff --git a/ui/app/templates/components/job-client-status-row.hbs b/ui/app/templates/components/job-client-status-row.hbs
new file mode 100644
index 00000000000..08540a00c27
--- /dev/null
+++ b/ui/app/templates/components/job-client-status-row.hbs
@@ -0,0 +1,41 @@
+
+
+
+ {{this.row.node.shortId}}
+
+ |
+
+ {{this.row.node.name}}
+ |
+
+ {{#if this.row.createTime}}
+
+ {{moment-from-now this.row.createTime}}
+
+ {{else}}
+ -
+ {{/if}}
+ |
+
+ {{#if this.row.modifyTime}}
+
+ {{moment-from-now this.row.modifyTime}}
+
+ {{else}}
+ -
+ {{/if}}
+ |
+
+
+ {{this.humanizedJobStatus}}
+ |
+
+ {{#if this.shouldDisplayAllocationSummary}}
+
+ {{else}}
+ {{this.allocationSummaryPlaceholder}}
+ {{/if}}
+ |
+
\ No newline at end of file
diff --git a/ui/app/templates/components/job-page/parameterized-child.hbs b/ui/app/templates/components/job-page/parameterized-child.hbs
index 881c9af49ae..5a4777057c8 100644
--- a/ui/app/templates/components/job-page/parameterized-child.hbs
+++ b/ui/app/templates/components/job-page/parameterized-child.hbs
@@ -19,7 +19,15 @@
-
+ {{#if this.job.hasClientStatus}}
+
+ {{/if}}
+
+
@@ -62,4 +70,4 @@
{{/if}}
-
+
\ No newline at end of file
diff --git a/ui/app/templates/components/job-page/parts/job-client-status-summary.hbs b/ui/app/templates/components/job-page/parts/job-client-status-summary.hbs
new file mode 100644
index 00000000000..837a6c34a0f
--- /dev/null
+++ b/ui/app/templates/components/job-page/parts/job-client-status-summary.hbs
@@ -0,0 +1,55 @@
+
+
+
+
+ Job Status in Client
+
+ {{this.jobClientStatus.totalNodes}}
+
+
+ {{x-icon "info-circle-outline" class="is-faded"}}
+
+
+ {{#unless a.isOpen}}
+
+ {{/unless}}
+
+
+
+
+
+ {{#each chart.data as |datum index|}}
+ -
+ {{#if (gt datum.value 0)}}
+
+
+
+ {{else}}
+
+ {{/if}}
+
+ {{/each}}
+
+
+
+
diff --git a/ui/app/templates/components/job-page/parts/summary-legend-item.hbs b/ui/app/templates/components/job-page/parts/summary-legend-item.hbs
new file mode 100644
index 00000000000..ab7997fbe6c
--- /dev/null
+++ b/ui/app/templates/components/job-page/parts/summary-legend-item.hbs
@@ -0,0 +1,12 @@
+
+
+
+ {{@datum.value}}
+ {{@datum.label}}
+
+ {{#if @datum.help}}
+
+ {{x-icon "info-circle-outline" class="is-faded"}}
+
+ {{/if}}
+
diff --git a/ui/app/templates/components/job-page/parts/summary.hbs b/ui/app/templates/components/job-page/parts/summary.hbs
index e90cdff5375..244e4f7d329 100644
--- a/ui/app/templates/components/job-page/parts/summary.hbs
+++ b/ui/app/templates/components/job-page/parts/summary.hbs
@@ -40,15 +40,10 @@
{{#each chart.data as |datum index|}}
-
-
- {{datum.value}}
-
- {{datum.label}}
-
+
{{/each}}
{{/component}}
-
diff --git a/ui/app/templates/components/job-page/periodic-child.hbs b/ui/app/templates/components/job-page/periodic-child.hbs
index 3c14a25fc26..965c5a41a76 100644
--- a/ui/app/templates/components/job-page/periodic-child.hbs
+++ b/ui/app/templates/components/job-page/periodic-child.hbs
@@ -19,7 +19,15 @@
-
+ {{#if this.job.hasClientStatus}}
+
+ {{/if}}
+
+
@@ -30,4 +38,4 @@
@gotoTaskGroup={{this.gotoTaskGroup}} />
-
+
\ No newline at end of file
diff --git a/ui/app/templates/components/job-page/sysbatch.hbs b/ui/app/templates/components/job-page/sysbatch.hbs
new file mode 100644
index 00000000000..35f6d6faa3a
--- /dev/null
+++ b/ui/app/templates/components/job-page/sysbatch.hbs
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+ Type:{{this.job.type}} |
+ Priority:{{this.job.priority}}
+ {{#if (and this.job.namespace this.system.shouldShowNamespaces)}}
+ | Namespace:{{this.job.namespace.name}}
+ {{/if}}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/components/job-page/system.hbs b/ui/app/templates/components/job-page/system.hbs
index 5e0abbea25f..1f1db67d186 100644
--- a/ui/app/templates/components/job-page/system.hbs
+++ b/ui/app/templates/components/job-page/system.hbs
@@ -19,7 +19,12 @@
{{/each}}
{{/if}}
-
+
+
+
diff --git a/ui/app/templates/components/job-subnav.hbs b/ui/app/templates/components/job-subnav.hbs
index a655756d862..67d9974dbc5 100644
--- a/ui/app/templates/components/job-subnav.hbs
+++ b/ui/app/templates/components/job-subnav.hbs
@@ -1,12 +1,15 @@
- - Overview
- - Definition
- - Versions
+ - Overview
+ - Definition
+ - Versions
{{#if this.job.supportsDeployments}}
- - Deployments
+ - Deployments
+ {{/if}}
+ - Allocations
+ - Evaluations
+ {{#if (and this.job.hasClientStatus (not this.job.hasChildren))}}
+ - Clients
{{/if}}
- - Allocations
- - Evaluations
diff --git a/ui/app/templates/jobs/job/clients.hbs b/ui/app/templates/jobs/job/clients.hbs
new file mode 100644
index 00000000000..b61d6dacfe8
--- /dev/null
+++ b/ui/app/templates/jobs/job/clients.hbs
@@ -0,0 +1,106 @@
+{{page-title "Job " this.job.name " clients"}}
+
+
+ {{#if this.nodes.length}}
+
+ {{#if this.sortedClients}}
+
+
+
+ Client ID
+ Client Name
+ Created
+ Modified
+ Job Status
+ Allocation Summary |
+
+
+
+
+
+
+
+ {{else}}
+
+
+
+ No Matches
+
+
+ No clients match the term
+
+ {{this.searchTerm}}
+
+
+
+
+ {{/if}}
+ {{else}}
+
+
+
+ No Clients
+
+
+ No clients available.
+
+
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/ui/app/templates/jobs/job/index.hbs b/ui/app/templates/jobs/job/index.hbs
index 56d18ec2e57..41834690f89 100644
--- a/ui/app/templates/jobs/job/index.hbs
+++ b/ui/app/templates/jobs/job/index.hbs
@@ -5,4 +5,6 @@
sortDescending=this.sortDescending
currentPage=this.currentPage
gotoJob=(action "gotoJob")
- gotoTaskGroup=(action "gotoTaskGroup")}}
+ gotoTaskGroup=(action "gotoTaskGroup")
+ gotoClients=(action "gotoClients")
+}}
\ No newline at end of file
diff --git a/ui/app/utils/properties/job-client-status.js b/ui/app/utils/properties/job-client-status.js
new file mode 100644
index 00000000000..c6482076c06
--- /dev/null
+++ b/ui/app/utils/properties/job-client-status.js
@@ -0,0 +1,141 @@
+import { computed } from '@ember/object';
+
+const STATUS = [
+ 'queued',
+ 'notScheduled',
+ 'starting',
+ 'running',
+ 'complete',
+ 'degraded',
+ 'failed',
+ 'lost',
+];
+
+// An Ember.Computed property that computes the aggregated status of a job in a
+// client based on the desiredStatus of each allocation placed in the client.
+//
+// ex. clientStaus: jobClientStatus('nodes', 'job'),
+export default function jobClientStatus(nodesKey, jobKey) {
+ return computed(
+ `${nodesKey}.[]`,
+ `${jobKey}.{datacenters,status,allocations.@each.clientStatus,taskGroups}`,
+ function() {
+ const job = this.get(jobKey);
+ const nodes = this.get(nodesKey);
+
+ // Filter nodes by the datacenters defined in the job.
+ const filteredNodes = nodes.filter(n => {
+ return job.datacenters.indexOf(n.datacenter) >= 0;
+ });
+
+ if (job.status === 'pending') {
+ return allQueued(filteredNodes);
+ }
+
+ // Group the job allocations by the ID of the client that is running them.
+ const allocsByNodeID = {};
+ job.allocations.forEach(a => {
+ const nodeId = a.node.get('id');
+ if (!allocsByNodeID[nodeId]) {
+ allocsByNodeID[nodeId] = [];
+ }
+ allocsByNodeID[nodeId].push(a);
+ });
+
+ const result = {
+ byNode: {},
+ byStatus: {},
+ totalNodes: filteredNodes.length,
+ };
+ filteredNodes.forEach(n => {
+ const status = jobStatus(allocsByNodeID[n.id], job.taskGroups.length);
+ result.byNode[n.id] = status;
+
+ if (!result.byStatus[status]) {
+ result.byStatus[status] = [];
+ }
+ result.byStatus[status].push(n.id);
+ });
+ result.byStatus = canonicalizeStatus(result.byStatus);
+ return result;
+ }
+ );
+}
+
+function allQueued(nodes) {
+ const nodeIDs = nodes.map(n => n.id);
+ return {
+ byNode: Object.fromEntries(nodeIDs.map(id => [id, 'queued'])),
+ byStatus: canonicalizeStatus({ queued: nodeIDs }),
+ totalNodes: nodes.length,
+ };
+}
+
+// canonicalizeStatus makes sure all possible statuses are present in the final
+// returned object. Statuses missing from the input will be assigned an emtpy
+// array.
+function canonicalizeStatus(status) {
+ for (let i = 0; i < STATUS.length; i++) {
+ const s = STATUS[i];
+ if (!status[s]) {
+ status[s] = [];
+ }
+ }
+ return status;
+}
+
+// jobStatus computes the aggregated status of a job in a client.
+//
+// `allocs` are the list of allocations for a job that are placed in a specific
+// client.
+// `expected` is the number of allocations the client should have.
+function jobStatus(allocs, expected) {
+ // The `pending` status has already been checked, so if at this point the
+ // client doesn't have any allocations we assume that it was not considered
+ // for scheduling for some reason.
+ if (!allocs) {
+ return 'notScheduled';
+ }
+
+ // If there are some allocations, but not how many we expected, the job is
+ // considered `degraded` since it did fully run in this client.
+ if (allocs.length < expected) {
+ return 'degraded';
+ }
+
+ // Count how many allocations are in each `clientStatus` value.
+ const summary = allocs
+ .filter(a => !a.isOld)
+ .reduce((acc, a) => {
+ const status = a.clientStatus;
+ if (!acc[status]) {
+ acc[status] = 0;
+ }
+ acc[status]++;
+ return acc;
+ }, {});
+
+ // Theses statuses are considered terminal, i.e., an allocation will never
+ // move from this status to another.
+ // If all of the expected allocations are in one of these statuses, the job
+ // as a whole is considered to be in the same status.
+ const terminalStatuses = ['failed', 'lost', 'complete'];
+ for (let i = 0; i < terminalStatuses.length; i++) {
+ const s = terminalStatuses[i];
+ if (summary[s] === expected) {
+ return s;
+ }
+ }
+
+ // It only takes one allocation to be in one of these statuses for the
+ // entire job to be considered in a given status.
+ if (summary['failed'] > 0 || summary['lost'] > 0) {
+ return 'degraded';
+ }
+
+ if (summary['running'] > 0) {
+ return 'running';
+ }
+
+ return 'starting';
+}
diff --git a/ui/config/environment.js b/ui/config/environment.js
index 35f112b9b50..fd9caf529fd 100644
--- a/ui/config/environment.js
+++ b/ui/config/environment.js
@@ -25,7 +25,8 @@ module.exports = function(environment) {
APP: {
blockingQueries: true,
- mirageScenario: 'topoMedium',
+ // TODO: revert before merging to main.
+ mirageScenario: 'sysbatchSmall', // convert to 'sysbatchSmall' when working on feature
mirageWithNamespaces: false,
mirageWithTokens: true,
mirageWithRegions: true,
diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js
index 1c914c7a991..bf135888f0c 100644
--- a/ui/mirage/factories/job.js
+++ b/ui/mirage/factories/job.js
@@ -6,7 +6,7 @@ import { DATACENTERS } from '../common';
const REF_TIME = new Date();
const JOB_PREFIXES = provide(5, faker.hacker.abbreviation);
-const JOB_TYPES = ['service', 'batch', 'system'];
+const JOB_TYPES = ['service', 'batch', 'system', 'sysbatch'];
const JOB_STATUSES = ['pending', 'running', 'dead'];
export default Factory.extend({
@@ -67,6 +67,20 @@ export default Factory.extend({
}),
}),
+ periodicSysbatch: trait({
+ type: 'sysbatch',
+ periodic: true,
+ // periodic details object
+ // serializer update for bool vs details object
+ periodicDetails: () => ({
+ Enabled: true,
+ ProhibitOverlap: true,
+ Spec: '*/5 * * * * *',
+ SpecType: 'cron',
+ TimeZone: 'UTC',
+ }),
+ }),
+
parameterized: trait({
type: 'batch',
parameterized: true,
@@ -79,6 +93,18 @@ export default Factory.extend({
}),
}),
+ parameterizedSysbatch: trait({
+ type: 'sysbatch',
+ parameterized: true,
+ // parameterized job object
+ // serializer update for bool vs details object
+ parameterizedJob: () => ({
+ MetaOptional: generateMetaFields(faker.random.number(10), 'optional'),
+ MetaRequired: generateMetaFields(faker.random.number(10), 'required'),
+ Payload: faker.random.boolean() ? 'required' : null,
+ }),
+ }),
+
periodicChild: trait({
// Periodic children need a parent job,
// It is the Periodic job's responsibility to create
@@ -86,6 +112,13 @@ export default Factory.extend({
type: 'batch',
}),
+ periodicSysbatchChild: trait({
+ // Periodic children need a parent job,
+ // It is the Periodic job's responsibility to create
+ // periodicChild jobs and provide a parent job.
+ type: 'sysbatch',
+ }),
+
parameterizedChild: trait({
// Parameterized children need a parent job,
// It is the Parameterized job's responsibility to create
@@ -96,6 +129,16 @@ export default Factory.extend({
payload: window.btoa(faker.lorem.sentence()),
}),
+ parameterizedSysbatchChild: trait({
+ // Parameterized children need a parent job,
+ // It is the Parameterized job's responsibility to create
+ // parameterizedChild jobs and provide a parent job.
+ type: 'sysbatch',
+ parameterized: true,
+ dispatched: true,
+ payload: window.btoa(faker.lorem.sentence()),
+ }),
+
createIndex: i => i,
modifyIndex: () => faker.random.number({ min: 10, max: 2000 }),
@@ -248,22 +291,44 @@ export default Factory.extend({
}
if (job.periodic) {
- // Create periodicChild jobs
- server.createList('job', job.childrenCount, 'periodicChild', {
+ let childType;
+ switch (job.type) {
+ case 'batch':
+ childType = 'periodicChild';
+ break;
+ case 'sysbatch':
+ childType = 'periodicSysbatchChild';
+ break;
+ }
+
+ // Create child jobs
+ server.createList('job', job.childrenCount, childType, {
parentId: job.id,
namespaceId: job.namespaceId,
namespace: job.namespace,
+ datacenters: job.datacenters,
createAllocations: job.createAllocations,
shallow: job.shallow,
});
}
if (job.parameterized && !job.parentId) {
- // Create parameterizedChild jobs
- server.createList('job', job.childrenCount, 'parameterizedChild', {
+ let childType;
+ switch (job.type) {
+ case 'batch':
+ childType = 'parameterizedChild';
+ break;
+ case 'sysbatch':
+ childType = 'parameterizedSysbatchChild';
+ break;
+ }
+
+ // Create child jobs
+ server.createList('job', job.childrenCount, childType, {
parentId: job.id,
namespaceId: job.namespaceId,
namespace: job.namespace,
+ datacenters: job.datacenters,
createAllocations: job.createAllocations,
shallow: job.shallow,
});
diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js
index 8d2423f86d5..6e5d11c123b 100644
--- a/ui/mirage/scenarios/default.js
+++ b/ui/mirage/scenarios/default.js
@@ -1,5 +1,6 @@
import config from 'nomad-ui/config/environment';
import * as topoScenarios from './topo';
+import * as sysbatchScenarios from './sysbatch';
import { pickOne } from '../utils';
const withNamespaces = getConfigValue('mirageWithNamespaces', false);
@@ -16,6 +17,7 @@ const allScenarios = {
everyFeature,
emptyCluster,
...topoScenarios,
+ ...sysbatchScenarios,
};
const scenario = getScenarioQueryParameter() || getConfigValue('mirageScenario', 'emptyCluster');
@@ -85,6 +87,8 @@ function allJobTypes(server) {
server.create('job', { type: 'system' });
server.create('job', 'periodic');
server.create('job', 'parameterized');
+ server.create('job', 'periodicSysbatch');
+ server.create('job', 'parameterizedSysbatch');
server.create('job', { failedPlacements: true });
}
diff --git a/ui/mirage/scenarios/sysbatch.js b/ui/mirage/scenarios/sysbatch.js
new file mode 100644
index 00000000000..5bb4b57c757
--- /dev/null
+++ b/ui/mirage/scenarios/sysbatch.js
@@ -0,0 +1,83 @@
+export function sysbatchSmall(server) {
+ return sysbatchScenario(server, 15);
+}
+
+export function sysbatchLarge(server) {
+ return sysbatchScenario(server, 55);
+}
+
+function sysbatchScenario(server, clientCount) {
+ server.createList('agent', 3);
+ const clients = server.createList('node', clientCount, {
+ datacenter: 'dc1',
+ status: 'ready',
+ });
+
+ // Create some clients not targeted by the sysbatch job.
+ server.createList('node', 3, {
+ datacenter: 'dc3',
+ status: 'ready',
+ });
+
+ // Generate non-system/sysbatch job as counter-example.
+ server.create('job', {
+ status: 'running',
+ type: 'service',
+ resourceSpec: ['M: 256, C: 500'],
+ createAllocations: true,
+ });
+
+ ['system', 'sysbatch'].forEach(type => {
+ // Job with 1 task group.
+ const job1 = server.create('job', {
+ status: 'running',
+ datacenters: ['dc1', 'dc2'],
+ type,
+ resourceSpec: ['M: 256, C: 500'],
+ createAllocations: false,
+ });
+ clients.forEach(c => {
+ server.create('allocation', { jobId: job1.id, nodeId: c.id });
+ });
+
+ // Job with 2 task groups.
+ const job2 = server.create('job', {
+ status: 'running',
+ datacenters: ['dc1'],
+ type,
+ resourceSpec: ['M: 256, C: 500', 'M: 256, C: 500'],
+ createAllocations: false,
+ });
+ clients.forEach(c => {
+ server.create('allocation', { jobId: job2.id, nodeId: c.id });
+ server.create('allocation', { jobId: job2.id, nodeId: c.id });
+ });
+
+ // Job with 3 task groups.
+ const job3 = server.create('job', {
+ status: 'running',
+ datacenters: ['dc1'],
+ type,
+ resourceSpec: ['M: 256, C: 500', 'M: 256, C: 500', 'M: 256, C: 500'],
+ createAllocations: false,
+ });
+ clients.forEach(c => {
+ server.create('allocation', { jobId: job3.id, nodeId: c.id });
+ server.create('allocation', { jobId: job3.id, nodeId: c.id });
+ server.create('allocation', { jobId: job3.id, nodeId: c.id });
+ });
+
+ // Job with client not scheduled.
+ const jobNotScheduled = server.create('job', {
+ status: 'running',
+ datacenters: ['dc1'],
+ type,
+ resourceSpec: ['M: 256, C: 500'],
+ createAllocations: false,
+ });
+ clients.forEach((c, i) => {
+ if (i > clients.length - 3) return;
+ server.create('allocation', { jobId: jobNotScheduled.id, nodeId: c.id });
+ });
+ });
+}
diff --git a/ui/tests/acceptance/job-clients-test.js b/ui/tests/acceptance/job-clients-test.js
new file mode 100644
index 00000000000..34583f1d90b
--- /dev/null
+++ b/ui/tests/acceptance/job-clients-test.js
@@ -0,0 +1,205 @@
+import { currentURL } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
+import Clients from 'nomad-ui/tests/pages/jobs/job/clients';
+
+let job;
+let clients;
+
+const makeSearchableClients = (server, job) => {
+ Array(10)
+ .fill(null)
+ .map((_, index) => {
+ const node = server.create('node', {
+ id: index < 5 ? `ffffff-dddddd-${index}` : `111111-222222-${index}`,
+ datacenter: 'dc1',
+ status: 'ready',
+ });
+ server.create('allocation', { jobId: job.id, nodeId: node.id });
+ });
+};
+
+module('Acceptance | job clients', function(hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function() {
+ clients = server.createList('node', 12, {
+ datacenter: 'dc1',
+ status: 'ready',
+ });
+ // Job with 1 task group.
+ job = server.create('job', {
+ status: 'running',
+ datacenters: ['dc1'],
+ type: 'sysbatch',
+ resourceSpec: ['M: 256, C: 500'],
+ createAllocations: false,
+ });
+ clients.forEach(c => {
+ server.create('allocation', { jobId: job.id, nodeId: c.id });
+ });
+
+ // Create clients without allocations to have some 'not scheduled' job status.
+ clients = clients.concat(
+ server.createList('node', 3, {
+ datacenter: 'dc1',
+ status: 'ready',
+ })
+ );
+ });
+
+ test('it passes an accessibility audit', async function(assert) {
+ await Clients.visit({ id: job.id });
+ await a11yAudit(assert);
+ });
+
+ test('lists all clients for the job', async function(assert) {
+ await Clients.visit({ id: job.id });
+ assert.equal(Clients.clients.length, 15, 'Clients are shown in a table');
+
+ const clientIDs = clients.sortBy('id').map(c => c.id);
+ const clientsInTable = Clients.clients.map(c => c.id).sort();
+ assert.deepEqual(clientsInTable, clientIDs);
+
+ assert.equal(document.title, `Job ${job.name} clients - Nomad`);
+ });
+
+ test('dates have tooltip', async function(assert) {
+ await Clients.visit({ id: job.id });
+
+ Clients.clients.forEach((clientRow, index) => {
+ const jobStatus = Clients.clientFor(clientRow.id).status;
+
+ ['createTime', 'modifyTime'].forEach(col => {
+ if (jobStatus === 'not scheduled') {
+ assert.equal(clientRow[col].text, '-', `row ${index} doesn't have ${col} tooltip`);
+ return;
+ }
+
+ const hasTooltip = clientRow[col].tooltip.isPresent;
+ const tooltipText = clientRow[col].tooltip.text;
+ assert.true(hasTooltip, `row ${index} has ${col} tooltip`);
+ assert.ok(tooltipText, `row ${index} has ${col} tooltip content ${tooltipText}`);
+ });
+ });
+ });
+
+ test('clients table is sortable', async function(assert) {
+ await Clients.visit({ id: job.id });
+ await Clients.sortBy('node.name');
+
+ assert.equal(
+ currentURL(),
+ `/jobs/${job.id}/clients?desc=true&sort=node.name`,
+ 'the URL persists the sort parameter'
+ );
+
+ const sortedClients = clients.sortBy('name').reverse();
+ Clients.clients.forEach((client, index) => {
+ const shortId = sortedClients[index].id.split('-')[0];
+ assert.equal(
+ client.shortId,
+ shortId,
+ `Client ${index} is ${shortId} with name ${sortedClients[index].name}`
+ );
+ });
+ });
+
+ test('clients table is searchable', async function(assert) {
+ makeSearchableClients(server, job);
+
+ await Clients.visit({ id: job.id });
+ await Clients.search('ffffff');
+
+ assert.equal(Clients.clients.length, 5, 'List is filtered by search term');
+ });
+
+ test('when a search yields no results, the search box remains', async function(assert) {
+ makeSearchableClients(server, job);
+
+ await Clients.visit({ id: job.id });
+ await Clients.search('^nothing will ever match this long regex$');
+
+ assert.equal(
+ Clients.emptyState.headline,
+ 'No Matches',
+ 'List is empty and the empty state is about search'
+ );
+
+ assert.ok(Clients.hasSearchBox, 'Search box is still shown');
+ });
+
+ test('when the job for the clients is not found, an error message is shown, but the URL persists', async function(assert) {
+ await Clients.visit({ id: 'not-a-real-job' });
+
+ assert.equal(
+ server.pretender.handledRequests
+ .filter(request => !request.url.includes('policy'))
+ .findBy('status', 404).url,
+ '/v1/job/not-a-real-job',
+ 'A request to the nonexistent job is made'
+ );
+ assert.equal(currentURL(), '/jobs/not-a-real-job/clients', 'The URL persists');
+ assert.ok(Clients.error.isPresent, 'Error message is shown');
+ assert.equal(Clients.error.title, 'Not Found', 'Error message is for 404');
+ });
+
+ test('clicking row goes to client details', async function(assert) {
+ const client = clients[0];
+
+ await Clients.visit({ id: job.id });
+ await Clients.clientFor(client.id).click();
+ assert.equal(currentURL(), `/clients/${client.id}`);
+
+ await Clients.visit({ id: job.id });
+ await Clients.clientFor(client.id).visit();
+ assert.equal(currentURL(), `/clients/${client.id}`);
+
+ await Clients.visit({ id: job.id });
+ await Clients.clientFor(client.id).visitRow();
+ assert.equal(currentURL(), `/clients/${client.id}`);
+ });
+
+ testFacet('Job Status', {
+ facet: Clients.facets.jobStatus,
+ paramName: 'jobStatus',
+ expectedOptions: [
+ 'Queued',
+ 'Not Scheduled',
+ 'Starting',
+ 'Running',
+ 'Complete',
+ 'Degraded',
+ 'Failed',
+ 'Lost',
+ ],
+ async beforeEach() {
+ await Clients.visit({ id: job.id });
+ },
+ });
+
+ function testFacet(label, { facet, paramName, beforeEach, expectedOptions }) {
+ test(`the ${label} facet has the correct options`, async function(assert) {
+ await beforeEach();
+ await facet.toggle();
+
+ let expectation;
+ if (typeof expectedOptions === 'function') {
+ expectation = expectedOptions();
+ } else {
+ expectation = expectedOptions;
+ }
+
+ assert.deepEqual(
+ facet.options.map(option => option.label.trim()),
+ expectation,
+ `Options for facet ${paramName} are as expected`
+ );
+ });
+
+ // TODO: add facet tests for actual list filtering
+ }
+});
diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js
index 74e02db9599..6ddc145ebb6 100644
--- a/ui/tests/acceptance/job-detail-test.js
+++ b/ui/tests/acceptance/job-detail-test.js
@@ -5,15 +5,85 @@ import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import moment from 'moment';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
-import moduleForJob from 'nomad-ui/tests/helpers/module-for-job';
+import moduleForJob, { moduleForJobWithClientStatus } from 'nomad-ui/tests/helpers/module-for-job';
import JobDetail from 'nomad-ui/tests/pages/jobs/detail';
moduleForJob('Acceptance | job detail (batch)', 'allocations', () =>
server.create('job', { type: 'batch', shallow: true })
);
+
moduleForJob('Acceptance | job detail (system)', 'allocations', () =>
server.create('job', { type: 'system', shallow: true })
);
+
+moduleForJobWithClientStatus('Acceptance | job detail with client status (system)', () =>
+ server.create('job', {
+ status: 'running',
+ datacenters: ['dc1'],
+ type: 'system',
+ createAllocations: false,
+ })
+);
+
+moduleForJob('Acceptance | job detail (sysbatch)', 'allocations', () =>
+ server.create('job', { type: 'sysbatch', shallow: true })
+);
+
+moduleForJobWithClientStatus('Acceptance | job detail with client status (sysbatch)', () =>
+ server.create('job', {
+ status: 'running',
+ datacenters: ['dc1'],
+ type: 'sysbatch',
+ createAllocations: false,
+ })
+);
+
+moduleForJobWithClientStatus(
+ 'Acceptance | job detail with client status (sysbatch with namespace)',
+ () => {
+ const namespace = server.create('namespace', { id: 'test' });
+ return server.create('job', {
+ status: 'running',
+ datacenters: ['dc1'],
+ type: 'sysbatch',
+ namespaceId: namespace.name,
+ createAllocations: false,
+ });
+ }
+);
+
+moduleForJob('Acceptance | job detail (sysbatch child)', 'allocations', () => {
+ const parent = server.create('job', 'periodicSysbatch', {
+ childrenCount: 1,
+ shallow: true,
+ datacenters: ['dc1'],
+ });
+ return server.db.jobs.where({ parentId: parent.id })[0];
+});
+
+moduleForJobWithClientStatus('Acceptance | job detail with client status (sysbatch child)', () => {
+ const parent = server.create('job', 'periodicSysbatch', {
+ childrenCount: 1,
+ shallow: true,
+ datacenters: ['dc1'],
+ });
+ return server.db.jobs.where({ parentId: parent.id })[0];
+});
+
+moduleForJobWithClientStatus(
+ 'Acceptance | job detail with client status (sysbatch child with namespace)',
+ () => {
+ const namespace = server.create('namespace', { id: 'test' });
+ const parent = server.create('job', 'periodicSysbatch', {
+ childrenCount: 1,
+ shallow: true,
+ namespaceId: namespace.name,
+ datacenters: ['dc1'],
+ });
+ return server.db.jobs.where({ parentId: parent.id })[0];
+ }
+);
+
moduleForJob(
'Acceptance | job detail (periodic)',
'children',
diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js
index 5e5c2052e9c..ada33469646 100644
--- a/ui/tests/acceptance/jobs-list-test.js
+++ b/ui/tests/acceptance/jobs-list-test.js
@@ -217,7 +217,7 @@ module('Acceptance | jobs list', function(hooks) {
testFacet('Type', {
facet: JobsList.facets.type,
paramName: 'type',
- expectedOptions: ['Batch', 'Parameterized', 'Periodic', 'Service', 'System'],
+ expectedOptions: ['Batch', 'Parameterized', 'Periodic', 'Service', 'System', 'System Batch'],
async beforeEach() {
server.createList('job', 2, { createAllocations: false, type: 'batch' });
server.createList('job', 2, {
diff --git a/ui/tests/helpers/module-for-job.js b/ui/tests/helpers/module-for-job.js
index 0bf9f70e38f..d631a374ca7 100644
--- a/ui/tests/helpers/module-for-job.js
+++ b/ui/tests/helpers/module-for-job.js
@@ -119,10 +119,95 @@ export default function moduleForJob(title, context, jobFactory, additionalTests
});
}
+// eslint-disable-next-line ember/no-test-module-for
+export function moduleForJobWithClientStatus(title, jobFactory, additionalTests) {
+ let job;
+
+ module(title, function(hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(async function() {
+ const clients = server.createList('node', 3, {
+ datacenter: 'dc1',
+ status: 'ready',
+ });
+ job = jobFactory();
+ clients.forEach(c => {
+ server.create('allocation', { jobId: job.id, nodeId: c.id });
+ });
+ if (!job.namespace || job.namespace === 'default') {
+ await JobDetail.visit({ id: job.id });
+ } else {
+ await JobDetail.visit({ id: job.id, namespace: job.namespace });
+ }
+ });
+
+ test('the subnav links to clients', async function(assert) {
+ await JobDetail.tabFor('clients').visit();
+ assert.equal(
+ currentURL(),
+ urlWithNamespace(`/jobs/${encodeURIComponent(job.id)}/clients`, job.namespace)
+ );
+ });
+
+ test('job status summary is shown in the overview', async function(assert) {
+ assert.ok(
+ JobDetail.jobClientStatusSummary.isPresent,
+ 'Summary bar is displayed in the Job Status in Client summary section'
+ );
+ });
+
+ test('clicking legend item navigates to a pre-filtered clients table', async function(assert) {
+ const legendItem = JobDetail.jobClientStatusSummary.legend.clickableItems[0];
+ const status = legendItem.label;
+ await legendItem.click();
+
+ const encodedStatus = encodeURIComponent(JSON.stringify([status]));
+ const expectedURL = new URL(
+ urlWithNamespace(`/jobs/${job.name}/clients?status=${encodedStatus}`, job.namespace),
+ window.location
+ );
+ const gotURL = new URL(currentURL(), window.location);
+ assert.deepEqual(gotURL.path, expectedURL.path);
+ assert.deepEqual(gotURL.searchParams, expectedURL.searchParams);
+ });
+
+ test('clicking in a slice takes you to a pre-filtered clients table', async function(assert) {
+ const slice = JobDetail.jobClientStatusSummary.slices[0];
+ const status = slice.label;
+ await slice.click();
+
+ const encodedStatus = encodeURIComponent(JSON.stringify([status]));
+ const expectedURL = new URL(
+ urlWithNamespace(`/jobs/${job.name}/clients?status=${encodedStatus}`, job.namespace),
+ window.location
+ );
+ const gotURL = new URL(currentURL(), window.location);
+ assert.deepEqual(gotURL.pathname, expectedURL.pathname);
+
+ // Sort and compare URL query params.
+ gotURL.searchParams.sort();
+ expectedURL.searchParams.sort();
+ assert.equal(gotURL.searchParams.toString(), expectedURL.searchParams.toString());
+ });
+
+ for (var testName in additionalTests) {
+ test(testName, async function(assert) {
+ await additionalTests[testName].call(this, job, assert);
+ });
+ }
+ });
+}
+
function urlWithNamespace(url, namespace) {
if (!namespace || namespace === 'default') {
return url;
}
- return `${url}?namespace=${namespace}`;
+ const parts = url.split('?');
+ const params = new URLSearchParams(parts[1]);
+ params.set('namespace', namespace);
+
+ return `${parts[0]}?${params.toString()}`;
}
diff --git a/ui/tests/integration/components/job-client-status-bar-test.js b/ui/tests/integration/components/job-client-status-bar-test.js
new file mode 100644
index 00000000000..827c033c70d
--- /dev/null
+++ b/ui/tests/integration/components/job-client-status-bar-test.js
@@ -0,0 +1,77 @@
+import { module, test } from 'qunit';
+import { create } from 'ember-cli-page-object';
+import { setupRenderingTest } from 'ember-qunit';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import sinon from 'sinon';
+import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import jobClientStatusBar from 'nomad-ui/tests/pages/components/job-client-status-bar';
+
+const JobClientStatusBar = create(jobClientStatusBar());
+
+module('Integration | Component | job-client-status-bar', function(hooks) {
+ setupRenderingTest(hooks);
+
+ const commonProperties = () => ({
+ onSliceClick: sinon.spy(),
+ job: {
+ namespace: {
+ get: () => 'my-namespace',
+ },
+ },
+ jobClientStatus: {
+ byStatus: {
+ queued: [],
+ starting: ['someNodeId'],
+ running: [],
+ complete: [],
+ degraded: [],
+ failed: [],
+ lost: [],
+ notScheduled: [],
+ },
+ },
+ isNarrow: true,
+ });
+
+ const commonTemplate = hbs`
+ `;
+
+ test('it renders', async function(assert) {
+ const props = commonProperties();
+ this.setProperties(props);
+ await render(commonTemplate);
+
+ assert.ok(JobClientStatusBar.isPresent, 'Client Status Bar is rendered');
+ await componentA11yAudit(this.element, assert);
+ });
+
+ test('it fires the onBarClick handler method when clicking a bar in the chart', async function(assert) {
+ const props = commonProperties();
+ this.setProperties(props);
+ await render(commonTemplate);
+ await JobClientStatusBar.slices[0].click();
+ assert.ok(props.onSliceClick.calledOnce);
+ });
+
+ test('it handles an update to client status property', async function(assert) {
+ const props = commonProperties();
+ this.setProperties(props);
+ await render(commonTemplate);
+ const newProps = {
+ ...props,
+ jobClientStatus: {
+ ...props.jobClientStatus,
+ byStatus: { ...props.jobClientStatus.byStatus, starting: [], running: ['someNodeId'] },
+ },
+ };
+ this.setProperties(newProps);
+ await JobClientStatusBar.visitSlice('running');
+ assert.ok(props.onSliceClick.calledOnce);
+ });
+});
diff --git a/ui/tests/pages/components/clients.js b/ui/tests/pages/components/clients.js
new file mode 100644
index 00000000000..4be7fbee31c
--- /dev/null
+++ b/ui/tests/pages/components/clients.js
@@ -0,0 +1,40 @@
+import { attribute, collection, clickable, text } from 'ember-cli-page-object';
+import { singularize } from 'ember-inflector';
+
+export default function(selector = '[data-test-client]', propKey = 'clients') {
+ const lookupKey = `${singularize(propKey)}For`;
+ // Remove the bracket notation
+ const attr = selector.substring(1, selector.length - 1);
+
+ return {
+ [propKey]: collection(selector, {
+ id: attribute(attr),
+ shortId: text('[data-test-short-id]'),
+ name: text('[data-test-name]'),
+ status: text('[data-test-job-status]'),
+
+ createTime: {
+ scope: '[data-test-create-time]',
+ tooltip: {
+ scope: '.tooltip',
+ text: attribute('aria-label'),
+ },
+ },
+
+ modifyTime: {
+ scope: '[data-test-modify-time]',
+ tooltip: {
+ scope: '.tooltip',
+ text: attribute('aria-label'),
+ },
+ },
+
+ visit: clickable('[data-test-short-id] a'),
+ visitRow: clickable(),
+ }),
+
+ [lookupKey]: function(id) {
+ return this[propKey].toArray().find(client => client.id === id);
+ },
+ };
+}
diff --git a/ui/tests/pages/components/job-client-status-bar.js b/ui/tests/pages/components/job-client-status-bar.js
new file mode 100644
index 00000000000..3cd753159be
--- /dev/null
+++ b/ui/tests/pages/components/job-client-status-bar.js
@@ -0,0 +1,37 @@
+import { attribute, clickable, collection } from 'ember-cli-page-object';
+
+export default scope => ({
+ scope,
+
+ slices: collection('svg .bars g', {
+ label: attribute('data-test-slice-label'),
+ click: clickable(),
+ }),
+
+ legend: {
+ scope: '.legend',
+
+ items: collection('li', {
+ label: attribute('data-test-legent-label'),
+ }),
+
+ clickableItems: collection('li.is-clickable', {
+ label: attribute('data-test-legent-label'),
+ click: clickable('a'),
+ }),
+ },
+
+ visitSlice: async function(label) {
+ await this.slices
+ .toArray()
+ .findBy('label', label)
+ .click();
+ },
+
+ visitLegend: async function(label) {
+ await this.legend.clickableItems
+ .toArray()
+ .findBy('label', label)
+ .click();
+ },
+});
diff --git a/ui/tests/pages/jobs/detail.js b/ui/tests/pages/jobs/detail.js
index cc6b64634b9..92e083e2ab8 100644
--- a/ui/tests/pages/jobs/detail.js
+++ b/ui/tests/pages/jobs/detail.js
@@ -13,6 +13,7 @@ import {
import allocations from 'nomad-ui/tests/pages/components/allocations';
import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button';
import recommendationAccordion from 'nomad-ui/tests/pages/components/recommendation-accordion';
+import jobClientStatusBar from 'nomad-ui/tests/pages/components/job-client-status-bar';
export default create({
visit: visitable('/jobs/:id'),
@@ -59,6 +60,7 @@ export default create({
return this.stats.toArray().findBy('id', id);
},
+ jobClientStatusSummary: jobClientStatusBar('[data-test-job-client-status-bar]'),
childrenSummary: isPresent('[data-test-job-summary] [data-test-children-status-bar]'),
allocationsSummary: isPresent('[data-test-job-summary] [data-test-allocation-status-bar]'),
diff --git a/ui/tests/pages/jobs/job/clients.js b/ui/tests/pages/jobs/job/clients.js
new file mode 100644
index 00000000000..83d69fa9e51
--- /dev/null
+++ b/ui/tests/pages/jobs/job/clients.js
@@ -0,0 +1,49 @@
+import {
+ attribute,
+ clickable,
+ create,
+ collection,
+ fillable,
+ isPresent,
+ text,
+ visitable,
+} from 'ember-cli-page-object';
+import { multiFacet } from 'nomad-ui/tests/pages/components/facet';
+
+import clients from 'nomad-ui/tests/pages/components/clients';
+import error from 'nomad-ui/tests/pages/components/error';
+
+export default create({
+ visit: visitable('/jobs/:id/clients'),
+ pageSize: 25,
+
+ hasSearchBox: isPresent('[data-test-clients-search]'),
+ search: fillable('[data-test-clients-search] input'),
+
+ ...clients(),
+
+ isEmpty: isPresent('[data-test-empty-clients-list]'),
+ emptyState: {
+ headline: text('[data-test-empty-clients-list-headline]'),
+ },
+
+ sortOptions: collection('[data-test-sort-by]', {
+ id: attribute('data-test-sort-by'),
+ sort: clickable(),
+ }),
+
+ sortBy(id) {
+ return this.sortOptions
+ .toArray()
+ .findBy('id', id)
+ .sort();
+ },
+
+ facets: {
+ jobStatus: multiFacet('[data-test-job-status-facet]'),
+ datacenter: multiFacet('[data-test-datacenter-facet]'),
+ clientClass: multiFacet('[data-test-class-facet]'),
+ },
+
+ error: error(),
+});
diff --git a/ui/tests/unit/utils/job-client-status-test.js b/ui/tests/unit/utils/job-client-status-test.js
new file mode 100644
index 00000000000..801f505ab23
--- /dev/null
+++ b/ui/tests/unit/utils/job-client-status-test.js
@@ -0,0 +1,319 @@
+import { module, test } from 'qunit';
+import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
+import EmberObject from '@ember/object';
+
+class JobClientStatusMock extends EmberObject {
+ constructor(job, nodes) {
+ super(...arguments);
+ this.job = job;
+ this.nodes = nodes;
+ }
+
+ @jobClientStatus('nodes', 'job') jobClientStatus;
+
+ get(key) {
+ switch (key) {
+ case 'job':
+ return this.job;
+ case 'nodes':
+ return this.nodes;
+ }
+ }
+}
+
+class NodeMock {
+ constructor(id, datacenter) {
+ this.id = id;
+ this.datacenter = datacenter;
+ }
+
+ get(key) {
+ switch (key) {
+ case 'id':
+ return this.id;
+ }
+ }
+}
+
+module('Unit | Util | JobClientStatus', function() {
+ test('it handles the case where all nodes are running', async function(assert) {
+ const node = new NodeMock('node-1', 'dc1');
+ const nodes = [node];
+ const job = {
+ datacenters: ['dc1'],
+ status: 'running',
+ allocations: [{ node, clientStatus: 'running' }],
+ taskGroups: [{}],
+ };
+ const expected = {
+ byNode: {
+ 'node-1': 'running',
+ },
+ byStatus: {
+ running: ['node-1'],
+ complete: [],
+ degraded: [],
+ failed: [],
+ lost: [],
+ notScheduled: [],
+ queued: [],
+ starting: [],
+ },
+ totalNodes: 1,
+ };
+
+ const mock = new JobClientStatusMock(job, nodes);
+ let result = mock.jobClientStatus;
+
+ assert.deepEqual(result, expected);
+ });
+
+ test('it handles the degraded case where a node has a failing allocation', async function(assert) {
+ const node = new NodeMock('node-2', 'dc1');
+ const nodes = [node];
+ const job = {
+ datacenters: ['dc1'],
+ status: 'running',
+ allocations: [
+ { node, clientStatus: 'running' },
+ { node, clientStatus: 'failed' },
+ { node, clientStatus: 'running' },
+ ],
+ taskGroups: [{}, {}, {}],
+ };
+ const expected = {
+ byNode: {
+ 'node-2': 'degraded',
+ },
+ byStatus: {
+ running: [],
+ complete: [],
+ degraded: ['node-2'],
+ failed: [],
+ lost: [],
+ notScheduled: [],
+ queued: [],
+ starting: [],
+ },
+ totalNodes: 1,
+ };
+
+ const mock = new JobClientStatusMock(job, nodes);
+ let result = mock.jobClientStatus;
+
+ assert.deepEqual(result, expected);
+ });
+
+ test('it handles the case where a node has all lost allocations', async function(assert) {
+ const node = new NodeMock('node-1', 'dc1');
+ const nodes = [node];
+ const job = {
+ datacenters: ['dc1'],
+ status: 'running',
+ allocations: [
+ { node, clientStatus: 'lost' },
+ { node, clientStatus: 'lost' },
+ { node, clientStatus: 'lost' },
+ ],
+ taskGroups: [{}, {}, {}],
+ };
+ const expected = {
+ byNode: {
+ 'node-1': 'lost',
+ },
+ byStatus: {
+ running: [],
+ complete: [],
+ degraded: [],
+ failed: [],
+ lost: ['node-1'],
+ notScheduled: [],
+ queued: [],
+ starting: [],
+ },
+ totalNodes: 1,
+ };
+
+ const mock = new JobClientStatusMock(job, nodes);
+ let result = mock.jobClientStatus;
+
+ assert.deepEqual(result, expected);
+ });
+
+ test('it handles the case where a node has all failed allocations', async function(assert) {
+ const node = new NodeMock('node-1', 'dc1');
+ const nodes = [node];
+ const job = {
+ datacenters: ['dc1'],
+ status: 'running',
+ allocations: [
+ { node, clientStatus: 'failed' },
+ { node, clientStatus: 'failed' },
+ { node, clientStatus: 'failed' },
+ ],
+ taskGroups: [{}, {}, {}],
+ };
+ const expected = {
+ byNode: {
+ 'node-1': 'failed',
+ },
+ byStatus: {
+ running: [],
+ complete: [],
+ degraded: [],
+ failed: ['node-1'],
+ lost: [],
+ notScheduled: [],
+ queued: [],
+ starting: [],
+ },
+ totalNodes: 1,
+ };
+
+ const mock = new JobClientStatusMock(job, nodes);
+ let result = mock.jobClientStatus;
+
+ assert.deepEqual(result, expected);
+ });
+
+ test('it handles the degraded case where the expected number of allocations doesnt match the actual number of allocations', async function(assert) {
+ const node = new NodeMock('node-1', 'dc1');
+ const nodes = [node];
+ const job = {
+ datacenters: ['dc1'],
+ status: 'running',
+ allocations: [
+ { node, clientStatus: 'running' },
+ { node, clientStatus: 'running' },
+ { node, clientStatus: 'running' },
+ ],
+ taskGroups: [{}, {}, {}, {}],
+ };
+ const expected = {
+ byNode: {
+ 'node-1': 'degraded',
+ },
+ byStatus: {
+ running: [],
+ complete: [],
+ degraded: ['node-1'],
+ failed: [],
+ lost: [],
+ notScheduled: [],
+ queued: [],
+ starting: [],
+ },
+ totalNodes: 1,
+ };
+
+ const mock = new JobClientStatusMock(job, nodes);
+ let result = mock.jobClientStatus;
+
+ assert.deepEqual(result, expected);
+ });
+
+ test('it handles the not scheduled case where a node has no allocations', async function(assert) {
+ const node = new NodeMock('node-1', 'dc1');
+ const nodes = [node];
+ const job = {
+ datacenters: ['dc1'],
+ status: 'running',
+ allocations: [],
+ taskGroups: [],
+ };
+ const expected = {
+ byNode: {
+ 'node-1': 'notScheduled',
+ },
+ byStatus: {
+ running: [],
+ complete: [],
+ degraded: [],
+ failed: [],
+ lost: [],
+ notScheduled: ['node-1'],
+ queued: [],
+ starting: [],
+ },
+ totalNodes: 1,
+ };
+
+ const mock = new JobClientStatusMock(job, nodes);
+ let result = mock.jobClientStatus;
+
+ assert.deepEqual(result, expected);
+ });
+
+ test('it handles the queued case where the job is pending', async function(assert) {
+ const node = new NodeMock('node-1', 'dc1');
+ const nodes = [node];
+ const job = {
+ datacenters: ['dc1'],
+ status: 'pending',
+ allocations: [
+ { node, clientStatus: 'starting' },
+ { node, clientStatus: 'starting' },
+ { node, clientStatus: 'starting' },
+ ],
+ taskGroups: [{}, {}, {}, {}],
+ };
+ const expected = {
+ byNode: {
+ 'node-1': 'queued',
+ },
+ byStatus: {
+ running: [],
+ complete: [],
+ degraded: [],
+ failed: [],
+ lost: [],
+ notScheduled: [],
+ queued: ['node-1'],
+ starting: [],
+ },
+ totalNodes: 1,
+ };
+
+ const mock = new JobClientStatusMock(job, nodes);
+ let result = mock.jobClientStatus;
+
+ assert.deepEqual(result, expected);
+ });
+
+ test('it filters nodes by the datacenter of the job', async function(assert) {
+ const node1 = new NodeMock('node-1', 'dc1');
+ const node2 = new NodeMock('node-2', 'dc2');
+ const nodes = [node1, node2];
+ const job = {
+ datacenters: ['dc1'],
+ status: 'running',
+ allocations: [
+ { node: node1, clientStatus: 'running' },
+ { node: node2, clientStatus: 'failed' },
+ { node: node1, clientStatus: 'running' },
+ ],
+ taskGroups: [{}, {}],
+ };
+ const expected = {
+ byNode: {
+ 'node-1': 'running',
+ },
+ byStatus: {
+ running: ['node-1'],
+ complete: [],
+ degraded: [],
+ failed: [],
+ lost: [],
+ notScheduled: [],
+ queued: [],
+ starting: [],
+ },
+ totalNodes: 1,
+ };
+
+ const mock = new JobClientStatusMock(job, nodes);
+ let result = mock.jobClientStatus;
+
+ assert.deepEqual(result, expected);
+ });
+});