mirror of
https://github.com/kemko/nomad.git
synced 2026-01-09 11:55:42 +03:00
Merge pull request #8207 from hashicorp/f-ui/manual-scaling-controls
UI: Task Group Scaling Controls
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
62
ui/app/components/stepper-input.js
Normal file
62
ui/app/components/stepper-input.js
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
15
ui/app/models/group-scaling.js
Normal file
15
ui/app/models/group-scaling.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -65,6 +66,7 @@ export default class TaskGroupRoute extends Route.extend(WithWatchers) {
|
||||
@watchRecord('job') watchJob;
|
||||
@watchRecord('job-summary') watchSummary;
|
||||
@watchRelationship('allocations') watchAllocations;
|
||||
@watchRelationship('latestDeployment') watchLatestDeployment;
|
||||
|
||||
@collect('watchJob', 'watchSummary', 'watchAllocations') watchers;
|
||||
@collect('watchJob', 'watchSummary', 'watchAllocations', 'watchLatestDeployment') watchers;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
.pair {
|
||||
margin-right: 2em;
|
||||
white-space: nowrap;
|
||||
|
||||
.term {
|
||||
font-weight: $weight-semibold;
|
||||
|
||||
102
ui/app/styles/components/stepper-input.scss
Normal file
102
ui/app/styles/components/stepper-input.scss
Normal file
@@ -0,0 +1,102 @@
|
||||
.stepper-input {
|
||||
display: inline-flex;
|
||||
font-weight: $weight-bold;
|
||||
box-shadow: $button-box-shadow-standard;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
border-radius: $radius;
|
||||
padding-left: 0.75em;
|
||||
white-space: nowrap;
|
||||
height: 2.25em;
|
||||
font-size: $body-size;
|
||||
vertical-align: top;
|
||||
|
||||
.stepper-input-label {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
padding-right: 0.75em;
|
||||
}
|
||||
|
||||
.stepper-input-input {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
-moz-appearance: textfield;
|
||||
width: 3em;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px $grey-light;
|
||||
}
|
||||
}
|
||||
|
||||
.stepper-input-input,
|
||||
.stepper-input-stepper {
|
||||
border: none;
|
||||
border-left: 1px solid;
|
||||
}
|
||||
|
||||
.stepper-input-stepper {
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: $radius;
|
||||
border-bottom-right-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
@each $name, $pair in $colors {
|
||||
$color: nth($pair, 1);
|
||||
$color-invert: nth($pair, 2);
|
||||
|
||||
&.is-#{$name} {
|
||||
border-color: darken($color, 10%);
|
||||
background: $color;
|
||||
color: $color-invert;
|
||||
|
||||
.stepper-input-input,
|
||||
.stepper-input-stepper {
|
||||
border-left-color: darken($color, 5%);
|
||||
}
|
||||
|
||||
.stepper-input-stepper.is-#{$name} {
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px rgba($white, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-small {
|
||||
font-size: $size-small;
|
||||
}
|
||||
|
||||
&.is-medium {
|
||||
font-size: $size-medium;
|
||||
}
|
||||
|
||||
&.is-large {
|
||||
font-size: $size-large;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
.stepper-input-input {
|
||||
opacity: 1;
|
||||
color: $grey;
|
||||
background-color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-xsmall {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
font-size: $size-7;
|
||||
}
|
||||
|
||||
@each $name, $pair in $colors {
|
||||
$color: nth($pair, 1);
|
||||
$color-invert: nth($pair, 2);
|
||||
|
||||
@@ -15,6 +15,7 @@ $icon-dimensions-large: 2rem;
|
||||
&.is-text {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.is-small {
|
||||
|
||||
27
ui/app/templates/components/stepper-input.hbs
Normal file
27
ui/app/templates/components/stepper-input.hbs
Normal file
@@ -0,0 +1,27 @@
|
||||
<label data-test-stepper-label class="stepper-input-label">{{yield}}</label>
|
||||
<input
|
||||
data-test-stepper-input
|
||||
type="number"
|
||||
min={{min}}
|
||||
max={{max}}
|
||||
value={{internalValue}}
|
||||
disabled={{disabled}}
|
||||
class="stepper-input-input"
|
||||
onKeyDown={{action "resetTextInput"}}
|
||||
onChange={{action "setValue"}}>
|
||||
<button
|
||||
data-test-stepper-decrement
|
||||
role="button"
|
||||
class="stepper-input-stepper button {{class}}"
|
||||
disabled={{or disabled (lte internalValue min)}}
|
||||
onclick={{action "decrement"}}>
|
||||
{{x-icon "minus-plain"}}
|
||||
</button>
|
||||
<button
|
||||
data-test-stepper-increment
|
||||
role="button"
|
||||
class="stepper-input-stepper button {{class}}"
|
||||
disabled={{or disabled (gte internalValue max)}}
|
||||
onclick={{action "increment"}}>
|
||||
{{x-icon "plus-plain"}}
|
||||
</button>
|
||||
@@ -3,7 +3,32 @@
|
||||
{{taskGroup.name}}
|
||||
</LinkTo>
|
||||
</td>
|
||||
<td data-test-task-group-count>{{taskGroup.count}}</td>
|
||||
<td data-test-task-group-count class="nowrap">
|
||||
{{count}}
|
||||
{{#if taskGroup.scaling}}
|
||||
<div
|
||||
data-test-scale-controls
|
||||
class="button-bar is-shadowless is-text bumper-left {{if (cannot "scale job") "tooltip"}}"
|
||||
aria-label={{if (cannot "scale job") "You aren't allowed to scale task groups"}}>
|
||||
<button
|
||||
data-test-scale="decrement"
|
||||
role="button"
|
||||
class="button is-xsmall is-light"
|
||||
disabled={{or isMinimum runningDeployment (cannot "scale job")}}
|
||||
onclick={{action "countDown"}}>
|
||||
{{x-icon "minus-plain" class="is-text"}}
|
||||
</button>
|
||||
<button
|
||||
data-test-scale="increment"
|
||||
role="button"
|
||||
class="button is-xsmall is-light"
|
||||
disabled={{or isMaximum runningDeployment (cannot "scale job")}}
|
||||
onclick={{action "countUp"}}>
|
||||
{{x-icon "plus-plain" class="is-text"}}
|
||||
</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-test-task-group-allocs>
|
||||
<div class="inline-chart"><AllocationStatusBar @allocationContainer={{taskGroup.summary}} @isNarrow={{true}} /></div>
|
||||
</td>
|
||||
|
||||
@@ -7,7 +7,21 @@
|
||||
<section class="section">
|
||||
<h1 class="title with-flex">
|
||||
<span>{{model.name}}</span>
|
||||
<Exec::OpenButton @job={{model.job}} @taskGroup={{model}} />
|
||||
<div>
|
||||
<Exec::OpenButton @job={{model.job}} @taskGroup={{model}} />
|
||||
{{#if model.scaling}}
|
||||
<StepperInput
|
||||
data-test-task-group-count-stepper
|
||||
@min={{model.scaling.min}}
|
||||
@max={{model.scaling.max}}
|
||||
@value={{model.count}}
|
||||
@class="is-primary is-small"
|
||||
@disabled={{or model.job.runningDeployment (cannot "scale job")}}
|
||||
@onChange={{action "scaleTaskGroup"}}>
|
||||
Count
|
||||
</StepperInput>
|
||||
{{/if}}
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
<div class="boxed-section is-small">
|
||||
@@ -18,6 +32,14 @@
|
||||
<span class="pair" data-test-task-group-cpu><span class="term">Reserved CPU</span> {{model.reservedCPU}} MHz</span>
|
||||
<span class="pair" data-test-task-group-mem><span class="term">Reserved Memory</span> {{model.reservedMemory}} MiB</span>
|
||||
<span class="pair" data-test-task-group-disk><span class="term">Reserved Disk</span> {{model.reservedEphemeralDisk}} MiB</span>
|
||||
{{#if model.scaling}}
|
||||
<span class="pair" data-test-task-group-min><span class="term">Count Range</span>
|
||||
{{model.scaling.min}} to {{model.scaling.max}}
|
||||
</span>
|
||||
<span class="pair" data-test-task-group-max><span class="term">Scaling Policy?</span>
|
||||
{{if model.scaling.policy "Yes" "No"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ export function watchRecord(modelName) {
|
||||
'To watch a record, the record adapter MUST extend Watchable',
|
||||
this.store.adapterFor(modelName) instanceof Watchable
|
||||
);
|
||||
const controller = new AbortController();
|
||||
if (typeof id === 'object') {
|
||||
id = get(id, 'id');
|
||||
}
|
||||
while (isEnabled && !Ember.testing) {
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
yield RSVP.all([
|
||||
this.store.findRecord(modelName, id, {
|
||||
@@ -45,8 +45,8 @@ export function watchRelationship(relationshipName) {
|
||||
'To watch a relationship, the adapter of the model provided to the watchRelationship task MUST extend Watchable',
|
||||
this.store.adapterFor(model.constructor.modelName) instanceof Watchable
|
||||
);
|
||||
const controller = new AbortController();
|
||||
while (isEnabled && !Ember.testing) {
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
yield RSVP.all([
|
||||
this.store
|
||||
@@ -73,8 +73,8 @@ export function watchAll(modelName) {
|
||||
'To watch all, the respective adapter MUST extend Watchable',
|
||||
this.store.adapterFor(modelName) instanceof Watchable
|
||||
);
|
||||
const controller = new AbortController();
|
||||
while (isEnabled && !Ember.testing) {
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
yield RSVP.all([
|
||||
this.store.findAll(modelName, {
|
||||
@@ -99,8 +99,8 @@ export function watchQuery(modelName) {
|
||||
'To watch a query, the adapter for the type being queried MUST extend Watchable',
|
||||
this.store.adapterFor(modelName) instanceof Watchable
|
||||
);
|
||||
const controller = new AbortController();
|
||||
while (isEnabled && !Ember.testing) {
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
yield RSVP.all([
|
||||
this.store.query(modelName, params, {
|
||||
|
||||
@@ -172,6 +172,10 @@ export default function() {
|
||||
return okEmpty();
|
||||
});
|
||||
|
||||
this.post('/job/:id/scale', function({ jobs }, { params }) {
|
||||
return this.serialize(jobs.find(params.id));
|
||||
});
|
||||
|
||||
this.delete('/job/:id', function(schema, { params }) {
|
||||
const job = schema.jobs.find(params.id);
|
||||
job.update({ status: 'dead' });
|
||||
|
||||
@@ -18,6 +18,8 @@ export default Factory.extend({
|
||||
volumes: () => ({}),
|
||||
}),
|
||||
|
||||
withScaling: faker.random.boolean,
|
||||
|
||||
volumes: makeHostVolumes(),
|
||||
|
||||
// Directive used to control whether or not allocations are automatically
|
||||
@@ -38,6 +40,31 @@ export default Factory.extend({
|
||||
let taskIds = [];
|
||||
let volumes = Object.keys(group.volumes);
|
||||
|
||||
if (group.withScaling) {
|
||||
group.update({
|
||||
scaling: {
|
||||
Min: 1,
|
||||
Max: 5,
|
||||
Policy: faker.random.boolean() && {
|
||||
EvaluationInterval: '10s',
|
||||
Cooldown: '2m',
|
||||
Check: {
|
||||
avg_conn: {
|
||||
Source: 'prometheus',
|
||||
Query:
|
||||
'scalar(avg((haproxy_server_current_sessions{backend="http_back"}) and (haproxy_server_up{backend="http_back"} == 1)))',
|
||||
Strategy: {
|
||||
'target-value': {
|
||||
target: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!group.shallow) {
|
||||
const tasks = provide(group.count, () => {
|
||||
const mounts = faker.helpers
|
||||
|
||||
48
ui/stories/components/stepper-input.stories.js
Normal file
48
ui/stories/components/stepper-input.stories.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { withKnobs, optionsKnob } from '@storybook/addon-knobs';
|
||||
|
||||
export default {
|
||||
title: 'Components|Stepper Input',
|
||||
decorators: [withKnobs],
|
||||
};
|
||||
|
||||
const variantKnob = () =>
|
||||
optionsKnob(
|
||||
'Variant',
|
||||
{
|
||||
Primary: 'is-primary',
|
||||
Info: 'is-info',
|
||||
Warning: 'is-warning',
|
||||
Danger: 'is-danger',
|
||||
},
|
||||
'is-primary',
|
||||
{
|
||||
display: 'inline-radio',
|
||||
},
|
||||
'variant-id'
|
||||
);
|
||||
|
||||
export let Standard = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<p class="mock-spacing">
|
||||
<StepperInput
|
||||
@value={{value}}
|
||||
@min={{min}}
|
||||
@max={{max}}
|
||||
@class={{variant}}
|
||||
@onChange={{action (mut value)}}>
|
||||
Stepper
|
||||
</StepperInput>
|
||||
<button class="button is-info">Button for Context</button>
|
||||
</p>
|
||||
<p class="mock-spacing"><strong>External Value:</strong> {{value}}</p>
|
||||
`,
|
||||
context: {
|
||||
min: 0,
|
||||
max: 10,
|
||||
value: 5,
|
||||
variant: variantKnob(),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { currentURL } from '@ember/test-helpers';
|
||||
import { currentURL, settled } from '@ember/test-helpers';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
@@ -11,6 +11,7 @@ let job;
|
||||
let taskGroup;
|
||||
let tasks;
|
||||
let allocations;
|
||||
let managementToken;
|
||||
|
||||
const sum = (total, n) => total + n;
|
||||
|
||||
@@ -61,6 +62,8 @@ module('Acceptance | task group detail', function(hooks) {
|
||||
previousAllocation: allocations[0].id,
|
||||
});
|
||||
|
||||
managementToken = server.create('token');
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -297,6 +300,59 @@ module('Acceptance | task group detail', function(hooks) {
|
||||
});
|
||||
});
|
||||
|
||||
test('the count stepper sends the appropriate POST request', async function(assert) {
|
||||
window.localStorage.nomadTokenSecret = managementToken.secretId;
|
||||
|
||||
job = server.create('job', {
|
||||
groupCount: 0,
|
||||
createAllocations: false,
|
||||
shallow: true,
|
||||
noActiveDeployment: true,
|
||||
});
|
||||
const scalingGroup = server.create('task-group', {
|
||||
job,
|
||||
name: 'scaling',
|
||||
count: 1,
|
||||
shallow: true,
|
||||
withScaling: true,
|
||||
});
|
||||
job.update({ taskGroupIds: [scalingGroup.id] });
|
||||
|
||||
await TaskGroup.visit({ id: job.id, name: scalingGroup.name });
|
||||
await TaskGroup.countStepper.increment.click();
|
||||
await settled();
|
||||
|
||||
const scaleRequest = server.pretender.handledRequests.find(req => req.url.endsWith('/scale'));
|
||||
const requestBody = JSON.parse(scaleRequest.requestBody);
|
||||
assert.equal(requestBody.Target.Group, scalingGroup.name);
|
||||
assert.equal(requestBody.Count, scalingGroup.count + 1);
|
||||
});
|
||||
|
||||
test('the count stepper is disabled when a deployment is running', async function(assert) {
|
||||
window.localStorage.nomadTokenSecret = managementToken.secretId;
|
||||
|
||||
job = server.create('job', {
|
||||
groupCount: 0,
|
||||
createAllocations: false,
|
||||
shallow: true,
|
||||
activeDeployment: true,
|
||||
});
|
||||
const scalingGroup = server.create('task-group', {
|
||||
job,
|
||||
name: 'scaling',
|
||||
count: 1,
|
||||
shallow: true,
|
||||
withScaling: true,
|
||||
});
|
||||
job.update({ taskGroupIds: [scalingGroup.id] });
|
||||
|
||||
await TaskGroup.visit({ id: job.id, name: scalingGroup.name });
|
||||
|
||||
assert.ok(TaskGroup.countStepper.input.isDisabled);
|
||||
assert.ok(TaskGroup.countStepper.increment.isDisabled);
|
||||
assert.ok(TaskGroup.countStepper.decrement.isDisabled);
|
||||
});
|
||||
|
||||
test('when the job for the task group is not found, an error message is shown, but the URL persists', async function(assert) {
|
||||
await TaskGroup.visit({ id: 'not-a-real-job', name: 'not-a-real-task-group' });
|
||||
|
||||
|
||||
167
ui/tests/integration/stepper-input-test.js
Normal file
167
ui/tests/integration/stepper-input-test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import { find, render, settled, triggerEvent, waitUntil } 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 stepperInput from 'nomad-ui/tests/pages/components/stepper-input';
|
||||
|
||||
const StepperInput = create(stepperInput());
|
||||
const valueChange = () => {
|
||||
const initial = StepperInput.input.value;
|
||||
return () => StepperInput.input.value !== initial;
|
||||
};
|
||||
|
||||
module('Integration | Component | stepper input', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
const commonProperties = () => ({
|
||||
min: 0,
|
||||
max: 10,
|
||||
value: 5,
|
||||
label: 'Stepper',
|
||||
classVariant: 'is-primary',
|
||||
disabled: false,
|
||||
onChange: sinon.spy(),
|
||||
});
|
||||
|
||||
const commonTemplate = hbs`
|
||||
<StepperInput
|
||||
@debounce=50
|
||||
@min={{min}}
|
||||
@max={{max}}
|
||||
@value={{value}}
|
||||
@class={{classVariant}}
|
||||
@disabled={{disabled}}
|
||||
@onChange={{onChange}}>
|
||||
{{label}}
|
||||
</StepperInput>
|
||||
`;
|
||||
|
||||
test('basic appearance includes a label, an input, and two buttons', async function(assert) {
|
||||
this.setProperties(commonProperties());
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.equal(StepperInput.label, this.label);
|
||||
assert.equal(StepperInput.input.value, this.value);
|
||||
assert.ok(StepperInput.decrement.isPresent);
|
||||
assert.ok(StepperInput.increment.isPresent);
|
||||
assert.ok(StepperInput.decrement.classNames.split(' ').includes(this.classVariant));
|
||||
assert.ok(StepperInput.increment.classNames.split(' ').includes(this.classVariant));
|
||||
});
|
||||
|
||||
test('clicking the increment and decrement buttons immediately changes the shown value in the input but debounces the onUpdate call', async function(assert) {
|
||||
this.setProperties(commonProperties());
|
||||
|
||||
const baseValue = this.value;
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
StepperInput.increment.click();
|
||||
await waitUntil(valueChange());
|
||||
assert.equal(StepperInput.input.value, baseValue + 1);
|
||||
assert.notOk(this.onChange.called);
|
||||
|
||||
StepperInput.decrement.click();
|
||||
await waitUntil(valueChange());
|
||||
assert.equal(StepperInput.input.value, baseValue);
|
||||
assert.notOk(this.onChange.called);
|
||||
|
||||
StepperInput.decrement.click();
|
||||
await waitUntil(valueChange());
|
||||
assert.equal(StepperInput.input.value, baseValue - 1);
|
||||
assert.notOk(this.onChange.called);
|
||||
|
||||
await settled();
|
||||
assert.ok(this.onChange.calledWith(baseValue - 1));
|
||||
});
|
||||
|
||||
test('the increment button is disabled when the internal value is the max value', async function(assert) {
|
||||
this.setProperties(commonProperties());
|
||||
this.set('value', this.max);
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.ok(StepperInput.increment.isDisabled);
|
||||
});
|
||||
|
||||
test('the decrement button is disabled when the internal value is the min value', async function(assert) {
|
||||
this.setProperties(commonProperties());
|
||||
this.set('value', this.min);
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.ok(StepperInput.decrement.isDisabled);
|
||||
});
|
||||
|
||||
test('the text input does not call the onUpdate function on oninput', async function(assert) {
|
||||
this.setProperties(commonProperties());
|
||||
const newValue = 8;
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
const input = find('[data-test-stepper-input]');
|
||||
|
||||
input.value = newValue;
|
||||
assert.equal(StepperInput.input.value, newValue);
|
||||
assert.notOk(this.onChange.called);
|
||||
|
||||
await triggerEvent(input, 'input');
|
||||
assert.equal(StepperInput.input.value, newValue);
|
||||
assert.notOk(this.onChange.called);
|
||||
|
||||
await triggerEvent(input, 'change');
|
||||
assert.equal(StepperInput.input.value, newValue);
|
||||
assert.ok(this.onChange.calledWith(newValue));
|
||||
});
|
||||
|
||||
test('the text input does call the onUpdate function on onchange', async function(assert) {
|
||||
this.setProperties(commonProperties());
|
||||
const newValue = 8;
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
await StepperInput.input.fill(newValue);
|
||||
|
||||
await settled();
|
||||
assert.equal(StepperInput.input.value, newValue);
|
||||
assert.ok(this.onChange.calledWith(newValue));
|
||||
});
|
||||
|
||||
test('text input limits input to the bounds of the min/max range', async function(assert) {
|
||||
this.setProperties(commonProperties());
|
||||
let newValue = this.max + 1;
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
await StepperInput.input.fill(newValue);
|
||||
await settled();
|
||||
|
||||
assert.equal(StepperInput.input.value, this.max);
|
||||
assert.ok(this.onChange.calledWith(this.max));
|
||||
|
||||
newValue = this.min - 1;
|
||||
|
||||
await StepperInput.input.fill(newValue);
|
||||
await settled();
|
||||
|
||||
assert.equal(StepperInput.input.value, this.min);
|
||||
assert.ok(this.onChange.calledWith(this.min));
|
||||
});
|
||||
|
||||
test('pressing ESC in the text input reverts the text value back to the current value', async function(assert) {
|
||||
this.setProperties(commonProperties());
|
||||
const newValue = 8;
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
const input = find('[data-test-stepper-input]');
|
||||
|
||||
input.value = newValue;
|
||||
assert.equal(StepperInput.input.value, newValue);
|
||||
|
||||
await StepperInput.input.esc();
|
||||
assert.equal(StepperInput.input.value, this.value);
|
||||
});
|
||||
});
|
||||
175
ui/tests/integration/task-group-row-test.js
Normal file
175
ui/tests/integration/task-group-row-test.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { click, find, render, settled, waitUntil } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
|
||||
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
|
||||
|
||||
const jobName = 'test-job';
|
||||
const jobId = JSON.stringify([jobName, 'default']);
|
||||
|
||||
let managementToken;
|
||||
let clientToken;
|
||||
|
||||
const makeJob = (server, props = {}) => {
|
||||
// These tests require a job with particular task groups. This requires
|
||||
// mild Mirage surgery.
|
||||
const job = server.create('job', {
|
||||
id: jobName,
|
||||
groupCount: 0,
|
||||
createAllocations: false,
|
||||
shallow: true,
|
||||
...props,
|
||||
});
|
||||
const noScalingGroup = server.create('task-group', {
|
||||
job,
|
||||
name: 'no-scaling',
|
||||
shallow: true,
|
||||
withScaling: false,
|
||||
});
|
||||
const scalingGroup = server.create('task-group', {
|
||||
job,
|
||||
count: 2,
|
||||
name: 'scaling',
|
||||
shallow: true,
|
||||
withScaling: true,
|
||||
});
|
||||
job.update({
|
||||
taskGroupIds: [noScalingGroup.id, scalingGroup.id],
|
||||
});
|
||||
};
|
||||
|
||||
module('Integration | Component | task group row', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(async function() {
|
||||
fragmentSerializerInitializer(this.owner);
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.token = this.owner.lookup('service:token');
|
||||
this.server = startMirage();
|
||||
this.server.create('node');
|
||||
|
||||
managementToken = this.server.create('token');
|
||||
clientToken = this.server.create('token');
|
||||
window.localStorage.nomadTokenSecret = managementToken.secretId;
|
||||
});
|
||||
|
||||
hooks.afterEach(function() {
|
||||
this.server.shutdown();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
const commonTemplate = hbs`
|
||||
<TaskGroupRow @taskGroup={{group}} />
|
||||
`;
|
||||
|
||||
test('Task group row conditionally shows scaling buttons based on the presence of the scaling attr on the task group', async function(assert) {
|
||||
makeJob(this.server);
|
||||
this.token.fetchSelfTokenAndPolicies.perform();
|
||||
await settled();
|
||||
|
||||
const job = await this.store.find('job', jobId);
|
||||
this.set('group', job.taskGroups.findBy('name', 'no-scaling'));
|
||||
|
||||
await render(commonTemplate);
|
||||
assert.notOk(find('[data-test-scale]'));
|
||||
|
||||
this.set('group', job.taskGroups.findBy('name', 'scaling'));
|
||||
|
||||
await settled();
|
||||
assert.ok(find('[data-test-scale]'));
|
||||
});
|
||||
|
||||
test('Clicking scaling buttons immediately updates the rendered count but debounces the scaling API request', async function(assert) {
|
||||
makeJob(this.server);
|
||||
this.token.fetchSelfTokenAndPolicies.perform();
|
||||
await settled();
|
||||
|
||||
const job = await this.store.find('job', jobId);
|
||||
this.set('group', job.taskGroups.findBy('name', 'scaling'));
|
||||
|
||||
await render(commonTemplate);
|
||||
assert.equal(find('[data-test-task-group-count]').textContent, 2);
|
||||
|
||||
click('[data-test-scale="increment"]');
|
||||
await waitUntil(() => !find('[data-test-task-group-count]').textContent.includes('1'));
|
||||
assert.equal(find('[data-test-task-group-count]').textContent, 3);
|
||||
|
||||
click('[data-test-scale="increment"]');
|
||||
await waitUntil(() => !find('[data-test-task-group-count]').textContent.includes('2'));
|
||||
assert.equal(find('[data-test-task-group-count]').textContent, 4);
|
||||
|
||||
assert.notOk(
|
||||
server.pretender.handledRequests.find(
|
||||
req => req.method === 'POST' && req.url.endsWith('/scale')
|
||||
)
|
||||
);
|
||||
|
||||
await settled();
|
||||
const scaleRequests = server.pretender.handledRequests.filter(
|
||||
req => req.method === 'POST' && req.url.endsWith('/scale')
|
||||
);
|
||||
assert.equal(scaleRequests.length, 1);
|
||||
assert.equal(JSON.parse(scaleRequests[0].requestBody).Count, 4);
|
||||
});
|
||||
|
||||
test('When the current count is equal to the max count, the increment count button is disabled', async function(assert) {
|
||||
makeJob(this.server);
|
||||
this.token.fetchSelfTokenAndPolicies.perform();
|
||||
await settled();
|
||||
|
||||
const job = await this.store.find('job', jobId);
|
||||
const group = job.taskGroups.findBy('name', 'scaling');
|
||||
group.set('count', group.scaling.max);
|
||||
this.set('group', group);
|
||||
|
||||
await render(commonTemplate);
|
||||
assert.ok(find('[data-test-scale="increment"]:disabled'));
|
||||
});
|
||||
|
||||
test('When the current count is equal to the min count, the decrement count button is disabled', async function(assert) {
|
||||
makeJob(this.server);
|
||||
this.token.fetchSelfTokenAndPolicies.perform();
|
||||
await settled();
|
||||
|
||||
const job = await this.store.find('job', jobId);
|
||||
const group = job.taskGroups.findBy('name', 'scaling');
|
||||
group.set('count', group.scaling.min);
|
||||
this.set('group', group);
|
||||
|
||||
await render(commonTemplate);
|
||||
assert.ok(find('[data-test-scale="decrement"]:disabled'));
|
||||
});
|
||||
|
||||
test('When there is an active deployment, both scale buttons are disabled', async function(assert) {
|
||||
makeJob(this.server, { activeDeployment: true });
|
||||
this.token.fetchSelfTokenAndPolicies.perform();
|
||||
await settled();
|
||||
|
||||
const job = await this.store.find('job', jobId);
|
||||
this.set('group', job.taskGroups.findBy('name', 'scaling'));
|
||||
|
||||
await render(commonTemplate);
|
||||
assert.ok(find('[data-test-scale="increment"]:disabled'));
|
||||
assert.ok(find('[data-test-scale="decrement"]:disabled'));
|
||||
});
|
||||
|
||||
test('When the current ACL token does not have the namespace:scale-job or namespace:submit-job policy rule', async function(assert) {
|
||||
makeJob(this.server);
|
||||
window.localStorage.nomadTokenSecret = clientToken.secretId;
|
||||
this.token.fetchSelfTokenAndPolicies.perform();
|
||||
await settled();
|
||||
|
||||
const job = await this.store.find('job', jobId);
|
||||
this.set('group', job.taskGroups.findBy('name', 'scaling'));
|
||||
|
||||
await render(commonTemplate);
|
||||
assert.ok(find('[data-test-scale="increment"]:disabled'));
|
||||
assert.ok(find('[data-test-scale="decrement"]:disabled'));
|
||||
assert.ok(
|
||||
find('[data-test-scale-controls]')
|
||||
.getAttribute('aria-label')
|
||||
.includes("You aren't allowed")
|
||||
);
|
||||
});
|
||||
});
|
||||
43
ui/tests/pages/components/stepper-input.js
Normal file
43
ui/tests/pages/components/stepper-input.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
attribute,
|
||||
blurrable,
|
||||
clickable,
|
||||
fillable,
|
||||
focusable,
|
||||
isPresent,
|
||||
text,
|
||||
triggerable,
|
||||
value,
|
||||
} from 'ember-cli-page-object';
|
||||
|
||||
export default scope => ({
|
||||
scope,
|
||||
|
||||
label: text('[data-test-stepper-label]'),
|
||||
|
||||
input: {
|
||||
scope: '[data-test-stepper-input]',
|
||||
fill: fillable(),
|
||||
focus: focusable(),
|
||||
blur: blurrable(),
|
||||
value: value(),
|
||||
esc: triggerable('keydown', '', { eventProperties: { keyCode: 27 } }),
|
||||
isDisabled: attribute('disabled'),
|
||||
},
|
||||
|
||||
decrement: {
|
||||
scope: '[data-test-stepper-decrement]',
|
||||
click: clickable(),
|
||||
isPresent: isPresent(),
|
||||
isDisabled: attribute('disabled'),
|
||||
classNames: attribute('class'),
|
||||
},
|
||||
|
||||
increment: {
|
||||
scope: '[data-test-stepper-increment]',
|
||||
click: clickable(),
|
||||
isPresent: isPresent(),
|
||||
isDisabled: attribute('disabled'),
|
||||
classNames: attribute('class'),
|
||||
},
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import allocations from 'nomad-ui/tests/pages/components/allocations';
|
||||
import error from 'nomad-ui/tests/pages/components/error';
|
||||
import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
|
||||
import stepperInput from 'nomad-ui/tests/pages/components/stepper-input';
|
||||
import LifecycleChart from 'nomad-ui/tests/pages/components/lifecycle-chart';
|
||||
|
||||
export default create({
|
||||
@@ -21,6 +22,8 @@ export default create({
|
||||
|
||||
search: fillable('.search-box input'),
|
||||
|
||||
countStepper: stepperInput('[data-test-task-group-count-stepper]'),
|
||||
|
||||
tasksCount: text('[data-test-task-group-tasks]'),
|
||||
cpu: text('[data-test-task-group-cpu]'),
|
||||
mem: text('[data-test-task-group-mem]'),
|
||||
|
||||
@@ -126,6 +126,49 @@ module('Unit | Ability | job', function(hooks) {
|
||||
assert.notOk(this.ability.canRun);
|
||||
});
|
||||
|
||||
test('job scale requires a client token with the submit-job or scale-job capability', function(assert) {
|
||||
const makePolicies = (namespace, ...capabilities) => [
|
||||
{
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: namespace,
|
||||
Capabilities: capabilities,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockSystem = Service.extend({
|
||||
aclEnabled: true,
|
||||
activeNamespace: {
|
||||
name: 'aNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'client' },
|
||||
selfTokenPolicies: makePolicies('aNamespace'),
|
||||
});
|
||||
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
const tokenService = this.owner.lookup('service:token');
|
||||
|
||||
assert.notOk(this.ability.canScale);
|
||||
|
||||
tokenService.set('selfTokenPolicies', makePolicies('aNamespace', 'scale-job'));
|
||||
assert.ok(this.ability.canScale);
|
||||
|
||||
tokenService.set('selfTokenPolicies', makePolicies('aNamespace', 'submit-job'));
|
||||
assert.ok(this.ability.canScale);
|
||||
|
||||
tokenService.set('selfTokenPolicies', makePolicies('bNamespace', 'scale-job'));
|
||||
assert.notOk(this.ability.canScale);
|
||||
});
|
||||
|
||||
test('it handles globs in namespace names', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
aclEnabled: true,
|
||||
|
||||
Reference in New Issue
Block a user