mirror of
https://github.com/kemko/nomad.git
synced 2026-01-04 01:15:43 +03:00
System Batch UI, Client Status Bar Chart and Client Tab page view (#11078)
This commit is contained in:
@@ -1,3 +1,13 @@
|
||||
printWidth: 100
|
||||
singleQuote: true
|
||||
trailingComma: es5
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.hbs",
|
||||
"options": {
|
||||
"singleQuote": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
120
ui/app/components/job-client-status-bar.js
Normal file
120
ui/app/components/job-client-status-bar.js
Normal file
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
89
ui/app/components/job-client-status-row.js
Normal file
89
ui/app/components/job-client-status-row.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
15
ui/app/components/job-page/sysbatch.js
Normal file
15
ui/app/components/job-page/sysbatch.js
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
192
ui/app/controllers/jobs/job/clients.js
Normal file
192
ui/app/controllers/jobs/job/clients.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ Router.map(function() {
|
||||
this.route('dispatch');
|
||||
this.route('evaluations');
|
||||
this.route('allocations');
|
||||
this.route('clients');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
30
ui/app/routes/jobs/job/clients.js
Normal file
30
ui/app/routes/jobs/job/clients.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
.accordion-head-content {
|
||||
width: 100%;
|
||||
margin-right: 1.5em;
|
||||
|
||||
.tooltip {
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-toggle {
|
||||
|
||||
41
ui/app/templates/components/job-client-status-row.hbs
Normal file
41
ui/app/templates/components/job-client-status-row.hbs
Normal file
@@ -0,0 +1,41 @@
|
||||
<tr data-test-client={{this.row.node.id}} class="job-client-status-row is-interactive" {{on "click" (fn @onClick this.row.node)}}>
|
||||
<td data-test-short-id>
|
||||
<LinkTo @route="allocations.allocation" @model={{this.allocation}} class="is-primary">
|
||||
{{this.row.node.shortId}}
|
||||
</LinkTo>
|
||||
</td>
|
||||
<td data-test-name class="is-200px is-truncatable">
|
||||
{{this.row.node.name}}
|
||||
</td>
|
||||
<td data-test-create-time>
|
||||
{{#if this.row.createTime}}
|
||||
<span class="tooltip" role="tooltip" aria-label="{{format-month-ts this.row.createTime}}">
|
||||
{{moment-from-now this.row.createTime}}
|
||||
</span>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-test-modify-time>
|
||||
{{#if this.row.modifyTime}}
|
||||
<span class="tooltip" role="tooltip" aria-label="{{format-month-ts this.row.modifyTime}}">
|
||||
{{moment-from-now this.row.modifyTime}}
|
||||
</span>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-test-job-status class="is-one-line">
|
||||
<span class="color-swatch {{this.jobStatusClass}}"></span>
|
||||
{{this.humanizedJobStatus}}
|
||||
</td>
|
||||
<td data-test-job-alloc-summary class="allocation-summary is-one-line">
|
||||
{{#if this.shouldDisplayAllocationSummary}}
|
||||
<div class="inline-chart">
|
||||
<AllocationStatusBar @allocationContainer={{this.allocationContainer}} @isNarrow={{true}} />
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="is-faded">{{this.allocationSummaryPlaceholder}}</div>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -19,7 +19,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JobPage::Parts::Summary @job={{this.job}} />
|
||||
{{#if this.job.hasClientStatus}}
|
||||
<JobPage::Parts::JobClientStatusSummary
|
||||
@gotoClients={{this.gotoClients}}
|
||||
@job={{this.job}}
|
||||
@jobClientStatus={{this.jobClientStatus}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.job.hasClientStatus}} />
|
||||
|
||||
<JobPage::Parts::PlacementFailures @job={{this.job}} />
|
||||
|
||||
@@ -62,4 +70,4 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</JobPage::Parts::Body>
|
||||
</JobPage::Parts::Body>
|
||||
@@ -0,0 +1,55 @@
|
||||
<ListAccordion
|
||||
data-test-job-summary
|
||||
@source={{array this.job}}
|
||||
@key="id"
|
||||
@startExpanded={{this.isExpanded}}
|
||||
@onToggle={{action this.persist}} as |a|
|
||||
>
|
||||
<a.head @buttonLabel={{if a.isOpen "collapse" "expand"}}>
|
||||
<div class="columns">
|
||||
<div class="column is-minimum nowrap">
|
||||
Job Status in Client
|
||||
<span class="badge {{if a.isOpen "is-white" "is-light"}}">
|
||||
{{this.jobClientStatus.totalNodes}}
|
||||
</span>
|
||||
<span class="tooltip multiline" aria-label="Aggreate status of job's allocations in each client.">
|
||||
{{x-icon "info-circle-outline" class="is-faded"}}
|
||||
</span>
|
||||
</div>
|
||||
{{#unless a.isOpen}}
|
||||
<div class="column">
|
||||
<div class="inline-chart bumper-left">
|
||||
<JobClientStatusBar
|
||||
@onSliceClick={{action this.onSliceClick}}
|
||||
@job={{this.job}}
|
||||
@jobClientStatus={{this.jobClientStatus}}
|
||||
@isNarrow={{true}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{/unless}}
|
||||
</div>
|
||||
</a.head>
|
||||
<a.body>
|
||||
<JobClientStatusBar
|
||||
@onSliceClick={{action this.onSliceClick}}
|
||||
@job={{this.job}}
|
||||
@jobClientStatus={{this.jobClientStatus}}
|
||||
class="split-view" as |chart|
|
||||
>
|
||||
<ol data-test-legend class="legend">
|
||||
{{#each chart.data as |datum index|}}
|
||||
<li data-test-legent-label="{{datum.className}}" class="{{datum.className}} {{if (eq datum.label chart.activeDatum.label) "is-active"}} {{if (eq datum.value 0) "is-empty" "is-clickable"}}">
|
||||
{{#if (gt datum.value 0)}}
|
||||
<LinkTo @route="jobs.job.clients" @model={{this.job}} @query={{datum.legendLink.queryParams}}>
|
||||
<JobPage::Parts::SummaryLegendItem @datum={{datum}} @index={{index}} />
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
<JobPage::Parts::SummaryLegendItem @datum={{datum}} @index={{index}} />
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
</JobClientStatusBar>
|
||||
</a.body>
|
||||
</ListAccordion>
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="legend-item">
|
||||
<span class="color-swatch {{if @datum.className @datum.className (concat "swatch-" @index)}}" />
|
||||
<span class="text">
|
||||
<span class="value" data-test-legend-value="{{@datum.className}}">{{@datum.value}}</span>
|
||||
<span class="label">{{@datum.label}}</span>
|
||||
</span>
|
||||
{{#if @datum.help}}
|
||||
<span class="tooltip multiline" role="tooltip" aria-label="{{@datum.help}}">
|
||||
{{x-icon "info-circle-outline" class="is-faded"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -40,15 +40,10 @@
|
||||
<ol data-test-legend class="legend">
|
||||
{{#each chart.data as |datum index|}}
|
||||
<li class="{{datum.className}} {{if (eq datum.label chart.activeDatum.label) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
|
||||
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
|
||||
<span class="value" data-test-legend-value="{{datum.className}}">{{datum.value}}</span>
|
||||
<span class="label">
|
||||
{{datum.label}}
|
||||
</span>
|
||||
<JobPage::Parts::SummaryLegendItem @datum={{datum}} @index={{index}} />
|
||||
</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
{{/component}}
|
||||
</a.body>
|
||||
</ListAccordion>
|
||||
|
||||
|
||||
@@ -19,7 +19,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JobPage::Parts::Summary @job={{this.job}} />
|
||||
{{#if this.job.hasClientStatus}}
|
||||
<JobPage::Parts::JobClientStatusSummary
|
||||
@gotoClients={{this.gotoClients}}
|
||||
@job={{this.job}}
|
||||
@jobClientStatus={{this.jobClientStatus}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.job.hasClientStatus}} />
|
||||
|
||||
<JobPage::Parts::PlacementFailures @job={{this.job}} />
|
||||
|
||||
@@ -30,4 +38,4 @@
|
||||
@gotoTaskGroup={{this.gotoTaskGroup}} />
|
||||
|
||||
<JobPage::Parts::RecentAllocations @job={{this.job}} />
|
||||
</JobPage::Parts::Body>
|
||||
</JobPage::Parts::Body>
|
||||
32
ui/app/templates/components/job-page/sysbatch.hbs
Normal file
32
ui/app/templates/components/job-page/sysbatch.hbs
Normal file
@@ -0,0 +1,32 @@
|
||||
<JobPage::Parts::Body @job={{this.job}}>
|
||||
<JobPage::Parts::Error @errorMessage={{this.errorMessage}} @onDismiss={{action "clearErrorMessage"}}/>
|
||||
|
||||
<JobPage::Parts::Title @job={{this.job}} @handleError={{action "handleError"}} />
|
||||
|
||||
<div class="boxed-section job-stats">
|
||||
<div class="boxed-section-body">
|
||||
<span data-test-job-stat="type"><strong>Type:</strong>{{this.job.type}} | </span>
|
||||
<span data-test-job-stat="priority"><strong>Priority:</strong>{{this.job.priority}}</span>
|
||||
{{#if (and this.job.namespace this.system.shouldShowNamespaces)}}
|
||||
<span data-test-job-stat="namespace"> | <strong>Namespace:</strong>{{this.job.namespace.name}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JobPage::Parts::JobClientStatusSummary
|
||||
@job={{this.job}}
|
||||
@jobClientStatus={{this.jobClientStatus}}
|
||||
@gotoClients={{this.gotoClients}} />
|
||||
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed="true" />
|
||||
|
||||
<JobPage::Parts::PlacementFailures @job={{this.job}} />
|
||||
|
||||
<JobPage::Parts::TaskGroups
|
||||
@job={{this.job}}
|
||||
@sortProperty={{this.sortProperty}}
|
||||
@sortDescending={{this.sortDescending}}
|
||||
@gotoTaskGroup={{this.gotoTaskGroup}} />
|
||||
|
||||
<JobPage::Parts::RecentAllocations @job={{this.job}} />
|
||||
</JobPage::Parts::Body>
|
||||
@@ -19,7 +19,12 @@
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
<JobPage::Parts::Summary @job={{this.job}} />
|
||||
<JobPage::Parts::JobClientStatusSummary
|
||||
@job={{this.job}}
|
||||
@jobClientStatus={{this.jobClientStatus}}
|
||||
@gotoClients={{this.gotoClients}} />
|
||||
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed="true" />
|
||||
|
||||
<JobPage::Parts::PlacementFailures @job={{this.job}} />
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<div data-test-subnav="job" class="tabs is-subnav">
|
||||
<ul>
|
||||
<li data-test-tab="overview"><LinkTo @route="jobs.job.index" @model={{@job}} @activeClass="is-active" @current-when="jobs.job.index jobs.job.dispatch">Overview</LinkTo></li>
|
||||
<li data-test-tab="definition"><LinkTo @route="jobs.job.definition" @model={{@job}} @activeClass="is-active">Definition</LinkTo></li>
|
||||
<li data-test-tab="versions"><LinkTo @route="jobs.job.versions" @model={{@job}} @activeClass="is-active">Versions</LinkTo></li>
|
||||
<li data-test-tab="overview"><LinkTo @route="jobs.job.index" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active" @current-when="jobs.job.index jobs.job.dispatch">Overview</LinkTo></li>
|
||||
<li data-test-tab="definition"><LinkTo @route="jobs.job.definition" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Definition</LinkTo></li>
|
||||
<li data-test-tab="versions"><LinkTo @route="jobs.job.versions" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Versions</LinkTo></li>
|
||||
{{#if this.job.supportsDeployments}}
|
||||
<li data-test-tab="deployments"><LinkTo @route="jobs.job.deployments" @model={{@job}} @activeClass="is-active">Deployments</LinkTo></li>
|
||||
<li data-test-tab="deployments"><LinkTo @route="jobs.job.deployments" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Deployments</LinkTo></li>
|
||||
{{/if}}
|
||||
<li data-test-tab="allocations"><LinkTo @route="jobs.job.allocations" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Allocations</LinkTo></li>
|
||||
<li data-test-tab="evaluations"><LinkTo @route="jobs.job.evaluations" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Evaluations</LinkTo></li>
|
||||
{{#if (and this.job.hasClientStatus (not this.job.hasChildren))}}
|
||||
<li data-test-tab="clients"><LinkTo @route="jobs.job.clients" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Clients</LinkTo></li>
|
||||
{{/if}}
|
||||
<li data-test-tab="allocations"><LinkTo @route="jobs.job.allocations" @model={{@job}} @activeClass="is-active">Allocations</LinkTo></li>
|
||||
<li data-test-tab="evaluations"><LinkTo @route="jobs.job.evaluations" @model={{@job}} @activeClass="is-active">Evaluations</LinkTo></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
106
ui/app/templates/jobs/job/clients.hbs
Normal file
106
ui/app/templates/jobs/job/clients.hbs
Normal file
@@ -0,0 +1,106 @@
|
||||
{{page-title "Job " this.job.name " clients"}}
|
||||
<JobSubnav @job={{this.job}} />
|
||||
<section class="section">
|
||||
{{#if this.nodes.length}}
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-item">
|
||||
<SearchBox
|
||||
data-test-clients-search
|
||||
@searchTerm={{mut this.searchTerm}}
|
||||
@onChange={{action this.resetPagination}}
|
||||
@placeholder="Search clients..."
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar-item is-right-aligned">
|
||||
<div class="button-bar">
|
||||
<MultiSelectDropdown
|
||||
data-test-job-status-facet
|
||||
@label="Job Status"
|
||||
@options={{this.optionsJobStatus}}
|
||||
@selection={{this.selectionStatus}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpStatus"}}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
data-test-datacenter-facet
|
||||
@label="Datacenter"
|
||||
@options={{this.optionsDatacenter}}
|
||||
@selection={{this.selectionDatacenter}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpDatacenter"}}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
data-test-client-class-facet
|
||||
@label="Client Class"
|
||||
@options={{this.optionsClientClass}}
|
||||
@selection={{this.selectionClientClass}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpClientClass"}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{#if this.sortedClients}}
|
||||
<ListPagination
|
||||
@source={{this.sortedClients}}
|
||||
@size={{this.pageSize}}
|
||||
@page={{this.currentPage}}
|
||||
@class="clients" as |p|
|
||||
>
|
||||
<ListTable
|
||||
@source={{p.list}}
|
||||
@sortProperty={{this.sortProperty}}
|
||||
@sortDescending={{this.sortDescending}}
|
||||
@class="with-foot" as |t|
|
||||
>
|
||||
<t.head>
|
||||
<t.sort-by @prop="node.id">Client ID</t.sort-by>
|
||||
<t.sort-by @prop="node.name" class="is-200px is-truncatable">Client Name</t.sort-by>
|
||||
<t.sort-by @prop="createTime" @title="Create Time">Created</t.sort-by>
|
||||
<t.sort-by @prop="modifyTime" @title="Modify Time">Modified</t.sort-by>
|
||||
<t.sort-by @prop="jobStatus">Job Status</t.sort-by>
|
||||
<th class="is-3">Allocation Summary</th>
|
||||
</t.head>
|
||||
<t.body as |row|>
|
||||
<JobClientStatusRow
|
||||
@row={{row}}
|
||||
@onClick={{this.gotoClient}}
|
||||
/>
|
||||
</t.body>
|
||||
</ListTable>
|
||||
<div class="table-foot">
|
||||
<nav class="pagination">
|
||||
<div class="pagination-numbers">
|
||||
{{p.startsAt}}–{{p.endsAt}} of {{this.sortedClients.length}}
|
||||
</div>
|
||||
<p.prev @class="pagination-previous"> < </p.prev>
|
||||
<p.next @class="pagination-next"> > </p.next>
|
||||
<ul class="pagination-list"></ul>
|
||||
</nav>
|
||||
</div>
|
||||
</ListPagination>
|
||||
{{else}}
|
||||
<div class="boxed-section-body">
|
||||
<div class="empty-message" data-test-empty-clients-list>
|
||||
<h3 class="empty-message-headline" data-test-empty-clients-list-headline>
|
||||
No Matches
|
||||
</h3>
|
||||
<p class="empty-message-body">
|
||||
No clients match the term
|
||||
<strong>
|
||||
{{this.searchTerm}}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div class="boxed-section-body">
|
||||
<div class="empty-message" data-test-empty-clients-list>
|
||||
<h3 class="empty-message-headline" data-test-empty-clients-list-headline>
|
||||
No Clients
|
||||
</h3>
|
||||
<p class="empty-message-body">
|
||||
No clients available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</section>
|
||||
@@ -5,4 +5,6 @@
|
||||
sortDescending=this.sortDescending
|
||||
currentPage=this.currentPage
|
||||
gotoJob=(action "gotoJob")
|
||||
gotoTaskGroup=(action "gotoTaskGroup")}}
|
||||
gotoTaskGroup=(action "gotoTaskGroup")
|
||||
gotoClients=(action "gotoClients")
|
||||
}}
|
||||
141
ui/app/utils/properties/job-client-status.js
Normal file
141
ui/app/utils/properties/job-client-status.js
Normal file
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
83
ui/mirage/scenarios/sysbatch.js
Normal file
83
ui/mirage/scenarios/sysbatch.js
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
205
ui/tests/acceptance/job-clients-test.js
Normal file
205
ui/tests/acceptance/job-clients-test.js
Normal file
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
<JobClientStatusBar
|
||||
@onSliceClick={{onSliceClick}}
|
||||
@job={{job}}
|
||||
@jobClientStatus={{jobClientStatus}}
|
||||
@isNarrow={{isNarrow}}
|
||||
/>`;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
40
ui/tests/pages/components/clients.js
Normal file
40
ui/tests/pages/components/clients.js
Normal file
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
37
ui/tests/pages/components/job-client-status-bar.js
Normal file
37
ui/tests/pages/components/job-client-status-bar.js
Normal file
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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]'),
|
||||
|
||||
|
||||
49
ui/tests/pages/jobs/job/clients.js
Normal file
49
ui/tests/pages/jobs/job/clients.js
Normal file
@@ -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(),
|
||||
});
|
||||
319
ui/tests/unit/utils/job-client-status-test.js
Normal file
319
ui/tests/unit/utils/job-client-status-test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user