From 29ff3f9c8c3dd5db81a5afd32a6294927076b723 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 00:16:12 -0700 Subject: [PATCH 01/28] Model the scaling properties of a task group as a fragment --- ui/app/models/group-scaling.js | 15 +++++++++++++++ ui/app/models/task-group.js | 4 +++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 ui/app/models/group-scaling.js 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/task-group.js b/ui/app/models/task-group.js index 1e60913f243e..8647eccc5121 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(); From e28efc6afcaacba2b657f9be084af98621b5f678 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 00:16:42 -0700 Subject: [PATCH 02/28] LazyClick should also get interrupted by buttons --- ui/app/helpers/lazy-click.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } } From 4e7b844422765f3bf1cda731f0a68219dd291096 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 00:17:15 -0700 Subject: [PATCH 03/28] New xsmall button size --- ui/app/styles/core/buttons.scss | 6 ++++++ 1 file changed, 6 insertions(+) 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); From 0137f8d02a7ba15b3e146658353007a9b5b14449 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 00:17:29 -0700 Subject: [PATCH 04/28] When an icon is intended as text, it shouldn't have pointer events This prevents the svg from being a target in click events. --- ui/app/styles/core/icon.scss | 1 + 1 file changed, 1 insertion(+) 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 { From 158f7764be82234db3dd9816f9572ea06e8b9af9 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 00:19:13 -0700 Subject: [PATCH 05/28] Extend button-bar support to buttons --- ui/app/styles/components/dropdown.scss | 31 +++++++++++++------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/ui/app/styles/components/dropdown.scss b/ui/app/styles/components/dropdown.scss index cfe613109117..b5af7da363f4 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,18 @@ } } - .dropdown:first-child { - .ember-power-select-trigger, - .dropdown-trigger { - border-top-left-radius: $radius; - border-bottom-left-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-trigger { - border-top-right-radius: $radius; - border-bottom-right-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; } } From 461980d927e623259df7472e3814736370e7bfb8 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 00:19:37 -0700 Subject: [PATCH 06/28] Additional button-bar treatments for use in a table row --- ui/app/styles/components/dropdown.scss | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ui/app/styles/components/dropdown.scss b/ui/app/styles/components/dropdown.scss index b5af7da363f4..8d47a94b7297 100644 --- a/ui/app/styles/components/dropdown.scss +++ b/ui/app/styles/components/dropdown.scss @@ -86,6 +86,18 @@ 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; + } } .ember-power-select-selected-item, From 6b57adc013fc4e1f90d8569ba630683cdff699ef Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 00:20:01 -0700 Subject: [PATCH 07/28] Prevent inline definition key/value pairs from breaking the key and value onto separate lines --- ui/app/styles/components/inline-definitions.scss | 1 + 1 file changed, 1 insertion(+) 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; From 2e1adbc83e42c2b2e37263bbc6b7c70341510d43 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 00:20:29 -0700 Subject: [PATCH 08/28] Add the min/max and policy y/n of a task group to the details ribbon --- ui/app/templates/jobs/job/task-group.hbs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index b2fce73718e3..3fb6d1ef33fd 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -18,6 +18,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}} From ed6c4147458ba2d6827893942f87cefbdf728484 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 00:20:57 -0700 Subject: [PATCH 09/28] Add the elements of the manual scaling actions to the task-group-row component --- ui/app/templates/components/task-group-row.hbs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/app/templates/components/task-group-row.hbs b/ui/app/templates/components/task-group-row.hbs index cb5501ca7f4a..fed43404426f 100644 --- a/ui/app/templates/components/task-group-row.hbs +++ b/ui/app/templates/components/task-group-row.hbs @@ -3,7 +3,15 @@ {{taskGroup.name}} -{{taskGroup.count}} + + {{taskGroup.count}} + {{#if taskGroup.scaling}} +
+ + +
+ {{/if}} +
From 3768a63712a9db586466abe9f3eed14a13253478 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 00:28:15 -0700 Subject: [PATCH 10/28] Make sure buttons in a button bar have a very visible focus state --- ui/app/styles/components/dropdown.scss | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ui/app/styles/components/dropdown.scss b/ui/app/styles/components/dropdown.scss index 8d47a94b7297..f68e704e4e80 100644 --- a/ui/app/styles/components/dropdown.scss +++ b/ui/app/styles/components/dropdown.scss @@ -73,6 +73,17 @@ } } + // 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:first-child .ember-power-select-trigger, .dropdown:first-child .dropdown-trigger, .button:first-child { From 856743983eb49d18fe84074be20326e18a815060 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 01:45:36 -0700 Subject: [PATCH 11/28] Create new AbortController with each tick of the ec task loops This was a disturbing discovery. Requests in watch loops would recycle AbortControllers meaning once any request was aborted, all requests forever after were skipped. I noticed it with deployments and job summary on the job detail page. I suspect this regression occurred when jQuery was removed. This needs test coverage still to make sure it doesn't happen again. --- ui/app/utils/properties/watch.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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, { From 149dcdacb449fa5803e1578ee57de8abe591ac34 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 01:48:10 -0700 Subject: [PATCH 12/28] New scale action for jobs (and a convenience task group method) --- ui/app/adapters/job.js | 16 ++++++++++++++++ ui/app/models/job.js | 4 ++++ ui/app/models/task-group.js | 4 ++++ 3 files changed, 24 insertions(+) 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/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 8647eccc5121..4926f0affa79 100644 --- a/ui/app/models/task-group.js +++ b/ui/app/models/task-group.js @@ -53,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); + } } From 9a344e468e3f128ce499d9af2f246672332e8501 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 01:48:41 -0700 Subject: [PATCH 13/28] Wire up the +/- buttons in task group rows to the job scale action --- ui/app/components/search-box.js | 6 +-- ui/app/components/task-group-row.js | 48 ++++++++++++++++++- .../templates/components/task-group-row.hbs | 18 +++++-- 3 files changed, 65 insertions(+), 7 deletions(-) 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/task-group-row.js b/ui/app/components/task-group-row.js index 399d901677a6..2aa2a3b64e36 100644 --- a/ui/app/components/task-group-row.js +++ b/ui/app/components/task-group-row.js @@ -1,17 +1,63 @@ import Component from '@ember/component'; -import { lazyClick } from '../helpers/lazy-click'; +import { computed, action } from '@ember/object'; +import { 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 = 300; + + @oneWay('taskGroup.count') count; 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/templates/components/task-group-row.hbs b/ui/app/templates/components/task-group-row.hbs index fed43404426f..63868ee168fa 100644 --- a/ui/app/templates/components/task-group-row.hbs +++ b/ui/app/templates/components/task-group-row.hbs @@ -4,11 +4,23 @@ - {{taskGroup.count}} + {{count}} {{#if taskGroup.scaling}}
- - + +
{{/if}} From a1f1079cfd133e1f14bea2e35ffa0968792fa141 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 18:07:53 -0700 Subject: [PATCH 14/28] Mirage updates for task group scaling and scaling post endpoint --- ui/mirage/config.js | 4 ++++ ui/mirage/factories/task-group.js | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) 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 From 7fec4d8bc59ce9d5cfcfa65d055008a5175b42f9 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 22:44:35 -0700 Subject: [PATCH 15/28] Add canScale ability for jobs --- ui/app/abilities/abstract.js | 7 +++++ ui/app/abilities/job.js | 20 ++++++++++---- ui/tests/unit/abilities/job-test.js | 43 +++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) 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/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, From 11822035593b2fb33ebd0afdcaea60e69372832c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 22:45:21 -0700 Subject: [PATCH 16/28] Disable scale buttons when a deployment is running or ACL forbids it --- ui/app/components/task-group-row.js | 3 ++- ui/app/templates/components/task-group-row.hbs | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/ui/app/components/task-group-row.js b/ui/app/components/task-group-row.js index 2aa2a3b64e36..7bcef3f5aa43 100644 --- a/ui/app/components/task-group-row.js +++ b/ui/app/components/task-group-row.js @@ -1,6 +1,6 @@ import Component from '@ember/component'; import { computed, action } from '@ember/object'; -import { oneWay } from '@ember/object/computed'; +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'; @@ -14,6 +14,7 @@ export default class TaskGroupRow extends Component { debounce = 300; @oneWay('taskGroup.count') count; + @alias('taskGroup.job.runningDeployment') runningDeployment; onClick() {} diff --git a/ui/app/templates/components/task-group-row.hbs b/ui/app/templates/components/task-group-row.hbs index 63868ee168fa..3bc7e6b67db5 100644 --- a/ui/app/templates/components/task-group-row.hbs +++ b/ui/app/templates/components/task-group-row.hbs @@ -6,18 +6,23 @@ {{count}} {{#if taskGroup.scaling}} -
+
From 469b107a64992f13c36e5d9e42e2d946335ecfb1 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Jun 2020 22:45:43 -0700 Subject: [PATCH 17/28] Test coverage for the task group row scale actions --- ui/tests/integration/task-group-row-test.js | 175 ++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 ui/tests/integration/task-group-row-test.js 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") + ); + }); +}); From 0b0be1b63bca9ce0efa421322e06217cb8d3dab1 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 18 Jun 2020 13:19:25 -0700 Subject: [PATCH 18/28] Slow the debounce time. --- ui/app/components/task-group-row.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/app/components/task-group-row.js b/ui/app/components/task-group-row.js index 7bcef3f5aa43..685ea65f6928 100644 --- a/ui/app/components/task-group-row.js +++ b/ui/app/components/task-group-row.js @@ -11,7 +11,7 @@ import { lazyClick } from '../helpers/lazy-click'; @classNames('task-group-row', 'is-interactive') export default class TaskGroupRow extends Component { taskGroup = null; - debounce = 300; + debounce = 500; @oneWay('taskGroup.count') count; @alias('taskGroup.job.runningDeployment') runningDeployment; From 2b88651fca67e23deca046271f7ffdd1e251fda9 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 18 Jun 2020 13:19:47 -0700 Subject: [PATCH 19/28] Barebones StepperInput component --- ui/app/components/stepper-input.js | 68 +++++++++++++++++++ ui/app/templates/components/stepper-input.hbs | 22 ++++++ 2 files changed, 90 insertions(+) create mode 100644 ui/app/components/stepper-input.js create mode 100644 ui/app/templates/components/stepper-input.hbs diff --git a/ui/app/components/stepper-input.js b/ui/app/components/stepper-input.js new file mode 100644 index 000000000000..eeef1c3bbaa6 --- /dev/null +++ b/ui/app/components/stepper-input.js @@ -0,0 +1,68 @@ +import Component from '@ember/component'; +import { action } from '@ember/object'; +import { debounce } from '@ember/runloop'; +import { oneWay } from '@ember/object/computed'; +import { classNames } from '@ember-decorators/component'; +import classic from 'ember-classic-decorator'; + +const ESC = 27; + +@classic +@classNames('stepper-input') +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; + + // text change: debounce set value if value changed + // debouncing here means when the text input blurs to click a button + // things don't get weird and send two change events + // text focus ESC: revert value + // + // Also add the xsmall button to the existing button story + + @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/templates/components/stepper-input.hbs b/ui/app/templates/components/stepper-input.hbs new file mode 100644 index 000000000000..708276d45d66 --- /dev/null +++ b/ui/app/templates/components/stepper-input.hbs @@ -0,0 +1,22 @@ + + + + From ed6395899f22bfda7509438d005a7bcad01aaf93 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 18 Jun 2020 13:19:58 -0700 Subject: [PATCH 20/28] StepperInput story --- .../components/stepper-input.stories.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 ui/stories/components/stepper-input.stories.js diff --git a/ui/stories/components/stepper-input.stories.js b/ui/stories/components/stepper-input.stories.js new file mode 100644 index 000000000000..a52df7b29d95 --- /dev/null +++ b/ui/stories/components/stepper-input.stories.js @@ -0,0 +1,27 @@ +import hbs from 'htmlbars-inline-precompile'; + +export default { + title: 'Components|Stepper Input', +}; + +export let Standard = () => { + return { + template: hbs` +

+ + Stepper + +

+

External Value: {{value}}

+ `, + context: { + min: 0, + max: 10, + value: 5, + }, + }; +}; From 140f9f680056d7d7d25881634652b77a1f6aad4d Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 18 Jun 2020 13:20:14 -0700 Subject: [PATCH 21/28] Add count StepperInput to the task group page --- ui/app/templates/jobs/job/task-group.hbs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index 3fb6d1ef33fd..ac438fc88eae 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -7,7 +7,14 @@

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

From 6bde0e522a9a84ed02210b37bbe43ee566c5b2a8 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 18 Jun 2020 20:13:36 -0700 Subject: [PATCH 22/28] Style the StepperInput component --- ui/app/components/stepper-input.js | 10 +-- ui/app/styles/components.scss | 1 + ui/app/styles/components/stepper-input.scss | 78 +++++++++++++++++++ ui/app/templates/components/stepper-input.hbs | 7 +- .../components/stepper-input.stories.js | 21 +++++ 5 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 ui/app/styles/components/stepper-input.scss diff --git a/ui/app/components/stepper-input.js b/ui/app/components/stepper-input.js index eeef1c3bbaa6..09db485324eb 100644 --- a/ui/app/components/stepper-input.js +++ b/ui/app/components/stepper-input.js @@ -2,13 +2,14 @@ import Component from '@ember/component'; import { action } from '@ember/object'; import { debounce } from '@ember/runloop'; import { oneWay } from '@ember/object/computed'; -import { classNames } from '@ember-decorators/component'; +import { classNames, classNameBindings } from '@ember-decorators/component'; import classic from 'ember-classic-decorator'; const ESC = 27; @classic @classNames('stepper-input') +@classNameBindings('class') export default class StepperInput extends Component { min = 0; max = 10; @@ -21,13 +22,6 @@ export default class StepperInput extends Component { // On onChange which is debounced. @oneWay('value') internalValue; - // text change: debounce set value if value changed - // debouncing here means when the text input blurs to click a button - // things don't get weird and send two change events - // text focus ESC: revert value - // - // Also add the xsmall button to the existing button story - @action increment() { if (this.internalValue < this.max) { 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/stepper-input.scss b/ui/app/styles/components/stepper-input.scss new file mode 100644 index 000000000000..3d48f91eaef4 --- /dev/null +++ b/ui/app/styles/components/stepper-input.scss @@ -0,0 +1,78 @@ +.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; + whitespace: nowrap; + height: 2.25em; + + .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); + } + } + } + } +} diff --git a/ui/app/templates/components/stepper-input.hbs b/ui/app/templates/components/stepper-input.hbs index 708276d45d66..3463164265c1 100644 --- a/ui/app/templates/components/stepper-input.hbs +++ b/ui/app/templates/components/stepper-input.hbs @@ -1,21 +1,22 @@ - +

External Value: {{value}}

`, @@ -22,6 +42,7 @@ export let Standard = () => { min: 0, max: 10, value: 5, + variant: variantKnob(), }, }; }; From 110f491c98c6207726026c6d492e60815c51146d Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 18 Jun 2020 22:07:51 -0700 Subject: [PATCH 23/28] Test coverage for the StepperInput --- ui/app/templates/components/stepper-input.hbs | 5 +- ui/tests/integration/stepper-input-test.js | 167 ++++++++++++++++++ ui/tests/pages/components/stepper-input.js | 44 +++++ 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 ui/tests/integration/stepper-input-test.js create mode 100644 ui/tests/pages/components/stepper-input.js diff --git a/ui/app/templates/components/stepper-input.hbs b/ui/app/templates/components/stepper-input.hbs index 3463164265c1..70f7c024c64b 100644 --- a/ui/app/templates/components/stepper-input.hbs +++ b/ui/app/templates/components/stepper-input.hbs @@ -1,5 +1,6 @@ - + @@ -20,7 +21,7 @@ data-test-stepper-increment role="button" class="stepper-input-stepper button {{class}}" - disabled={{gte internalValue max}} + disabled={{or disabled (gte internalValue max)}} onclick={{action "increment"}}> {{x-icon "plus-plain"}} diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index ac438fc88eae..8cd257711b07 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -10,7 +10,12 @@
{{#if model.scaling}} - + Count {{/if}} From eb901b59b0ffc501d968b3f0eebb7014dac05f82 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 18 Jun 2020 22:50:47 -0700 Subject: [PATCH 25/28] Wire up the scale action on the task group page --- ui/app/controllers/jobs/job/task-group.js | 5 +++++ ui/app/templates/jobs/job/task-group.hbs | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) 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/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index 8cd257711b07..7910b1059202 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -15,7 +15,8 @@ @max={{model.scaling.max}} @value={{model.count}} @class="is-primary is-small" - @disabled={{or model.job.runningDeployment (cannot "scale job")}}> + @disabled={{or model.job.runningDeployment (cannot "scale job")}} + @onChange={{action "scaleTaskGroup"}}> Count {{/if}} From 72161b06f7abab7f368285b9ced3cd8e0d0042e2 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 18 Jun 2020 22:51:05 -0700 Subject: [PATCH 26/28] Watch the latest deployment relationship to disable the stepper appropriately --- ui/app/routes/jobs/job/task-group.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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; } From 61042e0ecfe08fcf01ca776d74885406f8b256bb Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 18 Jun 2020 23:20:24 -0700 Subject: [PATCH 27/28] Acceptance tests for task group scaling --- ui/app/templates/jobs/job/task-group.hbs | 1 + ui/tests/acceptance/task-group-detail-test.js | 58 ++++++++++++++++++- ui/tests/pages/components/stepper-input.js | 1 + ui/tests/pages/jobs/job/task-group.js | 3 + 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index 7910b1059202..307cd2bbff76 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -11,6 +11,7 @@ {{#if model.scaling}} 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/pages/components/stepper-input.js b/ui/tests/pages/components/stepper-input.js index 807b6cf952b8..0fec7c85e024 100644 --- a/ui/tests/pages/components/stepper-input.js +++ b/ui/tests/pages/components/stepper-input.js @@ -24,6 +24,7 @@ export default scope => ({ blur: blurrable(), value: value(), esc: triggerable('keydown', '', { eventProperties: { keyCode: 27 } }), + isDisabled: attribute('disabled'), }, decrement: { 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]'), From c70ea9765f3f04231f2c4d523c8bef2b857ac48a Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 19 Jun 2020 10:21:39 -0700 Subject: [PATCH 28/28] Remove superfluous property from the StepperInput page object --- ui/tests/pages/components/stepper-input.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui/tests/pages/components/stepper-input.js b/ui/tests/pages/components/stepper-input.js index 0fec7c85e024..c35f9e49f40d 100644 --- a/ui/tests/pages/components/stepper-input.js +++ b/ui/tests/pages/components/stepper-input.js @@ -13,8 +13,6 @@ import { export default scope => ({ scope, - isPresent: isPresent(), - label: text('[data-test-stepper-label]'), input: {