Merge pull request #6819 from hashicorp/f-ui-node-drain

UI: Invoke Node Drains
This commit is contained in:
Michael Lange 2020-01-23 16:48:57 -08:00 committed by GitHub
commit 0c18d92395
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1753 additions and 108 deletions

View File

@ -1,3 +1,59 @@
import Watchable from './watchable';
import addToPath from 'nomad-ui/utils/add-to-path';
export default Watchable.extend();
export default Watchable.extend({
setEligible(node) {
return this.setEligibility(node, true);
},
setIneligible(node) {
return this.setEligibility(node, false);
},
setEligibility(node, isEligible) {
const url = addToPath(this.urlForFindRecord(node.id, 'node'), '/eligibility');
return this.ajax(url, 'POST', {
data: {
NodeID: node.id,
Eligibility: isEligible ? 'eligible' : 'ineligible',
},
});
},
// Force: -1s deadline
// No Deadline: 0 deadline
drain(node, drainSpec) {
const url = addToPath(this.urlForFindRecord(node.id, 'node'), '/drain');
return this.ajax(url, 'POST', {
data: {
NodeID: node.id,
DrainSpec: Object.assign(
{
Deadline: 0,
IgnoreSystemJobs: true,
},
drainSpec
),
},
});
},
forceDrain(node, drainSpec) {
return this.drain(
node,
Object.assign({}, drainSpec, {
Deadline: -1,
})
);
},
cancelDrain(node) {
const url = addToPath(this.urlForFindRecord(node.id, 'node'), '/drain');
return this.ajax(url, 'POST', {
data: {
NodeID: node.id,
DrainSpec: null,
},
});
},
});

View File

@ -0,0 +1,84 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { equal } from '@ember/object/computed';
import { computed as overridable } from 'ember-overridable-computed';
import { task } from 'ember-concurrency';
import Duration from 'duration-js';
export default Component.extend({
tagName: '',
client: null,
onError() {},
onDrain() {},
parseError: '',
deadlineEnabled: false,
forceDrain: false,
drainSystemJobs: true,
selectedDurationQuickOption: overridable(function() {
return this.durationQuickOptions[0];
}),
durationIsCustom: equal('selectedDurationQuickOption.value', 'custom'),
customDuration: '',
durationQuickOptions: computed(() => [
{ label: '1 Hour', value: '1h' },
{ label: '4 Hours', value: '4h' },
{ label: '8 Hours', value: '8h' },
{ label: '12 Hours', value: '12h' },
{ label: '1 Day', value: '1d' },
{ label: 'Custom', value: 'custom' },
]),
deadline: computed(
'deadlineEnabled',
'durationIsCustom',
'customDuration',
'selectedDurationQuickOption.value',
function() {
if (!this.deadlineEnabled) return 0;
if (this.durationIsCustom) return this.customDuration;
return this.selectedDurationQuickOption.value;
}
),
drain: task(function*(close) {
if (!this.client) return;
const isUpdating = this.client.isDraining;
let deadline;
try {
deadline = new Duration(this.deadline).nanoseconds();
} catch (err) {
this.set('parseError', err.message);
return;
}
const spec = {
Deadline: deadline,
IgnoreSystemJobs: !this.drainSystemJobs,
};
close();
try {
if (this.forceDrain) {
yield this.client.forceDrain(spec);
} else {
yield this.client.drain(spec);
}
this.onDrain(isUpdating);
} catch (err) {
this.onError(err);
}
}),
preventDefault(e) {
e.preventDefault();
},
});

View File

@ -0,0 +1,56 @@
import Component from '@ember/component';
import { run } from '@ember/runloop';
const TAB = 9;
const ARROW_DOWN = 40;
const FOCUSABLE = [
'a:not([disabled])',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'textarea:not([disabled])',
'[tabindex]:not([disabled]):not([tabindex="-1"])',
].join(', ');
export default Component.extend({
classnames: ['popover'],
triggerClass: '',
isOpen: false,
label: '',
dropdown: null,
capture(dropdown) {
// It's not a good idea to grab a dropdown reference like this, but it's necessary
// in order to invoke dropdown.actions.close in traverseList as well as
// dropdown.actions.reposition when the label or selection length changes.
this.set('dropdown', dropdown);
},
didReceiveAttrs() {
const dropdown = this.dropdown;
if (this.isOpen && dropdown) {
run.scheduleOnce('afterRender', () => {
dropdown.actions.reposition();
});
}
},
actions: {
openOnArrowDown(dropdown, e) {
if (!this.isOpen && e.keyCode === ARROW_DOWN) {
dropdown.actions.open(e);
e.preventDefault();
} else if (this.isOpen && (e.keyCode === TAB || e.keyCode === ARROW_DOWN)) {
const optionsId = this.element.querySelector('.popover-trigger').getAttribute('aria-owns');
const popoverContentEl = document.querySelector(`#${optionsId}`);
const firstFocusableElement = popoverContentEl.querySelector(FOCUSABLE);
if (firstFocusableElement) {
firstFocusableElement.focus();
e.preventDefault();
}
}
},
},
});

View File

@ -0,0 +1,13 @@
import Component from '@ember/component';
export default Component.extend({
tagName: 'label',
classNames: ['toggle'],
classNameBindings: ['isDisabled:is-disabled', 'isActive:is-active'],
'data-test-label': true,
isActive: false,
isDisabled: false,
onToggle() {},
});

View File

@ -13,6 +13,8 @@ export default Component.extend({
confirmationMessage: '',
awaitingConfirmation: false,
disabled: false,
alignRight: false,
isInfoAction: false,
onConfirm() {},
onCancel() {},

View File

@ -1,8 +1,10 @@
import { alias } from '@ember/object/computed';
import Controller from '@ember/controller';
import { computed } from '@ember/object';
import { computed, observer } from '@ember/object';
import { task } from 'ember-concurrency';
import Sortable from 'nomad-ui/mixins/sortable';
import Searchable from 'nomad-ui/mixins/searchable';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
export default Controller.extend(Sortable, Searchable, {
queryParams: {
@ -13,6 +15,9 @@ export default Controller.extend(Sortable, Searchable, {
onlyPreemptions: 'preemptions',
},
// Set in the route
flagAsDraining: false,
currentPage: 1,
pageSize: 8,
@ -36,6 +41,13 @@ export default Controller.extend(Sortable, Searchable, {
listToSearch: alias('listSorted'),
sortedAllocations: alias('listSearched'),
eligibilityError: null,
stopDrainError: null,
drainError: null,
showDrainNotification: false,
showDrainUpdateNotification: false,
showDrainStoppedNotification: false,
preemptions: computed('model.allocations.@each.wasPreempted', function() {
return this.model.allocations.filterBy('wasPreempted');
}),
@ -50,6 +62,46 @@ export default Controller.extend(Sortable, Searchable, {
return this.get('model.drivers').sortBy('name');
}),
setEligibility: task(function*(value) {
try {
yield value ? this.model.setEligible() : this.model.setIneligible();
} catch (err) {
const error = messageFromAdapterError(err) || 'Could not set eligibility';
this.set('eligibilityError', error);
}
}).drop(),
stopDrain: task(function*() {
try {
this.set('flagAsDraining', false);
yield this.model.cancelDrain();
this.set('showDrainStoppedNotification', true);
} catch (err) {
this.set('flagAsDraining', true);
const error = messageFromAdapterError(err) || 'Could not stop drain';
this.set('stopDrainError', error);
}
}).drop(),
forceDrain: task(function*() {
try {
yield this.model.forceDrain({
IgnoreSystemJobs: this.model.drainStrategy.ignoreSystemJobs,
});
} catch (err) {
const error = messageFromAdapterError(err) || 'Could not force drain';
this.set('drainError', error);
}
}).drop(),
triggerDrainNotification: observer('model.isDraining', function() {
if (!this.model.isDraining && this.flagAsDraining) {
this.set('showDrainNotification', true);
}
this.set('flagAsDraining', this.model.isDraining);
}),
actions: {
gotoAllocation(allocation) {
this.transitionToRoute('allocations.allocation', allocation);
@ -58,5 +110,14 @@ export default Controller.extend(Sortable, Searchable, {
setPreemptionFilter(value) {
this.set('onlyPreemptions', value);
},
drainNotify(isUpdating) {
this.set('showDrainUpdateNotification', isUpdating);
},
drainError(err) {
const error = messageFromAdapterError(err) || 'Could not run drain';
this.set('drainError', error);
},
},
});

View File

@ -41,6 +41,7 @@ export default Model.extend({
}),
isRunning: equal('clientStatus', 'running'),
isMigrating: attr('boolean'),
// When allocations are server-side rescheduled, a paper trail
// is left linking all reschedule attempts.

View File

@ -4,6 +4,7 @@ import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { hasMany } from 'ember-data/relationships';
import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes';
import RSVP from 'rsvp';
import shortUUIDProperty from '../utils/properties/short-uuid';
import ipParts from '../utils/ip-parts';
@ -43,6 +44,25 @@ export default Model.extend({
}),
allocations: hasMany('allocations', { inverse: 'node' }),
completeAllocations: computed('allocations.@each.clientStatus', function() {
return this.allocations.filterBy('clientStatus', 'complete');
}),
runningAllocations: computed('allocations.@each.isRunning', function() {
return this.allocations.filterBy('isRunning');
}),
migratingAllocations: computed('allocations.@each.{isMigrating,isRunning}', function() {
return this.allocations.filter(alloc => alloc.isRunning && alloc.isMigrating);
}),
lastMigrateTime: computed('allocations.@each.{isMigrating,isRunning,modifyTime}', function() {
const allocation = this.allocations
.filterBy('isRunning', false)
.filterBy('isMigrating')
.sortBy('modifyTime')
.reverse()[0];
if (allocation) {
return allocation.modifyTime;
}
}),
drivers: fragmentArray('node-driver'),
events: fragmentArray('node-event'),
@ -70,4 +90,30 @@ export default Model.extend({
return this.status;
}
}),
setEligible() {
if (this.isEligible) return RSVP.resolve();
// Optimistically update schedulingEligibility for immediate feedback
this.set('schedulingEligibility', 'eligible');
return this.store.adapterFor('node').setEligible(this);
},
setIneligible() {
if (!this.isEligible) return RSVP.resolve();
// Optimistically update schedulingEligibility for immediate feedback
this.set('schedulingEligibility', 'ineligible');
return this.store.adapterFor('node').setIneligible(this);
},
drain(drainSpec) {
return this.store.adapterFor('node').drain(this, drainSpec);
},
forceDrain(drainSpec) {
return this.store.adapterFor('node').forceDrain(this, drainSpec);
},
cancelDrain() {
return this.store.adapterFor('node').cancelDrain(this);
},
});

View File

@ -29,6 +29,24 @@ export default Route.extend(WithWatchers, {
return model && model.get('allocations');
},
setupController(controller, model) {
controller.set('flagAsDraining', model && model.isDraining);
return this._super(...arguments);
},
resetController(controller) {
controller.setProperties({
eligibilityError: null,
stopDrainError: null,
drainError: null,
flagAsDraining: false,
showDrainNotification: false,
showDrainUpdateNotification: false,
showDrainStoppedNotification: false,
});
},
startWatchers(controller, model) {
if (model) {
controller.set('watchModel', this.watch.perform(model));

View File

@ -40,6 +40,8 @@ export default ApplicationSerializer.extend({
hash.RescheduleEvents = (hash.RescheduleTracker || {}).Events;
hash.IsMigrating = (hash.DesiredTransition || {}).Migrate;
// API returns empty strings instead of null
hash.PreviousAllocationID = hash.PreviousAllocation ? hash.PreviousAllocation : null;
hash.NextAllocationID = hash.NextAllocation ? hash.NextAllocation : null;

View File

@ -19,11 +19,13 @@
@import './components/node-status-light';
@import './components/nomad-logo';
@import './components/page-layout';
@import './components/popover-menu';
@import './components/primary-metric';
@import './components/search-box';
@import './components/simple-list';
@import './components/status-text';
@import './components/timeline';
@import './components/toggle';
@import './components/toolbar';
@import './components/tooltip';
@import './components/two-step-button';

View File

@ -1,11 +1,16 @@
.ember-power-select-trigger,
.dropdown-trigger {
position: relative;
padding: 0.3em 16px 0.3em 0.3em;
display: flex;
align-items: center;
padding: 0.375em 16px 0.375em 0.3em;
line-height: 1;
border-radius: $radius;
box-shadow: $button-box-shadow-standard;
background: $white-bis;
border: 1px solid $grey-light;
height: 2.25em;
font-size: 1rem;
outline: none;
cursor: pointer;

View File

@ -25,4 +25,8 @@
font-weight: $weight-semibold;
}
}
&.is-small {
font-size: $size-7;
}
}

View File

@ -1,4 +1,4 @@
$size: 0.75em;
$size: 1.3rem;
.node-status-light {
display: inline-block;

View File

@ -0,0 +1,51 @@
.popover-content {
border: 1px solid $grey-blue;
box-shadow: 0 6px 8px -2px rgba($black, 0.05), 0 8px 4px -4px rgba($black, 0.1);
margin-right: -$radius;
margin-top: -1px;
border-radius: $radius;
padding: 0.5em 1em;
width: 200px;
z-index: $z-popover;
}
.popover-actions {
margin: 1rem -1rem -0.5rem -1rem;
border-top: 1px solid $grey-lighter;
display: flex;
.popover-action {
border: none;
height: 2.75em;
width: 100%;
margin: 4px;
border-radius: $radius;
font-size: 1rem;
font-weight: $weight-bold;
align-items: center;
cursor: pointer;
&:hover {
background: $white-ter;
}
&:focus {
background: $white-ter;
box-shadow: inset 0 0 0 2px rgba($blue, 0.5);
outline: none;
}
@each $name, $pair in $colors {
$color: nth($pair, 1);
&.is-#{$name} {
color: $color;
}
}
// Override is-primary, which is normally green, to be blue
&.is-primary {
color: $blue;
}
}
}

View File

@ -0,0 +1,76 @@
$size: 12px;
.toggle {
cursor: pointer;
font-weight: $weight-semibold;
position: relative;
&.is-disabled {
color: $grey-blue;
cursor: not-allowed;
}
.input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
z-index: -1;
}
.toggler {
display: inline-block;
position: relative;
vertical-align: middle;
width: $size * 2;
height: $size;
border-radius: $size;
background: $grey-blue;
transition: background 0.3s ease-in-out;
&::after {
content: ' ';
display: block;
position: absolute;
width: calc(#{$size} - 4px);
height: calc(#{$size} - 4px);
border-radius: 100%;
background: $white;
left: 2px;
top: 2px;
transform: translateX(0);
transition: transform 0.3s ease-in-out;
}
}
// Always style the toggler based off the input state to
// ensure that the native input is driving behavior.
.input:focus + .toggler {
box-shadow: 0 0 0 1px $grey-light;
outline: none;
}
.input:checked + .toggler {
background: $blue;
&::after {
transform: translateX($size);
}
}
.input:checked:focus + .toggler {
box-shadow: 0 0 0 1px rgba($blue, 0.5);
}
.input:disabled + .toggler {
background: rgba($grey-blue, 0.6);
&::after {
opacity: 0.5;
}
}
.input:checked:disabled + .toggler {
background: rgba($blue, 0.5);
}
}

View File

@ -15,11 +15,19 @@ $spacing: 1.5em;
align-self: center;
flex-grow: 1;
&.is-minimum {
flex-grow: 0;
}
&.is-right-aligned {
flex-grow: 0;
margin-left: auto;
}
&.is-top-aligned {
align-self: auto;
}
&.is-mobile-full-width {
@include mobile {
flex-grow: 1;

View File

@ -61,3 +61,8 @@
opacity: 1;
top: -20%;
}
.tooltip.multiline::after {
width: 200px;
white-space: normal;
}

View File

@ -14,5 +14,14 @@
font-weight: $weight-normal;
color: darken($grey-blue, 20%);
white-space: nowrap;
&.is-right-aligned {
left: auto;
right: 0;
}
&.inherit-color {
color: currentColor;
}
}
}

View File

@ -5,6 +5,7 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
box-shadow: $button-box-shadow-standard;
border: 1px solid transparent;
text-decoration: none;
line-height: 1;
&:hover,
&.is-hovered {
@ -70,6 +71,7 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
&.is-outlined {
border-color: $grey-lighter;
background-color: $white;
&.is-important {
border-color: $color;
@ -79,14 +81,14 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
&.is-hovered,
&:focus,
&.is-focused {
background-color: transparent;
background-color: $white;
border-color: darken($color, 10%);
color: $color;
}
&:active,
&.is-active {
background-color: transparent;
background-color: $white;
border-color: darken($color, 10%);
color: darken($color, 10%);
}
@ -95,6 +97,7 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
&.is-inverted.is-outlined {
border-color: rgba($color-invert, 0.5);
color: rgba($color-invert, 0.9);
background-color: transparent;
&:hover,
&.is-hovered,

View File

@ -40,4 +40,34 @@
&.is-inline {
display: inline-block;
}
&.is-sub-field {
margin-left: 2em;
}
&:not(:last-child) {
margin-bottom: 1rem;
}
}
.form {
&.is-small {
font-size: 0.85rem;
.label {
font-size: 0.85rem;
}
}
.label {
font-weight: $weight-medium;
}
.group-heading {
text-transform: uppercase;
letter-spacing: 0.05ch;
margin-bottom: 1.5em;
color: $grey;
font-weight: $weight-medium;
}
}

View File

@ -12,8 +12,12 @@ $icon-dimensions-large: 2rem;
width: $icon-dimensions;
fill: $text;
&.is-small,
&.is-text {
width: 1.2em;
height: 1.2em;
}
&.is-small {
height: $icon-dimensions-small;
width: $icon-dimensions-small;
}
@ -30,6 +34,7 @@ $icon-dimensions-large: 2rem;
&.is-faded {
fill: $grey-light;
color: $grey-light;
}
@each $name, $pair in $colors {

View File

@ -1,6 +1,10 @@
.section {
padding: 1.5rem;
max-width: 1200px;
&.with-headspace {
margin-top: 1.5rem;
}
}
.full-width-section {

View File

@ -8,4 +8,8 @@
&.with-headroom {
margin-top: 1rem;
}
&.with-subheading {
margin-bottom: 0.85rem;
}
}

View File

@ -1,7 +1,8 @@
$z-tooltip: 250;
$z-header: 210;
$z-gutter: 220;
$z-gutter-backdrop: 219;
$z-header: 210;
$z-subnav: 200;
$z-popover: 150;
$z-base: 100;
$z-icon-decorators: 50;

View File

@ -1,13 +1,131 @@
{{title "Client " (or model.name model.shortId)}}
<section class="section">
<h1 data-test-title class="title">
<span data-test-node-status="{{model.compositeStatus}}" class="node-status-light {{model.compositeStatus}}"></span>
{{or model.name model.shortId}}
<span class="tag is-hollow is-small no-text-transform">
{{model.id}}
{{copy-button clipboardText=model.id}}
</span>
</h1>
<section class="section with-headspace">
{{#if eligibilityError}}
<div data-test-eligibility-error class="columns">
<div class="column">
<div class="notification is-danger">
<h3 data-test-title class="title is-4">Eligibility Error</h3>
<p data-test-message>{{eligibilityError}}</p>
</div>
</div>
<div class="column is-centered is-minimum">
<button data-test-dismiss class="button is-danger" onclick={{action (mut eligibilityError) ""}}>Okay</button>
</div>
</div>
{{/if}}
{{#if stopDrainError}}
<div data-test-stop-drain-error class="columns">
<div class="column">
<div class="notification is-danger">
<h3 data-test-title class="title is-4">Stop Drain Error</h3>
<p data-test-message>{{stopDrainError}}</p>
</div>
</div>
<div class="column is-centered is-minimum">
<button data-test-dismiss class="button is-danger" onclick={{action (mut stopDrainError) ""}}>Okay</button>
</div>
</div>
{{/if}}
{{#if drainError}}
<div data-test-drain-error class="columns">
<div class="column">
<div class="notification is-danger">
<h3 data-test-title class="title is-4">Drain Error</h3>
<p data-test-message>{{drainError}}</p>
</div>
</div>
<div class="column is-centered is-minimum">
<button data-test-dismiss class="button is-danger" onclick={{action (mut drainError) ""}}>Okay</button>
</div>
</div>
{{/if}}
{{#if showDrainStoppedNotification}}
<div class="notification is-info">
<div data-test-drain-stopped-notification class="columns">
<div class="column">
<h3 data-test-title class="title is-4">Drain Stopped</h3>
<p data-test-message>The drain has been stopped and the node has been set to ineligible.</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-dismiss class="button is-info" onclick={{action (mut showDrainStoppedNotification) false}}>Okay</button>
</div>
</div>
</div>
{{/if}}
{{#if showDrainUpdateNotification}}
<div class="notification is-info">
<div data-test-drain-updated-notification class="columns">
<div class="column">
<h3 data-test-title class="title is-4">Drain Updated</h3>
<p data-test-message>The new drain specification has been applied.</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-dismiss class="button is-info" onclick={{action (mut showDrainUpdateNotification) false}}>Okay</button>
</div>
</div>
</div>
{{/if}}
{{#if showDrainNotification}}
<div class="notification is-info">
<div data-test-drain-complete-notification class="columns">
<div class="column">
<h3 data-test-title class="title is-4">Drain Complete</h3>
<p data-test-message>Allocations have been drained and the node has been set to ineligible.</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-dimiss class="button is-info" onclick={{action (mut showDrainNotification) false}}>Okay</button>
</div>
</div>
</div>
{{/if}}
<div class="toolbar">
<div class="toolbar-item is-top-aligned is-minimum">
<span class="title">
<span data-test-node-status="{{model.compositeStatus}}" class="node-status-light {{model.compositeStatus}}"></span>
</span>
</div>
<div class="toolbar-item">
<h1 data-test-title class="title with-subheading">
{{or model.name model.shortId}}
</h1>
<p>
<label class="is-interactive">
{{#toggle
data-test-eligibility-toggle
isActive=model.isEligible
isDisabled=(or setEligibility.isRunning model.isDraining)
onToggle=(perform setEligibility (not model.isEligible))}}
Eligible
{{/toggle}}
<span class="tooltip" aria-label="Only eligible clients can receive allocations">
{{x-icon "info-circle-outline" class="is-faded"}}
</span>
</label>
<span data-test-node-id class="tag is-hollow is-small no-text-transform">
{{model.id}}
{{copy-button clipboardText=model.id}}
</span>
</p>
</div>
<div class="toolbar-item is-right-aligned is-top-aligned">
{{#if model.isDraining}}
{{two-step-button
data-test-drain-stop
idleText="Stop Drain"
cancelText="Cancel"
confirmText="Yes, Stop"
confirmationMessage="Are you sure you want to stop this drain?"
awaitingConfirmation=stopDrain.isRunning
onConfirm=(perform stopDrain)}}
{{/if}}
</div>
<div class="toolbar-item is-right-aligned is-top-aligned">
{{drain-popover
client=model
onDrain=(action "drainNotify")
onError=(action "drainError")}}
</div>
</div>
<div class="boxed-section is-small">
<div class="boxed-section-body inline-definitions">
@ -20,22 +138,6 @@
<span class="term">Address</span>
{{model.httpAddr}}
</span>
<span class="pair" data-test-draining>
<span class="term">Draining</span>
{{#if model.isDraining}}
<span class="status-text is-info">true</span>
{{else}}
false
{{/if}}
</span>
<span class="pair" data-test-eligibility>
<span class="term">Eligibility</span>
{{#if model.isEligible}}
{{model.schedulingEligibility}}
{{else}}
<span class="status-text is-warning">{{model.schedulingEligibility}}</span>
{{/if}}
</span>
<span class="pair" data-test-datacenter-definition>
<span class="term">Datacenter</span>
{{model.datacenter}}
@ -53,30 +155,95 @@
</div>
{{#if model.drainStrategy}}
<div class="boxed-section is-small is-info">
<div class="boxed-section-body inline-definitions">
<span class="label">Drain Strategy</span>
<span class="pair" data-test-drain-deadline>
<span class="term">Deadline</span>
{{#if model.drainStrategy.isForced}}
<span class="badge is-danger">Forced Drain</span>
{{else if model.drainStrategy.hasNoDeadline}}
No deadline
{{else}}
{{format-duration model.drainStrategy.deadline}}
<div data-test-drain-details class="boxed-section is-info">
<div class="boxed-section-head">
<div class="boxed-section-row">Drain Strategy</div>
<div class="boxed-section-row">
<div class="inline-definitions is-small">
{{#if (not model.drainStrategy.hasNoDeadline)}}
<span class="pair">
<span class="term">Duration</span>
{{#if model.drainStrategy.isForced}}
<span data-test-duration>--</span>
{{else}}
<span data-test-duration class="tooltip" aria-label={{format-duration model.drainStrategy.deadline}}>
{{format-duration model.drainStrategy.deadline}}
</span>
{{/if}}
</span>
{{/if}}
<span class="pair">
<span class="term">{{if model.drainStrategy.hasNoDeadline "Deadline" "Remaining"}}</span>
{{#if model.drainStrategy.hasNoDeadline}}
<span data-test-deadline>No deadline</span>
{{else if model.drainStrategy.isForced}}
<span data-test-deadline>--</span>
{{else}}
<span data-test-deadline class="tooltip" aria-label={{format-ts model.drainStrategy.forceDeadline}}>
{{moment-from-now model.drainStrategy.forceDeadline interval=1000 hideAffix=true}}
</span>
{{/if}}
</span>
<span data-test-force-drain-text class="pair">
<span class="term">Force Drain</span>
{{#if model.drainStrategy.isForced}}
{{x-icon "warning" class="is-text is-warning"}} Yes
{{else}}
No
{{/if}}
</span>
<span data-test-drain-system-jobs-text class="pair">
<span class="term">Drain System Jobs</span>
{{if model.drainStrategy.ignoreSystemJobs "No" "Yes"}}
</span>
</div>
{{#if (not model.drainStrategy.isForced)}}
<div class="pull-right">
{{two-step-button
data-test-force
alignRight=true
isInfoAction=true
idleText="Force Drain"
cancelText="Cancel"
confirmText="Yes, Force Drain"
confirmationMessage="Are you sure you want to force drain?"
awaitingConfirmation=forceDrain.isRunning
onConfirm=(perform forceDrain)}}
</div>
{{/if}}
</span>
{{#if model.drainStrategy.forceDeadline}}
<span class="pair" data-test-drain-forced-deadline>
<span class="term">Forced Deadline</span>
{{format-ts model.drainStrategy.forceDeadline}}
({{moment-from-now model.drainStrategy.forceDeadline interval=1000}})
</span>
{{/if}}
<span class="pair" data-test-drain-ignore-system-jobs>
<span class="term">Ignore System Jobs?</span>
{{if model.drainStrategy.ignoreSystemJobs "Yes" "No"}}
</span>
</div>
</div>
<div class="boxed-section-body">
<div class="columns">
<div class="column nowrap is-minimum">
<div class="metric-group">
<div class="metric is-primary">
<h3 class="label">Complete</h3>
<p data-test-complete-count class="value">{{model.completeAllocations.length}}</p>
</div>
</div>
<div class="metric-group">
<div class="metric">
<h3 class="label">Migrating</h3>
<p data-test-migrating-count class="value">{{model.migratingAllocations.length}}</p>
</div>
</div>
<div class="metric-group">
<div class="metric">
<h3 class="label">Remaining</h3>
<p data-test-remaining-count class="value">{{model.runningAllocations.length}}</p>
</div>
</div>
</div>
<div class="column">
<h3 class="title is-4">Status</h3>
{{#if model.lastMigrateTime}}
<p data-test-status>{{moment-to-now model.lastMigrateTime interval=1000 hideAffix=true}} since an allocation was successfully migrated.</p>
{{else}}
<p data-test-status>No allocations migrated.</p>
{{/if}}
</div>
</div>
</div>
</div>
{{/if}}

View File

@ -0,0 +1,85 @@
{{#popover-menu
data-test-drain-popover
label=(if client.isDraining "Update Drain" "Drain")
triggerClass=(concat "is-small " (if drain.isRunning "is-loading")) as |m|}}
<form data-test-drain-popover-form onsubmit={{action (queue (action preventDefault) (perform drain m.actions.close))}} class="form is-small">
<h4 class="group-heading">Drain Options</h4>
<div class="field">
<label class="label is-interactive">
{{#toggle
data-test-drain-deadline-toggle
isActive=deadlineEnabled
onToggle=(action (mut deadlineEnabled) value="target.checked")}}
Deadline
{{/toggle}}
<span class="tooltip multiline" aria-label="The amount of time a drain must complete within.">
{{x-icon "info-circle-outline" class="is-faded"}}
</span>
</label>
</div>
{{#if deadlineEnabled}}
<div class="field is-sub-field">
{{#power-select
data-test-drain-deadline-option-select
tagName="div"
options=durationQuickOptions
selected=selectedDurationQuickOption
onChange=(action (mut selectedDurationQuickOption)) as |opt|}}
{{opt.label}}
{{/power-select}}
</div>
{{#if durationIsCustom}}
<div class="field is-sub-field">
<label class="label">Deadline</label>
<input
data-test-drain-custom-deadline
type="text"
class="input {{if parseError "is-danger"}}"
placeholder="1h30m"
oninput={{action (queue
(action (mut parseError) '')
(action (mut customDuration) value="target.value"))}} />
{{#if parseError}}
<em class="help is-danger">{{parseError}}</em>
{{/if}}
</div>
{{/if}}
{{/if}}
<div class="field">
<label class="label is-interactive">
{{#toggle
data-test-force-drain-toggle
isActive=forceDrain
onToggle=(action (mut forceDrain) value="target.checked")}}
Force Drain
{{/toggle}}
<span class="tooltip multiline" aria-label="Immediately remove allocations from the client.">
{{x-icon "info-circle-outline" class="is-faded"}}
</span>
</label>
</div>
<div class="field">
<label class="label is-interactive">
{{#toggle
data-test-system-jobs-toggle
isActive=drainSystemJobs
onToggle=(action (mut drainSystemJobs) value="target.checked")}}
Drain System Jobs
{{/toggle}}
<span class="tooltip multiline" aria-label="Stop allocations for system jobs.">
{{x-icon "info-circle-outline" class="is-faded"}}
</span>
</label>
</div>
<div class="popover-actions">
<button
data-test-drain-submit
type="button"
class="popover-action is-primary"
onclick={{perform drain m.actions.close}}>
Drain
</button>
<button data-test-drain-cancel type="button" class="popover-action" onclick={{action m.actions.close}}>Cancel</button>
</div>
</form>
{{/popover-menu}}

View File

@ -0,0 +1,12 @@
<BasicDropdown
@horizontalPosition="right"
@onOpen={{action (queue (action (mut isOpen) true) (action capture))}}
@onClose={{action (mut isOpen) false}} as |dd|
>
<dd.Trigger data-test-popover-trigger class={{concat "popover-trigger button is-primary " triggerClass}} {{on "keydown" (action "openOnArrowDown" dd)}}>
{{label}} {{x-icon "chevron-down" class="is-text"}}
</dd.Trigger>
<dd.Content data-test-popover-menu class="popover-content">
{{yield dd}}
</dd.Content>
</BasicDropdown>

View File

@ -0,0 +1,9 @@
<input
data-test-input
type="checkbox"
checked={{isActive}}
disabled={{isDisabled}}
class="input"
onchange={{action onToggle}} />
<span data-test-toggler class="toggler" />
{{yield}}

View File

@ -2,17 +2,21 @@
<button
data-test-idle-button
type="button"
class="button is-danger is-outlined is-important is-small"
class="button {{if isInfoAction "is-warning" "is-danger is-outlined"}} is-important is-small"
disabled={{disabled}}
onclick={{action "promptForConfirmation"}}>
{{idleText}}
</button>
{{else if isPendingConfirmation}}
<span data-test-confirmation-message class="confirmation-text">{{confirmationMessage}}</span>
<span
data-test-confirmation-message
class="confirmation-text {{if isInfoAction "inherit-color"}} {{if alignRight "is-right-aligned"}}">
{{confirmationMessage}}
</span>
<button
data-test-cancel-button
type="button"
class="button is-dark is-outlined is-small"
class="button {{if isInfoAction "is-danger is-important" "is-dark"}} is-outlined is-small"
disabled={{awaitingConfirmation}}
onclick={{action (queue
(action "setToIdle")
@ -22,7 +26,7 @@
</button>
<button
data-test-confirm-button
class="button is-danger is-small {{if awaitingConfirmation "is-loading"}}"
class="button {{if isInfoAction "is-warning" "is-danger"}} is-small {{if awaitingConfirmation "is-loading"}}"
disabled={{awaitingConfirmation}}
onclick={{action "confirm"}}>
{{confirmText}}

View File

@ -207,6 +207,18 @@ export default function() {
return this.serialize(allocations.where({ nodeId: params.id }));
});
this.post('/node/:id/eligibility', function({ nodes }, { params, requestBody }) {
const body = JSON.parse(requestBody);
const node = nodes.find(params.id);
node.update({ schedulingEligibility: body.Elibility === 'eligible' });
return this.serialize(node);
});
this.post('/node/:id/drain', function({ nodes }, { params }) {
return this.serialize(nodes.find(params.id));
});
this.get('/allocations');
this.get('/allocation/:id');

View File

@ -50,6 +50,7 @@
"d3-shape": "^1.2.0",
"d3-time-format": "^2.1.0",
"d3-transition": "^1.1.0",
"duration-js": "^4.0.0",
"ember-ajax": "^5.0.0",
"ember-auto-import": "^1.2.21",
"ember-can": "^2.0.0",

View File

@ -1,10 +1,9 @@
import { currentURL } from '@ember/test-helpers';
import { currentURL, waitUntil } from '@ember/test-helpers';
import { assign } from '@ember/polyfills';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { formatBytes } from 'nomad-ui/helpers/format-bytes';
import formatDuration from 'nomad-ui/utils/format-duration';
import moment from 'moment';
import ClientDetail from 'nomad-ui/tests/pages/clients/detail';
import Clients from 'nomad-ui/tests/pages/clients/list';
@ -55,10 +54,17 @@ module('Acceptance | client detail', function(hooks) {
});
test('/clients/:id should list immediate details for the node in the title', async function(assert) {
node = server.create('node', 'forceIPv4', { schedulingEligibility: 'eligible', drain: false });
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.title.includes(node.name), 'Title includes name');
assert.ok(ClientDetail.title.includes(node.id), 'Title includes id');
assert.ok(ClientDetail.clientId.includes(node.id), 'Title includes id');
assert.equal(
ClientDetail.statusLight.objectAt(0).id,
node.status,
'Title includes status light'
);
});
test('/clients/:id should list additional detail for the node below the title', async function(assert) {
@ -76,14 +82,6 @@ module('Acceptance | client detail', function(hooks) {
ClientDetail.addressDefinition.includes(node.httpAddr),
'Address is in additional details'
);
assert.ok(
ClientDetail.drainingDefinition.includes(node.drain + ''),
'Drain status is in additional details'
);
assert.ok(
ClientDetail.eligibilityDefinition.includes(node.schedulingEligibility),
'Scheduling eligibility is in additional details'
);
assert.ok(
ClientDetail.datacenterDefinition.includes(node.datacenter),
'Datacenter is in additional details'
@ -472,23 +470,19 @@ module('Acceptance | client detail', function(hooks) {
await ClientDetail.visit({ id: node.id });
assert.ok(
ClientDetail.drain.deadline.includes(formatDuration(deadline)),
ClientDetail.drainDetails.deadline.includes(forceDeadline.fromNow(true)),
'Deadline is shown in a human formatted way'
);
assert.ok(
ClientDetail.drain.forcedDeadline.includes(forceDeadline.format("MMM DD, 'YY HH:mm:ss ZZ")),
'Force deadline is shown as an absolute date'
assert.equal(
ClientDetail.drainDetails.deadlineTooltip,
forceDeadline.format("MMM DD, 'YY HH:mm:ss ZZ"),
'The tooltip for deadline shows the force deadline as an absolute date'
);
assert.ok(
ClientDetail.drain.forcedDeadline.includes(forceDeadline.fromNow()),
'Force deadline is shown as a relative date'
);
assert.ok(
ClientDetail.drain.ignoreSystemJobs.endsWith('No'),
'Ignore System Jobs state is shown'
ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'),
'Drain System Jobs state is shown'
);
});
@ -507,19 +501,16 @@ module('Acceptance | client detail', function(hooks) {
await ClientDetail.visit({ id: node.id });
assert.notOk(ClientDetail.drainDetails.durationIsShown, 'Duration is omitted');
assert.ok(
ClientDetail.drain.deadline.includes('No deadline'),
ClientDetail.drainDetails.deadline.includes('No deadline'),
'The value for Deadline is "no deadline"'
);
assert.notOk(
ClientDetail.drain.hasForcedDeadline,
'Forced deadline is not shown since there is no forced deadline'
);
assert.ok(
ClientDetail.drain.ignoreSystemJobs.endsWith('Yes'),
'Ignore System Jobs state is shown'
ClientDetail.drainDetails.drainSystemJobsText.endsWith('No'),
'Drain System Jobs state is shown'
);
});
@ -538,19 +529,362 @@ module('Acceptance | client detail', function(hooks) {
await ClientDetail.visit({ id: node.id });
assert.equal(ClientDetail.drain.badgeLabel, 'Forced Drain', 'Forced Drain badge is described');
assert.ok(ClientDetail.drain.badgeIsDangerous, 'Forced Drain is shown in a red badge');
assert.notOk(
ClientDetail.drain.hasForcedDeadline,
'Forced deadline is not shown since there is no forced deadline'
assert.ok(
ClientDetail.drainDetails.forceDrainText.endsWith('Yes'),
'Forced Drain is described'
);
assert.ok(ClientDetail.drainDetails.duration.includes('--'), 'Duration is shown but unset');
assert.ok(ClientDetail.drainDetails.deadline.includes('--'), 'Deadline is shown but unset');
assert.ok(
ClientDetail.drain.ignoreSystemJobs.endsWith('No'),
'Ignore System Jobs state is shown'
ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'),
'Drain System Jobs state is shown'
);
});
test('toggling node eligibility disables the toggle and sends the correct POST request', async function(assert) {
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
server.pretender.post('/v1/node/:id/eligibility', () => [200, {}, ''], true);
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.eligibilityToggle.isActive);
ClientDetail.eligibilityToggle.toggle();
await waitUntil(() => server.pretender.handledRequests.findBy('method', 'POST'));
assert.ok(ClientDetail.eligibilityToggle.isDisabled);
server.pretender.resolve(server.pretender.requestReferences[0].request);
assert.notOk(ClientDetail.eligibilityToggle.isActive);
assert.notOk(ClientDetail.eligibilityToggle.isDisabled);
const request = server.pretender.handledRequests.findBy('method', 'POST');
assert.equal(request.url, `/v1/node/${node.id}/eligibility`);
assert.deepEqual(JSON.parse(request.requestBody), {
NodeID: node.id,
Eligibility: 'ineligible',
});
ClientDetail.eligibilityToggle.toggle();
await waitUntil(() => server.pretender.handledRequests.filterBy('method', 'POST').length === 2);
server.pretender.resolve(server.pretender.requestReferences[0].request);
assert.ok(ClientDetail.eligibilityToggle.isActive);
const request2 = server.pretender.handledRequests.filterBy('method', 'POST')[1];
assert.equal(request2.url, `/v1/node/${node.id}/eligibility`);
assert.deepEqual(JSON.parse(request2.requestBody), {
NodeID: node.id,
Eligibility: 'eligible',
});
});
test('starting a drain sends the correct POST request', async function(assert) {
let request;
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
await ClientDetail.visit({ id: node.id });
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.submit();
request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
assert.equal(request.url, `/v1/node/${node.id}/drain`);
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: 0,
IgnoreSystemJobs: false,
},
},
'Drain with default settings'
);
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.deadlineToggle.toggle();
await ClientDetail.drainPopover.submit();
request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: 60 * 60 * 1000 * 1000000,
IgnoreSystemJobs: false,
},
},
'Drain with deadline toggled'
);
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.deadlineOptions.open();
await ClientDetail.drainPopover.deadlineOptions.options[1].choose();
await ClientDetail.drainPopover.submit();
request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: 4 * 60 * 60 * 1000 * 1000000,
IgnoreSystemJobs: false,
},
},
'Drain with non-default preset deadline set'
);
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.deadlineOptions.open();
const optionsCount = ClientDetail.drainPopover.deadlineOptions.options.length;
await ClientDetail.drainPopover.deadlineOptions.options.objectAt(optionsCount - 1).choose();
await ClientDetail.drainPopover.setCustomDeadline('1h40m20s');
await ClientDetail.drainPopover.submit();
request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: ((1 * 60 + 40) * 60 + 20) * 1000 * 1000000,
IgnoreSystemJobs: false,
},
},
'Drain with custom deadline set'
);
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.deadlineToggle.toggle();
await ClientDetail.drainPopover.forceDrainToggle.toggle();
await ClientDetail.drainPopover.submit();
request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: -1,
IgnoreSystemJobs: false,
},
},
'Drain with force set'
);
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.systemJobsToggle.toggle();
await ClientDetail.drainPopover.submit();
request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: -1,
IgnoreSystemJobs: true,
},
},
'Drain system jobs unset'
);
});
test('the drain popover cancel button closes the popover', async function(assert) {
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
await ClientDetail.visit({ id: node.id });
assert.notOk(ClientDetail.drainPopover.isOpen);
await ClientDetail.drainPopover.toggle();
assert.ok(ClientDetail.drainPopover.isOpen);
await ClientDetail.drainPopover.cancel();
assert.notOk(ClientDetail.drainPopover.isOpen);
assert.equal(server.pretender.handledRequests.filterBy('method', 'POST'), 0);
});
test('toggling eligibility is disabled while a drain is active', async function(assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
});
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.eligibilityToggle.isDisabled);
});
test('stopping a drain sends the correct POST request', async function(assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
});
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.stopDrainIsPresent);
await ClientDetail.stopDrain.idle();
await ClientDetail.stopDrain.confirm();
const request = server.pretender.handledRequests.findBy('method', 'POST');
assert.equal(request.url, `/v1/node/${node.id}/drain`);
assert.deepEqual(JSON.parse(request.requestBody), {
NodeID: node.id,
DrainSpec: null,
});
});
test('when a drain is active, the "drain" popover is labeled as the "update" popover', async function(assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
});
await ClientDetail.visit({ id: node.id });
assert.equal(ClientDetail.drainPopover.label, 'Update Drain');
});
test('forcing a drain sends the correct POST request', async function(assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
drainStrategy: {
Deadline: 0,
IgnoreSystemJobs: true,
},
});
await ClientDetail.visit({ id: node.id });
await ClientDetail.drainDetails.force.idle();
await ClientDetail.drainDetails.force.confirm();
const request = server.pretender.handledRequests.findBy('method', 'POST');
assert.equal(request.url, `/v1/node/${node.id}/drain`);
assert.deepEqual(JSON.parse(request.requestBody), {
NodeID: node.id,
DrainSpec: {
Deadline: -1,
IgnoreSystemJobs: true,
},
});
});
test('when stopping a drain fails, an error is shown', async function(assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
});
server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
await ClientDetail.visit({ id: node.id });
await ClientDetail.stopDrain.idle();
await ClientDetail.stopDrain.confirm();
assert.ok(ClientDetail.stopDrainError.isPresent);
assert.ok(ClientDetail.stopDrainError.title.includes('Stop Drain Error'));
await ClientDetail.stopDrainError.dismiss();
assert.notOk(ClientDetail.stopDrainError.isPresent);
});
test('when starting a drain fails, an error message is shown', async function(assert) {
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
await ClientDetail.visit({ id: node.id });
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.submit();
assert.ok(ClientDetail.drainError.isPresent);
assert.ok(ClientDetail.drainError.title.includes('Drain Error'));
await ClientDetail.drainError.dismiss();
assert.notOk(ClientDetail.drainError.isPresent);
});
test('when updating a drain fails, an error message is shown', async function(assert) {
node = server.create('node', {
drain: true,
schedulingEligibility: 'ineligible',
});
server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
await ClientDetail.visit({ id: node.id });
await ClientDetail.drainPopover.toggle();
await ClientDetail.drainPopover.submit();
assert.ok(ClientDetail.drainError.isPresent);
assert.ok(ClientDetail.drainError.title.includes('Drain Error'));
await ClientDetail.drainError.dismiss();
assert.notOk(ClientDetail.drainError.isPresent);
});
test('when toggling eligibility fails, an error message is shown', async function(assert) {
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
server.pretender.post('/v1/node/:id/eligibility', () => [500, {}, '']);
await ClientDetail.visit({ id: node.id });
await ClientDetail.eligibilityToggle.toggle();
assert.ok(ClientDetail.eligibilityError.isPresent);
assert.ok(ClientDetail.eligibilityError.title.includes('Eligibility Error'));
await ClientDetail.eligibilityError.dismiss();
assert.notOk(ClientDetail.eligibilityError.isPresent);
});
test('when navigating away from a client that has an error message to another client, the error is not shown', async function(assert) {
node = server.create('node', {
drain: false,
schedulingEligibility: 'eligible',
});
const node2 = server.create('node');
server.pretender.post('/v1/node/:id/eligibility', () => [500, {}, '']);
await ClientDetail.visit({ id: node.id });
await ClientDetail.eligibilityToggle.toggle();
assert.ok(ClientDetail.eligibilityError.isPresent);
assert.ok(ClientDetail.eligibilityError.title.includes('Eligibility Error'));
await ClientDetail.visit({ id: node2.id });
assert.notOk(ClientDetail.eligibilityError.isPresent);
});
});
module('Acceptance | client detail (multi-namespace)', function(hooks) {

View File

@ -0,0 +1,115 @@
import { click } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import { create } from 'ember-cli-page-object';
import popoverMenuPageObject from 'nomad-ui/tests/pages/components/popover-menu';
const PopoverMenu = create(popoverMenuPageObject());
module('Integration | Component | popover-menu', function(hooks) {
setupRenderingTest(hooks);
const commonProperties = overrides =>
Object.assign(
{
triggerClass: '',
label: 'Trigger Label',
},
overrides
);
const commonTemplate = hbs`
{{#popover-menu
isOpen=isOpen
label=label
triggerClass=triggerClass as |m|}}
<h1>This is a heading</h1>
<label>This is an input: <input id="mock-input-for-test" type="text" /></label>
<button id="mock-button-for-test" type="button" onclick={{action m.actions.close}}>Close Button</button>
{{/popover-menu}}
`;
test('presents as a button with a chevron-down icon', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await this.render(commonTemplate);
assert.ok(PopoverMenu.isPresent);
assert.ok(PopoverMenu.labelHasIcon);
assert.notOk(PopoverMenu.menu.isOpen);
assert.equal(PopoverMenu.label, props.label);
});
test('clicking the trigger button toggles the popover menu', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await this.render(commonTemplate);
assert.notOk(PopoverMenu.menu.isOpen);
await PopoverMenu.toggle();
assert.ok(PopoverMenu.menu.isOpen);
});
test('the trigger gets the triggerClass prop assigned as a class', async function(assert) {
const specialClass = 'is-special';
const props = commonProperties({ triggerClass: specialClass });
this.setProperties(props);
await this.render(commonTemplate);
assert.dom('[data-test-popover-trigger]').hasClass('is-special');
});
test('pressing DOWN ARROW when the trigger is focused opens the popover menu', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await this.render(commonTemplate);
assert.notOk(PopoverMenu.menu.isOpen);
await PopoverMenu.focus();
await PopoverMenu.downArrow();
assert.ok(PopoverMenu.menu.isOpen);
});
test('pressing TAB when the trigger button is focused and the menu is open focuses the first focusable element in the popover menu', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await this.render(commonTemplate);
await PopoverMenu.focus();
await PopoverMenu.downArrow();
assert.dom('[data-test-popover-trigger]').isFocused();
await PopoverMenu.focusNext();
assert.dom('#mock-input-for-test').isFocused();
});
test('pressing ESC when the popover menu is open closes the menu and returns focus to the trigger button', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await this.render(commonTemplate);
await PopoverMenu.toggle();
assert.ok(PopoverMenu.menu.isOpen);
await PopoverMenu.esc();
assert.notOk(PopoverMenu.menu.isOpen);
});
test('the ember-basic-dropdown object is yielded as context, including the close action', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await this.render(commonTemplate);
await PopoverMenu.toggle();
assert.ok(PopoverMenu.menu.isOpen);
await click('#mock-button-for-test');
assert.notOk(PopoverMenu.menu.isOpen);
});
});

View File

@ -0,0 +1,89 @@
import { find, settled } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import { create } from 'ember-cli-page-object';
import togglePageObject from 'nomad-ui/tests/pages/components/toggle';
const Toggle = create(togglePageObject());
module('Integration | Component | toggle', function(hooks) {
setupRenderingTest(hooks);
const commonProperties = () => ({
isActive: false,
isDisabled: false,
label: 'Label',
onToggle: sinon.spy(),
});
const commonTemplate = hbs`
{{#toggle
isActive=isActive
isDisabled=isDisabled
onToggle=onToggle}}
{{label}}
{{/toggle}}
`;
test('presents as a label with an inner checkbox and display span, and text', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await this.render(commonTemplate);
assert.equal(Toggle.label, props.label, `Label should be ${props.label}`);
assert.ok(Toggle.isPresent);
assert.notOk(Toggle.isActive);
assert.ok(find('[data-test-toggler]'));
assert.equal(
find('[data-test-input]').tagName.toLowerCase(),
'input',
'The input is a real HTML input'
);
assert.equal(
find('[data-test-input]').getAttribute('type'),
'checkbox',
'The input type is checkbox'
);
});
test('the isActive property dictates the active state and class', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await this.render(commonTemplate);
assert.notOk(Toggle.isActive);
assert.notOk(Toggle.hasActiveClass);
this.set('isActive', true);
await settled();
assert.ok(Toggle.isActive);
assert.ok(Toggle.hasActiveClass);
});
test('the isDisabled property dictates the disabled state and class', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await this.render(commonTemplate);
assert.notOk(Toggle.isDisabled);
assert.notOk(Toggle.hasDisabledClass);
this.set('isDisabled', true);
await settled();
assert.ok(Toggle.isDisabled);
assert.ok(Toggle.hasDisabledClass);
});
test('toggling the input calls the onToggle action', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await this.render(commonTemplate);
await Toggle.toggle();
assert.equal(props.onToggle.callCount, 1);
});
});

View File

@ -3,13 +3,16 @@ import {
create,
collection,
clickable,
hasClass,
fillable,
text,
isPresent,
visitable,
} from 'ember-cli-page-object';
import allocations from 'nomad-ui/tests/pages/components/allocations';
import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button';
import notification from 'nomad-ui/tests/pages/components/notification';
import toggle from 'nomad-ui/tests/pages/components/toggle';
export default create({
visit: visitable('/clients/:id'),
@ -25,6 +28,7 @@ export default create({
},
title: text('[data-test-title]'),
clientId: text('[data-test-node-id]'),
statusLight: collection('[data-test-node-status]', {
id: attribute('data-test-node-status'),
@ -34,8 +38,6 @@ export default create({
statusDefinition: text('[data-test-status-definition]'),
statusDecorationClass: attribute('class', '[data-test-status-definition] .status-text'),
addressDefinition: text('[data-test-address-definition]'),
drainingDefinition: text('[data-test-draining]'),
eligibilityDefinition: text('[data-test-eligibility]'),
datacenterDefinition: text('[data-test-datacenter-definition]'),
resourceCharts: collection('[data-test-primary-metric]', {
@ -92,12 +94,65 @@ export default create({
attributesAreShown: isPresent('[data-test-driver-attributes]'),
}),
drain: {
deadline: text('[data-test-drain-deadline]'),
forcedDeadline: text('[data-test-drain-forced-deadline]'),
hasForcedDeadline: isPresent('[data-test-drain-forced-deadline]'),
ignoreSystemJobs: text('[data-test-drain-ignore-system-jobs]'),
badgeIsDangerous: hasClass('is-danger', '[data-test-drain-deadline] .badge'),
badgeLabel: text('[data-test-drain-deadline] .badge'),
drainDetails: {
scope: '[data-test-drain-details]',
durationIsPresent: isPresent('[data-test-duration]'),
duration: text('[data-test-duration]'),
durationTooltip: attribute('aria-label', '[data-test-duration]'),
durationIsShown: isPresent('[data-test-duration]'),
deadline: text('[data-test-deadline]'),
deadlineTooltip: attribute('aria-label', '[data-test-deadline]'),
deadlineIsShown: isPresent('[data-test-deadline]'),
forceDrainText: text('[data-test-force-drain-text]'),
drainSystemJobsText: text('[data-test-drain-system-jobs-text]'),
completeCount: text('[data-test-complete-count]'),
migratingCount: text('[data-test-migrating-count]'),
remainingCount: text('[data-test-remaining-count]'),
status: text('[data-test-status]'),
force: twoStepButton('[data-test-force]'),
},
drainPopover: {
label: text('[data-test-drain-popover] [data-test-popover-trigger]'),
isOpen: isPresent('[data-test-drain-popover-form]'),
toggle: clickable('[data-test-drain-popover] [data-test-popover-trigger]'),
deadlineToggle: toggle('[data-test-drain-deadline-toggle]'),
deadlineOptions: {
open: clickable('[data-test-drain-deadline-option-select] .ember-power-select-trigger'),
options: collection('.ember-power-select-option', {
label: text(),
choose: clickable(),
}),
},
setCustomDeadline: fillable('[data-test-drain-custom-deadline]'),
customDeadline: attribute('value', '[data-test-drain-custom-deadline]'),
forceDrainToggle: toggle('[data-test-force-drain-toggle]'),
systemJobsToggle: toggle('[data-test-system-jobs-toggle]'),
submit: clickable('[data-test-drain-submit]'),
cancel: clickable('[data-test-drain-cancel]'),
setDeadline(label) {
this.deadlineOptions.open();
this.deadlineOptions.options
.toArray()
.findBy('label', label)
.choose();
},
},
stopDrain: twoStepButton('[data-test-drain-stop]'),
stopDrainIsPresent: isPresent('[data-test-drain-stop]'),
eligibilityToggle: toggle('[data-test-eligibility-toggle]'),
eligibilityError: notification('[data-test-eligibility-error]'),
stopDrainError: notification('[data-test-stop-drain-error]'),
drainError: notification('[data-test-drain-error]'),
drainStoppedNotification: notification('[data-test-drain-stopped-notification]'),
drainUpdatedNotification: notification('[data-test-drain-updated-notification]'),
drainCompleteNotification: notification('[data-test-drain-complete-notification]'),
});

View File

@ -0,0 +1,10 @@
import { isPresent, clickable, text } from 'ember-cli-page-object';
export default scope => ({
scope,
isPresent: isPresent(),
dismiss: clickable('[data-test-dismiss]'),
title: text('[data-test-title]'),
message: text('[data-test-message]'),
});

View File

@ -0,0 +1,32 @@
import { clickable, focusable, isPresent, text, triggerable } from 'ember-cli-page-object';
const ARROW_DOWN = 40;
const ESC = 27;
const TAB = 9;
export default scope => ({
scope,
isPresent: isPresent(),
label: text('[data-test-popover-trigger]'),
labelHasIcon: isPresent('[data-test-popover-trigger] svg.icon'),
toggle: clickable('[data-test-popover-trigger]'),
focus: focusable('[data-test-popover-trigger]'),
downArrow: triggerable('keydown', '[data-test-popover-trigger]', {
eventProperties: { keyCode: ARROW_DOWN },
}),
focusNext: triggerable('keydown', '[data-test-popover-trigger]', {
eventProperties: { keyCode: TAB },
}),
esc: triggerable('keydown', '[data-test-popover-trigger]', {
eventProperties: { keyCode: ESC },
}),
menu: {
scope: '[data-test-popover-menu]',
testContainer: '#ember-testing',
resetScope: true,
isOpen: isPresent(),
},
});

View File

@ -0,0 +1,16 @@
import { attribute, property, clickable, hasClass, isPresent, text } from 'ember-cli-page-object';
export default scope => ({
scope,
isPresent: isPresent(),
isDisabled: attribute('disabled', '[data-test-input]'),
isActive: property('checked', '[data-test-input]'),
hasDisabledClass: hasClass('is-disabled', '[data-test-label]'),
hasActiveClass: hasClass('is-active', '[data-test-label]'),
label: text('[data-test-label]'),
toggle: clickable('[data-test-input]'),
});

View File

@ -85,6 +85,159 @@ module('Unit | Adapter | Node', function(hooks) {
'The deleted allocation is removed from the store and the allocations associated with the other node are untouched'
);
});
test('setEligible makes the correct POST request to /:node_id/eligibility', async function(assert) {
const { pretender } = this.server;
const node = await run(() => this.store.findRecord('node', 'node-1'));
await this.subject().setEligible(node);
const request = pretender.handledRequests.lastObject;
assert.equal(
request.url,
`/v1/node/${node.id}/eligibility`,
'Request was made to /:node_id/eligibility'
);
assert.equal(request.method, 'POST', 'Request was made with the POST method');
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
Eligibility: 'eligible',
},
'POST request is made with the correct body arguments'
);
});
test('setIneligible makes the correct POST request to /:node_id/eligibility', async function(assert) {
const { pretender } = this.server;
const node = await run(() => this.store.findRecord('node', 'node-1'));
await this.subject().setIneligible(node);
const request = pretender.handledRequests.lastObject;
assert.equal(
request.url,
`/v1/node/${node.id}/eligibility`,
'Request was made to /:node_id/eligibility'
);
assert.equal(request.method, 'POST', 'Request was made with the POST method');
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
Eligibility: 'ineligible',
},
'POST request is made with the correct body arguments'
);
});
test('drain makes the correct POST request to /:node_id/drain with appropriate defaults', async function(assert) {
const { pretender } = this.server;
const node = await run(() => this.store.findRecord('node', 'node-1'));
await this.subject().drain(node);
const request = pretender.handledRequests.lastObject;
assert.equal(request.url, `/v1/node/${node.id}/drain`, 'Request was made to /:node_id/drain');
assert.equal(request.method, 'POST', 'Request was made with the POST method');
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: 0,
IgnoreSystemJobs: true,
},
},
'POST request is made with the default body arguments'
);
});
test('drain makes the correct POST request to /:node_id/drain with the provided drain spec', async function(assert) {
const { pretender } = this.server;
const node = await run(() => this.store.findRecord('node', 'node-1'));
const spec = { Deadline: 123456789, IgnoreSystemJobs: false };
await this.subject().drain(node, spec);
const request = pretender.handledRequests.lastObject;
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: spec.Deadline,
IgnoreSystemJobs: spec.IgnoreSystemJobs,
},
},
'POST request is made with the drain spec as body arguments'
);
});
test('forceDrain makes the correct POST request to /:node_id/drain with appropriate defaults', async function(assert) {
const { pretender } = this.server;
const node = await run(() => this.store.findRecord('node', 'node-1'));
await this.subject().forceDrain(node);
const request = pretender.handledRequests.lastObject;
assert.equal(request.url, `/v1/node/${node.id}/drain`, 'Request was made to /:node_id/drain');
assert.equal(request.method, 'POST', 'Request was made with the POST method');
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: -1,
IgnoreSystemJobs: true,
},
},
'POST request is made with the default body arguments'
);
});
test('forceDrain makes the correct POST request to /:node_id/drain with the provided drain spec', async function(assert) {
const { pretender } = this.server;
const node = await run(() => this.store.findRecord('node', 'node-1'));
const spec = { Deadline: 123456789, IgnoreSystemJobs: false };
await this.subject().forceDrain(node, spec);
const request = pretender.handledRequests.lastObject;
assert.equal(request.url, `/v1/node/${node.id}/drain`, 'Request was made to /:node_id/drain');
assert.equal(request.method, 'POST', 'Request was made with the POST method');
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: {
Deadline: -1,
IgnoreSystemJobs: spec.IgnoreSystemJobs,
},
},
'POST request is made with the drain spec, except deadline is not overridden'
);
});
test('cancelDrain makes the correct POST request to /:node_id/drain', async function(assert) {
const { pretender } = this.server;
const node = await run(() => this.store.findRecord('node', 'node-1'));
await this.subject().cancelDrain(node);
const request = pretender.handledRequests.lastObject;
assert.equal(request.url, `/v1/node/${node.id}/drain`, 'Request was made to /:node_id/drain');
assert.equal(request.method, 'POST', 'Request was made with the POST method');
assert.deepEqual(
JSON.parse(request.requestBody),
{
NodeID: node.id,
DrainSpec: null,
},
'POST request is made with a null drain spec'
);
});
});
// Using fetchLink on a model's hasMany relationship exercises the adapter's

View File

@ -6375,6 +6375,11 @@ duplexify@^3.4.2, duplexify@^3.6.0:
readable-stream "^2.0.0"
stream-shift "^1.0.0"
duration-js@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/duration-js/-/duration-js-4.0.0.tgz#ab91575a4f1a6b096034685cfc6ea9aca99cd63f"
integrity sha1-q5FXWk8aawlgNGhc/G6prKmc1j8=
ecc-jsbn@~0.1.1:
version "0.1.2"
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"