Merge pull request #8931 from hashicorp/f-ui/children-jobs-quality-of-life
UI: Launched jobs quality of life improvements
This commit is contained in:
commit
6187e3116e
|
@ -1,6 +1,7 @@
|
||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
import { computed } from '@ember/object';
|
import { computed } from '@ember/object';
|
||||||
import { alias } from '@ember/object/computed';
|
import { alias, readOnly } from '@ember/object/computed';
|
||||||
import Sortable from 'nomad-ui/mixins/sortable';
|
import Sortable from 'nomad-ui/mixins/sortable';
|
||||||
import { classNames } from '@ember-decorators/component';
|
import { classNames } from '@ember-decorators/component';
|
||||||
import classic from 'ember-classic-decorator';
|
import classic from 'ember-classic-decorator';
|
||||||
|
@ -8,6 +9,8 @@ import classic from 'ember-classic-decorator';
|
||||||
@classic
|
@classic
|
||||||
@classNames('boxed-section')
|
@classNames('boxed-section')
|
||||||
export default class Children extends Component.extend(Sortable) {
|
export default class Children extends Component.extend(Sortable) {
|
||||||
|
@service userSettings;
|
||||||
|
|
||||||
job = null;
|
job = null;
|
||||||
|
|
||||||
// Provide a value that is bound to a query param
|
// Provide a value that is bound to a query param
|
||||||
|
@ -18,7 +21,7 @@ export default class Children extends Component.extend(Sortable) {
|
||||||
// Provide an action with access to the router
|
// Provide an action with access to the router
|
||||||
gotoJob() {}
|
gotoJob() {}
|
||||||
|
|
||||||
pageSize = 10;
|
@readOnly('userSettings.pageSize') pageSize;
|
||||||
|
|
||||||
@computed('job.taskGroups.[]')
|
@computed('job.taskGroups.[]')
|
||||||
get taskGroups() {
|
get taskGroups() {
|
||||||
|
@ -32,4 +35,10 @@ export default class Children extends Component.extend(Sortable) {
|
||||||
|
|
||||||
@alias('children') listToSort;
|
@alias('children') listToSort;
|
||||||
@alias('listSorted') sortedChildren;
|
@alias('listSorted') sortedChildren;
|
||||||
|
|
||||||
|
resetPagination() {
|
||||||
|
if (this.currentPage != null) {
|
||||||
|
this.set('currentPage', 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,10 @@ export default class JobRow extends Component {
|
||||||
|
|
||||||
job = null;
|
job = null;
|
||||||
|
|
||||||
|
// One of independent, parent, or child. Used to customize the template
|
||||||
|
// based on the relationship of this job to others.
|
||||||
|
context = 'independent';
|
||||||
|
|
||||||
onClick() {}
|
onClick() {}
|
||||||
|
|
||||||
click(event) {
|
click(event) {
|
||||||
|
|
|
@ -23,6 +23,7 @@ export default class Job extends Model {
|
||||||
@attr('string') statusDescription;
|
@attr('string') statusDescription;
|
||||||
@attr('number') createIndex;
|
@attr('number') createIndex;
|
||||||
@attr('number') modifyIndex;
|
@attr('number') modifyIndex;
|
||||||
|
@attr('date') submitTime;
|
||||||
|
|
||||||
// True when the job is the parent periodic or parameterized jobs
|
// True when the job is the parent periodic or parameterized jobs
|
||||||
// Instances of periodic or parameterized jobs are false for both properties
|
// Instances of periodic or parameterized jobs are false for both properties
|
||||||
|
|
|
@ -19,6 +19,18 @@ export default class IndexRoute extends Route.extend(WithWatchers) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupController(controller, model) {
|
||||||
|
// Parameterized and periodic detail pages, which list children jobs,
|
||||||
|
// should sort by submit time.
|
||||||
|
if (model && ['periodic', 'parameterized'].includes(model.templateType)) {
|
||||||
|
controller.setProperties({
|
||||||
|
sortProperty: 'submitTime',
|
||||||
|
sortDescending: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return super.setupController(...arguments);
|
||||||
|
}
|
||||||
|
|
||||||
@watchRecord('job') watch;
|
@watchRecord('job') watch;
|
||||||
@watchAll('job') watchAll;
|
@watchAll('job') watchAll;
|
||||||
@watchRecord('job-summary') watchSummary;
|
@watchRecord('job-summary') watchSummary;
|
||||||
|
|
|
@ -7,6 +7,8 @@ export default class JobSerializer extends ApplicationSerializer {
|
||||||
parameterized: 'ParameterizedJob',
|
parameterized: 'ParameterizedJob',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
separateNanos = ['SubmitTime'];
|
||||||
|
|
||||||
normalize(typeHash, hash) {
|
normalize(typeHash, hash) {
|
||||||
hash.NamespaceID = hash.Namespace;
|
hash.NamespaceID = hash.Namespace;
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
@class="with-foot" as |t|>
|
@class="with-foot" as |t|>
|
||||||
<t.head>
|
<t.head>
|
||||||
<t.sort-by @prop="name">Name</t.sort-by>
|
<t.sort-by @prop="name">Name</t.sort-by>
|
||||||
|
<t.sort-by @prop="submitTime">Submitted At</t.sort-by>
|
||||||
<t.sort-by @prop="status">Status</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="type">Type</t.sort-by>
|
||||||
<t.sort-by @prop="priority">Priority</t.sort-by>
|
<t.sort-by @prop="priority">Priority</t.sort-by>
|
||||||
|
@ -21,16 +22,17 @@
|
||||||
<th class="is-3">Summary</th>
|
<th class="is-3">Summary</th>
|
||||||
</t.head>
|
</t.head>
|
||||||
<t.body @key="model.id" as |row|>
|
<t.body @key="model.id" as |row|>
|
||||||
<JobRow data-test-job-row @job={{row.model}} @onClick={{action this.gotoJob row.model}} />
|
<JobRow data-test-job-row @job={{row.model}} @context="child" @onClick={{action this.gotoJob row.model}} />
|
||||||
</t.body>
|
</t.body>
|
||||||
</ListTable>
|
</ListTable>
|
||||||
<div class="table-foot">
|
<div class="table-foot">
|
||||||
|
<PageSizeSelect @onChange={{action this.resetPagination}} />
|
||||||
<nav class="pagination">
|
<nav class="pagination">
|
||||||
<div class="pagination-numbers">
|
<div class="pagination-numbers">
|
||||||
{{p.startsAt}}–{{p.endsAt}} of {{this.sortedChildren.length}}
|
{{p.startsAt}}–{{p.endsAt}} of {{this.sortedChildren.length}}
|
||||||
</div>
|
</div>
|
||||||
<p.prev @class="pagination-previous"> < </p.prev>
|
<p.prev @class="pagination-previous">{{x-icon "chevron-left"}}</p.prev>
|
||||||
<p.next @class="pagination-next"> > </p.next>
|
<p.next @class="pagination-next">{{x-icon "chevron-right"}}</p.next>
|
||||||
<ul class="pagination-list"></ul>
|
<ul class="pagination-list"></ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
<td data-test-job-name><LinkTo @route="jobs.job" @model={{this.job.plainId}} class="is-primary">{{this.job.name}}</LinkTo></td>
|
<td data-test-job-name><LinkTo @route="jobs.job" @model={{this.job.plainId}} class="is-primary">{{this.job.name}}</LinkTo></td>
|
||||||
|
{{#if (eq @context "child")}}
|
||||||
|
<td data-test-job-submit-time>{{format-month-ts this.job.submitTime}}</td>
|
||||||
|
{{/if}}
|
||||||
<td data-test-job-status>
|
<td data-test-job-status>
|
||||||
<span class="tag {{this.job.statusClass}}">{{this.job.status}}</span>
|
<span class="tag {{this.job.statusClass}}">{{this.job.status}}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import faker from 'nomad-ui/mirage/faker';
|
||||||
import { provide, pickOne } from '../utils';
|
import { provide, pickOne } from '../utils';
|
||||||
import { DATACENTERS } from '../common';
|
import { DATACENTERS } from '../common';
|
||||||
|
|
||||||
|
const REF_TIME = new Date();
|
||||||
const JOB_PREFIXES = provide(5, faker.hacker.abbreviation);
|
const JOB_PREFIXES = provide(5, faker.hacker.abbreviation);
|
||||||
const JOB_TYPES = ['service', 'batch', 'system'];
|
const JOB_TYPES = ['service', 'batch', 'system'];
|
||||||
const JOB_STATUSES = ['pending', 'running', 'dead'];
|
const JOB_STATUSES = ['pending', 'running', 'dead'];
|
||||||
|
@ -19,6 +20,7 @@ export default Factory.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
version: 1,
|
version: 1,
|
||||||
|
submitTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000,
|
||||||
|
|
||||||
// When provided, the resourceSpec will inform how many task groups to create
|
// When provided, the resourceSpec will inform how many task groups to create
|
||||||
// and how much of each resource that task group reserves.
|
// and how much of each resource that task group reserves.
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { module, test } from 'qunit';
|
||||||
import { setupApplicationTest } from 'ember-qunit';
|
import { setupApplicationTest } from 'ember-qunit';
|
||||||
import { selectChoose } from 'ember-power-select/test-support';
|
import { selectChoose } from 'ember-power-select/test-support';
|
||||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||||
|
import moment from 'moment';
|
||||||
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
|
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
|
||||||
import moduleForJob from 'nomad-ui/tests/helpers/module-for-job';
|
import moduleForJob from 'nomad-ui/tests/helpers/module-for-job';
|
||||||
import JobDetail from 'nomad-ui/tests/pages/jobs/detail';
|
import JobDetail from 'nomad-ui/tests/pages/jobs/detail';
|
||||||
|
@ -14,12 +15,42 @@ moduleForJob('Acceptance | job detail (batch)', 'allocations', () =>
|
||||||
moduleForJob('Acceptance | job detail (system)', 'allocations', () =>
|
moduleForJob('Acceptance | job detail (system)', 'allocations', () =>
|
||||||
server.create('job', { type: 'system', shallow: true })
|
server.create('job', { type: 'system', shallow: true })
|
||||||
);
|
);
|
||||||
moduleForJob('Acceptance | job detail (periodic)', 'children', () =>
|
moduleForJob(
|
||||||
server.create('job', 'periodic', { shallow: true })
|
'Acceptance | job detail (periodic)',
|
||||||
|
'children',
|
||||||
|
() => server.create('job', 'periodic', { shallow: true }),
|
||||||
|
{
|
||||||
|
'the default sort is submitTime descending': async function(job, assert) {
|
||||||
|
const mostRecentLaunch = server.db.jobs
|
||||||
|
.where({ parentId: job.id })
|
||||||
|
.sortBy('submitTime')
|
||||||
|
.reverse()[0];
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
JobDetail.jobs[0].submitTime,
|
||||||
|
moment(mostRecentLaunch.submitTime / 1000000).format('MMM DD HH:mm:ss ZZ')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
moduleForJob('Acceptance | job detail (parameterized)', 'children', () =>
|
moduleForJob(
|
||||||
server.create('job', 'parameterized', { shallow: true })
|
'Acceptance | job detail (parameterized)',
|
||||||
|
'children',
|
||||||
|
() => server.create('job', 'parameterized', { shallow: true }),
|
||||||
|
{
|
||||||
|
'the default sort is submitTime descending': async (job, assert) => {
|
||||||
|
const mostRecentLaunch = server.db.jobs
|
||||||
|
.where({ parentId: job.id })
|
||||||
|
.sortBy('submitTime')
|
||||||
|
.reverse()[0];
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
JobDetail.jobs[0].submitTime,
|
||||||
|
moment(mostRecentLaunch.submitTime / 1000000).format('MMM DD HH:mm:ss ZZ')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
moduleForJob('Acceptance | job detail (periodic child)', 'allocations', () => {
|
moduleForJob('Acceptance | job detail (periodic child)', 'allocations', () => {
|
||||||
|
|
|
@ -93,7 +93,7 @@ export default function moduleForJob(title, context, jobFactory, additionalTests
|
||||||
|
|
||||||
for (var testName in additionalTests) {
|
for (var testName in additionalTests) {
|
||||||
test(testName, async function(assert) {
|
test(testName, async function(assert) {
|
||||||
await additionalTests[testName](job, assert);
|
await additionalTests[testName].call(this, job, assert);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -64,6 +64,9 @@ module('Integration | Component | job-page/parts/children', function(hooks) {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('eventually paginates', async function(assert) {
|
test('eventually paginates', async function(assert) {
|
||||||
|
const pageSize = 10;
|
||||||
|
window.localStorage.nomadPageSize = pageSize;
|
||||||
|
|
||||||
this.server.create('job', 'periodic', {
|
this.server.create('job', 'periodic', {
|
||||||
id: 'parent',
|
id: 'parent',
|
||||||
childrenCount: 11,
|
childrenCount: 11,
|
||||||
|
@ -86,8 +89,8 @@ module('Integration | Component | job-page/parts/children', function(hooks) {
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const childrenCount = parent.get('children.length');
|
const childrenCount = parent.get('children.length');
|
||||||
assert.ok(childrenCount > 10, 'Parent has more children than one page size');
|
assert.ok(childrenCount > pageSize, 'Parent has more children than one page size');
|
||||||
assert.equal(findAll('[data-test-job-name]').length, 10, 'Table length maxes out at 10');
|
assert.equal(findAll('[data-test-job-name]').length, pageSize, 'Table length maxes out at 10');
|
||||||
assert.ok(find('.pagination-next'), 'Next button is rendered');
|
assert.ok(find('.pagination-next'), 'Next button is rendered');
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
|
@ -2,7 +2,11 @@ import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'ember-qunit';
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
import { click, find, findAll, render } from '@ember/test-helpers';
|
import { click, find, findAll, render } from '@ember/test-helpers';
|
||||||
import hbs from 'htmlbars-inline-precompile';
|
import hbs from 'htmlbars-inline-precompile';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { create, collection } from 'ember-cli-page-object';
|
||||||
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
|
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
|
||||||
|
import pageSizeSelect from 'nomad-ui/tests/acceptance/behaviors/page-size-select';
|
||||||
|
import pageSizeSelectPageObject from 'nomad-ui/tests/pages/components/page-size-select';
|
||||||
import {
|
import {
|
||||||
jobURL,
|
jobURL,
|
||||||
stopJob,
|
stopJob,
|
||||||
|
@ -13,6 +17,13 @@ import {
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
|
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
|
||||||
|
|
||||||
|
// A minimum viable page object to use with the pageSizeSelect behavior
|
||||||
|
const PeriodicJobPage = create({
|
||||||
|
pageSize: 25,
|
||||||
|
jobs: collection('[data-test-job-row]'),
|
||||||
|
pageSizeSelect: pageSizeSelectPageObject(),
|
||||||
|
});
|
||||||
|
|
||||||
module('Integration | Component | job-page/periodic', function(hooks) {
|
module('Integration | Component | job-page/periodic', function(hooks) {
|
||||||
setupRenderingTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
@ -195,4 +206,45 @@ module('Integration | Component | job-page/periodic', function(hooks) {
|
||||||
await startJob();
|
await startJob();
|
||||||
expectError(assert, 'Could Not Start Job');
|
expectError(assert, 'Could Not Start Job');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Each job row includes the submitted time', async function(assert) {
|
||||||
|
this.server.create('job', 'periodic', {
|
||||||
|
id: 'parent',
|
||||||
|
childrenCount: 1,
|
||||||
|
createAllocations: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.store.findAll('job');
|
||||||
|
|
||||||
|
const job = this.store.peekAll('job').findBy('plainId', 'parent');
|
||||||
|
|
||||||
|
this.setProperties(commonProperties(job));
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
find('[data-test-job-submit-time]').textContent,
|
||||||
|
moment(job.get('children.firstObject.submitTime')).format('MMM DD HH:mm:ss ZZ'),
|
||||||
|
'The new periodic job launch is in the children list'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
pageSizeSelect({
|
||||||
|
resourceName: 'job',
|
||||||
|
pageObject: PeriodicJobPage,
|
||||||
|
pageObjectList: PeriodicJobPage.jobs,
|
||||||
|
async setup() {
|
||||||
|
this.server.create('job', 'periodic', {
|
||||||
|
id: 'parent',
|
||||||
|
childrenCount: PeriodicJobPage.pageSize,
|
||||||
|
createAllocations: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.store.findAll('job');
|
||||||
|
|
||||||
|
const job = this.store.peekAll('job').findBy('plainId', 'parent');
|
||||||
|
|
||||||
|
this.setProperties(commonProperties(job));
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -51,6 +51,20 @@ export default create({
|
||||||
|
|
||||||
viewAllAllocations: text('[data-test-view-all-allocations]'),
|
viewAllAllocations: text('[data-test-view-all-allocations]'),
|
||||||
|
|
||||||
|
jobs: collection('[data-test-job-row]', {
|
||||||
|
id: attribute('data-test-job-row'),
|
||||||
|
name: text('[data-test-job-name]'),
|
||||||
|
link: attribute('href', '[data-test-job-name] a'),
|
||||||
|
submitTime: text('[data-test-job-submit-time]'),
|
||||||
|
status: text('[data-test-job-status]'),
|
||||||
|
type: text('[data-test-job-type]'),
|
||||||
|
priority: text('[data-test-job-priority]'),
|
||||||
|
taskGroups: text('[data-test-job-task-groups]'),
|
||||||
|
|
||||||
|
clickRow: clickable(),
|
||||||
|
clickName: clickable('[data-test-job-name] a'),
|
||||||
|
}),
|
||||||
|
|
||||||
error: {
|
error: {
|
||||||
isPresent: isPresent('[data-test-error]'),
|
isPresent: isPresent('[data-test-error]'),
|
||||||
title: text('[data-test-error-title]'),
|
title: text('[data-test-error-title]'),
|
||||||
|
|
Loading…
Reference in New Issue