diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js index 5d19a7da5..d4f4d42a0 100644 --- a/ui/.eslintrc.js +++ b/ui/.eslintrc.js @@ -10,6 +10,9 @@ module.exports = { parserOptions: { ecmaVersion: 2017, sourceType: 'module', + ecmaFeatures: { + experimentalObjectRestSpread: true, + }, }, rules: { indent: ['error', 2, { SwitchCase: 1 }], diff --git a/ui/app/components/job-page/parts/recent-allocations.js b/ui/app/components/job-page/parts/recent-allocations.js new file mode 100644 index 000000000..c50ff7f02 --- /dev/null +++ b/ui/app/components/job-page/parts/recent-allocations.js @@ -0,0 +1,24 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import PromiseArray from 'nomad-ui/utils/classes/promise-array'; + +export default Component.extend({ + sortProperty: 'modifyIndex', + sortDescending: true, + sortedAllocations: computed('job.allocations.@each.modifyIndex', function() { + return new PromiseArray({ + promise: this.get('job.allocations').then(allocations => + allocations + .sortBy('modifyIndex') + .reverse() + .slice(0, 5) + ), + }); + }), + + actions: { + gotoAllocation(allocation) { + this.transitionToRoute('allocations.allocation', allocation); + }, + }, +}); diff --git a/ui/app/controllers/jobs/job/allocations.js b/ui/app/controllers/jobs/job/allocations.js new file mode 100644 index 000000000..1ec7a40a2 --- /dev/null +++ b/ui/app/controllers/jobs/job/allocations.js @@ -0,0 +1,39 @@ +import { alias } from '@ember/object/computed'; +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; +import Sortable from 'nomad-ui/mixins/sortable'; +import Searchable from 'nomad-ui/mixins/searchable'; +import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; + +export default Controller.extend(Sortable, Searchable, WithNamespaceResetting, { + queryParams: { + currentPage: 'page', + searchTerm: 'search', + sortProperty: 'sort', + sortDescending: 'desc', + }, + + currentPage: 1, + pageSize: 25, + + sortProperty: 'modifyIndex', + sortDescending: true, + + job: alias('model'), + + searchProps: computed(() => ['shortId', 'name', 'taskGroupName']), + + allocations: computed('model.allocations.[]', function() { + return this.get('model.allocations') || []; + }), + + listToSort: alias('allocations'), + listToSearch: alias('listSorted'), + sortedAllocations: alias('listSearched'), + + actions: { + gotoAllocation(allocation) { + this.transitionToRoute('allocations.allocation', allocation); + }, + }, +}); diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js index b65232ce2..f98e3fc22 100644 --- a/ui/app/models/allocation.js +++ b/ui/app/models/allocation.js @@ -25,9 +25,13 @@ export default Model.extend({ name: attr('string'), taskGroupName: attr('string'), resources: fragment('resources'), + jobVersion: attr('number'), + modifyIndex: attr('number'), modifyTime: attr('date'), - jobVersion: attr('number'), + + createIndex: attr('number'), + createTime: attr('date'), clientStatus: attr('string'), desiredStatus: attr('string'), diff --git a/ui/app/router.js b/ui/app/router.js index ce0a1bc74..39446ffa9 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -14,6 +14,7 @@ Router.map(function() { this.route('versions'); this.route('deployments'); this.route('evaluations'); + this.route('allocations'); }); }); diff --git a/ui/app/routes/jobs/job/allocations.js b/ui/app/routes/jobs/job/allocations.js new file mode 100644 index 000000000..da59c95a4 --- /dev/null +++ b/ui/app/routes/jobs/job/allocations.js @@ -0,0 +1,19 @@ +import Route from '@ember/routing/route'; +import { collect } from '@ember/object/computed'; +import { watchRelationship } from 'nomad-ui/utils/properties/watch'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; + +export default Route.extend(WithWatchers, { + model() { + const job = this.modelFor('jobs.job'); + return job.get('allocations').then(() => job); + }, + + startWatchers(controller, model) { + controller.set('watchAllocations', this.get('watchAllocations').perform(model)); + }, + + watchAllocations: watchRelationship('allocations'), + + watchers: collect('watchAllocations'), +}); diff --git a/ui/app/routes/jobs/job/index.js b/ui/app/routes/jobs/job/index.js index 58bf30beb..30423ce9d 100644 --- a/ui/app/routes/jobs/job/index.js +++ b/ui/app/routes/jobs/job/index.js @@ -11,6 +11,7 @@ export default Route.extend(WithWatchers, { controller.set('watchers', { model: this.get('watch').perform(model), summary: this.get('watchSummary').perform(model.get('summary')), + allocations: this.get('watchAllocations').perform(model), evaluations: this.get('watchEvaluations').perform(model), latestDeployment: model.get('supportsDeployments') && this.get('watchLatestDeployment').perform(model), @@ -21,6 +22,7 @@ export default Route.extend(WithWatchers, { watch: watchRecord('job'), watchAll: watchAll('job'), watchSummary: watchRecord('job-summary'), + watchAllocations: watchRelationship('allocations'), watchEvaluations: watchRelationship('evaluations'), watchLatestDeployment: watchRelationship('latestDeployment'), @@ -28,6 +30,7 @@ export default Route.extend(WithWatchers, { 'watch', 'watchAll', 'watchSummary', + 'watchAllocations', 'watchEvaluations', 'watchLatestDeployment' ), diff --git a/ui/app/serializers/allocation.js b/ui/app/serializers/allocation.js index 7cbd76a18..2aa91d39f 100644 --- a/ui/app/serializers/allocation.js +++ b/ui/app/serializers/allocation.js @@ -34,6 +34,9 @@ export default ApplicationSerializer.extend({ hash.ModifyTimeNanos = hash.ModifyTime % 1000000; hash.ModifyTime = Math.floor(hash.ModifyTime / 1000000); + hash.CreateTimeNanos = hash.CreateTime % 1000000; + hash.CreateTime = Math.floor(hash.CreateTime / 1000000); + hash.RescheduleEvents = (hash.RescheduleTracker || {}).Events; // API returns empty strings instead of null diff --git a/ui/app/styles/charts.scss b/ui/app/styles/charts.scss index e70738650..3db77a93b 100644 --- a/ui/app/styles/charts.scss +++ b/ui/app/styles/charts.scss @@ -1,11 +1,15 @@ -@import "./charts/distribution-bar"; -@import "./charts/tooltip"; -@import "./charts/colors"; +@import './charts/distribution-bar'; +@import './charts/tooltip'; +@import './charts/colors'; .inline-chart { height: 1.5rem; display: flex; align-items: center; + + &.is-small { + width: 50px; + } } // Patterns are templates referenced by other SVG fill properties. diff --git a/ui/app/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs index b6e2bcf9f..3b84f643e 100644 --- a/ui/app/templates/clients/client.hbs +++ b/ui/app/templates/clients/client.hbs @@ -100,7 +100,7 @@ {{#t.sort-by prop="shortId"}}ID{{/t.sort-by}} {{#t.sort-by prop="modifyIndex" title="Modify Index"}}Modified{{/t.sort-by}} - {{#t.sort-by prop="name"}}Name{{/t.sort-by}} + {{#t.sort-by prop="createIndex" title="Create Index"}}Created{{/t.sort-by}} {{#t.sort-by prop="statusIndex"}}Status{{/t.sort-by}} {{#t.sort-by prop="job.name"}}Job{{/t.sort-by}} {{#t.sort-by prop="jobVersion"}}Version{{/t.sort-by}} diff --git a/ui/app/templates/components/allocation-row.hbs b/ui/app/templates/components/allocation-row.hbs index 1da565ff5..fd16e5bc9 100644 --- a/ui/app/templates/components/allocation-row.hbs +++ b/ui/app/templates/components/allocation-row.hbs @@ -15,12 +15,19 @@ {{allocation.shortId}} {{/link-to}} -{{moment-format allocation.modifyTime "MM/DD HH:mm:ss"}} -{{allocation.name}} +{{#if (eq context "job")}} + + {{#link-to "jobs.job.task-group" allocation.job allocation.taskGroupName (query-params jobNamespace=allocation.job.namespace.id)}} + {{allocation.taskGroupName}} + {{/link-to}} + +{{/if}} +{{moment-format allocation.createTime "MM/DD HH:mm:ss"}} +{{moment-from-now allocation.modifyTime}} {{allocation.clientStatus}} -{{#if (eq context "job")}} +{{#if (or (eq context "taskGroup") (eq context "job"))}} {{allocation.jobVersion}} {{#link-to "clients.client" allocation.node}}{{allocation.node.shortId}}{{/link-to}} {{else if (eq context "node")}} @@ -32,9 +39,9 @@ / {{allocation.taskGroup.name}} {{/if}} - {{allocation.jobVersion}} + {{allocation.jobVersion}} {{/if}} - + {{#if (and (not stats) fetchStats.isRunning)}} ... {{else if (not allocation)}} @@ -44,7 +51,7 @@ {{x-icon "warning" class="is-warning"}} {{else}} -
+
{{/if}} - + {{#if (and (not stats) fetchStats.isRunning)}} ... {{else if (not allocation)}} {{! nothing when there's no allocation}} {{else if statsError}} - + {{x-icon "warning" class="is-warning"}} {{else}} diff --git a/ui/app/templates/components/job-deployment/deployment-allocations.hbs b/ui/app/templates/components/job-deployment/deployment-allocations.hbs index 242427238..3f7dbf895 100644 --- a/ui/app/templates/components/job-deployment/deployment-allocations.hbs +++ b/ui/app/templates/components/job-deployment/deployment-allocations.hbs @@ -9,8 +9,9 @@ {{#t.head}} ID + Task Group + Created Modified - Name Status Version Node diff --git a/ui/app/templates/components/job-page/batch.hbs b/ui/app/templates/components/job-page/batch.hbs index f11a77849..dcd1ed7be 100644 --- a/ui/app/templates/components/job-page/batch.hbs +++ b/ui/app/templates/components/job-page/batch.hbs @@ -22,4 +22,6 @@ sortProperty=sortProperty sortDescending=sortDescending gotoTaskGroup=gotoTaskGroup}} + + {{job-page/parts/recent-allocations job=job}} {{/job-page/parts/body}} diff --git a/ui/app/templates/components/job-page/parameterized-child.hbs b/ui/app/templates/components/job-page/parameterized-child.hbs index a5cbfb846..b20362263 100644 --- a/ui/app/templates/components/job-page/parameterized-child.hbs +++ b/ui/app/templates/components/job-page/parameterized-child.hbs @@ -29,6 +29,8 @@ sortDescending=sortDescending gotoTaskGroup=gotoTaskGroup}} + {{job-page/parts/recent-allocations job=job}} +
Payload
diff --git a/ui/app/templates/components/job-page/parts/recent-allocations.hbs b/ui/app/templates/components/job-page/parts/recent-allocations.hbs new file mode 100644 index 000000000..4a0c22965 --- /dev/null +++ b/ui/app/templates/components/job-page/parts/recent-allocations.hbs @@ -0,0 +1,46 @@ +
+
+ Recent Allocations +
+
+ {{#if job.allocations.length}} + {{#list-table + source=sortedAllocations + sortProperty=sortProperty + sortDescending=sortDescending + class="with-foot" as |t|}} + {{#t.head}} + + ID + Task Group + Created + Modified + Status + Version + Client + CPU + Memory + {{/t.head}} + {{#t.body as |row|}} + {{allocation-row + data-test-allocation=row.model.id + allocation=row.model + context="job" + onClick=(action "gotoAllocation" row.model)}} + {{/t.body}} + {{/list-table}} + {{else}} +
+

No Allocations

+

No allocations have been placed.

+
+ {{/if}} +
+ {{#if job.allocations.length}} +
+

{{#link-to "jobs.job.allocations" job}} + View all {{job.allocations.length}} {{pluralize "allocation" job.allocations.length}} + {{/link-to}}

+
+ {{/if}} +
diff --git a/ui/app/templates/components/job-page/periodic-child.hbs b/ui/app/templates/components/job-page/periodic-child.hbs index 2accceb84..698397503 100644 --- a/ui/app/templates/components/job-page/periodic-child.hbs +++ b/ui/app/templates/components/job-page/periodic-child.hbs @@ -28,4 +28,6 @@ sortProperty=sortProperty sortDescending=sortDescending gotoTaskGroup=gotoTaskGroup}} + + {{job-page/parts/recent-allocations job=job}} {{/job-page/parts/body}} diff --git a/ui/app/templates/components/job-page/service.hbs b/ui/app/templates/components/job-page/service.hbs index da7af5d5d..6a921beb8 100644 --- a/ui/app/templates/components/job-page/service.hbs +++ b/ui/app/templates/components/job-page/service.hbs @@ -24,4 +24,6 @@ sortProperty=sortProperty sortDescending=sortDescending gotoTaskGroup=gotoTaskGroup}} + + {{job-page/parts/recent-allocations job=job}} {{/job-page/parts/body}} diff --git a/ui/app/templates/components/job-page/system.hbs b/ui/app/templates/components/job-page/system.hbs index f11a77849..dcd1ed7be 100644 --- a/ui/app/templates/components/job-page/system.hbs +++ b/ui/app/templates/components/job-page/system.hbs @@ -22,4 +22,6 @@ sortProperty=sortProperty sortDescending=sortDescending gotoTaskGroup=gotoTaskGroup}} + + {{job-page/parts/recent-allocations job=job}} {{/job-page/parts/body}} diff --git a/ui/app/templates/jobs/job/allocations.hbs b/ui/app/templates/jobs/job/allocations.hbs new file mode 100644 index 000000000..080919b6a --- /dev/null +++ b/ui/app/templates/jobs/job/allocations.hbs @@ -0,0 +1,71 @@ +{{#gutter-menu class="page-body" onNamespaceChange=(action "gotoJobs")}} + {{partial "jobs/job/subnav"}} +
+ {{#if allocations.length}} +
+
+ {{search-box + data-test-allocations-search + searchTerm=(mut searchTerm) + placeholder="Search allocations..."}} +
+
+ {{#list-pagination + source=sortedAllocations + size=pageSize + page=currentPage + class="allocations" as |p|}} + {{#list-table + source=p.list + sortProperty=sortProperty + sortDescending=sortDescending + class="with-foot" as |t|}} + {{#t.head}} + + {{#t.sort-by prop="shortId"}}ID{{/t.sort-by}} + {{#t.sort-by prop="taskGroupName"}}Task Group{{/t.sort-by}} + {{#t.sort-by prop="createIndex" title="Create Index"}}Created{{/t.sort-by}} + {{#t.sort-by prop="modifyIndex" title="Modify Index"}}Modified{{/t.sort-by}} + {{#t.sort-by prop="statusIndex"}}Status{{/t.sort-by}} + {{#t.sort-by prop="jobVersion"}}Version{{/t.sort-by}} + {{#t.sort-by prop="node.shortId"}}Client{{/t.sort-by}} + CPU + Memory + {{/t.head}} + {{#t.body as |row|}} + {{allocation-row + data-test-allocation=row.model.id + allocation=row.model + context="job" + onClick=(action "gotoAllocation" row.model)}} + {{/t.body}} + {{/list-table}} +
+ +
+ {{else}} +
+
+

No Matches

+

No allocations match the term {{searchTerm}}

+
+
+ {{/list-pagination}} + {{else}} +
+
+

No Allocations

+

No allocations have been placed.

+
+
+ {{/if}} +
+{{/gutter-menu}} + diff --git a/ui/app/templates/jobs/job/evaluations.hbs b/ui/app/templates/jobs/job/evaluations.hbs index 212b6ea42..0c6e18290 100644 --- a/ui/app/templates/jobs/job/evaluations.hbs +++ b/ui/app/templates/jobs/job/evaluations.hbs @@ -1,46 +1,39 @@ {{partial "jobs/job/subnav"}}
-
-
- Evaluations + {{#if sortedEvaluations.length}} + {{#list-table + source=sortedEvaluations + sortProperty=sortProperty + sortDescending=sortDescending as |t|}} + {{#t.head}} + ID + {{#t.sort-by prop="priority"}}Priority{{/t.sort-by}} + {{#t.sort-by prop="triggeredBy"}}Triggered By{{/t.sort-by}} + {{#t.sort-by prop="status"}}Status{{/t.sort-by}} + {{#t.sort-by prop="hasPlacementFailures"}}Placement Failures{{/t.sort-by}} + {{/t.head}} + {{#t.body as |row|}} + + {{row.model.shortId}} + {{row.model.priority}} + {{row.model.triggeredBy}} + {{row.model.status}} + + {{#if (eq row.model.status "blocked")}} + N/A - In Progress + {{else if row.model.hasPlacementFailures}} + True + {{else}} + False + {{/if}} + + + {{/t.body}} + {{/list-table}} + {{else}} +
+

No Evaluations

+

This is most likely due to garbage collection.

-
- {{#if sortedEvaluations.length}} - {{#list-table - source=sortedEvaluations - sortProperty=sortProperty - sortDescending=sortDescending as |t|}} - {{#t.head}} - ID - {{#t.sort-by prop="priority"}}Priority{{/t.sort-by}} - {{#t.sort-by prop="triggeredBy"}}Triggered By{{/t.sort-by}} - {{#t.sort-by prop="status"}}Status{{/t.sort-by}} - {{#t.sort-by prop="hasPlacementFailures"}}Placement Failures{{/t.sort-by}} - {{/t.head}} - {{#t.body as |row|}} - - {{row.model.shortId}} - {{row.model.priority}} - {{row.model.triggeredBy}} - {{row.model.status}} - - {{#if (eq row.model.status "blocked")}} - N/A - In Progress - {{else if row.model.hasPlacementFailures}} - True - {{else}} - False - {{/if}} - - - {{/t.body}} - {{/list-table}} - {{else}} -
-

No Evaluations

-

This is most likely due to garbage collection.

-
- {{/if}} -
-
+ {{/if}}
diff --git a/ui/app/templates/jobs/job/subnav.hbs b/ui/app/templates/jobs/job/subnav.hbs index 257072649..fa3738205 100644 --- a/ui/app/templates/jobs/job/subnav.hbs +++ b/ui/app/templates/jobs/job/subnav.hbs @@ -6,6 +6,7 @@ {{#if job.supportsDeployments}}
  • {{#link-to "jobs.job.deployments" job activeClass="is-active"}}Deployments{{/link-to}}
  • {{/if}} +
  • {{#link-to "jobs.job.allocations" job activeClass="is-active"}}Allocations{{/link-to}}
  • {{#link-to "jobs.job.evaluations" job activeClass="is-active"}}Evaluations{{/link-to}}
  • diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index 0bf01cb84..1c20a03e7 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -63,8 +63,8 @@ {{#t.head}} {{#t.sort-by prop="shortId"}}ID{{/t.sort-by}} + {{#t.sort-by prop="createIndex" title="Create Index"}}Created{{/t.sort-by}} {{#t.sort-by prop="modifyIndex" title="Modify Index"}}Modified{{/t.sort-by}} - {{#t.sort-by prop="name"}}Name{{/t.sort-by}} {{#t.sort-by prop="statusIndex"}}Status{{/t.sort-by}} {{#t.sort-by prop="jobVersion"}}Version{{/t.sort-by}} {{#t.sort-by prop="node.shortId"}}Client{{/t.sort-by}} @@ -72,7 +72,7 @@ Memory {{/t.head}} {{#t.body as |row|}} - {{allocation-row data-test-allocation=row.model.id allocation=row.model context="job" onClick=(action "gotoAllocation" row.model)}} + {{allocation-row data-test-allocation=row.model.id allocation=row.model context="taskGroup" onClick=(action "gotoAllocation" row.model)}} {{/t.body}} {{/list-table}}
    diff --git a/ui/app/utils/classes/promise-array.js b/ui/app/utils/classes/promise-array.js new file mode 100644 index 000000000..0fd302aec --- /dev/null +++ b/ui/app/utils/classes/promise-array.js @@ -0,0 +1,4 @@ +import ArrayProxy from '@ember/array/proxy'; +import PromiseProxyMixin from '@ember/object/promise-proxy-mixin'; + +export default ArrayProxy.extend(PromiseProxyMixin); diff --git a/ui/ember-cli-build.js b/ui/ember-cli-build.js index 8e879ba07..121823950 100644 --- a/ui/ember-cli-build.js +++ b/ui/ember-cli-build.js @@ -19,6 +19,9 @@ module.exports = function(defaults) { `${defaults.project.pkg.name}/templates/components/freestyle/**/*`, ], }, + babel: { + plugins: ['transform-object-rest-spread'], + }, }); // Use `app.import` to add additional libraries to the generated diff --git a/ui/mirage/factories/allocation.js b/ui/mirage/factories/allocation.js index 3336a9c15..3eeac22ca 100644 --- a/ui/mirage/factories/allocation.js +++ b/ui/mirage/factories/allocation.js @@ -11,11 +11,16 @@ const REF_TIME = new Date(); export default Factory.extend({ id: i => (i >= 100 ? `${UUIDS[i % 100]}-${i}` : UUIDS[i]), - modifyIndex: () => faker.random.number({ min: 10, max: 2000 }), jobVersion: () => faker.random.number(10), + modifyIndex: () => faker.random.number({ min: 10, max: 2000 }), modifyTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000, + createIndex: () => faker.random.number({ min: 10, max: 2000 }), + createTime() { + return faker.date.past(2 / 365, new Date(this.modifyTime / 1000000)) * 1000000; + }, + namespace: null, clientStatus: faker.list.random(...CLIENT_STATUSES), diff --git a/ui/package.json b/ui/package.json index 6a33c98c4..b8c4f5839 100644 --- a/ui/package.json +++ b/ui/package.json @@ -24,6 +24,7 @@ ] }, "devDependencies": { + "babel-plugin-transform-object-rest-spread": "^6.26.0", "broccoli-asset-rev": "^2.4.5", "bulma": "0.6.1", "core-js": "^2.4.1", diff --git a/ui/tests/acceptance/client-detail-test.js b/ui/tests/acceptance/client-detail-test.js index cf260833d..1c766d429 100644 --- a/ui/tests/acceptance/client-detail-test.js +++ b/ui/tests/acceptance/client-detail-test.js @@ -128,13 +128,17 @@ test('each allocation should have high-level details for the allocation', functi andThen(() => { const allocationRow = ClientDetail.allocations.objectAt(0); - assert.equal(allocationRow.id, allocation.id.split('-')[0], 'Allocation short ID'); + assert.equal(allocationRow.shortId, allocation.id.split('-')[0], 'Allocation short ID'); + assert.equal( + allocationRow.createTime, + moment(allocation.createTime / 1000000).format('MM/DD HH:mm:ss'), + 'Allocation create time' + ); assert.equal( allocationRow.modifyTime, - moment(allocation.modifyTime / 1000000).format('MM/DD HH:mm:ss'), + moment(allocation.modifyTime / 1000000).fromNow(), 'Allocation modify time' ); - assert.equal(allocationRow.name, allocation.name, 'Allocation name'); assert.equal(allocationRow.status, allocation.clientStatus, 'Client status'); assert.equal(allocationRow.job, server.db.jobs.find(allocation.jobId).name, 'Job name'); assert.ok(allocationRow.taskGroup, 'Task group name'); diff --git a/ui/tests/acceptance/job-allocations-test.js b/ui/tests/acceptance/job-allocations-test.js new file mode 100644 index 000000000..202104599 --- /dev/null +++ b/ui/tests/acceptance/job-allocations-test.js @@ -0,0 +1,110 @@ +import { test } from 'qunit'; +import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance'; +import Allocations from 'nomad-ui/tests/pages/jobs/job/allocations'; + +let job; +let allocations; + +const makeSearchAllocations = server => { + Array(10) + .fill(null) + .map((_, index) => { + server.create('allocation', { + id: index < 5 ? `ffffff-dddddd-${index}` : `111111-222222-${index}`, + }); + }); +}; + +moduleForAcceptance('Acceptance | job allocations', { + beforeEach() { + server.create('node'); + + job = server.create('job', { noFailedPlacements: true, createAllocations: false }); + }, +}); + +test('lists all allocations for the job', function(assert) { + server.createList('allocation', Allocations.pageSize - 1); + allocations = server.schema.allocations.where({ jobId: job.id }).models; + + Allocations.visit({ id: job.id }); + + andThen(() => { + assert.equal( + Allocations.allocations.length, + Allocations.pageSize - 1, + 'Allocations are shown in a table' + ); + + const sortedAllocations = allocations.sortBy('modifyIndex').reverse(); + + Allocations.allocations.forEach((allocation, index) => { + const shortId = sortedAllocations[index].id.split('-')[0]; + assert.equal(allocation.shortId, shortId, `Allocation ${index} is ${shortId}`); + }); + }); +}); + +test('allocations table is sortable', function(assert) { + server.createList('allocation', Allocations.pageSize - 1); + allocations = server.schema.allocations.where({ jobId: job.id }).models; + + Allocations.visit({ id: job.id }); + + andThen(() => { + Allocations.sortBy('taskGroupName'); + + andThen(() => { + assert.equal( + currentURL(), + `/jobs/${job.id}/allocations?sort=taskGroupName`, + 'the URL persists the sort parameter' + ); + const sortedAllocations = allocations.sortBy('taskGroup').reverse(); + Allocations.allocations.forEach((allocation, index) => { + const shortId = sortedAllocations[index].id.split('-')[0]; + assert.equal( + allocation.shortId, + shortId, + `Allocation ${index} is ${shortId} with task group ${sortedAllocations[index].taskGroup}` + ); + }); + }); + }); +}); + +test('allocations table is searchable', function(assert) { + makeSearchAllocations(server); + + allocations = server.schema.allocations.where({ jobId: job.id }).models; + Allocations.visit({ id: job.id }); + + andThen(() => { + Allocations.search('ffffff'); + }); + + andThen(() => { + assert.equal(Allocations.allocations.length, 5, 'List is filtered by search term'); + }); +}); + +test('when a search yields no results, the search box remains', function(assert) { + makeSearchAllocations(server); + + allocations = server.schema.allocations.where({ jobId: job.id }).models; + Allocations.visit({ id: job.id }); + + andThen(() => { + Allocations.search('^nothing will ever match this long regex$'); + }); + + andThen(() => { + assert.equal( + Allocations.emptyState.headline, + 'No Matches', + 'List is empty and the empty state is about search' + ); + + assert.ok(Allocations.hasSearchBox, 'Search box is still shown'); + }); +}); diff --git a/ui/tests/acceptance/job-deployments-test.js b/ui/tests/acceptance/job-deployments-test.js index 6408df55f..407317b37 100644 --- a/ui/tests/acceptance/job-deployments-test.js +++ b/ui/tests/acceptance/job-deployments-test.js @@ -232,7 +232,7 @@ test('when open, a deployment shows a list of all allocations for the deployment const allocation = allocations[0]; const allocationRow = deploymentRow.allocations.objectAt(0); - assert.equal(allocationRow.id, allocation.id.split('-')[0], 'Allocation is as expected'); + assert.equal(allocationRow.shortId, allocation.id.split('-')[0], 'Allocation is as expected'); }); }); }); diff --git a/ui/tests/acceptance/task-group-detail-test.js b/ui/tests/acceptance/task-group-detail-test.js index 2466e6da9..cccdd67d7 100644 --- a/ui/tests/acceptance/task-group-detail-test.js +++ b/ui/tests/acceptance/task-group-detail-test.js @@ -143,12 +143,16 @@ test('each allocation should show basic information about the allocation', funct andThen(() => { assert.equal(allocationRow.shortId, allocation.id.split('-')[0], 'Allocation short id'); + assert.equal( + allocationRow.createTime, + moment(allocation.createTime / 1000000).format('MM/DD HH:mm:ss'), + 'Allocation create time' + ); assert.equal( allocationRow.modifyTime, - moment(allocation.modifyTime / 1000000).format('MM/DD HH:mm:ss'), + moment(allocation.modifyTime / 1000000).fromNow(), 'Allocation modify time' ); - assert.equal(allocationRow.name, allocation.name, 'Allocation name'); assert.equal(allocationRow.status, allocation.clientStatus, 'Client status'); assert.equal(allocationRow.jobVersion, allocation.jobVersion, 'Job Version'); assert.equal( diff --git a/ui/tests/integration/job-page/service-test.js b/ui/tests/integration/job-page/service-test.js index 5b9bf69f3..aef698a14 100644 --- a/ui/tests/integration/job-page/service-test.js +++ b/ui/tests/integration/job-page/service-test.js @@ -1,19 +1,23 @@ import { getOwner } from '@ember/application'; +import { assign } from '@ember/polyfills'; import { test, moduleForComponent } from 'ember-qunit'; import wait from 'ember-test-helpers/wait'; import hbs from 'htmlbars-inline-precompile'; import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; import { stopJob, expectStopError, expectDeleteRequest } from './helpers'; +import Job from 'nomad-ui/tests/pages/jobs/detail'; moduleForComponent('job-page/service', 'Integration | Component | job-page/service', { integration: true, beforeEach() { + Job.setContext(this); window.localStorage.clear(); this.store = getOwner(this).lookup('service:store'); this.server = startMirage(); this.server.create('namespace'); }, afterEach() { + Job.removeContext(); this.server.shutdown(); window.localStorage.clear(); }, @@ -36,12 +40,18 @@ const commonProperties = job => ({ gotoJob() {}, }); -const makeMirageJob = server => - server.create('job', { - type: 'service', - createAllocations: false, - status: 'running', - }); +const makeMirageJob = (server, props = {}) => + server.create( + 'job', + assign( + { + type: 'service', + createAllocations: false, + status: 'running', + }, + props + ) + ); test('Stopping a job sends a delete request for the job', function(assert) { let job; @@ -80,3 +90,78 @@ test('Stopping a job without proper permissions shows an error message', functio .then(stopJob) .then(expectStopError(assert)); }); + +test('Recent allocations shows allocations in the job context', function(assert) { + let job; + + this.server.create('node'); + const mirageJob = makeMirageJob(this.server, { createAllocations: true }); + this.store.findAll('job'); + + return wait() + .then(() => { + job = this.store.peekAll('job').findBy('plainId', mirageJob.id); + + this.setProperties(commonProperties(job)); + this.render(commonTemplate); + + return wait(); + }) + .then(() => { + const allocation = this.server.db.allocations.sortBy('modifyIndex').reverse()[0]; + const allocationRow = Job.allocations.objectAt(0); + + assert.equal(allocationRow.shortId, allocation.id.split('-')[0], 'ID'); + assert.equal(allocationRow.taskGroup, allocation.taskGroup, 'Task Group name'); + }); +}); + +test('Recent allocations caps out at five', function(assert) { + let job; + + this.server.create('node'); + const mirageJob = makeMirageJob(this.server); + this.server.createList('allocation', 10); + + this.store.findAll('job'); + + return wait().then(() => { + job = this.store.peekAll('job').findBy('plainId', mirageJob.id); + + this.setProperties(commonProperties(job)); + this.render(commonTemplate); + + return wait().then(() => { + assert.equal(Job.allocations.length, 5, 'Capped at 5 allocations'); + assert.ok( + Job.viewAllAllocations.includes(job.get('allocations.length') + ''), + `View link mentions ${job.get('allocations.length')} allocations` + ); + }); + }); +}); + +test('Recent allocations shows an empty message when the job has no allocations', function(assert) { + let job; + + this.server.create('node'); + const mirageJob = makeMirageJob(this.server); + + this.store.findAll('job'); + + return wait() + .then(() => { + job = this.store.peekAll('job').findBy('plainId', mirageJob.id); + + this.setProperties(commonProperties(job)); + this.render(commonTemplate); + + return wait(); + }) + .then(() => { + assert.ok( + Job.recentAllocationsEmptyState.headline.includes('No Allocations'), + 'No allocations empty message' + ); + }); +}); diff --git a/ui/tests/pages/clients/detail.js b/ui/tests/pages/clients/detail.js index d67e42c53..309a3f176 100644 --- a/ui/tests/pages/clients/detail.js +++ b/ui/tests/pages/clients/detail.js @@ -9,6 +9,8 @@ import { visitable, } from 'ember-cli-page-object'; +import allocations from 'nomad-ui/tests/pages/components/allocations'; + export default create({ visit: visitable('/clients/:id'), @@ -36,22 +38,7 @@ export default create({ eligibilityDefinition: text('[data-test-eligibility]'), datacenterDefinition: text('[data-test-datacenter-definition]'), - allocations: collection('[data-test-allocation]', { - id: text('[data-test-short-id]'), - modifyTime: text('[data-test-modify-time]'), - name: text('[data-test-name]'), - status: text('[data-test-client-status]'), - job: text('[data-test-job]'), - taskGroup: text('[data-test-task-group]'), - jobVersion: text('[data-test-job-version]'), - cpu: text('[data-test-cpu]'), - cpuTooltip: attribute('aria-label', '[data-test-cpu] .tooltip'), - mem: text('[data-test-mem]'), - memTooltip: attribute('aria-label', '[data-test-mem] .tooltip'), - - visit: clickable('[data-test-short-id] a'), - visitJob: clickable('[data-test-job]'), - }), + ...allocations(), attributesTable: isPresent('[data-test-attributes]'), metaTable: isPresent('[data-test-meta]'), diff --git a/ui/tests/pages/components/allocations.js b/ui/tests/pages/components/allocations.js new file mode 100644 index 000000000..29cfb3ffc --- /dev/null +++ b/ui/tests/pages/components/allocations.js @@ -0,0 +1,30 @@ +import { attribute, collection, clickable, isPresent, text } from 'ember-cli-page-object'; + +export default function(selector = '[data-test-allocation]') { + return { + allocations: collection(selector, { + id: attribute('data-test-allocation'), + shortId: text('[data-test-short-id]'), + createTime: text('[data-test-create-time]'), + modifyTime: text('[data-test-modify-time]'), + status: text('[data-test-client-status]'), + job: text('[data-test-job]'), + taskGroup: text('[data-test-task-group]'), + client: text('[data-test-client]'), + jobVersion: text('[data-test-job-version]'), + cpu: text('[data-test-cpu]'), + cpuTooltip: attribute('aria-label', '[data-test-cpu] .tooltip'), + mem: text('[data-test-mem]'), + memTooltip: attribute('aria-label', '[data-test-mem] .tooltip'), + rescheduled: isPresent('[data-test-indicators] [data-test-icon="reschedule"]'), + + visit: clickable('[data-test-short-id] a'), + visitJob: clickable('[data-test-job]'), + visitClient: clickable('[data-test-client] a'), + }), + + allocationFor(id) { + return this.allocations.toArray().find(allocation => allocation.id === id); + }, + }; +} diff --git a/ui/tests/pages/jobs/detail.js b/ui/tests/pages/jobs/detail.js index 2208a8003..0f5d21bdc 100644 --- a/ui/tests/pages/jobs/detail.js +++ b/ui/tests/pages/jobs/detail.js @@ -8,6 +8,8 @@ import { visitable, } from 'ember-cli-page-object'; +import allocations from 'nomad-ui/tests/pages/components/allocations'; + export default create({ visit: visitable('/jobs/:id'), @@ -29,10 +31,18 @@ export default create({ return this.stats.toArray().findBy('id', id); }, + ...allocations(), + + viewAllAllocations: text('[data-test-view-all-allocations]'), + error: { isPresent: isPresent('[data-test-error]'), title: text('[data-test-error-title]'), message: text('[data-test-error-message]'), seekHelp: clickable('[data-test-error-message] a'), }, + + recentAllocationsEmptyState: { + headline: text('[data-test-empty-recent-allocations-headline]'), + }, }); diff --git a/ui/tests/pages/jobs/job/allocations.js b/ui/tests/pages/jobs/job/allocations.js new file mode 100644 index 000000000..65ca46f5b --- /dev/null +++ b/ui/tests/pages/jobs/job/allocations.js @@ -0,0 +1,40 @@ +import { + attribute, + clickable, + create, + collection, + fillable, + isPresent, + text, + visitable, +} from 'ember-cli-page-object'; + +import allocations from 'nomad-ui/tests/pages/components/allocations'; + +export default create({ + visit: visitable('/jobs/:id/allocations'), + + pageSize: 25, + + hasSearchBox: isPresent('[data-test-allocations-search]'), + search: fillable('[data-test-allocations-search] input'), + + ...allocations(), + + isEmpty: isPresent('[data-test-empty-allocations-list]'), + emptyState: { + headline: text('[data-test-empty-allocations-list-headline]'), + }, + + sortOptions: collection('[data-test-sort-by]', { + id: attribute('data-test-sort-by'), + sort: clickable(), + }), + + sortBy(id) { + return this.sortOptions + .toArray() + .findBy('id', id) + .sort(); + }, +}); diff --git a/ui/tests/pages/jobs/job/deployments.js b/ui/tests/pages/jobs/job/deployments.js index 5f0d633da..fe7d2ed4c 100644 --- a/ui/tests/pages/jobs/job/deployments.js +++ b/ui/tests/pages/jobs/job/deployments.js @@ -8,6 +8,8 @@ import { visitable, } from 'ember-cli-page-object'; +import allocations from 'nomad-ui/tests/pages/components/allocations'; + export default create({ visit: visitable('/jobs/:id/deployments'), @@ -46,9 +48,7 @@ export default create({ progress: text('[data-test-deployment-task-group-progress-deadline]'), }), + ...allocations('[data-test-deployment-allocation]'), hasAllocations: isPresent('[data-test-deployment-allocations]'), - allocations: collection('[data-test-deployment-allocation]', { - id: text('[data-test-short-id]'), - }), }), }); diff --git a/ui/tests/pages/jobs/job/task-group.js b/ui/tests/pages/jobs/job/task-group.js index b1203e893..d20537e5a 100644 --- a/ui/tests/pages/jobs/job/task-group.js +++ b/ui/tests/pages/jobs/job/task-group.js @@ -9,6 +9,8 @@ import { visitable, } from 'ember-cli-page-object'; +import allocations from 'nomad-ui/tests/pages/components/allocations'; + export default create({ pageSize: 10, @@ -31,27 +33,7 @@ export default create({ return this.breadcrumbs.toArray().find(crumb => crumb.id === id); }, - allocations: collection('[data-test-allocation]', { - id: attribute('data-test-allocation'), - shortId: text('[data-test-short-id]'), - modifyTime: text('[data-test-modify-time]'), - name: text('[data-test-name]'), - status: text('[data-test-client-status]'), - jobVersion: text('[data-test-job-version]'), - client: text('[data-test-client]'), - cpu: text('[data-test-cpu]'), - cpuTooltip: attribute('aria-label', '[data-test-cpu] .tooltip'), - mem: text('[data-test-mem]'), - memTooltip: attribute('aria-label', '[data-test-mem] .tooltip'), - rescheduled: isPresent('[data-test-indicators] [data-test-icon="reschedule"]'), - - visit: clickable('[data-test-short-id] a'), - visitClient: clickable('[data-test-client] a'), - }), - - allocationFor(id) { - return this.allocations.toArray().find(allocation => allocation.id === id); - }, + ...allocations(), isEmpty: isPresent('[data-test-empty-allocations-list]'), diff --git a/ui/yarn.lock b/ui/yarn.lock index 2a1e4205b..ff3b1fc8f 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -854,6 +854,10 @@ babel-plugin-syntax-exponentiation-operator@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de" +babel-plugin-syntax-object-rest-spread@^6.8.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" + babel-plugin-syntax-trailing-function-commas@^6.22.0: version "6.22.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz#ba0360937f8d06e40180a43fe0d5616fff532cf3" @@ -1042,6 +1046,13 @@ babel-plugin-transform-exponentiation-operator@^6.22.0: babel-plugin-syntax-exponentiation-operator "^6.8.0" babel-runtime "^6.22.0" +babel-plugin-transform-object-rest-spread@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz#0f36692d50fef6b7e2d4b3ac1478137a963b7b06" + dependencies: + babel-plugin-syntax-object-rest-spread "^6.8.0" + babel-runtime "^6.26.0" + babel-plugin-transform-regenerator@^6.22.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz#e0703696fbde27f0a3efcacf8b4dca2f7b3a8f2f"