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 +

+ + {{/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 ) {