Merge pull request #4603 from hashicorp/f-ui-new-job-improved-plan
UI: New job improved plan screen
This commit is contained in:
commit
5aa452e028
|
@ -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] });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
10
ui/app/components/placement-failure.js
Normal file
10
ui/app/components/placement-failure.js
Normal 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'),
|
||||||
|
});
|
|
@ -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);
|
||||||
|
|
8
ui/app/models/job-plan.js
Normal file
8
ui/app/models/job-plan.js
Normal 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: () => [] }),
|
||||||
|
});
|
|
@ -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) {
|
||||||
|
|
12
ui/app/serializers/job-plan.js
Normal file
12
ui/app/serializers/job-plan.js
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
|
@ -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>
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue