open-nomad/ui/tests/acceptance/client-detail-test.js
Jai Bhagat 3350f3fb11 ui: apply new qunit linting rules to tests
Async tests should use  in integrations tests.
Acceptance tests are using Mirage and can't use
since we can't know the number of assertions.
2022-01-20 10:01:35 -05:00

1366 lines
41 KiB
JavaScript

/* eslint-disable qunit/require-expect */
/* eslint-disable qunit/no-conditional-assertions */
/* Mirage fixtures are random so we can't expect a set number of assertions */
import { currentURL, waitUntil, settled } from '@ember/test-helpers';
import { assign } from '@ember/polyfills';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import { formatBytes, formatHertz } from 'nomad-ui/utils/units';
import moment from 'moment';
import ClientDetail from 'nomad-ui/tests/pages/clients/detail';
import Clients from 'nomad-ui/tests/pages/clients/list';
import Jobs from 'nomad-ui/tests/pages/jobs/list';
import Layout from 'nomad-ui/tests/pages/layout';
let node;
let managementToken;
let clientToken;
const wasPreemptedFilter = (allocation) => !!allocation.preemptedByAllocation;
function nonSearchPOSTS() {
return server.pretender.handledRequests
.reject((request) => request.url.includes('fuzzy'))
.filterBy('method', 'POST');
}
module('Acceptance | client detail', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
window.localStorage.clear();
server.create('node', 'forceIPv4', { schedulingEligibility: 'eligible' });
node = server.db.nodes[0];
managementToken = server.create('token');
clientToken = server.create('token');
window.localStorage.nomadTokenSecret = managementToken.secretId;
// Related models
server.create('agent');
server.create('job', { createAllocations: false });
server.createList('allocation', 3);
server.create('allocation', 'preempted');
// Force all allocations into the running state so now allocation rows are missing
// CPU/Mem runtime metrics
server.schema.allocations.all().models.forEach((allocation) => {
allocation.update({ clientStatus: 'running' });
});
});
test('it passes an accessibility audit', async function (assert) {
await ClientDetail.visit({ id: node.id });
await a11yAudit(assert);
});
test('/clients/:id should have a breadcrumb trail linking back to clients', async function (assert) {
await ClientDetail.visit({ id: node.id });
assert.equal(document.title, `Client ${node.name} - Nomad`);
assert.equal(
Layout.breadcrumbFor('clients.index').text,
'Clients',
'First breadcrumb says clients'
);
assert.equal(
Layout.breadcrumbFor('clients.client').text,
`Client ${node.id.split('-')[0]}`,
'Second breadcrumb is a titled breadcrumb saying the node short id'
);
await Layout.breadcrumbFor('clients.index').visit();
assert.equal(
currentURL(),
'/clients',
'First breadcrumb links back to clients'
);
});
test('/clients/:id should list immediate details for the node in the title', async function (assert) {
node = server.create('node', 'forceIPv4', {
schedulingEligibility: 'eligible',
drain: false,
});
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.title.includes(node.name), 'Title includes name');
assert.ok(ClientDetail.clientId.includes(node.id), 'Title includes id');
assert.equal(
ClientDetail.statusLight.objectAt(0).id,
node.status,
'Title includes status light'
);
});
test('/clients/:id should list additional detail for the node below the title', async function (assert) {
await ClientDetail.visit({ id: node.id });
assert.ok(
ClientDetail.statusDefinition.includes(node.status),
'Status is in additional details'
);
assert.ok(
ClientDetail.statusDecorationClass.includes(`node-${node.status}`),
'Status is decorated with a status class'
);
assert.ok(
ClientDetail.addressDefinition.includes(node.httpAddr),
'Address is in additional details'
);
assert.ok(
ClientDetail.datacenterDefinition.includes(node.datacenter),
'Datacenter is in additional details'
);
});
test('/clients/:id should include resource utilization graphs', async function (assert) {
await ClientDetail.visit({ id: node.id });
assert.equal(
ClientDetail.resourceCharts.length,
2,
'Two resource utilization graphs'
);
assert.equal(
ClientDetail.resourceCharts.objectAt(0).name,
'CPU',
'First chart is CPU'
);
assert.equal(
ClientDetail.resourceCharts.objectAt(1).name,
'Memory',
'Second chart is Memory'
);
});
test('/clients/:id should list all allocations on the node', async function (assert) {
const allocationsCount = server.db.allocations.where({
nodeId: node.id,
}).length;
await ClientDetail.visit({ id: node.id });
assert.equal(
ClientDetail.allocations.length,
allocationsCount,
`Allocations table lists all ${allocationsCount} associated allocations`
);
});
test('/clients/:id should show empty message if there are no allocations on the node', async function (assert) {
const emptyNode = server.create('node');
await ClientDetail.visit({ id: emptyNode.id });
assert.true(
ClientDetail.emptyAllocations.isVisible,
'Empty message is visible'
);
assert.equal(ClientDetail.emptyAllocations.headline, 'No Allocations');
});
test('each allocation should have high-level details for the allocation', async function (assert) {
const allocation = server.db.allocations
.where({ nodeId: node.id })
.sortBy('modifyIndex')
.reverse()[0];
const allocStats = server.db.clientAllocationStats.find(allocation.id);
const taskGroup = server.db.taskGroups.findBy({
name: allocation.taskGroup,
jobId: allocation.jobId,
});
const tasks = taskGroup.taskIds.map((id) => server.db.tasks.find(id));
const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
const memoryUsed = tasks.reduce(
(sum, task) => sum + task.resources.MemoryMB,
0
);
await ClientDetail.visit({ id: node.id });
const allocationRow = ClientDetail.allocations.objectAt(0);
assert.equal(
allocationRow.shortId,
allocation.id.split('-')[0],
'Allocation short ID'
);
assert.equal(
allocationRow.createTime,
moment(allocation.createTime / 1000000).format('MMM DD HH:mm:ss ZZ'),
'Allocation create time'
);
assert.equal(
allocationRow.modifyTime,
moment(allocation.modifyTime / 1000000).fromNow(),
'Allocation modify time'
);
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');
assert.ok(allocationRow.jobVersion, 'Job Version');
assert.equal(allocationRow.volume, 'Yes', 'Volume');
assert.equal(
allocationRow.cpu,
Math.floor(allocStats.resourceUsage.CpuStats.TotalTicks) / cpuUsed,
'CPU %'
);
const roundedTicks = Math.floor(
allocStats.resourceUsage.CpuStats.TotalTicks
);
assert.equal(
allocationRow.cpuTooltip,
`${formatHertz(roundedTicks, 'MHz')} / ${formatHertz(cpuUsed, 'MHz')}`,
'Detailed CPU information is in a tooltip'
);
assert.equal(
allocationRow.mem,
allocStats.resourceUsage.MemoryStats.RSS / 1024 / 1024 / memoryUsed,
'Memory used'
);
assert.equal(
allocationRow.memTooltip,
`${formatBytes(allocStats.resourceUsage.MemoryStats.RSS)} / ${formatBytes(
memoryUsed,
'MiB'
)}`,
'Detailed memory information is in a tooltip'
);
});
test('each allocation should show job information even if the job is incomplete and already in the store', async function (assert) {
// First, visit clients to load the allocations for each visible node.
// Don't load the job belongsTo of the allocation! Leave it unfulfilled.
await Clients.visit();
// Then, visit jobs to load all jobs, which should implicitly fulfill
// the job belongsTo of each allocation pointed at each job.
await Jobs.visit();
// Finally, visit a node to assert that the job name and task group name are
// present. This will require reloading the job, since task groups aren't a
// part of the jobs list response.
await ClientDetail.visit({ id: node.id });
const allocationRow = ClientDetail.allocations.objectAt(0);
const allocation = server.db.allocations
.where({ nodeId: node.id })
.sortBy('modifyIndex')
.reverse()[0];
assert.equal(
allocationRow.job,
server.db.jobs.find(allocation.jobId).name,
'Job name'
);
assert.ok(
allocationRow.taskGroup.includes(allocation.taskGroup),
'Task group name'
);
});
test('each allocation should link to the allocation detail page', async function (assert) {
const allocation = server.db.allocations
.where({ nodeId: node.id })
.sortBy('modifyIndex')
.reverse()[0];
await ClientDetail.visit({ id: node.id });
await ClientDetail.allocations.objectAt(0).visit();
assert.equal(
currentURL(),
`/allocations/${allocation.id}`,
'Allocation rows link to allocation detail pages'
);
});
test('each allocation should link to the job the allocation belongs to', async function (assert) {
await ClientDetail.visit({ id: node.id });
const allocation = server.db.allocations.where({ nodeId: node.id })[0];
const job = server.db.jobs.find(allocation.jobId);
await ClientDetail.allocations.objectAt(0).visitJob();
assert.equal(
currentURL(),
`/jobs/${job.id}`,
'Allocation rows link to the job detail page for the allocation'
);
});
test('the allocation section should show the count of preempted allocations on the client', async function (assert) {
const allocations = server.db.allocations.where({ nodeId: node.id });
await ClientDetail.visit({ id: node.id });
assert.equal(
ClientDetail.allocationFilter.allCount,
allocations.length,
'All filter/badge shows all allocations count'
);
assert.ok(
ClientDetail.allocationFilter.preemptionsCount.startsWith(
allocations.filter(wasPreemptedFilter).length
),
'Preemptions filter/badge shows preempted allocations count'
);
});
test('clicking the preemption badge filters the allocations table and sets a query param', async function (assert) {
const allocations = server.db.allocations.where({ nodeId: node.id });
await ClientDetail.visit({ id: node.id });
await ClientDetail.allocationFilter.preemptions();
assert.equal(
ClientDetail.allocations.length,
allocations.filter(wasPreemptedFilter).length,
'Only preempted allocations are shown'
);
assert.equal(
currentURL(),
`/clients/${node.id}?preemptions=true`,
'Filter is persisted in the URL'
);
});
test('clicking the total allocations badge resets the filter and removes the query param', async function (assert) {
const allocations = server.db.allocations.where({ nodeId: node.id });
await ClientDetail.visit({ id: node.id });
await ClientDetail.allocationFilter.preemptions();
await ClientDetail.allocationFilter.all();
assert.equal(
ClientDetail.allocations.length,
allocations.length,
'All allocations are shown'
);
assert.equal(
currentURL(),
`/clients/${node.id}`,
'Filter is persisted in the URL'
);
});
test('navigating directly to the client detail page with the preemption query param set will filter the allocations table', async function (assert) {
const allocations = server.db.allocations.where({ nodeId: node.id });
await ClientDetail.visit({ id: node.id, preemptions: true });
assert.equal(
ClientDetail.allocations.length,
allocations.filter(wasPreemptedFilter).length,
'Only preempted allocations are shown'
);
});
test('/clients/:id should list all attributes for the node', async function (assert) {
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.attributesTable, 'Attributes table is on the page');
});
test('/clients/:id lists all meta attributes', async function (assert) {
node = server.create('node', 'forceIPv4', 'withMeta');
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.metaTable, 'Meta attributes table is on the page');
assert.notOk(ClientDetail.emptyMetaMessage, 'Meta attributes is not empty');
const firstMetaKey = Object.keys(node.meta)[0];
const firstMetaAttribute = ClientDetail.metaAttributes.objectAt(0);
assert.equal(
firstMetaAttribute.key,
firstMetaKey,
'Meta attributes for the node are bound to the attributes table'
);
assert.equal(
firstMetaAttribute.value,
node.meta[firstMetaKey],
'Meta attributes for the node are bound to the attributes table'
);
});
test('/clients/:id shows an empty message when there is no meta data', async function (assert) {
await ClientDetail.visit({ id: node.id });
assert.notOk(
ClientDetail.metaTable,
'Meta attributes table is not on the page'
);
assert.ok(ClientDetail.emptyMetaMessage, 'Meta attributes is empty');
});
test('when the node is not found, an error message is shown, but the URL persists', async function (assert) {
await ClientDetail.visit({ id: 'not-a-real-node' });
assert.equal(
server.pretender.handledRequests
.filter((request) => !request.url.includes('policy'))
.findBy('status', 404).url,
'/v1/node/not-a-real-node',
'A request to the nonexistent node is made'
);
assert.equal(currentURL(), '/clients/not-a-real-node', 'The URL persists');
assert.ok(ClientDetail.error.isShown, 'Error message is shown');
assert.equal(
ClientDetail.error.title,
'Not Found',
'Error message is for 404'
);
});
test('/clients/:id shows the recent events list', async function (assert) {
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.hasEvents, 'Client events section exists');
});
test('each node event shows basic node event information', async function (assert) {
const event = server.db.nodeEvents
.where({ nodeId: node.id })
.sortBy('time')
.reverse()[0];
await ClientDetail.visit({ id: node.id });
const eventRow = ClientDetail.events.objectAt(0);
assert.equal(
eventRow.time,
moment(event.time).format("MMM DD, 'YY HH:mm:ss ZZ"),
'Event timestamp'
);
assert.equal(eventRow.subsystem, event.subsystem, 'Event subsystem');
assert.equal(eventRow.message, event.message, 'Event message');
});
test('/clients/:id shows the driver status of every driver for the node', async function (assert) {
// Set the drivers up so health and detection is well tested
const nodeDrivers = node.drivers;
const undetectedDriver = 'raw_exec';
Object.values(nodeDrivers).forEach((driver) => {
driver.Detected = true;
});
nodeDrivers[undetectedDriver].Detected = false;
node.drivers = nodeDrivers;
const drivers = Object.keys(node.drivers)
.map((driverName) =>
assign({ Name: driverName }, node.drivers[driverName])
)
.sortBy('Name');
assert.ok(drivers.length > 0, 'Node has drivers');
await ClientDetail.visit({ id: node.id });
drivers.forEach((driver, index) => {
const driverHead = ClientDetail.driverHeads.objectAt(index);
assert.equal(
driverHead.name,
driver.Name,
`${driver.Name}: Name is correct`
);
assert.equal(
driverHead.detected,
driver.Detected ? 'Yes' : 'No',
`${driver.Name}: Detection is correct`
);
assert.equal(
driverHead.lastUpdated,
moment(driver.UpdateTime).fromNow(),
`${driver.Name}: Last updated shows time since now`
);
if (driver.Name === undetectedDriver) {
assert.notOk(
driverHead.healthIsShown,
`${driver.Name}: No health for the undetected driver`
);
} else {
assert.equal(
driverHead.health,
driver.Healthy ? 'Healthy' : 'Unhealthy',
`${driver.Name}: Health is correct`
);
assert.ok(
driverHead.healthClass.includes(
driver.Healthy ? 'running' : 'failed'
),
`${driver.Name}: Swatch with correct class is shown`
);
}
});
});
test('each driver can be opened to see a message and attributes', async function (assert) {
// Only detected drivers can be expanded
const nodeDrivers = node.drivers;
Object.values(nodeDrivers).forEach((driver) => {
driver.Detected = true;
});
node.drivers = nodeDrivers;
const driver = Object.keys(node.drivers)
.map((driverName) =>
assign({ Name: driverName }, node.drivers[driverName])
)
.sortBy('Name')[0];
await ClientDetail.visit({ id: node.id });
const driverHead = ClientDetail.driverHeads.objectAt(0);
const driverBody = ClientDetail.driverBodies.objectAt(0);
assert.notOk(
driverBody.descriptionIsShown,
'Driver health description is not shown'
);
assert.notOk(
driverBody.attributesAreShown,
'Driver attributes section is not shown'
);
await driverHead.toggle();
assert.equal(
driverBody.description,
driver.HealthDescription,
'Driver health description is now shown'
);
assert.ok(
driverBody.attributesAreShown,
'Driver attributes section is now shown'
);
});
test('the status light indicates when the node is ineligible for scheduling', async function (assert) {
node = server.create('node', {
drain: false,
schedulingEligibility: 'ineligible',
status: 'ready',
});
await ClientDetail.visit({ id: node.id });
assert.equal(
ClientDetail.statusLight.objectAt(0).id,
'ineligible',
'Title status light is in the ineligible state'
);
});
test('when the node has a drain strategy with a positive deadline, the drain stategy section prints the duration', async function (assert) {
const deadline = 5400000000000; // 1.5 hours in nanoseconds
const forceDeadline = moment().add(1, 'd');
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
drainStrategy: {
Deadline: deadline,
ForceDeadline: forceDeadline.toISOString(),
IgnoreSystemJobs: false,
},
});
await ClientDetail.visit({ id: node.id });
assert.ok(
ClientDetail.drainDetails.deadline.includes(forceDeadline.fromNow(true)),
'Deadline is shown in a human formatted way'
);
assert.equal(
ClientDetail.drainDetails.deadlineTooltip,
forceDeadline.format("MMM DD, 'YY HH:mm:ss ZZ"),
'The tooltip for deadline shows the force deadline as an absolute date'
);
assert.ok(
ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'),
'Drain System Jobs state is shown'
);
});
test('when the node has a drain stategy with no deadline, the drain stategy section mentions that and omits the force deadline', async function (assert) {
const deadline = 0;
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
drainStrategy: {
Deadline: deadline,
ForceDeadline: '0001-01-01T00:00:00Z', // null as a date
IgnoreSystemJobs: true,
},
});
await ClientDetail.visit({ id: node.id });
assert.notOk(
ClientDetail.drainDetails.durationIsShown,
'Duration is omitted'
);
assert.ok(
ClientDetail.drainDetails.deadline.includes('No deadline'),
'The value for Deadline is "no deadline"'
);
assert.ok(
ClientDetail.drainDetails.drainSystemJobsText.endsWith('No'),
'Drain System Jobs state is shown'
);
});
test('when the node has a drain stategy with a negative deadline, the drain strategy section shows the force badge', async function (assert) {
const deadline = -1;
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
drainStrategy: {
Deadline: deadline,
ForceDeadline: '0001-01-01T00:00:00Z', // null as a date
IgnoreSystemJobs: false,
},
});
await ClientDetail.visit({ id: node.id });
assert.ok(
ClientDetail.drainDetails.forceDrainText.endsWith('Yes'),
'Forced Drain is described'
);
assert.ok(
ClientDetail.drainDetails.duration.includes('--'),
'Duration is shown but unset'
);
assert.ok(
ClientDetail.drainDetails.deadline.includes('--'),
'Deadline is shown but unset'
);
assert.ok(
ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'),
'Drain System Jobs state is shown'
);
});
test('toggling node eligibility disables the toggle and sends the correct POST request', async function (assert) {
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
server.pretender.post(
'/v1/node/:id/eligibility',
() => [200, {}, ''],
true
);
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.eligibilityToggle.isActive);
ClientDetail.eligibilityToggle.toggle();
await waitUntil(() => nonSearchPOSTS());
assert.ok(ClientDetail.eligibilityToggle.isDisabled);
server.pretender.resolve(server.pretender.requestReferences[0].request);
await settled();
assert.notOk(ClientDetail.eligibilityToggle.isActive);
assert.notOk(ClientDetail.eligibilityToggle.isDisabled);
const request = nonSearchPOSTS()[0];
assert.equal(request.url, `/v1/node/${node.id}/eligibility`);
assert.deepEqual(JSON.parse(request.requestBody), {
NodeID: node.id,
Eligibility: 'ineligible',
});
ClientDetail.eligibilityToggle.toggle();
await waitUntil(() => nonSearchPOSTS().length === 2);
server.pretender.resolve(server.pretender.requestReferences[0].request);
assert.ok(ClientDetail.eligibilityToggle.isActive);
const request2 = nonSearchPOSTS()[1];
assert.equal(request2.url, `/v1/node/${node.id}/eligibility`);
assert.deepEqual(JSON.parse(request2.requestBody), {
NodeID: node.id,
Eligibility: 'eligible',
});
});
test('starting a drain sends the correct POST request', async function (assert) {
let request;
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
await ClientDetail.visit({ id: node.id });
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.submit();
request = nonSearchPOSTS().pop();
assert.equal(request.url, `/v1/node/${node.id}/drain`);
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: 0,
IgnoreSystemJobs: false,
},
},
'Drain with default settings'
);
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.deadlineToggle.toggle();
await ClientDetail.drainPopover.submit();
request = nonSearchPOSTS().pop();
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: 60 * 60 * 1000 * 1000000,
IgnoreSystemJobs: false,
},
},
'Drain with deadline toggled'
);
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.deadlineOptions.open();
await ClientDetail.drainPopover.deadlineOptions.options[1].choose();
await ClientDetail.drainPopover.submit();
request = nonSearchPOSTS().pop();
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: 4 * 60 * 60 * 1000 * 1000000,
IgnoreSystemJobs: false,
},
},
'Drain with non-default preset deadline set'
);
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.deadlineOptions.open();
const optionsCount =
ClientDetail.drainPopover.deadlineOptions.options.length;
await ClientDetail.drainPopover.deadlineOptions.options
.objectAt(optionsCount - 1)
.choose();
await ClientDetail.drainPopover.setCustomDeadline('1h40m20s');
await ClientDetail.drainPopover.submit();
request = nonSearchPOSTS().pop();
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: ((1 * 60 + 40) * 60 + 20) * 1000 * 1000000,
IgnoreSystemJobs: false,
},
},
'Drain with custom deadline set'
);
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.deadlineToggle.toggle();
await ClientDetail.drainPopover.forceDrainToggle.toggle();
await ClientDetail.drainPopover.submit();
request = nonSearchPOSTS().pop();
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: -1,
IgnoreSystemJobs: false,
},
},
'Drain with force set'
);
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.systemJobsToggle.toggle();
await ClientDetail.drainPopover.submit();
request = nonSearchPOSTS().pop();
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: -1,
IgnoreSystemJobs: true,
},
},
'Drain system jobs unset'
);
});
test('starting a drain persists options to localstorage', async function (assert) {
const nodes = server.createList('node', 2, {
drain: false,
schedulingEligibility: 'eligible',
});
await ClientDetail.visit({ id: nodes[0].id });
await ClientDetail.drainPopover.toggle();
// Change all options to non-default values.
await ClientDetail.drainPopover.deadlineToggle.toggle();
await ClientDetail.drainPopover.deadlineOptions.open();
const optionsCount =
ClientDetail.drainPopover.deadlineOptions.options.length;
await ClientDetail.drainPopover.deadlineOptions.options
.objectAt(optionsCount - 1)
.choose();
await ClientDetail.drainPopover.setCustomDeadline('1h40m20s');
await ClientDetail.drainPopover.forceDrainToggle.toggle();
await ClientDetail.drainPopover.systemJobsToggle.toggle();
await ClientDetail.drainPopover.submit();
const got = JSON.parse(window.localStorage.nomadDrainOptions);
const want = {
deadlineEnabled: true,
customDuration: '1h40m20s',
selectedDurationQuickOption: { label: 'Custom', value: 'custom' },
drainSystemJobs: false,
forceDrain: true,
};
assert.deepEqual(got, want);
// Visit another node and check that drain config is persisted.
await ClientDetail.visit({ id: nodes[1].id });
await ClientDetail.drainPopover.toggle();
assert.true(ClientDetail.drainPopover.deadlineToggle.isActive);
assert.equal(ClientDetail.drainPopover.customDeadline, '1h40m20s');
assert.true(ClientDetail.drainPopover.forceDrainToggle.isActive);
assert.false(ClientDetail.drainPopover.systemJobsToggle.isActive);
});
test('the drain popover cancel button closes the popover', async function (assert) {
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
await ClientDetail.visit({ id: node.id });
assert.notOk(ClientDetail.drainPopover.isOpen);
await ClientDetail.drainPopover.toggle();
assert.ok(ClientDetail.drainPopover.isOpen);
await ClientDetail.drainPopover.cancel();
assert.notOk(ClientDetail.drainPopover.isOpen);
assert.equal(nonSearchPOSTS(), 0);
});
test('toggling eligibility is disabled while a drain is active', async function (assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
});
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.eligibilityToggle.isDisabled);
});
test('stopping a drain sends the correct POST request', async function (assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
});
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.stopDrainIsPresent);
await ClientDetail.stopDrain.idle();
await ClientDetail.stopDrain.confirm();
const request = nonSearchPOSTS()[0];
assert.equal(request.url, `/v1/node/${node.id}/drain`);
assert.deepEqual(JSON.parse(request.requestBody), {
NodeID: node.id,
DrainSpec: null,
});
});
test('when a drain is active, the "drain" popover is labeled as the "update" popover', async function (assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
});
await ClientDetail.visit({ id: node.id });
assert.equal(ClientDetail.drainPopover.label, 'Update Drain');
});
test('forcing a drain sends the correct POST request', async function (assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
drainStrategy: {
Deadline: 0,
IgnoreSystemJobs: true,
},
});
await ClientDetail.visit({ id: node.id });
await ClientDetail.drainDetails.force.idle();
await ClientDetail.drainDetails.force.confirm();
const request = nonSearchPOSTS()[0];
assert.equal(request.url, `/v1/node/${node.id}/drain`);
assert.deepEqual(JSON.parse(request.requestBody), {
NodeID: node.id,
DrainSpec: {
Deadline: -1,
IgnoreSystemJobs: true,
},
});
});
test('when stopping a drain fails, an error is shown', async function (assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
});
server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
await ClientDetail.visit({ id: node.id });
await ClientDetail.stopDrain.idle();
await ClientDetail.stopDrain.confirm();
assert.ok(ClientDetail.stopDrainError.isPresent);
assert.ok(ClientDetail.stopDrainError.title.includes('Stop Drain Error'));
await ClientDetail.stopDrainError.dismiss();
assert.notOk(ClientDetail.stopDrainError.isPresent);
});
test('when starting a drain fails, an error message is shown', async function (assert) {
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
await ClientDetail.visit({ id: node.id });
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.submit();
assert.ok(ClientDetail.drainError.isPresent);
assert.ok(ClientDetail.drainError.title.includes('Drain Error'));
await ClientDetail.drainError.dismiss();
assert.notOk(ClientDetail.drainError.isPresent);
});
test('when updating a drain fails, an error message is shown', async function (assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
});
server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
await ClientDetail.visit({ id: node.id });
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.submit();
assert.ok(ClientDetail.drainError.isPresent);
assert.ok(ClientDetail.drainError.title.includes('Drain Error'));
await ClientDetail.drainError.dismiss();
assert.notOk(ClientDetail.drainError.isPresent);
});
test('when toggling eligibility fails, an error message is shown', async function (assert) {
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
server.pretender.post('/v1/node/:id/eligibility', () => [500, {}, '']);
await ClientDetail.visit({ id: node.id });
await ClientDetail.eligibilityToggle.toggle();
assert.ok(ClientDetail.eligibilityError.isPresent);
assert.ok(
ClientDetail.eligibilityError.title.includes('Eligibility Error')
);
await ClientDetail.eligibilityError.dismiss();
assert.notOk(ClientDetail.eligibilityError.isPresent);
});
test('when navigating away from a client that has an error message to another client, the error is not shown', async function (assert) {
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
const node2 = server.create('node');
server.pretender.post('/v1/node/:id/eligibility', () => [500, {}, '']);
await ClientDetail.visit({ id: node.id });
await ClientDetail.eligibilityToggle.toggle();
assert.ok(ClientDetail.eligibilityError.isPresent);
assert.ok(
ClientDetail.eligibilityError.title.includes('Eligibility Error')
);
await ClientDetail.visit({ id: node2.id });
assert.notOk(ClientDetail.eligibilityError.isPresent);
});
test('toggling eligibility and node drain are disabled when the active ACL token does not permit node write', async function (assert) {
window.localStorage.nomadTokenSecret = clientToken.secretId;
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.eligibilityToggle.isDisabled);
assert.ok(ClientDetail.drainPopover.isDisabled);
});
test('the host volumes table lists all host volumes in alphabetical order by name', async function (assert) {
await ClientDetail.visit({ id: node.id });
const sortedHostVolumes = Object.keys(node.hostVolumes)
.map((key) => node.hostVolumes[key])
.sortBy('Name');
assert.ok(ClientDetail.hasHostVolumes);
assert.equal(
ClientDetail.hostVolumes.length,
Object.keys(node.hostVolumes).length
);
ClientDetail.hostVolumes.forEach((volume, index) => {
assert.equal(volume.name, sortedHostVolumes[index].Name);
});
});
test('each host volume row contains information about the host volume', async function (assert) {
await ClientDetail.visit({ id: node.id });
const sortedHostVolumes = Object.keys(node.hostVolumes)
.map((key) => node.hostVolumes[key])
.sortBy('Name');
ClientDetail.hostVolumes[0].as((volume) => {
const volumeRow = sortedHostVolumes[0];
assert.equal(volume.name, volumeRow.Name);
assert.equal(volume.path, volumeRow.Path);
assert.equal(
volume.permissions,
volumeRow.ReadOnly ? 'Read' : 'Read/Write'
);
});
});
test('the host volumes table is not shown if the client has no host volumes', async function (assert) {
node = server.create('node', 'noHostVolumes');
await ClientDetail.visit({ id: node.id });
assert.notOk(ClientDetail.hasHostVolumes);
});
testFacet('Job', {
facet: ClientDetail.facets.job,
paramName: 'job',
expectedOptions(allocs) {
return Array.from(new Set(allocs.mapBy('jobId'))).sort();
},
async beforeEach() {
server.createList('job', 5);
await ClientDetail.visit({ id: node.id });
},
filter: (alloc, selection) => selection.includes(alloc.jobId),
});
testFacet('Status', {
facet: ClientDetail.facets.status,
paramName: 'status',
expectedOptions: ['Pending', 'Running', 'Complete', 'Failed', 'Lost'],
async beforeEach() {
server.createList('job', 5, { createAllocations: false });
['pending', 'running', 'complete', 'failed', 'lost'].forEach((s) => {
server.createList('allocation', 5, { clientStatus: s });
});
await ClientDetail.visit({ id: node.id });
},
filter: (alloc, selection) => selection.includes(alloc.clientStatus),
});
test('fiter results with no matches display empty message', async function (assert) {
const job = server.create('job', { createAllocations: false });
server.create('allocation', { jobId: job.id, clientStatus: 'running' });
await ClientDetail.visit({ id: node.id });
const statusFacet = ClientDetail.facets.status;
await statusFacet.toggle();
await statusFacet.options.objectAt(0).toggle();
assert.true(ClientDetail.emptyAllocations.isVisible);
assert.equal(ClientDetail.emptyAllocations.headline, 'No Matches');
});
});
module('Acceptance | client detail (multi-namespace)', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
server.create('node', 'forceIPv4', { schedulingEligibility: 'eligible' });
node = server.db.nodes[0];
// Related models
server.create('namespace');
server.create('namespace', { id: 'other-namespace' });
server.create('agent');
// Make a job for each namespace, but have both scheduled on the same node
server.create('job', {
id: 'job-1',
namespaceId: 'default',
createAllocations: false,
});
server.createList('allocation', 3, {
nodeId: node.id,
jobId: 'job-1',
clientStatus: 'running',
});
server.create('job', {
id: 'job-2',
namespaceId: 'other-namespace',
createAllocations: false,
});
server.createList('allocation', 3, {
nodeId: node.id,
jobId: 'job-2',
clientStatus: 'running',
});
});
test('when the node has allocations on different namespaces, the associated jobs are fetched correctly', async function (assert) {
window.localStorage.nomadActiveNamespace = 'other-namespace';
await ClientDetail.visit({ id: node.id });
assert.equal(
ClientDetail.allocations.length,
server.db.allocations.length,
'All allocations are scheduled on this node'
);
assert.ok(
server.pretender.handledRequests.findBy('url', '/v1/job/job-1'),
'Job One fetched correctly'
);
assert.ok(
server.pretender.handledRequests.findBy(
'url',
'/v1/job/job-2?namespace=other-namespace'
),
'Job Two fetched correctly'
);
});
testFacet('Namespace', {
facet: ClientDetail.facets.namespace,
paramName: 'namespace',
expectedOptions(allocs) {
return Array.from(new Set(allocs.mapBy('namespace'))).sort();
},
async beforeEach() {
await ClientDetail.visit({ id: node.id });
},
filter: (alloc, selection) => selection.includes(alloc.namespace),
});
test('facet Namespace | selecting namespace filters job options', async function (assert) {
await ClientDetail.visit({ id: node.id });
const nsFacet = ClientDetail.facets.namespace;
const jobFacet = ClientDetail.facets.job;
// Select both namespaces.
await nsFacet.toggle();
await nsFacet.options.objectAt(0).toggle();
await nsFacet.options.objectAt(1).toggle();
await jobFacet.toggle();
assert.deepEqual(
jobFacet.options.map((option) => option.label.trim()),
['job-1', 'job-2']
);
// Select juse one namespace.
await nsFacet.toggle();
await nsFacet.options.objectAt(1).toggle(); // deselect second option
await jobFacet.toggle();
assert.deepEqual(
jobFacet.options.map((option) => option.label.trim()),
['job-1']
);
});
});
function testFacet(
label,
{ facet, paramName, beforeEach, filter, expectedOptions }
) {
test(`facet ${label} | the ${label} facet has the correct options`, async function (assert) {
await beforeEach();
await facet.toggle();
let expectation;
if (typeof expectedOptions === 'function') {
expectation = expectedOptions(server.db.allocations);
} else {
expectation = expectedOptions;
}
assert.deepEqual(
facet.options.map((option) => option.label.trim()),
expectation,
'Options for facet are as expected'
);
});
test(`facet ${label} | the ${label} facet filters the allocations list by ${label}`, async function (assert) {
let option;
await beforeEach();
await facet.toggle();
option = facet.options.objectAt(0);
await option.toggle();
const selection = [option.key];
const expectedAllocs = server.db.allocations
.filter((alloc) => filter(alloc, selection))
.sortBy('modifyIndex')
.reverse();
ClientDetail.allocations.forEach((alloc, index) => {
assert.equal(
alloc.id,
expectedAllocs[index].id,
`Allocation at ${index} is ${expectedAllocs[index].id}`
);
});
});
test(`facet ${label} | selecting multiple options in the ${label} facet results in a broader search`, async function (assert) {
const selection = [];
await beforeEach();
await facet.toggle();
const option1 = facet.options.objectAt(0);
const option2 = facet.options.objectAt(1);
await option1.toggle();
selection.push(option1.key);
await option2.toggle();
selection.push(option2.key);
const expectedAllocs = server.db.allocations
.filter((alloc) => filter(alloc, selection))
.sortBy('modifyIndex')
.reverse();
ClientDetail.allocations.forEach((alloc, index) => {
assert.equal(
alloc.id,
expectedAllocs[index].id,
`Allocation at ${index} is ${expectedAllocs[index].id}`
);
});
});
test(`facet ${label} | selecting options in the ${label} facet updates the ${paramName} query param`, async function (assert) {
const selection = [];
await beforeEach();
await facet.toggle();
const option1 = facet.options.objectAt(0);
const option2 = facet.options.objectAt(1);
await option1.toggle();
selection.push(option1.key);
await option2.toggle();
selection.push(option2.key);
assert.equal(
currentURL(),
`/clients/${node.id}?${paramName}=${encodeURIComponent(
JSON.stringify(selection)
)}`,
'URL has the correct query param key and value'
);
});
}