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}} MiB |
+ Difference |
+ {{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}}
+
+
+
+
+
+
+
+
+
+
+
+ {{#if this.showToggleAllToggles}}
+ Task |
+ Toggle All |
+
+
+ CPU
+
+ |
+
+
+ Mem
+
+ |
+ {{else}}
+ Task |
+ CPU |
+ Mem |
+ {{/if}}
+
+
+
+ {{#each this.taskToggleRows key="task.name" as |taskToggleRow index|}}
+
+ {{/each}}
+
+
+
+
+
+
+
+ {{#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 @@
+
+
+
+
+
+
+
\ 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}}