github.com/hernad/nomad@v1.6.112/ui/tests/acceptance/client-detail-test.js (about)

     1  /**
     2   * Copyright (c) HashiCorp, Inc.
     3   * SPDX-License-Identifier: MPL-2.0
     4   */
     5  
     6  /* eslint-disable qunit/require-expect */
     7  /* eslint-disable qunit/no-conditional-assertions */
     8  /* Mirage fixtures are random so we can't expect a set number of assertions */
     9  import {
    10    currentURL,
    11    waitUntil,
    12    settled,
    13    click,
    14    fillIn,
    15    triggerEvent,
    16    findAll,
    17  } from '@ember/test-helpers';
    18  import { assign } from '@ember/polyfills';
    19  import { module, test } from 'qunit';
    20  import { setupApplicationTest } from 'ember-qunit';
    21  import { setupMirage } from 'ember-cli-mirage/test-support';
    22  import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
    23  import { formatBytes, formatHertz } from 'nomad-ui/utils/units';
    24  import moment from 'moment';
    25  import ClientDetail from 'nomad-ui/tests/pages/clients/detail';
    26  import Clients from 'nomad-ui/tests/pages/clients/list';
    27  import Jobs from 'nomad-ui/tests/pages/jobs/list';
    28  import Layout from 'nomad-ui/tests/pages/layout';
    29  
    30  let node;
    31  let managementToken;
    32  let clientToken;
    33  
    34  const wasPreemptedFilter = (allocation) => !!allocation.preemptedByAllocation;
    35  
    36  function nonSearchPOSTS() {
    37    return server.pretender.handledRequests
    38      .reject((request) => request.url.includes('fuzzy'))
    39      .filterBy('method', 'POST');
    40  }
    41  
    42  module('Acceptance | client detail', function (hooks) {
    43    setupApplicationTest(hooks);
    44    setupMirage(hooks);
    45  
    46    hooks.beforeEach(function () {
    47      window.localStorage.clear();
    48  
    49      server.create('node-pool');
    50      server.create('node', 'forceIPv4', { schedulingEligibility: 'eligible' });
    51      node = server.db.nodes[0];
    52  
    53      managementToken = server.create('token');
    54      clientToken = server.create('token');
    55  
    56      window.localStorage.nomadTokenSecret = managementToken.secretId;
    57  
    58      // Related models
    59      server.create('agent');
    60      server.create('job', { createAllocations: false });
    61      server.createList('allocation', 3);
    62      server.create('allocation', 'preempted');
    63  
    64      // Force all allocations into the running state so now allocation rows are missing
    65      // CPU/Mem runtime metrics
    66      server.schema.allocations.all().models.forEach((allocation) => {
    67        allocation.update({ clientStatus: 'running' });
    68      });
    69    });
    70  
    71    test('it passes an accessibility audit', async function (assert) {
    72      await ClientDetail.visit({ id: node.id });
    73      await a11yAudit(assert);
    74    });
    75  
    76    test('/clients/:id should have a breadcrumb trail linking back to clients', async function (assert) {
    77      await ClientDetail.visit({ id: node.id });
    78  
    79      assert.ok(document.title.includes(`Client ${node.name}`));
    80  
    81      assert.equal(
    82        Layout.breadcrumbFor('clients.index').text,
    83        'Clients',
    84        'First breadcrumb says clients'
    85      );
    86      assert.equal(
    87        Layout.breadcrumbFor('clients.client').text,
    88        `Client ${node.id.split('-')[0]}`,
    89        'Second breadcrumb is a titled breadcrumb saying the node short id'
    90      );
    91      await Layout.breadcrumbFor('clients.index').visit();
    92      assert.equal(
    93        currentURL(),
    94        '/clients',
    95        'First breadcrumb links back to clients'
    96      );
    97    });
    98  
    99    test('/clients/:id should list immediate details for the node in the title', async function (assert) {
   100      node = server.create('node', 'forceIPv4', {
   101        schedulingEligibility: 'eligible',
   102        drain: false,
   103      });
   104  
   105      await ClientDetail.visit({ id: node.id });
   106  
   107      assert.ok(ClientDetail.title.includes(node.name), 'Title includes name');
   108      assert.ok(ClientDetail.clientId.includes(node.id), 'Title includes id');
   109      assert.equal(
   110        ClientDetail.statusLight.objectAt(0).id,
   111        node.status,
   112        'Title includes status light'
   113      );
   114    });
   115  
   116    test('/clients/:id should list additional detail for the node below the title', async function (assert) {
   117      await ClientDetail.visit({ id: node.id });
   118  
   119      assert.ok(
   120        ClientDetail.statusDefinition.includes(node.status),
   121        'Status is in additional details'
   122      );
   123      assert.ok(
   124        ClientDetail.statusDecorationClass.includes(`node-${node.status}`),
   125        'Status is decorated with a status class'
   126      );
   127      assert.ok(
   128        ClientDetail.addressDefinition.includes(node.httpAddr),
   129        'Address is in additional details'
   130      );
   131      assert.ok(
   132        ClientDetail.datacenterDefinition.includes(node.datacenter),
   133        'Datacenter is in additional details'
   134      );
   135    });
   136  
   137    test('/clients/:id should include resource utilization graphs', async function (assert) {
   138      await ClientDetail.visit({ id: node.id });
   139  
   140      assert.equal(
   141        ClientDetail.resourceCharts.length,
   142        2,
   143        'Two resource utilization graphs'
   144      );
   145      assert.equal(
   146        ClientDetail.resourceCharts.objectAt(0).name,
   147        'CPU',
   148        'First chart is CPU'
   149      );
   150      assert.equal(
   151        ClientDetail.resourceCharts.objectAt(1).name,
   152        'Memory',
   153        'Second chart is Memory'
   154      );
   155    });
   156  
   157    test('/clients/:id should list all allocations on the node', async function (assert) {
   158      const allocationsCount = server.db.allocations.where({
   159        nodeId: node.id,
   160      }).length;
   161  
   162      await ClientDetail.visit({ id: node.id });
   163  
   164      assert.equal(
   165        ClientDetail.allocations.length,
   166        allocationsCount,
   167        `Allocations table lists all ${allocationsCount} associated allocations`
   168      );
   169    });
   170  
   171    test('/clients/:id should show empty message if there are no allocations on the node', async function (assert) {
   172      const emptyNode = server.create('node');
   173  
   174      await ClientDetail.visit({ id: emptyNode.id });
   175  
   176      assert.true(
   177        ClientDetail.emptyAllocations.isVisible,
   178        'Empty message is visible'
   179      );
   180      assert.equal(ClientDetail.emptyAllocations.headline, 'No Allocations');
   181    });
   182  
   183    test('each allocation should have high-level details for the allocation', async function (assert) {
   184      const allocation = server.db.allocations
   185        .where({ nodeId: node.id })
   186        .sortBy('modifyIndex')
   187        .reverse()[0];
   188  
   189      const allocStats = server.db.clientAllocationStats.find(allocation.id);
   190      const taskGroup = server.db.taskGroups.findBy({
   191        name: allocation.taskGroup,
   192        jobId: allocation.jobId,
   193      });
   194  
   195      const tasks = taskGroup.taskIds.map((id) => server.db.tasks.find(id));
   196      const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
   197      const memoryUsed = tasks.reduce(
   198        (sum, task) => sum + task.resources.MemoryMB,
   199        0
   200      );
   201  
   202      await ClientDetail.visit({ id: node.id });
   203  
   204      const allocationRow = ClientDetail.allocations.objectAt(0);
   205  
   206      assert.equal(
   207        allocationRow.shortId,
   208        allocation.id.split('-')[0],
   209        'Allocation short ID'
   210      );
   211      assert.equal(
   212        allocationRow.createTime,
   213        moment(allocation.createTime / 1000000).format('MMM DD HH:mm:ss ZZ'),
   214        'Allocation create time'
   215      );
   216      assert.equal(
   217        allocationRow.modifyTime,
   218        moment(allocation.modifyTime / 1000000).fromNow(),
   219        'Allocation modify time'
   220      );
   221      assert.equal(
   222        allocationRow.status,
   223        allocation.clientStatus,
   224        'Client status'
   225      );
   226      assert.equal(
   227        allocationRow.job,
   228        server.db.jobs.find(allocation.jobId).name,
   229        'Job name'
   230      );
   231      assert.ok(allocationRow.taskGroup, 'Task group name');
   232      assert.ok(allocationRow.jobVersion, 'Job Version');
   233      assert.equal(allocationRow.volume, 'Yes', 'Volume');
   234      assert.equal(
   235        allocationRow.cpu,
   236        Math.floor(allocStats.resourceUsage.CpuStats.TotalTicks) / cpuUsed,
   237        'CPU %'
   238      );
   239      const roundedTicks = Math.floor(
   240        allocStats.resourceUsage.CpuStats.TotalTicks
   241      );
   242      assert.equal(
   243        allocationRow.cpuTooltip,
   244        `${formatHertz(roundedTicks, 'MHz')} / ${formatHertz(cpuUsed, 'MHz')}`,
   245        'Detailed CPU information is in a tooltip'
   246      );
   247      assert.equal(
   248        allocationRow.mem,
   249        allocStats.resourceUsage.MemoryStats.RSS / 1024 / 1024 / memoryUsed,
   250        'Memory used'
   251      );
   252      assert.equal(
   253        allocationRow.memTooltip,
   254        `${formatBytes(allocStats.resourceUsage.MemoryStats.RSS)} / ${formatBytes(
   255          memoryUsed,
   256          'MiB'
   257        )}`,
   258        'Detailed memory information is in a tooltip'
   259      );
   260    });
   261  
   262    test('each allocation should show job information even if the job is incomplete and already in the store', async function (assert) {
   263      // First, visit clients to load the allocations for each visible node.
   264      // Don't load the job belongsTo of the allocation! Leave it unfulfilled.
   265  
   266      await Clients.visit();
   267  
   268      // Then, visit jobs to load all jobs, which should implicitly fulfill
   269      // the job belongsTo of each allocation pointed at each job.
   270  
   271      await Jobs.visit();
   272  
   273      // Finally, visit a node to assert that the job name and task group name are
   274      // present. This will require reloading the job, since task groups aren't a
   275      // part of the jobs list response.
   276  
   277      await ClientDetail.visit({ id: node.id });
   278  
   279      const allocationRow = ClientDetail.allocations.objectAt(0);
   280      const allocation = server.db.allocations
   281        .where({ nodeId: node.id })
   282        .sortBy('modifyIndex')
   283        .reverse()[0];
   284  
   285      assert.equal(
   286        allocationRow.job,
   287        server.db.jobs.find(allocation.jobId).name,
   288        'Job name'
   289      );
   290      assert.ok(
   291        allocationRow.taskGroup.includes(allocation.taskGroup),
   292        'Task group name'
   293      );
   294    });
   295  
   296    test('each allocation should link to the allocation detail page', async function (assert) {
   297      const allocation = server.db.allocations
   298        .where({ nodeId: node.id })
   299        .sortBy('modifyIndex')
   300        .reverse()[0];
   301  
   302      await ClientDetail.visit({ id: node.id });
   303      await ClientDetail.allocations.objectAt(0).visit();
   304  
   305      assert.equal(
   306        currentURL(),
   307        `/allocations/${allocation.id}`,
   308        'Allocation rows link to allocation detail pages'
   309      );
   310    });
   311  
   312    test('each allocation should link to the job the allocation belongs to', async function (assert) {
   313      await ClientDetail.visit({ id: node.id });
   314  
   315      const allocation = server.db.allocations.where({ nodeId: node.id })[0];
   316      const job = server.db.jobs.find(allocation.jobId);
   317  
   318      await ClientDetail.allocations.objectAt(0).visitJob();
   319  
   320      assert.equal(
   321        currentURL(),
   322        `/jobs/${job.id}@default`,
   323        'Allocation rows link to the job detail page for the allocation'
   324      );
   325    });
   326  
   327    test('the allocation section should show the count of preempted allocations on the client', async function (assert) {
   328      const allocations = server.db.allocations.where({ nodeId: node.id });
   329  
   330      await ClientDetail.visit({ id: node.id });
   331  
   332      assert.equal(
   333        ClientDetail.allocationFilter.allCount,
   334        allocations.length,
   335        'All filter/badge shows all allocations count'
   336      );
   337      assert.ok(
   338        ClientDetail.allocationFilter.preemptionsCount.startsWith(
   339          allocations.filter(wasPreemptedFilter).length
   340        ),
   341        'Preemptions filter/badge shows preempted allocations count'
   342      );
   343    });
   344  
   345    test('clicking the preemption badge filters the allocations table and sets a query param', async function (assert) {
   346      const allocations = server.db.allocations.where({ nodeId: node.id });
   347  
   348      await ClientDetail.visit({ id: node.id });
   349      await ClientDetail.allocationFilter.preemptions();
   350  
   351      assert.equal(
   352        ClientDetail.allocations.length,
   353        allocations.filter(wasPreemptedFilter).length,
   354        'Only preempted allocations are shown'
   355      );
   356      assert.equal(
   357        currentURL(),
   358        `/clients/${node.id}?preemptions=true`,
   359        'Filter is persisted in the URL'
   360      );
   361    });
   362  
   363    test('clicking the total allocations badge resets the filter and removes the query param', async function (assert) {
   364      const allocations = server.db.allocations.where({ nodeId: node.id });
   365  
   366      await ClientDetail.visit({ id: node.id });
   367      await ClientDetail.allocationFilter.preemptions();
   368      await ClientDetail.allocationFilter.all();
   369  
   370      assert.equal(
   371        ClientDetail.allocations.length,
   372        allocations.length,
   373        'All allocations are shown'
   374      );
   375      assert.equal(
   376        currentURL(),
   377        `/clients/${node.id}`,
   378        'Filter is persisted in the URL'
   379      );
   380    });
   381  
   382    test('navigating directly to the client detail page with the preemption query param set will filter the allocations table', async function (assert) {
   383      const allocations = server.db.allocations.where({ nodeId: node.id });
   384  
   385      await ClientDetail.visit({ id: node.id, preemptions: true });
   386  
   387      assert.equal(
   388        ClientDetail.allocations.length,
   389        allocations.filter(wasPreemptedFilter).length,
   390        'Only preempted allocations are shown'
   391      );
   392    });
   393  
   394    test('/clients/:id should list all attributes for the node', async function (assert) {
   395      await ClientDetail.visit({ id: node.id });
   396  
   397      assert.ok(ClientDetail.attributesTable, 'Attributes table is on the page');
   398    });
   399  
   400    test('/clients/:id lists all meta attributes', async function (assert) {
   401      node = server.create('node', 'forceIPv4', 'withMeta');
   402  
   403      await ClientDetail.visit({ id: node.id });
   404  
   405      assert.ok(ClientDetail.metaTable, 'Meta attributes table is on the page');
   406      assert.notOk(ClientDetail.emptyMetaMessage, 'Meta attributes is not empty');
   407  
   408      const firstMetaKey = Object.keys(node.meta)[0];
   409      const firstMetaAttribute = ClientDetail.metaAttributes.objectAt(0);
   410      assert.equal(
   411        firstMetaAttribute.key,
   412        firstMetaKey,
   413        'Meta attributes for the node are bound to the attributes table'
   414      );
   415      assert.equal(
   416        firstMetaAttribute.value,
   417        node.meta[firstMetaKey],
   418        'Meta attributes for the node are bound to the attributes table'
   419      );
   420    });
   421  
   422    test('node metadata is uneditable by default', async function (assert) {
   423      window.localStorage.nomadTokenSecret = clientToken.secretId;
   424      node = server.create('node', 'forceIPv4', 'withMeta');
   425      await ClientDetail.visit({ id: node.id });
   426  
   427      assert.dom('.edit-existing-metadata-button').exists({ count: 0 });
   428      assert.dom('.add-dynamic-metadata').doesNotExist();
   429    });
   430  
   431    test('node metadata is editable by managers', async function (assert) {
   432      window.localStorage.nomadTokenSecret = managementToken.secretId;
   433      node = server.create('node', 'forceIPv4', 'withMeta');
   434      await ClientDetail.visit({ id: node.id });
   435  
   436      const numberOfExistingMetaKeys = Object.keys(node.meta).length;
   437      assert
   438        .dom('.edit-existing-metadata-button')
   439        .exists({ count: numberOfExistingMetaKeys });
   440      assert.dom('.add-dynamic-metadata').exists();
   441    });
   442  
   443    test('metadata can be added and removed', async function (assert) {
   444      window.localStorage.nomadTokenSecret = managementToken.secretId;
   445      node = server.create('node', 'forceIPv4', 'withMeta');
   446      await ClientDetail.visit({ id: node.id });
   447  
   448      const numberOfExistingMetaKeys = Object.keys(node.meta).length;
   449      assert
   450        .dom('.edit-existing-metadata-button')
   451        .exists({ count: numberOfExistingMetaKeys });
   452      assert.dom('.add-dynamic-metadata').exists();
   453      await click('.add-dynamic-metadata button');
   454      assert.dom('[data-test-new-metadata-button]').isDisabled();
   455      await fillIn('#new-meta-key', 'newKey');
   456      await fillIn('[data-test-metadata-editor-value]', 'newValue');
   457      assert.dom('[data-test-new-metadata-button]').isNotDisabled();
   458      await click('[data-test-new-metadata-button]');
   459      assert
   460        .dom('.edit-existing-metadata-button')
   461        .exists(
   462          { count: numberOfExistingMetaKeys + 1 },
   463          'newly added item appears'
   464        );
   465  
   466      // find the newly added one and edit it
   467      assert.dom('.metadata-editor').doesNotExist();
   468      const newMetaRow = [...findAll('[data-test-attributes-section]')].filter(
   469        (a) => a.textContent.includes('newKey')
   470      )[0];
   471  
   472      await click(newMetaRow.querySelector('.edit-existing-metadata-button'));
   473      assert.dom('.metadata-editor').exists();
   474      assert.dom('.constant-key').exists('existing key shown but uneditable');
   475      await click('[data-test-delete-metadata]');
   476      assert
   477        .dom('.edit-existing-metadata-button')
   478        .exists({ count: numberOfExistingMetaKeys }, 'newly added item is gone');
   479    });
   480  
   481    test('metadata can be edited', async function (assert) {
   482      window.localStorage.nomadTokenSecret = managementToken.secretId;
   483      node = server.create(
   484        'node',
   485        {
   486          meta: {
   487            existingKey: 'existingValue',
   488            'existing.nested.foo': '1',
   489            'existing.nested.bar': '2',
   490          },
   491        },
   492        'forceIPv4',
   493        'withMeta'
   494      );
   495      await ClientDetail.visit({ id: node.id });
   496  
   497      const numberOfExistingMetaKeys = Object.keys(node.meta).length;
   498      assert
   499        .dom('.edit-existing-metadata-button')
   500        .exists({ count: numberOfExistingMetaKeys });
   501  
   502      const topLevelMetaRow = [
   503        ...findAll('[data-test-attributes-section]'),
   504      ].filter((a) => a.textContent.includes('existingKey'))[0];
   505  
   506      await click(
   507        topLevelMetaRow.querySelector('.edit-existing-metadata-button')
   508      );
   509      assert.dom('.metadata-editor').exists();
   510      assert.dom('.constant-key').exists('existing key shown but uneditable');
   511      assert.dom('[data-test-metadata-editor-value]').hasValue('existingValue');
   512      await fillIn('[data-test-metadata-editor-value]', 'newValue');
   513      await click('[data-test-update-metadata]');
   514      assert.dom('.metadata-editor').doesNotExist();
   515      const editedRow = [...findAll('[data-test-attributes-section]')].filter(
   516        (a) => a.textContent.includes('existingKey')
   517      )[0];
   518      assert.dom(editedRow).containsText('newValue', 'value updated');
   519  
   520      // Cancellable by click
   521      await click(editedRow.querySelector('.edit-existing-metadata-button'));
   522      assert.dom('.metadata-editor').exists();
   523      await click('[data-test-cancel-metadata]');
   524      assert.dom('.metadata-editor').doesNotExist();
   525  
   526      // Cancellable by typing escape
   527      await click(editedRow.querySelector('.edit-existing-metadata-button'));
   528      assert.dom('.metadata-editor').exists();
   529      await triggerEvent('[data-test-metadata-editor-value]', 'keyup', {
   530        key: 'Escape',
   531      });
   532      assert.dom('.metadata-editor').doesNotExist();
   533    });
   534  
   535    test('/clients/:id shows an empty message when there is no meta data', async function (assert) {
   536      await ClientDetail.visit({ id: node.id });
   537  
   538      assert.notOk(
   539        ClientDetail.metaTable,
   540        'Meta attributes table is not on the page'
   541      );
   542      assert.ok(ClientDetail.emptyMetaMessage, 'Meta attributes is empty');
   543    });
   544  
   545    test('when the node is not found, an error message is shown, but the URL persists', async function (assert) {
   546      await ClientDetail.visit({ id: 'not-a-real-node' });
   547  
   548      assert.equal(
   549        server.pretender.handledRequests
   550          .filter((request) => !request.url.includes('policy'))
   551          .findBy('status', 404).url,
   552        '/v1/node/not-a-real-node',
   553        'A request to the nonexistent node is made'
   554      );
   555      assert.equal(currentURL(), '/clients/not-a-real-node', 'The URL persists');
   556      assert.ok(ClientDetail.error.isShown, 'Error message is shown');
   557      assert.equal(
   558        ClientDetail.error.title,
   559        'Not Found',
   560        'Error message is for 404'
   561      );
   562    });
   563  
   564    test('/clients/:id shows the recent events list', async function (assert) {
   565      await ClientDetail.visit({ id: node.id });
   566  
   567      assert.ok(ClientDetail.hasEvents, 'Client events section exists');
   568    });
   569  
   570    test('each node event shows basic node event information', async function (assert) {
   571      const event = server.db.nodeEvents
   572        .where({ nodeId: node.id })
   573        .sortBy('time')
   574        .reverse()[0];
   575  
   576      await ClientDetail.visit({ id: node.id });
   577  
   578      const eventRow = ClientDetail.events.objectAt(0);
   579      assert.equal(
   580        eventRow.time,
   581        moment(event.time).format("MMM DD, 'YY HH:mm:ss ZZ"),
   582        'Event timestamp'
   583      );
   584      assert.equal(eventRow.subsystem, event.subsystem, 'Event subsystem');
   585      assert.equal(eventRow.message, event.message, 'Event message');
   586    });
   587  
   588    test('/clients/:id shows the driver status of every driver for the node', async function (assert) {
   589      // Set the drivers up so health and detection is well tested
   590      const nodeDrivers = node.drivers;
   591      const undetectedDriver = 'raw_exec';
   592  
   593      Object.values(nodeDrivers).forEach((driver) => {
   594        driver.Detected = true;
   595      });
   596  
   597      nodeDrivers[undetectedDriver].Detected = false;
   598      node.drivers = nodeDrivers;
   599  
   600      const drivers = Object.keys(node.drivers)
   601        .map((driverName) =>
   602          assign({ Name: driverName }, node.drivers[driverName])
   603        )
   604        .sortBy('Name');
   605  
   606      assert.ok(drivers.length > 0, 'Node has drivers');
   607  
   608      await ClientDetail.visit({ id: node.id });
   609  
   610      drivers.forEach((driver, index) => {
   611        const driverHead = ClientDetail.driverHeads.objectAt(index);
   612  
   613        assert.equal(
   614          driverHead.name,
   615          driver.Name,
   616          `${driver.Name}: Name is correct`
   617        );
   618        assert.equal(
   619          driverHead.detected,
   620          driver.Detected ? 'Yes' : 'No',
   621          `${driver.Name}: Detection is correct`
   622        );
   623        assert.equal(
   624          driverHead.lastUpdated,
   625          moment(driver.UpdateTime).fromNow(),
   626          `${driver.Name}: Last updated shows time since now`
   627        );
   628  
   629        if (driver.Name === undetectedDriver) {
   630          assert.notOk(
   631            driverHead.healthIsShown,
   632            `${driver.Name}: No health for the undetected driver`
   633          );
   634        } else {
   635          assert.equal(
   636            driverHead.health,
   637            driver.Healthy ? 'Healthy' : 'Unhealthy',
   638            `${driver.Name}: Health is correct`
   639          );
   640          assert.ok(
   641            driverHead.healthClass.includes(
   642              driver.Healthy ? 'running' : 'failed'
   643            ),
   644            `${driver.Name}: Swatch with correct class is shown`
   645          );
   646        }
   647      });
   648    });
   649  
   650    test('each driver can be opened to see a message and attributes', async function (assert) {
   651      // Only detected drivers can be expanded
   652      const nodeDrivers = node.drivers;
   653      Object.values(nodeDrivers).forEach((driver) => {
   654        driver.Detected = true;
   655      });
   656      node.drivers = nodeDrivers;
   657  
   658      const driver = Object.keys(node.drivers)
   659        .map((driverName) =>
   660          assign({ Name: driverName }, node.drivers[driverName])
   661        )
   662        .sortBy('Name')[0];
   663  
   664      await ClientDetail.visit({ id: node.id });
   665      const driverHead = ClientDetail.driverHeads.objectAt(0);
   666      const driverBody = ClientDetail.driverBodies.objectAt(0);
   667  
   668      assert.notOk(
   669        driverBody.descriptionIsShown,
   670        'Driver health description is not shown'
   671      );
   672      assert.notOk(
   673        driverBody.attributesAreShown,
   674        'Driver attributes section is not shown'
   675      );
   676  
   677      await driverHead.toggle();
   678      assert.equal(
   679        driverBody.description,
   680        driver.HealthDescription,
   681        'Driver health description is now shown'
   682      );
   683      assert.ok(
   684        driverBody.attributesAreShown,
   685        'Driver attributes section is now shown'
   686      );
   687    });
   688  
   689    test('the status light indicates when the node is ineligible for scheduling', async function (assert) {
   690      node = server.create('node', {
   691        drain: false,
   692        schedulingEligibility: 'ineligible',
   693        status: 'ready',
   694      });
   695  
   696      await ClientDetail.visit({ id: node.id });
   697  
   698      assert.equal(
   699        ClientDetail.statusLight.objectAt(0).id,
   700        'ineligible',
   701        'Title status light is in the ineligible state'
   702      );
   703    });
   704  
   705    test('when the node has a drain strategy with a positive deadline, the drain stategy section prints the duration', async function (assert) {
   706      const deadline = 5400000000000; // 1.5 hours in nanoseconds
   707      const forceDeadline = moment().add(1, 'd');
   708  
   709      node = server.create('node', {
   710        drain: true,
   711        schedulingEligibility: 'ineligible',
   712        drainStrategy: {
   713          Deadline: deadline,
   714          ForceDeadline: forceDeadline.toISOString(),
   715          IgnoreSystemJobs: false,
   716        },
   717      });
   718  
   719      await ClientDetail.visit({ id: node.id });
   720  
   721      assert.ok(
   722        ClientDetail.drainDetails.deadline.includes(forceDeadline.fromNow(true)),
   723        'Deadline is shown in a human formatted way'
   724      );
   725  
   726      assert.equal(
   727        ClientDetail.drainDetails.deadlineTooltip,
   728        forceDeadline.format("MMM DD, 'YY HH:mm:ss ZZ"),
   729        'The tooltip for deadline shows the force deadline as an absolute date'
   730      );
   731  
   732      assert.ok(
   733        ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'),
   734        'Drain System Jobs state is shown'
   735      );
   736    });
   737  
   738    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) {
   739      const deadline = 0;
   740  
   741      node = server.create('node', {
   742        drain: true,
   743        schedulingEligibility: 'ineligible',
   744        drainStrategy: {
   745          Deadline: deadline,
   746          ForceDeadline: '0001-01-01T00:00:00Z', // null as a date
   747          IgnoreSystemJobs: true,
   748        },
   749      });
   750  
   751      await ClientDetail.visit({ id: node.id });
   752  
   753      assert.notOk(
   754        ClientDetail.drainDetails.durationIsShown,
   755        'Duration is omitted'
   756      );
   757  
   758      assert.ok(
   759        ClientDetail.drainDetails.deadline.includes('No deadline'),
   760        'The value for Deadline is "no deadline"'
   761      );
   762  
   763      assert.ok(
   764        ClientDetail.drainDetails.drainSystemJobsText.endsWith('No'),
   765        'Drain System Jobs state is shown'
   766      );
   767    });
   768  
   769    test('when the node has a drain stategy with a negative deadline, the drain strategy section shows the force badge', async function (assert) {
   770      const deadline = -1;
   771  
   772      node = server.create('node', {
   773        drain: true,
   774        schedulingEligibility: 'ineligible',
   775        drainStrategy: {
   776          Deadline: deadline,
   777          ForceDeadline: '0001-01-01T00:00:00Z', // null as a date
   778          IgnoreSystemJobs: false,
   779        },
   780      });
   781  
   782      await ClientDetail.visit({ id: node.id });
   783  
   784      assert.ok(
   785        ClientDetail.drainDetails.forceDrainText.endsWith('Yes'),
   786        'Forced Drain is described'
   787      );
   788  
   789      assert.ok(
   790        ClientDetail.drainDetails.duration.includes('--'),
   791        'Duration is shown but unset'
   792      );
   793  
   794      assert.ok(
   795        ClientDetail.drainDetails.deadline.includes('--'),
   796        'Deadline is shown but unset'
   797      );
   798  
   799      assert.ok(
   800        ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'),
   801        'Drain System Jobs state is shown'
   802      );
   803    });
   804  
   805    test('toggling node eligibility disables the toggle and sends the correct POST request', async function (assert) {
   806      node = server.create('node', {
   807        drain: false,
   808        schedulingEligibility: 'eligible',
   809      });
   810  
   811      server.pretender.post(
   812        '/v1/node/:id/eligibility',
   813        () => [200, {}, ''],
   814        true
   815      );
   816  
   817      await ClientDetail.visit({ id: node.id });
   818      assert.ok(ClientDetail.eligibilityToggle.isActive);
   819  
   820      ClientDetail.eligibilityToggle.toggle();
   821      await waitUntil(() => nonSearchPOSTS());
   822  
   823      assert.ok(ClientDetail.eligibilityToggle.isDisabled);
   824      server.pretender.resolve(server.pretender.requestReferences[0].request);
   825  
   826      await settled();
   827  
   828      assert.notOk(ClientDetail.eligibilityToggle.isActive);
   829      assert.notOk(ClientDetail.eligibilityToggle.isDisabled);
   830  
   831      const request = nonSearchPOSTS()[0];
   832      assert.equal(request.url, `/v1/node/${node.id}/eligibility`);
   833      assert.deepEqual(JSON.parse(request.requestBody), {
   834        NodeID: node.id,
   835        Eligibility: 'ineligible',
   836      });
   837  
   838      ClientDetail.eligibilityToggle.toggle();
   839      await waitUntil(() => nonSearchPOSTS().length === 2);
   840      server.pretender.resolve(server.pretender.requestReferences[0].request);
   841  
   842      assert.ok(ClientDetail.eligibilityToggle.isActive);
   843      const request2 = nonSearchPOSTS()[1];
   844  
   845      assert.equal(request2.url, `/v1/node/${node.id}/eligibility`);
   846      assert.deepEqual(JSON.parse(request2.requestBody), {
   847        NodeID: node.id,
   848        Eligibility: 'eligible',
   849      });
   850    });
   851  
   852    test('starting a drain sends the correct POST request', async function (assert) {
   853      let request;
   854  
   855      node = server.create('node', {
   856        drain: false,
   857        schedulingEligibility: 'eligible',
   858      });
   859  
   860      await ClientDetail.visit({ id: node.id });
   861      await ClientDetail.drainPopover.toggle();
   862      await ClientDetail.drainPopover.submit();
   863  
   864      request = nonSearchPOSTS().pop();
   865  
   866      assert.equal(request.url, `/v1/node/${node.id}/drain`);
   867      assert.deepEqual(
   868        JSON.parse(request.requestBody),
   869        {
   870          NodeID: node.id,
   871          DrainSpec: {
   872            Deadline: 0,
   873            IgnoreSystemJobs: false,
   874          },
   875        },
   876        'Drain with default settings'
   877      );
   878  
   879      await ClientDetail.drainPopover.toggle();
   880      await ClientDetail.drainPopover.deadlineToggle.toggle();
   881      await ClientDetail.drainPopover.submit();
   882  
   883      request = nonSearchPOSTS().pop();
   884  
   885      assert.deepEqual(
   886        JSON.parse(request.requestBody),
   887        {
   888          NodeID: node.id,
   889          DrainSpec: {
   890            Deadline: 60 * 60 * 1000 * 1000000,
   891            IgnoreSystemJobs: false,
   892          },
   893        },
   894        'Drain with deadline toggled'
   895      );
   896  
   897      await ClientDetail.drainPopover.toggle();
   898      await ClientDetail.drainPopover.deadlineOptions.open();
   899      await ClientDetail.drainPopover.deadlineOptions.options[1].choose();
   900      await ClientDetail.drainPopover.submit();
   901  
   902      request = nonSearchPOSTS().pop();
   903  
   904      assert.deepEqual(
   905        JSON.parse(request.requestBody),
   906        {
   907          NodeID: node.id,
   908          DrainSpec: {
   909            Deadline: 4 * 60 * 60 * 1000 * 1000000,
   910            IgnoreSystemJobs: false,
   911          },
   912        },
   913        'Drain with non-default preset deadline set'
   914      );
   915  
   916      await ClientDetail.drainPopover.toggle();
   917      await ClientDetail.drainPopover.deadlineOptions.open();
   918      const optionsCount =
   919        ClientDetail.drainPopover.deadlineOptions.options.length;
   920      await ClientDetail.drainPopover.deadlineOptions.options
   921        .objectAt(optionsCount - 1)
   922        .choose();
   923      await ClientDetail.drainPopover.setCustomDeadline('1h40m20s');
   924      await ClientDetail.drainPopover.submit();
   925  
   926      request = nonSearchPOSTS().pop();
   927  
   928      assert.deepEqual(
   929        JSON.parse(request.requestBody),
   930        {
   931          NodeID: node.id,
   932          DrainSpec: {
   933            Deadline: ((1 * 60 + 40) * 60 + 20) * 1000 * 1000000,
   934            IgnoreSystemJobs: false,
   935          },
   936        },
   937        'Drain with custom deadline set'
   938      );
   939  
   940      await ClientDetail.drainPopover.toggle();
   941      await ClientDetail.drainPopover.deadlineToggle.toggle();
   942      await ClientDetail.drainPopover.forceDrainToggle.toggle();
   943      await ClientDetail.drainPopover.submit();
   944  
   945      request = nonSearchPOSTS().pop();
   946  
   947      assert.deepEqual(
   948        JSON.parse(request.requestBody),
   949        {
   950          NodeID: node.id,
   951          DrainSpec: {
   952            Deadline: -1,
   953            IgnoreSystemJobs: false,
   954          },
   955        },
   956        'Drain with force set'
   957      );
   958  
   959      await ClientDetail.drainPopover.toggle();
   960      await ClientDetail.drainPopover.systemJobsToggle.toggle();
   961      await ClientDetail.drainPopover.submit();
   962  
   963      request = nonSearchPOSTS().pop();
   964  
   965      assert.deepEqual(
   966        JSON.parse(request.requestBody),
   967        {
   968          NodeID: node.id,
   969          DrainSpec: {
   970            Deadline: -1,
   971            IgnoreSystemJobs: true,
   972          },
   973        },
   974        'Drain system jobs unset'
   975      );
   976    });
   977  
   978    test('starting a drain persists options to localstorage', async function (assert) {
   979      const nodes = server.createList('node', 2, {
   980        drain: false,
   981        schedulingEligibility: 'eligible',
   982      });
   983  
   984      await ClientDetail.visit({ id: nodes[0].id });
   985      await ClientDetail.drainPopover.toggle();
   986  
   987      // Change all options to non-default values.
   988      await ClientDetail.drainPopover.deadlineToggle.toggle();
   989      await ClientDetail.drainPopover.deadlineOptions.open();
   990      const optionsCount =
   991        ClientDetail.drainPopover.deadlineOptions.options.length;
   992      await ClientDetail.drainPopover.deadlineOptions.options
   993        .objectAt(optionsCount - 1)
   994        .choose();
   995      await ClientDetail.drainPopover.setCustomDeadline('1h40m20s');
   996      await ClientDetail.drainPopover.forceDrainToggle.toggle();
   997      await ClientDetail.drainPopover.systemJobsToggle.toggle();
   998  
   999      await ClientDetail.drainPopover.submit();
  1000  
  1001      const got = JSON.parse(window.localStorage.nomadDrainOptions);
  1002      const want = {
  1003        deadlineEnabled: true,
  1004        customDuration: '1h40m20s',
  1005        selectedDurationQuickOption: { label: 'Custom', value: 'custom' },
  1006        drainSystemJobs: false,
  1007        forceDrain: true,
  1008      };
  1009      assert.deepEqual(got, want);
  1010  
  1011      // Visit another node and check that drain config is persisted.
  1012      await ClientDetail.visit({ id: nodes[1].id });
  1013      await ClientDetail.drainPopover.toggle();
  1014      assert.true(ClientDetail.drainPopover.deadlineToggle.isActive);
  1015      assert.equal(ClientDetail.drainPopover.customDeadline, '1h40m20s');
  1016      assert.true(ClientDetail.drainPopover.forceDrainToggle.isActive);
  1017      assert.false(ClientDetail.drainPopover.systemJobsToggle.isActive);
  1018    });
  1019  
  1020    test('the drain popover cancel button closes the popover', async function (assert) {
  1021      node = server.create('node', {
  1022        drain: false,
  1023        schedulingEligibility: 'eligible',
  1024      });
  1025  
  1026      await ClientDetail.visit({ id: node.id });
  1027      assert.notOk(ClientDetail.drainPopover.isOpen);
  1028  
  1029      await ClientDetail.drainPopover.toggle();
  1030      assert.ok(ClientDetail.drainPopover.isOpen);
  1031  
  1032      await ClientDetail.drainPopover.cancel();
  1033      assert.notOk(ClientDetail.drainPopover.isOpen);
  1034      assert.equal(nonSearchPOSTS(), 0);
  1035    });
  1036  
  1037    test('toggling eligibility is disabled while a drain is active', async function (assert) {
  1038      node = server.create('node', {
  1039        drain: true,
  1040        schedulingEligibility: 'ineligible',
  1041      });
  1042  
  1043      await ClientDetail.visit({ id: node.id });
  1044      assert.ok(ClientDetail.eligibilityToggle.isDisabled);
  1045    });
  1046  
  1047    test('stopping a drain sends the correct POST request', async function (assert) {
  1048      node = server.create('node', {
  1049        drain: true,
  1050        schedulingEligibility: 'ineligible',
  1051      });
  1052  
  1053      await ClientDetail.visit({ id: node.id });
  1054      assert.ok(ClientDetail.stopDrainIsPresent);
  1055  
  1056      await ClientDetail.stopDrain.idle();
  1057      await ClientDetail.stopDrain.confirm();
  1058  
  1059      const request = nonSearchPOSTS()[0];
  1060      assert.equal(request.url, `/v1/node/${node.id}/drain`);
  1061      assert.deepEqual(JSON.parse(request.requestBody), {
  1062        NodeID: node.id,
  1063        DrainSpec: null,
  1064      });
  1065    });
  1066  
  1067    test('when a drain is active, the "drain" popover is labeled as the "update" popover', async function (assert) {
  1068      node = server.create('node', {
  1069        drain: true,
  1070        schedulingEligibility: 'ineligible',
  1071      });
  1072  
  1073      await ClientDetail.visit({ id: node.id });
  1074      assert.equal(ClientDetail.drainPopover.label, 'Update Drain');
  1075    });
  1076  
  1077    test('forcing a drain sends the correct POST request', async function (assert) {
  1078      node = server.create('node', {
  1079        drain: true,
  1080        schedulingEligibility: 'ineligible',
  1081        drainStrategy: {
  1082          Deadline: 0,
  1083          IgnoreSystemJobs: true,
  1084        },
  1085      });
  1086  
  1087      await ClientDetail.visit({ id: node.id });
  1088      await ClientDetail.drainDetails.force.idle();
  1089      await ClientDetail.drainDetails.force.confirm();
  1090  
  1091      const request = nonSearchPOSTS()[0];
  1092      assert.equal(request.url, `/v1/node/${node.id}/drain`);
  1093      assert.deepEqual(JSON.parse(request.requestBody), {
  1094        NodeID: node.id,
  1095        DrainSpec: {
  1096          Deadline: -1,
  1097          IgnoreSystemJobs: true,
  1098        },
  1099      });
  1100    });
  1101  
  1102    test('when stopping a drain fails, an error is shown', async function (assert) {
  1103      node = server.create('node', {
  1104        drain: true,
  1105        schedulingEligibility: 'ineligible',
  1106      });
  1107  
  1108      server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
  1109  
  1110      await ClientDetail.visit({ id: node.id });
  1111      await ClientDetail.stopDrain.idle();
  1112      await ClientDetail.stopDrain.confirm();
  1113  
  1114      assert.ok(ClientDetail.stopDrainError.isPresent);
  1115      assert.ok(ClientDetail.stopDrainError.title.includes('Stop Drain Error'));
  1116  
  1117      await ClientDetail.stopDrainError.dismiss();
  1118      assert.notOk(ClientDetail.stopDrainError.isPresent);
  1119    });
  1120  
  1121    test('when starting a drain fails, an error message is shown', async function (assert) {
  1122      node = server.create('node', {
  1123        drain: false,
  1124        schedulingEligibility: 'eligible',
  1125      });
  1126  
  1127      server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
  1128  
  1129      await ClientDetail.visit({ id: node.id });
  1130      await ClientDetail.drainPopover.toggle();
  1131      await ClientDetail.drainPopover.submit();
  1132  
  1133      assert.ok(ClientDetail.drainError.isPresent);
  1134      assert.ok(ClientDetail.drainError.title.includes('Drain Error'));
  1135  
  1136      await ClientDetail.drainError.dismiss();
  1137      assert.notOk(ClientDetail.drainError.isPresent);
  1138    });
  1139  
  1140    test('when updating a drain fails, an error message is shown', async function (assert) {
  1141      node = server.create('node', {
  1142        drain: true,
  1143        schedulingEligibility: 'ineligible',
  1144      });
  1145  
  1146      server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
  1147  
  1148      await ClientDetail.visit({ id: node.id });
  1149      await ClientDetail.drainPopover.toggle();
  1150      await ClientDetail.drainPopover.submit();
  1151  
  1152      assert.ok(ClientDetail.drainError.isPresent);
  1153      assert.ok(ClientDetail.drainError.title.includes('Drain Error'));
  1154  
  1155      await ClientDetail.drainError.dismiss();
  1156      assert.notOk(ClientDetail.drainError.isPresent);
  1157    });
  1158  
  1159    test('when toggling eligibility fails, an error message is shown', async function (assert) {
  1160      node = server.create('node', {
  1161        drain: false,
  1162        schedulingEligibility: 'eligible',
  1163      });
  1164  
  1165      server.pretender.post('/v1/node/:id/eligibility', () => [500, {}, '']);
  1166  
  1167      await ClientDetail.visit({ id: node.id });
  1168      await ClientDetail.eligibilityToggle.toggle();
  1169  
  1170      assert.ok(ClientDetail.eligibilityError.isPresent);
  1171      assert.ok(
  1172        ClientDetail.eligibilityError.title.includes('Eligibility Error')
  1173      );
  1174  
  1175      await ClientDetail.eligibilityError.dismiss();
  1176      assert.notOk(ClientDetail.eligibilityError.isPresent);
  1177    });
  1178  
  1179    test('when navigating away from a client that has an error message to another client, the error is not shown', async function (assert) {
  1180      node = server.create('node', {
  1181        drain: false,
  1182        schedulingEligibility: 'eligible',
  1183      });
  1184  
  1185      const node2 = server.create('node');
  1186  
  1187      server.pretender.post('/v1/node/:id/eligibility', () => [500, {}, '']);
  1188  
  1189      await ClientDetail.visit({ id: node.id });
  1190      await ClientDetail.eligibilityToggle.toggle();
  1191  
  1192      assert.ok(ClientDetail.eligibilityError.isPresent);
  1193      assert.ok(
  1194        ClientDetail.eligibilityError.title.includes('Eligibility Error')
  1195      );
  1196  
  1197      await ClientDetail.visit({ id: node2.id });
  1198  
  1199      assert.notOk(ClientDetail.eligibilityError.isPresent);
  1200    });
  1201  
  1202    test('toggling eligibility and node drain are disabled when the active ACL token does not permit node write', async function (assert) {
  1203      window.localStorage.nomadTokenSecret = clientToken.secretId;
  1204  
  1205      await ClientDetail.visit({ id: node.id });
  1206      assert.ok(ClientDetail.eligibilityToggle.isDisabled);
  1207      assert.ok(ClientDetail.drainPopover.isDisabled);
  1208    });
  1209  
  1210    test('the host volumes table lists all host volumes in alphabetical order by name', async function (assert) {
  1211      await ClientDetail.visit({ id: node.id });
  1212  
  1213      const sortedHostVolumes = Object.keys(node.hostVolumes)
  1214        .map((key) => node.hostVolumes[key])
  1215        .sortBy('Name');
  1216  
  1217      assert.ok(ClientDetail.hasHostVolumes);
  1218      assert.equal(
  1219        ClientDetail.hostVolumes.length,
  1220        Object.keys(node.hostVolumes).length
  1221      );
  1222  
  1223      ClientDetail.hostVolumes.forEach((volume, index) => {
  1224        assert.equal(volume.name, sortedHostVolumes[index].Name);
  1225      });
  1226    });
  1227  
  1228    test('each host volume row contains information about the host volume', async function (assert) {
  1229      await ClientDetail.visit({ id: node.id });
  1230  
  1231      const sortedHostVolumes = Object.keys(node.hostVolumes)
  1232        .map((key) => node.hostVolumes[key])
  1233        .sortBy('Name');
  1234  
  1235      ClientDetail.hostVolumes[0].as((volume) => {
  1236        const volumeRow = sortedHostVolumes[0];
  1237        assert.equal(volume.name, volumeRow.Name);
  1238        assert.equal(volume.path, volumeRow.Path);
  1239        assert.equal(
  1240          volume.permissions,
  1241          volumeRow.ReadOnly ? 'Read' : 'Read/Write'
  1242        );
  1243      });
  1244    });
  1245  
  1246    test('the host volumes table is not shown if the client has no host volumes', async function (assert) {
  1247      node = server.create('node', 'noHostVolumes');
  1248  
  1249      await ClientDetail.visit({ id: node.id });
  1250  
  1251      assert.notOk(ClientDetail.hasHostVolumes);
  1252    });
  1253  
  1254    testFacet('Job', {
  1255      facet: ClientDetail.facets.job,
  1256      paramName: 'job',
  1257      expectedOptions(allocs) {
  1258        return Array.from(new Set(allocs.mapBy('jobId'))).sort();
  1259      },
  1260      async beforeEach() {
  1261        server.create('node-pool');
  1262        server.createList('job', 5);
  1263        await ClientDetail.visit({ id: node.id });
  1264      },
  1265      filter: (alloc, selection) => selection.includes(alloc.jobId),
  1266    });
  1267  
  1268    testFacet('Status', {
  1269      facet: ClientDetail.facets.status,
  1270      paramName: 'status',
  1271      expectedOptions: [
  1272        'Pending',
  1273        'Running',
  1274        'Complete',
  1275        'Failed',
  1276        'Lost',
  1277        'Unknown',
  1278      ],
  1279      async beforeEach() {
  1280        server.create('node-pool');
  1281        server.createList('job', 5, { createAllocations: false });
  1282        ['pending', 'running', 'complete', 'failed', 'lost', 'unknown'].forEach(
  1283          (s) => {
  1284            server.createList('allocation', 5, { clientStatus: s });
  1285          }
  1286        );
  1287  
  1288        await ClientDetail.visit({ id: node.id });
  1289      },
  1290      filter: (alloc, selection) => selection.includes(alloc.clientStatus),
  1291    });
  1292  
  1293    test('fiter results with no matches display empty message', async function (assert) {
  1294      const job = server.create('job', { createAllocations: false });
  1295      server.create('allocation', { jobId: job.id, clientStatus: 'running' });
  1296  
  1297      await ClientDetail.visit({ id: node.id });
  1298      const statusFacet = ClientDetail.facets.status;
  1299      await statusFacet.toggle();
  1300      await statusFacet.options.objectAt(0).toggle();
  1301  
  1302      assert.true(ClientDetail.emptyAllocations.isVisible);
  1303      assert.equal(ClientDetail.emptyAllocations.headline, 'No Matches');
  1304    });
  1305  });
  1306  
  1307  module('Acceptance | client detail (multi-namespace)', function (hooks) {
  1308    setupApplicationTest(hooks);
  1309    setupMirage(hooks);
  1310  
  1311    hooks.beforeEach(function () {
  1312      server.create('node-pool');
  1313      server.create('node', 'forceIPv4', { schedulingEligibility: 'eligible' });
  1314      node = server.db.nodes[0];
  1315  
  1316      // Related models
  1317      server.create('namespace');
  1318      server.create('namespace', { id: 'other-namespace' });
  1319  
  1320      server.create('agent');
  1321  
  1322      // Make a job for each namespace, but have both scheduled on the same node
  1323      server.create('job', {
  1324        id: 'job-1',
  1325        namespaceId: 'default',
  1326        createAllocations: false,
  1327      });
  1328      server.createList('allocation', 3, {
  1329        nodeId: node.id,
  1330        jobId: 'job-1',
  1331        clientStatus: 'running',
  1332      });
  1333  
  1334      server.create('job', {
  1335        id: 'job-2',
  1336        namespaceId: 'other-namespace',
  1337        createAllocations: false,
  1338      });
  1339      server.createList('allocation', 3, {
  1340        nodeId: node.id,
  1341        jobId: 'job-2',
  1342        clientStatus: 'running',
  1343      });
  1344    });
  1345  
  1346    test('when the node has allocations on different namespaces, the associated jobs are fetched correctly', async function (assert) {
  1347      window.localStorage.nomadActiveNamespace = 'other-namespace';
  1348  
  1349      await ClientDetail.visit({ id: node.id });
  1350  
  1351      assert.equal(
  1352        ClientDetail.allocations.length,
  1353        server.db.allocations.length,
  1354        'All allocations are scheduled on this node'
  1355      );
  1356      assert.ok(
  1357        server.pretender.handledRequests.findBy('url', '/v1/job/job-1'),
  1358        'Job One fetched correctly'
  1359      );
  1360      assert.ok(
  1361        server.pretender.handledRequests.findBy(
  1362          'url',
  1363          '/v1/job/job-2?namespace=other-namespace'
  1364        ),
  1365        'Job Two fetched correctly'
  1366      );
  1367    });
  1368  
  1369    testFacet('Namespace', {
  1370      facet: ClientDetail.facets.namespace,
  1371      paramName: 'namespace',
  1372      expectedOptions(allocs) {
  1373        return Array.from(new Set(allocs.mapBy('namespace'))).sort();
  1374      },
  1375      async beforeEach() {
  1376        await ClientDetail.visit({ id: node.id });
  1377      },
  1378      filter: (alloc, selection) => selection.includes(alloc.namespace),
  1379    });
  1380  
  1381    test('facet Namespace | selecting namespace filters job options', async function (assert) {
  1382      await ClientDetail.visit({ id: node.id });
  1383  
  1384      const nsFacet = ClientDetail.facets.namespace;
  1385      const jobFacet = ClientDetail.facets.job;
  1386  
  1387      // Select both namespaces.
  1388      await nsFacet.toggle();
  1389      await nsFacet.options.objectAt(0).toggle();
  1390      await nsFacet.options.objectAt(1).toggle();
  1391      await jobFacet.toggle();
  1392  
  1393      assert.deepEqual(
  1394        jobFacet.options.map((option) => option.label.trim()),
  1395        ['job-1', 'job-2']
  1396      );
  1397  
  1398      // Select juse one namespace.
  1399      await nsFacet.toggle();
  1400      await nsFacet.options.objectAt(1).toggle(); // deselect second option
  1401      await jobFacet.toggle();
  1402  
  1403      assert.deepEqual(
  1404        jobFacet.options.map((option) => option.label.trim()),
  1405        ['job-1']
  1406      );
  1407    });
  1408  });
  1409  
  1410  function testFacet(
  1411    label,
  1412    { facet, paramName, beforeEach, filter, expectedOptions }
  1413  ) {
  1414    test(`facet ${label} | the ${label} facet has the correct options`, async function (assert) {
  1415      await beforeEach();
  1416      await facet.toggle();
  1417  
  1418      let expectation;
  1419      if (typeof expectedOptions === 'function') {
  1420        expectation = expectedOptions(server.db.allocations);
  1421      } else {
  1422        expectation = expectedOptions;
  1423      }
  1424  
  1425      assert.deepEqual(
  1426        facet.options.map((option) => option.label.trim()),
  1427        expectation,
  1428        'Options for facet are as expected'
  1429      );
  1430    });
  1431  
  1432    test(`facet ${label} | the ${label} facet filters the allocations list by ${label}`, async function (assert) {
  1433      let option;
  1434  
  1435      await beforeEach();
  1436  
  1437      await facet.toggle();
  1438      option = facet.options.objectAt(0);
  1439      await option.toggle();
  1440  
  1441      const selection = [option.key];
  1442      const expectedAllocs = server.db.allocations
  1443        .filter((alloc) => filter(alloc, selection))
  1444        .sortBy('modifyIndex')
  1445        .reverse();
  1446  
  1447      ClientDetail.allocations.forEach((alloc, index) => {
  1448        assert.equal(
  1449          alloc.id,
  1450          expectedAllocs[index].id,
  1451          `Allocation at ${index} is ${expectedAllocs[index].id}`
  1452        );
  1453      });
  1454    });
  1455  
  1456    test(`facet ${label} | selecting multiple options in the ${label} facet results in a broader search`, async function (assert) {
  1457      const selection = [];
  1458  
  1459      await beforeEach();
  1460      await facet.toggle();
  1461  
  1462      const option1 = facet.options.objectAt(0);
  1463      const option2 = facet.options.objectAt(1);
  1464      await option1.toggle();
  1465      selection.push(option1.key);
  1466      await option2.toggle();
  1467      selection.push(option2.key);
  1468  
  1469      const expectedAllocs = server.db.allocations
  1470        .filter((alloc) => filter(alloc, selection))
  1471        .sortBy('modifyIndex')
  1472        .reverse();
  1473  
  1474      ClientDetail.allocations.forEach((alloc, index) => {
  1475        assert.equal(
  1476          alloc.id,
  1477          expectedAllocs[index].id,
  1478          `Allocation at ${index} is ${expectedAllocs[index].id}`
  1479        );
  1480      });
  1481    });
  1482  
  1483    test(`facet ${label} | selecting options in the ${label} facet updates the ${paramName} query param`, async function (assert) {
  1484      const selection = [];
  1485  
  1486      await beforeEach();
  1487      await facet.toggle();
  1488  
  1489      const option1 = facet.options.objectAt(0);
  1490      const option2 = facet.options.objectAt(1);
  1491      await option1.toggle();
  1492      selection.push(option1.key);
  1493      await option2.toggle();
  1494      selection.push(option2.key);
  1495  
  1496      assert.equal(
  1497        currentURL(),
  1498        `/clients/${node.id}?${paramName}=${encodeURIComponent(
  1499          JSON.stringify(selection)
  1500        )}`,
  1501        'URL has the correct query param key and value'
  1502      );
  1503    });
  1504  }