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|}} + + {{/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|}} + + {{/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|}} + + {{/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"}} +
+ {{yield}} +
+{{/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|}} +
  1. + + {{datum.value}} + + {{datum.label}} + +
  2. + {{/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|}} + + {{/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|}} + + {{/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|}} + + {{/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|}} + + {{/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|}} - - {{/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|}} -
  1. - - {{datum.value}} - - {{datum.label}} - -
  2. - {{/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' + ); + }); + }); + }); + }); +});