diff --git a/ui/app/abilities/abstract.js b/ui/app/abilities/abstract.js index d8af277d492f..0f23e41fbec7 100644 --- a/ui/app/abilities/abstract.js +++ b/ui/app/abilities/abstract.js @@ -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) { diff --git a/ui/app/abilities/job.js b/ui/app/abilities/job.js index 1f8418c158ce..7b96096b3de9 100644 --- a/ui/app/abilities/job.js +++ b/ui/app/abilities/job.js @@ -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'); } } diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index f6992cb13569..dad4b37aa5e6 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -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', + }, + }, + }); + } } diff --git a/ui/app/components/search-box.js b/ui/app/components/search-box.js index 94de9724b1a9..be9cbcf26976 100644 --- a/ui/app/components/search-box.js +++ b/ui/app/components/search-box.js @@ -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); } } diff --git a/ui/app/components/stepper-input.js b/ui/app/components/stepper-input.js new file mode 100644 index 000000000000..871d74729346 --- /dev/null +++ b/ui/app/components/stepper-input.js @@ -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); +} diff --git a/ui/app/components/task-group-row.js b/ui/app/components/task-group-row.js index 399d901677a6..685ea65f6928 100644 --- a/ui/app/components/task-group-row.js +++ b/ui/app/components/task-group-row.js @@ -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); } diff --git a/ui/app/controllers/jobs/job/task-group.js b/ui/app/controllers/jobs/job/task-group.js index a20fdc52f7b8..cb6915be0500 100644 --- a/ui/app/controllers/jobs/job/task-group.js +++ b/ui/app/controllers/jobs/job/task-group.js @@ -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); + } } diff --git a/ui/app/helpers/lazy-click.js b/ui/app/helpers/lazy-click.js index e5927aa3ac4a..4260cca805d6 100644 --- a/ui/app/helpers/lazy-click.js +++ b/ui/app/helpers/lazy-click.js @@ -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); } } diff --git a/ui/app/models/group-scaling.js b/ui/app/models/group-scaling.js new file mode 100644 index 000000000000..f5ee13958cb5 --- /dev/null +++ b/ui/app/models/group-scaling.js @@ -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; +} diff --git a/ui/app/models/job.js b/ui/app/models/job.js index e179cd4b42bb..154fc07b1146 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -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; diff --git a/ui/app/models/task-group.js b/ui/app/models/task-group.js index 1e60913f243e..4926f0affa79 100644 --- a/ui/app/models/task-group.js +++ b/ui/app/models/task-group.js @@ -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); + } } diff --git a/ui/app/routes/jobs/job/task-group.js b/ui/app/routes/jobs/job/task-group.js index 6aad25aa0dde..72c130c7edd5 100644 --- a/ui/app/routes/jobs/job/task-group.js +++ b/ui/app/routes/jobs/job/task-group.js @@ -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; } diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 1bfd76d9b422..6f70cceef9cf 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -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'; diff --git a/ui/app/styles/components/dropdown.scss b/ui/app/styles/components/dropdown.scss index cfe613109117..f68e704e4e80 100644 --- a/ui/app/styles/components/dropdown.scss +++ b/ui/app/styles/components/dropdown.scss @@ -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; } } diff --git a/ui/app/styles/components/inline-definitions.scss b/ui/app/styles/components/inline-definitions.scss index ec28240cd105..a7927391986d 100644 --- a/ui/app/styles/components/inline-definitions.scss +++ b/ui/app/styles/components/inline-definitions.scss @@ -10,6 +10,7 @@ .pair { margin-right: 2em; + white-space: nowrap; .term { font-weight: $weight-semibold; diff --git a/ui/app/styles/components/stepper-input.scss b/ui/app/styles/components/stepper-input.scss new file mode 100644 index 000000000000..6059346937fe --- /dev/null +++ b/ui/app/styles/components/stepper-input.scss @@ -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; + } + } +} diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index 1fc5c7a530d6..2c39ed3ff583 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -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); diff --git a/ui/app/styles/core/icon.scss b/ui/app/styles/core/icon.scss index d2b499d50bf0..9a98c0409035 100644 --- a/ui/app/styles/core/icon.scss +++ b/ui/app/styles/core/icon.scss @@ -15,6 +15,7 @@ $icon-dimensions-large: 2rem; &.is-text { width: 1.2em; height: 1.2em; + pointer-events: none; } &.is-small { diff --git a/ui/app/templates/components/stepper-input.hbs b/ui/app/templates/components/stepper-input.hbs new file mode 100644 index 000000000000..80440a605b1d --- /dev/null +++ b/ui/app/templates/components/stepper-input.hbs @@ -0,0 +1,27 @@ + + + + diff --git a/ui/app/templates/components/task-group-row.hbs b/ui/app/templates/components/task-group-row.hbs index cb5501ca7f4a..3bc7e6b67db5 100644 --- a/ui/app/templates/components/task-group-row.hbs +++ b/ui/app/templates/components/task-group-row.hbs @@ -3,7 +3,32 @@ {{taskGroup.name}} -{{taskGroup.count}} + + {{count}} + {{#if taskGroup.scaling}} +
+ + +
+ {{/if}} +
diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index b2fce73718e3..307cd2bbff76 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -7,7 +7,21 @@

{{model.name}} - +
+ + {{#if model.scaling}} + + Count + + {{/if}} +

@@ -18,6 +32,14 @@ Reserved CPU {{model.reservedCPU}} MHz Reserved Memory {{model.reservedMemory}} MiB Reserved Disk {{model.reservedEphemeralDisk}} MiB + {{#if model.scaling}} + Count Range + {{model.scaling.min}} to {{model.scaling.max}} + + Scaling Policy? + {{if model.scaling.policy "Yes" "No"}} + + {{/if}}
diff --git a/ui/app/utils/properties/watch.js b/ui/app/utils/properties/watch.js index f4aac8d32429..ee890ef2bad5 100644 --- a/ui/app/utils/properties/watch.js +++ b/ui/app/utils/properties/watch.js @@ -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, { diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 8d5e30c9ed70..80c521791ded 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -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' }); diff --git a/ui/mirage/factories/task-group.js b/ui/mirage/factories/task-group.js index 8d443c1332d0..92a031944b18 100644 --- a/ui/mirage/factories/task-group.js +++ b/ui/mirage/factories/task-group.js @@ -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 diff --git a/ui/stories/components/stepper-input.stories.js b/ui/stories/components/stepper-input.stories.js new file mode 100644 index 000000000000..caab13c7cb54 --- /dev/null +++ b/ui/stories/components/stepper-input.stories.js @@ -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` +

+ + Stepper + + +

+

External Value: {{value}}

+ `, + context: { + min: 0, + max: 10, + value: 5, + variant: variantKnob(), + }, + }; +}; diff --git a/ui/tests/acceptance/task-group-detail-test.js b/ui/tests/acceptance/task-group-detail-test.js index 8b7761526fe1..15b477b3491d 100644 --- a/ui/tests/acceptance/task-group-detail-test.js +++ b/ui/tests/acceptance/task-group-detail-test.js @@ -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' }); diff --git a/ui/tests/integration/stepper-input-test.js b/ui/tests/integration/stepper-input-test.js new file mode 100644 index 000000000000..aa2161ced06b --- /dev/null +++ b/ui/tests/integration/stepper-input-test.js @@ -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` + + {{label}} + + `; + + 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); + }); +}); diff --git a/ui/tests/integration/task-group-row-test.js b/ui/tests/integration/task-group-row-test.js new file mode 100644 index 000000000000..3ebe4c99c0c7 --- /dev/null +++ b/ui/tests/integration/task-group-row-test.js @@ -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` + + `; + + 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") + ); + }); +}); diff --git a/ui/tests/pages/components/stepper-input.js b/ui/tests/pages/components/stepper-input.js new file mode 100644 index 000000000000..c35f9e49f40d --- /dev/null +++ b/ui/tests/pages/components/stepper-input.js @@ -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'), + }, +}); diff --git a/ui/tests/pages/jobs/job/task-group.js b/ui/tests/pages/jobs/job/task-group.js index 19f120f1f023..d69b267501fe 100644 --- a/ui/tests/pages/jobs/job/task-group.js +++ b/ui/tests/pages/jobs/job/task-group.js @@ -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]'), diff --git a/ui/tests/unit/abilities/job-test.js b/ui/tests/unit/abilities/job-test.js index bdc4ef5c6d21..b7a874a029e3 100644 --- a/ui/tests/unit/abilities/job-test.js +++ b/ui/tests/unit/abilities/job-test.js @@ -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,