Merge pull request #8207 from hashicorp/f-ui/manual-scaling-controls

UI: Task Group Scaling Controls
This commit is contained in:
Michael Lange
2020-06-19 10:29:01 -07:00
committed by GitHub
31 changed files with 982 additions and 33 deletions

View File

@@ -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) {

View File

@@ -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');
}
}

View File

@@ -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',
},
},
});
}
}

View File

@@ -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);
}
}

View 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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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;
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -10,6 +10,7 @@
.pair {
margin-right: 2em;
white-space: nowrap;
.term {
font-weight: $weight-semibold;

View 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;
}
}
}

View File

@@ -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);

View File

@@ -15,6 +15,7 @@ $icon-dimensions-large: 2rem;
&.is-text {
width: 1.2em;
height: 1.2em;
pointer-events: none;
}
&.is-small {

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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, {

View File

@@ -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' });

View File

@@ -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

View 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(),
},
};
};

View File

@@ -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' });

View 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);
});
});

View 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")
);
});
});

View 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'),
},
});

View File

@@ -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]'),

View File

@@ -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,