diff --git a/ui/app/adapters/node.js b/ui/app/adapters/node.js index 8aa4b662f..d541d70ad 100644 --- a/ui/app/adapters/node.js +++ b/ui/app/adapters/node.js @@ -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, + }, + }); + }, +}); diff --git a/ui/app/components/drain-popover.js b/ui/app/components/drain-popover.js new file mode 100644 index 000000000..b5741d302 --- /dev/null +++ b/ui/app/components/drain-popover.js @@ -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(); + }, +}); diff --git a/ui/app/components/popover-menu.js b/ui/app/components/popover-menu.js new file mode 100644 index 000000000..856f26945 --- /dev/null +++ b/ui/app/components/popover-menu.js @@ -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(); + } + } + }, + }, +}); diff --git a/ui/app/components/toggle.js b/ui/app/components/toggle.js new file mode 100644 index 000000000..e4becf153 --- /dev/null +++ b/ui/app/components/toggle.js @@ -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() {}, +}); diff --git a/ui/app/components/two-step-button.js b/ui/app/components/two-step-button.js index 1499edbf0..15fe6aad6 100644 --- a/ui/app/components/two-step-button.js +++ b/ui/app/components/two-step-button.js @@ -13,6 +13,8 @@ export default Component.extend({ confirmationMessage: '', awaitingConfirmation: false, disabled: false, + alignRight: false, + isInfoAction: false, onConfirm() {}, onCancel() {}, diff --git a/ui/app/controllers/clients/client.js b/ui/app/controllers/clients/client.js index 9044fd1f7..faa3886fa 100644 --- a/ui/app/controllers/clients/client.js +++ b/ui/app/controllers/clients/client.js @@ -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); + }, }, }); diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js index 1b4f19757..6452a2bcb 100644 --- a/ui/app/models/allocation.js +++ b/ui/app/models/allocation.js @@ -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. diff --git a/ui/app/models/node.js b/ui/app/models/node.js index c3efaff54..e685d4e54 100644 --- a/ui/app/models/node.js +++ b/ui/app/models/node.js @@ -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); + }, }); diff --git a/ui/app/routes/clients/client.js b/ui/app/routes/clients/client.js index 499d58599..c248c944d 100644 --- a/ui/app/routes/clients/client.js +++ b/ui/app/routes/clients/client.js @@ -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)); diff --git a/ui/app/serializers/allocation.js b/ui/app/serializers/allocation.js index 64bb540d0..89e321a62 100644 --- a/ui/app/serializers/allocation.js +++ b/ui/app/serializers/allocation.js @@ -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; diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 6941c2443..7f8e04784 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -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'; diff --git a/ui/app/styles/components/dropdown.scss b/ui/app/styles/components/dropdown.scss index c77675d9e..3c9de0a70 100644 --- a/ui/app/styles/components/dropdown.scss +++ b/ui/app/styles/components/dropdown.scss @@ -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; diff --git a/ui/app/styles/components/inline-definitions.scss b/ui/app/styles/components/inline-definitions.scss index cc064c48e..ec28240cd 100644 --- a/ui/app/styles/components/inline-definitions.scss +++ b/ui/app/styles/components/inline-definitions.scss @@ -25,4 +25,8 @@ font-weight: $weight-semibold; } } + + &.is-small { + font-size: $size-7; + } } diff --git a/ui/app/styles/components/node-status-light.scss b/ui/app/styles/components/node-status-light.scss index 50153ce2f..d7583a184 100644 --- a/ui/app/styles/components/node-status-light.scss +++ b/ui/app/styles/components/node-status-light.scss @@ -1,4 +1,4 @@ -$size: 0.75em; +$size: 1.3rem; .node-status-light { display: inline-block; diff --git a/ui/app/styles/components/popover-menu.scss b/ui/app/styles/components/popover-menu.scss new file mode 100644 index 000000000..b6627793e --- /dev/null +++ b/ui/app/styles/components/popover-menu.scss @@ -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; + } + } +} diff --git a/ui/app/styles/components/toggle.scss b/ui/app/styles/components/toggle.scss new file mode 100644 index 000000000..439084073 --- /dev/null +++ b/ui/app/styles/components/toggle.scss @@ -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); + } +} diff --git a/ui/app/styles/components/toolbar.scss b/ui/app/styles/components/toolbar.scss index 3b22b3341..5e0acbe5d 100644 --- a/ui/app/styles/components/toolbar.scss +++ b/ui/app/styles/components/toolbar.scss @@ -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; diff --git a/ui/app/styles/components/tooltip.scss b/ui/app/styles/components/tooltip.scss index 8531269c7..0546b5355 100644 --- a/ui/app/styles/components/tooltip.scss +++ b/ui/app/styles/components/tooltip.scss @@ -61,3 +61,8 @@ opacity: 1; top: -20%; } + +.tooltip.multiline::after { + width: 200px; + white-space: normal; +} diff --git a/ui/app/styles/components/two-step-button.scss b/ui/app/styles/components/two-step-button.scss index b46b1a439..4c787f026 100644 --- a/ui/app/styles/components/two-step-button.scss +++ b/ui/app/styles/components/two-step-button.scss @@ -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; + } } } diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index 41a90d585..1ba83b729 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -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, diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index 2dbed30e7..5381921ea 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -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; + } } diff --git a/ui/app/styles/core/icon.scss b/ui/app/styles/core/icon.scss index c96186558..3a4973125 100644 --- a/ui/app/styles/core/icon.scss +++ b/ui/app/styles/core/icon.scss @@ -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 { diff --git a/ui/app/styles/core/section.scss b/ui/app/styles/core/section.scss index 14f1d27a7..09bafee30 100644 --- a/ui/app/styles/core/section.scss +++ b/ui/app/styles/core/section.scss @@ -1,6 +1,10 @@ .section { padding: 1.5rem; max-width: 1200px; + + &.with-headspace { + margin-top: 1.5rem; + } } .full-width-section { diff --git a/ui/app/styles/core/title.scss b/ui/app/styles/core/title.scss index 40de40ed8..c37d1359c 100644 --- a/ui/app/styles/core/title.scss +++ b/ui/app/styles/core/title.scss @@ -8,4 +8,8 @@ &.with-headroom { margin-top: 1rem; } + + &.with-subheading { + margin-bottom: 0.85rem; + } } diff --git a/ui/app/styles/utils/z-indices.scss b/ui/app/styles/utils/z-indices.scss index c85a60439..18b825013 100644 --- a/ui/app/styles/utils/z-indices.scss +++ b/ui/app/styles/utils/z-indices.scss @@ -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; diff --git a/ui/app/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs index a71becadf..28bc82164 100644 --- a/ui/app/templates/clients/client.hbs +++ b/ui/app/templates/clients/client.hbs @@ -1,13 +1,131 @@ {{title "Client " (or model.name model.shortId)}} -
-

- - {{or model.name model.shortId}} - - {{model.id}} - {{copy-button clipboardText=model.id}} - -

+
+ {{#if eligibilityError}} +
+
+
+

Eligibility Error

+

{{eligibilityError}}

+
+
+
+ +
+
+ {{/if}} + {{#if stopDrainError}} +
+
+
+

Stop Drain Error

+

{{stopDrainError}}

+
+
+
+ +
+
+ {{/if}} + {{#if drainError}} +
+
+
+

Drain Error

+

{{drainError}}

+
+
+
+ +
+
+ {{/if}} + {{#if showDrainStoppedNotification}} +
+
+
+

Drain Stopped

+

The drain has been stopped and the node has been set to ineligible.

+
+
+ +
+
+
+ {{/if}} + {{#if showDrainUpdateNotification}} +
+
+
+

Drain Updated

+

The new drain specification has been applied.

+
+
+ +
+
+
+ {{/if}} + {{#if showDrainNotification}} +
+
+
+

Drain Complete

+

Allocations have been drained and the node has been set to ineligible.

+
+
+ +
+
+
+ {{/if}} +
+
+ + + +
+
+

+ {{or model.name model.shortId}} +

+

+ + + {{model.id}} + {{copy-button clipboardText=model.id}} + +

+
+
+ {{#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}} +
+
+ {{drain-popover + client=model + onDrain=(action "drainNotify") + onError=(action "drainError")}} +
+
@@ -20,22 +138,6 @@ Address {{model.httpAddr}} - - Draining - {{#if model.isDraining}} - true - {{else}} - false - {{/if}} - - - Eligibility - {{#if model.isEligible}} - {{model.schedulingEligibility}} - {{else}} - {{model.schedulingEligibility}} - {{/if}} - Datacenter {{model.datacenter}} @@ -53,30 +155,95 @@
{{#if model.drainStrategy}} -
-
- Drain Strategy - - Deadline - {{#if model.drainStrategy.isForced}} - Forced Drain - {{else if model.drainStrategy.hasNoDeadline}} - No deadline - {{else}} - {{format-duration model.drainStrategy.deadline}} +
+
+
Drain Strategy
+
+
+ {{#if (not model.drainStrategy.hasNoDeadline)}} + + Duration + {{#if model.drainStrategy.isForced}} + -- + {{else}} + + {{format-duration model.drainStrategy.deadline}} + + {{/if}} + + {{/if}} + + {{if model.drainStrategy.hasNoDeadline "Deadline" "Remaining"}} + {{#if model.drainStrategy.hasNoDeadline}} + No deadline + {{else if model.drainStrategy.isForced}} + -- + {{else}} + + {{moment-from-now model.drainStrategy.forceDeadline interval=1000 hideAffix=true}} + + {{/if}} + + + Force Drain + {{#if model.drainStrategy.isForced}} + {{x-icon "warning" class="is-text is-warning"}} Yes + {{else}} + No + {{/if}} + + + Drain System Jobs + {{if model.drainStrategy.ignoreSystemJobs "No" "Yes"}} + +
+ {{#if (not model.drainStrategy.isForced)}} +
+ {{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)}} +
{{/if}} - - {{#if model.drainStrategy.forceDeadline}} - - Forced Deadline - {{format-ts model.drainStrategy.forceDeadline}} - ({{moment-from-now model.drainStrategy.forceDeadline interval=1000}}) - - {{/if}} - - Ignore System Jobs? - {{if model.drainStrategy.ignoreSystemJobs "Yes" "No"}} - +
+
+
+
+
+
+
+

Complete

+

{{model.completeAllocations.length}}

+
+
+
+
+

Migrating

+

{{model.migratingAllocations.length}}

+
+
+
+
+

Remaining

+

{{model.runningAllocations.length}}

+
+
+
+
+

Status

+ {{#if model.lastMigrateTime}} +

{{moment-to-now model.lastMigrateTime interval=1000 hideAffix=true}} since an allocation was successfully migrated.

+ {{else}} +

No allocations migrated.

+ {{/if}} +
+
{{/if}} diff --git a/ui/app/templates/components/drain-popover.hbs b/ui/app/templates/components/drain-popover.hbs new file mode 100644 index 000000000..e900c928e --- /dev/null +++ b/ui/app/templates/components/drain-popover.hbs @@ -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|}} +
+

Drain Options

+
+ +
+ {{#if deadlineEnabled}} +
+ {{#power-select + data-test-drain-deadline-option-select + tagName="div" + options=durationQuickOptions + selected=selectedDurationQuickOption + onChange=(action (mut selectedDurationQuickOption)) as |opt|}} + {{opt.label}} + {{/power-select}} +
+ {{#if durationIsCustom}} +
+ + + {{#if parseError}} + {{parseError}} + {{/if}} +
+ {{/if}} + {{/if}} +
+ +
+
+ +
+
+ + +
+
+{{/popover-menu}} diff --git a/ui/app/templates/components/popover-menu.hbs b/ui/app/templates/components/popover-menu.hbs new file mode 100644 index 000000000..bebdb18fd --- /dev/null +++ b/ui/app/templates/components/popover-menu.hbs @@ -0,0 +1,12 @@ + + + {{label}} {{x-icon "chevron-down" class="is-text"}} + + + {{yield dd}} + + diff --git a/ui/app/templates/components/toggle.hbs b/ui/app/templates/components/toggle.hbs new file mode 100644 index 000000000..dbcf208df --- /dev/null +++ b/ui/app/templates/components/toggle.hbs @@ -0,0 +1,9 @@ + + +{{yield}} diff --git a/ui/app/templates/components/two-step-button.hbs b/ui/app/templates/components/two-step-button.hbs index e29194e9f..18e7d26fb 100644 --- a/ui/app/templates/components/two-step-button.hbs +++ b/ui/app/templates/components/two-step-button.hbs @@ -2,17 +2,21 @@ {{else if isPendingConfirmation}} - {{confirmationMessage}} + + {{confirmationMessage}} + + {{/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); + }); +}); diff --git a/ui/tests/integration/toggle-test.js b/ui/tests/integration/toggle-test.js new file mode 100644 index 000000000..e4ff27f3f --- /dev/null +++ b/ui/tests/integration/toggle-test.js @@ -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); + }); +}); diff --git a/ui/tests/pages/clients/detail.js b/ui/tests/pages/clients/detail.js index c0db54b74..bcef940aa 100644 --- a/ui/tests/pages/clients/detail.js +++ b/ui/tests/pages/clients/detail.js @@ -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]'), }); diff --git a/ui/tests/pages/components/notification.js b/ui/tests/pages/components/notification.js new file mode 100644 index 000000000..95e4a20a8 --- /dev/null +++ b/ui/tests/pages/components/notification.js @@ -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]'), +}); diff --git a/ui/tests/pages/components/popover-menu.js b/ui/tests/pages/components/popover-menu.js new file mode 100644 index 000000000..e156198aa --- /dev/null +++ b/ui/tests/pages/components/popover-menu.js @@ -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(), + }, +}); diff --git a/ui/tests/pages/components/toggle.js b/ui/tests/pages/components/toggle.js new file mode 100644 index 000000000..a4f1f6e3f --- /dev/null +++ b/ui/tests/pages/components/toggle.js @@ -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]'), +}); diff --git a/ui/tests/unit/adapters/node-test.js b/ui/tests/unit/adapters/node-test.js index 9faa709b0..143e2f65c 100644 --- a/ui/tests/unit/adapters/node-test.js +++ b/ui/tests/unit/adapters/node-test.js @@ -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 diff --git a/ui/yarn.lock b/ui/yarn.lock index 4ebb0d560..0032c5119 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -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"