Add DAS UI code from enterprise (#9192)

This is a few combined iterations on the DAS feature.
This commit is contained in:
Buck Doyle 2020-10-29 07:46:42 -05:00 committed by GitHub
parent 57f694ff2e
commit 31b4ed7a6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 4167 additions and 18 deletions

View File

@ -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') || [];

View File

@ -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');
}
}

View File

@ -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 });
}
}

View File

@ -0,0 +1,7 @@
<section class="das-accepted">
<main>
<h3>Recommendation accepted</h3>
<p>A new version of this job will now be deployed.</p>
</main>
{{x-icon "check-circle-fill"}}
</section>

View File

@ -0,0 +1,20 @@
<table class='diffs-table' ...attributes>
<tbody>
<tr data-test-current>
<th>Current</th>
<td data-test-cpu>{{@model.reservedCPU}} MHz</td>
<td data-test-memory>{{@model.reservedMemory}} MiB</td>
<th class='diff'>Difference</th>
<td class='diff' data-test-cpu-unit-diff>{{this.diffs.cpu.signedDiff}}</td>
<td class='diff' data-test-memory-unit-diff>{{this.diffs.memory.signedDiff}}</td>
</tr>
<tr data-test-recommended>
<th>Recommended</th>
<td data-test-cpu class={{this.cpuClass}}>{{this.diffs.cpu.recommended}} MHz</td>
<td data-test-memory class={{this.memoryClass}}>{{this.diffs.memory.recommended}} MiB</td>
<th class='diff'>% Difference</th>
<td class='diff' data-test-cpu-percent-diff>{{this.diffs.cpu.percentDiff}}</td>
<td class='diff' data-test-memory-percent-diff>{{this.diffs.memory.percentDiff}}</td>
</tr>
</tbody>
</table>

View File

@ -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 '';
}

View File

@ -0,0 +1,30 @@
<section class="das-dismissed {{if this.explanationUnderstood 'understood'}}">
{{#if this.explanationUnderstood}}
<h3 {{did-insert this.proceedAutomatically}}>Recommendation dismissed</h3>
{{else}}
<section>
<h3>Recommendation dismissed</h3>
<p>Nomad will not apply these resource change recommendations.</p>
<p>To never get recommendations for this task group again, disable dynamic application sizing in the job definition.</p>
</section>
<section class="actions">
<button
data-test-understood
class='button is-info'
type='button'
{{@on 'click' this.understoodClicked}}
>Understood</button>
<label>
<input
type="checkbox"
checked={{this.dismissInTheFuture}}
onchange={{toggle-action 'dismissInTheFuture' this}}
/>
Dont show this again
</label>
</section>
{{/if}}
</section>

View File

@ -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 });
}
}

View File

@ -0,0 +1,22 @@
<section class="das-error" data-test-recommendation-error>
<section>
<h3 data-test-headline>Recommendation error</h3>
<p>
There were errors processing applications:
</p>
<pre data-test-errors>{{@error}}</pre>
</section>
{{x-icon "alert-circle-fill"}}
<section class="actions">
<button
data-test-dismiss
class='button is-light'
type='button'
{{@on 'click' this.dismissClicked}}
>Okay</button>
</section>
</section>

View File

@ -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 });
}
}

View File

@ -0,0 +1,46 @@
{{#if this.show}}
<ListAccordion
data-test-recommendation-accordion
class="recommendation-accordion boxed-section {{if this.closing "closing"}}"
@source={{array @summary}}
@key="id"
{{did-insert this.inserted}}
as |a|>
{{#if a.isOpen}}
<div class="animation-container" style={{this.animationContainerStyle}}>
<Das::RecommendationCard
@summary={{@summary}}
@proceed={{this.proceed}}
@onCollapse={{action (mut a.isOpen) false}}
@skipReset=true
/>
</div>
{{else}}
<a.head @buttonLabel={{unless a.isOpen "Show"}}>
<section class="left">
{{x-icon "info-circle-fill"}}
<span>Resource Recommendation</span>
<span data-test-group class="group">{{@summary.taskGroup.name}}</span>
</section>
<section class="diffs">
{{#if this.diffs.cpu.delta}}
<section>
<span class="resource">CPU</span>
{{this.diffs.cpu.signedDiff}}
<span class="percent">{{this.diffs.cpu.percentDiff}}</span>
</section>
{{/if}}
{{#if this.diffs.memory.delta}}
<section>
<span class="resource">Mem</span>
{{this.diffs.memory.signedDiff}}
<span class="percent">{{this.diffs.memory.percentDiff}}</span>
</section>
{{/if}}
</section>
</a.head>
{{/if}}
</ListAccordion>
{{/if}}

View File

@ -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
);
}
}

View File

@ -0,0 +1,136 @@
{{#if this.interstitialComponent}}
<section class="das-interstitial" style={{this.interstitialStyle}}>
{{component (concat 'das/' this.interstitialComponent) proceed=this.proceedPromiseResolve error=this.error}}
</section>
{{else}}
<section
...attributes
data-test-task-group-recommendations
class='recommendation-card'
{{will-destroy this.recommendationCardDestroying}}
>
<h2 class="top overview inner-container">Resource Recommendation</h2>
<header class="overview inner-container">
<h3 class="slug">
<span class="job" data-test-job-name>{{@summary.taskGroup.job.name}}</span>
<span class="group" data-test-task-group-name>{{@summary.taskGroup.name}}</span>
</h3>
<h4 class="namespace">
<span class="namespace-label">Namespace:</span> <span data-test-namespace>{{@summary.taskGroup.job.namespace.name}}</span>
</h4>
</header>
<section class="diffs overview inner-container">
<Das::DiffsTable
data-test-group-totals
@model={{@summary.taskGroup}}
@recommendations={{@summary.recommendations}}
@excludedRecommendations={{@summary.excludedRecommendations}}
/>
</section>
<section class="narrative overview inner-container">
<p data-test-narrative>{{this.narrative}}</p>
</section>
<section class="main overview inner-container task-toggles">
<table data-test-toggles-table>
<thead data-test-tasks-head>
<tr>
{{#if this.showToggleAllToggles}}
<th>Task</th>
<th class="toggle-all">Toggle All</th>
<th class="toggle-cell">
<Toggle
data-test-cpu-toggle
@isActive={{and this.allCpuToggleActive (not this.allCpuToggleDisabled)}}
@isDisabled={{this.allCpuToggleDisabled}}
@onToggle={{action this.toggleAllRecommendationsForResource 'CPU'}}
title='Toggle CPU recommendations for all tasks'
>
<div class="label-wrapper">CPU</div>
</Toggle>
</th>
<th class="toggle-cell">
<Toggle
data-test-memory-toggle
@isActive={{and this.allMemoryToggleActive (not this.allMemoryToggleDisabled)}}
@isDisabled={{this.allMemoryToggleDisabled}}
@onToggle={{action this.toggleAllRecommendationsForResource 'MemoryMB'}}
title='Toggle memory recommendations for all tasks'
>
<div class="label-wrapper">Mem</div>
</Toggle>
</th>
{{else}}
<th colspan="2">Task</th>
<th class="toggle-cell">CPU</th>
<th class="toggle-cell">Mem</th>
{{/if}}
</tr>
</thead>
<tbody>
{{#each this.taskToggleRows key="task.name" as |taskToggleRow index|}}
<Das::TaskRow
@task={{taskToggleRow.task}}
@active={{eq this.activeTaskToggleRowIndex index}}
@cpu={{taskToggleRow.cpu}}
@memory={{taskToggleRow.memory}}
@onClick={{action (mut this.activeTaskToggleRowIndex) index}}
@toggleRecommendation={{@summary.toggleRecommendation}}
/>
{{/each}}
</tbody>
</table>
</section>
<section class="actions overview inner-container">
<button class='button is-primary' type='button' disabled={{this.cannotAccept}} data-test-accept {{on "click" this.accept}}>Accept</button>
<button class='button is-light' type='button' data-test-dismiss {{on "click" this.dismiss}}>Dismiss</button>
</section>
<section class="active-task-group" data-test-active-task>
{{#if @onCollapse}}
<section class="top active-task inner-container">
<button
data-test-accordion-toggle
class="button is-light is-compact pull-right accordion-toggle"
{{on "click" @onCollapse}}
type="button">
Collapse
</button>
</section>
{{/if}}
<header class="active-task inner-container">
<h3 data-test-task-name>{{this.activeTask.name}} task</h3>
</header>
<section class="diffs active-task inner-container">
<Das::DiffsTable
@model={{this.activeTask}}
@recommendations={{this.activeTaskToggleRow.recommendations}}
@excludedRecommendations={{@summary.excludedRecommendations}}
/>
</section>
<ul class="main active-task inner-container">
{{#each this.activeTaskToggleRow.recommendations as |recommendation|}}
<li data-test-recommendation>
<Das::RecommendationChart
data-test-chart-for={{recommendation.resource}}
@resource={{recommendation.resource}}
@currentValue={{recommendation.currentValue}}
@recommendedValue={{recommendation.value}}
@stats={{recommendation.stats}}
@disabled={{contains recommendation @summary.excludedRecommendations}}
/>
</li>
{{/each}}
</ul>
</section>
</section>
{{/if}}

View File

@ -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 += ` <strong>${diffs.cpu.absoluteAggregateDiff} of CPU</strong>`;
}
if (cpuDelta && memoryDelta) {
narrative += ' and';
}
if (memoryDelta) {
if (!deltasSameDirection) {
narrative += ` ${verbForDelta(memoryDelta)} ${aggregateString}`;
}
narrative += ` <strong>${diffs.memory.absoluteAggregateDiff} of memory</strong>`;
}
if (taskGroup.count === 1) {
narrative += '.';
} else {
narrative += ` across <strong>${taskGroup.count} allocations</strong>.`;
}
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';
}
}

View File

@ -0,0 +1,159 @@
<div
...attributes
class="chart recommendation-chart {{this.directionClass}}"
{{did-insert this.onResize}}
{{window-resize this.onResize}}
{{on "mousemove" this.setLegendPosition}}
{{on "mouseleave" (action (mut this.showLegend) false)}}
>
<svg
class="chart"
height={{this.chartHeight}}
{{did-insert this.storeSvgElement}}
>
<svg
class="icon delta"
x={{this.icon.x}}
y={{this.icon.y}}
width={{this.icon.width}}
height={{this.icon.height}}
>
{{x-icon this.icon.name}}
</svg>
<text
class="resource"
alignment-baseline="central"
text-anchor="end"
x={{this.resourceLabel.x}}
y={{this.resourceLabel.y}}
>
{{this.resourceLabel.text}}
</text>
{{#if this.center}}
<line class="center" x1={{this.center.x1}} y1={{this.center.y1}} x2={{this.center.x2}} y2={{this.center.y2}} />
{{/if}}
{{#each this.statsShapes as |shapes|}}
<text
class="stats-label {{shapes.text.class}}"
text-anchor="end"
x={{shapes.text.x}}
y={{shapes.text.y}}
data-test-label={{shapes.class}}
{{on "mouseenter" (fn this.setActiveLegendRow shapes.text.label)}}
{{on "mouseleave" this.unsetActiveLegendRow}}
>
{{shapes.text.label}}
</text>
<rect
class="stat {{shapes.class}}"
x={{shapes.rect.x}}
width={{shapes.rect.width}}
y={{shapes.rect.y}}
height={{shapes.rect.height}}
{{on "mouseenter" (fn this.setActiveLegendRow shapes.text.label)}}
{{on "mouseleave" this.unsetActiveLegendRow}}
/>
<line
class="stat {{shapes.class}}"
x1={{shapes.line.x1}}
y1={{shapes.line.y1}}
x2={{shapes.line.x2}}
y2={{shapes.line.y2}}
{{on "mouseenter" (fn this.setActiveLegendRow shapes.text.label)}}
{{on "mouseleave" this.unsetActiveLegendRow}}
/>
{{/each}}
{{#unless @disabled}}
{{#if this.deltaRect.x}}
<rect
{{did-insert this.isShown}}
class="delta"
x={{this.deltaRect.x}}
y={{this.deltaRect.y}}
width={{this.deltaRect.width}}
height={{this.deltaRect.height}}
/>
<polygon
class="delta"
style={{this.deltaTriangle.style}}
points={{this.deltaTriangle.points}}
/>
<line
class="changes delta"
style={{this.deltaLines.delta.style}}
x1=0
y1={{this.edgeTickY1}}
x2=0
y2={{this.edgeTickY2}}
{{on "mouseenter" (fn this.setActiveLegendRow "New")}}
{{on "mouseleave" this.unsetActiveLegendRow}}
/>
<line
class="changes"
x1={{this.deltaLines.original.x}}
y1={{this.edgeTickY1}}
x2={{this.deltaLines.original.x}}
y2={{this.edgeTickY2}}
{{on "mouseenter" (fn this.setActiveLegendRow "Current")}}
{{on "mouseleave" this.unsetActiveLegendRow}}
/>
<text
class="changes"
text-anchor="{{this.deltaText.original.anchor}}"
x={{this.deltaText.original.x}}
y={{this.deltaText.original.y}}
{{on "mouseenter" (fn this.setActiveLegendRow "Current")}}
{{on "mouseleave" this.unsetActiveLegendRow}}
>
Current
</text>
<text
class="changes new"
text-anchor="{{this.deltaText.delta.anchor}}"
x={{this.deltaText.delta.x}}
y={{this.deltaText.delta.y}}
{{on "mouseenter" (fn this.setActiveLegendRow "New")}}
{{on "mouseleave" this.unsetActiveLegendRow}}
>
New
</text>
<text
class="changes percent"
x={{this.deltaText.percent.x}}
y={{this.deltaText.percent.y}}
>
{{this.deltaText.percent.text}}
</text>
{{/if}}
{{/unless}}
<line class="zero" x1={{this.gutterWidthLeft}} y1={{this.edgeTickY1}} x2={{this.gutterWidthLeft}} y2={{this.edgeTickY2}} />
</svg>
<div class="chart-tooltip {{if this.showLegend "active" "inactive"}}" style={{this.tooltipStyle}}>
<ol>
{{#each this.sortedStats as |stat|}}
<li class={{if (eq this.activeLegendRow stat.label) "active"}}>
<span class="label">
{{stat.label}}
</span>
<span class="value">{{stat.value}}</span>
</li>
{{/each}}
</ol>
</div>
</div>

View File

@ -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;
}
}

View File

@ -0,0 +1,48 @@
{{#if @summary.taskGroup.allocations.length}}
{{!-- Prevent storing aggregate diffs until allocation count is known --}}
<tr
class="recommendation-row"
...attributes
data-test-recommendation-summary-row
{{did-insert this.storeDiffs}}
>
<td>
<div data-test-slug>
<span class='job'>{{@summary.taskGroup.job.name}}</span>
/
<span class='task-group'>{{@summary.taskGroup.name}}</span>
</div>
<div class='namespace'>
Namespace: <span data-test-namespace>{{@summary.job.namespace.name}}</span>
</div>
</td>
<td data-test-date>
{{format-month-ts @summary.submitTime}}
</td>
<td data-test-allocation-count>
{{@summary.taskGroup.count}}
</td>
<td data-test-cpu>
{{#if this.cpu.delta}}
{{this.cpu.signedDiff}}
<span class='percent'>{{this.cpu.percentDiff}}</span>
{{/if}}
</td>
<td data-test-memory>
{{#if this.memory.delta}}
{{this.memory.signedDiff}}
<span class='percent'>{{this.memory.percentDiff}}</span>
{{/if}}
</td>
<td data-test-aggregate-cpu>
{{#if this.cpu.delta}}
{{this.cpu.signedAggregateDiff}}
{{/if}}
</td>
<td data-test-aggregate-memory>
{{#if this.memory.delta}}
{{this.memory.signedAggregateDiff}}
{{/if}}
</td>
</tr>
{{/if}}

View File

@ -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,
};
}
}

View File

@ -0,0 +1,33 @@
<tr
class={{if @active 'active'}}
{{on 'click' @onClick}}
{{did-insert this.calculateHeight}}
data-test-task-toggles
>
<td class="task-cell" data-test-name colspan="2">{{@task.name}}</td>
<td class="toggle-cell">
<Toggle
data-test-cpu-toggle
@isActive={{@cpu.isActive}}
@onToggle={{action @toggleRecommendation @cpu.recommendation}}
@isDisabled={{not @cpu.recommendation}}
title={{concat 'CPU for ' @task.name}}
/>
</td>
<td class="toggle-cell">
<Toggle
data-test-memory-toggle
@isActive={{@memory.isActive}}
@onToggle={{action @toggleRecommendation @memory.recommendation}}
@isDisabled={{not @memory.recommendation}}
title={{concat 'Memory for ' @task.name}}
/>
{{#if (and @active this.height)}}
<svg width={{this.height}} height={{this.height}}>
<rect class="border-cover" x="0" y="1" height={{this.borderCoverHeight}} />
<polyline class="triangle" points="1 1 {{this.half}} {{this.half}} 1 {{this.height}}" />
</svg>
{{/if}}
</td>
</tr>

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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

View File

@ -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));
}
}
}

View File

@ -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;
}

View File

@ -25,6 +25,8 @@ Router.map(function() {
});
});
this.route('optimize');
this.route('clients', function() {
this.route('client', { path: '/:node_id' }, function() {
this.route('monitor');

View File

@ -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));
}

35
ui/app/routes/optimize.js Normal file
View File

@ -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;
}
}

View File

@ -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',
}),
},
},
});
}
}

View File

@ -0,0 +1,91 @@
import ApplicationSerializer from './application';
import classic from 'ember-classic-decorator';
/*
Theres 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,
},
},
};
}
}

View File

@ -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;
}

View File

@ -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)};
}
}

View File

@ -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;
}
}
}

View File

@ -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';

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}
}

View File

@ -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 theres only one task, it doesnt 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;
}
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;

View File

@ -60,6 +60,16 @@
Jobs
</LinkTo>
</li>
{{#if (can "accept recommendation")}}
<li>
<LinkTo
@route="optimize"
@activeClass="is-active"
data-test-gutter-link="optimize">
Optimize
</LinkTo>
</li>
{{/if}}
</ul>
<p class="menu-label is-minor">
Integrations

View File

@ -13,6 +13,10 @@
</div>
</div>
{{#each this.job.recommendationSummaries as |summary|}}
<Das::RecommendationAccordion @summary={{summary}} />
{{/each}}
<JobPage::Parts::Summary @job={{this.job}} />
<JobPage::Parts::PlacementFailures @job={{this.job}} />

View File

@ -13,6 +13,10 @@
</div>
</div>
{{#each this.job.recommendationSummaries as |summary|}}
<Das::RecommendationAccordion @summary={{summary}} />
{{/each}}
<JobPage::Parts::Summary @job={{this.job}} />
<JobPage::Parts::PlacementFailures @job={{this.job}} />

View File

@ -1,3 +1,3 @@
{{#each this.rows key=this.key as |row|}}
{{yield row}}
{{#each this.rows key=this.key as |row index|}}
{{yield row index}}
{{/each}}

View File

@ -6,5 +6,14 @@
<image xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMCcgaGVpZ2h0PScxMCc+CiAgPHJlY3Qgd2lkdGg9JzEwJyBoZWlnaHQ9JzEwJyBmaWxsPSd3aGl0ZScvPgogIDxwYXRoIGQ9J00tMSwxIGwyLC0yCiAgICAgICAgICAgTTAsMTAgbDEwLC0xMAogICAgICAgICAgIE05LDExIGwyLC0yJyBzdHJva2U9J2JsYWNrJyBzdHJva2Utd2lkdGg9JzMnLz4KPC9zdmc+" x="0" y="0" width="10" height="10"></image>
</pattern>
<linearGradient class="das-gradient" id="recommendation-chart-decrease-gradient" x2="100%" y2="0">
<stop offset="0%" stop-color="var(--full-color)" />
<stop offset="100%" stop-color="var(--faint-color)" />
</linearGradient>
<linearGradient class="das-gradient" id="recommendation-chart-increase-gradient" x2="100%" y2="0">
<stop offset="0%" stop-color="var(--faint-color)" />
<stop offset="100%" stop-color="var(--full-color)" />
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,47 @@
<PageLayout>
<section class="section">
{{#if @model}}
{{#if this.activeRecommendationSummary}}
<Das::RecommendationCard
@summary={{this.activeRecommendationSummary}}
@proceed={{this.proceed}}
/>
{{/if}}
<ListTable
@source={{this.sortedSummaries}} as |t|>
<t.head>
<th>Job</th>
<th>Recommended At</th>
<th># Allocs</th>
<th>CPU</th>
<th>Mem</th>
<th>Agg. CPU</th>
<th>Agg. Mem</th>
</t.head>
<t.body as |row index|>
{{#if row.model.isProcessed}}
<Das::RecommendationRow
class="is-disabled"
@summary={{row.model}}
/>
{{else}}
<Das::RecommendationRow
class="is-interactive {{if (eq row.model this.activeRecommendationSummary) 'is-active'}}"
@summary={{row.model}}
{{on "click" (action (mut this.recommendationSummaryIndex) index)}}
/>
{{/if}}
</t.body>
</ListTable>
{{else}}
<div class="empty-message" data-test-empty-recommendations>
<h3 class="empty-message-headline" data-test-empty-recommendations-headline>No Recommendations</h3>
<p class="empty-message-body">
All recommendations have been accepted or dismissed. Nomad will continuously monitor applications so expect more recommendations in the future.
</p>
</div>
{{/if}}
</section>
</PageLayout>

View File

@ -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 '';
}

View File

@ -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) {

View File

@ -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,
};

View File

@ -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,
});
},
});

View File

@ -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');

View File

@ -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') });
}
},
});

View File

@ -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);
}

View File

@ -0,0 +1,5 @@
import { Model, belongsTo } from 'ember-cli-mirage';
export default Model.extend({
task: belongsTo('task'),
});

View File

@ -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(),
});

View File

@ -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);

View File

@ -99,6 +99,7 @@ export function topoMedium(server) {
datacenters: ['dc1'],
type: 'service',
createAllocations: false,
createRecommendations: true,
resourceSpec: spec,
});
});

View File

@ -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;
}

View File

@ -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",

View File

@ -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);
});
});

View File

@ -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 doesnt 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`;
}
}

View File

@ -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`<Das::Dismissed @proceed={{proceedSpy}} />`);
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`<Das::Dismissed @proceed={{proceedSpy}} />`);
assert.dom('[data-test-understood]').doesNotExist();
await componentA11yAudit(this.element, assert);
assert.ok(proceedSpy.calledWith({ manuallyDismissed: false }));
});
});

View File

@ -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`<Das::RecommendationCard @summary={{summary}} />`);
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 doesnt have header toggles when theres 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`<Das::RecommendationCard @summary={{summary}} />`);
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`<Das::RecommendationCard @summary={{summary}} />`);
await RecommendationCard.togglesTable.tasks[0].cpu.toggle();
await RecommendationCard.togglesTable.tasks[0].memory.toggle();
assert.ok(RecommendationCard.acceptButton.isDisabled);
});
test('it doesnt show a toggle or chart when theres 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`<Das::RecommendationCard @summary={{summary}} />`);
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 resources 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`<Das::RecommendationCard @summary={{summary}} />`);
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`<Das::RecommendationCard @summary={{summary}} />`);
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`<Das::RecommendationCard @summary={{summary}} />`);
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));
}
}
}

View File

@ -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`<Das::RecommendationChart
@resource={{resource}}
@currentValue={{current}}
@recommendedValue={{recommended}}
@stats={{stats}}
/>`
);
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`<Das::RecommendationChart
@resource={{resource}}
@currentValue={{current}}
@recommendedValue={{recommended}}
@stats={{stats}}
/>`
);
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`<Das::RecommendationChart
@resource={{resource}}
@currentValue={{current}}
@recommendedValue={{recommended}}
@stats={{stats}}
/>`
);
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`<Das::RecommendationChart
@resource={{resource}}
@currentValue={{current}}
@recommendedValue={{recommended}}
@stats={{stats}}
@disabled={{true}}
/>`
);
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`<Das::RecommendationChart
@resource={{resource}}
@currentValue={{current}}
@recommendedValue={{recommended}}
@stats={{stats}}
/>`
);
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`<Das::RecommendationChart
@resource={{resource}}
@currentValue={{current}}
@recommendedValue={{recommended}}
@stats={{stats}}
/>`
);
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();
});
});

View File

@ -42,11 +42,12 @@ module('Integration | Component | list table', function(hooks) {
});
await render(hbs`
<ListTable @source={{source}} @sortProperty={{sortProperty}} @sortDescending={{sortDescending}} as |t|>
<t.body @class="body" as |row|>
<t.body @class="body" as |row index|>
<tr class="item">
<td>{{row.model.firstName}}</td>
<td>{{row.model.lastName}}</td>
<td>{{row.model.age}}</td>
<td>{{index}}</td>
</tr>
</t.body>
</ListTable>
@ -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);

View File

@ -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,
};

View File

@ -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'),
};
}

View File

@ -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]'),
});

View File

@ -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]'),

View File

@ -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"]'),

View File

@ -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]'),
},
});

View File

@ -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);
});
});

View File

@ -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
);
});
});
});

View File

@ -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: