diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js
index 80398adac..c783c6ff2 100644
--- a/ui/app/adapters/job.js
+++ b/ui/app/adapters/job.js
@@ -65,4 +65,17 @@ export default ApplicationAdapter.extend({
const url = this.buildURL('job', name, job, 'findRecord');
return this.ajax(url, 'GET', { data: assign(this.buildQuery() || {}, namespaceQuery) });
},
+
+ forcePeriodic(job) {
+ if (job.get('periodic')) {
+ const [name, namespace] = JSON.parse(job.get('id'));
+ let url = `${this.buildURL('job', name, job, 'findRecord')}/periodic/force`;
+
+ if (namespace) {
+ url += `?namespace=${namespace}`;
+ }
+
+ return this.ajax(url, 'POST');
+ }
+ },
});
diff --git a/ui/app/components/allocation-status-bar.js b/ui/app/components/allocation-status-bar.js
index 7f2d1e2fc..5322ac97e 100644
--- a/ui/app/components/allocation-status-bar.js
+++ b/ui/app/components/allocation-status-bar.js
@@ -6,6 +6,8 @@ export default DistributionBar.extend({
allocationContainer: null,
+ 'data-test-allocation-status-bar': true,
+
data: computed(
'allocationContainer.{queuedAllocs,completeAllocs,failedAllocs,runningAllocs,startingAllocs}',
function() {
diff --git a/ui/app/components/children-status-bar.js b/ui/app/components/children-status-bar.js
new file mode 100644
index 000000000..b95b4f240
--- /dev/null
+++ b/ui/app/components/children-status-bar.js
@@ -0,0 +1,27 @@
+import { computed } from '@ember/object';
+import DistributionBar from './distribution-bar';
+
+export default DistributionBar.extend({
+ layoutName: 'components/distribution-bar',
+
+ job: null,
+
+ 'data-test-children-status-bar': true,
+
+ data: computed('job.{pendingChildren,runningChildren,deadChildren}', function() {
+ if (!this.get('job')) {
+ return [];
+ }
+
+ const children = this.get('job').getProperties(
+ 'pendingChildren',
+ 'runningChildren',
+ 'deadChildren'
+ );
+ return [
+ { label: 'Pending', value: children.pendingChildren, className: 'queued' },
+ { label: 'Running', value: children.runningChildren, className: 'running' },
+ { label: 'Dead', value: children.deadChildren, className: 'complete' },
+ ];
+ }),
+});
diff --git a/ui/app/components/job-page/abstract.js b/ui/app/components/job-page/abstract.js
new file mode 100644
index 000000000..eb80479d7
--- /dev/null
+++ b/ui/app/components/job-page/abstract.js
@@ -0,0 +1,29 @@
+import Component from '@ember/component';
+import { computed } from '@ember/object';
+import { inject as service } from '@ember/service';
+
+export default Component.extend({
+ system: service(),
+
+ job: null,
+
+ // Provide a value that is bound to a query param
+ sortProperty: null,
+ sortDescending: null,
+
+ // Provide actions that require routing
+ onNamespaceChange() {},
+ gotoTaskGroup() {},
+ gotoJob() {},
+
+ breadcrumbs: computed('job.{name,id}', function() {
+ const job = this.get('job');
+ return [
+ { label: 'Jobs', args: ['jobs'] },
+ {
+ label: job.get('name'),
+ args: ['jobs.job', job],
+ },
+ ];
+ }),
+});
diff --git a/ui/app/components/job-page/batch.js b/ui/app/components/job-page/batch.js
new file mode 100644
index 000000000..559b3c8b8
--- /dev/null
+++ b/ui/app/components/job-page/batch.js
@@ -0,0 +1,3 @@
+import AbstractJobPage from './abstract';
+
+export default AbstractJobPage.extend();
diff --git a/ui/app/components/job-page/parameterized-child.js b/ui/app/components/job-page/parameterized-child.js
new file mode 100644
index 000000000..841c6fa60
--- /dev/null
+++ b/ui/app/components/job-page/parameterized-child.js
@@ -0,0 +1,16 @@
+import { computed } from '@ember/object';
+import { alias } from '@ember/object/computed';
+import PeriodicChildJobPage from './periodic-child';
+
+export default PeriodicChildJobPage.extend({
+ payload: alias('job.decodedPayload'),
+ payloadJSON: computed('payload', function() {
+ let json;
+ try {
+ json = JSON.parse(this.get('payload'));
+ } catch (e) {
+ // Swallow error and fall back to plain text rendering
+ }
+ return json;
+ }),
+});
diff --git a/ui/app/components/job-page/parameterized.js b/ui/app/components/job-page/parameterized.js
new file mode 100644
index 000000000..559b3c8b8
--- /dev/null
+++ b/ui/app/components/job-page/parameterized.js
@@ -0,0 +1,3 @@
+import AbstractJobPage from './abstract';
+
+export default AbstractJobPage.extend();
diff --git a/ui/app/components/job-page/parts/children.js b/ui/app/components/job-page/parts/children.js
new file mode 100644
index 000000000..30772cd0a
--- /dev/null
+++ b/ui/app/components/job-page/parts/children.js
@@ -0,0 +1,31 @@
+import Component from '@ember/component';
+import { computed } from '@ember/object';
+import { alias } from '@ember/object/computed';
+import Sortable from 'nomad-ui/mixins/sortable';
+
+export default Component.extend(Sortable, {
+ job: null,
+
+ classNames: ['boxed-section'],
+
+ // Provide a value that is bound to a query param
+ sortProperty: null,
+ sortDescending: null,
+ currentPage: null,
+
+ // Provide an action with access to the router
+ gotoJob() {},
+
+ pageSize: 10,
+
+ taskGroups: computed('job.taskGroups.[]', function() {
+ return this.get('job.taskGroups') || [];
+ }),
+
+ children: computed('job.children.[]', function() {
+ return this.get('job.children') || [];
+ }),
+
+ listToSort: alias('children'),
+ sortedChildren: alias('listSorted'),
+});
diff --git a/ui/app/components/job-page/parts/evaluations.js b/ui/app/components/job-page/parts/evaluations.js
new file mode 100644
index 000000000..33f6054a7
--- /dev/null
+++ b/ui/app/components/job-page/parts/evaluations.js
@@ -0,0 +1,12 @@
+import Component from '@ember/component';
+import { computed } from '@ember/object';
+
+export default Component.extend({
+ job: null,
+
+ classNames: ['boxed-section'],
+
+ sortedEvaluations: computed('job.evaluations.@each.modifyIndex', function() {
+ return (this.get('job.evaluations') || []).sortBy('modifyIndex').reverse();
+ }),
+});
diff --git a/ui/app/components/job-page/parts/placement-failures.js b/ui/app/components/job-page/parts/placement-failures.js
new file mode 100644
index 000000000..7df4236d8
--- /dev/null
+++ b/ui/app/components/job-page/parts/placement-failures.js
@@ -0,0 +1,6 @@
+import Component from '@ember/component';
+
+export default Component.extend({
+ job: null,
+ tagName: '',
+});
diff --git a/ui/app/components/job-page/parts/running-deployment.js b/ui/app/components/job-page/parts/running-deployment.js
new file mode 100644
index 000000000..7df4236d8
--- /dev/null
+++ b/ui/app/components/job-page/parts/running-deployment.js
@@ -0,0 +1,6 @@
+import Component from '@ember/component';
+
+export default Component.extend({
+ job: null,
+ tagName: '',
+});
diff --git a/ui/app/components/job-page/parts/summary.js b/ui/app/components/job-page/parts/summary.js
new file mode 100644
index 000000000..0ff44fc5a
--- /dev/null
+++ b/ui/app/components/job-page/parts/summary.js
@@ -0,0 +1,7 @@
+import Component from '@ember/component';
+
+export default Component.extend({
+ job: null,
+
+ classNames: ['boxed-section'],
+});
diff --git a/ui/app/components/job-page/parts/task-groups.js b/ui/app/components/job-page/parts/task-groups.js
new file mode 100644
index 000000000..f5ce33757
--- /dev/null
+++ b/ui/app/components/job-page/parts/task-groups.js
@@ -0,0 +1,24 @@
+import Component from '@ember/component';
+import { computed } from '@ember/object';
+import { alias } from '@ember/object/computed';
+import Sortable from 'nomad-ui/mixins/sortable';
+
+export default Component.extend(Sortable, {
+ job: null,
+
+ classNames: ['boxed-section'],
+
+ // Provide a value that is bound to a query param
+ sortProperty: null,
+ sortDescending: null,
+
+ // Provide an action with access to the router
+ gotoTaskGroup() {},
+
+ taskGroups: computed('job.taskGroups.[]', function() {
+ return this.get('job.taskGroups') || [];
+ }),
+
+ listToSort: alias('taskGroups'),
+ sortedTaskGroups: alias('listSorted'),
+});
diff --git a/ui/app/components/job-page/periodic-child.js b/ui/app/components/job-page/periodic-child.js
new file mode 100644
index 000000000..060627d93
--- /dev/null
+++ b/ui/app/components/job-page/periodic-child.js
@@ -0,0 +1,21 @@
+import AbstractJobPage from './abstract';
+import { computed } from '@ember/object';
+
+export default AbstractJobPage.extend({
+ breadcrumbs: computed('job.{name,id}', 'job.parent.{name,id}', function() {
+ const job = this.get('job');
+ const parent = this.get('job.parent');
+
+ return [
+ { label: 'Jobs', args: ['jobs'] },
+ {
+ label: parent.get('name'),
+ args: ['jobs.job', parent],
+ },
+ {
+ label: job.get('trimmedName'),
+ args: ['jobs.job', job],
+ },
+ ];
+ }),
+});
diff --git a/ui/app/components/job-page/periodic.js b/ui/app/components/job-page/periodic.js
new file mode 100644
index 000000000..705d95a2f
--- /dev/null
+++ b/ui/app/components/job-page/periodic.js
@@ -0,0 +1,15 @@
+import AbstractJobPage from './abstract';
+import { inject as service } from '@ember/service';
+
+export default AbstractJobPage.extend({
+ store: service(),
+ actions: {
+ forceLaunch() {
+ this.get('job')
+ .forcePeriodic()
+ .then(() => {
+ this.get('store').findAll('job');
+ });
+ },
+ },
+});
diff --git a/ui/app/components/job-page/service.js b/ui/app/components/job-page/service.js
new file mode 100644
index 000000000..559b3c8b8
--- /dev/null
+++ b/ui/app/components/job-page/service.js
@@ -0,0 +1,3 @@
+import AbstractJobPage from './abstract';
+
+export default AbstractJobPage.extend();
diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js
index d0d06e9ee..db1a561c0 100644
--- a/ui/app/controllers/jobs/index.js
+++ b/ui/app/controllers/jobs/index.js
@@ -1,5 +1,5 @@
import { inject as service } from '@ember/service';
-import { alias, filterBy } from '@ember/object/computed';
+import { alias } from '@ember/object/computed';
import Controller, { inject as controller } from '@ember/controller';
import { computed } from '@ember/object';
import Sortable from 'nomad-ui/mixins/sortable';
@@ -11,10 +11,6 @@ export default Controller.extend(Sortable, Searchable, {
isForbidden: alias('jobsController.isForbidden'),
- pendingJobs: filterBy('model', 'status', 'pending'),
- runningJobs: filterBy('model', 'status', 'running'),
- deadJobs: filterBy('model', 'status', 'dead'),
-
queryParams: {
currentPage: 'page',
searchTerm: 'search',
@@ -30,16 +26,22 @@ export default Controller.extend(Sortable, Searchable, {
searchProps: computed(() => ['id', 'name']),
+ /**
+ Filtered jobs are those that match the selected namespace and aren't children
+ of periodic or parameterized jobs.
+ */
filteredJobs: computed(
'model.[]',
+ 'model.@each.parent',
'system.activeNamespace',
'system.namespaces.length',
function() {
- if (this.get('system.namespaces.length')) {
- return this.get('model').filterBy('namespace.id', this.get('system.activeNamespace.id'));
- } else {
- return this.get('model');
- }
+ const hasNamespaces = this.get('system.namespaces.length');
+ const activeNamespace = this.get('system.activeNamespace.id');
+
+ return this.get('model')
+ .filter(job => !hasNamespaces || job.get('namespace.id') === activeNamespace)
+ .filter(job => !job.get('parent.content'));
}
),
diff --git a/ui/app/controllers/jobs/job/index.js b/ui/app/controllers/jobs/job/index.js
index 97b97efb5..c5cb709a9 100644
--- a/ui/app/controllers/jobs/job/index.js
+++ b/ui/app/controllers/jobs/job/index.js
@@ -1,11 +1,9 @@
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import Controller, { inject as controller } from '@ember/controller';
-import { computed } from '@ember/object';
-import Sortable from 'nomad-ui/mixins/sortable';
import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting';
-export default Controller.extend(Sortable, WithNamespaceResetting, {
+export default Controller.extend(WithNamespaceResetting, {
system: service(),
jobController: controller('jobs.job'),
@@ -16,7 +14,6 @@ export default Controller.extend(Sortable, WithNamespaceResetting, {
},
currentPage: 1,
- pageSize: 10,
sortProperty: 'name',
sortDescending: false,
@@ -24,20 +21,15 @@ export default Controller.extend(Sortable, WithNamespaceResetting, {
breadcrumbs: alias('jobController.breadcrumbs'),
job: alias('model'),
- taskGroups: computed('model.taskGroups.[]', function() {
- return this.get('model.taskGroups') || [];
- }),
-
- listToSort: alias('taskGroups'),
- sortedTaskGroups: alias('listSorted'),
-
- sortedEvaluations: computed('model.evaluations.@each.modifyIndex', function() {
- return (this.get('model.evaluations') || []).sortBy('modifyIndex').reverse();
- }),
-
actions: {
gotoTaskGroup(taskGroup) {
this.transitionToRoute('jobs.job.task-group', taskGroup.get('job'), taskGroup);
},
+
+ gotoJob(job) {
+ this.transitionToRoute('jobs.job', job, {
+ queryParams: { jobNamespace: job.get('namespace.name') },
+ });
+ },
},
});
diff --git a/ui/app/models/job.js b/ui/app/models/job.js
index 511f89856..b77fc7a66 100644
--- a/ui/app/models/job.js
+++ b/ui/app/models/job.js
@@ -1,4 +1,4 @@
-import { collect, sum, bool, equal } from '@ember/object/computed';
+import { collect, sum, bool, equal, or } from '@ember/object/computed';
import { computed } from '@ember/object';
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
@@ -6,6 +6,8 @@ import { belongsTo, hasMany } from 'ember-data/relationships';
import { fragmentArray } from 'ember-data-model-fragments/attributes';
import sumAggregation from '../utils/properties/sum-aggregation';
+const JOB_TYPES = ['service', 'batch', 'system'];
+
export default Model.extend({
region: attr('string'),
name: attr('string'),
@@ -19,9 +21,66 @@ export default Model.extend({
createIndex: attr('number'),
modifyIndex: attr('number'),
+ // True when the job is the parent periodic or parameterized jobs
+ // Instances of periodic or parameterized jobs are false for both properties
periodic: attr('boolean'),
parameterized: attr('boolean'),
+ periodicDetails: attr(),
+ parameterizedDetails: attr(),
+
+ hasChildren: or('periodic', 'parameterized'),
+
+ parent: belongsTo('job', { inverse: 'children' }),
+ children: hasMany('job', { inverse: 'parent' }),
+
+ // The parent job name is prepended to child launch job names
+ trimmedName: computed('name', 'parent', function() {
+ return this.get('parent.content') ? this.get('name').replace(/.+?\//, '') : this.get('name');
+ }),
+
+ // A composite of type and other job attributes to determine
+ // a better type descriptor for human interpretation rather
+ // than for scheduling.
+ displayType: computed('type', 'periodic', 'parameterized', function() {
+ if (this.get('periodic')) {
+ return 'periodic';
+ } else if (this.get('parameterized')) {
+ return 'parameterized';
+ }
+ return this.get('type');
+ }),
+
+ // A composite of type and other job attributes to determine
+ // type for templating rather than scheduling
+ templateType: computed(
+ 'type',
+ 'periodic',
+ 'parameterized',
+ 'parent.periodic',
+ 'parent.parameterized',
+ function() {
+ const type = this.get('type');
+
+ if (this.get('periodic')) {
+ return 'periodic';
+ } else if (this.get('parameterized')) {
+ return 'parameterized';
+ } else if (this.get('parent.periodic')) {
+ return 'periodic-child';
+ } else if (this.get('parent.parameterized')) {
+ return 'parameterized-child';
+ } else if (JOB_TYPES.includes(type)) {
+ // Guard against the API introducing a new type before the UI
+ // is prepared to handle it.
+ return this.get('type');
+ }
+
+ // A fail-safe in the event the API introduces a new type.
+ return 'service';
+ }
+ ),
+
datacenters: attr(),
taskGroups: fragmentArray('task-group', { defaultValue: () => [] }),
taskGroupSummaries: fragmentArray('task-group-summary'),
@@ -49,6 +108,12 @@ export default Model.extend({
runningChildren: attr('number'),
deadChildren: attr('number'),
+ childrenList: collect('pendingChildren', 'runningChildren', 'deadChildren'),
+
+ totalChildren: sum('childrenList'),
+
+ version: attr('number'),
+
versions: hasMany('job-versions'),
allocations: hasMany('allocations'),
deployments: hasMany('deployments'),
@@ -91,6 +156,10 @@ export default Model.extend({
return this.store.adapterFor('job').fetchRawDefinition(this);
},
+ forcePeriodic() {
+ return this.store.adapterFor('job').forcePeriodic(this);
+ },
+
statusClass: computed('status', function() {
const classMap = {
pending: 'is-pending',
@@ -100,4 +169,10 @@ export default Model.extend({
return classMap[this.get('status')] || 'is-dark';
}),
+
+ payload: attr('string'),
+ decodedPayload: computed('payload', function() {
+ // Lazily decode the base64 encoded payload
+ return window.atob(this.get('payload') || '');
+ }),
});
diff --git a/ui/app/serializers/job.js b/ui/app/serializers/job.js
index e09c95cd8..df77e2f38 100644
--- a/ui/app/serializers/job.js
+++ b/ui/app/serializers/job.js
@@ -15,6 +15,25 @@ export default ApplicationSerializer.extend({
hash.PlainId = hash.ID;
hash.ID = JSON.stringify([hash.ID, hash.NamespaceID || 'default']);
+ // ParentID comes in as "" instead of null
+ if (!hash.ParentID) {
+ hash.ParentID = null;
+ } else {
+ hash.ParentID = JSON.stringify([hash.ParentID, hash.NamespaceID || 'default']);
+ }
+
+ // Periodic is a boolean on list and an object on single
+ if (hash.Periodic instanceof Object) {
+ hash.PeriodicDetails = hash.Periodic;
+ hash.Periodic = true;
+ }
+
+ // Parameterized behaves like Periodic
+ if (hash.ParameterizedJob instanceof Object) {
+ hash.ParameterizedDetails = hash.ParameterizedJob;
+ hash.ParameterizedJob = true;
+ }
+
// Transform the map-based JobSummary object into an array-based
// JobSummary fragment list
hash.TaskGroupSummaries = Object.keys(get(hash, 'JobSummary.Summary') || {}).map(key => {
diff --git a/ui/app/styles/components/cli-window.scss b/ui/app/styles/components/cli-window.scss
index f8be6ff7a..0f78d1708 100644
--- a/ui/app/styles/components/cli-window.scss
+++ b/ui/app/styles/components/cli-window.scss
@@ -12,4 +12,8 @@
.is-light {
color: $text;
}
+
+ &.is-elastic {
+ height: auto;
+ }
}
diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss
index 27d44f0e9..9a360f3c9 100644
--- a/ui/app/styles/core/buttons.scss
+++ b/ui/app/styles/core/buttons.scss
@@ -24,6 +24,10 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
background: transparent;
}
+ &.is-inline {
+ vertical-align: middle;
+ }
+
&.is-compact {
padding: 0.25em 0.75em;
margin: -0.25em -0.25em -0.25em 0;
diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs
index 055e8e323..716a623fd 100644
--- a/ui/app/templates/components/gutter-menu.hbs
+++ b/ui/app/templates/components/gutter-menu.hbs
@@ -42,6 +42,6 @@
-
+
{{yield}}
diff --git a/ui/app/templates/components/job-page/batch.hbs b/ui/app/templates/components/job-page/batch.hbs
new file mode 100644
index 000000000..74b7f5c28
--- /dev/null
+++ b/ui/app/templates/components/job-page/batch.hbs
@@ -0,0 +1,35 @@
+{{#global-header class="page-header"}}
+ {{#each breadcrumbs as |breadcrumb index|}}
+
+ {{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
+
+ {{/each}}
+{{/global-header}}
+{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
+ {{job.name}}
+ {{job.status}}
+
+
+
+
+ Type: {{job.type}} |
+ Priority: {{job.priority}}
+ {{#if (and job.namespace system.shouldShowNamespaces)}}
+ | Namespace: {{job.namespace.name}}
+ {{/if}}
+
+
+
+ {{job-page/parts/summary job=job}}
+
+ {{job-page/parts/placement-failures job=job}}
+
+ {{job-page/parts/task-groups
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ gotoTaskGroup=gotoTaskGroup}}
+
+ {{job-page/parts/evaluations job=job}}
+{{/job-page/parts/body}}
diff --git a/ui/app/templates/components/job-page/parameterized-child.hbs b/ui/app/templates/components/job-page/parameterized-child.hbs
new file mode 100644
index 000000000..b01ad400f
--- /dev/null
+++ b/ui/app/templates/components/job-page/parameterized-child.hbs
@@ -0,0 +1,52 @@
+{{#global-header class="page-header"}}
+ {{#each breadcrumbs as |breadcrumb index|}}
+
+ {{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
+
+ {{/each}}
+{{/global-header}}
+{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
+ {{job.trimmedName}}
+ {{job.status}}
+
+
+
+
+ Type: {{job.type}} |
+ Priority: {{job.priority}}
+
+ Parent:
+ {{#link-to "jobs.job" job.parent (query-params jobNamespace=job.parent.namespace.name)}}
+ {{job.parent.name}}
+ {{/link-to}}
+
+ {{#if (and job.namespace system.shouldShowNamespaces)}}
+ | Namespace: {{job.namespace.name}}
+ {{/if}}
+
+
+
+ {{job-page/parts/summary job=job}}
+
+ {{job-page/parts/placement-failures job=job}}
+
+ {{job-page/parts/task-groups
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ gotoTaskGroup=gotoTaskGroup}}
+
+ {{job-page/parts/evaluations job=job}}
+
+
+
Payload
+
+ {{#if payloadJSON}}
+ {{json-viewer json=payloadJSON}}
+ {{else}}
+
{{payload}}
+ {{/if}}
+
+
+{{/job-page/parts/body}}
diff --git a/ui/app/templates/components/job-page/parameterized.hbs b/ui/app/templates/components/job-page/parameterized.hbs
new file mode 100644
index 000000000..a8e3ed47f
--- /dev/null
+++ b/ui/app/templates/components/job-page/parameterized.hbs
@@ -0,0 +1,32 @@
+{{#global-header class="page-header"}}
+ {{#each breadcrumbs as |breadcrumb index|}}
+
+ {{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
+
+ {{/each}}
+{{/global-header}}
+{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
+ {{job.name}}
+ {{job.status}}
+ Parameterized
+
+
+
+
+ Version: {{job.version}} |
+ Priority: {{job.priority}}
+ {{#if (and job.namespace system.shouldShowNamespaces)}}
+ | Namespace: {{job.namespace.name}}
+ {{/if}}
+
+
+
+ {{job-page/parts/summary job=job}}
+ {{job-page/parts/children
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ currentPage=currentPage
+ gotoJob=gotoJob}}
+{{/job-page/parts/body}}
diff --git a/ui/app/templates/components/job-page/parts/body.hbs b/ui/app/templates/components/job-page/parts/body.hbs
new file mode 100644
index 000000000..12c339ce4
--- /dev/null
+++ b/ui/app/templates/components/job-page/parts/body.hbs
@@ -0,0 +1,6 @@
+{{#gutter-menu class="page-body" onNamespaceChange=onNamespaceChange}}
+ {{partial "jobs/job/subnav"}}
+
+{{/gutter-menu}}
diff --git a/ui/app/templates/components/job-page/parts/children.hbs b/ui/app/templates/components/job-page/parts/children.hbs
new file mode 100644
index 000000000..944168f1b
--- /dev/null
+++ b/ui/app/templates/components/job-page/parts/children.hbs
@@ -0,0 +1,42 @@
+
+ Job Launches
+
+
+ {{#list-pagination
+ source=sortedChildren
+ size=pageSize
+ page=currentPage as |p|}}
+ {{#list-table
+ source=p.list
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ class="with-foot" as |t|}}
+ {{#t.head}}
+ {{#t.sort-by prop="name"}}Name{{/t.sort-by}}
+ {{#t.sort-by prop="status"}}Status{{/t.sort-by}}
+ {{#t.sort-by prop="type"}}Type{{/t.sort-by}}
+ {{#t.sort-by prop="priority"}}Priority{{/t.sort-by}}
+
Groups |
+
Summary |
+ {{/t.head}}
+ {{#t.body key="model.id" as |row|}}
+ {{job-row data-test-job-row job=row.model onClick=(action gotoJob row.model)}}
+ {{/t.body}}
+ {{/list-table}}
+
+ {{else}}
+
+
No Job Launches
+
No remaining living job launches.
+
+ {{/list-pagination}}
+
diff --git a/ui/app/templates/components/job-page/parts/evaluations.hbs b/ui/app/templates/components/job-page/parts/evaluations.hbs
new file mode 100644
index 000000000..f49a6f10b
--- /dev/null
+++ b/ui/app/templates/components/job-page/parts/evaluations.hbs
@@ -0,0 +1,38 @@
+
+ Evaluations
+
+
+ {{#if sortedEvaluations.length}}
+ {{#list-table source=sortedEvaluations as |t|}}
+ {{#t.head}}
+
ID |
+
Priority |
+
Triggered By |
+
Status |
+
Placement Failures |
+ {{/t.head}}
+ {{#t.body as |row|}}
+
+ {{row.model.shortId}} |
+ {{row.model.priority}} |
+ {{row.model.triggeredBy}} |
+ {{row.model.status}} |
+
+ {{#if (eq row.model.status "blocked")}}
+ N/A - In Progress
+ {{else if row.model.hasPlacementFailures}}
+ True
+ {{else}}
+ False
+ {{/if}}
+ |
+
+ {{/t.body}}
+ {{/list-table}}
+ {{else}}
+
+
No Evaluations
+
This is most likely due to garbage collection.
+
+ {{/if}}
+
diff --git a/ui/app/templates/components/job-page/parts/placement-failures.hbs b/ui/app/templates/components/job-page/parts/placement-failures.hbs
new file mode 100644
index 000000000..f8bf078d0
--- /dev/null
+++ b/ui/app/templates/components/job-page/parts/placement-failures.hbs
@@ -0,0 +1,12 @@
+{{#if job.hasPlacementFailures}}
+
+
+ Placement Failures
+
+
+ {{#each job.taskGroups as |taskGroup|}}
+ {{placement-failure taskGroup=taskGroup}}
+ {{/each}}
+
+
+{{/if}}
diff --git a/ui/app/templates/components/job-page/parts/running-deployment.hbs b/ui/app/templates/components/job-page/parts/running-deployment.hbs
new file mode 100644
index 000000000..c42bb866d
--- /dev/null
+++ b/ui/app/templates/components/job-page/parts/running-deployment.hbs
@@ -0,0 +1,33 @@
+{{#if job.runningDeployment}}
+
+
+
+ Active Deployment
+ {{job.runningDeployment.shortId}}
+ {{#if job.runningDeployment.version.submitTime}}
+ {{moment-from-now job.runningDeployment.version.submitTime}}
+ {{/if}}
+
+
+ Running
+ {{#if job.runningDeployment.requiresPromotion}}
+ Deployment is running but requires promotion
+ {{/if}}
+
+
+
+ {{#job-deployment-details deployment=job.runningDeployment as |d|}}
+ {{d.metrics}}
+ {{#if isShowingDeploymentDetails}}
+ {{d.taskGroups}}
+ {{d.allocations}}
+ {{/if}}
+ {{/job-deployment-details}}
+
+
+
+{{/if}}
diff --git a/ui/app/templates/components/job-page/parts/summary.hbs b/ui/app/templates/components/job-page/parts/summary.hbs
new file mode 100644
index 000000000..57e2dd25e
--- /dev/null
+++ b/ui/app/templates/components/job-page/parts/summary.hbs
@@ -0,0 +1,27 @@
+
+
+ {{#if job.hasChildren}}
+ Children Status {{job.totalChildren}}
+ {{else}}
+ Allocation Status {{job.totalAllocs}}
+ {{/if}}
+
+
+
+ {{#component (if job.hasChildren "children-status-bar" "allocation-status-bar")
+ allocationContainer=job
+ job=job
+ class="split-view" as |chart|}}
+
+ {{#each chart.data as |datum index|}}
+ -
+
+ {{datum.value}}
+
+ {{datum.label}}
+
+
+ {{/each}}
+
+ {{/component}}
+
diff --git a/ui/app/templates/components/job-page/parts/task-groups.hbs b/ui/app/templates/components/job-page/parts/task-groups.hbs
new file mode 100644
index 000000000..d6fbf5942
--- /dev/null
+++ b/ui/app/templates/components/job-page/parts/task-groups.hbs
@@ -0,0 +1,25 @@
+
+
+ Task Groups
+
+
+ {{#list-table
+ source=sortedTaskGroups
+ sortProperty=sortProperty
+ sortDescending=sortDescending as |t|}}
+ {{#t.head}}
+ {{#t.sort-by prop="name"}}Name{{/t.sort-by}}
+ {{#t.sort-by prop="count"}}Count{{/t.sort-by}}
+ {{#t.sort-by prop="queuedOrStartingAllocs" class="is-3"}}Allocation Status{{/t.sort-by}}
+ {{#t.sort-by prop="reservedCPU"}}Reserved CPU{{/t.sort-by}}
+ {{#t.sort-by prop="reservedMemory"}}Reserved Memory{{/t.sort-by}}
+ {{#t.sort-by prop="reservedEphemeralDisk"}}Reserved Disk{{/t.sort-by}}
+ {{/t.head}}
+ {{#t.body as |row|}}
+ {{task-group-row data-test-task-group
+ taskGroup=row.model
+ onClick=(action gotoTaskGroup row.model)}}
+ {{/t.body}}
+ {{/list-table}}
+
+
diff --git a/ui/app/templates/components/job-page/periodic-child.hbs b/ui/app/templates/components/job-page/periodic-child.hbs
new file mode 100644
index 000000000..e39aed637
--- /dev/null
+++ b/ui/app/templates/components/job-page/periodic-child.hbs
@@ -0,0 +1,41 @@
+{{#global-header class="page-header"}}
+ {{#each breadcrumbs as |breadcrumb index|}}
+
+ {{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
+
+ {{/each}}
+{{/global-header}}
+{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
+ {{job.trimmedName}}
+ {{job.status}}
+
+
+
+
+ Type: {{job.type}} |
+ Priority: {{job.priority}}
+
+ Parent:
+ {{#link-to "jobs.job" job.parent (query-params jobNamespace=job.parent.namespace.name)}}
+ {{job.parent.name}}
+ {{/link-to}}
+
+ {{#if (and job.namespace system.shouldShowNamespaces)}}
+ | Namespace: {{job.namespace.name}}
+ {{/if}}
+
+
+
+ {{job-page/parts/summary job=job}}
+
+ {{job-page/parts/placement-failures job=job}}
+
+ {{job-page/parts/task-groups
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ gotoTaskGroup=gotoTaskGroup}}
+
+ {{job-page/parts/evaluations job=job}}
+{{/job-page/parts/body}}
diff --git a/ui/app/templates/components/job-page/periodic.hbs b/ui/app/templates/components/job-page/periodic.hbs
new file mode 100644
index 000000000..6a815b332
--- /dev/null
+++ b/ui/app/templates/components/job-page/periodic.hbs
@@ -0,0 +1,34 @@
+{{#global-header class="page-header"}}
+ {{#each breadcrumbs as |breadcrumb index|}}
+
+ {{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
+
+ {{/each}}
+{{/global-header}}
+{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
+ {{job.name}}
+ {{job.status}}
+ periodic
+
+
+
+
+
+ Version: {{job.version}} |
+ Priority: {{job.priority}}
+ {{#if (and job.namespace system.shouldShowNamespaces)}}
+ | Namespace: {{job.namespace.name}}
+ {{/if}}
+ | Cron: {{job.periodicDetails.Spec}}
+
+
+
+ {{job-page/parts/summary job=job}}
+ {{job-page/parts/children
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ currentPage=currentPage
+ gotoJob=gotoJob}}
+{{/job-page/parts/body}}
diff --git a/ui/app/templates/components/job-page/service.hbs b/ui/app/templates/components/job-page/service.hbs
new file mode 100644
index 000000000..47343c49a
--- /dev/null
+++ b/ui/app/templates/components/job-page/service.hbs
@@ -0,0 +1,37 @@
+{{#global-header class="page-header"}}
+ {{#each breadcrumbs as |breadcrumb index|}}
+
+ {{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
+
+ {{/each}}
+{{/global-header}}
+{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
+ {{job.name}}
+ {{job.status}}
+
+
+
+
+ Type: {{job.type}} |
+ Priority: {{job.priority}}
+ {{#if (and job.namespace system.shouldShowNamespaces)}}
+ | Namespace: {{job.namespace.name}}
+ {{/if}}
+
+
+
+ {{job-page/parts/summary job=job}}
+
+ {{job-page/parts/placement-failures job=job}}
+
+ {{job-page/parts/running-deployment job=job}}
+
+ {{job-page/parts/task-groups
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ gotoTaskGroup=gotoTaskGroup}}
+
+ {{job-page/parts/evaluations job=job}}
+{{/job-page/parts/body}}
diff --git a/ui/app/templates/components/job-page/system.hbs b/ui/app/templates/components/job-page/system.hbs
new file mode 100644
index 000000000..47343c49a
--- /dev/null
+++ b/ui/app/templates/components/job-page/system.hbs
@@ -0,0 +1,37 @@
+{{#global-header class="page-header"}}
+ {{#each breadcrumbs as |breadcrumb index|}}
+
+ {{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
+
+ {{/each}}
+{{/global-header}}
+{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
+ {{job.name}}
+ {{job.status}}
+
+
+
+
+ Type: {{job.type}} |
+ Priority: {{job.priority}}
+ {{#if (and job.namespace system.shouldShowNamespaces)}}
+ | Namespace: {{job.namespace.name}}
+ {{/if}}
+
+
+
+ {{job-page/parts/summary job=job}}
+
+ {{job-page/parts/placement-failures job=job}}
+
+ {{job-page/parts/running-deployment job=job}}
+
+ {{job-page/parts/task-groups
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ gotoTaskGroup=gotoTaskGroup}}
+
+ {{job-page/parts/evaluations job=job}}
+{{/job-page/parts/body}}
diff --git a/ui/app/templates/components/job-row.hbs b/ui/app/templates/components/job-row.hbs
index 54032c664..f64d05889 100644
--- a/ui/app/templates/components/job-row.hbs
+++ b/ui/app/templates/components/job-row.hbs
@@ -2,7 +2,7 @@
{{job.status}}
|
-
{{job.type}} |
+
{{job.displayType}} |
{{job.priority}} |
{{#if job.isReloading}}
@@ -12,5 +12,11 @@
{{/if}}
|
- {{allocation-status-bar allocationContainer=job isNarrow=true}}
+
+ {{#if job.hasChildren}}
+ {{children-status-bar job=job isNarrow=true}}
+ {{else}}
+ {{allocation-status-bar allocationContainer=job isNarrow=true}}
+ {{/if}}
+
|
diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs
index 9c8c93888..34e24c33a 100644
--- a/ui/app/templates/jobs/index.hbs
+++ b/ui/app/templates/jobs/index.hbs
@@ -28,7 +28,7 @@
{{#t.sort-by prop="type"}}Type{{/t.sort-by}}
{{#t.sort-by prop="priority"}}Priority{{/t.sort-by}}
Groups |
-
Allocation Status |
+
Summary |
{{/t.head}}
{{#t.body key="model.id" as |row|}}
{{job-row data-test-job-row job=row.model onClick=(action "gotoJob" row.model)}}
diff --git a/ui/app/templates/jobs/job/index.hbs b/ui/app/templates/jobs/job/index.hbs
index bc2e6b7ce..7d81851b2 100644
--- a/ui/app/templates/jobs/job/index.hbs
+++ b/ui/app/templates/jobs/job/index.hbs
@@ -1,162 +1,8 @@
-{{#global-header class="page-header"}}
- {{#each breadcrumbs as |breadcrumb index|}}
-
- {{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
-
- {{/each}}
-{{/global-header}}
-{{#gutter-menu class="page-body" onNamespaceChange=(action "gotoJobs")}}
- {{partial "jobs/job/subnav"}}
-
-
- {{model.name}}
- {{model.status}}
- {{#if model.periodic}}
- periodic
- {{else if model.parameterized}}
- parameterized
- {{/if}}
-
-
-
-
- Type: {{model.type}} |
- Priority: {{model.priority}}
- {{#if (and model.namespace system.shouldShowNamespaces)}}
- | Namespace: {{model.namespace.name}}
- {{/if}}
-
-
-
-
-
-
Allocation Status {{taskGroups.length}}
-
-
- {{#allocation-status-bar allocationContainer=model class="split-view" as |chart|}}
-
- {{#each chart.data as |datum index|}}
- -
-
- {{datum.value}}
-
- {{datum.label}}
-
-
- {{/each}}
-
- {{/allocation-status-bar}}
-
-
-
- {{#if model.hasPlacementFailures}}
-
-
- Placement Failures
-
-
- {{#each model.taskGroups as |taskGroup|}}
- {{placement-failure taskGroup=taskGroup}}
- {{/each}}
-
-
- {{/if}}
-
- {{#if model.runningDeployment}}
-
-
-
- Active Deployment
- {{model.runningDeployment.shortId}}
- {{#if model.runningDeployment.version.submitTime}}
- {{moment-from-now model.runningDeployment.version.submitTime}}
- {{/if}}
-
-
- Running
- {{#if model.runningDeployment.requiresPromotion}}
- Deployment is running but requires promotion
- {{/if}}
-
-
-
- {{#job-deployment-details deployment=model.runningDeployment as |d|}}
- {{d.metrics}}
- {{#if isShowingDeploymentDetails}}
- {{d.taskGroups}}
- {{d.allocations}}
- {{/if}}
- {{/job-deployment-details}}
-
-
-
- {{/if}}
-
-
-
- Task Groups
-
-
- {{#list-pagination
- source=sortedTaskGroups
- sortProperty=sortProperty
- sortDescending=sortDescending as |p|}}
- {{#list-table
- source=p.list
- sortProperty=sortProperty
- sortDescending=sortDescending as |t|}}
- {{#t.head}}
- {{#t.sort-by prop="name"}}Name{{/t.sort-by}}
- {{#t.sort-by prop="count"}}Count{{/t.sort-by}}
- {{#t.sort-by prop="queuedOrStartingAllocs" class="is-3"}}Allocation Status{{/t.sort-by}}
- {{#t.sort-by prop="reservedCPU"}}Reserved CPU{{/t.sort-by}}
- {{#t.sort-by prop="reservedMemory"}}Reserved Memory{{/t.sort-by}}
- {{#t.sort-by prop="reservedEphemeralDisk"}}Reserved Disk{{/t.sort-by}}
- {{/t.head}}
- {{#t.body as |row|}}
- {{task-group-row data-test-task-group taskGroup=row.model onClick=(action "gotoTaskGroup" row.model)}}
- {{/t.body}}
- {{/list-table}}
- {{/list-pagination}}
-
-
-
-
-
- Evaluations
-
-
- {{#list-table source=sortedEvaluations as |t|}}
- {{#t.head}}
-
ID |
- Priority |
- Triggered By |
- Status |
- Placement Failures |
- {{/t.head}}
- {{#t.body as |row|}}
-
- {{row.model.shortId}} |
- {{row.model.priority}} |
- {{row.model.triggeredBy}} |
- {{row.model.status}} |
-
- {{#if (eq row.model.status "blocked")}}
- N/A - In Progress
- {{else if row.model.hasPlacementFailures}}
- True
- {{else}}
- False
- {{/if}}
- |
-
- {{/t.body}}
- {{/list-table}}
-
-
-
-{{/gutter-menu}}
+{{component (concat "job-page/" model.templateType)
+ job=model
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ currentPage=currentPage
+ onNamespaceChange=(action "gotoJobs")
+ gotoJob=(action "gotoJob")
+ gotoTaskGroup=(action "gotoTaskGroup")}}
diff --git a/ui/app/templates/jobs/job/subnav.hbs b/ui/app/templates/jobs/job/subnav.hbs
index cfbdeb672..e4ef97f80 100644
--- a/ui/app/templates/jobs/job/subnav.hbs
+++ b/ui/app/templates/jobs/job/subnav.hbs
@@ -1,4 +1,4 @@
-
+
- {{#link-to "jobs.job.index" job activeClass="is-active"}}Overview{{/link-to}}
- {{#link-to "jobs.job.definition" job activeClass="is-active"}}Definition{{/link-to}}
diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs
index 90928f830..2934d2c1a 100644
--- a/ui/app/templates/jobs/job/task-group.hbs
+++ b/ui/app/templates/jobs/job/task-group.hbs
@@ -100,12 +100,21 @@
{{else}}
-
-
-
No Matches
-
No allocations match the term {{searchTerm}}
+ {{#if allocations.length}}
+
+
+
No Matches
+
No allocations match the term {{searchTerm}}
+
-
+ {{else}}
+
+
+
No Allocations
+
No allocations have been placed.
+
+
+ {{/if}}
{{/list-pagination}}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js
index b32c70b7c..c17c7d3e5 100644
--- a/ui/mirage/config.js
+++ b/ui/mirage/config.js
@@ -11,6 +11,7 @@ export function findLeader(schema) {
}
export default function() {
+ const server = this;
this.timing = 0; // delay for each request, automatically set to 0 during testing
this.namespace = 'v1';
@@ -58,6 +59,22 @@ export default function() {
return this.serialize(deployments.where({ jobId: params.id }));
});
+ this.post('/job/:id/periodic/force', 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', 'periodicChild', {
+ parentId: parent.id,
+ namespaceId: parent.namespaceId,
+ namespace: parent.namespace,
+ createAllocations: parent.createAllocations,
+ });
+
+ // Return bogus, since the response is normally just eval information
+ return new Response(200, {}, '{}');
+ });
+
this.get('/deployment/:id');
this.get('/job/:id/evaluations', function({ evaluations }, { params }) {
diff --git a/ui/mirage/factories/job-summary.js b/ui/mirage/factories/job-summary.js
index c766b8b22..c76d39e9f 100644
--- a/ui/mirage/factories/job-summary.js
+++ b/ui/mirage/factories/job-summary.js
@@ -1,4 +1,4 @@
-import { Factory, faker } from 'ember-cli-mirage';
+import { Factory, faker, trait } from 'ember-cli-mirage';
export default Factory.extend({
// Hidden property used to compute the Summary hash
@@ -6,17 +6,27 @@ export default Factory.extend({
JobID: '',
- Summary: function() {
- return this.groupNames.reduce((summary, group) => {
- summary[group] = {
- Queued: faker.random.number(10),
- Complete: faker.random.number(10),
- Failed: faker.random.number(10),
- Running: faker.random.number(10),
- Starting: faker.random.number(10),
- Lost: faker.random.number(10),
- };
- return summary;
- }, {});
- },
+ withSummary: trait({
+ Summary: function() {
+ return this.groupNames.reduce((summary, group) => {
+ summary[group] = {
+ Queued: faker.random.number(10),
+ Complete: faker.random.number(10),
+ Failed: faker.random.number(10),
+ Running: faker.random.number(10),
+ Starting: faker.random.number(10),
+ Lost: faker.random.number(10),
+ };
+ return summary;
+ }, {});
+ },
+ }),
+
+ withChildren: trait({
+ Children: () => ({
+ Pending: faker.random.number(10),
+ Running: faker.random.number(10),
+ Dead: faker.random.number(10),
+ }),
+ }),
});
diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js
index b18d16d71..fe97f3573 100644
--- a/ui/mirage/factories/job.js
+++ b/ui/mirage/factories/job.js
@@ -1,4 +1,4 @@
-import { Factory, faker } from 'ember-cli-mirage';
+import { Factory, faker, trait } from 'ember-cli-mirage';
import { provide, provider, pickOne } from '../utils';
import { DATACENTERS } from '../common';
@@ -22,10 +22,48 @@ export default Factory.extend({
faker.list.random(...DATACENTERS)
),
- periodic: () => Math.random() > 0.5,
- parameterized() {
- return !this.periodic;
- },
+ childrenCount: () => faker.random.number({ min: 1, max: 5 }),
+
+ periodic: trait({
+ type: 'batch',
+ periodic: true,
+ // periodic details object
+ // serializer update for bool vs details object
+ periodicDetails: () => ({
+ Enabled: true,
+ ProhibitOverlap: true,
+ Spec: '*/5 * * * * *',
+ SpecType: 'cron',
+ TimeZone: 'UTC',
+ }),
+ }),
+
+ parameterized: trait({
+ type: 'batch',
+ parameterized: true,
+ // parameterized details object
+ // serializer update for bool vs details object
+ parameterizedDetails: () => ({
+ MetaOptional: null,
+ MetaRequired: null,
+ Payload: Math.random() > 0.5 ? 'required' : null,
+ }),
+ }),
+
+ periodicChild: trait({
+ // Periodic children need a parent job,
+ // It is the Periodic job's responsibility to create
+ // periodicChild jobs and provide a parent job.
+ type: 'batch',
+ }),
+
+ parameterizedChild: trait({
+ // Parameterized children need a parent job,
+ // It is the Parameterized job's responsibility to create
+ // parameterizedChild jobs and provide a parent job.
+ type: 'batch',
+ payload: window.btoa(faker.lorem.sentence()),
+ }),
createIndex: i => i,
modifyIndex: () => faker.random.number({ min: 10, max: 2000 }),
@@ -70,7 +108,8 @@ export default Factory.extend({
});
}
- const jobSummary = server.create('job-summary', {
+ const hasChildren = job.periodic || job.parameterized;
+ const jobSummary = server.create('job-summary', hasChildren ? 'withChildren' : 'withSummary', {
groupNames: groups.mapBy('name'),
job,
});
@@ -102,5 +141,25 @@ export default Factory.extend({
modifyIndex: 4000,
});
}
+
+ if (job.periodic) {
+ // Create periodicChild jobs
+ server.createList('job', job.childrenCount, 'periodicChild', {
+ parentId: job.id,
+ namespaceId: job.namespaceId,
+ namespace: job.namespace,
+ createAllocations: job.createAllocations,
+ });
+ }
+
+ if (job.parameterized) {
+ // Create parameterizedChild jobs
+ server.createList('job', job.childrenCount, 'parameterizedChild', {
+ parentId: job.id,
+ namespaceId: job.namespaceId,
+ namespace: job.namespace,
+ createAllocations: job.createAllocations,
+ });
+ }
},
});
diff --git a/ui/package.json b/ui/package.json
index 914d94ad2..7866bcc6b 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -14,11 +14,11 @@
"precommit": "lint-staged"
},
"lint-staged": {
- "ui/{app,tests,config,lib,mirage}/**/*.js": [
+ "{app,tests,config,lib,mirage}/**/*.js": [
"prettier --write",
"git add"
],
- "ui/app/styles/**/*.*": [
+ "app/styles/**/*.*": [
"prettier --write",
"git add"
]
diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js
index a1df252ec..be2593488 100644
--- a/ui/tests/acceptance/job-detail-test.js
+++ b/ui/tests/acceptance/job-detail-test.js
@@ -1,361 +1,38 @@
-import { get } from '@ember/object';
import { click, findAll, currentURL, find, visit } from 'ember-native-dom-helpers';
-import moment from 'moment';
import { test } from 'qunit';
import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance';
+import moduleForJob from 'nomad-ui/tests/helpers/module-for-job';
-const sum = (list, key) => list.reduce((sum, item) => sum + get(item, key), 0);
+moduleForJob('Acceptance | job detail (batch)', () => server.create('job', { type: 'batch' }));
+moduleForJob('Acceptance | job detail (system)', () => server.create('job', { type: 'system' }));
+moduleForJob('Acceptance | job detail (periodic)', () => server.create('job', 'periodic'));
-let job;
+moduleForJob('Acceptance | job detail (parameterized)', () =>
+ server.create('job', 'parameterized')
+);
-moduleForAcceptance('Acceptance | job detail', {
- beforeEach() {
- server.create('node');
- job = server.create('job', { type: 'service' });
- visit(`/jobs/${job.id}`);
+moduleForJob('Acceptance | job detail (periodic child)', () => {
+ const parent = server.create('job', 'periodic');
+ return server.db.jobs.where({ parentId: parent.id })[0];
+});
+
+moduleForJob('Acceptance | job detail (parameterized child)', () => {
+ const parent = server.create('job', 'parameterized');
+ return server.db.jobs.where({ parentId: parent.id })[0];
+});
+
+moduleForJob('Acceptance | job detail (service)', () => server.create('job', { type: 'service' }), {
+ 'the subnav links to deployment': (job, assert) => {
+ click(find('[data-test-tab="deployments"] a'));
+ andThen(() => {
+ assert.equal(currentURL(), `/jobs/${job.id}/deployments`);
+ });
},
});
-test('visiting /jobs/:job_id', function(assert) {
- assert.equal(currentURL(), `/jobs/${job.id}`);
-});
+let job;
-test('breadcrumbs includes job name and link back to the jobs list', function(assert) {
- assert.equal(
- find('[data-test-breadcrumb="Jobs"]').textContent,
- 'Jobs',
- 'First breadcrumb says jobs'
- );
- assert.equal(
- find(`[data-test-breadcrumb="${job.name}"]`).textContent,
- job.name,
- 'Second breadcrumb says the job name'
- );
-
- click(find('[data-test-breadcrumb="Jobs"]'));
- andThen(() => {
- assert.equal(currentURL(), '/jobs', 'First breadcrumb links back to jobs');
- });
-});
-
-test('the subnav includes links to definition, versions, and deployments when type = service', function(
- assert
-) {
- const subnavLabels = findAll('[data-test-tab]').map(anchor => anchor.textContent);
- assert.ok(subnavLabels.some(label => label === 'Definition'), 'Definition link');
- assert.ok(subnavLabels.some(label => label === 'Versions'), 'Versions link');
- assert.ok(subnavLabels.some(label => label === 'Deployments'), 'Deployments link');
-});
-
-test('the subnav includes links to definition and versions when type != service', function(assert) {
- job = server.create('job', { type: 'batch' });
- visit(`/jobs/${job.id}`);
-
- andThen(() => {
- const subnavLabels = findAll('[data-test-tab]').map(anchor => anchor.textContent);
- assert.ok(subnavLabels.some(label => label === 'Definition'), 'Definition link');
- assert.ok(subnavLabels.some(label => label === 'Versions'), 'Versions link');
- assert.notOk(subnavLabels.some(label => label === 'Deployments'), 'Deployments link');
- });
-});
-
-test('the job detail page should contain basic information about the job', function(assert) {
- assert.ok(find('[data-test-job-status]').textContent.includes(job.status), 'Status');
- assert.ok(find('[data-test-job-stat="type"]').textContent.includes(job.type), 'Type');
- assert.ok(find('[data-test-job-stat="priority"]').textContent.includes(job.priority), 'Priority');
- assert.notOk(find('[data-test-job-stat="namespace"]'), 'Namespace is not included');
-});
-
-test('the job detail page should list all task groups', function(assert) {
- assert.equal(
- findAll('[data-test-task-group]').length,
- server.db.taskGroups.where({ jobId: job.id }).length
- );
-});
-
-test('each row in the task group table should show basic information about the task group', function(
- assert
-) {
- const taskGroup = job.taskGroupIds.map(id => server.db.taskGroups.find(id)).sortBy('name')[0];
- const taskGroupRow = find('[data-test-task-group]');
- const tasks = server.db.tasks.where({ taskGroupId: taskGroup.id });
- const sum = (list, key) => list.reduce((sum, item) => sum + get(item, key), 0);
-
- assert.equal(
- taskGroupRow.querySelector('[data-test-task-group-name]').textContent.trim(),
- taskGroup.name,
- 'Name'
- );
- assert.equal(
- taskGroupRow.querySelector('[data-test-task-group-count]').textContent.trim(),
- taskGroup.count,
- 'Count'
- );
- assert.equal(
- taskGroupRow.querySelector('[data-test-task-group-cpu]').textContent.trim(),
- `${sum(tasks, 'Resources.CPU')} MHz`,
- 'Reserved CPU'
- );
- assert.equal(
- taskGroupRow.querySelector('[data-test-task-group-mem]').textContent.trim(),
- `${sum(tasks, 'Resources.MemoryMB')} MiB`,
- 'Reserved Memory'
- );
- assert.equal(
- taskGroupRow.querySelector('[data-test-task-group-disk]').textContent.trim(),
- `${taskGroup.ephemeralDisk.SizeMB} MiB`,
- 'Reserved Disk'
- );
-});
-
-test('the allocations diagram lists all allocation status figures', function(assert) {
- const jobSummary = server.db.jobSummaries.findBy({ jobId: job.id });
- const statusCounts = Object.keys(jobSummary.Summary).reduce(
- (counts, key) => {
- const group = jobSummary.Summary[key];
- counts.queued += group.Queued;
- counts.starting += group.Starting;
- counts.running += group.Running;
- counts.complete += group.Complete;
- counts.failed += group.Failed;
- counts.lost += group.Lost;
- return counts;
- },
- { queued: 0, starting: 0, running: 0, complete: 0, failed: 0, lost: 0 }
- );
-
- assert.equal(
- find('[data-test-legend-value="queued"]').textContent,
- statusCounts.queued,
- `${statusCounts.queued} are queued`
- );
-
- assert.equal(
- find('[data-test-legend-value="starting"]').textContent,
- statusCounts.starting,
- `${statusCounts.starting} are starting`
- );
-
- assert.equal(
- find('[data-test-legend-value="running"]').textContent,
- statusCounts.running,
- `${statusCounts.running} are running`
- );
-
- assert.equal(
- find('[data-test-legend-value="complete"]').textContent,
- statusCounts.complete,
- `${statusCounts.complete} are complete`
- );
-
- assert.equal(
- find('[data-test-legend-value="failed"]').textContent,
- statusCounts.failed,
- `${statusCounts.failed} are failed`
- );
-
- assert.equal(
- find('[data-test-legend-value="lost"]').textContent,
- statusCounts.lost,
- `${statusCounts.lost} are lost`
- );
-});
-
-test('there is no active deployment section when the job has no active deployment', function(
- assert
-) {
- // TODO: it would be better to not visit two different job pages in one test, but this
- // way is much more convenient.
- job = server.create('job', { noActiveDeployment: true, type: 'service' });
- visit(`/jobs/${job.id}`);
-
- andThen(() => {
- assert.notOk(find('[data-test-active-deployment]'), 'No active deployment');
- });
-});
-
-test('the active deployment section shows up for the currently running deployment', function(
- assert
-) {
- job = server.create('job', { activeDeployment: true, type: 'service' });
- const deployment = server.db.deployments.where({ jobId: job.id })[0];
- const taskGroupSummaries = server.db.deploymentTaskGroupSummaries.where({
- deploymentId: deployment.id,
- });
- const version = server.db.jobVersions.findBy({
- jobId: job.id,
- version: deployment.versionNumber,
- });
- visit(`/jobs/${job.id}`);
-
- andThen(() => {
- assert.ok(find('[data-test-active-deployment]'), 'Active deployment');
- assert.equal(
- find('[data-test-active-deployment-stat="id"]').textContent.trim(),
- deployment.id.split('-')[0],
- 'The active deployment is the most recent running deployment'
- );
-
- assert.equal(
- find('[data-test-active-deployment-stat="submit-time"]').textContent.trim(),
- moment(version.submitTime / 1000000).fromNow(),
- 'Time since the job was submitted is in the active deployment header'
- );
-
- assert.equal(
- find('[data-test-deployment-metric="canaries"]').textContent.trim(),
- `${sum(taskGroupSummaries, 'placedCanaries')} / ${sum(
- taskGroupSummaries,
- 'desiredCanaries'
- )}`,
- 'Canaries, both places and desired, are in the metrics'
- );
-
- assert.equal(
- find('[data-test-deployment-metric="placed"]').textContent.trim(),
- sum(taskGroupSummaries, 'placedAllocs'),
- 'Placed allocs aggregates across task groups'
- );
-
- assert.equal(
- find('[data-test-deployment-metric="desired"]').textContent.trim(),
- sum(taskGroupSummaries, 'desiredTotal'),
- 'Desired allocs aggregates across task groups'
- );
-
- assert.equal(
- find('[data-test-deployment-metric="healthy"]').textContent.trim(),
- sum(taskGroupSummaries, 'healthyAllocs'),
- 'Healthy allocs aggregates across task groups'
- );
-
- assert.equal(
- find('[data-test-deployment-metric="unhealthy"]').textContent.trim(),
- sum(taskGroupSummaries, 'unhealthyAllocs'),
- 'Unhealthy allocs aggregates across task groups'
- );
-
- assert.equal(
- find('[data-test-deployment-notification]').textContent.trim(),
- deployment.statusDescription,
- 'Status description is in the metrics block'
- );
- });
-});
-
-test('the active deployment section can be expanded to show task groups and allocations', function(
- assert
-) {
- job = server.create('job', { activeDeployment: true, type: 'service' });
- visit(`/jobs/${job.id}`);
-
- andThen(() => {
- assert.notOk(find('[data-test-deployment-task-groups]'), 'Task groups not found');
- assert.notOk(find('[data-test-deployment-allocations]'), 'Allocations not found');
- });
-
- andThen(() => {
- click('[data-test-deployment-toggle-details]');
- });
-
- andThen(() => {
- assert.ok(find('[data-test-deployment-task-groups]'), 'Task groups found');
- assert.ok(find('[data-test-deployment-allocations]'), 'Allocations found');
- });
-});
-
-test('the evaluations table lists evaluations sorted by modify index', function(assert) {
- job = server.create('job');
- const evaluations = server.db.evaluations
- .where({ jobId: job.id })
- .sortBy('modifyIndex')
- .reverse();
-
- visit(`/jobs/${job.id}`);
-
- andThen(() => {
- assert.equal(
- findAll('[data-test-evaluation]').length,
- evaluations.length,
- 'A row for each evaluation'
- );
-
- evaluations.forEach((evaluation, index) => {
- const row = findAll('[data-test-evaluation]')[index];
- assert.equal(
- row.querySelector('[data-test-id]').textContent,
- evaluation.id.split('-')[0],
- `Short ID, row ${index}`
- );
- });
-
- const firstEvaluation = evaluations[0];
- const row = find('[data-test-evaluation]');
- assert.equal(
- row.querySelector('[data-test-priority]').textContent,
- '' + firstEvaluation.priority,
- 'Priority'
- );
- assert.equal(
- row.querySelector('[data-test-triggered-by]').textContent,
- firstEvaluation.triggeredBy,
- 'Triggered By'
- );
- assert.equal(
- row.querySelector('[data-test-status]').textContent,
- firstEvaluation.status,
- 'Status'
- );
- });
-});
-
-test('when the job has placement failures, they are called out', function(assert) {
- job = server.create('job', { failedPlacements: true });
- const failedEvaluation = server.db.evaluations
- .where({ jobId: job.id })
- .filter(evaluation => evaluation.failedTGAllocs)
- .sortBy('modifyIndex')
- .reverse()[0];
-
- const failedTaskGroupNames = Object.keys(failedEvaluation.failedTGAllocs);
-
- visit(`/jobs/${job.id}`);
-
- andThen(() => {
- assert.ok(find('[data-test-placement-failures]'), 'Placement failures section found');
-
- const taskGroupLabels = findAll('[data-test-placement-failure-task-group]').map(title =>
- title.textContent.trim()
- );
- failedTaskGroupNames.forEach(name => {
- assert.ok(
- taskGroupLabels.find(label => label.includes(name)),
- `${name} included in placement failures list`
- );
- assert.ok(
- taskGroupLabels.find(label =>
- label.includes(failedEvaluation.failedTGAllocs[name].CoalescedFailures + 1)
- ),
- 'The number of unplaced allocs = CoalescedFailures + 1'
- );
- });
- });
-});
-
-test('when the job has no placement failures, the placement failures section is gone', function(
- assert
-) {
- job = server.create('job', { noFailedPlacements: true });
- visit(`/jobs/${job.id}`);
-
- andThen(() => {
- assert.notOk(find('[data-test-placement-failures]'), 'Placement failures section not found');
- });
-});
-
-test('when the job is not found, an error message is shown, but the URL persists', function(
- assert
-) {
+test('when the job is not found, an error message is shown, but the URL persists', function(assert) {
visit('/jobs/not-a-real-job');
andThen(() => {
@@ -378,14 +55,12 @@ moduleForAcceptance('Acceptance | job detail (with namespaces)', {
beforeEach() {
server.createList('namespace', 2);
server.create('node');
- job = server.create('job', { namespaceId: server.db.namespaces[1].name });
+ job = server.create('job', { type: 'service', namespaceId: server.db.namespaces[1].name });
server.createList('job', 3, { namespaceId: server.db.namespaces[0].name });
},
});
-test('when there are namespaces, the job detail page states the namespace for the job', function(
- assert
-) {
+test('when there are namespaces, the job detail page states the namespace for the job', function(assert) {
const namespace = server.db.namespaces.find(job.namespaceId);
visit(`/jobs/${job.id}?namespace=${namespace.name}`);
@@ -397,9 +72,7 @@ test('when there are namespaces, the job detail page states the namespace for th
});
});
-test('when switching namespaces, the app redirects to /jobs with the new namespace', function(
- assert
-) {
+test('when switching namespaces, the app redirects to /jobs with the new namespace', function(assert) {
const namespace = server.db.namespaces.find(job.namespaceId);
const otherNamespace = server.db.namespaces.toArray().find(ns => ns !== namespace).name;
const label = otherNamespace === 'default' ? 'Default Namespace' : otherNamespace;
diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js
index d7f870214..d7a30a1d1 100644
--- a/ui/tests/acceptance/jobs-list-test.js
+++ b/ui/tests/acceptance/jobs-list-test.js
@@ -59,7 +59,7 @@ test('each job row should contain information about the job', function(assert) {
job.status,
'Status'
);
- assert.equal(jobRow.querySelector('[data-test-job-type]').textContent, job.type, 'Type');
+ assert.equal(jobRow.querySelector('[data-test-job-type]').textContent, typeForJob(job), 'Type');
assert.equal(
jobRow.querySelector('[data-test-job-priority]').textContent,
job.priority,
@@ -99,9 +99,7 @@ test('when there are no jobs, there is an empty message', function(assert) {
});
});
-test('when there are jobs, but no matches for a search result, there is an empty message', function(
- assert
-) {
+test('when there are jobs, but no matches for a search result, there is an empty message', function(assert) {
server.create('job', { name: 'cat 1' });
server.create('job', { name: 'cat 2' });
@@ -117,9 +115,7 @@ test('when there are jobs, but no matches for a search result, there is an empty
});
});
-test('when the namespace query param is set, only matching jobs are shown and the namespace value is forwarded to app state', function(
- assert
-) {
+test('when the namespace query param is set, only matching jobs are shown and the namespace value is forwarded to app state', function(assert) {
server.createList('namespace', 2);
const job1 = server.create('job', { namespaceId: server.db.namespaces[0].id });
const job2 = server.create('job', { namespaceId: server.db.namespaces[1].id });
@@ -144,9 +140,7 @@ test('when the namespace query param is set, only matching jobs are shown and th
});
});
-test('when accessing jobs is forbidden, show a message with a link to the tokens page', function(
- assert
-) {
+test('when accessing jobs is forbidden, show a message with a link to the tokens page', function(assert) {
server.pretender.get('/v1/jobs', () => [403, {}, null]);
visit('/jobs');
@@ -163,3 +157,7 @@ test('when accessing jobs is forbidden, show a message with a link to the tokens
assert.equal(currentURL(), '/settings/tokens');
});
});
+
+function typeForJob(job) {
+ return job.periodic ? 'periodic' : job.parameterized ? 'parameterized' : job.type;
+}
diff --git a/ui/tests/helpers/module-for-job.js b/ui/tests/helpers/module-for-job.js
new file mode 100644
index 000000000..1e1c27fa7
--- /dev/null
+++ b/ui/tests/helpers/module-for-job.js
@@ -0,0 +1,45 @@
+import { test } from 'qunit';
+import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance';
+
+export default function moduleForJob(title, jobFactory, additionalTests) {
+ let job;
+
+ moduleForAcceptance(title, {
+ beforeEach() {
+ server.create('node');
+ job = jobFactory();
+ visit(`/jobs/${job.id}`);
+ },
+ });
+
+ test('visiting /jobs/:job_id', function(assert) {
+ assert.equal(currentURL(), `/jobs/${job.id}`);
+ });
+
+ test('the subnav links to overview', function(assert) {
+ click(find('[data-test-tab="overview"] a'));
+ andThen(() => {
+ assert.equal(currentURL(), `/jobs/${job.id}`);
+ });
+ });
+
+ test('the subnav links to definition', function(assert) {
+ click(find('[data-test-tab="definition"] a'));
+ andThen(() => {
+ assert.equal(currentURL(), `/jobs/${job.id}/definition`);
+ });
+ });
+
+ test('the subnav links to versions', function(assert) {
+ click(find('[data-test-tab="versions"] a'));
+ andThen(() => {
+ assert.equal(currentURL(), `/jobs/${job.id}/versions`);
+ });
+ });
+
+ for (var testName in additionalTests) {
+ test(testName, function(assert) {
+ additionalTests[testName](job, assert);
+ });
+ }
+}
diff --git a/ui/tests/helpers/start-app.js b/ui/tests/helpers/start-app.js
index 2ff98a176..304c6e377 100644
--- a/ui/tests/helpers/start-app.js
+++ b/ui/tests/helpers/start-app.js
@@ -2,7 +2,7 @@ import { run } from '@ember/runloop';
import { merge } from '@ember/polyfills';
import Application from '../../app';
import config from '../../config/environment';
-import registerPowerSelectHelpers from '../../tests/helpers/ember-power-select';
+import registerPowerSelectHelpers from 'ember-power-select/test-support/helpers';
registerPowerSelectHelpers();
diff --git a/ui/tests/integration/job-page/parts/body-test.js b/ui/tests/integration/job-page/parts/body-test.js
new file mode 100644
index 000000000..042d1048b
--- /dev/null
+++ b/ui/tests/integration/job-page/parts/body-test.js
@@ -0,0 +1,137 @@
+import { run } from '@ember/runloop';
+import { getOwner } from '@ember/application';
+import { test, moduleForComponent } from 'ember-qunit';
+import { click, find, findAll } from 'ember-native-dom-helpers';
+import wait from 'ember-test-helpers/wait';
+import hbs from 'htmlbars-inline-precompile';
+import sinon from 'sinon';
+import { clickTrigger } from 'ember-power-select/test-support/helpers';
+import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+
+moduleForComponent('job-page/parts/body', 'Integration | Component | job-page/parts/body', {
+ integration: true,
+ beforeEach() {
+ window.localStorage.clear();
+ this.server = startMirage();
+ this.server.createList('namespace', 3);
+ },
+ afterEach() {
+ this.server.shutdown();
+ window.localStorage.clear();
+ },
+});
+
+test('includes a subnav for the job', function(assert) {
+ this.set('job', {});
+ this.set('onNamespaceChange', () => {});
+
+ this.render(hbs`
+ {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
Inner content
+ {{/job-page/parts/body}}
+ `);
+
+ return wait().then(() => {
+ assert.ok(find('[data-test-subnav="job"]'), 'Job subnav is rendered');
+ });
+});
+
+test('the subnav includes the deployments link when the job is a service', function(assert) {
+ const store = getOwner(this).lookup('service:store');
+ let job;
+
+ run(() => {
+ job = store.createRecord('job', {
+ id: 'service-job',
+ type: 'service',
+ });
+ });
+
+ this.set('job', job);
+ this.set('onNamespaceChange', () => {});
+
+ this.render(hbs`
+ {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
Inner content
+ {{/job-page/parts/body}}
+ `);
+
+ return wait().then(() => {
+ const subnavLabels = findAll('[data-test-tab]').map(anchor => anchor.textContent);
+ assert.ok(subnavLabels.some(label => label === 'Definition'), 'Definition link');
+ assert.ok(subnavLabels.some(label => label === 'Versions'), 'Versions link');
+ assert.ok(subnavLabels.some(label => label === 'Deployments'), 'Deployments link');
+ });
+});
+
+test('the subnav does not include the deployments link when the job is not a service', function(assert) {
+ const store = getOwner(this).lookup('service:store');
+ let job;
+
+ run(() => {
+ job = store.createRecord('job', {
+ id: 'batch-job',
+ type: 'batch',
+ });
+ });
+
+ this.set('job', job);
+ this.set('onNamespaceChange', () => {});
+
+ this.render(hbs`
+ {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
Inner content
+ {{/job-page/parts/body}}
+ `);
+
+ return wait().then(() => {
+ const subnavLabels = findAll('[data-test-tab]').map(anchor => anchor.textContent);
+ assert.ok(subnavLabels.some(label => label === 'Definition'), 'Definition link');
+ assert.ok(subnavLabels.some(label => label === 'Versions'), 'Versions link');
+ assert.notOk(subnavLabels.some(label => label === 'Deployments'), 'Deployments link');
+ });
+});
+
+test('body yields content to a section after the subnav', function(assert) {
+ this.set('job', {});
+ this.set('onNamespaceChange', () => {});
+
+ this.render(hbs`
+ {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
Inner content
+ {{/job-page/parts/body}}
+ `);
+
+ return wait().then(() => {
+ assert.ok(
+ find('[data-test-page-content] .section > .inner-content'),
+ 'Content is rendered in a section in a gutter menu'
+ );
+ assert.ok(
+ find('[data-test-subnav="job"] + .section > .inner-content'),
+ 'Content is rendered immediately after the subnav'
+ );
+ });
+});
+
+test('onNamespaceChange action is called when the namespace changes in the nested gutter menu', function(assert) {
+ const namespaceSpy = sinon.spy();
+
+ this.set('job', {});
+ this.set('onNamespaceChange', namespaceSpy);
+
+ this.render(hbs`
+ {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
+
Inner content
+ {{/job-page/parts/body}}
+ `);
+
+ return wait().then(() => {
+ clickTrigger('[data-test-namespace-switcher]');
+ click(findAll('.ember-power-select-option')[1]);
+
+ return wait().then(() => {
+ assert.ok(namespaceSpy.calledOnce, 'Switching namespaces calls the onNamespaceChange action');
+ });
+ });
+});
diff --git a/ui/tests/integration/job-page/parts/children-test.js b/ui/tests/integration/job-page/parts/children-test.js
new file mode 100644
index 000000000..9b7149d41
--- /dev/null
+++ b/ui/tests/integration/job-page/parts/children-test.js
@@ -0,0 +1,206 @@
+import { getOwner } from '@ember/application';
+import { assign } from '@ember/polyfills';
+import { run } from '@ember/runloop';
+import hbs from 'htmlbars-inline-precompile';
+import wait from 'ember-test-helpers/wait';
+import { findAll, find, click } from 'ember-native-dom-helpers';
+import sinon from 'sinon';
+import { test, moduleForComponent } from 'ember-qunit';
+import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+
+moduleForComponent('job-page/parts/children', 'Integration | Component | job-page/parts/children', {
+ integration: true,
+ beforeEach() {
+ window.localStorage.clear();
+ this.store = getOwner(this).lookup('service:store');
+ this.server = startMirage();
+ this.server.create('namespace');
+ },
+ afterEach() {
+ this.server.shutdown();
+ window.localStorage.clear();
+ },
+});
+
+const props = (job, options = {}) =>
+ assign(
+ {
+ job,
+ sortProperty: 'name',
+ sortDescending: true,
+ currentPage: 1,
+ gotoJob: () => {},
+ },
+ options
+ );
+
+test('lists each child', function(assert) {
+ let parent;
+
+ this.server.create('job', 'periodic', {
+ id: 'parent',
+ childrenCount: 3,
+ createAllocations: false,
+ });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ run(() => {
+ parent = this.store.peekAll('job').findBy('plainId', 'parent');
+ });
+
+ this.setProperties(props(parent));
+
+ this.render(hbs`
+ {{job-page/parts/children
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ currentPage=currentPage
+ gotoJob=gotoJob}}
+ `);
+
+ return wait().then(() => {
+ assert.equal(
+ findAll('[data-test-job-name]').length,
+ parent.get('children.length'),
+ 'A row for each child'
+ );
+ });
+ });
+});
+
+test('eventually paginates', function(assert) {
+ let parent;
+
+ this.server.create('job', 'periodic', {
+ id: 'parent',
+ childrenCount: 11,
+ createAllocations: false,
+ });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ run(() => {
+ parent = this.store.peekAll('job').findBy('plainId', 'parent');
+ });
+
+ this.setProperties(props(parent));
+
+ this.render(hbs`
+ {{job-page/parts/children
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ currentPage=currentPage
+ gotoJob=gotoJob}}
+ `);
+
+ return wait().then(() => {
+ const childrenCount = parent.get('children.length');
+ assert.ok(childrenCount > 10, 'Parent has more children than one page size');
+ assert.equal(findAll('[data-test-job-name]').length, 10, 'Table length maxes out at 10');
+ assert.ok(find('.pagination-next'), 'Next button is rendered');
+
+ assert.ok(
+ new RegExp(`1.10.+?${childrenCount}`).test(find('.pagination-numbers').textContent.trim())
+ );
+ });
+ });
+});
+
+test('is sorted based on the sortProperty and sortDescending properties', function(assert) {
+ let parent;
+
+ this.server.create('job', 'periodic', {
+ id: 'parent',
+ childrenCount: 3,
+ createAllocations: false,
+ });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ run(() => {
+ parent = this.store.peekAll('job').findBy('plainId', 'parent');
+ });
+
+ this.setProperties(props(parent));
+
+ this.render(hbs`
+ {{job-page/parts/children
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ currentPage=currentPage
+ gotoJob=gotoJob}}
+ `);
+
+ return wait().then(() => {
+ const sortedChildren = parent.get('children').sortBy('name');
+ const childRows = findAll('[data-test-job-name]');
+
+ sortedChildren.reverse().forEach((child, index) => {
+ assert.equal(
+ childRows[index].textContent.trim(),
+ child.get('name'),
+ `Child ${index} is ${child.get('name')}`
+ );
+ });
+
+ this.set('sortDescending', false);
+
+ sortedChildren.forEach((child, index) => {
+ assert.equal(
+ childRows[index].textContent.trim(),
+ child.get('name'),
+ `Child ${index} is ${child.get('name')}`
+ );
+ });
+ });
+ });
+});
+
+test('gotoJob is called when a job row is clicked', function(assert) {
+ let parent;
+ const gotoJobSpy = sinon.spy();
+
+ this.server.create('job', 'periodic', {
+ id: 'parent',
+ childrenCount: 1,
+ createAllocations: false,
+ });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ run(() => {
+ parent = this.store.peekAll('job').findBy('plainId', 'parent');
+ });
+
+ this.setProperties(
+ props(parent, {
+ gotoJob: gotoJobSpy,
+ })
+ );
+
+ this.render(hbs`
+ {{job-page/parts/children
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ currentPage=currentPage
+ gotoJob=gotoJob}}
+ `);
+
+ return wait().then(() => {
+ click('tr.job-row');
+ assert.ok(
+ gotoJobSpy.withArgs(parent.get('children.firstObject')).calledOnce,
+ 'Clicking the job row calls the gotoJob action'
+ );
+ });
+ });
+});
diff --git a/ui/tests/integration/job-page/parts/evaluations-test.js b/ui/tests/integration/job-page/parts/evaluations-test.js
new file mode 100644
index 000000000..982c1d294
--- /dev/null
+++ b/ui/tests/integration/job-page/parts/evaluations-test.js
@@ -0,0 +1,65 @@
+import { run } from '@ember/runloop';
+import { getOwner } from '@ember/application';
+import { test, moduleForComponent } from 'ember-qunit';
+import { findAll } from 'ember-native-dom-helpers';
+import wait from 'ember-test-helpers/wait';
+import hbs from 'htmlbars-inline-precompile';
+import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+
+moduleForComponent(
+ 'job-page/parts/evaluations',
+ 'Integration | Component | job-page/parts/evaluations',
+ {
+ integration: true,
+ beforeEach() {
+ window.localStorage.clear();
+ this.store = getOwner(this).lookup('service:store');
+ this.server = startMirage();
+ this.server.create('namespace');
+ },
+ afterEach() {
+ this.server.shutdown();
+ window.localStorage.clear();
+ },
+ }
+);
+
+test('lists all evaluations for the job', function(assert) {
+ let job;
+
+ this.server.create('job', { noFailedPlacements: true, createAllocations: false });
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ run(() => {
+ job = this.store.peekAll('job').get('firstObject');
+ });
+
+ this.setProperties({ job });
+
+ this.render(hbs`
+ {{job-page/parts/evaluations job=job}}
+ `);
+
+ return wait().then(() => {
+ const evaluationRows = findAll('[data-test-evaluation]');
+ assert.equal(
+ evaluationRows.length,
+ job.get('evaluations.length'),
+ 'All evaluations are listed'
+ );
+
+ job
+ .get('evaluations')
+ .sortBy('modifyIndex')
+ .reverse()
+ .forEach((evaluation, index) => {
+ assert.equal(
+ evaluationRows[index].querySelector('[data-test-id]').textContent.trim(),
+ evaluation.get('shortId'),
+ `Evaluation ${index} is ${evaluation.get('shortId')}`
+ );
+ });
+ });
+ });
+});
diff --git a/ui/tests/integration/job-page/parts/placement-failures-test.js b/ui/tests/integration/job-page/parts/placement-failures-test.js
new file mode 100644
index 000000000..844e51c5b
--- /dev/null
+++ b/ui/tests/integration/job-page/parts/placement-failures-test.js
@@ -0,0 +1,90 @@
+import { getOwner } from '@ember/application';
+import { run } from '@ember/runloop';
+import hbs from 'htmlbars-inline-precompile';
+import wait from 'ember-test-helpers/wait';
+import { findAll, find } from 'ember-native-dom-helpers';
+import { test, moduleForComponent } from 'ember-qunit';
+import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
+
+moduleForComponent(
+ 'job-page/parts/placement-failures',
+ 'Integration | Component | job-page/parts/placement-failures',
+ {
+ integration: true,
+ beforeEach() {
+ fragmentSerializerInitializer(getOwner(this));
+ window.localStorage.clear();
+ this.store = getOwner(this).lookup('service:store');
+ this.server = startMirage();
+ this.server.create('namespace');
+ },
+ afterEach() {
+ this.server.shutdown();
+ window.localStorage.clear();
+ },
+ }
+);
+
+test('when the job has placement failures, they are called out', function(assert) {
+ this.server.create('job', { failedPlacements: true, createAllocations: false });
+ this.store.findAll('job').then(jobs => {
+ jobs.forEach(job => job.reload());
+ });
+
+ return wait().then(() => {
+ run(() => {
+ this.set('job', this.store.peekAll('job').get('firstObject'));
+ });
+
+ this.render(hbs`
+ {{job-page/parts/placement-failures job=job}})
+ `);
+
+ return wait().then(() => {
+ const failedEvaluation = this.get('job.evaluations')
+ .filterBy('hasPlacementFailures')
+ .sortBy('modifyIndex')
+ .reverse()
+ .get('firstObject');
+ const failedTGAllocs = failedEvaluation.get('failedTGAllocs');
+
+ assert.ok(find('[data-test-placement-failures]'), 'Placement failures section found');
+
+ const taskGroupLabels = findAll('[data-test-placement-failure-task-group]').map(title =>
+ title.textContent.trim()
+ );
+
+ failedTGAllocs.forEach(alloc => {
+ const name = alloc.get('name');
+ assert.ok(
+ taskGroupLabels.find(label => label.includes(name)),
+ `${name} included in placement failures list`
+ );
+ assert.ok(
+ taskGroupLabels.find(label => label.includes(alloc.get('coalescedFailures') + 1)),
+ 'The number of unplaced allocs = CoalescedFailures + 1'
+ );
+ });
+ });
+ });
+});
+
+test('when the job has no placement failures, the placement failures section is gone', function(assert) {
+ this.server.create('job', { noFailedPlacements: true, createAllocations: false });
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ run(() => {
+ this.set('job', this.store.peekAll('job').get('firstObject'));
+ });
+
+ this.render(hbs`
+ {{job-page/parts/placement-failures job=job}})
+ `);
+
+ return wait().then(() => {
+ assert.notOk(find('[data-test-placement-failures]'), 'Placement failures section not found');
+ });
+ });
+});
diff --git a/ui/tests/integration/job-page/parts/running-deployment-test.js b/ui/tests/integration/job-page/parts/running-deployment-test.js
new file mode 100644
index 000000000..e6cb09bbd
--- /dev/null
+++ b/ui/tests/integration/job-page/parts/running-deployment-test.js
@@ -0,0 +1,141 @@
+import { getOwner } from '@ember/application';
+import { test, moduleForComponent } from 'ember-qunit';
+import { click, find } from 'ember-native-dom-helpers';
+import wait from 'ember-test-helpers/wait';
+import hbs from 'htmlbars-inline-precompile';
+import moment from 'moment';
+import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
+
+moduleForComponent(
+ 'job-page/parts/running-deployment',
+ 'Integration | Component | job-page/parts/running-deployment',
+ {
+ integration: true,
+ beforeEach() {
+ fragmentSerializerInitializer(getOwner(this));
+ window.localStorage.clear();
+ this.store = getOwner(this).lookup('service:store');
+ this.server = startMirage();
+ this.server.create('namespace');
+ },
+ afterEach() {
+ this.server.shutdown();
+ window.localStorage.clear();
+ },
+ }
+);
+
+test('there is no active deployment section when the job has no active deployment', function(assert) {
+ this.server.create('job', {
+ type: 'service',
+ noActiveDeployment: true,
+ createAllocations: false,
+ });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ this.set('job', this.store.peekAll('job').get('firstObject'));
+ this.render(hbs`
+ {{job-page/parts/running-deployment job=job}})
+ `);
+
+ return wait().then(() => {
+ assert.notOk(find('[data-test-active-deployment]'), 'No active deployment');
+ });
+ });
+});
+
+test('the active deployment section shows up for the currently running deployment', function(assert) {
+ this.server.create('job', { type: 'service', createAllocations: false, activeDeployment: true });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ this.set('job', this.store.peekAll('job').get('firstObject'));
+ this.render(hbs`
+ {{job-page/parts/running-deployment job=job}}
+ `);
+
+ return wait().then(() => {
+ const deployment = this.get('job.runningDeployment');
+ const version = deployment.get('version');
+
+ assert.ok(find('[data-test-active-deployment]'), 'Active deployment');
+ assert.equal(
+ find('[data-test-active-deployment-stat="id"]').textContent.trim(),
+ deployment.get('shortId'),
+ 'The active deployment is the most recent running deployment'
+ );
+
+ assert.equal(
+ find('[data-test-active-deployment-stat="submit-time"]').textContent.trim(),
+ moment(version.get('submitTime')).fromNow(),
+ 'Time since the job was submitted is in the active deployment header'
+ );
+
+ assert.equal(
+ find('[data-test-deployment-metric="canaries"]').textContent.trim(),
+ `${deployment.get('placedCanaries')} / ${deployment.get('desiredCanaries')}`,
+ 'Canaries, both places and desired, are in the metrics'
+ );
+
+ assert.equal(
+ find('[data-test-deployment-metric="placed"]').textContent.trim(),
+ deployment.get('placedAllocs'),
+ 'Placed allocs aggregates across task groups'
+ );
+
+ assert.equal(
+ find('[data-test-deployment-metric="desired"]').textContent.trim(),
+ deployment.get('desiredTotal'),
+ 'Desired allocs aggregates across task groups'
+ );
+
+ assert.equal(
+ find('[data-test-deployment-metric="healthy"]').textContent.trim(),
+ deployment.get('healthyAllocs'),
+ 'Healthy allocs aggregates across task groups'
+ );
+
+ assert.equal(
+ find('[data-test-deployment-metric="unhealthy"]').textContent.trim(),
+ deployment.get('unhealthyAllocs'),
+ 'Unhealthy allocs aggregates across task groups'
+ );
+
+ assert.equal(
+ find('[data-test-deployment-notification]').textContent.trim(),
+ deployment.get('statusDescription'),
+ 'Status description is in the metrics block'
+ );
+ });
+ });
+});
+
+test('the active deployment section can be expanded to show task groups and allocations', function(assert) {
+ this.server.create('node');
+ this.server.create('job', { type: 'service', activeDeployment: true });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ this.set('job', this.store.peekAll('job').get('firstObject'));
+ this.render(hbs`
+ {{job-page/parts/running-deployment job=job}}
+ `);
+
+ return wait().then(() => {
+ assert.notOk(find('[data-test-deployment-task-groups]'), 'Task groups not found');
+ assert.notOk(find('[data-test-deployment-allocations]'), 'Allocations not found');
+
+ click('[data-test-deployment-toggle-details]');
+
+ return wait().then(() => {
+ assert.ok(find('[data-test-deployment-task-groups]'), 'Task groups found');
+ assert.ok(find('[data-test-deployment-allocations]'), 'Allocations found');
+ });
+ });
+ });
+});
diff --git a/ui/tests/integration/job-page/parts/summary-test.js b/ui/tests/integration/job-page/parts/summary-test.js
new file mode 100644
index 000000000..186c4ffcf
--- /dev/null
+++ b/ui/tests/integration/job-page/parts/summary-test.js
@@ -0,0 +1,154 @@
+import { getOwner } from '@ember/application';
+import hbs from 'htmlbars-inline-precompile';
+import wait from 'ember-test-helpers/wait';
+import { find } from 'ember-native-dom-helpers';
+import { test, moduleForComponent } from 'ember-qunit';
+import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
+
+moduleForComponent('job-page/parts/summary', 'Integration | Component | job-page/parts/summary', {
+ integration: true,
+ beforeEach() {
+ fragmentSerializerInitializer(getOwner(this));
+ window.localStorage.clear();
+ this.store = getOwner(this).lookup('service:store');
+ this.server = startMirage();
+ this.server.create('namespace');
+ },
+ afterEach() {
+ this.server.shutdown();
+ window.localStorage.clear();
+ },
+});
+
+test('jobs with children use the children diagram', function(assert) {
+ this.server.create('job', 'periodic', {
+ createAllocations: false,
+ });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ this.set('job', this.store.peekAll('job').get('firstObject'));
+
+ this.render(hbs`
+ {{job-page/parts/summary job=job}}
+ `);
+
+ return wait().then(() => {
+ assert.ok(find('[data-test-children-status-bar]'), 'Children status bar found');
+ assert.notOk(find('[data-test-allocation-status-bar]'), 'Allocation status bar not found');
+ });
+ });
+});
+
+test('jobs without children use the allocations diagram', function(assert) {
+ this.server.create('job', {
+ createAllocations: false,
+ });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ this.set('job', this.store.peekAll('job').get('firstObject'));
+
+ this.render(hbs`
+ {{job-page/parts/summary job=job}}
+ `);
+
+ return wait().then(() => {
+ assert.ok(find('[data-test-allocation-status-bar]'), 'Allocation status bar found');
+ assert.notOk(find('[data-test-children-status-bar]'), 'Children status bar not found');
+ });
+ });
+});
+
+test('the allocations diagram lists all allocation status figures', function(assert) {
+ this.server.create('job', {
+ createAllocations: false,
+ });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ this.set('job', this.store.peekAll('job').get('firstObject'));
+
+ this.render(hbs`
+ {{job-page/parts/summary job=job}}
+ `);
+
+ return wait().then(() => {
+ assert.equal(
+ find('[data-test-legend-value="queued"]').textContent,
+ this.get('job.queuedAllocs'),
+ `${this.get('job.queuedAllocs')} are queued`
+ );
+
+ assert.equal(
+ find('[data-test-legend-value="starting"]').textContent,
+ this.get('job.startingAllocs'),
+ `${this.get('job.startingAllocs')} are starting`
+ );
+
+ assert.equal(
+ find('[data-test-legend-value="running"]').textContent,
+ this.get('job.runningAllocs'),
+ `${this.get('job.runningAllocs')} are running`
+ );
+
+ assert.equal(
+ find('[data-test-legend-value="complete"]').textContent,
+ this.get('job.completeAllocs'),
+ `${this.get('job.completeAllocs')} are complete`
+ );
+
+ assert.equal(
+ find('[data-test-legend-value="failed"]').textContent,
+ this.get('job.failedAllocs'),
+ `${this.get('job.failedAllocs')} are failed`
+ );
+
+ assert.equal(
+ find('[data-test-legend-value="lost"]').textContent,
+ this.get('job.lostAllocs'),
+ `${this.get('job.lostAllocs')} are lost`
+ );
+ });
+ });
+});
+
+test('the children diagram lists all children status figures', function(assert) {
+ this.server.create('job', 'periodic', {
+ createAllocations: false,
+ });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ this.set('job', this.store.peekAll('job').get('firstObject'));
+
+ this.render(hbs`
+ {{job-page/parts/summary job=job}}
+ `);
+
+ return wait().then(() => {
+ assert.equal(
+ find('[data-test-legend-value="queued"]').textContent,
+ this.get('job.pendingChildren'),
+ `${this.get('job.pendingChildren')} are pending`
+ );
+
+ assert.equal(
+ find('[data-test-legend-value="running"]').textContent,
+ this.get('job.runningChildren'),
+ `${this.get('job.runningChildren')} are running`
+ );
+
+ assert.equal(
+ find('[data-test-legend-value="complete"]').textContent,
+ this.get('job.deadChildren'),
+ `${this.get('job.deadChildren')} are dead`
+ );
+ });
+ });
+});
diff --git a/ui/tests/integration/job-page/parts/task-groups-test.js b/ui/tests/integration/job-page/parts/task-groups-test.js
new file mode 100644
index 000000000..f9ac811f8
--- /dev/null
+++ b/ui/tests/integration/job-page/parts/task-groups-test.js
@@ -0,0 +1,170 @@
+import { getOwner } from '@ember/application';
+import { assign } from '@ember/polyfills';
+import hbs from 'htmlbars-inline-precompile';
+import wait from 'ember-test-helpers/wait';
+import { click, findAll, find } from 'ember-native-dom-helpers';
+import { test, moduleForComponent } from 'ember-qunit';
+import sinon from 'sinon';
+import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
+
+moduleForComponent(
+ 'job-page/parts/task-groups',
+ 'Integration | Component | job-page/parts/task-groups',
+ {
+ integration: true,
+ beforeEach() {
+ fragmentSerializerInitializer(getOwner(this));
+ window.localStorage.clear();
+ this.store = getOwner(this).lookup('service:store');
+ this.server = startMirage();
+ this.server.create('namespace');
+ },
+ afterEach() {
+ this.server.shutdown();
+ },
+ }
+);
+
+const props = (job, options = {}) =>
+ assign(
+ {
+ job,
+ sortProperty: 'name',
+ sortDescending: true,
+ gotoTaskGroup: () => {},
+ },
+ options
+ );
+
+test('the job detail page should list all task groups', function(assert) {
+ this.server.create('job', {
+ createAllocations: false,
+ });
+
+ this.store.findAll('job').then(jobs => {
+ jobs.forEach(job => job.reload());
+ });
+
+ return wait().then(() => {
+ const job = this.store.peekAll('job').get('firstObject');
+ this.setProperties(props(job));
+
+ this.render(hbs`
+ {{job-page/parts/task-groups
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ gotoTaskGroup=gotoTaskGroup}}
+ `);
+
+ return wait().then(() => {
+ assert.equal(
+ findAll('[data-test-task-group]').length,
+ job.get('taskGroups.length'),
+ 'One row per task group'
+ );
+ });
+ });
+});
+
+test('each row in the task group table should show basic information about the task group', function(assert) {
+ this.server.create('job', {
+ createAllocations: false,
+ });
+
+ this.store.findAll('job').then(jobs => {
+ jobs.forEach(job => job.reload());
+ });
+
+ return wait().then(() => {
+ const job = this.store.peekAll('job').get('firstObject');
+ const taskGroup = job
+ .get('taskGroups')
+ .sortBy('name')
+ .reverse()
+ .get('firstObject');
+
+ this.setProperties(props(job));
+
+ this.render(hbs`
+ {{job-page/parts/task-groups
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ gotoTaskGroup=gotoTaskGroup}}
+ `);
+
+ return wait().then(() => {
+ const taskGroupRow = find('[data-test-task-group]');
+
+ assert.equal(
+ taskGroupRow.querySelector('[data-test-task-group-name]').textContent.trim(),
+ taskGroup.get('name'),
+ 'Name'
+ );
+ assert.equal(
+ taskGroupRow.querySelector('[data-test-task-group-count]').textContent.trim(),
+ taskGroup.get('count'),
+ 'Count'
+ );
+ assert.equal(
+ taskGroupRow.querySelector('[data-test-task-group-cpu]').textContent.trim(),
+ `${taskGroup.get('reservedCPU')} MHz`,
+ 'Reserved CPU'
+ );
+ assert.equal(
+ taskGroupRow.querySelector('[data-test-task-group-mem]').textContent.trim(),
+ `${taskGroup.get('reservedMemory')} MiB`,
+ 'Reserved Memory'
+ );
+ assert.equal(
+ taskGroupRow.querySelector('[data-test-task-group-disk]').textContent.trim(),
+ `${taskGroup.get('reservedEphemeralDisk')} MiB`,
+ 'Reserved Disk'
+ );
+ });
+ });
+});
+
+test('gotoTaskGroup is called when task group rows are clicked', function(assert) {
+ this.server.create('job', {
+ createAllocations: false,
+ });
+
+ this.store.findAll('job').then(jobs => {
+ jobs.forEach(job => job.reload());
+ });
+
+ return wait().then(() => {
+ const taskGroupSpy = sinon.spy();
+ const job = this.store.peekAll('job').get('firstObject');
+ const taskGroup = job
+ .get('taskGroups')
+ .sortBy('name')
+ .reverse()
+ .get('firstObject');
+
+ this.setProperties(
+ props(job, {
+ gotoTaskGroup: taskGroupSpy,
+ })
+ );
+
+ this.render(hbs`
+ {{job-page/parts/task-groups
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ gotoTaskGroup=gotoTaskGroup}}
+ `);
+
+ return wait().then(() => {
+ click('[data-test-task-group]');
+ assert.ok(
+ taskGroupSpy.withArgs(taskGroup).calledOnce,
+ 'Clicking the task group row calls the gotoTaskGroup action'
+ );
+ });
+ });
+});
diff --git a/ui/tests/integration/job-page/periodic-test.js b/ui/tests/integration/job-page/periodic-test.js
new file mode 100644
index 000000000..c259f0167
--- /dev/null
+++ b/ui/tests/integration/job-page/periodic-test.js
@@ -0,0 +1,86 @@
+import { getOwner } from '@ember/application';
+import { test, moduleForComponent } from 'ember-qunit';
+import { click, findAll } from 'ember-native-dom-helpers';
+import wait from 'ember-test-helpers/wait';
+import hbs from 'htmlbars-inline-precompile';
+import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+
+moduleForComponent('job-page/periodic', 'Integration | Component | job-page/periodic', {
+ integration: true,
+ beforeEach() {
+ window.localStorage.clear();
+ this.store = getOwner(this).lookup('service:store');
+ this.server = startMirage();
+ this.server.create('namespace');
+ },
+ afterEach() {
+ this.server.shutdown();
+ window.localStorage.clear();
+ },
+});
+
+test('Clicking Force Launch launches a new periodic child job', function(assert) {
+ const childrenCount = 3;
+
+ this.server.create('job', 'periodic', {
+ id: 'parent',
+ childrenCount,
+ createAllocations: false,
+ });
+
+ this.store.findAll('job');
+
+ return wait().then(() => {
+ const job = this.store.peekAll('job').findBy('plainId', 'parent');
+ this.setProperties({
+ job,
+ sortProperty: 'name',
+ sortDescending: true,
+ currentPage: 1,
+ gotoJob: () => {},
+ });
+
+ this.render(hbs`
+ {{job-page/periodic
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ currentPage=currentPage
+ gotoJob=gotoJob}}
+ `);
+
+ return wait().then(() => {
+ const currentJobCount = server.db.jobs.length;
+
+ assert.equal(
+ findAll('[data-test-job-name]').length,
+ childrenCount,
+ 'The new periodic job launch is in the children list'
+ );
+
+ click('[data-test-force-launch]');
+
+ return wait().then(() => {
+ const id = job.get('plainId');
+ const namespace = job.get('namespace.name') || 'default';
+
+ assert.ok(
+ server.pretender.handledRequests
+ .filterBy('method', 'POST')
+ .find(req => req.url === `/v1/job/${id}/periodic/force?namespace=${namespace}`),
+ 'POST URL was correct'
+ );
+
+ assert.ok(server.db.jobs.length, currentJobCount + 1, 'POST request was made');
+
+ return wait().then(() => {
+ assert.equal(
+ findAll('[data-test-job-name]').length,
+ childrenCount + 1,
+ 'The new periodic job launch is in the children list'
+ );
+ });
+ });
+ });
+ });
+});