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'),
|
||||
Diff: true,
|
||||
},
|
||||
}).then(json => {
|
||||
json.ID = job.get('id');
|
||||
this.get('store').pushPayload('job-plan', { jobPlans: [json] });
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -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 { 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,
|
||||
|
@ -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'));
|
||||
this.set('planOutput', plan);
|
||||
} catch (err) {
|
||||
const error = messageFromAdapterError(err) || 'Could not plan job';
|
||||
this.set('planError', error);
|
||||
|
|
|
@ -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 {
|
||||
// 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) {
|
||||
|
|
|
@ -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>
|
||||
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>
|
||||
|
|
|
@ -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">
|
||||
|
@ -37,4 +37,3 @@
|
|||
</ul>
|
||||
{{/with}}
|
||||
{{/if}}
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
this.post('/jobs/parse', function({ jobs }, req) {
|
||||
this.post('/jobs/parse', function(schema, req) {
|
||||
const body = JSON.parse(req.requestBody);
|
||||
|
||||
if (!body.JobHCL)
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}, {});
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}, {});
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
@ -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
|
||||
),
|
||||
|
@ -37,7 +40,7 @@ job "${newJobName}" {
|
|||
namespace = "default"
|
||||
datacenters = ["dc1"]
|
||||
|
||||
task "redis" {
|
||||
task "${newJobTaskGroupName}" {
|
||||
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,
|
||||
placementFailures: assign(
|
||||
{
|
||||
name: name,
|
||||
coalescedFailures: 10,
|
||||
nodesEvaluated: 0,
|
||||
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 error from 'nomad-ui/tests/pages/components/error';
|
||||
|
@ -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'),
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue