diff --git a/ui/app/controllers/jobs/job/index.js b/ui/app/controllers/jobs/job/index.js
index 6e840df3e5a4..2738cccf1ae3 100644
--- a/ui/app/controllers/jobs/job/index.js
+++ b/ui/app/controllers/jobs/job/index.js
@@ -30,6 +30,10 @@ export default Controller.extend(Sortable, WithNamespaceResetting, {
listToSort: computed.alias('taskGroups'),
sortedTaskGroups: computed.alias('listSorted'),
+ sortedEvaluations: computed('model.evaluations.@each.modifyIndex', function() {
+ return (this.get('model.evaluations') || []).sortBy('modifyIndex').reverse();
+ }),
+
actions: {
gotoTaskGroup(taskGroup) {
this.transitionToRoute('jobs.job.task-group', taskGroup.get('job'), taskGroup);
diff --git a/ui/app/models/evaluation.js b/ui/app/models/evaluation.js
new file mode 100644
index 000000000000..8afdf15aee3d
--- /dev/null
+++ b/ui/app/models/evaluation.js
@@ -0,0 +1,27 @@
+import Ember from 'ember';
+import Model from 'ember-data/model';
+import attr from 'ember-data/attr';
+import { belongsTo } from 'ember-data/relationships';
+import { fragmentArray } from 'ember-data-model-fragments/attributes';
+import shortUUIDProperty from '../utils/properties/short-uuid';
+
+const { computed } = Ember;
+
+export default Model.extend({
+ shortId: shortUUIDProperty('id'),
+ priority: attr('number'),
+ type: attr('string'),
+ triggeredBy: attr('string'),
+ status: attr('string'),
+ statusDescription: attr('string'),
+ failedTGAllocs: fragmentArray('placement-failure', { defaultValue: () => [] }),
+
+ hasPlacementFailures: computed.bool('failedTGAllocs.length'),
+
+ // TEMPORARY: https://github.com/emberjs/data/issues/5209
+ originalJobId: attr('string'),
+
+ job: belongsTo('job'),
+
+ modifyIndex: attr('number'),
+});
diff --git a/ui/app/models/job.js b/ui/app/models/job.js
index 18d74b8ff7cc..4f33983ace9e 100644
--- a/ui/app/models/job.js
+++ b/ui/app/models/job.js
@@ -53,8 +53,35 @@ export default Model.extend({
versions: hasMany('job-versions'),
allocations: hasMany('allocations'),
deployments: hasMany('deployments'),
+ evaluations: hasMany('evaluations'),
namespace: belongsTo('namespace'),
+ hasPlacementFailures: computed.bool('latestFailureEvaluation'),
+
+ latestEvaluation: computed('evaluations.@each.modifyIndex', 'evaluations.isPending', function() {
+ const evaluations = this.get('evaluations');
+ if (!evaluations || evaluations.get('isPending')) {
+ return null;
+ }
+ return evaluations.sortBy('modifyIndex').get('lastObject');
+ }),
+
+ latestFailureEvaluation: computed(
+ 'evaluations.@each.modifyIndex',
+ 'evaluations.isPending',
+ function() {
+ const evaluations = this.get('evaluations');
+ if (!evaluations || evaluations.get('isPending')) {
+ return null;
+ }
+
+ const failureEvaluations = evaluations.filterBy('hasPlacementFailures');
+ if (failureEvaluations) {
+ return failureEvaluations.sortBy('modifyIndex').get('lastObject');
+ }
+ }
+ ),
+
supportsDeployments: computed.equal('type', 'service'),
runningDeployment: computed('deployments.@each.status', function() {
diff --git a/ui/app/models/placement-failure.js b/ui/app/models/placement-failure.js
new file mode 100644
index 000000000000..d711166b1d09
--- /dev/null
+++ b/ui/app/models/placement-failure.js
@@ -0,0 +1,20 @@
+import attr from 'ember-data/attr';
+import Fragment from 'ember-data-model-fragments/fragment';
+
+export default Fragment.extend({
+ name: attr('string'),
+
+ coalescedFailures: attr('number'),
+
+ nodesEvaluated: attr('number'),
+ nodesExhausted: attr('number'),
+
+ // Maps keyed by relevant dimension (dc, class, constraint, etc) with count values
+ nodesAvailable: attr(),
+ classFiltered: attr(),
+ constraintFiltered: attr(),
+ classExhausted: attr(),
+ dimensionExhausted: attr(),
+ quotaExhausted: attr(),
+ scores: attr(),
+});
diff --git a/ui/app/models/task-group.js b/ui/app/models/task-group.js
index 56e83d59a7c3..b3998a4cd523 100644
--- a/ui/app/models/task-group.js
+++ b/ui/app/models/task-group.js
@@ -24,6 +24,11 @@ export default Fragment.extend({
reservedEphemeralDisk: attr('number'),
+ placementFailures: computed('job.latestFailureEvaluation.failedTGAllocs.[]', function() {
+ const placementFailures = this.get('job.latestFailureEvaluation.failedTGAllocs');
+ return placementFailures && placementFailures.findBy('name', this.get('name'));
+ }),
+
queuedOrStartingAllocs: computed('summary.{queuedAllocs,startingAllocs}', function() {
return this.get('summary.queuedAllocs') + this.get('summary.startingAllocs');
}),
diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js
index ae4c66ca105e..4317a1e35be2 100644
--- a/ui/app/routes/jobs/job.js
+++ b/ui/app/routes/jobs/job.js
@@ -1,7 +1,7 @@
import Ember from 'ember';
import notifyError from 'nomad-ui/utils/notify-error';
-const { Route, inject } = Ember;
+const { Route, RSVP, inject } = Ember;
export default Route.extend({
store: inject.service(),
@@ -17,7 +17,7 @@ export default Route.extend({
return this.get('store')
.findRecord('job', fullId, { reload: true })
.then(job => {
- return job.get('allocations').then(() => job);
+ return RSVP.all([job.get('allocations'), job.get('evaluations')]).then(() => job);
})
.catch(notifyError(this));
},
diff --git a/ui/app/serializers/evaluation.js b/ui/app/serializers/evaluation.js
new file mode 100644
index 000000000000..76a2b9b3cdd5
--- /dev/null
+++ b/ui/app/serializers/evaluation.js
@@ -0,0 +1,27 @@
+import Ember from 'ember';
+import ApplicationSerializer from './application';
+
+const { inject, get, assign } = Ember;
+
+export default ApplicationSerializer.extend({
+ system: inject.service(),
+
+ normalize(typeHash, hash) {
+ hash.FailedTGAllocs = Object.keys(hash.FailedTGAllocs || {}).map(key => {
+ return assign({ Name: key }, get(hash, `FailedTGAllocs.${key}`) || {});
+ });
+
+ hash.PlainJobId = hash.JobID;
+ hash.Namespace =
+ hash.Namespace ||
+ get(hash, 'Job.Namespace') ||
+ this.get('system.activeNamespace.id') ||
+ 'default';
+ hash.JobID = JSON.stringify([hash.JobID, hash.Namespace]);
+
+ // TEMPORARY: https://github.com/emberjs/data/issues/5209
+ hash.OriginalJobId = hash.JobID;
+
+ return this._super(typeHash, hash);
+ },
+});
diff --git a/ui/app/serializers/job.js b/ui/app/serializers/job.js
index 0f36f7524fbc..74eeb78b086b 100644
--- a/ui/app/serializers/job.js
+++ b/ui/app/serializers/job.js
@@ -19,7 +19,7 @@ export default ApplicationSerializer.extend({
// Transform the map-based JobSummary object into an array-based
// JobSummary fragment list
hash.TaskGroupSummaries = Object.keys(get(hash, 'JobSummary.Summary') || {}).map(key => {
- const allocStats = get(hash, `JobSummary.Summary.${key}`);
+ const allocStats = get(hash, `JobSummary.Summary.${key}`) || {};
const summary = { Name: key };
Object.keys(allocStats).forEach(
@@ -65,6 +65,11 @@ export default ApplicationSerializer.extend({
related: buildURL(`${jobURL}/deployments`, { namespace: namespace }),
},
},
+ evaluations: {
+ links: {
+ related: buildURL(`${jobURL}/evaluations`, { namespace: namespace }),
+ },
+ },
});
},
});
diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss
index 0b4545c213ba..cb2162c2fd8f 100644
--- a/ui/app/styles/components.scss
+++ b/ui/app/styles/components.scss
@@ -12,6 +12,7 @@
@import "./components/loading-spinner";
@import "./components/metrics";
@import "./components/node-status-light";
+@import "./components/simple-list";
@import "./components/status-text";
@import "./components/timeline";
@import "./components/tooltip";
diff --git a/ui/app/styles/components/simple-list.scss b/ui/app/styles/components/simple-list.scss
new file mode 100644
index 000000000000..b3c962fd535d
--- /dev/null
+++ b/ui/app/styles/components/simple-list.scss
@@ -0,0 +1,9 @@
+.simple-list {
+ list-style: disc;
+ list-style-position: inside;
+ margin-left: 1.5rem;
+
+ li {
+ margin-bottom: 0.5em;
+ }
+}
diff --git a/ui/app/templates/jobs/job/index.hbs b/ui/app/templates/jobs/job/index.hbs
index f740bca74bad..07a83d98ffaf 100644
--- a/ui/app/templates/jobs/job/index.hbs
+++ b/ui/app/templates/jobs/job/index.hbs
@@ -47,6 +47,57 @@
+ {{#if model.hasPlacementFailures}}
+
+
+ Placement Failures
+
+
+ {{#each model.taskGroups as |taskGroup|}}
+ {{#if taskGroup.placementFailures}}
+ {{#with taskGroup.placementFailures as |failures|}}
+
+ {{taskGroup.name}}
+ {{inc failures.coalescedFailures}} unplaced
+
+
+ {{#if (eq failures.nodesEvaluated 0)}}
+ - No nodes were eligible for evaluation
+ {{/if}}
+ {{#each-in failures.nodesAvailable as |datacenter available|}}
+ {{#if (eq available 0)}}
+ - No nodes are available in datacenter {{datacenter}}
+ {{/if}}
+ {{/each-in}}
+ {{#each-in failures.classFiltered as |class count|}}
+ - Class {{class}} filtered {{count}} {{pluralize "node" count}}
+ {{/each-in}}
+ {{#each-in failures.constraintFiltered as |constraint count|}}
+ - Constraint
{{constraint}}
filtered {{count}} {{pluralize "node" count}}
+ {{/each-in}}
+ {{#if failures.nodesExhausted}}
+ - Resources exhausted on {{failures.nodesExhausted}} {{pluralize "node" failures.nodesExhausted}}
+ {{/if}}
+ {{#each-in failures.classExhausted as |class count|}}
+ - Class {{class}} exhausted on {{count}} {{pluralize "node" count}}
+ {{/each-in}}
+ {{#each-in failures.dimensionExhausted as |dimension count|}}
+ - Dimension {{dimension}} exhausted on {{count}} {{pluralize "node" count}}
+ {{/each-in}}
+ {{#each-in failures.quotaExhausted as |quota dimension|}}
+ - Quota limit hit {{dimension}}
+ {{/each-in}}
+ {{#each-in failures.scores as |name score|}}
+ - Score {{name}} = {{score}}
+ {{/each-in}}
+
+ {{/with}}
+ {{/if}}
+ {{/each}}
+
+
+ {{/if}}
+
{{#if model.runningDeployment}}
@@ -109,5 +160,39 @@
{{/list-pagination}}
+
+
+
+ Evaluations
+
+
+ {{#list-table source=sortedEvaluations as |t|}}
+ {{#t.head}}
+
ID |
+ Priority |
+ Triggered By |
+ Status |
+ Placement Failures |
+ {{/t.head}}
+ {{#t.body as |row|}}
+
+ {{row.model.shortId}} |
+ {{row.model.priority}} |
+ {{row.model.triggeredBy}} |
+ {{row.model.status}} |
+
+ {{#if (eq row.model.status "blocked")}}
+ N/A - In Progress
+ {{else if row.model.hasPlacementFailures}}
+ True
+ {{else}}
+ False
+ {{/if}}
+ |
+
+ {{/t.body}}
+ {{/list-table}}
+
+
{{/gutter-menu}}
diff --git a/ui/mirage/common.js b/ui/mirage/common.js
index c9c5de517526..75416b593fb1 100644
--- a/ui/mirage/common.js
+++ b/ui/mirage/common.js
@@ -71,7 +71,7 @@ function ipv6() {
for (var i = 0; i < 8; i++) {
var subnet = [];
for (var char = 0; char < 4; char++) {
- subnet.push(faker.random.number(16).toString(16));
+ subnet.push(faker.random.number(15).toString(16));
}
subnets.push(subnet.join(''));
}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js
index 64bbcea417ec..dd23a35e7143 100644
--- a/ui/mirage/config.js
+++ b/ui/mirage/config.js
@@ -59,6 +59,12 @@ export default function() {
this.get('/deployment/:id');
+ this.get('/job/:id/evaluations', function({ evaluations }, { params }) {
+ return this.serialize(evaluations.where({ jobId: params.id }));
+ });
+
+ this.get('/evaluation/:id');
+
this.get('/deployment/allocations/:id', function(schema, { params }) {
const job = schema.jobs.find(schema.deployments.find(params.id).jobId);
const allocations = schema.allocations.where({ jobId: job.id });
diff --git a/ui/mirage/factories/evaluation.js b/ui/mirage/factories/evaluation.js
new file mode 100644
index 000000000000..98cb7c03ef5b
--- /dev/null
+++ b/ui/mirage/factories/evaluation.js
@@ -0,0 +1,110 @@
+import Ember from 'ember';
+import { Factory, faker, trait } from 'ember-cli-mirage';
+import { provide, pickOne } from '../utils';
+import { DATACENTERS } from '../common';
+
+const EVAL_TYPES = ['system', 'service', 'batch'];
+const EVAL_STATUSES = ['blocked', 'pending', 'complete', 'failed', 'canceled'];
+const EVAL_TRIGGERED_BY = [
+ 'job-register',
+ 'job-deregister',
+ 'periodic-job',
+ 'node-update',
+ 'scheduled',
+ 'roling-update',
+ 'deployment-watcher',
+ 'failed-follow-up',
+ 'max-plan-attempts',
+];
+
+const generateCountMap = (keysCount, list) => () => {
+ const sample = Array(keysCount)
+ .fill(null)
+ .map(() => pickOne(list))
+ .uniq();
+ return sample.reduce((hash, key) => {
+ hash[key] = faker.random.number({ min: 1, max: 5 });
+ return hash;
+ }, {});
+};
+
+const generateNodesAvailable = generateCountMap(5, DATACENTERS);
+const generateClassFiltered = generateCountMap(3, provide(10, faker.hacker.abbreviation));
+const generateClassExhausted = generateClassFiltered;
+const generateDimensionExhausted = generateCountMap(1, ['cpu', 'mem', 'disk', 'iops']);
+const generateQuotaExhausted = generateDimensionExhausted;
+const generateScores = generateCountMap(1, ['binpack', 'job-anti-affinity']);
+const generateConstraintFiltered = generateCountMap(2, [
+ 'prop = val',
+ 'driver = docker',
+ 'arch = x64',
+]);
+
+export default Factory.extend({
+ id: () => faker.random.uuid(),
+
+ priority: () => faker.random.number(100),
+
+ type: faker.list.random(...EVAL_TYPES),
+ triggeredBy: faker.list.random(...EVAL_TRIGGERED_BY),
+ status: faker.list.random(...EVAL_STATUSES),
+ statusDescription: () => faker.lorem.sentence(),
+
+ failedTGAllocs: null,
+
+ modifyIndex: () => faker.random.number({ min: 10, max: 2000 }),
+
+ withPlacementFailures: trait({
+ status: faker.list.random(...EVAL_STATUSES.without('blocked')),
+ afterCreate(evaluation, server) {
+ assignJob(evaluation, server);
+ const taskGroups = server.db.taskGroups.where({ jobId: evaluation.jobId });
+
+ const taskGroupNames = taskGroups.mapBy('name');
+ const failedTaskGroupsCount = faker.random.number({ min: 1, max: taskGroupNames.length });
+ const failedTaskGroupNames = [];
+ for (let i = 0; i < failedTaskGroupsCount; i++) {
+ failedTaskGroupNames.push(
+ ...taskGroupNames.splice(faker.random.number(taskGroupNames.length), 1)
+ );
+ }
+
+ const placementFailures = failedTaskGroupNames.reduce((hash, name) => {
+ hash[name] = {
+ CoalescedFailures: faker.random.number({ min: 1, max: 20 }),
+ NodesEvaluated: faker.random.number({ min: 1, max: 100 }),
+ NodesExhausted: faker.random.number({ min: 1, max: 100 }),
+
+ NodesAvailable: Math.random() > 0.7 ? generateNodesAvailable() : null,
+ ClassFiltered: Math.random() > 0.7 ? generateClassFiltered() : null,
+ ConstraintFiltered: Math.random() > 0.7 ? generateConstraintFiltered() : null,
+ ClassExhausted: Math.random() > 0.7 ? generateClassExhausted() : null,
+ DimensionExhausted: Math.random() > 0.7 ? generateDimensionExhausted() : null,
+ QuotaExhausted: Math.random() > 0.7 ? generateQuotaExhausted() : null,
+ Scores: Math.random() > 0.7 ? generateScores() : null,
+ };
+ return hash;
+ }, {});
+
+ evaluation.update({
+ failedTGAllocs: placementFailures,
+ });
+ },
+ }),
+
+ afterCreate(evaluation, server) {
+ assignJob(evaluation, server);
+ },
+});
+
+function assignJob(evaluation, server) {
+ Ember.assert(
+ '[Mirage] No jobs! make sure jobs are created before evaluations',
+ server.db.jobs.length
+ );
+
+ const job = evaluation.jobId ? server.db.jobs.find(evaluation.jobId) : pickOne(server.db.jobs);
+ evaluation.update({
+ jobId: job.id,
+ });
+}
diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js
index eee780e473c5..b18d16d71db0 100644
--- a/ui/mirage/factories/job.js
+++ b/ui/mirage/factories/job.js
@@ -14,7 +14,7 @@ export default Factory.extend({
region: () => 'global',
type: faker.list.random(...JOB_TYPES),
- priority: () => faker.random.number(200),
+ priority: () => faker.random.number(100),
all_at_once: faker.random.boolean,
status: faker.list.random(...JOB_STATUSES),
datacenters: provider(
@@ -41,6 +41,12 @@ export default Factory.extend({
// When true, deployments for the job will always have a 'running' status
activeDeployment: false,
+ // When true, an evaluation with a high modify index and placement failures is created
+ failedPlacements: false,
+
+ // When true, no evaluations have failed placements
+ noFailedPlacements: false,
+
afterCreate(job, server) {
const groups = server.createList('task-group', job.groupsCount, {
job,
@@ -84,5 +90,17 @@ export default Factory.extend({
activeDeployment: job.activeDeployment,
});
});
+
+ server.createList('evaluation', faker.random.number({ min: 1, max: 5 }), { job });
+ if (!job.noFailedPlacements) {
+ server.createList('evaluation', faker.random.number(3), 'withPlacementFailures', { job });
+ }
+
+ if (job.failedPlacements) {
+ server.create('evaluation', 'withPlacementFailures', {
+ job,
+ modifyIndex: 4000,
+ });
+ }
},
});
diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js
index 700d96b90d3f..7ab761099dfa 100644
--- a/ui/mirage/scenarios/default.js
+++ b/ui/mirage/scenarios/default.js
@@ -4,7 +4,8 @@ export default function(server) {
server.createList('namespace', 3);
- server.createList('job', 15);
+ server.createList('job', 10);
+ server.createList('job', 5, { failedPlacements: true });
server.createList('token', 3);
logTokens(server);
diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js
index 512c572e4463..cf3b1bcc52c8 100644
--- a/ui/tests/acceptance/job-detail-test.js
+++ b/ui/tests/acceptance/job-detail-test.js
@@ -296,6 +296,83 @@ test('the active deployment section can be expanded to show task groups and allo
});
});
+test('the evaluations table lists evaluations sorted by modify index', function(assert) {
+ job = server.create('job');
+ const evaluations = server.db.evaluations
+ .where({ jobId: job.id })
+ .sortBy('modifyIndex')
+ .reverse();
+
+ visit(`/jobs/${job.id}`);
+
+ andThen(() => {
+ assert.equal(
+ findAll('.evaluations tbody tr').length,
+ evaluations.length,
+ 'A row for each evaluation'
+ );
+
+ evaluations.forEach((evaluation, index) => {
+ const row = $(findAll('.evaluations tbody tr')[index]);
+ assert.equal(
+ row.find('td:eq(0)').text(),
+ evaluation.id.split('-')[0],
+ `Short ID, row ${index}`
+ );
+ });
+
+ const firstEvaluation = evaluations[0];
+ const row = $(findAll('.evaluations tbody tr')[0]);
+ assert.equal(row.find('td:eq(1)').text(), '' + firstEvaluation.priority, 'Priority');
+ assert.equal(row.find('td:eq(2)').text(), firstEvaluation.triggeredBy, 'Triggered By');
+ assert.equal(row.find('td:eq(3)').text(), firstEvaluation.status, 'Status');
+ });
+});
+
+test('when the job has placement failures, they are called out', function(assert) {
+ job = server.create('job', { failedPlacements: true });
+ const failedEvaluation = server.db.evaluations
+ .where({ jobId: job.id })
+ .filter(evaluation => evaluation.failedTGAllocs)
+ .sortBy('modifyIndex')
+ .reverse()[0];
+
+ const failedTaskGroupNames = Object.keys(failedEvaluation.failedTGAllocs);
+
+ visit(`/jobs/${job.id}`);
+
+ andThen(() => {
+ assert.ok(find('.placement-failures'), 'Placement failures section found');
+
+ const taskGroupLabels = findAll('.placement-failures h3.title').map(title =>
+ title.textContent.trim()
+ );
+ failedTaskGroupNames.forEach(name => {
+ assert.ok(
+ taskGroupLabels.find(label => label.includes(name)),
+ `${name} included in placement failures list`
+ );
+ assert.ok(
+ taskGroupLabels.find(label =>
+ label.includes(failedEvaluation.failedTGAllocs[name].CoalescedFailures + 1)
+ ),
+ 'The number of unplaced allocs = CoalescedFailures + 1'
+ );
+ });
+ });
+});
+
+test('when the job has no placement failures, the placement failures section is gone', function(
+ assert
+) {
+ job = server.create('job', { noFailedPlacements: true });
+ visit(`/jobs/${job.id}`);
+
+ andThen(() => {
+ assert.notOk(find('.placement-failures'), 'Placement failures section not found');
+ });
+});
+
test('when the job is not found, an error message is shown, but the URL persists', function(
assert
) {