Merge pull request #4603 from hashicorp/f-ui-new-job-improved-plan

UI: New job improved plan screen
This commit is contained in:
Michael Lange 2018-08-23 15:21:13 -07:00 committed by GitHub
commit 5aa452e028
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 170 additions and 37 deletions

View file

@ -77,6 +77,9 @@ export default Watchable.extend({
Job: job.get('_newDefinitionJSON'), Job: job.get('_newDefinitionJSON'),
Diff: true, Diff: true,
}, },
}).then(json => {
json.ID = job.get('id');
this.get('store').pushPayload('job-plan', { jobPlans: [json] });
}); });
}, },

View file

@ -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'),
});

View file

@ -1,10 +1,13 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
import localStorageProperty from 'nomad-ui/utils/properties/local-storage'; import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
export default Controller.extend({ export default Controller.extend({
store: service(),
parseError: null, parseError: null,
planError: null, planError: null,
runError: null, runError: null,
@ -30,8 +33,9 @@ export default Controller.extend({
} }
try { try {
const planOutput = yield this.get('model').plan(); yield this.get('model').plan();
this.set('planOutput', planOutput.Diff); const plan = this.get('store').peekRecord('job-plan', this.get('model.id'));
this.set('planOutput', plan);
} catch (err) { } catch (err) {
const error = messageFromAdapterError(err) || 'Could not plan job'; const error = messageFromAdapterError(err) || 'Could not plan job';
this.set('planError', error); this.set('planError', error);

View file

@ -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: () => [] }),
});

View file

@ -210,7 +210,7 @@ export default Model.extend({
try { try {
// If the definition is already JSON then it doesn't need to be parsed. // If the definition is already JSON then it doesn't need to be parsed.
const json = JSON.parse(definition); const json = JSON.parse(definition);
this.set('_newDefinitionJSON', definition); this.set('_newDefinitionJSON', json);
this.setIDByPayload(json); this.setIDByPayload(json);
promise = RSVP.resolve(definition); promise = RSVP.resolve(definition);
} catch (err) { } catch (err) {

View file

@ -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);
},
});

View file

@ -80,12 +80,12 @@
</span> </span>
Task: "{{task.Name}}" Task: "{{task.Name}}"
{{#if task.Annotations}} {{#if task.Annotations}}
({{#each task.Annotations as |annotation index|}} ({{~#each task.Annotations as |annotation index|}}
<span class="{{css-class annotation}}">{{annotation}}</span> <span class="{{css-class annotation}}">{{annotation}}</span>
{{#unless (eq index (dec annotations.length))}},{{/unless}} {{#unless (eq index (dec task.Annotations.length))}},{{/unless}}
{{/each}}) {{/each~}})
{{/if}} {{/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}} {{job-diff-fields-and-objects fields=task.Fields objects=task.Objects}}
{{/if}} {{/if}}
</div> </div>

View file

@ -1,7 +1,7 @@
{{#if taskGroup.placementFailures}} {{#if placementFailures}}
{{#with taskGroup.placementFailures as |failures|}} {{#with placementFailures as |failures|}}
<h3 class="title is-5" data-test-placement-failure-task-group> <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> <span class="badge is-light" data-test-placement-failure-coalesced-failures>{{inc failures.coalescedFailures}} unplaced</span>
</h3> </h3>
<ul class="simple-list"> <ul class="simple-list">
@ -37,4 +37,3 @@
</ul> </ul>
{{/with}} {{/with}}
{{/if}} {{/if}}

View file

@ -71,7 +71,19 @@
<div class="boxed-section"> <div class="boxed-section">
<div class="boxed-section-head">Job Plan</div> <div class="boxed-section-head">Job Plan</div>
<div class="boxed-section-body is-dark"> <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> </div>
<div class="content is-associative"> <div class="content is-associative">

View file

@ -3,6 +3,7 @@ import Response from 'ember-cli-mirage/response';
import { HOSTS } from './common'; import { HOSTS } from './common';
import { logFrames, logEncode } from './data/logs'; import { logFrames, logEncode } from './data/logs';
import { generateDiff } from './factories/job-version'; import { generateDiff } from './factories/job-version';
import { generateTaskGroupFailures } from './factories/evaluation';
const { copy } = Ember; const { copy } = Ember;
@ -56,7 +57,7 @@ export default function() {
}) })
); );
this.post('/jobs', function({ jobs }, req) { this.post('/jobs', function(schema, req) {
const body = JSON.parse(req.requestBody); const body = JSON.parse(req.requestBody);
if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload');
@ -64,7 +65,7 @@ export default function() {
return okEmpty(); return okEmpty();
}); });
this.post('/jobs/parse', function({ jobs }, req) { this.post('/jobs/parse', function(schema, req) {
const body = JSON.parse(req.requestBody); const body = JSON.parse(req.requestBody);
if (!body.JobHCL) if (!body.JobHCL)
@ -84,13 +85,19 @@ export default function() {
return new Response(200, {}, this.serialize(job)); 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); const body = JSON.parse(req.requestBody);
if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); 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'); 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( this.get(
@ -319,3 +326,16 @@ function filterKeys(object, ...keys) {
function okEmpty() { function okEmpty() {
return new Response(200, {}, '{}'); 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;
}, {});
}

View file

@ -72,19 +72,7 @@ export default Factory.extend({
} }
const placementFailures = failedTaskGroupNames.reduce((hash, name) => { const placementFailures = failedTaskGroupNames.reduce((hash, name) => {
hash[name] = { hash[name] = generateTaskGroupFailures();
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; return hash;
}, {}); }, {});
@ -111,3 +99,19 @@ function assignJob(evaluation, server) {
job_id: job.id, 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,
};
}

View file

@ -5,6 +5,7 @@ import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance';
import JobRun from 'nomad-ui/tests/pages/jobs/run'; import JobRun from 'nomad-ui/tests/pages/jobs/run';
const newJobName = 'new-job'; const newJobName = 'new-job';
const newJobTaskGroupName = 'redis';
const jsonJob = overrides => { const jsonJob = overrides => {
return JSON.stringify( return JSON.stringify(
@ -15,15 +16,17 @@ const jsonJob = overrides => {
Namespace: 'default', Namespace: 'default',
Datacenters: ['dc1'], Datacenters: ['dc1'],
Priority: 50, Priority: 50,
TaskGroups: { TaskGroups: [
redis: { {
Tasks: { Name: newJobTaskGroupName,
redis: { Tasks: [
{
Name: 'redis',
Driver: 'docker', Driver: 'docker',
}, },
],
}, },
}, ],
},
}, },
overrides overrides
), ),
@ -37,7 +40,7 @@ job "${newJobName}" {
namespace = "default" namespace = "default"
datacenters = ["dc1"] datacenters = ["dc1"]
task "redis" { task "${newJobTaskGroupName}" {
driver = "docker" driver = "docker"
} }
} }
@ -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
const spec = jsonJob({ Unschedulable: true });
JobRun.visit();
andThen(() => {
JobRun.editor.fillIn(spec);
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'
);
});
});

View file

@ -108,6 +108,7 @@ function createFixture(obj = {}, name = 'Placement Failure') {
name: name, name: name,
placementFailures: assign( placementFailures: assign(
{ {
name: name,
coalescedFailures: 10, coalescedFailures: 10,
nodesEvaluated: 0, nodesEvaluated: 0,
nodesAvailable: { nodesAvailable: {

View file

@ -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 { codeFillable, code } from 'nomad-ui/tests/pages/helpers/codemirror';
import error from 'nomad-ui/tests/pages/components/error'; import error from 'nomad-ui/tests/pages/components/error';
@ -35,4 +35,12 @@ export default create({
contents: code('[data-test-editor]'), contents: code('[data-test-editor]'),
fillIn: codeFillable('[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'),
},
}); });