Skip to content

Commit

Permalink
Merge pull request #8207 from hashicorp/f-ui/manual-scaling-controls
Browse files Browse the repository at this point in the history
UI: Task Group Scaling Controls
  • Loading branch information
DingoEatingFuzz authored Jun 19, 2020
2 parents 5a068e6 + c70ea97 commit fcf08db
Show file tree
Hide file tree
Showing 31 changed files with 982 additions and 33 deletions.
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('[email protected]')
get policiesSupportRunning() {
return this.rulesForActiveNamespace.some(rules => {
let capabilities = get(rules, 'Capabilities') || [];
return capabilities.includes('submit-job');
});
return this.activeNamespaceIncludesCapability('submit-job');
}

@computed('[email protected]')
get policiesSupportScaling() {
return this.activeNamespaceIncludesCapability('scale-job');
}
}
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');
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('[email protected]')
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

0 comments on commit fcf08db

Please sign in to comment.