Merge pull request #11711 from hashicorp/f-ui/evaluations-table

feat:  add evaluations view with table
This commit is contained in:
Jai
2022-01-28 11:15:42 -05:00
committed by GitHub
16 changed files with 796 additions and 30 deletions

View File

@@ -0,0 +1,9 @@
import ApplicationAdapter from './application';
export default class EvaluationAdapter extends ApplicationAdapter {
handleResponse(_status, headers) {
const result = super.handleResponse(...arguments);
result.meta = { nextToken: headers['x-nomad-nexttoken'] };
return result;
}
}

View File

@@ -0,0 +1 @@
<span class="color-swatch {{@status}}"></span>{{@status}}

View File

@@ -0,0 +1,70 @@
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 {
@service userSettings;
queryParams = ['nextToken', 'pageSize', 'status'];
get shouldDisableNext() {
return !this.model.meta?.nextToken;
}
get shouldDisablePrev() {
return !this.previousTokens.length;
}
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) {
this.pageSize = newPageSize;
}
@action
onNext(nextToken) {
this.previousTokens = [...this.previousTokens, this.nextToken];
this.nextToken = nextToken;
}
@action
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 = [];
}
}

View File

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

View File

@@ -74,5 +74,8 @@ Router.map(function () {
this.route('tokens');
});
// if we don't include function() the outlet won't render
this.route('evaluations', function () {});
this.route('not-found', { path: '/*' });
});

View File

@@ -0,0 +1,29 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
const ALL_NAMESPACE_WILDCARD = '*';
export default class EvaluationsIndexRoute extends Route {
@service store;
queryParams = {
pageSize: {
refreshModel: true,
},
nextToken: {
refreshModel: true,
},
status: {
refreshModel: true,
},
};
model({ pageSize, status, nextToken }) {
return this.store.query('evaluation', {
namespace: ALL_NAMESPACE_WILDCARD,
per_page: pageSize,
next_token: nextToken,
status,
});
}
}

View File

@@ -6,6 +6,8 @@ $failed: $danger;
$lost: $dark;
$not-scheduled: $blue-200;
$degraded: $warning;
$blocked: $danger;
$canceled: $dark;
.chart {
.queued {
@@ -120,6 +122,14 @@ $degraded: $warning;
background: $degraded;
}
&.canceled {
background: $canceled;
}
&.blocked {
background: $blocked;
}
@each $name, $pair in $colors {
$color: nth($pair, 1);

View File

@@ -269,6 +269,10 @@
justify-content: space-between;
align-items: center;
&.with-padding {
padding: 6px;
}
.pagination {
padding: 0;
margin: 0;

View File

@@ -1,7 +1,12 @@
<div data-test-gutter-menu class="page-column is-left {{if this.isOpen "is-open"}}">
<div class="gutter {{if this.isOpen "is-open"}}">
<header class="collapsed-menu {{if this.isOpen "is-open"}}">
<span data-test-gutter-gutter-toggle class="gutter-toggle" aria-label="menu" onclick={{action this.onHamburgerClick}}>
<span
data-test-gutter-gutter-toggle
class="gutter-toggle"
aria-label="menu"
onclick={{action this.onHamburgerClick}}
>
<HamburgerMenu />
</span>
<span class="logo-container">
@@ -12,7 +17,8 @@
{{#if this.system.shouldShowRegions}}
<div class="collapsed-only">
<p class="menu-label">
Region {{if this.system.shouldShowNamespaces "& Namespace"}}
Region
{{if this.system.shouldShowNamespaces "& Namespace"}}
</p>
<ul class="menu-list">
<li>
@@ -28,19 +34,13 @@
</p>
<ul class="menu-list">
<li>
<LinkTo
@route="jobs"
@activeClass="is-active"
data-test-gutter-link="jobs">
<LinkTo @route="jobs" @activeClass="is-active" data-test-gutter-link="jobs">
Jobs
</LinkTo>
</li>
{{#if (can "accept recommendation")}}
<li>
<LinkTo
@route="optimize"
@activeClass="is-active"
data-test-gutter-link="optimize">
<LinkTo @route="optimize" @activeClass="is-active" data-test-gutter-link="optimize">
Optimize
</LinkTo>
</li>
@@ -51,11 +51,11 @@
</p>
<ul class="menu-list">
<li>
<LinkTo
@route="csi"
@activeClass="is-active"
data-test-gutter-link="storage">
Storage <span class="tag is-small">Beta</span>
<LinkTo @route="csi" @activeClass="is-active" data-test-gutter-link="storage">
Storage
<span class="tag is-small">
Beta
</span>
</LinkTo>
</li>
</ul>
@@ -63,19 +63,48 @@
Cluster
</p>
<ul class="menu-list">
<li><LinkTo @route="clients" @activeClass="is-active" data-test-gutter-link="clients">Clients</LinkTo></li>
<li><LinkTo @route="servers" @activeClass="is-active" data-test-gutter-link="servers">Servers</LinkTo></li>
<li><LinkTo @route="topology" @activeClass="is-active" data-test-gutter-link="topology">Topology</LinkTo></li>
<li>
<LinkTo @route="clients" @activeClass="is-active" data-test-gutter-link="clients">
Clients
</LinkTo>
</li>
<li>
<LinkTo @route="servers" @activeClass="is-active" data-test-gutter-link="servers">
Servers
</LinkTo>
</li>
<li>
<LinkTo @route="topology" @activeClass="is-active" data-test-gutter-link="topology">
Topology
</LinkTo>
</li>
</ul>
<p class="menu-label">
Debugging
</p>
<ul class="menu-list">
<li>
<LinkTo @route="evaluations" @activeClass="is-active" data-test-gutter-link="evaluations">
Evaluations
</LinkTo>
</li>
</ul>
</aside>
{{#if this.system.agent.version}}
<footer class="gutter-footer">
<span class="is-faded">v{{this.system.agent.version}}</span>
</footer>
<footer class="gutter-footer">
<span class="is-faded">
v
{{this.system.agent.version}}
</span>
</footer>
{{/if}}
</div>
</div>
<div data-test-page-content class="page-column is-right">
{{yield}}
</div>
<div data-test-gutter-backdrop class="gutter-backdrop {{if this.isOpen "is-open"}}" onclick={{action this.onHamburgerClick}}></div>
<div
data-test-gutter-backdrop
class="gutter-backdrop {{if this.isOpen "is-open"}}"
onclick={{action this.onHamburgerClick}}
></div>

View File

@@ -1,15 +1,16 @@
<div class="field is-horizontal" data-test-page-size-select-parent>
<span class="field-label is-small">Per page</span>
<div class="field is-horizontal" data-test-page-size-select-parent ...attributes>
<span class="field-label is-small">
Per page
</span>
<PowerSelect
@tagName="div"
class="field-body"
data-test-page-size-select
@options={{this.pageSizeOptions}}
@selected={{this.userSettings.pageSize}}
@onChange={{action (queue
(action (mut this.userSettings.pageSize))
(action this.onChange)
)}} as |option|>
@onChange={{action (queue (action (mut this.userSettings.pageSize)) (action this.onChange))
}} as |option|
>
{{option}}
</PowerSelect>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<PageLayout>
{{outlet}}
</PageLayout>

View File

@@ -0,0 +1,138 @@
{{page-title "Evaluations"}}
<section class="section">
<div class="toolbar">
<div class="toolbar-item is-right-aligned">
<SingleSelectDropdown
data-test-evaluation-status-facet
@label="Status"
@options={{this.optionsEvaluationsStatus}}
@selection={{this.status}}
@onSelect={{action this.setStatus}}
/>
</div>
</div>
{{#if @model.length}}
<ListTable data-test-eval-table @source={{@model}} as |t|>
<t.head>
<th>
Evaluation ID
</th>
<th>
Resource
</th>
<th>
Priority
</th>
<th>
Created
</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-id>
{{#if row.model.hasJob}}
<LinkTo
data-test-evaluation-resource
@model={{row.model.plainJobId}}
@route="jobs.job"
@query={{hash namespace=row.model.namespace}}
>
{{row.model.plainJobId}}
</LinkTo>
{{else}}
<LinkTo
data-test-evaluation-resource
@model={{row.model.nodeId}}
@route="clients.client"
>
{{row.model.shortNodeId}}
</LinkTo>
{{/if}}
</td>
<td data-test-priority>
{{row.model.priority}}
</td>
<td data-test-create-time>
{{format-month-ts row.model.createTime}}
</td>
<td data-test-triggered-by>
{{row.model.triggeredBy}}
</td>
<td data-test-status class="is-one-line">
<StatusCell @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>
</ListTable>
<div class="table-foot with-padding">
<PageSizeSelect data-test-per-page @onChange={{this.onChange}} />
<div>
<button class="button" data-test-eval-refresh type="button" {{on "click" this.refresh}}>
{{x-icon "refresh-default" class="is-text"}}
Refresh
</button>
<button
data-test-eval-pagination-prev
type="button"
class="button is-text is-borderless"
disabled={{this.shouldDisablePrev}}
{{on "click" (fn this.onPrev this.lastToken)}}
>
{{x-icon "chevron-left" class="is-large"}}
</button>
<button
data-test-eval-pagination-next
type="button"
class="button is-text is-borderless"
disabled={{this.shouldDisableNext}}
{{on "click" (fn this.onNext @model.meta.nextToken)}}
>
{{x-icon "chevron-right" class="is-large"}}
</button>
</div>
</div>
{{else}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-evaluations-list>
<h3 class="empty-message-headline" data-test-empty-evalations-list-headline>
No Matches
</h3>
<p class="empty-message-body">
{{#if this.status}}
<span data-test-no-eval-match>
No evaluations match the status
<strong>
{{this.status}}
</strong>
</span>
{{else}}
<span data-test-no-eval>
There are no evaluations
</span>
{{/if}}
</p>
</div>
</div>
{{/if}}
</section>

View File

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

View File

@@ -91,7 +91,9 @@ export default Factory.extend({
}),
afterCreate(evaluation, server) {
assignJob(evaluation, server);
if (!evaluation.nodeId) {
assignJob(evaluation, server);
}
},
});

View File

@@ -0,0 +1,446 @@
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) {
assert.expect(2);
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.');
});
});
});

View File

@@ -40,6 +40,8 @@ module('Unit | Serializer | Evaluation', function (hooks) {
nodesAvailable: 10,
},
],
namespace: 'test-namespace',
plainJobId: 'some-job-id',
},
relationships: {
job: {
@@ -89,6 +91,8 @@ module('Unit | Serializer | Evaluation', function (hooks) {
nodesAvailable: 25,
},
],
namespace: 'test-namespace',
plainJobId: 'some-job-id',
},
relationships: {
job: {