Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: New job improved plan screen #4603

Merged
merged 4 commits into from
Aug 23, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ui/app/adapters/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export default Watchable.extend({
Job: job.get('_newDefinitionJSON'),
Diff: true,
},
}).then(json => {
json.ID = job.get('id');
this.get('store').pushPayload('job-plan', { jobPlans: [json] });
});
},

Expand Down
10 changes: 10 additions & 0 deletions ui/app/components/placement-failure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Component from '@ember/component';
import { or } from '@ember/object/computed';

export default Component.extend({
// Either provide a taskGroup or a failedTGAlloc
taskGroup: null,
failedTGAlloc: null,

placementFailures: or('taskGroup.placementFailures', 'failedTGAlloc'),
});
8 changes: 6 additions & 2 deletions ui/app/controllers/jobs/run.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { computed } from '@ember/object';
import { task } from 'ember-concurrency';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
import localStorageProperty from 'nomad-ui/utils/properties/local-storage';

export default Controller.extend({
store: service(),

parseError: null,
planError: null,
runError: null,
Expand All @@ -30,8 +33,9 @@ export default Controller.extend({
}

try {
const planOutput = yield this.get('model').plan();
this.set('planOutput', planOutput.Diff);
yield this.get('model').plan();
const plan = this.get('store').peekRecord('job-plan', this.get('model.id'));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you consider returning the JobPlan record from the adapter plan method? Reading this code, I had to go through the model to the adapter to verify that the peekRecord would work at this point. Returning a promise with a value instead might make the responsibility of the plan method clearer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally I wrote this as a relationship, then I noped out of there when planning multiple times didn't refresh the relationship on the job side and ended up at this place.

It made sense then to change what was job.get('jobPlan') to store.peekRecord at the call site, but I appreciate your fresh eyes here. Returning the new record from the plan method is much better.

I tested it out and it works, but I already refactored this code in the PR I'm about to open, so instead of making the change here, I'll make it there.

this.set('planOutput', plan);
} catch (err) {
const error = messageFromAdapterError(err) || 'Could not plan job';
this.set('planError', error);
Expand Down
8 changes: 8 additions & 0 deletions ui/app/models/job-plan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { fragmentArray } from 'ember-data-model-fragments/attributes';

export default Model.extend({
diff: attr(),
failedTGAllocs: fragmentArray('placement-failure', { defaultValue: () => [] }),
});
2 changes: 1 addition & 1 deletion ui/app/models/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export default Model.extend({
try {
// If the definition is already JSON then it doesn't need to be parsed.
const json = JSON.parse(definition);
this.set('_newDefinitionJSON', definition);
this.set('_newDefinitionJSON', json);
this.setIDByPayload(json);
promise = RSVP.resolve(definition);
} catch (err) {
Expand Down
12 changes: 12 additions & 0 deletions ui/app/serializers/job-plan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { get } from '@ember/object';
import { assign } from '@ember/polyfills';
import ApplicationSerializer from './application';

export default ApplicationSerializer.extend({
normalize(typeHash, hash) {
hash.FailedTGAllocs = Object.keys(hash.FailedTGAllocs || {}).map(key => {
return assign({ Name: key }, get(hash, `FailedTGAllocs.${key}`) || {});
});
return this._super(...arguments);
},
});
8 changes: 4 additions & 4 deletions ui/app/templates/components/job-diff.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,12 @@
</span>
Task: "{{task.Name}}"
{{#if task.Annotations}}
({{#each task.Annotations as |annotation index|}}
({{~#each task.Annotations as |annotation index|}}
<span class="{{css-class annotation}}">{{annotation}}</span>
{{#unless (eq index (dec annotations.length))}},{{/unless}}
{{/each}})
{{#unless (eq index (dec task.Annotations.length))}},{{/unless}}
{{/each~}})
{{/if}}
{{#if (or verbose (eq (lowercase task.Type "edited")))}}
{{#if (or verbose (eq (lowercase task.Type) "edited"))}}
{{job-diff-fields-and-objects fields=task.Fields objects=task.Objects}}
{{/if}}
</div>
Expand Down
7 changes: 3 additions & 4 deletions ui/app/templates/components/placement-failure.hbs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{{#if taskGroup.placementFailures}}
{{#with taskGroup.placementFailures as |failures|}}
{{#if placementFailures}}
{{#with placementFailures as |failures|}}
<h3 class="title is-5" data-test-placement-failure-task-group>
{{taskGroup.name}}
{{placementFailures.name}}
<span class="badge is-light" data-test-placement-failure-coalesced-failures>{{inc failures.coalescedFailures}} unplaced</span>
</h3>
<ul class="simple-list">
Expand Down Expand Up @@ -37,4 +37,3 @@
</ul>
{{/with}}
{{/if}}

14 changes: 13 additions & 1 deletion ui/app/templates/jobs/run.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,19 @@
<div class="boxed-section">
<div class="boxed-section-head">Job Plan</div>
<div class="boxed-section-body is-dark">
{{job-diff data-test-plan-output diff=planOutput}}
{{job-diff data-test-plan-output diff=planOutput.diff verbose=false}}
</div>
</div>
<div class="boxed-section {{if planOutput.failedTGAllocs "is-warning" "is-primary"}}" data-test-dry-run-message>
<div class="boxed-section-head" data-test-dry-run-title>Scheduler dry-run</div>
<div class="boxed-section-body" data-test-dry-run-body>
{{#if planOutput.failedTGAllocs}}
{{#each planOutput.failedTGAllocs as |placementFailure|}}
{{placement-failure failedTGAlloc=placementFailure}}
{{/each}}
{{else}}
All tasks successfully allocated.
{{/if}}
</div>
</div>
<div class="content is-associative">
Expand Down
28 changes: 24 additions & 4 deletions ui/mirage/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Response from 'ember-cli-mirage/response';
import { HOSTS } from './common';
import { logFrames, logEncode } from './data/logs';
import { generateDiff } from './factories/job-version';
import { generateTaskGroupFailures } from './factories/evaluation';

const { copy } = Ember;

Expand Down Expand Up @@ -56,15 +57,15 @@ export default function() {
})
);

this.post('/jobs', function({ jobs }, req) {
this.post('/jobs', function(schema, req) {
const body = JSON.parse(req.requestBody);

if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload');

return okEmpty();
});

this.post('/jobs/parse', function({ jobs }, req) {
this.post('/jobs/parse', function(schema, req) {
const body = JSON.parse(req.requestBody);

if (!body.JobHCL)
Expand All @@ -84,13 +85,19 @@ export default function() {
return new Response(200, {}, this.serialize(job));
});

this.post('/job/:id/plan', function({ jobs }, req) {
this.post('/job/:id/plan', function(schema, req) {
const body = JSON.parse(req.requestBody);

if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload');
if (!body.Diff) return new Response(400, {}, 'Expected Diff to be true');

return new Response(200, {}, JSON.stringify({ Diff: generateDiff(req.params.id) }));
const FailedTGAllocs = body.Job.Unschedulable && generateFailedTGAllocs(body.Job);

return new Response(
200,
{},
JSON.stringify({ FailedTGAllocs, Diff: generateDiff(req.params.id) })
);
});

this.get(
Expand Down Expand Up @@ -319,3 +326,16 @@ function filterKeys(object, ...keys) {
function okEmpty() {
return new Response(200, {}, '{}');
}

function generateFailedTGAllocs(job, taskGroups) {
const taskGroupsFromSpec = job.TaskGroups && job.TaskGroups.mapBy('Name');

let tgNames = ['tg-one', 'tg-two'];
if (taskGroupsFromSpec && taskGroupsFromSpec.length) tgNames = taskGroupsFromSpec;
if (taskGroups && taskGroups.length) tgNames = taskGroups;

return tgNames.reduce((hash, tgName) => {
hash[tgName] = generateTaskGroupFailures();
return hash;
}, {});
}
30 changes: 17 additions & 13 deletions ui/mirage/factories/evaluation.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,7 @@ export default Factory.extend({
}

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,
};
hash[name] = generateTaskGroupFailures();
return hash;
}, {});

Expand All @@ -111,3 +99,19 @@ function assignJob(evaluation, server) {
job_id: job.id,
});
}

export function generateTaskGroupFailures() {
return {
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,
};
}
66 changes: 59 additions & 7 deletions ui/tests/acceptance/job-run-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance';
import JobRun from 'nomad-ui/tests/pages/jobs/run';

const newJobName = 'new-job';
const newJobTaskGroupName = 'redis';

const jsonJob = overrides => {
return JSON.stringify(
Expand All @@ -15,15 +16,17 @@ const jsonJob = overrides => {
Namespace: 'default',
Datacenters: ['dc1'],
Priority: 50,
TaskGroups: {
redis: {
Tasks: {
redis: {
TaskGroups: [
{
Name: newJobTaskGroupName,
Tasks: [
{
Name: 'redis',
Driver: 'docker',
},
},
],
},
},
],
},
overrides
),
Expand All @@ -37,7 +40,7 @@ job "${newJobName}" {
namespace = "default"
datacenters = ["dc1"]

task "redis" {
task "${newJobTaskGroupName}" {
driver = "docker"
}
}
Expand Down Expand Up @@ -313,3 +316,52 @@ test('when submitting a job to a different namespace, the redirect to the job ov
);
});
});

test('when the scheduler dry-run has warnings, the warnings are shown to the user', function(assert) {
// Unschedulable is a hint to Mirage to respond with warnings from the plan endpoint

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is helpful!

const spec = jsonJob({ Unschedulable: true });

JobRun.visit();

andThen(() => {
JobRun.editor.fillIn(spec);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌 I need to get around to grabbing this editor page object…

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes in my next PR too 😢

Since registerHelper only applies to acceptance tests, I have to do some Not Cool™ things to make this work in a component integration test.

JobRun.plan();
});

andThen(() => {
assert.ok(
JobRun.dryRunMessage.errored,
'The scheduler dry-run message is in the warning state'
);
assert.notOk(
JobRun.dryRunMessage.succeeded,
'The success message is not shown in addition to the warning message'
);
assert.ok(
JobRun.dryRunMessage.body.includes(newJobTaskGroupName),
'The scheduler dry-run message includes the warning from send back by the API'
);
});
});

test('when the scheduler dry-run has no warnings, a success message is shown to the user', function(assert) {
const spec = hclJob();

JobRun.visit();

andThen(() => {
JobRun.editor.fillIn(spec);
JobRun.plan();
});

andThen(() => {
assert.ok(
JobRun.dryRunMessage.succeeded,
'The scheduler dry-run message is in the success state'
);
assert.notOk(
JobRun.dryRunMessage.errored,
'The warning message is not shown in addition to the success message'
);
});
});
1 change: 1 addition & 0 deletions ui/tests/integration/placement-failure-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ function createFixture(obj = {}, name = 'Placement Failure') {
name: name,
placementFailures: assign(
{
name: name,
coalescedFailures: 10,
nodesEvaluated: 0,
nodesAvailable: {
Expand Down
10 changes: 9 additions & 1 deletion ui/tests/pages/jobs/run.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { clickable, create, isPresent, text, visitable } from 'ember-cli-page-object';
import { clickable, create, hasClass, isPresent, text, visitable } from 'ember-cli-page-object';
import { codeFillable, code } from 'nomad-ui/tests/pages/helpers/codemirror';

import error from 'nomad-ui/tests/pages/components/error';
Expand Down Expand Up @@ -35,4 +35,12 @@ export default create({
contents: code('[data-test-editor]'),
fillIn: codeFillable('[data-test-editor]'),
},

dryRunMessage: {
scope: '[data-test-dry-run-message]',
title: text('[data-test-dry-run-title]'),
body: text('[data-test-dry-run-body]'),
errored: hasClass('is-warning'),
succeeded: hasClass('is-primary'),
},
});