From c72cb2ee4ffb3a760b4163b6ff3a46d2e899f909 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 28 Nov 2017 17:21:56 -0800 Subject: [PATCH 1/8] Models evaluation data --- ui/app/models/evaluation.js | 27 +++++++++++++++++++++++++++ ui/app/models/placement-failure.js | 20 ++++++++++++++++++++ ui/app/serializers/evaluation.js | 27 +++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 ui/app/models/evaluation.js create mode 100644 ui/app/models/placement-failure.js create mode 100644 ui/app/serializers/evaluation.js 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/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/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); + }, +}); From 0af56f3af436dec9d40b55ba8ef2fb5b3ae38364 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 28 Nov 2017 17:23:30 -0800 Subject: [PATCH 2/8] Associate jobs, task groups, and evaluations --- ui/app/models/job.js | 27 +++++++++++++++++++++++++++ ui/app/models/task-group.js | 5 +++++ ui/app/serializers/job.js | 7 ++++++- 3 files changed, 38 insertions(+), 1 deletion(-) 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/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/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 }), + }, + }, }); }, }); From 3f3b31614ce97cc255b475c919922df783466907 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 28 Nov 2017 17:24:30 -0800 Subject: [PATCH 3/8] Add a table of evaluations to the job detail page --- ui/app/controllers/jobs/job/index.js | 4 ++++ ui/app/routes/jobs/job.js | 4 ++-- ui/app/templates/jobs/job/index.hbs | 34 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) 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/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/templates/jobs/job/index.hbs b/ui/app/templates/jobs/job/index.hbs index f740bca74bad..b9c573d6d3be 100644 --- a/ui/app/templates/jobs/job/index.hbs +++ b/ui/app/templates/jobs/job/index.hbs @@ -109,5 +109,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}} From a7b054167669ad4534df0e11553fcadbf9534389 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 28 Nov 2017 17:25:06 -0800 Subject: [PATCH 4/8] List placement failures on the job detail page --- ui/app/styles/components.scss | 1 + ui/app/styles/components/simple-list.scss | 9 ++++ ui/app/templates/jobs/job/index.hbs | 51 +++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 ui/app/styles/components/simple-list.scss 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 b9c573d6d3be..7dfdd62f3fc6 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}} +

+
    + {{#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}}
From f30772556e1c274643d02b9690e36a34ff03b242 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 29 Nov 2017 10:42:44 -0800 Subject: [PATCH 5/8] Be clear about what the placment failures number next to task groups is --- ui/app/templates/jobs/job/index.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/app/templates/jobs/job/index.hbs b/ui/app/templates/jobs/job/index.hbs index 7dfdd62f3fc6..ff767e0d6344 100644 --- a/ui/app/templates/jobs/job/index.hbs +++ b/ui/app/templates/jobs/job/index.hbs @@ -58,7 +58,7 @@ {{#with taskGroup.placementFailures as |failures|}}

{{taskGroup.name}} - {{inc failures.coalescedFailures}} + {{inc failures.coalescedFailures}} unplaced

    {{#if (eq failures.nodesEvaluated 0)}} From 98fb10f9ad5b809ce5c9f88f27338babf96b7875 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 29 Nov 2017 15:36:34 -0800 Subject: [PATCH 6/8] Mirage magic for evaluations --- ui/mirage/config.js | 6 ++ ui/mirage/factories/evaluation.js | 110 ++++++++++++++++++++++++++++++ ui/mirage/factories/job.js | 15 +++- ui/mirage/scenarios/default.js | 3 +- 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 ui/mirage/factories/evaluation.js 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..296b634c53f3 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,9 @@ 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, + afterCreate(job, server) { const groups = server.createList('task-group', job.groupsCount, { job, @@ -84,5 +87,15 @@ export default Factory.extend({ activeDeployment: job.activeDeployment, }); }); + + server.createList('evaluation', faker.random.number({ min: 1, max: 5 }), { job }); + 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); From 46d25b771c41a39abc603664a0e4e6dd8655912d Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 29 Nov 2017 19:45:22 -0800 Subject: [PATCH 7/8] Fixes an off by one bug in the ipv6 generator function Sigh. --- ui/mirage/common.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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('')); } From b4ee45a2d61c7349a2eed1478063734acb1da3d4 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 29 Nov 2017 16:29:32 -0800 Subject: [PATCH 8/8] Acceptance tests for evaluations --- ui/app/templates/jobs/job/index.hbs | 4 +- ui/mirage/factories/job.js | 7 ++- ui/tests/acceptance/job-detail-test.js | 77 ++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/ui/app/templates/jobs/job/index.hbs b/ui/app/templates/jobs/job/index.hbs index ff767e0d6344..07a83d98ffaf 100644 --- a/ui/app/templates/jobs/job/index.hbs +++ b/ui/app/templates/jobs/job/index.hbs @@ -48,7 +48,7 @@
{{#if model.hasPlacementFailures}} -
+
Placement Failures
@@ -165,7 +165,7 @@
Evaluations
-
+
{{#list-table source=sortedEvaluations as |t|}} {{#t.head}} ID diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js index 296b634c53f3..b18d16d71db0 100644 --- a/ui/mirage/factories/job.js +++ b/ui/mirage/factories/job.js @@ -44,6 +44,9 @@ export default Factory.extend({ // 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, @@ -89,7 +92,9 @@ export default Factory.extend({ }); server.createList('evaluation', faker.random.number({ min: 1, max: 5 }), { job }); - server.createList('evaluation', faker.random.number(3), 'withPlacementFailures', { job }); + if (!job.noFailedPlacements) { + server.createList('evaluation', faker.random.number(3), 'withPlacementFailures', { job }); + } if (job.failedPlacements) { server.create('evaluation', 'withPlacementFailures', { 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 ) {