Merge pull request #3829 from hashicorp/f-ui-specialized-job-pages

UI: Specialized Job Detail Pages
This commit is contained in:
Michael Lange 2018-02-06 17:48:44 -08:00 committed by GitHub
commit f95f5a1a66
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 2063 additions and 586 deletions

View file

@ -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');
}
},
});

View file

@ -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() {

View file

@ -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' },
];
}),
});

View file

@ -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],
},
];
}),
});

View file

@ -0,0 +1,3 @@
import AbstractJobPage from './abstract';
export default AbstractJobPage.extend();

View file

@ -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;
}),
});

View file

@ -0,0 +1,3 @@
import AbstractJobPage from './abstract';
export default AbstractJobPage.extend();

View file

@ -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'),
});

View file

@ -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();
}),
});

View file

@ -0,0 +1,6 @@
import Component from '@ember/component';
export default Component.extend({
job: null,
tagName: '',
});

View file

@ -0,0 +1,6 @@
import Component from '@ember/component';
export default Component.extend({
job: null,
tagName: '',
});

View file

@ -0,0 +1,7 @@
import Component from '@ember/component';
export default Component.extend({
job: null,
classNames: ['boxed-section'],
});

View file

@ -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'),
});

View file

@ -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],
},
];
}),
});

View file

@ -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');
});
},
},
});

View file

@ -0,0 +1,3 @@
import AbstractJobPage from './abstract';
export default AbstractJobPage.extend();

View file

@ -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'));
}
),

View file

@ -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') },
});
},
},
});

View file

@ -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') || '');
}),
});

View file

@ -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 => {

View file

@ -12,4 +12,8 @@
.is-light {
color: $text;
}
&.is-elastic {
height: auto;
}
}

View file

@ -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;

View file

@ -42,6 +42,6 @@
</aside>
</div>
</div>
<div class="page-column is-right">
<div data-test-page-content class="page-column is-right">
{{yield}}
</div>

View file

@ -0,0 +1,35 @@
{{#global-header class="page-header"}}
{{#each breadcrumbs as |breadcrumb index|}}
<li class="{{if (eq (inc index) breadcrumbs.length) "is-active"}}">
{{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
</li>
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.name}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
</h1>
<div class="boxed-section job-stats">
<div class="boxed-section-body">
<span data-test-job-stat="type"><strong>Type:</strong> {{job.type}} | </span>
<span data-test-job-stat="priority"><strong>Priority:</strong> {{job.priority}} </span>
{{#if (and job.namespace system.shouldShowNamespaces)}}
<span data-test-job-stat="namespace"> | <strong>Namespace:</strong> {{job.namespace.name}}</span>
{{/if}}
</div>
</div>
{{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}}

View file

@ -0,0 +1,52 @@
{{#global-header class="page-header"}}
{{#each breadcrumbs as |breadcrumb index|}}
<li class="{{if (eq (inc index) breadcrumbs.length) "is-active"}}">
{{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
</li>
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.trimmedName}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
</h1>
<div class="boxed-section job-stats">
<div class="boxed-section-body">
<span data-test-job-stat="type"><strong>Type:</strong> {{job.type}} | </span>
<span data-test-job-stat="priority"><strong>Priority:</strong> {{job.priority}} </span>
<span data-test-job-stat="parent">
<strong>Parent:</strong>
{{#link-to "jobs.job" job.parent (query-params jobNamespace=job.parent.namespace.name)}}
{{job.parent.name}}
{{/link-to}}
</span>
{{#if (and job.namespace system.shouldShowNamespaces)}}
<span data-test-job-stat="namespace"> | <strong>Namespace:</strong> {{job.namespace.name}}</span>
{{/if}}
</div>
</div>
{{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}}
<div class="boxed-section">
<div class="boxed-section-head">Payload</div>
<div class="boxed-section-body is-dark">
{{#if payloadJSON}}
{{json-viewer json=payloadJSON}}
{{else}}
<pre class="cli-window is-elastic"><code>{{payload}}</code></pre>
{{/if}}
</div>
</div>
{{/job-page/parts/body}}

View file

@ -0,0 +1,32 @@
{{#global-header class="page-header"}}
{{#each breadcrumbs as |breadcrumb index|}}
<li class="{{if (eq (inc index) breadcrumbs.length) "is-active"}}">
{{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
</li>
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.name}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
<span class="tag is-hollow">Parameterized</span>
</h1>
<div class="boxed-section job-stats">
<div class="boxed-section-body">
<span data-test-job-stat="version"><strong>Version:</strong> {{job.version}} | </span>
<span data-test-job-stat="priority"><strong>Priority:</strong> {{job.priority}} </span>
{{#if (and job.namespace system.shouldShowNamespaces)}}
<span data-test-job-stat="namespace"> | <strong>Namespace:</strong> {{job.namespace.name}}</span>
{{/if}}
</div>
</div>
{{job-page/parts/summary job=job}}
{{job-page/parts/children
job=job
sortProperty=sortProperty
sortDescending=sortDescending
currentPage=currentPage
gotoJob=gotoJob}}
{{/job-page/parts/body}}

View file

@ -0,0 +1,6 @@
{{#gutter-menu class="page-body" onNamespaceChange=onNamespaceChange}}
{{partial "jobs/job/subnav"}}
<section class="section">
{{yield}}
</section>
{{/gutter-menu}}

View file

@ -0,0 +1,42 @@
<div class="boxed-section-head">
Job Launches
</div>
<div class="boxed-section-body {{if sortedChildren.length "is-full-bleed"}}">
{{#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}}
<th>Groups</th>
<th class="is-3">Summary</th>
{{/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}}
<div class="table-foot">
<nav class="pagination">
<div class="pagination-numbers">
{{p.startsAt}}&ndash;{{p.endsAt}} of {{sortedChildren.length}}
</div>
{{#p.prev class="pagination-previous"}} &lt; {{/p.prev}}
{{#p.next class="pagination-next"}} &gt; {{/p.next}}
<ul class="pagination-list"></ul>
</nav>
</div>
{{else}}
<div class="empty-message">
<h3 class="empty-message-headline">No Job Launches</h3>
<p class="empty-message-body">No remaining living job launches.</p>
</div>
{{/list-pagination}}
</div>

View file

@ -0,0 +1,38 @@
<div class="boxed-section-head">
Evaluations
</div>
<div class="boxed-section-body {{if sortedEvaluations.length "is-full-bleed"}} evaluations">
{{#if sortedEvaluations.length}}
{{#list-table source=sortedEvaluations as |t|}}
{{#t.head}}
<th>ID</th>
<th>Priority</th>
<th>Triggered By</th>
<th>Status</th>
<th>Placement Failures</th>
{{/t.head}}
{{#t.body as |row|}}
<tr data-test-evaluation="{{row.model.shortId}}">
<td data-test-id>{{row.model.shortId}}</td>
<td data-test-priority>{{row.model.priority}}</td>
<td data-test-triggered-by>{{row.model.triggeredBy}}</td>
<td data-test-status>{{row.model.status}}</td>
<td data-test-blocked>
{{#if (eq row.model.status "blocked")}}
N/A - In Progress
{{else if row.model.hasPlacementFailures}}
True
{{else}}
False
{{/if}}
</td>
</tr>
{{/t.body}}
{{/list-table}}
{{else}}
<div data-test-empty-evaluations-list class="empty-message">
<h3 data-test-empty-evaluations-list-headline class="empty-message-headline">No Evaluations</h3>
<p class="empty-message-body">This is most likely due to garbage collection.</p>
</div>
{{/if}}
</div>

View file

@ -0,0 +1,12 @@
{{#if job.hasPlacementFailures}}
<div class="boxed-section is-danger" data-test-placement-failures>
<div class="boxed-section-head">
Placement Failures
</div>
<div class="boxed-section-body">
{{#each job.taskGroups as |taskGroup|}}
{{placement-failure taskGroup=taskGroup}}
{{/each}}
</div>
</div>
{{/if}}

View file

@ -0,0 +1,33 @@
{{#if job.runningDeployment}}
<div class="boxed-section is-info" data-test-active-deployment>
<div class="boxed-section-head">
<div class="boxed-section-row">
Active Deployment
<span class="badge is-white is-subtle bumper-left" data-test-active-deployment-stat="id">{{job.runningDeployment.shortId}}</span>
{{#if job.runningDeployment.version.submitTime}}
<span class="pull-right submit-time" data-test-active-deployment-stat="submit-time">{{moment-from-now job.runningDeployment.version.submitTime}}</span>
{{/if}}
</div>
<div class="boxed-section-row">
<span class="tag is-info is-outlined">Running</span>
{{#if job.runningDeployment.requiresPromotion}}
<span class="tag bumper-left is-warning no-text-transform">Deployment is running but requires promotion</span>
{{/if}}
</div>
</div>
<div class="boxed-section-body with-foot">
{{#job-deployment-details deployment=job.runningDeployment as |d|}}
{{d.metrics}}
{{#if isShowingDeploymentDetails}}
{{d.taskGroups}}
{{d.allocations}}
{{/if}}
{{/job-deployment-details}}
</div>
<div class="boxed-section-foot">
<a class="pull-right" {{action (toggle "isShowingDeploymentDetails" this)}} data-test-deployment-toggle-details>
{{if isShowingDeploymentDetails "Hide" "Show"}} deployment task groups and allocations
</a>
</div>
</div>
{{/if}}

View file

@ -0,0 +1,27 @@
<div class="boxed-section-head">
<div>
{{#if job.hasChildren}}
Children Status <span class="badge is-white">{{job.totalChildren}}</span>
{{else}}
Allocation Status <span class="badge is-white">{{job.totalAllocs}}</span>
{{/if}}
</div>
</div>
<div class="boxed-section-body">
{{#component (if job.hasChildren "children-status-bar" "allocation-status-bar")
allocationContainer=job
job=job
class="split-view" as |chart|}}
<ol data-test-legend class="legend">
{{#each chart.data as |datum index|}}
<li class="{{datum.className}} {{if (eq datum.index chart.activeDatum.index) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
<span class="value" data-test-legend-value="{{datum.className}}">{{datum.value}}</span>
<span class="label">
{{datum.label}}
</span>
</li>
{{/each}}
</ol>
{{/component}}
</div>

View file

@ -0,0 +1,25 @@
<div class="boxed-section">
<div class="boxed-section-head">
Task Groups
</div>
<div class="boxed-section-body is-full-bleed">
{{#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}}
</div>
</div>

View file

@ -0,0 +1,41 @@
{{#global-header class="page-header"}}
{{#each breadcrumbs as |breadcrumb index|}}
<li class="{{if (eq (inc index) breadcrumbs.length) "is-active"}}">
{{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
</li>
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.trimmedName}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
</h1>
<div class="boxed-section job-stats">
<div class="boxed-section-body">
<span data-test-job-stat="type"><strong>Type:</strong> {{job.type}} | </span>
<span data-test-job-stat="priority"><strong>Priority:</strong> {{job.priority}} </span>
<span data-test-job-stat="parent">
<strong>Parent:</strong>
{{#link-to "jobs.job" job.parent (query-params jobNamespace=job.parent.namespace.name)}}
{{job.parent.name}}
{{/link-to}}
</span>
{{#if (and job.namespace system.shouldShowNamespaces)}}
<span data-test-job-stat="namespace"> | <strong>Namespace:</strong> {{job.namespace.name}}</span>
{{/if}}
</div>
</div>
{{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}}

View file

@ -0,0 +1,34 @@
{{#global-header class="page-header"}}
{{#each breadcrumbs as |breadcrumb index|}}
<li class="{{if (eq (inc index) breadcrumbs.length) "is-active"}}">
{{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
</li>
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.name}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
<span class="tag is-hollow">periodic</span>
<button data-test-force-launch class="button is-warning is-small is-inline" onclick={{action "forceLaunch"}}>Force Launch</button>
</h1>
<div class="boxed-section job-stats">
<div class="boxed-section-body">
<span data-test-job-stat="version"><strong>Version:</strong> {{job.version}} | </span>
<span data-test-job-stat="priority"><strong>Priority:</strong> {{job.priority}} </span>
{{#if (and job.namespace system.shouldShowNamespaces)}}
<span data-test-job-stat="namespace"> | <strong>Namespace:</strong> {{job.namespace.name}}</span>
{{/if}}
<span data-test-job-stat="cron"> | <strong>Cron:</strong> <code>{{job.periodicDetails.Spec}}</code></span>
</div>
</div>
{{job-page/parts/summary job=job}}
{{job-page/parts/children
job=job
sortProperty=sortProperty
sortDescending=sortDescending
currentPage=currentPage
gotoJob=gotoJob}}
{{/job-page/parts/body}}

View file

@ -0,0 +1,37 @@
{{#global-header class="page-header"}}
{{#each breadcrumbs as |breadcrumb index|}}
<li class="{{if (eq (inc index) breadcrumbs.length) "is-active"}}">
{{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
</li>
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.name}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
</h1>
<div class="boxed-section job-stats">
<div class="boxed-section-body">
<span data-test-job-stat="type"><strong>Type:</strong> {{job.type}} | </span>
<span data-test-job-stat="priority"><strong>Priority:</strong> {{job.priority}} </span>
{{#if (and job.namespace system.shouldShowNamespaces)}}
<span data-test-job-stat="namespace"> | <strong>Namespace:</strong> {{job.namespace.name}}</span>
{{/if}}
</div>
</div>
{{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}}

View file

@ -0,0 +1,37 @@
{{#global-header class="page-header"}}
{{#each breadcrumbs as |breadcrumb index|}}
<li class="{{if (eq (inc index) breadcrumbs.length) "is-active"}}">
{{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
</li>
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.name}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
</h1>
<div class="boxed-section job-stats">
<div class="boxed-section-body">
<span data-test-job-stat="type"><strong>Type:</strong> {{job.type}} | </span>
<span data-test-job-stat="priority"><strong>Priority:</strong> {{job.priority}} </span>
{{#if (and job.namespace system.shouldShowNamespaces)}}
<span data-test-job-stat="namespace"> | <strong>Namespace:</strong> {{job.namespace.name}}</span>
{{/if}}
</div>
</div>
{{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}}

View file

@ -2,7 +2,7 @@
<td data-test-job-status>
<span class="tag {{job.statusClass}}">{{job.status}}</span>
</td>
<td data-test-job-type>{{job.type}}</td>
<td data-test-job-type>{{job.displayType}}</td>
<td data-test-job-priority>{{job.priority}}</td>
<td data-test-job-task-groups>
{{#if job.isReloading}}
@ -12,5 +12,11 @@
{{/if}}
</td>
<td data-test-job-allocations>
<div class="inline-chart">{{allocation-status-bar allocationContainer=job isNarrow=true}}</div>
<div class="inline-chart">
{{#if job.hasChildren}}
{{children-status-bar job=job isNarrow=true}}
{{else}}
{{allocation-status-bar allocationContainer=job isNarrow=true}}
{{/if}}
</div>
</td>

View file

@ -28,7 +28,7 @@
{{#t.sort-by prop="type"}}Type{{/t.sort-by}}
{{#t.sort-by prop="priority"}}Priority{{/t.sort-by}}
<th>Groups</th>
<th class="is-3">Allocation Status</th>
<th class="is-3">Summary</th>
{{/t.head}}
{{#t.body key="model.id" as |row|}}
{{job-row data-test-job-row job=row.model onClick=(action "gotoJob" row.model)}}

View file

@ -1,162 +1,8 @@
{{#global-header class="page-header"}}
{{#each breadcrumbs as |breadcrumb index|}}
<li class="{{if (eq (inc index) breadcrumbs.length) "is-active"}}">
{{#link-to data-test-breadcrumb=breadcrumb.label params=breadcrumb.args}}{{breadcrumb.label}}{{/link-to}}
</li>
{{/each}}
{{/global-header}}
{{#gutter-menu class="page-body" onNamespaceChange=(action "gotoJobs")}}
{{partial "jobs/job/subnav"}}
<section class="section">
<h1 class="title">
{{model.name}}
<span class="bumper-left tag {{model.statusClass}}" data-test-job-status>{{model.status}}</span>
{{#if model.periodic}}
<span class="tag is-hollow">periodic</span>
{{else if model.parameterized}}
<span class="tag is-hollow">parameterized</span>
{{/if}}
</h1>
<div class="boxed-section job-stats">
<div class="boxed-section-body">
<span data-test-job-stat="type"><strong>Type:</strong> {{model.type}} | </span>
<span data-test-job-stat="priority"><strong>Priority:</strong> {{model.priority}} </span>
{{#if (and model.namespace system.shouldShowNamespaces)}}
<span data-test-job-stat="namespace"> | <strong>Namespace:</strong> {{model.namespace.name}}</span>
{{/if}}
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
<div>Allocation Status <span class="badge is-white">{{taskGroups.length}}</span></div>
</div>
<div class="boxed-section-body">
{{#allocation-status-bar allocationContainer=model class="split-view" as |chart|}}
<ol data-test-allocations-legend class="legend">
{{#each chart.data as |datum index|}}
<li class="{{datum.className}} {{if (eq datum.index chart.activeDatum.index) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
<span class="value" data-test-legend-value="{{datum.className}}">{{datum.value}}</span>
<span class="label">
{{datum.label}}
</span>
</li>
{{/each}}
</ol>
{{/allocation-status-bar}}
</div>
</div>
{{#if model.hasPlacementFailures}}
<div class="boxed-section is-danger" data-test-placement-failures>
<div class="boxed-section-head">
Placement Failures
</div>
<div class="boxed-section-body">
{{#each model.taskGroups as |taskGroup|}}
{{placement-failure taskGroup=taskGroup}}
{{/each}}
</div>
</div>
{{/if}}
{{#if model.runningDeployment}}
<div class="boxed-section is-info" data-test-active-deployment>
<div class="boxed-section-head">
<div class="boxed-section-row">
Active Deployment
<span class="badge is-white is-subtle bumper-left" data-test-active-deployment-stat="id">{{model.runningDeployment.shortId}}</span>
{{#if model.runningDeployment.version.submitTime}}
<span class="pull-right submit-time" data-test-active-deployment-stat="submit-time">{{moment-from-now model.runningDeployment.version.submitTime}}</span>
{{/if}}
</div>
<div class="boxed-section-row">
<span class="tag is-info is-outlined">Running</span>
{{#if model.runningDeployment.requiresPromotion}}
<span class="tag bumper-left is-warning no-text-transform">Deployment is running but requires promotion</span>
{{/if}}
</div>
</div>
<div class="boxed-section-body with-foot">
{{#job-deployment-details deployment=model.runningDeployment as |d|}}
{{d.metrics}}
{{#if isShowingDeploymentDetails}}
{{d.taskGroups}}
{{d.allocations}}
{{/if}}
{{/job-deployment-details}}
</div>
<div class="boxed-section-foot">
<a class="pull-right" {{action (toggle "isShowingDeploymentDetails" this)}} data-test-deployment-toggle-details>
{{if isShowingDeploymentDetails "Hide" "Show"}} deployment task groups and allocations
</a>
</div>
</div>
{{/if}}
<div class="boxed-section">
<div class="boxed-section-head">
Task Groups
</div>
<div class="boxed-section-body is-full-bleed">
{{#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}}
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
Evaluations
</div>
<div class="boxed-section-body is-full-bleed evaluations">
{{#list-table source=sortedEvaluations as |t|}}
{{#t.head}}
<th>ID</th>
<th>Priority</th>
<th>Triggered By</th>
<th>Status</th>
<th>Placement Failures</th>
{{/t.head}}
{{#t.body as |row|}}
<tr data-test-evaluation="{{row.model.shortId}}">
<td data-test-id>{{row.model.shortId}}</td>
<td data-test-priority>{{row.model.priority}}</td>
<td data-test-triggered-by>{{row.model.triggeredBy}}</td>
<td data-test-status>{{row.model.status}}</td>
<td data-test-blocked>
{{#if (eq row.model.status "blocked")}}
N/A - In Progress
{{else if row.model.hasPlacementFailures}}
True
{{else}}
False
{{/if}}
</td>
</tr>
{{/t.body}}
{{/list-table}}
</div>
</div>
</section>
{{/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")}}

View file

@ -1,4 +1,4 @@
<div class="tabs is-subnav">
<div data-test-subnav="job" class="tabs is-subnav">
<ul>
<li data-test-tab="overview">{{#link-to "jobs.job.index" job activeClass="is-active"}}Overview{{/link-to}}</li>
<li data-test-tab="definition">{{#link-to "jobs.job.definition" job activeClass="is-active"}}Definition{{/link-to}}</li>

View file

@ -100,12 +100,21 @@
</nav>
</div>
{{else}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-allocations-list>
<h3 class="empty-message-headline" data-test-empty-allocations-list-headline>No Matches</h3>
<p class="empty-message-body">No allocations match the term <strong>{{searchTerm}}</strong></p>
{{#if allocations.length}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-allocations-list>
<h3 class="empty-message-headline" data-test-empty-allocations-list-headline>No Matches</h3>
<p class="empty-message-body">No allocations match the term <strong>{{searchTerm}}</strong></p>
</div>
</div>
</div>
{{else}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-allocations-list>
<h3 class="empty-message-headline" data-test-empty-allocations-list-headline>No Allocations</h3>
<p class="empty-message-body">No allocations have been placed.</p>
</div>
</div>
{{/if}}
{{/list-pagination}}
</div>
</div>

View file

@ -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 }) {

View file

@ -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),
}),
}),
});

View file

@ -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,
});
}
},
});

View file

@ -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"
]

View file

@ -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;

View file

@ -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;
}

View file

@ -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);
});
}
}

View file

@ -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();

View file

@ -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}}
<div class="inner-content">Inner content</div>
{{/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}}
<div class="inner-content">Inner content</div>
{{/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}}
<div class="inner-content">Inner content</div>
{{/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}}
<div class="inner-content">Inner content</div>
{{/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}}
<div class="inner-content">Inner content</div>
{{/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');
});
});
});

View file

@ -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'
);
});
});
});

View file

@ -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')}`
);
});
});
});
});

View file

@ -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');
});
});
});

View file

@ -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');
});
});
});
});

View file

@ -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`
);
});
});
});

View file

@ -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'
);
});
});
});

View file

@ -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'
);
});
});
});
});
});