diff --git a/ui/app/controllers/evaluations/index.js b/ui/app/controllers/evaluations/index.js index f83235f31..e277abe7f 100644 --- a/ui/app/controllers/evaluations/index.js +++ b/ui/app/controllers/evaluations/index.js @@ -1,9 +1,12 @@ import Controller from '@ember/controller'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; export default class EvaluationsController extends Controller { - queryParams = ['nextToken', 'pageSize']; + @service userSettings; + + queryParams = ['nextToken', 'pageSize', 'status']; get shouldDisableNext() { return !this.model.meta?.nextToken; @@ -13,9 +16,21 @@ export default class EvaluationsController extends Controller { return !this.previousTokens.length; } - @tracked pageSize = 25; + get optionsEvaluationsStatus() { + return [ + { key: null, label: 'All' }, + { key: 'blocked', label: 'Blocked' }, + { key: 'pending', label: 'Pending' }, + { key: 'complete', label: 'Complete' }, + { key: 'failed', label: 'Failed' }, + { key: 'canceled', label: 'Canceled' }, + ]; + } + + @tracked pageSize = this.userSettings.pageSize; @tracked nextToken = null; @tracked previousTokens = []; + @tracked status = null; @action onChange(newPageSize) { @@ -29,14 +44,26 @@ export default class EvaluationsController extends Controller { } @action - onPrev(lastToken) { - this.previousTokens.pop(); + onPrev() { + const lastToken = this.previousTokens.pop(); this.previousTokens = [...this.previousTokens]; this.nextToken = lastToken; } @action refresh() { + this._resetTokens(); + this.status = null; + this.pageSize = this.userSettings.pageSize; + } + + @action + setStatus(selection) { + this._resetTokens(); + this.status = selection; + } + + _resetTokens() { this.nextToken = null; this.previousTokens = []; } diff --git a/ui/app/models/evaluation.js b/ui/app/models/evaluation.js index 944d03e10..51507708b 100644 --- a/ui/app/models/evaluation.js +++ b/ui/app/models/evaluation.js @@ -6,6 +6,7 @@ import shortUUIDProperty from '../utils/properties/short-uuid'; export default class Evaluation extends Model { @shortUUIDProperty('id') shortId; + @shortUUIDProperty('nodeId') shortNodeId; @attr('number') priority; @attr('string') type; @attr('string') triggeredBy; @@ -18,6 +19,7 @@ export default class Evaluation extends Model { @equal('status', 'blocked') isBlocked; @belongsTo('job') job; + @belongsTo('node') node; @attr('number') modifyIndex; @attr('date') modifyTime; @@ -26,4 +28,18 @@ export default class Evaluation extends Model { @attr('date') createTime; @attr('date') waitUntil; + @attr('string') namespace; + @attr('string') plainJobId; + + get hasJob() { + return !!this.plainJobId; + } + + get hasNode() { + return !!this.belongsTo('node').id(); + } + + get nodeId() { + return this.belongsTo('node').id(); + } } diff --git a/ui/app/routes/evaluations/index.js b/ui/app/routes/evaluations/index.js index 0442d4cb6..6733e5bd6 100644 --- a/ui/app/routes/evaluations/index.js +++ b/ui/app/routes/evaluations/index.js @@ -13,13 +13,17 @@ export default class EvaluationsIndexRoute extends Route { nextToken: { refreshModel: true, }, + status: { + refreshModel: true, + }, }; - model({ pageSize, nextToken }) { + model({ pageSize, status, nextToken }) { return this.store.query('evaluation', { namespace: ALL_NAMESPACE_WILDCARD, per_page: pageSize, next_token: nextToken, + status, }); } } diff --git a/ui/app/templates/components/page-size-select.hbs b/ui/app/templates/components/page-size-select.hbs index f61c33295..3b2efb3c2 100644 --- a/ui/app/templates/components/page-size-select.hbs +++ b/ui/app/templates/components/page-size-select.hbs @@ -1,15 +1,16 @@ -
- Per page +
+ + Per page + + @onChange={{action (queue (action (mut this.userSettings.pageSize)) (action this.onChange)) + }} as |option| + > {{option}} -
+
\ No newline at end of file diff --git a/ui/app/templates/evaluations/index.hbs b/ui/app/templates/evaluations/index.hbs index 342c72685..a4070dc8e 100644 --- a/ui/app/templates/evaluations/index.hbs +++ b/ui/app/templates/evaluations/index.hbs @@ -1,79 +1,136 @@ {{page-title "Evaluations"}}
- - - - Evaluation ID - - - Resource - - - Priority - - - Created - - - Triggered By - - - Status - - - Placement Failures - - - - - - {{row.model.shortId}} - - - Resource Placeholder - - - {{row.model.priority}} - - - {{format-month-ts row.model.createTime}} - - - {{row.model.triggeredBy}} - - - - - - {{#if (eq row.model.status "blocked")}} - N/A - In Progress - {{else if row.model.hasPlacementFailures}} - True - {{else}} - False - {{/if}} - - - - -
- - - - +
+
+ +
+ {{#if @model.length}} + + + + Evaluation ID + + + Resource + + + Priority + + + Created + + + Triggered By + + + Status + + + Placement Failures + + + + + + {{row.model.shortId}} + + + {{#if row.model.hasJob}} + + {{row.model.plainJobId}} + + {{else}} + + {{row.model.shortNodeId}} + + {{/if}} + + + {{row.model.priority}} + + + {{format-month-ts row.model.createTime}} + + + {{row.model.triggeredBy}} + + + + + + {{#if (eq row.model.status "blocked")}} + N/A - In Progress + {{else if row.model.hasPlacementFailures}} + True + {{else}} + False + {{/if}} + + + + +
+ + + + +
+ {{else}} +
+
+

+ No Matches +

+

+ {{#if this.status}} + + No evaluations match the status + + {{this.status}} + + + + {{else}} + + There are no evaluations + + {{/if}} +

+
+
+ {{/if}}
\ No newline at end of file diff --git a/ui/mirage/config.js b/ui/mirage/config.js index b95244b54..a95e06823 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -261,6 +261,7 @@ export default function () { return this.serialize(evaluations.where({ jobId: params.id })); }); + this.get('/evaluations'); this.get('/evaluation/:id'); this.get('/deployment/allocations/:id', function (schema, { params }) { diff --git a/ui/mirage/factories/evaluation.js b/ui/mirage/factories/evaluation.js index 8a3666d16..4986675a1 100644 --- a/ui/mirage/factories/evaluation.js +++ b/ui/mirage/factories/evaluation.js @@ -91,7 +91,9 @@ export default Factory.extend({ }), afterCreate(evaluation, server) { - assignJob(evaluation, server); + if (!evaluation.nodeId) { + assignJob(evaluation, server); + } }, }); diff --git a/ui/tests/acceptance/evaluations-test.js b/ui/tests/acceptance/evaluations-test.js new file mode 100644 index 000000000..13b0800f4 --- /dev/null +++ b/ui/tests/acceptance/evaluations-test.js @@ -0,0 +1,397 @@ +import { click, currentRouteName, visit } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { Response } from 'ember-cli-mirage'; +import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; +import { selectChoose, clickTrigger } from 'ember-power-select/test-support/helpers'; + +const getStandardRes = () => [ + { + CreateIndex: 1249, + CreateTime: 1640181894162724000, + DeploymentID: '12efbb28-840e-7794-b215-a7b112e40a4f', + ID: '5fb1b8cd-00f8-fff8-de0c-197dc37f5053', + JobID: 'cores-example', + JobModifyIndex: 694, + ModifyIndex: 1251, + ModifyTime: 1640181894167194000, + Namespace: 'ted-lasso', + Priority: 50, + QueuedAllocations: { + lb: 0, + webapp: 0, + }, + SnapshotIndex: 1249, + Status: 'complete', + TriggeredBy: 'job-register', + Type: 'service', + }, + { + CreateIndex: 1304, + CreateTime: 1640183201719510000, + DeploymentID: '878435bf-7265-62b1-7902-d45c44b23b79', + ID: '66cb98a6-7740-d5ef-37e4-fa0f8b1de44b', + JobID: 'cores-example', + JobModifyIndex: 1304, + ModifyIndex: 1306, + ModifyTime: 1640183201721418000, + Namespace: 'default', + Priority: 50, + QueuedAllocations: { + webapp: 0, + lb: 0, + }, + SnapshotIndex: 1304, + Status: 'complete', + TriggeredBy: 'job-register', + Type: 'service', + }, + { + CreateIndex: 1267, + CreateTime: 1640182198255685000, + DeploymentID: '12efbb28-840e-7794-b215-a7b112e40a4f', + ID: '78009518-574d-eee6-919a-e83879175dd3', + JobID: 'cores-example', + JobModifyIndex: 1250, + ModifyIndex: 1274, + ModifyTime: 1640182228112823000, + Namespace: 'ted-lasso', + PreviousEval: '84f1082f-3e6e-034d-6df4-c6a321e7bd63', + Priority: 50, + QueuedAllocations: { + lb: 0, + }, + SnapshotIndex: 1272, + Status: 'complete', + TriggeredBy: 'alloc-failure', + Type: 'service', + WaitUntil: '2021-12-22T14:10:28.108136Z', + }, + { + CreateIndex: 1322, + CreateTime: 1640183505760099000, + DeploymentID: '878435bf-7265-62b1-7902-d45c44b23b79', + ID: 'c184f72b-68a3-5180-afd6-af01860ad371', + JobID: 'cores-example', + JobModifyIndex: 1305, + ModifyIndex: 1329, + ModifyTime: 1640183535540881000, + Namespace: 'default', + PreviousEval: '9a917a93-7bc3-6991-ffc9-15919a38f04b', + Priority: 50, + QueuedAllocations: { + lb: 0, + }, + SnapshotIndex: 1326, + Status: 'complete', + TriggeredBy: 'alloc-failure', + Type: 'service', + WaitUntil: '2021-12-22T14:32:15.539556Z', + }, +]; + +module('Acceptance | evaluations list', function(hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + test('it passes an accessibility audit', async function(assert) { + await visit('/evaluations'); + + assert.equal( + currentRouteName(), + 'evaluations.index', + 'The default route in evaluations is evaluations index' + ); + + await a11yAudit(assert); + }); + + test('it renders an empty message if there are no evaluations rendered', async function(assert) { + await visit('/evaluations'); + + assert.dom('[data-test-empty-evaluations-list]').exists('We display empty table message.'); + assert + .dom('[data-test-no-eval]') + .exists('We display a message saying there are no evaluations.'); + }); + + test('it renders a list of evaluations', async function(assert) { + assert.expect(3); + + server.get('/evaluations', function(_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + status: '', + next_token: '', + }, + 'Forwards the correct query parameters on default query when route initially loads' + ); + return getStandardRes(); + }); + + await visit('/evaluations'); + + assert.dom('[data-test-eval-table]').exists('Evaluations table should render'); + assert + .dom('[data-test-evaluation]') + .exists({ count: 4 }, 'Should render the correct number of evaluations'); + }); + + test('it should enable filtering by evaluation status', async function(assert) { + assert.expect(2); + + server.get('/evaluations', getStandardRes); + + await visit('/evaluations'); + + server.get('/evaluations', function(_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + status: 'pending', + next_token: '', + }, + 'It makes another server request using the options selected by the user' + ); + return []; + }); + + await clickTrigger('[data-test-evaluation-status-facet]'); + await selectChoose('[data-test-evaluation-status-facet]', 'Pending'); + + assert + .dom('[data-test-no-eval-match]') + .exists('Renders a message saying no evaluations match filter status'); + }); + + module('page size', function(hooks) { + hooks.afterEach(function() { + // PageSizeSelect and the Evaluations Controller are both using localStorage directly + // Will come back and invert the dependency + window.localStorage.clear(); + }); + + test('it is possible to change page size', async function(assert) { + assert.expect(1); + + server.get('/evaluations', getStandardRes); + + await visit('/evaluations'); + + server.get('/evaluations', function(_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '50', + status: '', + next_token: '', + }, + 'It makes a request with the per_page set by the user' + ); + return getStandardRes(); + }); + + await clickTrigger('[data-test-per-page]'); + await selectChoose('[data-test-per-page]', 50); + }); + }); + + module('pagination', function() { + test('it should enable pagination by using next tokens', async function(assert) { + assert.expect(7); + + server.get('/evaluations', function() { + return new Response(200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes()); + }); + + await visit('/evaluations'); + + server.get('/evaluations', function(_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + status: '', + next_token: 'next-token-1', + }, + 'It makes another server request using the options selected by the user' + ); + return new Response(200, { 'x-nomad-nexttoken': 'next-token-2' }, getStandardRes()); + }); + + assert + .dom('[data-test-eval-pagination-next]') + .isEnabled( + 'If there is a next-token in the API response the next button should be enabled.' + ); + await click('[data-test-eval-pagination-next]'); + + server.get('/evaluations', function(_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + status: '', + next_token: 'next-token-2', + }, + 'It makes another server request using the options selected by the user' + ); + return getStandardRes(); + }); + await click('[data-test-eval-pagination-next]'); + + assert + .dom('[data-test-eval-pagination-next]') + .isDisabled('If there is no next-token, the next button is disabled.'); + + assert + .dom('[data-test-eval-pagination-prev]') + .isEnabled('After we transition to the next page, the previous page button is enabled.'); + + server.get('/evaluations', function(_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + status: '', + next_token: 'next-token-1', + }, + 'It makes a request using the stored old token.' + ); + return new Response(200, { 'x-nomad-nexttoken': 'next-token-2' }, getStandardRes()); + }); + + await click('[data-test-eval-pagination-prev]'); + + server.get('/evaluations', function(_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + status: '', + next_token: '', + }, + 'When there are no more stored previous tokens, we will request with no next-token.' + ); + return new Response(200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes()); + }); + + await click('[data-test-eval-pagination-prev]'); + }); + + test('it should clear all query parameters on refresh', async function(assert) { + assert.expect(1); + + server.get('/evaluations', function() { + return new Response(200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes()); + }); + + await visit('/evaluations'); + + server.get('/evaluations', function() { + return getStandardRes(); + }); + + await click('[data-test-eval-pagination-next]'); + + await clickTrigger('[data-test-evaluation-status-facet]'); + await selectChoose('[data-test-evaluation-status-facet]', 'Pending'); + + server.get('/evaluations', function(_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + status: '', + next_token: '', + }, + 'It clears all query parameters when making a refresh' + ); + return new Response(200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes()); + }); + + await click('[data-test-eval-refresh]'); + }); + + test('it should reset pagination when filters are applied', async function(assert) { + assert.expect(1); + + server.get('/evaluations', function() { + return new Response(200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes()); + }); + + await visit('/evaluations'); + + server.get('/evaluations', function() { + return new Response(200, { 'x-nomad-nexttoken': 'next-token-2' }, getStandardRes()); + }); + + await click('[data-test-eval-pagination-next]'); + + server.get('/evaluations', getStandardRes); + await click('[data-test-eval-pagination-next]'); + + server.get('/evaluations', function(_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + status: 'pending', + next_token: '', + }, + 'It clears all next token when filtered request is made' + ); + return getStandardRes(); + }); + await clickTrigger('[data-test-evaluation-status-facet]'); + await selectChoose('[data-test-evaluation-status-facet]', 'Pending'); + }); + }); + + module('resource linking', function() { + test('it should generate a link to the job resource', async function(assert) { + server.create('node'); + const job = server.create('job', { shallow: true }); + server.create('evaluation', { jobId: job.id }); + await visit('/evaluations'); + + assert + .dom('[data-test-evaluation-resource]') + .hasText(job.name, 'It conditionally renders the correct resource name'); + await click('[data-test-evaluation-resource]'); + assert + .dom('[data-test-job-name]') + .includesText(job.name, 'We navigate to the correct job page.'); + }); + + test('it should generate a link to the node resource', async function(assert) { + const node = server.create('node'); + server.create('evaluation', { nodeId: node.id }); + await visit('/evaluations'); + + const shortNodeId = node.id.split('-')[0]; + assert + .dom('[data-test-evaluation-resource]') + .hasText(shortNodeId, 'It conditionally renders the correct resource name'); + + await click('[data-test-evaluation-resource]'); + + assert + .dom('[data-test-title]') + .includesText(node.name, 'We navigate to the correct client page.'); + }); + }); +});