Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Invoke Node Drains #6819

Merged
merged 49 commits into from
Jan 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
a43d108
Mock the eligibility endpoint in mirage
DingoEatingFuzz Oct 25, 2019
649be7f
Implement eligibility toggling in the data layer
DingoEatingFuzz Oct 25, 2019
b22047e
Add isMigrating property to the allocation model
DingoEatingFuzz Oct 25, 2019
1cbbba0
Mock the drain endpoint
DingoEatingFuzz Oct 25, 2019
4ddcd60
drain and forceDrain adapter methods
DingoEatingFuzz Oct 25, 2019
0b031f0
Update drain methods to properly wrap DrainSpec params
DingoEatingFuzz Oct 30, 2019
4d05f53
cancelDrain adapter method
DingoEatingFuzz Nov 2, 2019
3ad8987
Reformat the client detail page to use the two-row header design
DingoEatingFuzz Nov 2, 2019
09b62eb
Add tooltip to the eligibility control
DingoEatingFuzz Nov 2, 2019
44904ba
Update the underlying node model when toggling eligibility in mirage
DingoEatingFuzz Nov 2, 2019
8788d39
Eligibility toggling behavior
DingoEatingFuzz Nov 2, 2019
de03d82
PopoverMenu component
DingoEatingFuzz Nov 6, 2019
b02f05f
Update the dropdown styles to be more similar to button styles
DingoEatingFuzz Nov 6, 2019
5098b55
Multiline modifier for tooltips
DingoEatingFuzz Nov 6, 2019
030b449
More form styles as needed for the drain form
DingoEatingFuzz Nov 6, 2019
0a8c27d
Initial layout of the drain options popover
DingoEatingFuzz Nov 6, 2019
7eb71ca
Let dropdowns assume their full width
DingoEatingFuzz Nov 12, 2019
d401a11
Add triggerClass support to the popover menu
DingoEatingFuzz Nov 12, 2019
e47d255
Factor out the drain popover and implement its behaviors
DingoEatingFuzz Nov 12, 2019
faffb83
Extract the duration parsing into a util
DingoEatingFuzz Nov 12, 2019
6f6b9c2
Test coverage for the parse duration util
DingoEatingFuzz Nov 12, 2019
da576bc
Refactor parseDuration to support multi-character units
DingoEatingFuzz Nov 12, 2019
2ddc54e
Polish for the drain popover
DingoEatingFuzz Nov 12, 2019
4439853
Stub out all the markup for the new drain strategy view
DingoEatingFuzz Nov 13, 2019
ad8df16
Fill in the drain strategy ribbon values
DingoEatingFuzz Nov 13, 2019
e886361
Fill out the metrics and time since values in the drain summary
DingoEatingFuzz Nov 15, 2019
fabf956
Drain complete notification
DingoEatingFuzz Nov 15, 2019
96522e7
Drain stop and update and notifications
DingoEatingFuzz Nov 19, 2019
504a7af
Modifiers to the two-step-button
DingoEatingFuzz Nov 19, 2019
4bcd47c
Make outline buttons have a solid white background
DingoEatingFuzz Nov 19, 2019
6a0a77c
Force drain button in the drain info box
DingoEatingFuzz Nov 19, 2019
2e39c0a
New toggle component
DingoEatingFuzz Nov 19, 2019
595f3c9
Swap the eligiblity checkbox out for a toggle
DingoEatingFuzz Nov 19, 2019
a36290d
Toggle bugs: focus and multiline alignment
DingoEatingFuzz Nov 19, 2019
3009ad3
Switch drain popover checkboxes for toggles
DingoEatingFuzz Nov 19, 2019
8f67e4b
Clear all notifications when resetting the controller
DingoEatingFuzz Nov 22, 2019
da941d2
Model the notification pattern as a page object component
DingoEatingFuzz Nov 22, 2019
b03e1e6
Update the client detail page object
DingoEatingFuzz Nov 22, 2019
8dadd3d
Integration tests for the toggle component
DingoEatingFuzz Dec 3, 2019
413681e
PopoverMenu integration tests
DingoEatingFuzz Dec 3, 2019
b11c82c
Update existing tests
DingoEatingFuzz Dec 5, 2019
93eaaab
New test coverage for the drain capabilities
DingoEatingFuzz Dec 7, 2019
1d6799e
Stack the popover menu under the subnav
DingoEatingFuzz Dec 13, 2019
4cc6702
Use qunit-dom where applicable
DingoEatingFuzz Dec 13, 2019
66ea7c1
Increase the size and spacing of the toggle component
DingoEatingFuzz Dec 13, 2019
deb2b31
Remove superfluous information from the client details ribbon
DingoEatingFuzz Dec 13, 2019
40d6531
Tweak vertical spacing of headings
DingoEatingFuzz Dec 13, 2019
b9b6cda
Update client detail test given change to the compositeStatus property
DingoEatingFuzz Dec 13, 2019
4793dc9
Replace custom parse-duration implementation with an existing lib
DingoEatingFuzz Dec 13, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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