Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ui: add parameterized dispatch interface #10675

Merged
merged 23 commits into from
Jul 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bc7ae22
ui: add parameterized dispatch interface
nicholascioli Jun 1, 2021
01f9fed
fix failing lint test
nicholascioli Jun 1, 2021
e39b2a4
clean up dispatch and remove meta
nicholascioli Jun 8, 2021
0c8d18d
ui: encode dispatch job payload and start adding tests
lgfa29 Jul 13, 2021
c5dd0e8
ui: remove unused test imports
lgfa29 Jul 13, 2021
c8022a4
ui: redesign job dispatch form
lgfa29 Jul 13, 2021
bfcd72b
ui: initial acceptance tests for dispatch job
lgfa29 Jul 14, 2021
cc8ccce
ui: generate parameterized job children with correct id format
lgfa29 Jul 14, 2021
641316c
ui: fix job dispatch breadcrumb link
lgfa29 Jul 14, 2021
948ab34
ui: refactor job dispatch component into glimmer component and add fo…
lgfa29 Jul 14, 2021
b1a8639
ui: remove unused CSS class
lgfa29 Jul 14, 2021
fc9f9cb
ui: align job dispatch button
lgfa29 Jul 15, 2021
56ae0ff
ui: handle namespace-specific requests on job dispatch
lgfa29 Jul 15, 2021
c548237
ui: rename payloadMissing to payloadHasError
lgfa29 Jul 15, 2021
acd8fcf
ui: don't re-fetch job spec on dispatch job
lgfa29 Jul 15, 2021
b743acc
ui: keep overview tab selected on job dispatch page
lgfa29 Jul 15, 2021
006a1ff
ui: fix task and task-group linting
lgfa29 Jul 15, 2021
f474c3b
ui: URL encode job id on dispatch job tests
lgfa29 Jul 16, 2021
98f2324
ui: fix error when job meta is null
lgfa29 Jul 19, 2021
fc91dce
ui: handle job dispatch from adapter
lgfa29 Jul 19, 2021
2bf00be
ui: add more tests for dispatch job page
lgfa29 Jul 19, 2021
8959f8f
ui: add "job dispatch" capability check
lgfa29 Jul 20, 2021
3782f03
ui: update job dispatch from code review
lgfa29 Jul 20, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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() {
lgfa29 marked this conversation as resolved.
Show resolved Hide resolved
return this.namespaceIncludesCapability('dispatch-job');
lgfa29 marked this conversation as resolved.
Show resolved Hide resolved
}
}
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;
}
lgfa29 marked this conversation as resolved.
Show resolved Hide resolved
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