Skip to content

Commit

Permalink
ui: add parameterized dispatch interface (#10675)
Browse files Browse the repository at this point in the history
* 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.

* fix failing lint test

* 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: encode dispatch job payload and start adding tests

* ui: remove unused test imports

* ui: redesign job dispatch form

* ui: initial acceptance tests for dispatch job

* ui: generate parameterized job children with correct id format

* ui: fix job dispatch breadcrumb link

* ui: refactor job dispatch component into glimmer component and add form validation

* ui: remove unused CSS class

* ui: align job dispatch button

* ui: handle namespace-specific requests on job dispatch

* ui: rename payloadMissing to payloadHasError

* ui: don't re-fetch job spec on dispatch job

* ui: keep overview tab selected on job dispatch page

* ui: fix task and task-group linting

* ui: URL encode job id on dispatch job tests

* ui: fix error when job meta is null

* ui: handle job dispatch from adapter

* ui: add more tests for dispatch job page

* ui: add "job dispatch" capability check

* ui: update job dispatch from code review

Co-authored-by: Luiz Aoqui <luiz@hashicorp.com>
  • Loading branch information
nicholascioli and lgfa29 committed Jul 20, 2021
1 parent 3165ae8 commit 801a732
Show file tree
Hide file tree
Showing 29 changed files with 981 additions and 61 deletions.
8 changes: 8 additions & 0 deletions ui/app/abilities/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export default class Job extends AbstractAbility {
@or('bypassAuthorization', 'selfTokenIsManagement')
canListAll;

@or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportDispatching')
canDispatch;

@computed('rulesForNamespace.@each.capabilities')
get policiesSupportRunning() {
return this.namespaceIncludesCapability('submit-job');
Expand All @@ -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');
}
}
11 changes: 11 additions & 0 deletions ui/app/adapters/job.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -84,4 +85,14 @@ export default class JobAdapter extends WatchableNamespaceIDs {
},
});
}

dispatch(job, meta, payload) {
const url = addToPath(this.urlForFindRecord(job.get('id'), 'job'), '/dispatch');
return this.ajax(url, 'POST', {
data: {
Payload: base64EncodeString(payload),
Meta: meta,
},
});
}
}
137 changes: 137 additions & 0 deletions ui/app/components/job-dispatch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { A } from '@ember/array';
import { task } from 'ember-concurrency';
import { noCase } from 'no-case';
import { titleCase } from 'title-case';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';

class MetaField {
@tracked value;
@tracked error;

name;
required;
title;

constructor(meta) {
this.name = meta.name;
this.required = meta.required;
this.title = meta.title;
this.value = meta.value;
this.error = meta.error;
}

validate() {
this.error = '';

if (this.required && !this.value) {
this.error = `Missing required meta parameter "${this.name}".`;
}
}
}

export default class JobDispatch extends Component {
@service router;
@service config;

@tracked metaFields = [];
@tracked payload = '';
@tracked payloadHasError = false;
errors = A([]);
constructor() {
super(...arguments);
// Helper for mapping the params into a useable form.
const mapper = (values = [], required) =>
values.map(
x =>
new MetaField({
name: x,
required,
title: titleCase(noCase(x)),
value: this.args.job.meta ? this.args.job.meta[x] : '',
})
);
// Fetch the different types of parameters.
const required = mapper(this.args.job.parameterizedDetails.MetaRequired, true);
const optional = mapper(this.args.job.parameterizedDetails.MetaOptional, false);
// Merge them, required before optional.
this.metaFields = required.concat(optional);
}
get hasPayload() {
return this.args.job.parameterizedDetails.Payload !== 'forbidden';
}
get payloadRequired() {
return this.args.job.parameterizedDetails.Payload === 'required';
}
@action
dispatch() {
this.validateForm();
if (this.errors.length > 0) {
this.scrollToError();
return;
}
this.onDispatched.perform();
}
@action
cancel() {
this.router.transitionTo('jobs.job');
}
@task({ drop: true }) *onDispatched() {
// Try to create the dispatch.
try {
let paramValues = {};
this.metaFields.forEach(m => (paramValues[m.name] = m.value));
const dispatch = yield this.args.job.dispatch(paramValues, this.payload);
// Navigate to the newly created instance.
this.router.transitionTo('jobs.job', dispatch.DispatchedJobID);
} catch (err) {
const error = messageFromAdapterError(err) || 'Could not dispatch job';
this.errors.pushObject(error);
this.scrollToError();
}
}
scrollToError() {
if (!this.config.isTest) {
window.scrollTo(0, 0);
}
}
resetErrors() {
this.payloadHasError = false;
this.errors.clear();
}
validateForm() {
this.resetErrors();
// Make sure that we have all of the meta fields that we need.
this.metaFields.forEach(f => {
f.validate();
if (f.error) {
this.errors.pushObject(f.error);
}
});
// Validate payload.
if (this.payloadRequired && !this.payload) {
this.errors.pushObject('Missing required payload.');
this.payloadHasError = true;
}
}
}
10 changes: 10 additions & 0 deletions ui/app/models/job-dispatch.js
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions ui/app/models/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default class Job extends Model {
@attr('number') createIndex;
@attr('number') modifyIndex;
@attr('date') submitTime;
@attr() meta;

// True when the job is the parent periodic or parameterized jobs
// Instances of periodic or parameterized jobs are false for both properties
Expand Down Expand Up @@ -253,6 +254,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;
Expand Down
10 changes: 10 additions & 0 deletions ui/app/models/task-group.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ export default class TaskGroup extends Fragment {

@fragment('group-scaling') scaling;

@attr() meta;

@computed('job.meta', 'meta')
get mergedMeta() {
return {
...this.job.meta,
...this.meta,
};
}

@computed('tasks.@each.driver')
get drivers() {
return this.tasks.mapBy('driver').uniq();
Expand Down
10 changes: 10 additions & 0 deletions ui/app/models/task.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ export default class Task extends Fragment {
@attr('string') driver;
@attr('string') kind;

@attr() meta;

@computed('taskGroup.mergedMeta', 'meta')
get mergedMeta() {
return {
...this.taskGroup.mergedMeta,
...this.meta,
};
}

@fragment('lifecycle') lifecycle;

@computed('lifecycle', 'lifecycle.sidecar')
Expand Down
1 change: 1 addition & 0 deletions ui/app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
27 changes: 27 additions & 0 deletions ui/app/routes/jobs/job/dispatch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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.job.dispatch'],
},
];

beforeModel() {
const job = this.modelFor('jobs.job');
const namespace = job.namespace.get('name');
if (this.can.cannot('dispatch job', null, { namespace })) {
this.transitionTo('jobs.job');
}
}

model() {
const job = this.modelFor('jobs.job');
if (!job) return this.transitionTo('jobs.job');
return job;
}
}
77 changes: 77 additions & 0 deletions ui/app/templates/components/job-dispatch.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{{#if this.errors}}
<div data-test-dispatch-error class="notification is-danger">
<h3 class="title is-4" data-test-parse-error-title>Dispatch Error</h3>
<ul>
{{#each this.errors as |error|}}
<li>{{error}}</li>
{{/each}}
</ul>
</div>
{{/if}}

<form action="#" onsubmit="return false">
<h1 class="title">Dispatch an instance of '{{@job.name}}'</h1>

{{#each this.metaFields as |meta|}}
<div class="columns">
<div class="column is-6">
<div data-test-meta-field="{{ if meta.required "required" "optional" }}" class="field">
<label data-test-meta-field-label class="label {{if meta.error "has-text-danger"}}" for="{{meta.name}}">
{{meta.title}} {{#if meta.required}}*{{/if}}
</label>
<div class="control">
<input
data-test-meta-field-input
id="{{meta.name}}"
class="input {{if meta.error "is-danger"}}"
type="text"
value={{meta.value}}
oninput={{action (mut meta.value) value="target.value"}}
required={{meta.required}} >

<p class="help {{if meta.error "has-text-danger"}}">
{{#if meta.required}}Required{{else}}Optional{{/if}}
Meta Param
<span class="badge is-light is-faded">
<code>{{ meta.name }}</code>
</span>
</p>
</div>
</div>
</div>
</div>
{{/each}}

<div class="boxed-section {{if this.payloadHasError "is-danger"}}">
<div data-test-payload-head class="boxed-section-head">
Payload {{#if this.payloadRequired}}*{{/if}}
</div>
{{#if this.hasPayload}}
<div class="boxed-section-body is-full-bleed">
<IvyCodemirror
data-test-payload-editor
aria-label="Payload definition"
@valueUpdated={{action (mut this.payload)}}
@options={{hash
mode="javascript"
theme="hashi"
screenReaderLabel="Payload definition editor"
tabSize=2
lineNumbers=true
}} />
</div>
{{else}}
<div class="boxed-section-body">
<div data-test-empty-payload-message class="empty-message">
<h3 class="empty-message-headline">Payload Disabled</h3>
<p class="empty-message-body">Payload is disabled for this job.</p>
</div>
</div>
{{/if}}
</div>

<div>
<button data-test-dispatch-button class="button is-primary" type="button" onclick={{action "dispatch"}}>Dispatch</button>
<button data-test-cancel-button class="button is-white" type="button" onclick={{action "cancel"}}>Cancel</button>
</div>
</form>
21 changes: 21 additions & 0 deletions ui/app/templates/components/job-page/parameterized-child.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@

<JobPage::Parts::RecentAllocations @job={{this.job}} />

<div class="boxed-section">
<div class="boxed-section-head">
Meta
</div>
{{#if this.job.definition.Meta}}
<div class="boxed-section-body is-full-bleed">
<AttributesTable
data-test-meta
@attributePairs={{this.job.definition.Meta}}
@class="attributes-table" />
</div>
{{else}}
<div class="boxed-section-body">
<div data-test-empty-meta-message class="empty-message">
<h3 class="empty-message-headline">No Meta Attributes</h3>
<p class="empty-message-body">This job is configured with no meta attributes.</p>
</div>
</div>
{{/if}}
</div>

<div class="boxed-section">
<div class="boxed-section-head">Payload</div>
<div class="boxed-section-body is-dark">
Expand Down
Loading

0 comments on commit 801a732

Please sign in to comment.