From bc7ae2282fb3d5c8b3dad2d0be65d90c92fa4017 Mon Sep 17 00:00:00 2001 From: Nicholas Cioli Date: Tue, 1 Jun 2021 03:06:25 -0400 Subject: [PATCH 01/23] ui: add parameterized dispatch interface This commit adds a new interface for dispatching parameteried jobs, if the user has the right permissions. The UI can be accessed by viewing a parameterized job and clicking on the "Dispatch Job" button located in the "Job Launches" section. --- ui/app/abilities/job.js | 8 ++ ui/app/adapters/job.js | 17 +++ ui/app/components/job-dispatch.js | 108 ++++++++++++++++++ .../components/job-page/parts/meta-values.js | 44 +++++++ ui/app/models/job-dispatch.js | 10 ++ ui/app/models/job.js | 4 + ui/app/router.js | 1 + ui/app/routes/jobs/job/dispatch.js | 29 +++++ ui/app/styles/utils/bumper.scss | 4 + ui/app/templates/components/job-dispatch.hbs | 50 ++++++++ .../job-page/parameterized-child.hbs | 2 + .../components/job-page/parts/children.hbs | 3 + .../components/job-page/parts/meta-values.hbs | 20 ++++ ui/app/templates/jobs/job/dispatch.hbs | 7 ++ ui/package.json | 4 +- ui/yarn.lock | 7 ++ 16 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 ui/app/components/job-dispatch.js create mode 100644 ui/app/components/job-page/parts/meta-values.js create mode 100644 ui/app/models/job-dispatch.js create mode 100644 ui/app/routes/jobs/job/dispatch.js create mode 100644 ui/app/templates/components/job-dispatch.hbs create mode 100644 ui/app/templates/components/job-page/parts/meta-values.hbs create mode 100644 ui/app/templates/jobs/job/dispatch.hbs diff --git a/ui/app/abilities/job.js b/ui/app/abilities/job.js index 3c7465b59c40..01103bbbf00e 100644 --- a/ui/app/abilities/job.js +++ b/ui/app/abilities/job.js @@ -20,6 +20,9 @@ export default class Job extends AbstractAbility { @or('bypassAuthorization', 'selfTokenIsManagement') canListAll; + @or('bypassAuthorization', 'policiesSupportDispatching') + canDispatch; + @computed('rulesForNamespace.@each.capabilities') get policiesSupportRunning() { return this.namespaceIncludesCapability('submit-job'); @@ -29,4 +32,9 @@ export default class Job extends AbstractAbility { get policiesSupportScaling() { return this.namespaceIncludesCapability('scale-job'); } + + @computed('rulesForNamespace.@each.capabilities') + get policiesSupportDispatching() { + return this.namespaceIncludesCapability('dispatch-job'); + } } diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index cda5c218aab7..7893505df401 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -84,4 +84,21 @@ export default class JobAdapter extends WatchableNamespaceIDs { }, }); } + + dispatch(job, meta, payload) { + const jobId = job.get('id'); + const store = this.store; + const url = addToPath(this.urlForFindRecord(jobId, 'job'), '/dispatch'); + + return this.ajax(url, 'POST', { + data: { + Payload: payload, + Meta: meta, + }, + }).then(json => { + json.ID = jobId; + store.pushPayload('job-dispatch', { jobDispatches: [json] }); + return store.peekRecord('job-dispatch', jobId); + }); + } } diff --git a/ui/app/components/job-dispatch.js b/ui/app/components/job-dispatch.js new file mode 100644 index 000000000000..399cfb7b0d52 --- /dev/null +++ b/ui/app/components/job-dispatch.js @@ -0,0 +1,108 @@ +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { action, computed } from '@ember/object'; +import { task } from 'ember-concurrency'; +import classic from 'ember-classic-decorator'; + +import { noCase } from 'no-case'; +import { titleCase } from 'title-case'; + +import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; +import localStorageProperty from 'nomad-ui/utils/properties/local-storage'; + +@classic +export default class JobDispatch extends Component { + @service can; + @service store; + @service config; + @service router; + + job = null; + dispatchError = null; + paramValues = {}; + payload = null; + + @computed('job.definition.Meta', 'job.definition.ParameterizedJob.{MetaOptional,MetaRequired}') + get params() { + // Helper for mapping the params into a useable form + let mapper = (values, isRequired) => + values.map(x => { + let emptyPlaceholder = ''; + let placeholder = + this.job.definition.Meta != null ? this.job.definition.Meta[x] : emptyPlaceholder; + + return { + isRequired: isRequired, + name: x, + title: titleCase(noCase(x)), + + // Only show the placeholder on fields that aren't mandatory + placeholder: isRequired ? emptyPlaceholder : placeholder, + }; + }); + + // Fetch the different types of parameters + let required = mapper(this.job.definition.ParameterizedJob.MetaRequired || [], true); + let optional = mapper(this.job.definition.ParameterizedJob.MetaOptional || [], false); + + // Return them, required before optional + return required.concat(optional); + } + + @computed('job.definition.ParameterizedJob.Payload') + get hasPayload() { + return this.job.definition.ParameterizedJob.Payload != 'forbidden'; + } + + @computed('job.definition.ParameterizedJob.Payload') + get isPayloadRequired() { + return this.job.definition.ParameterizedJob.Payload == 'required'; + } + + @action + updateParamValue(name, input) { + this.paramValues[name] = input.originalTarget.value; + } + + @task(function*() { + // Make sure that we have all of the fields that we need + let isValid = true; + let required = this.job.definition.ParameterizedJob.MetaRequired || []; + required.forEach(required => { + let input = document.getElementById(required); + isValid &= input.checkValidity(); + }); + + // Short out if we are missing fields + if (!isValid) yield; + + // Try to create the dispatch + try { + const dispatch = yield this.job.rawJob.dispatch(this.paramValues, this.payload); + + // Navigate to the newly created instance + this.router.transitionTo('jobs.job', dispatch.toJSON().dispatchedJobID); + } catch (err) { + const error = messageFromAdapterError(err) || 'Could not dispatch job'; + this.set('dispatchError', error); + } + }) + submit; + + @action + cancel() { + this.router.transitionTo('jobs.job'); + } + + reset() { + this.set('dispatchError', null); + this.set('paramValues', {}); + this.set('payload', null); + } + + scrollToError() { + if (!this.get('config.isTest')) { + window.scrollTo(0, 0); + } + } +} diff --git a/ui/app/components/job-page/parts/meta-values.js b/ui/app/components/job-page/parts/meta-values.js new file mode 100644 index 000000000000..aa4ddfde1b5e --- /dev/null +++ b/ui/app/components/job-page/parts/meta-values.js @@ -0,0 +1,44 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import classic from 'ember-classic-decorator'; + +import Sortable from 'nomad-ui/mixins/sortable'; + +@classic +export default class MetaValues extends Component { + job = null; + definition = null; + + init() { + super.init(...arguments); + + // Note: The JOB route does not fetch the raw definition, so we do it here + this.job.fetchRawDefinition().then(def => this.set('definition', def)); + } + + @computed('definition.Meta', 'job.parameterizedDetails') + get paramMap() { + let params = this.job.parameterizedDetails || {}; + let merged = []; + + let required = params.MetaRequired || []; + let optional = params.MetaOptional || []; + + // Helper for getting the value for a meta, if provided. + let getValue = name => (this.definition ? this.definition.Meta[name] : ''); + + // Merge all of the different info + let mergeFun = required => name => + merged.push({ + name: name, + value: getValue(name), + required: required, + }); + + required.forEach(mergeFun(true)); + optional.forEach(mergeFun(false)); + + return merged; + } +} diff --git a/ui/app/models/job-dispatch.js b/ui/app/models/job-dispatch.js new file mode 100644 index 000000000000..788e0cbb4c24 --- /dev/null +++ b/ui/app/models/job-dispatch.js @@ -0,0 +1,10 @@ +import Model from '@ember-data/model'; +import { attr } from '@ember-data/model'; + +export default class JobDispatch extends Model { + @attr() index; + @attr() jobCreateIndex; + @attr() evalCreateIndex; + @attr() evalID; + @attr() dispatchedJobID; +} diff --git a/ui/app/models/job.js b/ui/app/models/job.js index 8e37f64e3911..aa04a94587f0 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -253,6 +253,10 @@ export default class Job extends Model { return this.store.adapterFor('job').scale(this, group, count, message); } + dispatch(meta, payload) { + return this.store.adapterFor('job').dispatch(this, meta, payload); + } + setIdByPayload(payload) { const namespace = payload.Namespace || 'default'; const id = payload.Name; diff --git a/ui/app/router.js b/ui/app/router.js index 80d24697dfd0..072291fe673a 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -20,6 +20,7 @@ Router.map(function() { this.route('definition'); this.route('versions'); this.route('deployments'); + this.route('dispatch'); this.route('evaluations'); this.route('allocations'); }); diff --git a/ui/app/routes/jobs/job/dispatch.js b/ui/app/routes/jobs/job/dispatch.js new file mode 100644 index 000000000000..30a7b83571e7 --- /dev/null +++ b/ui/app/routes/jobs/job/dispatch.js @@ -0,0 +1,29 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class DispatchRoute extends Route { + @service can; + + breadcrumbs = [ + { + label: 'Dispatch', + args: ['jobs.run'], + }, + ]; + + beforeModel() { + if (this.can.cannot('dispatch job')) { + this.transitionTo('jobs.job'); + } + } + + model() { + const job = this.modelFor('jobs.job'); + if (!job) return this.transitionTo('jobs.job'); + + return job.fetchRawDefinition().then(definition => ({ + rawJob: job, + definition, + })); + } +} diff --git a/ui/app/styles/utils/bumper.scss b/ui/app/styles/utils/bumper.scss index 97c3f0134001..8982a7ab269c 100644 --- a/ui/app/styles/utils/bumper.scss +++ b/ui/app/styles/utils/bumper.scss @@ -5,3 +5,7 @@ .bumper-right { margin-right: 1.5rem; } + +.bumper-top { + margin-top: 1.5em; +} diff --git a/ui/app/templates/components/job-dispatch.hbs b/ui/app/templates/components/job-dispatch.hbs new file mode 100644 index 000000000000..d2ac61dc84ef --- /dev/null +++ b/ui/app/templates/components/job-dispatch.hbs @@ -0,0 +1,50 @@ +{{#if this.dispatchError}} +
+

Dispatch Error

+

{{this.dispatchError}}

+
+{{/if}} + +
+
+ {{#each this.params as |meta|}} +
+
+

{{meta.title}} {{#if meta.isRequired}}*{{/if}}

+ + {{#if meta.isRequired}}Required{{else}}Optional{{/if}} Meta Param +
+
+ {{/each}} + + {{#if this.hasPayload}} +
+

Payload {{#if this.isPayloadRequired}}*{{/if}}

+ +
+ {{else}} +
Payload is disabled
+ {{/if}} + +
+ + +
+
+
\ No newline at end of file diff --git a/ui/app/templates/components/job-page/parameterized-child.hbs b/ui/app/templates/components/job-page/parameterized-child.hbs index 42a97f71dd28..b0c3038b38f6 100644 --- a/ui/app/templates/components/job-page/parameterized-child.hbs +++ b/ui/app/templates/components/job-page/parameterized-child.hbs @@ -31,6 +31,8 @@ + +
Payload
diff --git a/ui/app/templates/components/job-page/parts/children.hbs b/ui/app/templates/components/job-page/parts/children.hbs index 6d97868c2689..b8519a3a1411 100644 --- a/ui/app/templates/components/job-page/parts/children.hbs +++ b/ui/app/templates/components/job-page/parts/children.hbs @@ -1,5 +1,8 @@
Job Launches + {{#if (can "dispatch job")}} + Dispatch Job + {{/if}}
{{#if this.sortedChildren}} diff --git a/ui/app/templates/components/job-page/parts/meta-values.hbs b/ui/app/templates/components/job-page/parts/meta-values.hbs new file mode 100644 index 000000000000..6d3408bbfb4b --- /dev/null +++ b/ui/app/templates/components/job-page/parts/meta-values.hbs @@ -0,0 +1,20 @@ +
+
Meta
+
+ + + Name + Value + Required + + + + {{row.model.name}} + {{row.model.value}} + {{row.model.required}} + + + +
+
\ No newline at end of file diff --git a/ui/app/templates/jobs/job/dispatch.hbs b/ui/app/templates/jobs/job/dispatch.hbs new file mode 100644 index 000000000000..9fd672b14347 --- /dev/null +++ b/ui/app/templates/jobs/job/dispatch.hbs @@ -0,0 +1,7 @@ +{{page-title "Dispatch new " this.job.name}} + +
+ +
diff --git a/ui/package.json b/ui/package.json index 45db5e4f90cd..3fac4031abef 100644 --- a/ui/package.json +++ b/ui/package.json @@ -153,7 +153,9 @@ ] }, "dependencies": { - "lru_map": "^0.3.3" + "lru_map": "^0.3.3", + "no-case": "^3.0.4", + "title-case": "^3.0.3" }, "resolutions": { "ivy-codemirror/codemirror": "^5.56.0" diff --git a/ui/yarn.lock b/ui/yarn.lock index 2d567643e339..d8fbfad2444e 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -15812,6 +15812,13 @@ tinycolor2@^1.4.1: resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== +title-case@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/title-case/-/title-case-3.0.3.tgz#bc689b46f02e411f1d1e1d081f7c3deca0489982" + integrity sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA== + dependencies: + tslib "^2.0.3" + tmp@0.0.28: version "0.0.28" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.28.tgz#172735b7f614ea7af39664fa84cf0de4e515d120" From 01f9fed82dd28f7747d05d95850ea320a68c0b16 Mon Sep 17 00:00:00 2001 From: Nicholas Cioli Date: Tue, 1 Jun 2021 03:18:21 -0400 Subject: [PATCH 02/23] fix failing lint test --- ui/app/components/job-dispatch.js | 1 - ui/app/components/job-page/parts/meta-values.js | 3 --- 2 files changed, 4 deletions(-) diff --git a/ui/app/components/job-dispatch.js b/ui/app/components/job-dispatch.js index 399cfb7b0d52..c2810d3b2e39 100644 --- a/ui/app/components/job-dispatch.js +++ b/ui/app/components/job-dispatch.js @@ -8,7 +8,6 @@ import { noCase } from 'no-case'; import { titleCase } from 'title-case'; import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; -import localStorageProperty from 'nomad-ui/utils/properties/local-storage'; @classic export default class JobDispatch extends Component { diff --git a/ui/app/components/job-page/parts/meta-values.js b/ui/app/components/job-page/parts/meta-values.js index aa4ddfde1b5e..547e8cb973e0 100644 --- a/ui/app/components/job-page/parts/meta-values.js +++ b/ui/app/components/job-page/parts/meta-values.js @@ -1,10 +1,7 @@ import Component from '@ember/component'; import { computed } from '@ember/object'; -import { alias } from '@ember/object/computed'; import classic from 'ember-classic-decorator'; -import Sortable from 'nomad-ui/mixins/sortable'; - @classic export default class MetaValues extends Component { job = null; From e39b2a4d47abf1e5d0301d7fe537bf0b241b7170 Mon Sep 17 00:00:00 2001 From: Nicholas Cioli Date: Tue, 8 Jun 2021 00:07:32 -0400 Subject: [PATCH 03/23] clean up dispatch and remove meta This commit cleans up a few things that had typos and inconsistent naming. In line with this, the custom `meta` view was removed in favor of using the included `AttributesTable`. --- ui/app/components/job-dispatch.js | 26 ++++++----- .../components/job-page/parts/meta-values.js | 41 ----------------- ui/app/routes/jobs/job.js | 7 +++ ui/app/routes/jobs/job/dispatch.js | 2 +- ui/app/templates/components/job-dispatch.hbs | 46 +++++++++++-------- .../job-page/parameterized-child.hbs | 21 ++++++++- .../components/job-page/parts/meta-values.hbs | 20 -------- ui/app/templates/jobs/job/dispatch.hbs | 6 +-- 8 files changed, 74 insertions(+), 95 deletions(-) delete mode 100644 ui/app/components/job-page/parts/meta-values.js delete mode 100644 ui/app/templates/components/job-page/parts/meta-values.hbs diff --git a/ui/app/components/job-dispatch.js b/ui/app/components/job-dispatch.js index c2810d3b2e39..0d31ea1732b9 100644 --- a/ui/app/components/job-dispatch.js +++ b/ui/app/components/job-dispatch.js @@ -16,19 +16,22 @@ export default class JobDispatch extends Component { @service config; @service router; - job = null; + model = null; dispatchError = null; paramValues = {}; payload = null; - @computed('job.definition.Meta', 'job.definition.ParameterizedJob.{MetaOptional,MetaRequired}') + @computed( + 'model.definition.Meta', + 'model.definition.ParameterizedJob.{MetaOptional,MetaRequired}' + ) get params() { // Helper for mapping the params into a useable form let mapper = (values, isRequired) => values.map(x => { let emptyPlaceholder = ''; let placeholder = - this.job.definition.Meta != null ? this.job.definition.Meta[x] : emptyPlaceholder; + this.model.definition.Meta != null ? this.model.definition.Meta[x] : emptyPlaceholder; return { isRequired: isRequired, @@ -41,21 +44,21 @@ export default class JobDispatch extends Component { }); // Fetch the different types of parameters - let required = mapper(this.job.definition.ParameterizedJob.MetaRequired || [], true); - let optional = mapper(this.job.definition.ParameterizedJob.MetaOptional || [], false); + let required = mapper(this.model.definition.ParameterizedJob.MetaRequired || [], true); + let optional = mapper(this.model.definition.ParameterizedJob.MetaOptional || [], false); // Return them, required before optional return required.concat(optional); } - @computed('job.definition.ParameterizedJob.Payload') + @computed('model.definition.ParameterizedJob.Payload') get hasPayload() { - return this.job.definition.ParameterizedJob.Payload != 'forbidden'; + return this.model.definition.ParameterizedJob.Payload != 'forbidden'; } - @computed('job.definition.ParameterizedJob.Payload') + @computed('model.definition.ParameterizedJob.Payload') get isPayloadRequired() { - return this.job.definition.ParameterizedJob.Payload == 'required'; + return this.model.definition.ParameterizedJob.Payload == 'required'; } @action @@ -66,7 +69,7 @@ export default class JobDispatch extends Component { @task(function*() { // Make sure that we have all of the fields that we need let isValid = true; - let required = this.job.definition.ParameterizedJob.MetaRequired || []; + let required = this.model.definition.ParameterizedJob.MetaRequired || []; required.forEach(required => { let input = document.getElementById(required); isValid &= input.checkValidity(); @@ -77,13 +80,14 @@ export default class JobDispatch extends Component { // Try to create the dispatch try { - const dispatch = yield this.job.rawJob.dispatch(this.paramValues, this.payload); + const dispatch = yield this.model.job.dispatch(this.paramValues, this.payload); // Navigate to the newly created instance this.router.transitionTo('jobs.job', dispatch.toJSON().dispatchedJobID); } catch (err) { const error = messageFromAdapterError(err) || 'Could not dispatch job'; this.set('dispatchError', error); + this.scrollToError(); } }) submit; diff --git a/ui/app/components/job-page/parts/meta-values.js b/ui/app/components/job-page/parts/meta-values.js deleted file mode 100644 index 547e8cb973e0..000000000000 --- a/ui/app/components/job-page/parts/meta-values.js +++ /dev/null @@ -1,41 +0,0 @@ -import Component from '@ember/component'; -import { computed } from '@ember/object'; -import classic from 'ember-classic-decorator'; - -@classic -export default class MetaValues extends Component { - job = null; - definition = null; - - init() { - super.init(...arguments); - - // Note: The JOB route does not fetch the raw definition, so we do it here - this.job.fetchRawDefinition().then(def => this.set('definition', def)); - } - - @computed('definition.Meta', 'job.parameterizedDetails') - get paramMap() { - let params = this.job.parameterizedDetails || {}; - let merged = []; - - let required = params.MetaRequired || []; - let optional = params.MetaOptional || []; - - // Helper for getting the value for a meta, if provided. - let getValue = name => (this.definition ? this.definition.Meta[name] : ''); - - // Merge all of the different info - let mergeFun = required => name => - merged.push({ - name: name, - value: getValue(name), - required: required, - }); - - required.forEach(mergeFun(true)); - optional.forEach(mergeFun(false)); - - return merged; - } -} diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js index 32709b0be70b..b2fcf55e0c42 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -32,6 +32,13 @@ export default class JobRoute extends Route { this.store.findAll('namespace'), ]; + // Only add the definition as needed (for parameterized jobs) + if (job.parameterized) { + relatedModelsQueries.push( + job.fetchRawDefinition().then(definition => (job.definition = definition)) + ); + } + if (this.can.can('accept recommendation')) { relatedModelsQueries.push(job.get('recommendationSummaries')); } diff --git a/ui/app/routes/jobs/job/dispatch.js b/ui/app/routes/jobs/job/dispatch.js index 30a7b83571e7..04245ec9c1f2 100644 --- a/ui/app/routes/jobs/job/dispatch.js +++ b/ui/app/routes/jobs/job/dispatch.js @@ -22,7 +22,7 @@ export default class DispatchRoute extends Route { if (!job) return this.transitionTo('jobs.job'); return job.fetchRawDefinition().then(definition => ({ - rawJob: job, + job, definition, })); } diff --git a/ui/app/templates/components/job-dispatch.hbs b/ui/app/templates/components/job-dispatch.hbs index d2ac61dc84ef..52b37531408f 100644 --- a/ui/app/templates/components/job-dispatch.hbs +++ b/ui/app/templates/components/job-dispatch.hbs @@ -6,6 +6,7 @@ {{/if}}
+

Dispatch an instance of '{{ this.model.job.name }}'

{{#each this.params as |meta|}}
@@ -18,29 +19,38 @@ oninput={{action "updateParamValue" meta.name}} placeholder={{meta.placeholder}} required={{meta.isRequired}} > - {{#if meta.isRequired}}Required{{else}}Optional{{/if}} Meta Param + {{#if meta.isRequired}}Required{{else}}Optional{{/if}} Meta Param ({{ meta.name }})
{{/each}} - {{#if this.hasPayload}} -
-

Payload {{#if this.isPayloadRequired}}*{{/if}}

- +
+
+ Payload {{#if this.isPayloadRequired}}*{{/if}}
- {{else}} -
Payload is disabled
- {{/if}} + {{#if this.hasPayload}} +
+ +
+ {{else}} +
+
+

Payload Disabled

+

Payload is disabled for this job.

+
+
+ {{/if}} +
diff --git a/ui/app/templates/components/job-page/parameterized-child.hbs b/ui/app/templates/components/job-page/parameterized-child.hbs index b0c3038b38f6..3531701ee665 100644 --- a/ui/app/templates/components/job-page/parameterized-child.hbs +++ b/ui/app/templates/components/job-page/parameterized-child.hbs @@ -31,7 +31,26 @@ - +
+
+ Meta +
+ {{#if this.job.definition.Meta}} +
+ +
+ {{else}} +
+
+

No Meta Attributes

+

This job is configured with no meta attributes.

+
+
+ {{/if}} +
Payload
diff --git a/ui/app/templates/components/job-page/parts/meta-values.hbs b/ui/app/templates/components/job-page/parts/meta-values.hbs deleted file mode 100644 index 6d3408bbfb4b..000000000000 --- a/ui/app/templates/components/job-page/parts/meta-values.hbs +++ /dev/null @@ -1,20 +0,0 @@ -
-
Meta
-
- - - Name - Value - Required - - - - {{row.model.name}} - {{row.model.value}} - {{row.model.required}} - - - -
-
\ No newline at end of file diff --git a/ui/app/templates/jobs/job/dispatch.hbs b/ui/app/templates/jobs/job/dispatch.hbs index 9fd672b14347..244b718aa1ab 100644 --- a/ui/app/templates/jobs/job/dispatch.hbs +++ b/ui/app/templates/jobs/job/dispatch.hbs @@ -1,7 +1,7 @@ -{{page-title "Dispatch new " this.job.name}} - +{{page-title "Dispatch new " this.model.job.name}} +
From 0c8d18dce7f1dcf8f6c5792c2e9d68794f1c6862 Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Mon, 12 Jul 2021 20:31:15 -0400 Subject: [PATCH 04/23] ui: encode dispatch job payload and start adding tests --- ui/app/abilities/job.js | 2 +- ui/app/adapters/job.js | 3 +- ui/app/components/job-dispatch.js | 2 +- ui/app/templates/components/job-dispatch.hbs | 4 +- .../components/job-page/parts/children.hbs | 10 ++- .../classes/exec-socket-xterm-adapter.js | 17 +--- ui/app/utils/encode.js | 14 ++++ ui/mirage/config.js | 41 ++++++---- ui/mirage/factories/job.js | 4 +- ui/tests/acceptance/job-dispatch-test.js | 80 +++++++++++++++++++ ui/tests/pages/jobs/detail.js | 5 ++ ui/tests/pages/jobs/dispatch.js | 10 +++ ui/tests/unit/adapters/job-test.js | 35 ++++++++ 13 files changed, 188 insertions(+), 39 deletions(-) create mode 100644 ui/app/utils/encode.js create mode 100644 ui/tests/acceptance/job-dispatch-test.js create mode 100644 ui/tests/pages/jobs/dispatch.js diff --git a/ui/app/abilities/job.js b/ui/app/abilities/job.js index 01103bbbf00e..02e175cb42b9 100644 --- a/ui/app/abilities/job.js +++ b/ui/app/abilities/job.js @@ -20,7 +20,7 @@ export default class Job extends AbstractAbility { @or('bypassAuthorization', 'selfTokenIsManagement') canListAll; - @or('bypassAuthorization', 'policiesSupportDispatching') + @or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportDispatching') canDispatch; @computed('rulesForNamespace.@each.capabilities') diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index 7893505df401..f7bc08a1d2ee 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -1,5 +1,6 @@ import WatchableNamespaceIDs from './watchable-namespace-ids'; import addToPath from 'nomad-ui/utils/add-to-path'; +import { base64EncodeString } from 'nomad-ui/utils/encode'; export default class JobAdapter extends WatchableNamespaceIDs { relationshipFallbackLinks = { @@ -92,7 +93,7 @@ export default class JobAdapter extends WatchableNamespaceIDs { return this.ajax(url, 'POST', { data: { - Payload: payload, + Payload: base64EncodeString(payload), Meta: meta, }, }).then(json => { diff --git a/ui/app/components/job-dispatch.js b/ui/app/components/job-dispatch.js index 0d31ea1732b9..d138f48877b5 100644 --- a/ui/app/components/job-dispatch.js +++ b/ui/app/components/job-dispatch.js @@ -63,7 +63,7 @@ export default class JobDispatch extends Component { @action updateParamValue(name, input) { - this.paramValues[name] = input.originalTarget.value; + this.paramValues[name] = input.target.value; } @task(function*() { diff --git a/ui/app/templates/components/job-dispatch.hbs b/ui/app/templates/components/job-dispatch.hbs index 52b37531408f..8d47d6b3c6ba 100644 --- a/ui/app/templates/components/job-dispatch.hbs +++ b/ui/app/templates/components/job-dispatch.hbs @@ -53,8 +53,8 @@
- - + +
\ No newline at end of file diff --git a/ui/app/templates/components/job-page/parts/children.hbs b/ui/app/templates/components/job-page/parts/children.hbs index b8519a3a1411..dd9dc54b82db 100644 --- a/ui/app/templates/components/job-page/parts/children.hbs +++ b/ui/app/templates/components/job-page/parts/children.hbs @@ -1,7 +1,15 @@
Job Launches {{#if (can "dispatch job")}} - Dispatch Job + Dispatch Job + {{else}} + {{/if}}
diff --git a/ui/app/utils/classes/exec-socket-xterm-adapter.js b/ui/app/utils/classes/exec-socket-xterm-adapter.js index 2f9669b1fc09..c97e9cc0ed67 100644 --- a/ui/app/utils/classes/exec-socket-xterm-adapter.js +++ b/ui/app/utils/classes/exec-socket-xterm-adapter.js @@ -1,7 +1,6 @@ const ANSI_UI_GRAY_400 = '\x1b[38;2;142;150;163m'; -import base64js from 'base64-js'; -import { TextDecoderLite, TextEncoderLite } from 'text-encoder-lite'; +import { base64DecodeString, base64EncodeString } from 'nomad-ui/utils/encode'; export const HEARTBEAT_INTERVAL = 10000; // ten seconds @@ -26,7 +25,7 @@ export default class ExecSocketXtermAdapter { // stderr messages will not be produced as the socket is opened with the tty flag if (json.stdout && json.stdout.data) { - terminal.write(decodeString(json.stdout.data)); + terminal.write(base64DecodeString(json.stdout.data)); } }; @@ -64,16 +63,6 @@ export default class ExecSocketXtermAdapter { } handleData(data) { - this.socket.send(JSON.stringify({ stdin: { data: encodeString(data) } })); + this.socket.send(JSON.stringify({ stdin: { data: base64EncodeString(data) } })); } } - -function encodeString(string) { - let encoded = new TextEncoderLite('utf-8').encode(string); - return base64js.fromByteArray(encoded); -} - -function decodeString(b64String) { - let uint8array = base64js.toByteArray(b64String); - return new TextDecoderLite('utf-8').decode(uint8array); -} diff --git a/ui/app/utils/encode.js b/ui/app/utils/encode.js new file mode 100644 index 000000000000..713968477534 --- /dev/null +++ b/ui/app/utils/encode.js @@ -0,0 +1,14 @@ +import base64js from 'base64-js'; +import { TextDecoderLite, TextEncoderLite } from 'text-encoder-lite'; + +export { base64EncodeString, base64DecodeString }; + +function base64EncodeString(string) { + let encoded = new TextEncoderLite('utf-8').encode(string); + return base64js.fromByteArray(encoded); +} + +function base64DecodeString(b64String) { + let uint8array = base64js.toByteArray(b64String); + return new TextDecoderLite('utf-8').decode(uint8array); +} diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 267d5ac50549..1d3e90d1a30c 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -182,6 +182,21 @@ export default function() { return okEmpty(); }); + this.post('/job/:id/dispatch', function(schema, { params }) { + // Create the child job + const parent = schema.jobs.find(params.id); + + // Use the server instead of the schema to leverage the job factory + server.create('job', 'parameterizedChild', { + parentId: parent.id, + namespaceId: parent.namespaceId, + namespace: parent.namespace, + createAllocations: parent.createAllocations, + }); + + return okEmpty(); + }); + this.post('/job/:id/revert', function({ jobs }, { requestBody }) { const { JobID, JobVersion } = JSON.parse(requestBody); const job = jobs.find(JobID); @@ -579,7 +594,10 @@ export default function() { }); }); - this.post('/search/fuzzy', function( { allocations, jobs, nodes, taskGroups, csiPlugins }, { requestBody }) { + this.post('/search/fuzzy', function( + { allocations, jobs, nodes, taskGroups, csiPlugins }, + { requestBody } + ) { const { Text } = JSON.parse(requestBody); const matchedAllocs = allocations.where(allocation => allocation.name.includes(Text)); @@ -590,33 +608,22 @@ export default function() { const transformedAllocs = matchedAllocs.models.map(alloc => ({ ID: alloc.name, - Scope: [ - (alloc.namespace || {}).id, - alloc.id, - ], + Scope: [(alloc.namespace || {}).id, alloc.id], })); const transformedGroups = matchedGroups.models.map(group => ({ ID: group.name, - Scope: [ - group.job.namespace, - group.job.id, - ], + Scope: [group.job.namespace, group.job.id], })); const transformedJobs = matchedJobs.models.map(job => ({ ID: job.name, - Scope: [ - job.namespace, - job.id, - ] + Scope: [job.namespace, job.id], })); const transformedNodes = matchedNodes.models.map(node => ({ ID: node.name, - Scope: [ - node.id, - ], + Scope: [node.id], })); const transformedPlugins = matchedPlugins.models.map(plugin => ({ @@ -644,7 +651,7 @@ export default function() { nodes: truncatedNodes.length < transformedNodes.length, plugins: truncatedPlugins.length < transformedPlugins.length, }, - } + }; }); this.get('/recommendations', function( diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js index e7940461b0b9..05691c92a53a 100644 --- a/ui/mirage/factories/job.js +++ b/ui/mirage/factories/job.js @@ -63,9 +63,9 @@ export default Factory.extend({ parameterized: trait({ type: 'batch', parameterized: true, - // parameterized details object + // parameterized job object // serializer update for bool vs details object - parameterizedDetails: () => ({ + parameterizedJob: () => ({ MetaOptional: null, MetaRequired: null, Payload: faker.random.boolean() ? 'required' : null, diff --git a/ui/tests/acceptance/job-dispatch-test.js b/ui/tests/acceptance/job-dispatch-test.js new file mode 100644 index 000000000000..96bcdaa2e774 --- /dev/null +++ b/ui/tests/acceptance/job-dispatch-test.js @@ -0,0 +1,80 @@ +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import JobDispatch from 'nomad-ui/tests/pages/jobs/dispatch'; +import JobDetail from 'nomad-ui/tests/pages/jobs/detail'; +import { pauseTest } from '@ember/test-helpers'; + +import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; +import { setup } from 'qunit-dom'; + +let job, managementToken, clientToken; + +module('Acceptance | job dispatch (with namespace)', function(hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function() { + // Required for placing allocations (a result of dispatching jobs) + server.create('node'); + server.createList('namespace', 2); + + job = server.create('job', 'parameterized', { + status: 'running', + namespaceId: server.db.namespaces[0].name, + }); + + managementToken = server.create('token'); + clientToken = server.create('token'); + + window.localStorage.nomadTokenSecret = managementToken.secretId; + }); + + test('it passes an accessibility audit', async function(assert) { + const namespace = server.db.namespaces.find(job.namespaceId); + await JobDispatch.visit({ id: job.id, namespace: namespace.name }); + await a11yAudit(assert); + }); + + test('the dispatch button is displayed with management token', async function(assert) { + const namespace = server.db.namespaces.find(job.namespaceId); + await JobDetail.visit({ id: job.id, namespace: namespace.name }); + assert.notOk(JobDetail.dispatchButton.isDisabled); + }); + + test('the dispatch button is displayed when allowed', async function(assert) { + window.localStorage.nomadTokenSecret = clientToken.secretId; + + const namespace = server.db.namespaces.find(job.namespaceId); + const policy = server.create('policy', { + id: 'dispatch', + name: 'dispatch', + rulesJSON: { + Namespaces: [ + { + Name: namespace.name, + Capabilities: ['list-jobs', 'dispatch-job'], + }, + ], + }, + }); + + clientToken.policyIds = [policy.id]; + clientToken.save(); + + await JobDetail.visit({ id: job.id, namespace: namespace.name }); + assert.notOk(JobDetail.dispatchButton.isDisabled); + + // Reset clientToken policies. + clientToken.policyIds = []; + clientToken.save(); + }); + + test('the dispatch button is not displayed when not allowed', async function(assert) { + window.localStorage.nomadTokenSecret = clientToken.secretId; + + const namespace = server.db.namespaces.find(job.namespaceId); + await JobDetail.visit({ id: job.id, namespace: namespace.name }); + assert.ok(JobDetail.dispatchButton.isDisabled); + }); +}); diff --git a/ui/tests/pages/jobs/detail.js b/ui/tests/pages/jobs/detail.js index fa17971a3d47..83513e8f0752 100644 --- a/ui/tests/pages/jobs/detail.js +++ b/ui/tests/pages/jobs/detail.js @@ -38,6 +38,11 @@ export default create({ tooltipText: attribute('aria-label'), }, + dispatchButton: { + scope: '[data-test-dispatch-button]', + isDisabled: property('disabled'), + }, + stats: collection('[data-test-job-stat]', { id: attribute('data-test-job-stat'), text: text(), diff --git a/ui/tests/pages/jobs/dispatch.js b/ui/tests/pages/jobs/dispatch.js new file mode 100644 index 000000000000..b5d01b12bc52 --- /dev/null +++ b/ui/tests/pages/jobs/dispatch.js @@ -0,0 +1,10 @@ +import { create, property, visitable } from 'ember-cli-page-object'; + +export default create({ + visit: visitable('/jobs/:id/dispatch'), + + dispatchButton: { + scope: '[data-test-dispatch-button]', + isDisabled: property('disabled'), + }, +}); diff --git a/ui/tests/unit/adapters/job-test.js b/ui/tests/unit/adapters/job-test.js index 31956fdb3628..e6d283bb9c5b 100644 --- a/ui/tests/unit/adapters/job-test.js +++ b/ui/tests/unit/adapters/job-test.js @@ -5,6 +5,8 @@ import { setupTest } from 'ember-qunit'; import { module, test } from 'qunit'; import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; import { AbortController } from 'fetch'; +import { TextEncoderLite } from 'text-encoder-lite'; +import base64js from 'base64-js'; module('Unit | Adapter | Job', function(hooks) { setupTest(hooks); @@ -387,6 +389,27 @@ module('Unit | Adapter | Job', function(hooks) { assert.notOk(xhr2.aborted, 'Request two was not aborted'); }); + test('dispatch job encodes payload as base64', async function(assert) { + const job = await this.initializeWithJob(); + job.set('parameterized', true); + + const payload = "I'm a payload 🙂"; + + // Base64 encode payload. + const Encoder = new TextEncoderLite('utf-8'); + const encodedPayload = base64js.fromByteArray(Encoder.encode(payload)); + + await this.subject().dispatch(job, {}, payload); + + const request = this.server.pretender.handledRequests[0]; + assert.equal(request.url, `/v1/job/${job.plainId}/dispatch`); + assert.equal(request.method, 'POST'); + assert.deepEqual(JSON.parse(request.requestBody), { + Payload: encodedPayload, + Meta: {}, + }); + }); + test('when there is no region set, requests are made without the region query param', async function(assert) { await this.initializeUI(); @@ -544,6 +567,18 @@ module('Unit | Adapter | Job', function(hooks) { assert.equal(request.url, `/v1/job/${job.plainId}/scale?region=${region}`); assert.equal(request.method, 'POST'); }); + + test('dispatch requests include the activeRegion', async function(assert) { + const region = 'region-2'; + const job = await this.initializeWithJob({ region }); + job.set('parameterized', true); + + await this.subject().dispatch(job, {}, ''); + + const request = this.server.pretender.handledRequests[0]; + assert.equal(request.url, `/v1/job/${job.plainId}/dispatch?region=${region}`); + assert.equal(request.method, 'POST'); + }); }); function makeMockModel(id, options) { From c5dd0e81193f18e39659e50bcbee679470480ebf Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Mon, 12 Jul 2021 20:39:35 -0400 Subject: [PATCH 05/23] ui: remove unused test imports --- ui/tests/acceptance/job-dispatch-test.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/ui/tests/acceptance/job-dispatch-test.js b/ui/tests/acceptance/job-dispatch-test.js index 96bcdaa2e774..8fbe4797c5bd 100644 --- a/ui/tests/acceptance/job-dispatch-test.js +++ b/ui/tests/acceptance/job-dispatch-test.js @@ -3,10 +3,7 @@ import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import JobDispatch from 'nomad-ui/tests/pages/jobs/dispatch'; import JobDetail from 'nomad-ui/tests/pages/jobs/detail'; -import { pauseTest } from '@ember/test-helpers'; - import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; -import { setup } from 'qunit-dom'; let job, managementToken, clientToken; From c8022a4c6b8afbf5ff9a6837a08143eaa88038d9 Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Tue, 13 Jul 2021 18:05:47 -0400 Subject: [PATCH 06/23] ui: redesign job dispatch form --- ui/app/templates/components/job-dispatch.hbs | 97 +++++++++++--------- 1 file changed, 54 insertions(+), 43 deletions(-) diff --git a/ui/app/templates/components/job-dispatch.hbs b/ui/app/templates/components/job-dispatch.hbs index 8d47d6b3c6ba..29592b65f30a 100644 --- a/ui/app/templates/components/job-dispatch.hbs +++ b/ui/app/templates/components/job-dispatch.hbs @@ -7,54 +7,65 @@

Dispatch an instance of '{{ this.model.job.name }}'

-
- {{#each this.params as |meta|}} -
-
-

{{meta.title}} {{#if meta.isRequired}}*{{/if}}

- - {{#if meta.isRequired}}Required{{else}}Optional{{/if}} Meta Param ({{ meta.name }}) -
-
- {{/each}} -
-
- Payload {{#if this.isPayloadRequired}}*{{/if}} -
- {{#if this.hasPayload}} -
- -
- {{else}} -
-
-

Payload Disabled

-

Payload is disabled for this job.

+ {{#each this.params as |meta|}} +
+
+
+ +
+ +

+ {{#if meta.isRequired}}Required{{else}}Optional{{/if}} Meta Param + + {{ meta.name }} + +

+
- {{/if}}
+ {{/each}} -
- - +
+
+ Payload {{#if this.isPayloadRequired}}*{{/if}}
+ {{#if this.hasPayload}} +
+ +
+ {{else}} +
+
+

Payload Disabled

+

Payload is disabled for this job.

+
+
+ {{/if}} +
+ +
+ +
\ No newline at end of file From bfcd72bfb19a0320fa0f30af14e0c983e9c9d8b0 Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Tue, 13 Jul 2021 20:38:58 -0400 Subject: [PATCH 07/23] ui: initial acceptance tests for dispatch job --- ui/app/templates/components/job-dispatch.hbs | 11 ++-- ui/mirage/factories/job.js | 20 ++++++- ui/tests/acceptance/job-dispatch-test.js | 57 +++++++++++++++++--- ui/tests/pages/jobs/dispatch.js | 35 +++++++++++- 4 files changed, 109 insertions(+), 14 deletions(-) diff --git a/ui/app/templates/components/job-dispatch.hbs b/ui/app/templates/components/job-dispatch.hbs index 29592b65f30a..27351077884b 100644 --- a/ui/app/templates/components/job-dispatch.hbs +++ b/ui/app/templates/components/job-dispatch.hbs @@ -9,14 +9,15 @@

Dispatch an instance of '{{ this.model.job.name }}'

{{#each this.params as |meta|}} -
+
-
-