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