From 31b4ed7a6deccd94cd5ce70245e69d59e716c760 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 29 Oct 2020 07:46:42 -0500 Subject: [PATCH] Add DAS UI code from enterprise (#9192) This is a few combined iterations on the DAS feature. --- ui/app/abilities/abstract.js | 12 + ui/app/abilities/recommendation.js | 13 + ui/app/adapters/recommendation-summary.js | 27 + ui/app/components/das/accepted.hbs | 7 + ui/app/components/das/diffs-table.hbs | 20 + ui/app/components/das/diffs-table.js | 31 + ui/app/components/das/dismissed.hbs | 30 + ui/app/components/das/dismissed.js | 21 + ui/app/components/das/error.hbs | 22 + ui/app/components/das/error.js | 9 + .../das/recommendation-accordion.hbs | 46 ++ .../das/recommendation-accordion.js | 50 ++ ui/app/components/das/recommendation-card.hbs | 136 ++++ ui/app/components/das/recommendation-card.js | 239 +++++++ .../components/das/recommendation-chart.hbs | 159 +++++ ui/app/components/das/recommendation-chart.js | 359 +++++++++++ ui/app/components/das/recommendation-row.hbs | 48 ++ ui/app/components/das/recommendation-row.js | 42 ++ ui/app/components/das/task-row.hbs | 33 + ui/app/components/das/task-row.js | 20 + ui/app/controllers/optimize.js | 27 + ui/app/models/job.js | 2 + ui/app/models/recommendation-summary.js | 46 ++ ui/app/models/recommendation.js | 31 + ui/app/router.js | 2 + ui/app/routes/jobs/job.js | 6 +- ui/app/routes/optimize.js | 35 ++ ui/app/serializers/job.js | 10 + ui/app/serializers/recommendation-summary.js | 91 +++ ui/app/serializers/recommendation.js | 38 ++ ui/app/styles/charts.scss | 6 + .../styles/charts/recommendation-chart.scss | 153 +++++ ui/app/styles/components.scss | 4 + .../styles/components/das-interstitial.scss | 74 +++ .../components/recommendation-accordion.scss | 54 ++ .../components/recommendation-card.scss | 221 +++++++ .../styles/components/recommendation-row.scss | 23 + ui/app/styles/core/variables.scss | 4 + ui/app/styles/utils/structure-colors.scss | 19 + ui/app/templates/components/gutter-menu.hbs | 10 + .../templates/components/job-page/service.hbs | 4 + .../templates/components/job-page/system.hbs | 4 + .../components/list-table/table-body.hbs | 4 +- ui/app/templates/components/svg-patterns.hbs | 9 + ui/app/templates/optimize.hbs | 47 ++ ui/app/utils/resources-diffs.js | 120 ++++ ui/mirage/config.js | 48 ++ ui/mirage/factories/job.js | 4 + ui/mirage/factories/recommendation.js | 31 + ui/mirage/factories/task-group.js | 5 + ui/mirage/factories/task.js | 18 + ui/mirage/faker.js | 4 +- ui/mirage/models/recommendation.js | 5 + ui/mirage/models/task.js | 3 +- ui/mirage/scenarios/default.js | 2 +- ui/mirage/scenarios/topo.js | 1 + ui/mirage/serializers/recommendation.js | 30 + ui/package.json | 2 +- ui/tests/acceptance/job-detail-test.js | 43 +- ui/tests/acceptance/optimize-test.js | 331 ++++++++++ .../components/das/dismissed-test.js | 44 ++ .../das/recommendation-card-test.js | 581 ++++++++++++++++++ .../das/recommendation-chart-test.js | 194 ++++++ .../integration/components/list-table-test.js | 4 +- .../components/recommendation-accordion.js | 13 + .../pages/components/recommendation-card.js | 107 ++++ ui/tests/pages/components/toggle.js | 1 + ui/tests/pages/jobs/detail.js | 3 + ui/tests/pages/layout.js | 5 + ui/tests/pages/optimize.js | 52 ++ .../unit/abilities/recommendation-test.js | 55 ++ .../recommendation-summary-test.js | 213 +++++++ ui/yarn.lock | 18 +- 73 files changed, 4167 insertions(+), 18 deletions(-) create mode 100644 ui/app/abilities/recommendation.js create mode 100644 ui/app/adapters/recommendation-summary.js create mode 100644 ui/app/components/das/accepted.hbs create mode 100644 ui/app/components/das/diffs-table.hbs create mode 100644 ui/app/components/das/diffs-table.js create mode 100644 ui/app/components/das/dismissed.hbs create mode 100644 ui/app/components/das/dismissed.js create mode 100644 ui/app/components/das/error.hbs create mode 100644 ui/app/components/das/error.js create mode 100644 ui/app/components/das/recommendation-accordion.hbs create mode 100644 ui/app/components/das/recommendation-accordion.js create mode 100644 ui/app/components/das/recommendation-card.hbs create mode 100644 ui/app/components/das/recommendation-card.js create mode 100644 ui/app/components/das/recommendation-chart.hbs create mode 100644 ui/app/components/das/recommendation-chart.js create mode 100644 ui/app/components/das/recommendation-row.hbs create mode 100644 ui/app/components/das/recommendation-row.js create mode 100644 ui/app/components/das/task-row.hbs create mode 100644 ui/app/components/das/task-row.js create mode 100644 ui/app/controllers/optimize.js create mode 100644 ui/app/models/recommendation-summary.js create mode 100644 ui/app/models/recommendation.js create mode 100644 ui/app/routes/optimize.js create mode 100644 ui/app/serializers/recommendation-summary.js create mode 100644 ui/app/serializers/recommendation.js create mode 100644 ui/app/styles/charts/recommendation-chart.scss create mode 100644 ui/app/styles/components/das-interstitial.scss create mode 100644 ui/app/styles/components/recommendation-accordion.scss create mode 100644 ui/app/styles/components/recommendation-card.scss create mode 100644 ui/app/styles/components/recommendation-row.scss create mode 100644 ui/app/templates/optimize.hbs create mode 100644 ui/app/utils/resources-diffs.js create mode 100644 ui/mirage/factories/recommendation.js create mode 100644 ui/mirage/models/recommendation.js create mode 100644 ui/mirage/serializers/recommendation.js create mode 100644 ui/tests/acceptance/optimize-test.js create mode 100644 ui/tests/integration/components/das/dismissed-test.js create mode 100644 ui/tests/integration/components/das/recommendation-card-test.js create mode 100644 ui/tests/integration/components/das/recommendation-chart-test.js create mode 100644 ui/tests/pages/components/recommendation-accordion.js create mode 100644 ui/tests/pages/components/recommendation-card.js create mode 100644 ui/tests/pages/optimize.js create mode 100644 ui/tests/unit/abilities/recommendation-test.js create mode 100644 ui/tests/unit/serializers/recommendation-summary-test.js diff --git a/ui/app/abilities/abstract.js b/ui/app/abilities/abstract.js index 0f23e41fb..b0ad3167e 100644 --- a/ui/app/abilities/abstract.js +++ b/ui/app/abilities/abstract.js @@ -34,6 +34,18 @@ export default class Abstract extends Ability { }, []); } + @computed('token.selfTokenPolicies.[]') + get capabilitiesForAllNamespaces() { + return (this.get('token.selfTokenPolicies') || []) + .toArray() + .reduce((allCapabilities, policy) => { + (get(policy, 'rulesJSON.Namespaces') || []).forEach(({ Capabilities }) => { + allCapabilities = allCapabilities.concat(Capabilities); + }); + return allCapabilities; + }, []); + } + activeNamespaceIncludesCapability(capability) { return this.rulesForActiveNamespace.some(rules => { let capabilities = get(rules, 'Capabilities') || []; diff --git a/ui/app/abilities/recommendation.js b/ui/app/abilities/recommendation.js new file mode 100644 index 000000000..d3cdf6f85 --- /dev/null +++ b/ui/app/abilities/recommendation.js @@ -0,0 +1,13 @@ +import AbstractAbility from './abstract'; +import { computed } from '@ember/object'; +import { or } from '@ember/object/computed'; + +export default class Recommendation extends AbstractAbility { + @or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportAcceptingOnAnyNamespace') + canAccept; + + @computed('capabilitiesForAllNamespaces.[]') + get policiesSupportAcceptingOnAnyNamespace() { + return this.capabilitiesForAllNamespaces.includes('submit-job'); + } +} diff --git a/ui/app/adapters/recommendation-summary.js b/ui/app/adapters/recommendation-summary.js new file mode 100644 index 000000000..42754d80b --- /dev/null +++ b/ui/app/adapters/recommendation-summary.js @@ -0,0 +1,27 @@ +import ApplicationAdapter from './application'; + +export default class RecommendationSummaryAdapter extends ApplicationAdapter { + pathForType = () => 'recommendations'; + + urlForFindAll() { + const url = super.urlForFindAll(...arguments); + return `${url}?namespace=*`; + } + + updateRecord(store, type, snapshot) { + const url = `${super.urlForCreateRecord('recommendations', snapshot)}/apply`; + + const allRecommendationIds = snapshot.hasMany('recommendations').mapBy('id'); + const excludedRecommendationIds = (snapshot.hasMany('excludedRecommendations') || []).mapBy( + 'id' + ); + const includedRecommendationIds = allRecommendationIds.removeObjects(excludedRecommendationIds); + + const data = { + Apply: includedRecommendationIds, + Dismiss: excludedRecommendationIds, + }; + + return this.ajax(url, 'POST', { data }); + } +} diff --git a/ui/app/components/das/accepted.hbs b/ui/app/components/das/accepted.hbs new file mode 100644 index 000000000..b59889cda --- /dev/null +++ b/ui/app/components/das/accepted.hbs @@ -0,0 +1,7 @@ +
+
+

Recommendation accepted

+

A new version of this job will now be deployed.

+
+ {{x-icon "check-circle-fill"}} +
\ No newline at end of file diff --git a/ui/app/components/das/diffs-table.hbs b/ui/app/components/das/diffs-table.hbs new file mode 100644 index 000000000..6b34af33b --- /dev/null +++ b/ui/app/components/das/diffs-table.hbs @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + +
Current{{@model.reservedCPU}} MHz{{@model.reservedMemory}} MiBDifference{{this.diffs.cpu.signedDiff}}{{this.diffs.memory.signedDiff}}
Recommended{{this.diffs.cpu.recommended}} MHz{{this.diffs.memory.recommended}} MiB% Difference{{this.diffs.cpu.percentDiff}}{{this.diffs.memory.percentDiff}}
diff --git a/ui/app/components/das/diffs-table.js b/ui/app/components/das/diffs-table.js new file mode 100644 index 000000000..1b439b83f --- /dev/null +++ b/ui/app/components/das/diffs-table.js @@ -0,0 +1,31 @@ +import Component from '@glimmer/component'; +import ResourcesDiffs from 'nomad-ui/utils/resources-diffs'; + +export default class DasResourceTotalsComponent extends Component { + get diffs() { + return new ResourcesDiffs( + this.args.model, + 1, + this.args.recommendations, + this.args.excludedRecommendations + ); + } + + get cpuClass() { + return classForDelta(this.diffs.cpu.delta); + } + + get memoryClass() { + return classForDelta(this.diffs.memory.delta); + } +} + +function classForDelta(delta) { + if (delta > 0) { + return 'increase'; + } else if (delta < 0) { + return 'decrease'; + } + + return ''; +} diff --git a/ui/app/components/das/dismissed.hbs b/ui/app/components/das/dismissed.hbs new file mode 100644 index 000000000..ff2ffbaf3 --- /dev/null +++ b/ui/app/components/das/dismissed.hbs @@ -0,0 +1,30 @@ +
+ {{#if this.explanationUnderstood}} +

Recommendation dismissed

+ {{else}} +
+

Recommendation dismissed

+ +

Nomad will not apply these resource change recommendations.

+ +

To never get recommendations for this task group again, disable dynamic application sizing in the job definition.

+
+ +
+ + +
+ {{/if}} +
\ No newline at end of file diff --git a/ui/app/components/das/dismissed.js b/ui/app/components/das/dismissed.js new file mode 100644 index 000000000..85fa8e9f1 --- /dev/null +++ b/ui/app/components/das/dismissed.js @@ -0,0 +1,21 @@ +import Component from '@glimmer/component'; +import localStorageProperty from 'nomad-ui/utils/properties/local-storage'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +export default class DasDismissedComponent extends Component { + @localStorageProperty('nomadRecommendationDismssalUnderstood', false) explanationUnderstood; + + @tracked dismissInTheFuture = false; + + @action + proceedAutomatically() { + this.args.proceed({ manuallyDismissed: false }); + } + + @action + understoodClicked() { + this.explanationUnderstood = this.dismissInTheFuture; + this.args.proceed({ manuallyDismissed: true }); + } +} diff --git a/ui/app/components/das/error.hbs b/ui/app/components/das/error.hbs new file mode 100644 index 000000000..382e824bc --- /dev/null +++ b/ui/app/components/das/error.hbs @@ -0,0 +1,22 @@ +
+
+

Recommendation error

+ +

+ There were errors processing applications: +

+ +
{{@error}}
+
+ + {{x-icon "alert-circle-fill"}} + +
+ +
+
\ No newline at end of file diff --git a/ui/app/components/das/error.js b/ui/app/components/das/error.js new file mode 100644 index 000000000..add8bd7dd --- /dev/null +++ b/ui/app/components/das/error.js @@ -0,0 +1,9 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class DasErrorComponent extends Component { + @action + dismissClicked() { + this.args.proceed({ manuallyDismissed: true }); + } +} diff --git a/ui/app/components/das/recommendation-accordion.hbs b/ui/app/components/das/recommendation-accordion.hbs new file mode 100644 index 000000000..08f304853 --- /dev/null +++ b/ui/app/components/das/recommendation-accordion.hbs @@ -0,0 +1,46 @@ +{{#if this.show}} + + {{#if a.isOpen}} +
+ +
+ {{else}} + +
+ {{x-icon "info-circle-fill"}} + Resource Recommendation + {{@summary.taskGroup.name}} +
+ +
+ {{#if this.diffs.cpu.delta}} +
+ CPU + {{this.diffs.cpu.signedDiff}} + {{this.diffs.cpu.percentDiff}} +
+ {{/if}} + + {{#if this.diffs.memory.delta}} +
+ Mem + {{this.diffs.memory.signedDiff}} + {{this.diffs.memory.percentDiff}} +
+ {{/if}} +
+
+ {{/if}} +
+{{/if}} \ No newline at end of file diff --git a/ui/app/components/das/recommendation-accordion.js b/ui/app/components/das/recommendation-accordion.js new file mode 100644 index 000000000..1270a0d27 --- /dev/null +++ b/ui/app/components/das/recommendation-accordion.js @@ -0,0 +1,50 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { task, timeout } from 'ember-concurrency'; +import { htmlSafe } from '@ember/template'; +import Ember from 'ember'; +import ResourcesDiffs from 'nomad-ui/utils/resources-diffs'; + +export default class DasRecommendationAccordionComponent extends Component { + @tracked waitingToProceed = false; + @tracked closing = false; + @tracked animationContainerStyle = htmlSafe(''); + + @(task(function*() { + this.closing = true; + this.animationContainerStyle = htmlSafe(`height: ${this.accordionElement.clientHeight}px`); + + yield timeout(10); + + this.animationContainerStyle = htmlSafe('height: 0px'); + + // The 450ms for the animation to complete, set in CSS as $timing-slow + yield timeout(Ember.testing ? 0 : 450); + + this.waitingToProceed = false; + }).drop()) + proceed; + + @action + inserted(element) { + this.accordionElement = element; + this.waitingToProceed = true; + } + + get show() { + return !this.args.summary.isProcessed || this.waitingToProceed; + } + + get diffs() { + const summary = this.args.summary; + const taskGroup = summary.taskGroup; + + return new ResourcesDiffs( + taskGroup, + taskGroup.count, + this.args.summary.recommendations, + this.args.summary.excludedRecommendations + ); + } +} diff --git a/ui/app/components/das/recommendation-card.hbs b/ui/app/components/das/recommendation-card.hbs new file mode 100644 index 000000000..a2bb9e3dc --- /dev/null +++ b/ui/app/components/das/recommendation-card.hbs @@ -0,0 +1,136 @@ +{{#if this.interstitialComponent}} +
+ {{component (concat 'das/' this.interstitialComponent) proceed=this.proceedPromiseResolve error=this.error}} +
+{{else}} +
+ +

Resource Recommendation

+ +
+

+ {{@summary.taskGroup.job.name}} + {{@summary.taskGroup.name}} +

+

+ Namespace: {{@summary.taskGroup.job.namespace.name}} +

+
+ +
+ +
+ +
+

{{this.narrative}}

+
+ +
+ + + + {{#if this.showToggleAllToggles}} + + + + + {{else}} + + + + {{/if}} + + + + {{#each this.taskToggleRows key="task.name" as |taskToggleRow index|}} + + {{/each}} + +
TaskToggle All + +
CPU
+
+
+ +
Mem
+
+
TaskCPUMem
+
+ +
+ + +
+ +
+ {{#if @onCollapse}} +
+ +
+ {{/if}} + +
+

{{this.activeTask.name}} task

+
+ +
+ +
+ +
    + {{#each this.activeTaskToggleRow.recommendations as |recommendation|}} +
  • + +
  • + {{/each}} +
+
+ +
+{{/if}} \ No newline at end of file diff --git a/ui/app/components/das/recommendation-card.js b/ui/app/components/das/recommendation-card.js new file mode 100644 index 000000000..cd270138c --- /dev/null +++ b/ui/app/components/das/recommendation-card.js @@ -0,0 +1,239 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import ResourcesDiffs from 'nomad-ui/utils/resources-diffs'; +import { htmlSafe } from '@ember/template'; +import { didCancel, task, timeout } from 'ember-concurrency'; +import Ember from 'ember'; + +export default class DasRecommendationCardComponent extends Component { + @tracked allCpuToggleActive = true; + @tracked allMemoryToggleActive = true; + + @tracked activeTaskToggleRowIndex = 0; + + @tracked cardHeight; + @tracked interstitialComponent; + @tracked error; + + @tracked proceedPromiseResolve; + + get activeTaskToggleRow() { + return this.taskToggleRows[this.activeTaskToggleRowIndex]; + } + + get activeTask() { + return this.activeTaskToggleRow.task; + } + + get narrative() { + const summary = this.args.summary; + const taskGroup = summary.taskGroup; + + const diffs = new ResourcesDiffs( + taskGroup, + taskGroup.count, + this.args.summary.recommendations, + this.args.summary.excludedRecommendations + ); + + const cpuDelta = diffs.cpu.delta; + const memoryDelta = diffs.memory.delta; + + const aggregate = taskGroup.count > 1; + const aggregateString = aggregate ? ' an aggregate' : ''; + + if (cpuDelta || memoryDelta) { + const deltasSameDirection = + (cpuDelta < 0 && memoryDelta < 0) || (cpuDelta > 0 && memoryDelta > 0); + + let narrative = 'Applying the selected recommendations will'; + + if (deltasSameDirection) { + narrative += ` ${verbForDelta(cpuDelta)} ${aggregateString}`; + } + + if (cpuDelta) { + if (!deltasSameDirection) { + narrative += ` ${verbForDelta(cpuDelta)} ${aggregateString}`; + } + + narrative += ` ${diffs.cpu.absoluteAggregateDiff} of CPU`; + } + + if (cpuDelta && memoryDelta) { + narrative += ' and'; + } + + if (memoryDelta) { + if (!deltasSameDirection) { + narrative += ` ${verbForDelta(memoryDelta)} ${aggregateString}`; + } + + narrative += ` ${diffs.memory.absoluteAggregateDiff} of memory`; + } + + if (taskGroup.count === 1) { + narrative += '.'; + } else { + narrative += ` across ${taskGroup.count} allocations.`; + } + + return htmlSafe(narrative); + } else { + return ''; + } + } + + get taskToggleRows() { + const taskNameToTaskToggles = {}; + + return this.args.summary.recommendations.reduce((taskToggleRows, recommendation) => { + let taskToggleRow = taskNameToTaskToggles[recommendation.task.name]; + + if (!taskToggleRow) { + taskToggleRow = { + recommendations: [], + task: recommendation.task, + }; + + taskNameToTaskToggles[recommendation.task.name] = taskToggleRow; + taskToggleRows.push(taskToggleRow); + } + + const isCpu = recommendation.resource === 'CPU'; + const rowResourceProperty = isCpu ? 'cpu' : 'memory'; + + taskToggleRow[rowResourceProperty] = { + recommendation, + isActive: !this.args.summary.excludedRecommendations.includes(recommendation), + }; + + if (isCpu) { + taskToggleRow.recommendations.unshift(recommendation); + } else { + taskToggleRow.recommendations.push(recommendation); + } + + return taskToggleRows; + }, []); + } + + get showToggleAllToggles() { + return this.taskToggleRows.length > 1; + } + + get allCpuToggleDisabled() { + return !this.args.summary.recommendations.filterBy('resource', 'CPU').length; + } + + get allMemoryToggleDisabled() { + return !this.args.summary.recommendations.filterBy('resource', 'MemoryMB').length; + } + + get cannotAccept() { + return ( + this.args.summary.excludedRecommendations.length == this.args.summary.recommendations.length + ); + } + + @action + toggleAllRecommendationsForResource(resource) { + let enabled; + + if (resource === 'CPU') { + this.allCpuToggleActive = !this.allCpuToggleActive; + enabled = this.allCpuToggleActive; + } else { + this.allMemoryToggleActive = !this.allMemoryToggleActive; + enabled = this.allMemoryToggleActive; + } + + this.args.summary.toggleAllRecommendationsForResource(resource, enabled); + } + + @action + accept() { + this.args.summary + .save() + .then(() => this.onApplied.perform(), e => this.onError.perform(e)) + .catch(e => { + if (!didCancel(e)) { + throw e; + } + }); + } + + @action + dismiss() { + this.args.summary.excludedRecommendations.pushObjects(this.args.summary.recommendations); + this.args.summary + .save() + .then(() => this.onDismissed.perform(), e => this.onError.perform(e)) + .catch(e => { + if (!didCancel(e)) { + throw e; + } + }); + } + + @(task(function*() { + this.interstitialComponent = 'accepted'; + yield timeout(Ember.testing ? 0 : 2000); + + this.args.proceed.perform(); + this.resetInterstitial(); + }).drop()) + onApplied; + + @(task(function*() { + const { manuallyDismissed } = yield new Promise(resolve => { + this.proceedPromiseResolve = resolve; + this.interstitialComponent = 'dismissed'; + }); + + if (!manuallyDismissed) { + yield timeout(Ember.testing ? 0 : 2000); + } + + this.args.proceed.perform(); + this.resetInterstitial(); + }).drop()) + onDismissed; + + @(task(function*(error) { + yield new Promise(resolve => { + this.proceedPromiseResolve = resolve; + this.interstitialComponent = 'error'; + this.error = error.toString(); + }); + + this.args.proceed.perform(); + this.resetInterstitial(); + }).drop()) + onError; + + get interstitialStyle() { + return htmlSafe(`height: ${this.cardHeight}px`); + } + + resetInterstitial() { + if (!this.args.skipReset) { + this.interstitialComponent = undefined; + this.error = undefined; + } + } + + @action + recommendationCardDestroying(element) { + this.cardHeight = element.clientHeight; + } +} + +function verbForDelta(delta) { + if (delta > 0) { + return 'add'; + } else { + return 'save'; + } +} diff --git a/ui/app/components/das/recommendation-chart.hbs b/ui/app/components/das/recommendation-chart.hbs new file mode 100644 index 000000000..00ef97dc8 --- /dev/null +++ b/ui/app/components/das/recommendation-chart.hbs @@ -0,0 +1,159 @@ +
+ + + + {{x-icon this.icon.name}} + + + + {{this.resourceLabel.text}} + + + {{#if this.center}} + + {{/if}} + + {{#each this.statsShapes as |shapes|}} + + {{shapes.text.label}} + + + + + + {{/each}} + + {{#unless @disabled}} + {{#if this.deltaRect.x}} + + + + + + + + + + Current + + + + New + + + + {{this.deltaText.percent.text}} + + {{/if}} + {{/unless}} + + + + +
+
    + {{#each this.sortedStats as |stat|}} +
  1. + + {{stat.label}} + + {{stat.value}} +
  2. + {{/each}} +
+
+ +
\ No newline at end of file diff --git a/ui/app/components/das/recommendation-chart.js b/ui/app/components/das/recommendation-chart.js new file mode 100644 index 000000000..d9e0fc515 --- /dev/null +++ b/ui/app/components/das/recommendation-chart.js @@ -0,0 +1,359 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { next } from '@ember/runloop'; +import { htmlSafe } from '@ember/string'; +import { get } from '@ember/object'; + +import { scaleLinear } from 'd3-scale'; +import d3Format from 'd3-format'; + +const statsKeyToLabel = { + min: 'Min', + median: 'Median', + mean: 'Mean', + p99: '99th', + max: 'Max', + current: 'Current', + recommended: 'New', +}; + +const formatPercent = d3Format.format('+.0%'); +export default class RecommendationChartComponent extends Component { + @tracked width; + @tracked height; + + @tracked shown = false; + + @tracked showLegend = false; + @tracked mouseX; + @tracked activeLegendRow; + + get isIncrease() { + return this.args.currentValue < this.args.recommendedValue; + } + + get directionClass() { + if (this.args.disabled) { + return 'disabled'; + } else if (this.isIncrease) { + return 'increase'; + } else { + return 'decrease'; + } + } + + get icon() { + return { + x: 0, + y: this.resourceLabel.y - this.iconHeight / 2, + width: 20, + height: this.iconHeight, + name: this.isIncrease ? 'arrow-up' : 'arrow-down', + }; + } + + gutterWidthLeft = 62; + gutterWidthRight = 50; + gutterWidth = this.gutterWidthLeft + this.gutterWidthRight; + + iconHeight = 21; + + tickTextHeight = 15; + + edgeTickHeight = 23; + centerTickOffset = 6; + + centerY = this.tickTextHeight + this.centerTickOffset + this.edgeTickHeight / 2; + + edgeTickY1 = this.tickTextHeight + this.centerTickOffset; + edgeTickY2 = this.tickTextHeight + this.edgeTickHeight + this.centerTickOffset; + + deltaTextY = this.edgeTickY2; + + meanHeight = this.edgeTickHeight * 0.6; + p99Height = this.edgeTickHeight * 0.48; + maxHeight = this.edgeTickHeight * 0.4; + + deltaTriangleHeight = this.edgeTickHeight / 2.5; + + get statsShapes() { + if (this.width) { + const maxShapes = this.shapesFor('max'); + const p99Shapes = this.shapesFor('p99'); + const meanShapes = this.shapesFor('mean'); + + const labelProximityThreshold = 25; + + if (p99Shapes.text.x + labelProximityThreshold > maxShapes.text.x) { + maxShapes.text.class = 'right'; + } + + if (meanShapes.text.x + labelProximityThreshold > p99Shapes.text.x) { + p99Shapes.text.class = 'right'; + } + + if (meanShapes.text.x + labelProximityThreshold * 2 > maxShapes.text.x) { + p99Shapes.text.class = 'hidden'; + } + + return [maxShapes, p99Shapes, meanShapes]; + } else { + return []; + } + } + + shapesFor(key) { + const stat = this.args.stats[key]; + + const rectWidth = this.xScale(stat); + const rectHeight = this[`${key}Height`]; + + const tickX = rectWidth + this.gutterWidthLeft; + + const label = statsKeyToLabel[key]; + + return { + class: key, + text: { + label, + x: tickX, + y: this.tickTextHeight - 5, + class: '', // overridden in statsShapes to align/hide based on proximity + }, + line: { + x1: tickX, + y1: this.tickTextHeight, + x2: tickX, + y2: this.centerY - 2, + }, + rect: { + x: this.gutterWidthLeft, + y: (this.edgeTickHeight - rectHeight) / 2 + this.centerTickOffset + this.tickTextHeight, + width: rectWidth, + height: rectHeight, + }, + }; + } + + get barWidth() { + return this.width - this.gutterWidth; + } + + get higherValue() { + return Math.max(this.args.currentValue, this.args.recommendedValue); + } + + get maximumX() { + return Math.max(this.higherValue, get(this.args.stats, 'max') || Number.MIN_SAFE_INTEGER); + } + + get lowerValue() { + return Math.min(this.args.currentValue, this.args.recommendedValue); + } + + get xScale() { + return scaleLinear() + .domain([0, this.maximumX]) + .rangeRound([0, this.barWidth]); + } + + get lowerValueWidth() { + return this.gutterWidthLeft + this.xScale(this.lowerValue); + } + + get higherValueWidth() { + return this.gutterWidthLeft + this.xScale(this.higherValue); + } + + get center() { + if (this.width) { + return { + x1: this.gutterWidthLeft, + y1: this.centerY, + x2: this.width - this.gutterWidthRight, + y2: this.centerY, + }; + } else { + return null; + } + } + + get resourceLabel() { + const text = this.args.resource === 'CPU' ? 'CPU' : 'Mem'; + + return { + text, + x: this.gutterWidthLeft - 10, + y: this.centerY, + }; + } + + get deltaRect() { + if (this.isIncrease) { + return { + x: this.lowerValueWidth, + y: this.edgeTickY1, + width: this.shown ? this.higherValueWidth - this.lowerValueWidth : 0, + height: this.edgeTickHeight, + }; + } else { + return { + x: this.shown ? this.lowerValueWidth : this.higherValueWidth, + y: this.edgeTickY1, + width: this.shown ? this.higherValueWidth - this.lowerValueWidth : 0, + height: this.edgeTickHeight, + }; + } + } + + get deltaTriangle() { + const directionXMultiplier = this.isIncrease ? 1 : -1; + let translateX; + + if (this.shown) { + translateX = this.isIncrease ? this.higherValueWidth : this.lowerValueWidth; + } else { + translateX = this.isIncrease ? this.lowerValueWidth : this.higherValueWidth; + } + + return { + style: htmlSafe(`transform: translateX(${translateX}px)`), + points: ` + 0,${this.center.y1} + 0,${this.center.y1 - this.deltaTriangleHeight / 2} + ${(directionXMultiplier * this.deltaTriangleHeight) / 2},${this.center.y1} + 0,${this.center.y1 + this.deltaTriangleHeight / 2} + `, + }; + } + + get deltaLines() { + if (this.isIncrease) { + return { + original: { + x: this.lowerValueWidth, + }, + delta: { + style: htmlSafe( + `transform: translateX(${this.shown ? this.higherValueWidth : this.lowerValueWidth}px)` + ), + }, + }; + } else { + return { + original: { + x: this.higherValueWidth, + }, + delta: { + style: htmlSafe( + `transform: translateX(${this.shown ? this.lowerValueWidth : this.higherValueWidth}px)` + ), + }, + }; + } + } + + get deltaText() { + const yOffset = 17; + const y = this.deltaTextY + yOffset; + + const lowerValueText = { + anchor: 'end', + x: this.lowerValueWidth, + y, + }; + + const higherValueText = { + anchor: 'start', + x: this.higherValueWidth, + y, + }; + + const percentText = formatPercent( + (this.args.recommendedValue - this.args.currentValue) / this.args.currentValue + ); + + const percent = { + x: (lowerValueText.x + higherValueText.x) / 2, + y, + text: percentText, + }; + + if (this.isIncrease) { + return { + original: lowerValueText, + delta: higherValueText, + percent, + }; + } else { + return { + original: higherValueText, + delta: lowerValueText, + percent, + }; + } + } + + get chartHeight() { + return this.deltaText.original.y + 1; + } + + get tooltipStyle() { + if (this.showLegend) { + return htmlSafe(`left: ${this.mouseX}px`); + } + + return undefined; + } + + get sortedStats() { + if (this.args.stats) { + const statsWithCurrentAndRecommended = { + ...this.args.stats, + current: this.args.currentValue, + recommended: this.args.recommendedValue, + }; + + return Object.keys(statsWithCurrentAndRecommended) + .map(key => ({ label: statsKeyToLabel[key], value: statsWithCurrentAndRecommended[key] })) + .sortBy('value'); + } else { + return []; + } + } + + @action + isShown() { + next(() => { + this.shown = true; + }); + } + + @action + onResize() { + this.width = this.svgElement.clientWidth; + this.height = this.svgElement.clientHeight; + } + + @action + storeSvgElement(element) { + this.svgElement = element; + } + + @action + setLegendPosition(mouseMoveEvent) { + this.showLegend = true; + this.mouseX = mouseMoveEvent.layerX; + } + + @action + setActiveLegendRow(row) { + this.activeLegendRow = row; + } + + @action + unsetActiveLegendRow() { + this.activeLegendRow = undefined; + } +} diff --git a/ui/app/components/das/recommendation-row.hbs b/ui/app/components/das/recommendation-row.hbs new file mode 100644 index 000000000..360613756 --- /dev/null +++ b/ui/app/components/das/recommendation-row.hbs @@ -0,0 +1,48 @@ +{{#if @summary.taskGroup.allocations.length}} + {{!-- Prevent storing aggregate diffs until allocation count is known --}} + + +
+ {{@summary.taskGroup.job.name}} + / + {{@summary.taskGroup.name}} +
+
+ Namespace: {{@summary.job.namespace.name}} +
+ + + {{format-month-ts @summary.submitTime}} + + + {{@summary.taskGroup.count}} + + + {{#if this.cpu.delta}} + {{this.cpu.signedDiff}} + {{this.cpu.percentDiff}} + {{/if}} + + + {{#if this.memory.delta}} + {{this.memory.signedDiff}} + {{this.memory.percentDiff}} + {{/if}} + + + {{#if this.cpu.delta}} + {{this.cpu.signedAggregateDiff}} + {{/if}} + + + {{#if this.memory.delta}} + {{this.memory.signedAggregateDiff}} + {{/if}} + + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/das/recommendation-row.js b/ui/app/components/das/recommendation-row.js new file mode 100644 index 000000000..7480bac4c --- /dev/null +++ b/ui/app/components/das/recommendation-row.js @@ -0,0 +1,42 @@ +import Component from '@glimmer/component'; +import ResourcesDiffs from 'nomad-ui/utils/resources-diffs'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +export default class DasRecommendationRow extends Component { + @tracked cpu; + @tracked memory; + + @action + storeDiffs() { + // Prevent resource toggling from affecting the summary diffs + + const diffs = new ResourcesDiffs( + this.args.summary.taskGroup, + 1, + this.args.summary.recommendations, + this.args.summary.excludedRecommendations + ); + + const aggregateDiffs = new ResourcesDiffs( + this.args.summary.taskGroup, + this.args.summary.taskGroup.count, + this.args.summary.recommendations, + this.args.summary.excludedRecommendations + ); + + this.cpu = { + delta: diffs.cpu.delta, + signedDiff: diffs.cpu.signedDiff, + percentDiff: diffs.cpu.percentDiff, + signedAggregateDiff: aggregateDiffs.cpu.signedDiff, + }; + + this.memory = { + delta: diffs.memory.delta, + signedDiff: diffs.memory.signedDiff, + percentDiff: diffs.memory.percentDiff, + signedAggregateDiff: aggregateDiffs.memory.signedDiff, + }; + } +} diff --git a/ui/app/components/das/task-row.hbs b/ui/app/components/das/task-row.hbs new file mode 100644 index 000000000..fb7f0a257 --- /dev/null +++ b/ui/app/components/das/task-row.hbs @@ -0,0 +1,33 @@ + + {{@task.name}} + + + + + + + {{#if (and @active this.height)}} + + + + + {{/if}} + + diff --git a/ui/app/components/das/task-row.js b/ui/app/components/das/task-row.js new file mode 100644 index 000000000..15bf5fc4d --- /dev/null +++ b/ui/app/components/das/task-row.js @@ -0,0 +1,20 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +export default class DasTaskRowComponent extends Component { + @tracked height; + + get half() { + return this.height / 2; + } + + get borderCoverHeight() { + return this.height - 2; + } + + @action + calculateHeight(element) { + this.height = element.clientHeight + 1; + } +} diff --git a/ui/app/controllers/optimize.js b/ui/app/controllers/optimize.js new file mode 100644 index 000000000..27e49a2c8 --- /dev/null +++ b/ui/app/controllers/optimize.js @@ -0,0 +1,27 @@ +import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; +import { sort } from '@ember/object/computed'; +import { task, timeout } from 'ember-concurrency'; +import Ember from 'ember'; + +export default class OptimizeController extends Controller { + @tracked recommendationSummaryIndex = 0; + + summarySorting = ['submitTime:desc']; + @sort('model', 'summarySorting') sortedSummaries; + + get activeRecommendationSummary() { + return this.sortedSummaries.objectAt(this.recommendationSummaryIndex); + } + + @(task(function*() { + this.recommendationSummaryIndex++; + + if (this.recommendationSummaryIndex >= this.model.length) { + this.store.unloadAll('recommendation-summary'); + yield timeout(Ember.testing ? 0 : 1000); + this.store.findAll('recommendation-summary'); + } + }).drop()) + proceed; +} diff --git a/ui/app/models/job.js b/ui/app/models/job.js index 0f505201b..ec78c10d3 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -122,6 +122,8 @@ export default class Job extends Model { @belongsTo('namespace') namespace; @belongsTo('job-scale') scaleState; + @hasMany('recommendation-summary') recommendationSummaries; + @computed('taskGroups.@each.drivers') get drivers() { return this.taskGroups diff --git a/ui/app/models/recommendation-summary.js b/ui/app/models/recommendation-summary.js new file mode 100644 index 000000000..28da0242b --- /dev/null +++ b/ui/app/models/recommendation-summary.js @@ -0,0 +1,46 @@ +import Model from 'ember-data/model'; +import { attr } from '@ember-data/model'; +import { belongsTo, hasMany } from 'ember-data/relationships'; +import { get } from '@ember/object'; +import { action } from '@ember/object'; + +export default class RecommendationSummary extends Model { + @hasMany('recommendation') recommendations; + @hasMany('recommendation', { defaultValue: () => [] }) excludedRecommendations; + + @belongsTo('job') job; + + @attr('date') submitTime; + @attr('string') taskGroupName; + + // Set in the serialiser upon saving + @attr('boolean', { defaultValue: false }) isProcessed; + + get taskGroup() { + const taskGroups = get(this, 'job.taskGroups'); + + if (taskGroups) { + return taskGroups.findBy('name', this.taskGroupName); + } else { + return undefined; + } + } + + @action + toggleRecommendation(recommendation) { + if (this.excludedRecommendations.includes(recommendation)) { + this.excludedRecommendations = this.excludedRecommendations.removeObject(recommendation); + } else { + this.excludedRecommendations.pushObject(recommendation); + } + } + + @action + toggleAllRecommendationsForResource(resource, enabled) { + if (enabled) { + this.excludedRecommendations = this.excludedRecommendations.rejectBy('resource', resource); + } else { + this.excludedRecommendations.pushObjects(this.recommendations.filterBy('resource', resource)); + } + } +} diff --git a/ui/app/models/recommendation.js b/ui/app/models/recommendation.js new file mode 100644 index 000000000..84b2ebabf --- /dev/null +++ b/ui/app/models/recommendation.js @@ -0,0 +1,31 @@ +import Model from 'ember-data/model'; +import attr from 'ember-data/attr'; +import { belongsTo } from 'ember-data/relationships'; +import { get } from '@ember/object'; + +export default class Recommendation extends Model { + @belongsTo('job') job; + @belongsTo('recommendation-summary', { inverse: 'recommendations' }) recommendationSummary; + + @attr('date') submitTime; + + get taskGroup() { + return get(this, 'recommendationSummary.taskGroup'); + } + + @attr('string') taskName; + + get task() { + return get(this, 'taskGroup.tasks').findBy('name', this.taskName); + } + + @attr('string') resource; + @attr('number') value; + + get currentValue() { + const resourceProperty = this.resource === 'CPU' ? 'reservedCPU' : 'reservedMemory'; + return get(this, `task.${resourceProperty}`); + } + + @attr() stats; +} diff --git a/ui/app/router.js b/ui/app/router.js index 8a6cf648c..74e29172e 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -25,6 +25,8 @@ Router.map(function() { }); }); + this.route('optimize'); + this.route('clients', function() { this.route('client', { path: '/:node_id' }, function() { this.route('monitor'); diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js index 6b79e338a..7c3c60f4a 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -23,7 +23,11 @@ export default class JobRoute extends Route { return this.store .findRecord('job', fullId, { reload: true }) .then(job => { - return RSVP.all([job.get('allocations'), job.get('evaluations')]).then(() => job); + return RSVP.all([ + job.get('allocations'), + job.get('evaluations'), + job.get('recommendationSummaries'), + ]).then(() => job); }) .catch(notifyError(this)); } diff --git a/ui/app/routes/optimize.js b/ui/app/routes/optimize.js new file mode 100644 index 000000000..b4c6db7b3 --- /dev/null +++ b/ui/app/routes/optimize.js @@ -0,0 +1,35 @@ +import Route from '@ember/routing/route'; +import classic from 'ember-classic-decorator'; +import { inject as service } from '@ember/service'; +import RSVP from 'rsvp'; + +@classic +export default class OptimizeRoute extends Route { + @service can; + + breadcrumbs = [ + { + label: 'Recommendations', + args: ['optimize'], + }, + ]; + + beforeModel() { + if (this.can.cannot('accept recommendation')) { + this.transitionTo('jobs'); + } + } + + async model() { + const summaries = await this.store.findAll('recommendation-summary'); + const jobs = await RSVP.all(summaries.mapBy('job')); + await RSVP.all( + jobs + .filter(job => job) + .filterBy('isPartial') + .map(j => j.reload()) + ); + + return summaries; + } +} diff --git a/ui/app/serializers/job.js b/ui/app/serializers/job.js index 2719bb981..8c24bea0a 100644 --- a/ui/app/serializers/job.js +++ b/ui/app/serializers/job.js @@ -55,6 +55,8 @@ export default class JobSerializer extends ApplicationSerializer { !hash.NamespaceID || hash.NamespaceID === 'default' ? undefined : hash.NamespaceID; const { modelName } = modelClass; + const apiNamespace = this.store.adapterFor(modelClass.modelName).get('namespace'); + const [jobURL] = this.store .adapterFor(modelName) .buildURL(modelName, hash.ID, hash, 'findRecord') @@ -91,6 +93,14 @@ export default class JobSerializer extends ApplicationSerializer { related: buildURL(`${jobURL}/scale`, { namespace }), }, }, + recommendationSummaries: { + links: { + related: buildURL(`/${apiNamespace}/recommendations`, { + job: hash.PlainId, + namespace: hash.NamespaceID || 'default', + }), + }, + }, }); } } diff --git a/ui/app/serializers/recommendation-summary.js b/ui/app/serializers/recommendation-summary.js new file mode 100644 index 000000000..a484d4d02 --- /dev/null +++ b/ui/app/serializers/recommendation-summary.js @@ -0,0 +1,91 @@ +import ApplicationSerializer from './application'; +import classic from 'ember-classic-decorator'; + +/* + There’s no grouping of recommendations on the server, so this + processes a list of recommendations and groups them by task + group. +*/ + +@classic +export default class RecommendationSummarySerializer extends ApplicationSerializer { + normalizeArrayResponse(store, modelClass, payload) { + const recommendationSerializer = store.serializerFor('recommendation'); + const RecommendationModel = store.modelFor('recommendation'); + + const slugToSummaryObject = {}; + const allRecommendations = []; + + payload.forEach(recommendationHash => { + const slug = `${JSON.stringify([recommendationHash.JobID, recommendationHash.Namespace])}/${ + recommendationHash.Group + }`; + + if (!slugToSummaryObject[slug]) { + slugToSummaryObject[slug] = { + attributes: { + jobId: recommendationHash.JobID, + jobNamespace: recommendationHash.Namespace, + taskGroupName: recommendationHash.Group, + }, + recommendations: [], + }; + } + + slugToSummaryObject[slug].recommendations.push(recommendationHash); + allRecommendations.push(recommendationHash); + }); + + return { + data: Object.values(slugToSummaryObject).map(summaryObject => { + const latest = Math.max(...summaryObject.recommendations.mapBy('SubmitTime')); + + return { + type: 'recommendation-summary', + id: summaryObject.recommendations + .mapBy('ID') + .sort() + .join('-'), + attributes: { + ...summaryObject.attributes, + submitTime: new Date(Math.floor(latest / 1000000)), + }, + relationships: { + job: { + data: { + type: 'job', + id: JSON.stringify([ + summaryObject.attributes.jobId, + summaryObject.attributes.jobNamespace, + ]), + }, + }, + recommendations: { + data: summaryObject.recommendations.map(r => { + return { + type: 'recommendation', + id: r.ID, + }; + }), + }, + }, + }; + }), + included: allRecommendations.map( + recommendationHash => + recommendationSerializer.normalize(RecommendationModel, recommendationHash).data + ), + }; + } + + normalizeUpdateRecordResponse(store, primaryModelClass, payload, id) { + return { + data: { + id, + attributes: { + isProcessed: true, + }, + }, + }; + } +} diff --git a/ui/app/serializers/recommendation.js b/ui/app/serializers/recommendation.js new file mode 100644 index 000000000..bb9bd785a --- /dev/null +++ b/ui/app/serializers/recommendation.js @@ -0,0 +1,38 @@ +import { assign } from '@ember/polyfills'; +import ApplicationSerializer from './application'; +import classic from 'ember-classic-decorator'; +import queryString from 'query-string'; + +@classic +export default class RecommendationSerializer extends ApplicationSerializer { + attrs = { + taskName: 'Task', + }; + + separateNanos = ['SubmitTime']; + + extractRelationships(modelClass, hash) { + const namespace = !hash.Namespace || hash.Namespace === 'default' ? undefined : hash.Namespace; + + const [jobURL] = this.store + .adapterFor('job') + .buildURL('job', JSON.stringify([hash.JobID]), hash, 'findRecord') + .split('?'); + + return assign(super.extractRelationships(...arguments), { + job: { + links: { + related: buildURL(jobURL, { namespace }), + }, + }, + }); + } +} + +function buildURL(path, queryParams) { + const qpString = queryString.stringify(queryParams); + if (qpString) { + return `${path}?${qpString}`; + } + return path; +} diff --git a/ui/app/styles/charts.scss b/ui/app/styles/charts.scss index 3d494e091..fe301aca1 100644 --- a/ui/app/styles/charts.scss +++ b/ui/app/styles/charts.scss @@ -1,6 +1,7 @@ @import './charts/distribution-bar'; @import './charts/gauge-chart'; @import './charts/line-chart'; +@import './charts/recommendation-chart'; @import './charts/tooltip'; @import './charts/colors'; @import './charts/chart-annotation'; @@ -23,4 +24,9 @@ overflow: hidden; position: absolute; left: -100%; + + .das-gradient { + --full-color: #{change-color($yellow-400, $alpha: 0.7)}; + --faint-color: #{change-color($yellow-400, $alpha: 0.1)}; + } } diff --git a/ui/app/styles/charts/recommendation-chart.scss b/ui/app/styles/charts/recommendation-chart.scss new file mode 100644 index 000000000..fbb6f8695 --- /dev/null +++ b/ui/app/styles/charts/recommendation-chart.scss @@ -0,0 +1,153 @@ +.chart.recommendation-chart { + display: block; + position: relative; + + svg.chart { + display: inline-block; + width: 100%; + } + + .resource { + font-weight: $weight-semibold; + } + + .icon.delta g { + transform: scale(0.8); + transform-origin: center; + } + + .delta { + transition: width 1s, x 1s, transform 1s, color 0.5s; + } + + rect.stat, + line.stat { + transition: fill 0.5s, stroke 0.5s; + } + + rect.delta { + // Allow hover events for stats ticks beneath delta gradient + pointer-events: none; + } + + polygon.delta { + fill: $yellow-700; + } + + .center { + stroke: $cool-gray-500; + stroke-width: 1.5px; + } + + .stats-label { + font-size: $size-7; + text-anchor: end; + + &.right { + text-anchor: start; + } + + &.hidden { + display: none; + } + } + + text.new { + font-weight: $weight-semibold; + } + + text.percent { + font-size: $size-7; + text-anchor: middle; + } + + &.increase { + .mean { + fill: $red-500; + stroke: $red-500; + } + + .p99 { + fill: $red-300; + stroke: $red-300; + } + + .max { + fill: $red-200; + stroke: $red-200; + } + + rect.delta { + fill: url(#recommendation-chart-increase-gradient); + } + + text.percent { + fill: $red-500; + } + } + + &.decrease { + .mean { + fill: $teal-500; + stroke: $teal-500; + } + + .p99 { + fill: $teal-300; + stroke: $teal-300; + } + + .max { + fill: $teal-200; + stroke: $teal-200; + } + + rect.delta { + fill: url(#recommendation-chart-decrease-gradient); + } + + text.percent { + fill: $teal-500; + } + } + + &.disabled { + .resource, + .icon { + fill: $cool-gray-500; + color: $ui-gray-500; + } + + .mean { + fill: $gray-300; + stroke: $gray-300; + } + + .p99 { + fill: $gray-200; + stroke: $gray-200; + } + + .max { + fill: $gray-100; + stroke: $gray-100; + } + } + + line { + stroke-width: 1px; + + &.zero { + stroke: $cool-gray-500; + } + + &.changes { + stroke: $yellow-700; + } + + &.changes:hover, + &.stat:hover { + stroke-width: 2px; + } + } +} diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index a032c11e6..207f03bcc 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -4,6 +4,7 @@ @import './components/codemirror'; @import './components/copy-button'; @import './components/cli-window'; +@import './components/das-interstitial'; @import './components/dashboard-metric'; @import './components/dropdown'; @import './components/ember-power-select'; @@ -31,6 +32,9 @@ @import './components/page-layout'; @import './components/popover-menu'; @import './components/primary-metric'; +@import './components/recommendation-accordion'; +@import './components/recommendation-card'; +@import './components/recommendation-row'; @import './components/search-box'; @import './components/simple-list'; @import './components/status-text'; diff --git a/ui/app/styles/components/das-interstitial.scss b/ui/app/styles/components/das-interstitial.scss new file mode 100644 index 000000000..c324467af --- /dev/null +++ b/ui/app/styles/components/das-interstitial.scss @@ -0,0 +1,74 @@ +.das-interstitial { + margin-bottom: 1.5em; + + .das-accepted, + .das-error { + border: 1px solid; + height: 100%; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-evenly; + + text-align: center; + + h3 { + font-size: $title-size; + font-weight: $weight-bold; + } + + .icon { + height: 8em; + width: auto; + } + } + + .das-accepted { + border-color: $ui-gray-200; + color: $green-500; + } + + .das-error { + border-color: $red-500; + color: $red-500; + } + + .das-dismissed { + border: 1px solid $info; + height: 100%; + padding: 2rem; + + display: flex; + flex-direction: column; + justify-content: space-between; + + h3 { + color: $info; + font-size: $size-5; + font-weight: $weight-bold; + } + + &.understood { + align-items: center; + justify-content: center; + + h3 { + font-size: $title-size; + } + } + + p { + padding: 0.5rem 0; + } + + .actions { + display: flex; + align-items: center; + + .button { + margin-right: 1em; + } + } + } +} diff --git a/ui/app/styles/components/recommendation-accordion.scss b/ui/app/styles/components/recommendation-accordion.scss new file mode 100644 index 000000000..0744ece77 --- /dev/null +++ b/ui/app/styles/components/recommendation-accordion.scss @@ -0,0 +1,54 @@ +.recommendation-accordion { + transition: margin-bottom $timing-fast ($timing-slow - $timing-fast); + + &.closing { + margin-bottom: 0; + } + + .animation-container { + overflow: hidden; + transition: height $timing-slow; + } + + .accordion-head-content { + display: flex; + justify-content: space-between; + + .left { + display: flex; + align-items: center; + + > * { + margin-right: 1.5em; + } + + .icon { + color: $info; + width: 1.75rem; + height: 1.75rem; + margin-left: -10px; + margin-right: 1em; + } + } + + .diffs { + display: flex; + + > * { + margin-left: 1.5em; + } + + .resource { + font-weight: $weight-bold; + } + + .percent { + color: $ui-gray-500; + } + } + + .group { + font-weight: $weight-bold; + } + } +} diff --git a/ui/app/styles/components/recommendation-card.scss b/ui/app/styles/components/recommendation-card.scss new file mode 100644 index 000000000..7a59a3d23 --- /dev/null +++ b/ui/app/styles/components/recommendation-card.scss @@ -0,0 +1,221 @@ +.recommendation-card { + display: grid; + grid-template-columns: [overview] 55% [active-task] 45%; + grid-template-rows: [top] auto [headings] auto [diffs] auto [narrative] auto [main] auto [actions]; + + border: 1px solid $ui-gray-200; + margin-bottom: 1.5em; + + .overview { + grid-column: overview; + border-right: 1px solid $ui-gray-200; + } + + .active-task { + grid-column: active-task; + } + + .active-task-group { + // Allow the active task section to be in a grouped test selector container + display: contents; + } + + .top { + grid-row: top; + + &.active-task { + display: flex; + justify-content: flex-end; + } + } + + header { + grid-row: headings; + } + + .diffs { + grid-row: diffs; + } + + .main { + grid-row: main; + + &.overview { + display: flex; + flex-direction: column; + justify-content: center; + } + + &.active-task { + > li:first-child { + margin-bottom: 2em; + } + } + } + + .actions { + grid-row: actions; + + .button { + margin-bottom: 2em; + margin-right: 0.5em; + } + } + + h3 { + font-size: $size-4; + font-weight: $weight-semibold; + + .group { + color: $cool-gray-500; + font-weight: $weight-normal; + + &:before { + content: '/'; + padding: 0 0.25em 0 0.1em; + } + } + } + + .namespace { + color: $cool-gray-500; + + .namespace-label { + font-weight: $weight-semibold; + } + } + + .increase { + color: $red-500; + } + + .decrease { + color: $teal-500; + } + + .inner-container { + padding: 1em 2em; + + &.task-toggles { + padding-right: 0; + } + } + + .diffs-table { + th, + td { + padding-right: 0.5em; + } + + td.diff { + color: $cool-gray-500; + } + } + + .active-task th.diff { + display: none; + } + + .copy-button { + display: flex; + flex-direction: row-reverse; + + .button { + display: flex; + flex-direction: row-reverse; + color: $ui-gray-400; + + .icon { + margin-left: 0.75em; + } + } + } + + .task-toggles { + table { + width: calc(100% + 1px); // To remove a mysterious 1px gap between this and the pane border + } + + th { + vertical-align: bottom; + font-size: $size-7; + + &.toggle-cell .toggle { + display: flex; + flex-direction: column-reverse; + align-items: center; + padding-bottom: 2px; + + .label-wrapper { + margin-bottom: 6px; + } + } + } + + .toggle-all { + text-align: right; + } + + tr { + border-bottom: 1px solid $ui-gray-200; + } + + tbody tr:not(.active):hover { + background: $ui-gray-100; + cursor: pointer; + } + + tr.active { + color: blue; + font-weight: bold; + + // When there’s only one task, it doesn’t need highlighting + &:first-child:last-child { + color: inherit; + font-weight: inherit; + } + + td:last-child { + position: relative; + } + + svg { + position: absolute; + top: -1px; + left: calc(100% - 1px); // To balance out the table width calc above + + .border-cover { + fill: white; + width: 2px; + } + + .triangle { + fill: transparent; + stroke: $ui-gray-200; + } + } + } + + th, + td { + padding: 0.75em 0; + + &:first-child { + padding-left: 5px; + } + + &:last-child { + padding-right: 2em; + } + } + + .task-cell { + width: 100%; + } + + .toggle-cell { + text-align: center; + padding: 0.75em; + } + } +} diff --git a/ui/app/styles/components/recommendation-row.scss b/ui/app/styles/components/recommendation-row.scss new file mode 100644 index 000000000..7786d5f9e --- /dev/null +++ b/ui/app/styles/components/recommendation-row.scss @@ -0,0 +1,23 @@ +.recommendation-row { + &.is-disabled { + cursor: not-allowed; + } + + .job { + font-weight: $weight-semibold; + } + + .task-group, + .percent { + color: $ui-gray-500; + } + + td { + vertical-align: middle; + } + + .namespace { + font-size: $size-7; + color: $ui-gray-500; + } +} diff --git a/ui/app/styles/core/variables.scss b/ui/app/styles/core/variables.scss index 24832cf1a..01e965436 100644 --- a/ui/app/styles/core/variables.scss +++ b/ui/app/styles/core/variables.scss @@ -44,3 +44,7 @@ $breadcrumb-item-active-color: $white; $breadcrumb-item-separator-color: $primary; $mq-hidden-gutter: 'only screen and (max-width : 960px)'; + +$timing-fast: 150ms; +$timing-medium: 300ms; +$timing-slow: 450ms; diff --git a/ui/app/styles/utils/structure-colors.scss b/ui/app/styles/utils/structure-colors.scss index 70d320a88..270379ccc 100644 --- a/ui/app/styles/utils/structure-colors.scss +++ b/ui/app/styles/utils/structure-colors.scss @@ -6,3 +6,22 @@ $ui-gray-500: #6f7682; $ui-gray-700: #525761; $ui-gray-800: #373a42; $ui-gray-900: #1f2124; + +$red-500: #c73445; +$red-300: #db7d88; +$red-200: #e5a2aa; + +$teal-500: #25ba81; +$teal-300: #74d3ae; +$teal-200: #9bdfc5; + +$gray-300: #bac1cc; +$gray-200: #dce0e6; +$gray-100: #ebeef2; + +$cool-gray-500: #7c8797; + +$yellow-400: #face30; +$yellow-700: #a07d02; + +$green-500: #2eb039; diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs index f2157ee6d..acafdc6cc 100644 --- a/ui/app/templates/components/gutter-menu.hbs +++ b/ui/app/templates/components/gutter-menu.hbs @@ -60,6 +60,16 @@ Jobs + {{#if (can "accept recommendation")}} +
  • + + Optimize + +
  • + {{/if}}
    + {{#if @model}} + {{#if this.activeRecommendationSummary}} + + {{/if}} + + + + Job + Recommended At + # Allocs + CPU + Mem + Agg. CPU + Agg. Mem + + + {{#if row.model.isProcessed}} + + {{else}} + + {{/if}} + + + + {{else}} +
    +

    No Recommendations

    +

    + All recommendations have been accepted or dismissed. Nomad will continuously monitor applications so expect more recommendations in the future. +

    +
    + {{/if}} +
    + diff --git a/ui/app/utils/resources-diffs.js b/ui/app/utils/resources-diffs.js new file mode 100644 index 000000000..ef13ca902 --- /dev/null +++ b/ui/app/utils/resources-diffs.js @@ -0,0 +1,120 @@ +import d3Format from 'd3-format'; + +import { reduceToLargestUnit } from 'nomad-ui/helpers/format-bytes'; + +const formatPercent = d3Format.format('+.0%'); +const sumAggregate = (total, val) => total + val; + +export default class ResourcesDiffs { + constructor(model, multiplier, recommendations, excludedRecommendations) { + this.model = model; + this.multiplier = multiplier; + this.recommendations = recommendations; + this.excludedRecommendations = excludedRecommendations.filter(r => recommendations.includes(r)); + } + + get cpu() { + const included = this.includedRecommendations.filterBy('resource', 'CPU'); + const excluded = this.excludedRecommendations.filterBy('resource', 'CPU'); + + return new ResourceDiffs( + this.model.reservedCPU, + 'reservedCPU', + 'MHz', + this.multiplier, + included, + excluded + ); + } + + get memory() { + const included = this.includedRecommendations.filterBy('resource', 'MemoryMB'); + const excluded = this.excludedRecommendations.filterBy('resource', 'MemoryMB'); + + return new ResourceDiffs( + this.model.reservedMemory, + 'reservedMemory', + 'MiB', + this.multiplier, + included, + excluded + ); + } + + get includedRecommendations() { + return this.recommendations.reject(r => this.excludedRecommendations.includes(r)); + } +} + +class ResourceDiffs { + constructor( + base, + baseTaskPropertyName, + units, + multiplier, + includedRecommendations, + excludedRecommendations + ) { + this.base = base; + this.baseTaskPropertyName = baseTaskPropertyName; + this.units = units; + this.multiplier = multiplier; + this.included = includedRecommendations; + this.excluded = excludedRecommendations; + } + + get recommended() { + if (this.included.length) { + return ( + this.included.mapBy('value').reduce(sumAggregate, 0) + + this.excluded.mapBy(`task.${this.baseTaskPropertyName}`).reduce(sumAggregate, 0) + ); + } else { + return this.base; + } + } + + get delta() { + return this.recommended - this.base; + } + + get aggregateDiff() { + return this.delta * this.multiplier; + } + + get absoluteAggregateDiff() { + const delta = Math.abs(this.aggregateDiff); + + if (this.units === 'MiB') { + if (delta === 0) { + return '0 MiB'; + } + + const [memory, units] = reduceToLargestUnit(delta * 1024 * 1024); + const formattedMemory = Number.isInteger(memory) ? memory : memory.toFixed(2); + + return `${formattedMemory} ${units}`; + } else { + return `${delta} ${this.units}`; + } + } + + get signedDiff() { + const delta = this.aggregateDiff; + return `${signForDelta(delta)}${this.absoluteAggregateDiff}`; + } + + get percentDiff() { + return formatPercent(this.delta / this.base); + } +} + +function signForDelta(delta) { + if (delta > 0) { + return '+'; + } else if (delta < 0) { + return '-'; + } + + return ''; +} diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 2bac39cc5..9ad6c07c0 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -532,6 +532,54 @@ export default function() { return this.serialize(clientStats.find(host)); }); }); + + this.get('/recommendations', function( + { jobs, namespaces, recommendations }, + { queryParams: { job: id, namespace } } + ) { + if (id) { + if (!namespaces.all().length) { + namespace = null; + } + + const job = jobs.findBy({ id, namespace }); + + if (!job) { + return []; + } + + const taskGroups = job.taskGroups.models; + + const tasks = taskGroups.reduce((tasks, taskGroup) => { + return tasks.concat(taskGroup.tasks.models); + }, []); + + const recommendationIds = tasks.reduce((recommendationIds, task) => { + return recommendationIds.concat(task.recommendations.models.mapBy('id')); + }, []); + + return recommendations.find(recommendationIds); + } else { + return recommendations.all(); + } + }); + + this.post('/recommendations/apply', function({ recommendations }, { requestBody }) { + const { Apply, Dismiss } = JSON.parse(requestBody); + + Apply.concat(Dismiss).forEach(id => { + const recommendation = recommendations.find(id); + const task = recommendation.task; + + if (Apply.includes(id)) { + task.resources[recommendation.resource] = recommendation.value; + } + recommendation.destroy(); + task.save(); + }); + + return {}; + }); } function filterKeys(object, ...keys) { diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js index 4d9512457..a15d99ce6 100644 --- a/ui/mirage/factories/job.js +++ b/ui/mirage/factories/job.js @@ -121,6 +121,9 @@ export default Factory.extend({ // When true, task groups will have services withGroupServices: false, + // When true, dynamic application sizing recommendations will be made + createRecommendations: false, + // When true, only task groups and allocations are made shallow: false, @@ -142,6 +145,7 @@ export default Factory.extend({ createAllocations: job.createAllocations, withRescheduling: job.withRescheduling, withServices: job.withGroupServices, + createRecommendations: job.createRecommendations, shallow: job.shallow, }; diff --git a/ui/mirage/factories/recommendation.js b/ui/mirage/factories/recommendation.js new file mode 100644 index 000000000..2d424cb77 --- /dev/null +++ b/ui/mirage/factories/recommendation.js @@ -0,0 +1,31 @@ +import { Factory } from 'ember-cli-mirage'; + +import faker from 'nomad-ui/mirage/faker'; + +const REF_TIME = new Date(); + +export default Factory.extend({ + submitTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000, + + afterCreate(recommendation) { + const base = + recommendation.resource === 'CPU' + ? recommendation.task.resources.CPU + : recommendation.task.resources.MemoryMB; + const recommendDecrease = faker.random.boolean(); + const directionMultiplier = recommendDecrease ? -1 : 1; + + const value = base + Math.floor(base * 0.5) * directionMultiplier; + + const min = faker.random.number({ min: 5, max: value * 0.4 }); + const max = faker.random.number({ min: value * 0.6, max: value }); + const p99 = faker.random.number({ min: min + (max - min) * 0.8, max }); + const mean = faker.random.number({ min, max: p99 }); + const median = faker.random.number({ min, max: p99 }); + + recommendation.update({ + stats: { min, max, p99, mean, median }, + value, + }); + }, +}); diff --git a/ui/mirage/factories/task-group.js b/ui/mirage/factories/task-group.js index 73ecf36f6..be01ad3a3 100644 --- a/ui/mirage/factories/task-group.js +++ b/ui/mirage/factories/task-group.js @@ -34,6 +34,10 @@ export default Factory.extend({ // Directive used to control whether the task group should have services. withServices: false, + // Directive used to control whether dynamic application sizing recommendations + // should be created. + createRecommendations: false, + // When true, only creates allocations shallow: false, @@ -90,6 +94,7 @@ export default Factory.extend({ PropagationMode: '', ReadOnly: faker.random.boolean(), })), + createRecommendations: group.createRecommendations, }); }); taskIds = tasks.mapBy('id'); diff --git a/ui/mirage/factories/task.js b/ui/mirage/factories/task.js index ab3ee6359..10d688e0f 100644 --- a/ui/mirage/factories/task.js +++ b/ui/mirage/factories/task.js @@ -5,6 +5,8 @@ import { generateResources } from '../common'; const DRIVERS = ['docker', 'java', 'rkt', 'qemu', 'exec', 'raw_exec']; export default Factory.extend({ + createRecommendations: false, + // Hidden property used to compute the Summary hash groupNames: [], @@ -43,4 +45,20 @@ export default Factory.extend({ return { Hook: 'poststart', Sidecar: true }; } }, + + afterCreate(task, server) { + if (task.createRecommendations) { + const recommendations = []; + + if (faker.random.number(10) >= 1) { + recommendations.push(server.create('recommendation', { task, resource: 'CPU' })); + } + + if (faker.random.number(10) >= 1) { + recommendations.push(server.create('recommendation', { task, resource: 'MemoryMB' })); + } + + task.save({ recommendationIds: recommendations.mapBy('id') }); + } + }, }); diff --git a/ui/mirage/faker.js b/ui/mirage/faker.js index 817781701..72653f193 100644 --- a/ui/mirage/faker.js +++ b/ui/mirage/faker.js @@ -13,7 +13,9 @@ if (config.environment !== 'test' || searchIncludesSeed) { } } else if (config.environment === 'test') { const randomSeed = faker.random.number(); - console.log(`No seed specified with faker-seed query parameter, seeding Faker with ${randomSeed}`); + console.log( + `No seed specified with faker-seed query parameter, seeding Faker with ${randomSeed}` + ); faker.seed(randomSeed); } diff --git a/ui/mirage/models/recommendation.js b/ui/mirage/models/recommendation.js new file mode 100644 index 000000000..2ea42f8ed --- /dev/null +++ b/ui/mirage/models/recommendation.js @@ -0,0 +1,5 @@ +import { Model, belongsTo } from 'ember-cli-mirage'; + +export default Model.extend({ + task: belongsTo('task'), +}); diff --git a/ui/mirage/models/task.js b/ui/mirage/models/task.js index 359d354e0..e8613750d 100644 --- a/ui/mirage/models/task.js +++ b/ui/mirage/models/task.js @@ -1,5 +1,6 @@ -import { Model, belongsTo } from 'ember-cli-mirage'; +import { Model, belongsTo, hasMany } from 'ember-cli-mirage'; export default Model.extend({ taskGroup: belongsTo(), + recommendations: hasMany(), }); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index c30dc446a..da5a4148a 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -41,7 +41,7 @@ export default function(server) { function smallCluster(server) { server.createList('agent', 3); server.createList('node', 5); - server.createList('job', 5); + server.createList('job', 5, { createRecommendations: true }); server.createList('allocFile', 5); server.create('allocFile', 'dir', { depth: 2 }); server.createList('csi-plugin', 2); diff --git a/ui/mirage/scenarios/topo.js b/ui/mirage/scenarios/topo.js index d38d28df5..c8ce1ed65 100644 --- a/ui/mirage/scenarios/topo.js +++ b/ui/mirage/scenarios/topo.js @@ -99,6 +99,7 @@ export function topoMedium(server) { datacenters: ['dc1'], type: 'service', createAllocations: false, + createRecommendations: true, resourceSpec: spec, }); }); diff --git a/ui/mirage/serializers/recommendation.js b/ui/mirage/serializers/recommendation.js new file mode 100644 index 000000000..02d1c024c --- /dev/null +++ b/ui/mirage/serializers/recommendation.js @@ -0,0 +1,30 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + embed: true, + include: ['task', 'task.task-group'], + + serialize() { + var json = ApplicationSerializer.prototype.serialize.apply(this, arguments); + if (json instanceof Array) { + json.forEach(recommendationJson => serializeRecommendation(recommendationJson, this.schema)); + } else { + serializeRecommendation(json, this.schema); + } + return json; + }, +}); + +function serializeRecommendation(recommendation, schema) { + const taskJson = recommendation.Task; + + recommendation.Task = taskJson.Name; + + const taskGroup = schema.taskGroups.find(taskJson.TaskGroupID); + + recommendation.Group = taskGroup.name; + recommendation.JobID = taskGroup.job.id; + recommendation.Namespace = taskGroup.job.namespace || 'default'; + + return recommendation; +} diff --git a/ui/package.json b/ui/package.json index cd1b4b292..f4d80804d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -84,7 +84,7 @@ "ember-inline-svg": "^0.3.0", "ember-load-initializers": "^2.1.1", "ember-maybe-import-regenerator": "^0.1.6", - "ember-modifier": "^2.1.0", + "ember-modifier": "^2.1.1", "ember-moment": "^7.8.1", "ember-overridable-computed": "^1.0.0", "ember-page-title": "^5.0.2", diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js index d6cd81ba7..d2d624ffd 100644 --- a/ui/tests/acceptance/job-detail-test.js +++ b/ui/tests/acceptance/job-detail-test.js @@ -105,8 +105,12 @@ module('Acceptance | job detail (with namespaces)', function(hooks) { type: 'service', status: 'running', namespaceId: server.db.namespaces[1].name, + createRecommendations: true, + }); + server.createList('job', 3, { + namespaceId: server.db.namespaces[0].name, + createRecommendations: true, }); - server.createList('job', 3, { namespaceId: server.db.namespaces[0].name }); server.create('token'); clientToken = server.create('token'); @@ -206,4 +210,41 @@ module('Acceptance | job detail (with namespaces)', function(hooks) { await JobDetail.visit({ id: job.id, namespace: server.db.namespaces[1].name }); assert.notOk(JobDetail.execButton.isDisabled); }); + + test('resource recommendations show when they exist and can be expanded, collapsed, and processed', async function(assert) { + await JobDetail.visit({ id: job.id, namespace: server.db.namespaces[1].name }); + + assert.equal(JobDetail.recommendations.length, job.taskGroups.length); + + const recommendation = JobDetail.recommendations[0]; + + assert.equal(recommendation.group, job.taskGroups.models[0].name); + assert.ok(recommendation.card.isHidden); + + const toggle = recommendation.toggleButton; + + assert.equal(toggle.text, 'Show'); + + await toggle.click(); + + assert.ok(recommendation.card.isPresent); + assert.equal(toggle.text, 'Collapse'); + + await toggle.click(); + + assert.ok(recommendation.card.isHidden); + + await toggle.click(); + + assert.equal(recommendation.card.slug.groupName, job.taskGroups.models[0].name); + + await recommendation.card.acceptButton.click(); + + assert.equal(JobDetail.recommendations.length, job.taskGroups.length - 1); + + await JobDetail.tabFor('definition').visit(); + await JobDetail.tabFor('overview').visit(); + + assert.equal(JobDetail.recommendations.length, job.taskGroups.length - 1); + }); }); diff --git a/ui/tests/acceptance/optimize-test.js b/ui/tests/acceptance/optimize-test.js new file mode 100644 index 000000000..96f48bc9f --- /dev/null +++ b/ui/tests/acceptance/optimize-test.js @@ -0,0 +1,331 @@ +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import { currentURL } from '@ember/test-helpers'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; +import Response from 'ember-cli-mirage/response'; +import moment from 'moment'; + +import Optimize from 'nomad-ui/tests/pages/optimize'; +import PageLayout from 'nomad-ui/tests/pages/layout'; +import JobsList from 'nomad-ui/tests/pages/jobs/list'; + +let managementToken, clientToken; + +function getLatestRecommendationSubmitTimeForJob(job) { + const tasks = job.taskGroups.models + .mapBy('tasks.models') + .reduce((tasks, taskModels) => tasks.concat(taskModels), []); + const recommendations = tasks.reduce( + (recommendations, task) => recommendations.concat(task.recommendations.models), + [] + ); + return Math.max(...recommendations.mapBy('submitTime')); +} + +module('Acceptance | optimize', function(hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function() { + server.create('node'); + + server.createList('namespace', 2); + + const jobs = server.createList('job', 2, { + createRecommendations: true, + groupsCount: 1, + groupTaskCount: 2, + namespaceId: server.db.namespaces[1].id, + }); + + jobs.sort((jobA, jobB) => { + return ( + getLatestRecommendationSubmitTimeForJob(jobB) - + getLatestRecommendationSubmitTimeForJob(jobA) + ); + }); + + [this.job1, this.job2] = jobs; + + managementToken = server.create('token'); + clientToken = server.create('token'); + + window.localStorage.clear(); + window.localStorage.nomadTokenSecret = managementToken.secretId; + }); + + test('it passes an accessibility audit', async function(assert) { + await Optimize.visit(); + await a11yAudit(assert); + }); + + test('lets recommendations be toggled, reports the choices to the recommendations API, and displays task group recommendations serially', async function(assert) { + await Optimize.visit(); + + const currentTaskGroup = this.job1.taskGroups.models[0]; + const nextTaskGroup = this.job2.taskGroups.models[0]; + + assert.equal(Optimize.breadcrumbFor('optimize').text, 'Recommendations'); + + assert.equal( + Optimize.recommendationSummaries[0].slug, + `${this.job1.name} / ${currentTaskGroup.name}` + ); + + assert.equal(Optimize.recommendationSummaries[0].namespace, this.job1.namespace); + + assert.equal( + Optimize.recommendationSummaries[1].slug, + `${this.job2.name} / ${nextTaskGroup.name}` + ); + + const currentRecommendations = currentTaskGroup.tasks.models.reduce( + (recommendations, task) => recommendations.concat(task.recommendations.models), + [] + ); + const latestSubmitTime = Math.max(...currentRecommendations.mapBy('submitTime')); + + Optimize.recommendationSummaries[0].as(summary => { + assert.equal( + summary.date, + moment(new Date(latestSubmitTime / 1000000)).format('MMM DD HH:mm:ss ZZ') + ); + + const currentTaskGroupAllocations = server.schema.allocations.where({ + jobId: currentTaskGroup.job.name, + taskGroup: currentTaskGroup.name, + }); + assert.equal(summary.allocationCount, currentTaskGroupAllocations.length); + + const { currCpu, currMem } = currentTaskGroup.tasks.models.reduce( + (currentResources, task) => { + currentResources.currCpu += task.resources.CPU; + currentResources.currMem += task.resources.MemoryMB; + return currentResources; + }, + { currCpu: 0, currMem: 0 } + ); + + const { recCpu, recMem } = currentRecommendations.reduce( + (recommendedResources, recommendation) => { + if (recommendation.resource === 'CPU') { + recommendedResources.recCpu += recommendation.value; + } else { + recommendedResources.recMem += recommendation.value; + } + + return recommendedResources; + }, + { recCpu: 0, recMem: 0 } + ); + + const cpuDiff = recCpu > 0 ? recCpu - currCpu : 0; + const memDiff = recMem > 0 ? recMem - currMem : 0; + + const cpuSign = cpuDiff > 0 ? '+' : ''; + const memSign = memDiff > 0 ? '+' : ''; + + const cpuDiffPercent = Math.round((100 * cpuDiff) / currCpu); + const memDiffPercent = Math.round((100 * memDiff) / currMem); + + assert.equal( + summary.cpu, + cpuDiff ? `${cpuSign}${cpuDiff} MHz ${cpuSign}${cpuDiffPercent}%` : '' + ); + assert.equal( + summary.memory, + memDiff ? `${memSign}${formattedMemDiff(memDiff)} ${memSign}${memDiffPercent}%` : '' + ); + + assert.equal( + summary.aggregateCpu, + cpuDiff ? `${cpuSign}${cpuDiff * currentTaskGroupAllocations.length} MHz` : '' + ); + + assert.equal( + summary.aggregateMemory, + memDiff ? `${memSign}${formattedMemDiff(memDiff * currentTaskGroupAllocations.length)}` : '' + ); + }); + + assert.ok(Optimize.recommendationSummaries[0].isActive); + assert.notOk(Optimize.recommendationSummaries[1].isActive); + + assert.equal(Optimize.card.slug.jobName, this.job1.name); + assert.equal(Optimize.card.slug.groupName, currentTaskGroup.name); + + const summaryMemoryBefore = Optimize.recommendationSummaries[0].memory; + + let toggledAnything = true; + + // Toggle off all memory + if (Optimize.card.togglesTable.toggleAllMemory.isPresent) { + await Optimize.card.togglesTable.toggleAllMemory.toggle(); + + assert.notOk(Optimize.card.togglesTable.tasks[0].memory.isActive); + assert.notOk(Optimize.card.togglesTable.tasks[1].memory.isActive); + } else if (!Optimize.card.togglesTable.tasks[0].cpu.isDisabled) { + await Optimize.card.togglesTable.tasks[0].memory.toggle(); + } else { + toggledAnything = false; + } + + assert.equal( + Optimize.recommendationSummaries[0].memory, + summaryMemoryBefore, + 'toggling recommendations doesn’t affect the summary table diffs' + ); + + const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id'); + const taskIdFilter = task => currentTaskIds.includes(task.taskId); + + const cpuRecommendationIds = server.schema.recommendations + .where({ resource: 'CPU' }) + .models.filter(taskIdFilter) + .mapBy('id'); + + const memoryRecommendationIds = server.schema.recommendations + .where({ resource: 'MemoryMB' }) + .models.filter(taskIdFilter) + .mapBy('id'); + + const appliedIds = toggledAnything ? cpuRecommendationIds : memoryRecommendationIds; + const dismissedIds = toggledAnything ? memoryRecommendationIds : []; + + await Optimize.card.acceptButton.click(); + + const request = server.pretender.handledRequests.filterBy('method', 'POST').pop(); + const { Apply, Dismiss } = JSON.parse(request.requestBody); + + assert.equal(request.url, '/v1/recommendations/apply'); + + assert.deepEqual(Apply, appliedIds); + assert.deepEqual(Dismiss, dismissedIds); + + assert.equal(Optimize.card.slug.jobName, this.job2.name); + assert.equal(Optimize.card.slug.groupName, nextTaskGroup.name); + + assert.ok(Optimize.recommendationSummaries[1].isActive); + }); + + test('can navigate between summaries via the table', async function(assert) { + server.createList('job', 10, { + createRecommendations: true, + groupsCount: 1, + groupTaskCount: 2, + namespaceId: server.db.namespaces[1].id, + }); + + await Optimize.visit(); + await Optimize.recommendationSummaries[1].click(); + + assert.equal( + `${Optimize.card.slug.jobName} / ${Optimize.card.slug.groupName}`, + Optimize.recommendationSummaries[1].slug + ); + assert.ok(Optimize.recommendationSummaries[1].isActive); + }); + + test('cannot return to already-processed summaries', async function(assert) { + await Optimize.visit(); + await Optimize.card.acceptButton.click(); + + assert.ok(Optimize.recommendationSummaries[0].isDisabled); + + await Optimize.recommendationSummaries[0].click(); + + assert.ok(Optimize.recommendationSummaries[1].isActive); + }); + + test('can dismiss a set of recommendations', async function(assert) { + await Optimize.visit(); + + const currentTaskGroup = this.job1.taskGroups.models[0]; + const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id'); + const taskIdFilter = task => currentTaskIds.includes(task.taskId); + + const idsBeforeDismissal = server.schema.recommendations + .all() + .models.filter(taskIdFilter) + .mapBy('id'); + + await Optimize.card.dismissButton.click(); + + const request = server.pretender.handledRequests.filterBy('method', 'POST').pop(); + const { Apply, Dismiss } = JSON.parse(request.requestBody); + + assert.equal(request.url, '/v1/recommendations/apply'); + + assert.deepEqual(Apply, []); + assert.deepEqual(Dismiss, idsBeforeDismissal); + }); + + test('it displays an error encountered trying to save and proceeds to the next summary when the error is dismiss', async function(assert) { + server.post('/recommendations/apply', function() { + return new Response(500, {}, null); + }); + + await Optimize.visit(); + await Optimize.card.acceptButton.click(); + + assert.ok(Optimize.error.isPresent); + assert.equal(Optimize.error.headline, 'Recommendation error'); + assert.equal( + Optimize.error.errors, + 'Error: Ember Data Request POST /v1/recommendations/apply returned a 500 Payload (application/json)' + ); + + await Optimize.error.dismiss(); + assert.equal(Optimize.card.slug.jobName, this.job2.name); + }); + + test('it displays an empty message when there are no recommendations', async function(assert) { + server.db.recommendations.remove(); + await Optimize.visit(); + + assert.ok(Optimize.empty.isPresent); + assert.equal(Optimize.empty.headline, 'No Recommendations'); + }); + + test('it displays an empty message after all recommendations have been processed', async function(assert) { + await Optimize.visit(); + + await Optimize.card.acceptButton.click(); + await Optimize.card.acceptButton.click(); + + assert.ok(Optimize.empty.isPresent); + }); + + test('it redirects to jobs and hides the gutter link when the token lacks permissions', async function(assert) { + window.localStorage.nomadTokenSecret = clientToken.secretId; + await Optimize.visit(); + + assert.equal(currentURL(), '/jobs'); + assert.ok(PageLayout.gutter.optimize.isHidden); + }); + + test('it reloads partially-loaded jobs', async function(assert) { + await JobsList.visit(); + await Optimize.visit(); + + assert.equal(Optimize.recommendationSummaries.length, 2); + }); +}); + +function formattedMemDiff(memDiff) { + const absMemDiff = Math.abs(memDiff); + const negativeSign = memDiff < 0 ? '-' : ''; + + if (absMemDiff >= 1024) { + const gibDiff = absMemDiff / 1024; + + if (Number.isInteger(gibDiff)) { + return `${negativeSign}${gibDiff} GiB`; + } else { + return `${negativeSign}${gibDiff.toFixed(2)} GiB`; + } + } else { + return `${negativeSign}${absMemDiff} MiB`; + } +} diff --git a/ui/tests/integration/components/das/dismissed-test.js b/ui/tests/integration/components/das/dismissed-test.js new file mode 100644 index 000000000..c990e964f --- /dev/null +++ b/ui/tests/integration/components/das/dismissed-test.js @@ -0,0 +1,44 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { click, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit'; +import sinon from 'sinon'; + +module('Integration | Component | das/dismissed', function(hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function() { + window.localStorage.clear(); + }); + + test('it renders the dismissal interstitial with a button to proceed and an option to never show again and proceeds manually', async function(assert) { + const proceedSpy = sinon.spy(); + this.set('proceedSpy', proceedSpy); + + await render(hbs``); + + await componentA11yAudit(this.element, assert); + + await click('input[type=checkbox]'); + await click('[data-test-understood]'); + + assert.ok(proceedSpy.calledWith({ manuallyDismissed: true })); + assert.equal(window.localStorage.getItem('nomadRecommendationDismssalUnderstood'), 'true'); + }); + + test('it renders the dismissal interstitial with no button when the option to never show again has been chosen and proceeds automatically', async function(assert) { + window.localStorage.setItem('nomadRecommendationDismssalUnderstood', true); + + const proceedSpy = sinon.spy(); + this.set('proceedSpy', proceedSpy); + + await render(hbs``); + + assert.dom('[data-test-understood]').doesNotExist(); + + await componentA11yAudit(this.element, assert); + + assert.ok(proceedSpy.calledWith({ manuallyDismissed: false })); + }); +}); diff --git a/ui/tests/integration/components/das/recommendation-card-test.js b/ui/tests/integration/components/das/recommendation-card-test.js new file mode 100644 index 000000000..67dbb6cd6 --- /dev/null +++ b/ui/tests/integration/components/das/recommendation-card-test.js @@ -0,0 +1,581 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, settled } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit'; + +import RecommendationCardComponent from 'nomad-ui/tests/pages/components/recommendation-card'; +import { create } from 'ember-cli-page-object'; +const RecommendationCard = create(RecommendationCardComponent); + +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { set } from '@ember/object'; + +module('Integration | Component | das/recommendation-card', function(hooks) { + setupRenderingTest(hooks); + + test('it renders a recommendation card', async function(assert) { + const task1 = { + name: 'jortle', + reservedCPU: 150, + reservedMemory: 128, + }; + + const task2 = { + name: 'tortle', + reservedCPU: 125, + reservedMemory: 256, + }; + + this.set( + 'summary', + new MockRecommendationSummary({ + recommendations: [ + { + resource: 'MemoryMB', + stats: {}, + task: task1, + value: 192, + currentValue: task1.reservedMemory, + }, + { + resource: 'CPU', + stats: {}, + task: task1, + value: 50, + currentValue: task1.reservedCPU, + }, + { + resource: 'CPU', + stats: {}, + task: task2, + value: 150, + currentValue: task2.reservedCPU, + }, + { + resource: 'MemoryMB', + stats: {}, + task: task2, + value: 320, + currentValue: task2.reservedMemory, + }, + ], + + taskGroup: { + count: 2, + name: 'group-name', + job: { + name: 'job-name', + namespace: { + name: 'namespace', + }, + }, + reservedCPU: task1.reservedCPU + task2.reservedCPU, + reservedMemory: task1.reservedMemory + task2.reservedMemory, + }, + }) + ); + + await render(hbs``); + + assert.equal(RecommendationCard.slug.jobName, 'job-name'); + assert.equal(RecommendationCard.slug.groupName, 'group-name'); + + assert.equal(RecommendationCard.namespace, 'namespace'); + + assert.equal(RecommendationCard.totalsTable.current.cpu.text, '275 MHz'); + assert.equal(RecommendationCard.totalsTable.current.memory.text, '384 MiB'); + + RecommendationCard.totalsTable.recommended.cpu.as(RecommendedCpu => { + assert.equal(RecommendedCpu.text, '200 MHz'); + assert.ok(RecommendedCpu.isDecrease); + }); + + RecommendationCard.totalsTable.recommended.memory.as(RecommendedMemory => { + assert.equal(RecommendedMemory.text, '512 MiB'); + assert.ok(RecommendedMemory.isIncrease); + }); + + assert.equal(RecommendationCard.totalsTable.unitDiff.cpu, '-75 MHz'); + assert.equal(RecommendationCard.totalsTable.unitDiff.memory, '+128 MiB'); + + assert.equal(RecommendationCard.totalsTable.percentDiff.cpu, '-27%'); + assert.equal(RecommendationCard.totalsTable.percentDiff.memory, '+33%'); + + assert.equal(RecommendationCard.activeTask.totalsTable.current.cpu.text, '150 MHz'); + assert.equal(RecommendationCard.activeTask.totalsTable.current.memory.text, '128 MiB'); + + RecommendationCard.activeTask.totalsTable.recommended.cpu.as(RecommendedCpu => { + assert.equal(RecommendedCpu.text, '50 MHz'); + assert.ok(RecommendedCpu.isDecrease); + }); + + RecommendationCard.activeTask.totalsTable.recommended.memory.as(RecommendedMemory => { + assert.equal(RecommendedMemory.text, '192 MiB'); + assert.ok(RecommendedMemory.isIncrease); + }); + + assert.equal(RecommendationCard.activeTask.charts.length, 2); + assert.equal( + RecommendationCard.activeTask.charts[0].resource, + 'CPU', + 'CPU chart should be first when present' + ); + + assert.ok(RecommendationCard.activeTask.cpuChart.isDecrease); + assert.ok(RecommendationCard.activeTask.memoryChart.isIncrease); + + assert.equal(RecommendationCard.togglesTable.tasks.length, 2); + + await RecommendationCard.togglesTable.tasks[0].as(async FirstTask => { + assert.equal(FirstTask.name, 'jortle'); + assert.ok(FirstTask.isActive); + + assert.equal(FirstTask.cpu.title, 'CPU for jortle'); + assert.ok(FirstTask.cpu.isActive); + + assert.equal(FirstTask.memory.title, 'Memory for jortle'); + assert.ok(FirstTask.memory.isActive); + + await FirstTask.cpu.toggle(); + + assert.notOk(FirstTask.cpu.isActive); + assert.ok(RecommendationCard.activeTask.cpuChart.isDisabled); + }); + + assert.notOk(RecommendationCard.togglesTable.tasks[1].isActive); + + assert.equal(RecommendationCard.activeTask.name, 'jortle task'); + + RecommendationCard.totalsTable.recommended.cpu.as(RecommendedCpu => { + assert.equal(RecommendedCpu.text, '300 MHz'); + assert.ok(RecommendedCpu.isIncrease); + }); + + RecommendationCard.activeTask.totalsTable.recommended.cpu.as(RecommendedCpu => { + assert.equal(RecommendedCpu.text, '150 MHz'); + assert.ok(RecommendedCpu.isNeutral); + }); + + await RecommendationCard.togglesTable.toggleAllMemory.toggle(); + + assert.notOk(RecommendationCard.togglesTable.tasks[0].memory.isActive); + assert.notOk(RecommendationCard.togglesTable.tasks[1].memory.isActive); + + RecommendationCard.totalsTable.recommended.memory.as(RecommendedMemory => { + assert.equal(RecommendedMemory.text, '384 MiB'); + assert.ok(RecommendedMemory.isNeutral); + }); + + await RecommendationCard.togglesTable.tasks[1].click(); + + assert.notOk(RecommendationCard.togglesTable.tasks[0].isActive); + assert.ok(RecommendationCard.togglesTable.tasks[1].isActive); + + assert.equal(RecommendationCard.activeTask.name, 'tortle task'); + assert.equal(RecommendationCard.activeTask.totalsTable.current.cpu.text, '125 MHz'); + + await componentA11yAudit(this.element, assert); + }); + + test('it doesn’t have header toggles when there’s only one task', async function(assert) { + const task1 = { + name: 'jortle', + reservedCPU: 150, + reservedMemory: 128, + }; + + this.set( + 'summary', + new MockRecommendationSummary({ + recommendations: [ + { + resource: 'CPU', + stats: {}, + task: task1, + value: 50, + }, + { + resource: 'MemoryMB', + stats: {}, + task: task1, + value: 192, + }, + ], + + taskGroup: { + count: 1, + reservedCPU: task1.reservedCPU, + reservedMemory: task1.reservedMemory, + }, + }) + ); + + await render(hbs``); + + assert.notOk(RecommendationCard.togglesTable.toggleAllIsPresent); + assert.notOk(RecommendationCard.togglesTable.toggleAllCPU.isPresent); + assert.notOk(RecommendationCard.togglesTable.toggleAllMemory.isPresent); + }); + + test('it disables the accept button when all recommendations are disabled', async function(assert) { + const task1 = { + name: 'jortle', + reservedCPU: 150, + reservedMemory: 128, + }; + + this.set( + 'summary', + new MockRecommendationSummary({ + recommendations: [ + { + resource: 'CPU', + stats: {}, + task: task1, + value: 50, + }, + { + resource: 'MemoryMB', + stats: {}, + task: task1, + value: 192, + }, + ], + + taskGroup: { + count: 1, + reservedCPU: task1.reservedCPU, + reservedMemory: task1.reservedMemory, + }, + }) + ); + + await render(hbs``); + + await RecommendationCard.togglesTable.tasks[0].cpu.toggle(); + await RecommendationCard.togglesTable.tasks[0].memory.toggle(); + + assert.ok(RecommendationCard.acceptButton.isDisabled); + }); + + test('it doesn’t show a toggle or chart when there’s no recommendation for that resource', async function(assert) { + const task1 = { + name: 'jortle', + reservedCPU: 150, + reservedMemory: 128, + }; + + this.set( + 'summary', + new MockRecommendationSummary({ + recommendations: [ + { + resource: 'CPU', + stats: {}, + task: task1, + value: 50, + }, + ], + + taskGroup: { + count: 2, + name: 'group-name', + job: { + name: 'job-name', + }, + reservedCPU: task1.reservedCPU, + reservedMemory: task1.reservedMemory, + }, + }) + ); + + await render(hbs``); + + assert.equal(RecommendationCard.totalsTable.recommended.memory.text, '128 MiB'); + assert.equal(RecommendationCard.totalsTable.unitDiff.memory, '0 MiB'); + assert.equal(RecommendationCard.totalsTable.percentDiff.memory, '+0%'); + + assert.equal( + RecommendationCard.narrative.trim(), + 'Applying the selected recommendations will save an aggregate 200 MHz of CPU across 2 allocations.' + ); + + assert.ok(RecommendationCard.togglesTable.tasks[0].memory.isDisabled); + assert.notOk(RecommendationCard.activeTask.memoryChart.isPresent); + }); + + test('it disables a resource’s toggle all toggle when there are no recommendations for it', async function(assert) { + const task1 = { + name: 'jortle', + reservedCPU: 150, + reservedMemory: 128, + }; + + const task2 = { + name: 'tortle', + reservedCPU: 150, + reservedMemory: 128, + }; + + this.set( + 'summary', + new MockRecommendationSummary({ + recommendations: [ + { + resource: 'CPU', + stats: {}, + task: task1, + value: 50, + }, + { + resource: 'CPU', + stats: {}, + task: task2, + value: 50, + }, + ], + + taskGroup: { + count: 2, + name: 'group-name', + job: { + name: 'job-name', + }, + reservedCPU: task1.reservedCPU + task2.reservedCPU, + reservedMemory: task1.reservedMemory + task2.reservedMemory, + }, + }) + ); + + await render(hbs``); + + assert.ok(RecommendationCard.togglesTable.toggleAllMemory.isDisabled); + assert.notOk(RecommendationCard.togglesTable.toggleAllMemory.isActive); + assert.notOk(RecommendationCard.activeTask.memoryChart.isPresent); + }); + + test('it renders diff calculations in a sentence', async function(assert) { + const task1 = { + name: 'jortle', + reservedCPU: 150, + reservedMemory: 128, + }; + + const task2 = { + name: 'tortle', + reservedCPU: 125, + reservedMemory: 256, + }; + + this.set( + 'summary', + new MockRecommendationSummary({ + recommendations: [ + { + resource: 'CPU', + stats: {}, + task: task1, + value: 50, + currentValue: task1.reservedCPU, + }, + { + resource: 'MemoryMB', + stats: {}, + task: task1, + value: 192, + currentValue: task1.reservedMemory, + }, + { + resource: 'CPU', + stats: {}, + task: task2, + value: 150, + currentValue: task2.reservedCPU, + }, + { + resource: 'MemoryMB', + stats: {}, + task: task2, + value: 320, + currentValue: task2.reservedMemory, + }, + ], + + taskGroup: { + count: 10, + name: 'group-name', + job: { + name: 'job-name', + namespace: { + name: 'namespace', + }, + }, + reservedCPU: task1.reservedCPU + task2.reservedCPU, + reservedMemory: task1.reservedMemory + task2.reservedMemory, + }, + }) + ); + + await render(hbs``); + + const [cpuRec1, memRec1, cpuRec2, memRec2] = this.summary.recommendations; + + assert.equal( + RecommendationCard.narrative.trim(), + 'Applying the selected recommendations will save an aggregate 750 MHz of CPU and add an aggregate 1.25 GiB of memory across 10 allocations.' + ); + + this.summary.toggleRecommendation(cpuRec1); + await settled(); + + assert.equal( + RecommendationCard.narrative.trim(), + 'Applying the selected recommendations will add an aggregate 250 MHz of CPU and 1.25 GiB of memory across 10 allocations.' + ); + + this.summary.toggleRecommendation(memRec1); + await settled(); + + assert.equal( + RecommendationCard.narrative.trim(), + 'Applying the selected recommendations will add an aggregate 250 MHz of CPU and 640 MiB of memory across 10 allocations.' + ); + + this.summary.toggleRecommendation(cpuRec2); + await settled(); + + assert.equal( + RecommendationCard.narrative.trim(), + 'Applying the selected recommendations will add an aggregate 640 MiB of memory across 10 allocations.' + ); + + this.summary.toggleRecommendation(cpuRec1); + this.summary.toggleRecommendation(memRec2); + await settled(); + + assert.equal( + RecommendationCard.narrative.trim(), + 'Applying the selected recommendations will save an aggregate 1000 MHz of CPU across 10 allocations.' + ); + + this.summary.toggleRecommendation(cpuRec1); + await settled(); + + assert.equal(RecommendationCard.narrative.trim(), ''); + + this.summary.toggleRecommendation(cpuRec1); + await settled(); + + assert.equal( + RecommendationCard.narrative.trim(), + 'Applying the selected recommendations will save an aggregate 1000 MHz of CPU across 10 allocations.' + ); + + this.summary.toggleRecommendation(memRec2); + set(memRec2, 'value', 128); + await settled(); + + assert.equal( + RecommendationCard.narrative.trim(), + 'Applying the selected recommendations will save an aggregate 1000 MHz of CPU and 1.25 GiB of memory across 10 allocations.' + ); + }); + + test('it renders diff calculations in a sentence with no aggregation for one allocatio', async function(assert) { + const task1 = { + name: 'jortle', + reservedCPU: 150, + reservedMemory: 128, + }; + + const task2 = { + name: 'tortle', + reservedCPU: 125, + reservedMemory: 256, + }; + + this.set( + 'summary', + new MockRecommendationSummary({ + recommendations: [ + { + resource: 'CPU', + stats: {}, + task: task1, + value: 50, + currentValue: task1.reservedCPU, + }, + { + resource: 'MemoryMB', + stats: {}, + task: task1, + value: 192, + currentValue: task1.reservedMemory, + }, + { + resource: 'CPU', + stats: {}, + task: task2, + value: 150, + currentValue: task2.reservedCPU, + }, + { + resource: 'MemoryMB', + stats: {}, + task: task2, + value: 320, + currentValue: task2.reservedMemory, + }, + ], + + taskGroup: { + count: 1, + name: 'group-name', + job: { + name: 'job-name', + namespace: { + name: 'namespace', + }, + }, + reservedCPU: task1.reservedCPU + task2.reservedCPU, + reservedMemory: task1.reservedMemory + task2.reservedMemory, + }, + }) + ); + + await render(hbs``); + + assert.equal( + RecommendationCard.narrative.trim(), + 'Applying the selected recommendations will save 75 MHz of CPU and add 128 MiB of memory.' + ); + }); +}); + +class MockRecommendationSummary { + @tracked excludedRecommendations = []; + + constructor(attributes) { + Object.assign(this, attributes); + } + + @action + toggleRecommendation(recommendation) { + if (this.excludedRecommendations.includes(recommendation)) { + this.excludedRecommendations.removeObject(recommendation); + } else { + this.excludedRecommendations.pushObject(recommendation); + } + } + + @action + toggleAllRecommendationsForResource(resource, enabled) { + if (enabled) { + this.excludedRecommendations = this.excludedRecommendations.rejectBy('resource', resource); + } else { + this.excludedRecommendations.pushObjects(this.recommendations.filterBy('resource', resource)); + } + } +} diff --git a/ui/tests/integration/components/das/recommendation-chart-test.js b/ui/tests/integration/components/das/recommendation-chart-test.js new file mode 100644 index 000000000..a48c9856e --- /dev/null +++ b/ui/tests/integration/components/das/recommendation-chart-test.js @@ -0,0 +1,194 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, triggerEvent } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit'; + +module('Integration | Component | das/recommendation-chart', function(hooks) { + setupRenderingTest(hooks); + + test('it renders a chart for a recommended CPU increase', async function(assert) { + this.set('resource', 'CPU'); + this.set('current', 1312); + this.set('recommended', 1919); + this.set('stats', {}); + + await render( + hbs`` + ); + + assert.dom('.recommendation-chart.increase').exists(); + assert.dom('.recommendation-chart .resource').hasText('CPU'); + assert.dom('.recommendation-chart .icon-is-arrow-up').exists(); + assert.dom('text.percent').hasText('+46%'); + await componentA11yAudit(this.element, assert); + }); + + test('it renders a chart for a recommended memory decrease', async function(assert) { + this.set('resource', 'MemoryMB'); + this.set('current', 1919); + this.set('recommended', 1312); + this.set('stats', {}); + + await render( + hbs`` + ); + + assert.dom('.recommendation-chart.decrease').exists(); + assert.dom('.recommendation-chart .resource').hasText('Mem'); + assert.dom('.recommendation-chart .icon-is-arrow-down').exists(); + assert.dom('text.percent').hasText('-32%'); + await componentA11yAudit(this.element, assert); + }); + + test('it handles the maximum being far beyond the recommended', async function(assert) { + this.set('resource', 'CPU'); + this.set('current', 1312); + this.set('recommended', 1919); + this.set('stats', { + max: 3000, + }); + + await render( + hbs`` + ); + + const chartSvg = this.element.querySelector('.recommendation-chart svg'); + const maxLine = chartSvg.querySelector('line.stat.max'); + + assert.ok(maxLine.getAttribute('x1') < chartSvg.clientWidth); + }); + + test('it can be disabled and will show no delta', async function(assert) { + this.set('resource', 'CPU'); + this.set('current', 1312); + this.set('recommended', 1919); + this.set('stats', {}); + + await render( + hbs`` + ); + + assert.dom('.recommendation-chart.disabled'); + assert.dom('.recommendation-chart.increase').doesNotExist(); + assert.dom('.recommendation-chart rect.delta').doesNotExist(); + assert.dom('.recommendation-chart .changes').doesNotExist(); + assert.dom('.recommendation-chart .resource').hasText('CPU'); + assert.dom('.recommendation-chart .icon-is-arrow-up').exists(); + await componentA11yAudit(this.element, assert); + }); + + test('the stats labels shift aligment and disappear to account for space', async function(assert) { + this.set('resource', 'CPU'); + this.set('current', 50); + this.set('recommended', 100); + + this.set('stats', { + mean: 5, + p99: 99, + max: 100, + }); + + await render( + hbs`` + ); + + assert.dom('[data-test-label=max]').hasClass('right'); + + this.set('stats', { + mean: 5, + p99: 6, + max: 100, + }); + + assert.dom('[data-test-label=max]').hasNoClass('right'); + assert.dom('[data-test-label=p99]').hasClass('right'); + + this.set('stats', { + mean: 5, + p99: 6, + max: 7, + }); + + assert.dom('[data-test-label=max]').hasClass('right'); + assert.dom('[data-test-label=p99]').hasClass('hidden'); + }); + + test('a legend tooltip shows the sorted stats values on hover', async function(assert) { + this.set('resource', 'CPU'); + this.set('current', 50); + this.set('recommended', 101); + + this.set('stats', { + mean: 5, + p99: 99, + max: 100, + min: 1, + median: 55, + }); + + await render( + hbs`` + ); + + assert.dom('.chart-tooltip').isNotVisible(); + + await triggerEvent('.recommendation-chart', 'mousemove'); + + assert.dom('.chart-tooltip').isVisible(); + + assert.dom('.chart-tooltip li:nth-child(1)').hasText('Min 1'); + assert.dom('.chart-tooltip li:nth-child(2)').hasText('Mean 5'); + assert.dom('.chart-tooltip li:nth-child(3)').hasText('Current 50'); + assert.dom('.chart-tooltip li:nth-child(4)').hasText('Median 55'); + assert.dom('.chart-tooltip li:nth-child(5)').hasText('99th 99'); + assert.dom('.chart-tooltip li:nth-child(6)').hasText('Max 100'); + assert.dom('.chart-tooltip li:nth-child(7)').hasText('New 101'); + + assert.dom('.chart-tooltip li.active').doesNotExist(); + + await triggerEvent('.recommendation-chart text.changes.new', 'mouseenter'); + assert.dom('.chart-tooltip li:nth-child(7).active').exists(); + + await triggerEvent('.recommendation-chart line.stat.max', 'mouseenter'); + assert.dom('.chart-tooltip li:nth-child(6).active').exists(); + + await triggerEvent('.recommendation-chart rect.stat.p99', 'mouseenter'); + assert.dom('.chart-tooltip li:nth-child(5).active').exists(); + + await triggerEvent('.recommendation-chart', 'mouseleave'); + + assert.dom('.chart-tooltip').isNotVisible(); + }); +}); diff --git a/ui/tests/integration/components/list-table-test.js b/ui/tests/integration/components/list-table-test.js index 96b4f1c26..54a5e9a76 100644 --- a/ui/tests/integration/components/list-table-test.js +++ b/ui/tests/integration/components/list-table-test.js @@ -42,11 +42,12 @@ module('Integration | Component | list table', function(hooks) { }); await render(hbs` - + {{row.model.firstName}} {{row.model.lastName}} {{row.model.age}} + {{index}} @@ -64,6 +65,7 @@ module('Integration | Component | list table', function(hooks) { assert.equal($item.querySelectorAll('td')[0].innerHTML.trim(), item.firstName, 'First name'); assert.equal($item.querySelectorAll('td')[1].innerHTML.trim(), item.lastName, 'Last name'); assert.equal($item.querySelectorAll('td')[2].innerHTML.trim(), item.age, 'Age'); + assert.equal($item.querySelectorAll('td')[3].innerHTML.trim(), index, 'Index'); }); await componentA11yAudit(this.element, assert); diff --git a/ui/tests/pages/components/recommendation-accordion.js b/ui/tests/pages/components/recommendation-accordion.js new file mode 100644 index 000000000..ce7542049 --- /dev/null +++ b/ui/tests/pages/components/recommendation-accordion.js @@ -0,0 +1,13 @@ +import { text } from 'ember-cli-page-object'; + +import recommendationCard from 'nomad-ui/tests/pages/components/recommendation-card'; + +export default { + group: text('[data-test-group]'), + + toggleButton: { + scope: '.accordion-toggle', + }, + + card: recommendationCard, +}; diff --git a/ui/tests/pages/components/recommendation-card.js b/ui/tests/pages/components/recommendation-card.js new file mode 100644 index 000000000..cbb289dec --- /dev/null +++ b/ui/tests/pages/components/recommendation-card.js @@ -0,0 +1,107 @@ +import { attribute, collection, hasClass, isPresent, text } from 'ember-cli-page-object'; +import { getter } from 'ember-cli-page-object/macros'; + +import toggle from 'nomad-ui/tests/pages/components/toggle'; + +export default { + scope: '[data-test-task-group-recommendations]', + + slug: { + jobName: text('[data-test-job-name]'), + groupName: text('[data-test-task-group-name]'), + }, + + namespace: text('[data-test-namespace]'), + + totalsTable: totalsTableComponent('[data-test-group-totals]'), + + narrative: text('[data-test-narrative]'), + + togglesTable: { + scope: '[data-test-toggles-table]', + + toggleAllIsPresent: isPresent('[data-test-toggle-all]'), + toggleAllCPU: toggle('[data-test-tasks-head] [data-test-cpu-toggle]'), + toggleAllMemory: toggle('[data-test-tasks-head] [data-test-memory-toggle]'), + + tasks: collection('[data-test-task-toggles]', { + name: text('[data-test-name]'), + cpu: toggle('[data-test-cpu-toggle]'), + memory: toggle('[data-test-memory-toggle]'), + + isActive: hasClass('active'), + }), + }, + + activeTask: { + scope: '[data-test-active-task]', + + name: text('[data-test-task-name]'), + totalsTable: totalsTableComponent(''), + + charts: collection('[data-test-chart-for]', { + resource: text('text.resource'), + }), + + cpuChart: resourceChartComponent('[data-test-chart-for=CPU]'), + memoryChart: resourceChartComponent('[data-test-chart-for=MemoryMB]'), + }, + + acceptButton: { + scope: '[data-test-accept]', + isDisabled: attribute('disabled'), + }, + + dismissButton: { + scope: '[data-test-dismiss]', + }, +}; + +function totalsTableCell(scope) { + return { + scope, + isIncrease: hasClass('increase'), + isDecrease: hasClass('decrease'), + isNeutral: getter(function() { + return !this.isIncrease && !this.isDecrease; + }), + }; +} + +function totalsTableComponent(scope) { + return { + scope, + + current: { + scope: '[data-test-current]', + cpu: totalsTableCell('[data-test-cpu]'), + memory: totalsTableCell('[data-test-memory]'), + }, + + recommended: { + scope: '[data-test-recommended]', + cpu: totalsTableCell('[data-test-cpu]'), + memory: totalsTableCell('[data-test-memory]'), + }, + + unitDiff: { + cpu: text('[data-test-cpu-unit-diff]'), + memory: text('[data-test-memory-unit-diff]'), + }, + + percentDiff: { + cpu: text('[data-test-cpu-percent-diff]'), + memory: text('[data-test-memory-percent-diff]'), + }, + }; +} + +function resourceChartComponent(scope) { + return { + scope, + + isIncrease: hasClass('increase'), + isDecrease: hasClass('decrease'), + isDisabled: hasClass('disabled'), + }; +} diff --git a/ui/tests/pages/components/toggle.js b/ui/tests/pages/components/toggle.js index a4f1f6e3f..cdd1342cf 100644 --- a/ui/tests/pages/components/toggle.js +++ b/ui/tests/pages/components/toggle.js @@ -11,6 +11,7 @@ export default scope => ({ hasActiveClass: hasClass('is-active', '[data-test-label]'), label: text('[data-test-label]'), + title: attribute('title'), toggle: clickable('[data-test-input]'), }); diff --git a/ui/tests/pages/jobs/detail.js b/ui/tests/pages/jobs/detail.js index e2af9a0b8..fa17971a3 100644 --- a/ui/tests/pages/jobs/detail.js +++ b/ui/tests/pages/jobs/detail.js @@ -12,6 +12,7 @@ import { import allocations from 'nomad-ui/tests/pages/components/allocations'; import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button'; +import recommendationAccordion from 'nomad-ui/tests/pages/components/recommendation-accordion'; export default create({ visit: visitable('/jobs/:id'), @@ -25,6 +26,8 @@ export default create({ return this.tabs.toArray().findBy('id', id); }, + recommendations: collection('[data-test-recommendation-accordion]', recommendationAccordion), + stop: twoStepButton('[data-test-stop]'), start: twoStepButton('[data-test-start]'), diff --git a/ui/tests/pages/layout.js b/ui/tests/pages/layout.js index e89aba53f..639c55ef5 100644 --- a/ui/tests/pages/layout.js +++ b/ui/tests/pages/layout.js @@ -61,6 +61,11 @@ export default create({ }), }, visitJobs: clickable('[data-test-gutter-link="jobs"]'), + + optimize: { + scope: '[data-test-gutter-link="optimize"]', + }, + visitClients: clickable('[data-test-gutter-link="clients"]'), visitServers: clickable('[data-test-gutter-link="servers"]'), visitStorage: clickable('[data-test-gutter-link="storage"]'), diff --git a/ui/tests/pages/optimize.js b/ui/tests/pages/optimize.js new file mode 100644 index 000000000..d2c4dab4f --- /dev/null +++ b/ui/tests/pages/optimize.js @@ -0,0 +1,52 @@ +import { + attribute, + clickable, + collection, + create, + hasClass, + text, + visitable, +} from 'ember-cli-page-object'; + +import recommendationCard from 'nomad-ui/tests/pages/components/recommendation-card'; + +export default create({ + visit: visitable('/optimize'), + + breadcrumbs: collection('[data-test-breadcrumb]', { + id: attribute('data-test-breadcrumb'), + text: text(), + }), + + breadcrumbFor(id) { + return this.breadcrumbs.toArray().find(crumb => crumb.id === id); + }, + + card: recommendationCard, + + recommendationSummaries: collection('[data-test-recommendation-summary-row]', { + isActive: hasClass('is-active'), + isDisabled: hasClass('is-disabled'), + + slug: text('[data-test-slug]'), + namespace: text('[data-test-namespace]'), + date: text('[data-test-date]'), + allocationCount: text('[data-test-allocation-count]'), + cpu: text('[data-test-cpu]'), + memory: text('[data-test-memory]'), + aggregateCpu: text('[data-test-aggregate-cpu]'), + aggregateMemory: text('[data-test-aggregate-memory]'), + }), + + empty: { + scope: '[data-test-empty-recommendations]', + headline: text('[data-test-empty-recommendations-headline]'), + }, + + error: { + scope: '[data-test-recommendation-error]', + headline: text('[data-test-headline]'), + errors: text('[data-test-errors]'), + dismiss: clickable('[data-test-dismiss]'), + }, +}); diff --git a/ui/tests/unit/abilities/recommendation-test.js b/ui/tests/unit/abilities/recommendation-test.js new file mode 100644 index 000000000..a80db7496 --- /dev/null +++ b/ui/tests/unit/abilities/recommendation-test.js @@ -0,0 +1,55 @@ +/* eslint-disable ember/avoid-leaking-state-in-ember-objects */ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import Service from '@ember/service'; +import setupAbility from 'nomad-ui/tests/helpers/setup-ability'; + +module('Unit | Ability | recommendation', function(hooks) { + setupTest(hooks); + setupAbility('recommendation')(hooks); + + test('it permits accepting recommendations when ACLs are disabled', function(assert) { + const mockToken = Service.extend({ + aclEnabled: false, + }); + + this.owner.register('service:token', mockToken); + + assert.ok(this.ability.canAccept); + }); + + test('it permits accepting recommendations for client tokens where any namespace has submit-job capabilities', function(assert) { + const mockSystem = Service.extend({ + aclEnabled: true, + activeNamespace: { + name: 'anotherNamespace', + }, + }); + + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'client' }, + selfTokenPolicies: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'aNamespace', + Capabilities: [], + }, + { + Name: 'bNamespace', + Capabilities: ['submit-job'], + }, + ], + }, + }, + ], + }); + + this.owner.register('service:system', mockSystem); + this.owner.register('service:token', mockToken); + + assert.ok(this.ability.canAccept); + }); +}); diff --git a/ui/tests/unit/serializers/recommendation-summary-test.js b/ui/tests/unit/serializers/recommendation-summary-test.js new file mode 100644 index 000000000..fd7683279 --- /dev/null +++ b/ui/tests/unit/serializers/recommendation-summary-test.js @@ -0,0 +1,213 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import RecommendationSummaryModel from 'nomad-ui/models/recommendation-summary'; + +module('Unit | Serializer | RecommendationSummary', function(hooks) { + setupTest(hooks); + hooks.beforeEach(function() { + this.store = this.owner.lookup('service:store'); + this.subject = () => this.store.serializerFor('recommendation-summary'); + }); + + const normalizationTestCases = [ + { + name: 'Normal', + in: [ + { + ID: '2345', + JobID: 'job-id', + Namespace: 'default', + Region: 'us-east-1', + Group: 'group-1', + Task: 'task-1', + Resource: 'MemoryMB', + Value: 500, + Current: 1000, + Stats: { + min: 25.0, + max: 575.0, + mean: 425.0, + media: 40.0, + }, + SubmitTime: 1600000002000000000, + }, + { + ID: '1234', + JobID: 'job-id', + Namespace: 'default', + Region: 'us-east-1', + Group: 'group-1', + Task: 'task-1', + Resource: 'CPU', + Value: 500, + Current: 1000, + Stats: { + min: 25.0, + max: 575.0, + mean: 425.0, + media: 40.0, + }, + SubmitTime: 1600000001000000000, + }, + { + ID: '6789', + JobID: 'other-job-id', + Namespace: 'other', + Region: 'us-east-1', + Group: 'group-2', + Task: 'task-2', + Resource: 'MemoryMB', + Value: 500, + Current: 1000, + Stats: { + min: 25.0, + max: 575.0, + mean: 425.0, + media: 40.0, + }, + SubmitTime: 1600000003000000000, + }, + ], + out: { + data: [ + { + attributes: { + jobId: 'job-id', + jobNamespace: 'default', + submitTime: new Date(1600000002000), + taskGroupName: 'group-1', + }, + id: '1234-2345', + relationships: { + job: { + data: { + id: '["job-id","default"]', + type: 'job', + }, + }, + recommendations: { + data: [ + { + id: '2345', + type: 'recommendation', + }, + { + id: '1234', + type: 'recommendation', + }, + ], + }, + }, + type: 'recommendation-summary', + }, + { + attributes: { + jobId: 'other-job-id', + jobNamespace: 'other', + submitTime: new Date(1600000003000), + taskGroupName: 'group-2', + }, + id: '6789', + relationships: { + job: { + data: { + id: '["other-job-id","other"]', + type: 'job', + }, + }, + recommendations: { + data: [ + { + id: '6789', + type: 'recommendation', + }, + ], + }, + }, + type: 'recommendation-summary', + }, + ], + included: [ + { + attributes: { + resource: 'MemoryMB', + stats: { + max: 575, + mean: 425, + media: 40, + min: 25, + }, + submitTime: new Date(1600000002000), + taskName: 'task-1', + value: 500, + }, + id: '2345', + relationships: { + job: { + links: { + related: '/v1/job/job-id', + }, + }, + }, + type: 'recommendation', + }, + { + attributes: { + resource: 'CPU', + stats: { + max: 575, + mean: 425, + media: 40, + min: 25, + }, + submitTime: new Date(1600000001000), + taskName: 'task-1', + value: 500, + }, + id: '1234', + relationships: { + job: { + links: { + related: '/v1/job/job-id', + }, + }, + }, + type: 'recommendation', + }, + { + attributes: { + resource: 'MemoryMB', + stats: { + max: 575, + mean: 425, + media: 40, + min: 25, + }, + submitTime: new Date(1600000003000), + taskName: 'task-2', + value: 500, + }, + id: '6789', + relationships: { + job: { + links: { + related: '/v1/job/other-job-id?namespace=other', + }, + }, + }, + type: 'recommendation', + }, + ], + }, + }, + ]; + + normalizationTestCases.forEach(testCase => { + test(`normalization: ${testCase.name}`, async function(assert) { + assert.deepEqual( + this.subject().normalizeArrayResponse(this.store, RecommendationSummaryModel, testCase.in), + testCase.out + ); + }); + }); +}); diff --git a/ui/yarn.lock b/ui/yarn.lock index 4df1697a3..724335d5d 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -9752,10 +9752,10 @@ ember-decorators@^6.1.1: "@ember-decorators/object" "^6.1.1" ember-cli-babel "^7.7.3" -ember-destroyable-polyfill@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/ember-destroyable-polyfill/-/ember-destroyable-polyfill-2.0.1.tgz#391cd95a99debaf24148ce953054008d00f151a6" - integrity sha512-hyK+/GPWOIxM1vxnlVMknNl9E5CAFVbcxi8zPiM0vCRwHiFS8Wuj7PfthZ1/OFitNNv7ITTeU8hxqvOZVsrbnQ== +ember-destroyable-polyfill@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/ember-destroyable-polyfill/-/ember-destroyable-polyfill-2.0.2.tgz#2cc7532bd3c00e351b4da9b7fc683f4daff79671" + integrity sha512-9t+ya+9c+FkNM5IAyJIv6ETG8jfZQaUnFCO5SeLlV0wkSw7TOexyb61jh5GVee0KmknfRhrRGGAyT4Y0TwkZ+w== dependencies: ember-cli-babel "^7.22.1" ember-cli-version-checker "^5.1.1" @@ -9889,16 +9889,16 @@ ember-modifier-manager-polyfill@^1.1.0, ember-modifier-manager-polyfill@^1.2.0: ember-cli-version-checker "^2.1.2" ember-compatibility-helpers "^1.2.0" -ember-modifier@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-2.1.0.tgz#99d85995caad8789220dc3208fb5ded45647dccf" - integrity sha512-tVmRcEloYg8AZHheEMhBhzX64r7n6AFLXG69L/jiHePvQzet9mjV18YiIPStQf+fdjTAO25S6yzNPDP3zQjWtQ== +ember-modifier@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-2.1.1.tgz#aa3a12e2d6cf1622f774f3f1eab4880982a43fa9" + integrity sha512-g9mcpFWgw5lgNU40YNf0USNWqoGTJ+EqjDQKjm7556gaRNDeGnLylFKqx9O3opwLHEt6ZODnRDy9U0S5YEMREg== dependencies: ember-cli-babel "^7.22.1" ember-cli-normalize-entity-name "^1.0.0" ember-cli-string-utils "^1.1.0" ember-cli-typescript "^3.1.3" - ember-destroyable-polyfill "^2.0.1" + ember-destroyable-polyfill "^2.0.2" ember-modifier-manager-polyfill "^1.2.0" ember-moment@^7.8.1: