Skip to content

Commit

Permalink
Merge pull request #6819 from hashicorp/f-ui-node-drain
Browse files Browse the repository at this point in the history
UI: Invoke Node Drains
  • Loading branch information
DingoEatingFuzz committed Jan 24, 2020
2 parents 3ed31eb + 4793dc9 commit fea44b0
Show file tree
Hide file tree
Showing 41 changed files with 1,753 additions and 108 deletions.
58 changes: 57 additions & 1 deletion ui/app/adapters/node.js
Original file line number Diff line number Diff line change
@@ -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,
},
});
},
});
84 changes: 84 additions & 0 deletions ui/app/components/drain-popover.js
Original file line number Diff line number Diff line change
@@ -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();
},
});
56 changes: 56 additions & 0 deletions ui/app/components/popover-menu.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
},
},
});
13 changes: 13 additions & 0 deletions ui/app/components/toggle.js
Original file line number Diff line number Diff line change
@@ -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() {},
});
2 changes: 2 additions & 0 deletions ui/app/components/two-step-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export default Component.extend({
confirmationMessage: '',
awaitingConfirmation: false,
disabled: false,
alignRight: false,
isInfoAction: false,
onConfirm() {},
onCancel() {},

Expand Down
63 changes: 62 additions & 1 deletion ui/app/controllers/clients/client.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -13,6 +15,9 @@ export default Controller.extend(Sortable, Searchable, {
onlyPreemptions: 'preemptions',
},

// Set in the route
flagAsDraining: false,

currentPage: 1,
pageSize: 8,

Expand All @@ -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');
}),
Expand All @@ -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);
Expand All @@ -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);
},
},
});
1 change: 1 addition & 0 deletions ui/app/models/allocation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions ui/app/models/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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);
},
});
Loading

0 comments on commit fea44b0

Please sign in to comment.