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: Task Group Scaling Controls #8207

Merged
merged 28 commits into from
Jun 19, 2020
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
29ff3f9
Model the scaling properties of a task group as a fragment
DingoEatingFuzz Jun 17, 2020
e28efc6
LazyClick should also get interrupted by buttons
DingoEatingFuzz Jun 17, 2020
4e7b844
New xsmall button size
DingoEatingFuzz Jun 17, 2020
0137f8d
When an icon is intended as text, it shouldn't have pointer events
DingoEatingFuzz Jun 17, 2020
158f776
Extend button-bar support to buttons
DingoEatingFuzz Jun 17, 2020
461980d
Additional button-bar treatments for use in a table row
DingoEatingFuzz Jun 17, 2020
6b57adc
Prevent inline definition key/value pairs from breaking the key and v…
DingoEatingFuzz Jun 17, 2020
2e1adbc
Add the min/max and policy y/n of a task group to the details ribbon
DingoEatingFuzz Jun 17, 2020
ed6c414
Add the elements of the manual scaling actions to the task-group-row …
DingoEatingFuzz Jun 17, 2020
3768a63
Make sure buttons in a button bar have a very visible focus state
DingoEatingFuzz Jun 17, 2020
8567439
Create new AbortController with each tick of the ec task loops
DingoEatingFuzz Jun 17, 2020
149dcda
New scale action for jobs (and a convenience task group method)
DingoEatingFuzz Jun 17, 2020
9a344e4
Wire up the +/- buttons in task group rows to the job scale action
DingoEatingFuzz Jun 17, 2020
a1f1079
Mirage updates for task group scaling and scaling post endpoint
DingoEatingFuzz Jun 18, 2020
7fec4d8
Add canScale ability for jobs
DingoEatingFuzz Jun 18, 2020
1182203
Disable scale buttons when a deployment is running or ACL forbids it
DingoEatingFuzz Jun 18, 2020
469b107
Test coverage for the task group row scale actions
DingoEatingFuzz Jun 18, 2020
0b0be1b
Slow the debounce time.
DingoEatingFuzz Jun 18, 2020
2b88651
Barebones StepperInput component
DingoEatingFuzz Jun 18, 2020
ed63958
StepperInput story
DingoEatingFuzz Jun 18, 2020
140f9f6
Add count StepperInput to the task group page
DingoEatingFuzz Jun 18, 2020
6bde0e5
Style the StepperInput component
DingoEatingFuzz Jun 19, 2020
110f491
Test coverage for the StepperInput
DingoEatingFuzz Jun 19, 2020
2227f24
Integrate the stepper input with the task group page
DingoEatingFuzz Jun 19, 2020
eb901b5
Wire up the scale action on the task group page
DingoEatingFuzz Jun 19, 2020
72161b0
Watch the latest deployment relationship to disable the stepper appro…
DingoEatingFuzz Jun 19, 2020
61042e0
Acceptance tests for task group scaling
DingoEatingFuzz Jun 19, 2020
c70ea97
Remove superfluous property from the StepperInput page object
DingoEatingFuzz Jun 19, 2020
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
7 changes: 7 additions & 0 deletions ui/app/abilities/abstract.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export default class Abstract extends Ability {
}, []);
}

activeNamespaceIncludesCapability(capability) {
return this.rulesForActiveNamespace.some(rules => {
let capabilities = get(rules, 'Capabilities') || [];
return capabilities.includes(capability);
});
}

// Chooses the closest namespace as described at the bottom here:
// https://www.nomadproject.io/guides/security/acl.html#namespace-rules
_findMatchingNamespace(policyNamespaces, activeNamespace) {
Expand Down
20 changes: 15 additions & 5 deletions ui/app/abilities/job.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import AbstractAbility from './abstract';
import { computed, get } from '@ember/object';
import { computed } from '@ember/object';
import { or } from '@ember/object/computed';

export default class Job extends AbstractAbility {
@or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportRunning')
canRun;

@or(
'bypassAuthorization',
'selfTokenIsManagement',
'policiesSupportRunning',
'policiesSupportScaling'
)
canScale;

@computed('rulesForActiveNamespace.@each.capabilities')
get policiesSupportRunning() {
return this.rulesForActiveNamespace.some(rules => {
let capabilities = get(rules, 'Capabilities') || [];
return capabilities.includes('submit-job');
});
return this.activeNamespaceIncludesCapability('submit-job');
}

@computed('rulesForActiveNamespace.@each.capabilities')
get policiesSupportScaling() {
return this.activeNamespaceIncludesCapability('scale-job');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that we are slowly building up useful abstractions here as appropriate 🤩

}
}
16 changes: 16 additions & 0 deletions ui/app/adapters/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,20 @@ export default class JobAdapter extends WatchableNamespaceIDs {
},
});
}

scale(job, group, count, reason) {
const url = addToPath(this.urlForFindRecord(job.get('id'), 'job'), '/scale');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hadn’t known about addToPath before, I feel like I could have used it at some point, I’ll try to remember it exists 😯

return this.ajax(url, 'POST', {
data: {
Count: count,
Reason: reason,
Target: {
Group: group,
},
Meta: {
Source: 'nomad-ui',
},
},
});
}
}
6 changes: 3 additions & 3 deletions ui/app/components/search-box.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { reads } from '@ember/object/computed';
import Component from '@ember/component';
import { action } from '@ember/object';
import { run } from '@ember/runloop';
import { debounce } from '@ember/runloop';
import { classNames } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';

Expand All @@ -23,13 +23,13 @@ export default class SearchBox extends Component {
@action
setSearchTerm(e) {
this.set('_searchTerm', e.target.value);
run.debounce(this, updateSearch, this.debounce);
debounce(this, updateSearch, this.debounce);
}

@action
clear() {
this.set('_searchTerm', '');
run.debounce(this, updateSearch, this.debounce);
debounce(this, updateSearch, this.debounce);
}
}

Expand Down
62 changes: 62 additions & 0 deletions ui/app/components/stepper-input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Component from '@ember/component';
import { action } from '@ember/object';
import { debounce } from '@ember/runloop';
import { oneWay } from '@ember/object/computed';
import { classNames, classNameBindings } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';

const ESC = 27;

@classic
@classNames('stepper-input')
@classNameBindings('class', 'disabled:is-disabled')
export default class StepperInput extends Component {
min = 0;
max = 10;
value = 0;
debounce = 500;
onChange() {}

// Internal value changes immediately for instant visual feedback.
// Value is still the public API and is expected to mutate and re-render
// On onChange which is debounced.
@oneWay('value') internalValue;

@action
increment() {
if (this.internalValue < this.max) {
this.incrementProperty('internalValue');
this.update(this.internalValue);
}
}

@action
decrement() {
if (this.internalValue > this.min) {
this.decrementProperty('internalValue');
this.update(this.internalValue);
}
}

@action
setValue(e) {
const newValue = Math.min(this.max, Math.max(this.min, e.target.value));
this.set('internalValue', newValue);
this.update(this.internalValue);
}

@action
resetTextInput(e) {
if (e.keyCode === ESC) {
e.target.value = this.internalValue;
}
}

update(value) {
debounce(this, sendUpdateAction, value, this.debounce);
}
}

function sendUpdateAction(value) {
return this.onChange(value);
}
49 changes: 48 additions & 1 deletion ui/app/components/task-group-row.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,64 @@
import Component from '@ember/component';
import { lazyClick } from '../helpers/lazy-click';
import { computed, action } from '@ember/object';
import { alias, oneWay } from '@ember/object/computed';
import { debounce } from '@ember/runloop';
import { classNames, tagName } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';
import { lazyClick } from '../helpers/lazy-click';

@classic
@tagName('tr')
@classNames('task-group-row', 'is-interactive')
export default class TaskGroupRow extends Component {
taskGroup = null;
debounce = 500;

@oneWay('taskGroup.count') count;
@alias('taskGroup.job.runningDeployment') runningDeployment;

onClick() {}

click(event) {
lazyClick([this.onClick, event]);
}

@computed('count', 'taskGroup.scaling.min')
get isMinimum() {
const scaling = this.taskGroup.scaling;
if (!scaling || scaling.min == null) return false;
return this.count <= scaling.min;
}

@computed('count', 'taskGroup.scaling.max')
get isMaximum() {
const scaling = this.taskGroup.scaling;
if (!scaling || scaling.max == null) return false;
return this.count >= scaling.max;
}

@action
countUp() {
const scaling = this.taskGroup.scaling;
if (!scaling || scaling.max == null || this.count < scaling.max) {
this.incrementProperty('count');
this.scale(this.count);
}
}

@action
countDown() {
const scaling = this.taskGroup.scaling;
if (!scaling || scaling.min == null || this.count > scaling.min) {
this.decrementProperty('count');
this.scale(this.count);
}
}

scale(count) {
debounce(this, sendCountAction, count, this.debounce);
}
}

function sendCountAction(count) {
return this.taskGroup.scale(count);
}
5 changes: 5 additions & 0 deletions ui/app/controllers/jobs/job/task-group.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,9 @@ export default class TaskGroupController extends Controller.extend(
gotoAllocation(allocation) {
this.transitionToRoute('allocations.allocation', allocation);
}

@action
scaleTaskGroup(count) {
return this.model.scale(count);
}
}
2 changes: 1 addition & 1 deletion ui/app/helpers/lazy-click.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Helper from '@ember/component/helper';
* that should be handled instead.
*/
export function lazyClick([onClick, event]) {
if (event.target.tagName.toLowerCase() !== 'a') {
if (!['a', 'button'].includes(event.target.tagName.toLowerCase())) {
onClick(event);
}
}
Expand Down
15 changes: 15 additions & 0 deletions ui/app/models/group-scaling.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Fragment from 'ember-data-model-fragments/fragment';
import attr from 'ember-data/attr';
import { fragmentOwner } from 'ember-data-model-fragments/attributes';
import classic from 'ember-classic-decorator';

@classic
export default class TaskGroup extends Fragment {
@fragmentOwner() taskGroup;

@attr('boolean') enabled;
@attr('number') max;
@attr('number') min;

@attr() policy;
}
4 changes: 4 additions & 0 deletions ui/app/models/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ export default class Job extends Model {
return promise;
}

scale(group, count, reason = 'Manual scaling event from the Nomad UI') {
return this.store.adapterFor('job').scale(this, group, count, reason);
}

setIdByPayload(payload) {
const namespace = payload.Namespace || 'default';
const id = payload.Name;
Expand Down
8 changes: 7 additions & 1 deletion ui/app/models/task-group.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { computed } from '@ember/object';
import Fragment from 'ember-data-model-fragments/fragment';
import attr from 'ember-data/attr';
import { fragmentOwner, fragmentArray } from 'ember-data-model-fragments/attributes';
import { fragmentOwner, fragmentArray, fragment } from 'ember-data-model-fragments/attributes';
import sumAggregation from '../utils/properties/sum-aggregation';
import classic from 'ember-classic-decorator';

Expand All @@ -20,6 +20,8 @@ export default class TaskGroup extends Fragment {

@fragmentArray('volume-definition') volumes;

@fragment('group-scaling') scaling;

@computed('tasks.@each.driver')
get drivers() {
return this.tasks.mapBy('driver').uniq();
Expand Down Expand Up @@ -51,4 +53,8 @@ export default class TaskGroup extends Fragment {
get summary() {
return maybe(this.get('job.taskGroupSummaries')).findBy('name', this.name);
}

scale(count, reason) {
return this.job.scale(this.name, count, reason);
}
}
4 changes: 3 additions & 1 deletion ui/app/routes/jobs/job/task-group.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,15 @@ export default class TaskGroupRoute extends Route.extend(WithWatchers) {
job: this.watchJob.perform(job),
summary: this.watchSummary.perform(job.get('summary')),
allocations: this.watchAllocations.perform(job),
latestDeployment: job.get('supportsDeployments') && this.watchLatestDeployment.perform(job),
});
}
}

@watchRecord('job') watchJob;
@watchRecord('job-summary') watchSummary;
@watchRelationship('allocations') watchAllocations;
@watchRelationship('latestDeployment') watchLatestDeployment;

@collect('watchJob', 'watchSummary', 'watchAllocations') watchers;
@collect('watchJob', 'watchSummary', 'watchAllocations', 'watchLatestDeployment') watchers;
}
1 change: 1 addition & 0 deletions ui/app/styles/components.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
@import './components/search-box';
@import './components/simple-list';
@import './components/status-text';
@import './components/stepper-input';
@import './components/timeline';
@import './components/toggle';
@import './components/toolbar';
Expand Down
52 changes: 38 additions & 14 deletions ui/app/styles/components/dropdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,20 @@
flex-direction: row;
box-shadow: $button-box-shadow-standard;

.dropdown {
.dropdown,
.button {
display: flex;
position: relative;

& + .dropdown {
& + .dropdown,
& + .button {
margin-left: -1px;
}
}

.ember-power-select-trigger,
.dropdown-trigger {
.dropdown-trigger,
.button {
border-radius: 0;
box-shadow: none;

Expand All @@ -70,20 +73,41 @@
}
}

.dropdown:first-child {
.ember-power-select-trigger,
.dropdown-trigger {
border-top-left-radius: $radius;
border-bottom-left-radius: $radius;
// Buttons have their own focus treatment that needs to be overrided here.
// Since .button.is-color takes precedence over .button-bar .button, each
// color needs the override.
.button {
@each $name, $pair in $colors {
&.is-#{$name}:focus {
box-shadow: inset 0 0 0 2px $grey-lighter;
}
}
}

.dropdown:last-child {
.ember-power-select-trigger,
.dropdown-trigger {
border-top-right-radius: $radius;
border-bottom-right-radius: $radius;
}
.dropdown:first-child .ember-power-select-trigger,
.dropdown:first-child .dropdown-trigger,
.button:first-child {
border-top-left-radius: $radius;
border-bottom-left-radius: $radius;
}

.dropdown:last-child .ember-power-select-trigger,
.dropdown:last-child .dropdown-trigger,
.button:last-child {
border-top-right-radius: $radius;
border-bottom-right-radius: $radius;
}

&.is-shadowless {
box-shadow: none;
}

// Used to minimize any extra height the buttons would add to an otherwise
// text only container.
&.is-text {
margin-top: -0.5em;
margin-bottom: -0.5em;
vertical-align: middle;
}
}

Expand Down
Loading