Merge pull request #6819 from hashicorp/f-ui-node-drain
UI: Invoke Node Drains
This commit is contained in:
commit
0c18d92395
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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() {},
|
||||
});
|
|
@ -13,6 +13,8 @@ export default Component.extend({
|
|||
confirmationMessage: '',
|
||||
awaitingConfirmation: false,
|
||||
disabled: false,
|
||||
alignRight: false,
|
||||
isInfoAction: false,
|
||||
onConfirm() {},
|
||||
onCancel() {},
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -25,4 +25,8 @@
|
|||
font-weight: $weight-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-small {
|
||||
font-size: $size-7;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
$size: 0.75em;
|
||||
$size: 1.3rem;
|
||||
|
||||
.node-status-light {
|
||||
display: inline-block;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -61,3 +61,8 @@
|
|||
opacity: 1;
|
||||
top: -20%;
|
||||
}
|
||||
|
||||
.tooltip.multiline::after {
|
||||
width: 200px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
.section {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
|
||||
&.with-headspace {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width-section {
|
||||
|
|
|
@ -8,4 +8,8 @@
|
|||
&.with-headroom {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
&.with-subheading {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,13 +1,131 @@
|
|||
{{title "Client " (or model.name model.shortId)}}
|
||||
<section class="section">
|
||||
<h1 data-test-title class="title">
|
||||
<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}}
|
||||
<span class="tag is-hollow is-small no-text-transform">
|
||||
</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>
|
||||
</h1>
|
||||
</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>
|
||||
<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 class="badge is-danger">Forced Drain</span>
|
||||
{{else if model.drainStrategy.hasNoDeadline}}
|
||||
No deadline
|
||||
<span data-test-duration>--</span>
|
||||
{{else}}
|
||||
<span data-test-duration class="tooltip" aria-label={{format-duration model.drainStrategy.deadline}}>
|
||||
{{format-duration model.drainStrategy.deadline}}
|
||||
{{/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>
|
||||
{{/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}}
|
||||
</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}}
|
||||
|
|
|
@ -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}}
|
|
@ -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>
|
|
@ -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}}
|
|
@ -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}}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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]'),
|
||||
});
|
||||
|
|
|
@ -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]'),
|
||||
});
|
|
@ -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(),
|
||||
},
|
||||
});
|
|
@ -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]'),
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue