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 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: '', confirmationMessage: '',
awaitingConfirmation: false, awaitingConfirmation: false,
disabled: false, disabled: false,
alignRight: false,
isInfoAction: false,
onConfirm() {}, onConfirm() {},
onCancel() {}, onCancel() {},

View File

@ -1,8 +1,10 @@
import { alias } from '@ember/object/computed'; import { alias } from '@ember/object/computed';
import Controller from '@ember/controller'; 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 Sortable from 'nomad-ui/mixins/sortable';
import Searchable from 'nomad-ui/mixins/searchable'; import Searchable from 'nomad-ui/mixins/searchable';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
export default Controller.extend(Sortable, Searchable, { export default Controller.extend(Sortable, Searchable, {
queryParams: { queryParams: {
@ -13,6 +15,9 @@ export default Controller.extend(Sortable, Searchable, {
onlyPreemptions: 'preemptions', onlyPreemptions: 'preemptions',
}, },
// Set in the route
flagAsDraining: false,
currentPage: 1, currentPage: 1,
pageSize: 8, pageSize: 8,
@ -36,6 +41,13 @@ export default Controller.extend(Sortable, Searchable, {
listToSearch: alias('listSorted'), listToSearch: alias('listSorted'),
sortedAllocations: alias('listSearched'), sortedAllocations: alias('listSearched'),
eligibilityError: null,
stopDrainError: null,
drainError: null,
showDrainNotification: false,
showDrainUpdateNotification: false,
showDrainStoppedNotification: false,
preemptions: computed('model.allocations.@each.wasPreempted', function() { preemptions: computed('model.allocations.@each.wasPreempted', function() {
return this.model.allocations.filterBy('wasPreempted'); return this.model.allocations.filterBy('wasPreempted');
}), }),
@ -50,6 +62,46 @@ export default Controller.extend(Sortable, Searchable, {
return this.get('model.drivers').sortBy('name'); 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: { actions: {
gotoAllocation(allocation) { gotoAllocation(allocation) {
this.transitionToRoute('allocations.allocation', allocation); this.transitionToRoute('allocations.allocation', allocation);
@ -58,5 +110,14 @@ export default Controller.extend(Sortable, Searchable, {
setPreemptionFilter(value) { setPreemptionFilter(value) {
this.set('onlyPreemptions', 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'), isRunning: equal('clientStatus', 'running'),
isMigrating: attr('boolean'),
// When allocations are server-side rescheduled, a paper trail // When allocations are server-side rescheduled, a paper trail
// is left linking all reschedule attempts. // 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 attr from 'ember-data/attr';
import { hasMany } from 'ember-data/relationships'; import { hasMany } from 'ember-data/relationships';
import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes'; import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes';
import RSVP from 'rsvp';
import shortUUIDProperty from '../utils/properties/short-uuid'; import shortUUIDProperty from '../utils/properties/short-uuid';
import ipParts from '../utils/ip-parts'; import ipParts from '../utils/ip-parts';
@ -43,6 +44,25 @@ export default Model.extend({
}), }),
allocations: hasMany('allocations', { inverse: 'node' }), 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'), drivers: fragmentArray('node-driver'),
events: fragmentArray('node-event'), events: fragmentArray('node-event'),
@ -70,4 +90,30 @@ export default Model.extend({
return this.status; 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'); 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) { startWatchers(controller, model) {
if (model) { if (model) {
controller.set('watchModel', this.watch.perform(model)); controller.set('watchModel', this.watch.perform(model));

View File

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

View File

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

View File

@ -1,11 +1,16 @@
.ember-power-select-trigger, .ember-power-select-trigger,
.dropdown-trigger { .dropdown-trigger {
position: relative; 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; border-radius: $radius;
box-shadow: $button-box-shadow-standard; box-shadow: $button-box-shadow-standard;
background: $white-bis; background: $white-bis;
border: 1px solid $grey-light; border: 1px solid $grey-light;
height: 2.25em;
font-size: 1rem;
outline: none; outline: none;
cursor: pointer; cursor: pointer;

View File

@ -25,4 +25,8 @@
font-weight: $weight-semibold; 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 { .node-status-light {
display: inline-block; 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; align-self: center;
flex-grow: 1; flex-grow: 1;
&.is-minimum {
flex-grow: 0;
}
&.is-right-aligned { &.is-right-aligned {
flex-grow: 0; flex-grow: 0;
margin-left: auto; margin-left: auto;
} }
&.is-top-aligned {
align-self: auto;
}
&.is-mobile-full-width { &.is-mobile-full-width {
@include mobile { @include mobile {
flex-grow: 1; flex-grow: 1;

View File

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

View File

@ -14,5 +14,14 @@
font-weight: $weight-normal; font-weight: $weight-normal;
color: darken($grey-blue, 20%); color: darken($grey-blue, 20%);
white-space: nowrap; 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; box-shadow: $button-box-shadow-standard;
border: 1px solid transparent; border: 1px solid transparent;
text-decoration: none; text-decoration: none;
line-height: 1;
&:hover, &:hover,
&.is-hovered { &.is-hovered {
@ -70,6 +71,7 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
&.is-outlined { &.is-outlined {
border-color: $grey-lighter; border-color: $grey-lighter;
background-color: $white;
&.is-important { &.is-important {
border-color: $color; border-color: $color;
@ -79,14 +81,14 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
&.is-hovered, &.is-hovered,
&:focus, &:focus,
&.is-focused { &.is-focused {
background-color: transparent; background-color: $white;
border-color: darken($color, 10%); border-color: darken($color, 10%);
color: $color; color: $color;
} }
&:active, &:active,
&.is-active { &.is-active {
background-color: transparent; background-color: $white;
border-color: darken($color, 10%); border-color: darken($color, 10%);
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 { &.is-inverted.is-outlined {
border-color: rgba($color-invert, 0.5); border-color: rgba($color-invert, 0.5);
color: rgba($color-invert, 0.9); color: rgba($color-invert, 0.9);
background-color: transparent;
&:hover, &:hover,
&.is-hovered, &.is-hovered,

View File

@ -40,4 +40,34 @@
&.is-inline { &.is-inline {
display: inline-block; 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; width: $icon-dimensions;
fill: $text; fill: $text;
&.is-small,
&.is-text { &.is-text {
width: 1.2em;
height: 1.2em;
}
&.is-small {
height: $icon-dimensions-small; height: $icon-dimensions-small;
width: $icon-dimensions-small; width: $icon-dimensions-small;
} }
@ -30,6 +34,7 @@ $icon-dimensions-large: 2rem;
&.is-faded { &.is-faded {
fill: $grey-light; fill: $grey-light;
color: $grey-light;
} }
@each $name, $pair in $colors { @each $name, $pair in $colors {

View File

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

View File

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

View File

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

View File

@ -1,13 +1,131 @@
{{title "Client " (or model.name model.shortId)}} {{title "Client " (or model.name model.shortId)}}
<section class="section"> <section class="section with-headspace">
<h1 data-test-title class="title"> {{#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 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}} {{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}} {{model.id}}
{{copy-button clipboardText=model.id}} {{copy-button clipboardText=model.id}}
</span> </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 is-small">
<div class="boxed-section-body inline-definitions"> <div class="boxed-section-body inline-definitions">
@ -20,22 +138,6 @@
<span class="term">Address</span> <span class="term">Address</span>
{{model.httpAddr}} {{model.httpAddr}}
</span> </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="pair" data-test-datacenter-definition>
<span class="term">Datacenter</span> <span class="term">Datacenter</span>
{{model.datacenter}} {{model.datacenter}}
@ -53,30 +155,95 @@
</div> </div>
{{#if model.drainStrategy}} {{#if model.drainStrategy}}
<div class="boxed-section is-small is-info"> <div data-test-drain-details class="boxed-section is-info">
<div class="boxed-section-body inline-definitions"> <div class="boxed-section-head">
<span class="label">Drain Strategy</span> <div class="boxed-section-row">Drain Strategy</div>
<span class="pair" data-test-drain-deadline> <div class="boxed-section-row">
<span class="term">Deadline</span> <div class="inline-definitions is-small">
{{#if (not model.drainStrategy.hasNoDeadline)}}
<span class="pair">
<span class="term">Duration</span>
{{#if model.drainStrategy.isForced}} {{#if model.drainStrategy.isForced}}
<span class="badge is-danger">Forced Drain</span> <span data-test-duration>--</span>
{{else if model.drainStrategy.hasNoDeadline}}
No deadline
{{else}} {{else}}
<span data-test-duration class="tooltip" aria-label={{format-duration model.drainStrategy.deadline}}>
{{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> </span>
{{/if}} {{/if}}
<span class="pair" data-test-drain-ignore-system-jobs>
<span class="term">Ignore System Jobs?</span>
{{if model.drainStrategy.ignoreSystemJobs "Yes" "No"}}
</span> </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>
</div> </div>
{{/if}} {{/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 <button
data-test-idle-button data-test-idle-button
type="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}} disabled={{disabled}}
onclick={{action "promptForConfirmation"}}> onclick={{action "promptForConfirmation"}}>
{{idleText}} {{idleText}}
</button> </button>
{{else if isPendingConfirmation}} {{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 <button
data-test-cancel-button data-test-cancel-button
type="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}} disabled={{awaitingConfirmation}}
onclick={{action (queue onclick={{action (queue
(action "setToIdle") (action "setToIdle")
@ -22,7 +26,7 @@
</button> </button>
<button <button
data-test-confirm-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}} disabled={{awaitingConfirmation}}
onclick={{action "confirm"}}> onclick={{action "confirm"}}>
{{confirmText}} {{confirmText}}

View File

@ -207,6 +207,18 @@ export default function() {
return this.serialize(allocations.where({ nodeId: params.id })); 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('/allocations');
this.get('/allocation/:id'); this.get('/allocation/:id');

View File

@ -50,6 +50,7 @@
"d3-shape": "^1.2.0", "d3-shape": "^1.2.0",
"d3-time-format": "^2.1.0", "d3-time-format": "^2.1.0",
"d3-transition": "^1.1.0", "d3-transition": "^1.1.0",
"duration-js": "^4.0.0",
"ember-ajax": "^5.0.0", "ember-ajax": "^5.0.0",
"ember-auto-import": "^1.2.21", "ember-auto-import": "^1.2.21",
"ember-can": "^2.0.0", "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 { assign } from '@ember/polyfills';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit'; import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support';
import { formatBytes } from 'nomad-ui/helpers/format-bytes'; import { formatBytes } from 'nomad-ui/helpers/format-bytes';
import formatDuration from 'nomad-ui/utils/format-duration';
import moment from 'moment'; import moment from 'moment';
import ClientDetail from 'nomad-ui/tests/pages/clients/detail'; import ClientDetail from 'nomad-ui/tests/pages/clients/detail';
import Clients from 'nomad-ui/tests/pages/clients/list'; 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) { 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 }); await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.title.includes(node.name), 'Title includes name'); 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) { 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), ClientDetail.addressDefinition.includes(node.httpAddr),
'Address is in additional details' '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( assert.ok(
ClientDetail.datacenterDefinition.includes(node.datacenter), ClientDetail.datacenterDefinition.includes(node.datacenter),
'Datacenter is in additional details' 'Datacenter is in additional details'
@ -472,23 +470,19 @@ module('Acceptance | client detail', function(hooks) {
await ClientDetail.visit({ id: node.id }); await ClientDetail.visit({ id: node.id });
assert.ok( assert.ok(
ClientDetail.drain.deadline.includes(formatDuration(deadline)), ClientDetail.drainDetails.deadline.includes(forceDeadline.fromNow(true)),
'Deadline is shown in a human formatted way' 'Deadline is shown in a human formatted way'
); );
assert.ok( assert.equal(
ClientDetail.drain.forcedDeadline.includes(forceDeadline.format("MMM DD, 'YY HH:mm:ss ZZ")), ClientDetail.drainDetails.deadlineTooltip,
'Force deadline is shown as an absolute date' forceDeadline.format("MMM DD, 'YY HH:mm:ss ZZ"),
'The tooltip for deadline shows the force deadline as an absolute date'
); );
assert.ok( assert.ok(
ClientDetail.drain.forcedDeadline.includes(forceDeadline.fromNow()), ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'),
'Force deadline is shown as a relative date' 'Drain System Jobs state is shown'
);
assert.ok(
ClientDetail.drain.ignoreSystemJobs.endsWith('No'),
'Ignore System Jobs state is shown'
); );
}); });
@ -507,19 +501,16 @@ module('Acceptance | client detail', function(hooks) {
await ClientDetail.visit({ id: node.id }); await ClientDetail.visit({ id: node.id });
assert.notOk(ClientDetail.drainDetails.durationIsShown, 'Duration is omitted');
assert.ok( assert.ok(
ClientDetail.drain.deadline.includes('No deadline'), ClientDetail.drainDetails.deadline.includes('No deadline'),
'The value for Deadline is "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( assert.ok(
ClientDetail.drain.ignoreSystemJobs.endsWith('Yes'), ClientDetail.drainDetails.drainSystemJobsText.endsWith('No'),
'Ignore System Jobs state is shown' 'Drain System Jobs state is shown'
); );
}); });
@ -538,19 +529,362 @@ module('Acceptance | client detail', function(hooks) {
await ClientDetail.visit({ id: node.id }); await ClientDetail.visit({ id: node.id });
assert.equal(ClientDetail.drain.badgeLabel, 'Forced Drain', 'Forced Drain badge is described'); assert.ok(
assert.ok(ClientDetail.drain.badgeIsDangerous, 'Forced Drain is shown in a red badge'); ClientDetail.drainDetails.forceDrainText.endsWith('Yes'),
'Forced Drain is described'
assert.notOk(
ClientDetail.drain.hasForcedDeadline,
'Forced deadline is not shown since there is no forced deadline'
); );
assert.ok(ClientDetail.drainDetails.duration.includes('--'), 'Duration is shown but unset');
assert.ok(ClientDetail.drainDetails.deadline.includes('--'), 'Deadline is shown but unset');
assert.ok( assert.ok(
ClientDetail.drain.ignoreSystemJobs.endsWith('No'), ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'),
'Ignore System Jobs state is shown' '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) { 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, create,
collection, collection,
clickable, clickable,
hasClass, fillable,
text, text,
isPresent, isPresent,
visitable, visitable,
} from 'ember-cli-page-object'; } from 'ember-cli-page-object';
import allocations from 'nomad-ui/tests/pages/components/allocations'; 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({ export default create({
visit: visitable('/clients/:id'), visit: visitable('/clients/:id'),
@ -25,6 +28,7 @@ export default create({
}, },
title: text('[data-test-title]'), title: text('[data-test-title]'),
clientId: text('[data-test-node-id]'),
statusLight: collection('[data-test-node-status]', { statusLight: collection('[data-test-node-status]', {
id: attribute('data-test-node-status'), id: attribute('data-test-node-status'),
@ -34,8 +38,6 @@ export default create({
statusDefinition: text('[data-test-status-definition]'), statusDefinition: text('[data-test-status-definition]'),
statusDecorationClass: attribute('class', '[data-test-status-definition] .status-text'), statusDecorationClass: attribute('class', '[data-test-status-definition] .status-text'),
addressDefinition: text('[data-test-address-definition]'), addressDefinition: text('[data-test-address-definition]'),
drainingDefinition: text('[data-test-draining]'),
eligibilityDefinition: text('[data-test-eligibility]'),
datacenterDefinition: text('[data-test-datacenter-definition]'), datacenterDefinition: text('[data-test-datacenter-definition]'),
resourceCharts: collection('[data-test-primary-metric]', { resourceCharts: collection('[data-test-primary-metric]', {
@ -92,12 +94,65 @@ export default create({
attributesAreShown: isPresent('[data-test-driver-attributes]'), attributesAreShown: isPresent('[data-test-driver-attributes]'),
}), }),
drain: { drainDetails: {
deadline: text('[data-test-drain-deadline]'), scope: '[data-test-drain-details]',
forcedDeadline: text('[data-test-drain-forced-deadline]'), durationIsPresent: isPresent('[data-test-duration]'),
hasForcedDeadline: isPresent('[data-test-drain-forced-deadline]'), duration: text('[data-test-duration]'),
ignoreSystemJobs: text('[data-test-drain-ignore-system-jobs]'), durationTooltip: attribute('aria-label', '[data-test-duration]'),
badgeIsDangerous: hasClass('is-danger', '[data-test-drain-deadline] .badge'), durationIsShown: isPresent('[data-test-duration]'),
badgeLabel: text('[data-test-drain-deadline] .badge'), 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' '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 // 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" readable-stream "^2.0.0"
stream-shift "^1.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: ecc-jsbn@~0.1.1:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"