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}} -
+
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` +