Add DAS UI code from enterprise (#9192)
This is a few combined iterations on the DAS feature.
This commit is contained in:
parent
57f694ff2e
commit
31b4ed7a6d
|
@ -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') || [];
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 '';
|
||||
}
|
|
@ -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}}
|
||||
/>
|
||||
Don’t show this again
|
||||
</label>
|
||||
</section>
|
||||
{{/if}}
|
||||
</section>
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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}}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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}}
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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}}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -25,6 +25,8 @@ Router.map(function() {
|
|||
});
|
||||
});
|
||||
|
||||
this.route('optimize');
|
||||
|
||||
this.route('clients', function() {
|
||||
this.route('client', { path: '/:node_id' }, function() {
|
||||
this.route('monitor');
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
import ApplicationSerializer from './application';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
/*
|
||||
There’s no grouping of recommendations on the server, so this
|
||||
processes a list of recommendations and groups them by task
|
||||
group.
|
||||
*/
|
||||
|
||||
@classic
|
||||
export default class RecommendationSummarySerializer extends ApplicationSerializer {
|
||||
normalizeArrayResponse(store, modelClass, payload) {
|
||||
const recommendationSerializer = store.serializerFor('recommendation');
|
||||
const RecommendationModel = store.modelFor('recommendation');
|
||||
|
||||
const slugToSummaryObject = {};
|
||||
const allRecommendations = [];
|
||||
|
||||
payload.forEach(recommendationHash => {
|
||||
const slug = `${JSON.stringify([recommendationHash.JobID, recommendationHash.Namespace])}/${
|
||||
recommendationHash.Group
|
||||
}`;
|
||||
|
||||
if (!slugToSummaryObject[slug]) {
|
||||
slugToSummaryObject[slug] = {
|
||||
attributes: {
|
||||
jobId: recommendationHash.JobID,
|
||||
jobNamespace: recommendationHash.Namespace,
|
||||
taskGroupName: recommendationHash.Group,
|
||||
},
|
||||
recommendations: [],
|
||||
};
|
||||
}
|
||||
|
||||
slugToSummaryObject[slug].recommendations.push(recommendationHash);
|
||||
allRecommendations.push(recommendationHash);
|
||||
});
|
||||
|
||||
return {
|
||||
data: Object.values(slugToSummaryObject).map(summaryObject => {
|
||||
const latest = Math.max(...summaryObject.recommendations.mapBy('SubmitTime'));
|
||||
|
||||
return {
|
||||
type: 'recommendation-summary',
|
||||
id: summaryObject.recommendations
|
||||
.mapBy('ID')
|
||||
.sort()
|
||||
.join('-'),
|
||||
attributes: {
|
||||
...summaryObject.attributes,
|
||||
submitTime: new Date(Math.floor(latest / 1000000)),
|
||||
},
|
||||
relationships: {
|
||||
job: {
|
||||
data: {
|
||||
type: 'job',
|
||||
id: JSON.stringify([
|
||||
summaryObject.attributes.jobId,
|
||||
summaryObject.attributes.jobNamespace,
|
||||
]),
|
||||
},
|
||||
},
|
||||
recommendations: {
|
||||
data: summaryObject.recommendations.map(r => {
|
||||
return {
|
||||
type: 'recommendation',
|
||||
id: r.ID,
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
included: allRecommendations.map(
|
||||
recommendationHash =>
|
||||
recommendationSerializer.normalize(RecommendationModel, recommendationHash).data
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
normalizeUpdateRecordResponse(store, primaryModelClass, payload, id) {
|
||||
return {
|
||||
data: {
|
||||
id,
|
||||
attributes: {
|
||||
isProcessed: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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)};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,221 @@
|
|||
.recommendation-card {
|
||||
display: grid;
|
||||
grid-template-columns: [overview] 55% [active-task] 45%;
|
||||
grid-template-rows: [top] auto [headings] auto [diffs] auto [narrative] auto [main] auto [actions];
|
||||
|
||||
border: 1px solid $ui-gray-200;
|
||||
margin-bottom: 1.5em;
|
||||
|
||||
.overview {
|
||||
grid-column: overview;
|
||||
border-right: 1px solid $ui-gray-200;
|
||||
}
|
||||
|
||||
.active-task {
|
||||
grid-column: active-task;
|
||||
}
|
||||
|
||||
.active-task-group {
|
||||
// Allow the active task section to be in a grouped test selector container
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.top {
|
||||
grid-row: top;
|
||||
|
||||
&.active-task {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
grid-row: headings;
|
||||
}
|
||||
|
||||
.diffs {
|
||||
grid-row: diffs;
|
||||
}
|
||||
|
||||
.main {
|
||||
grid-row: main;
|
||||
|
||||
&.overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.active-task {
|
||||
> li:first-child {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-row: actions;
|
||||
|
||||
.button {
|
||||
margin-bottom: 2em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: $size-4;
|
||||
font-weight: $weight-semibold;
|
||||
|
||||
.group {
|
||||
color: $cool-gray-500;
|
||||
font-weight: $weight-normal;
|
||||
|
||||
&:before {
|
||||
content: '/';
|
||||
padding: 0 0.25em 0 0.1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.namespace {
|
||||
color: $cool-gray-500;
|
||||
|
||||
.namespace-label {
|
||||
font-weight: $weight-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.increase {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.decrease {
|
||||
color: $teal-500;
|
||||
}
|
||||
|
||||
.inner-container {
|
||||
padding: 1em 2em;
|
||||
|
||||
&.task-toggles {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.diffs-table {
|
||||
th,
|
||||
td {
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
td.diff {
|
||||
color: $cool-gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
.active-task th.diff {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
color: $ui-gray-400;
|
||||
|
||||
.icon {
|
||||
margin-left: 0.75em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-toggles {
|
||||
table {
|
||||
width: calc(100% + 1px); // To remove a mysterious 1px gap between this and the pane border
|
||||
}
|
||||
|
||||
th {
|
||||
vertical-align: bottom;
|
||||
font-size: $size-7;
|
||||
|
||||
&.toggle-cell .toggle {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
padding-bottom: 2px;
|
||||
|
||||
.label-wrapper {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-all {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid $ui-gray-200;
|
||||
}
|
||||
|
||||
tbody tr:not(.active):hover {
|
||||
background: $ui-gray-100;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.active {
|
||||
color: blue;
|
||||
font-weight: bold;
|
||||
|
||||
// When there’s only one task, it doesn’t need highlighting
|
||||
&:first-child:last-child {
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: calc(100% - 1px); // To balance out the table width calc above
|
||||
|
||||
.border-cover {
|
||||
fill: white;
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.triangle {
|
||||
fill: transparent;
|
||||
stroke: $ui-gray-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.75em 0;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.task-cell {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggle-cell {
|
||||
text-align: center;
|
||||
padding: 0.75em;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}} />
|
||||
|
|
|
@ -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}} />
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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 |
|
@ -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>
|
|
@ -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 '';
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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');
|
||||
|
|
|
@ -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') });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import { Model, belongsTo } from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
task: belongsTo('task'),
|
||||
});
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -99,6 +99,7 @@ export function topoMedium(server) {
|
|||
datacenters: ['dc1'],
|
||||
type: 'service',
|
||||
createAllocations: false,
|
||||
createRecommendations: true,
|
||||
resourceSpec: spec,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,331 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { currentURL } from '@ember/test-helpers';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
|
||||
import Response from 'ember-cli-mirage/response';
|
||||
import moment from 'moment';
|
||||
|
||||
import Optimize from 'nomad-ui/tests/pages/optimize';
|
||||
import PageLayout from 'nomad-ui/tests/pages/layout';
|
||||
import JobsList from 'nomad-ui/tests/pages/jobs/list';
|
||||
|
||||
let managementToken, clientToken;
|
||||
|
||||
function getLatestRecommendationSubmitTimeForJob(job) {
|
||||
const tasks = job.taskGroups.models
|
||||
.mapBy('tasks.models')
|
||||
.reduce((tasks, taskModels) => tasks.concat(taskModels), []);
|
||||
const recommendations = tasks.reduce(
|
||||
(recommendations, task) => recommendations.concat(task.recommendations.models),
|
||||
[]
|
||||
);
|
||||
return Math.max(...recommendations.mapBy('submitTime'));
|
||||
}
|
||||
|
||||
module('Acceptance | optimize', function(hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(async function() {
|
||||
server.create('node');
|
||||
|
||||
server.createList('namespace', 2);
|
||||
|
||||
const jobs = server.createList('job', 2, {
|
||||
createRecommendations: true,
|
||||
groupsCount: 1,
|
||||
groupTaskCount: 2,
|
||||
namespaceId: server.db.namespaces[1].id,
|
||||
});
|
||||
|
||||
jobs.sort((jobA, jobB) => {
|
||||
return (
|
||||
getLatestRecommendationSubmitTimeForJob(jobB) -
|
||||
getLatestRecommendationSubmitTimeForJob(jobA)
|
||||
);
|
||||
});
|
||||
|
||||
[this.job1, this.job2] = jobs;
|
||||
|
||||
managementToken = server.create('token');
|
||||
clientToken = server.create('token');
|
||||
|
||||
window.localStorage.clear();
|
||||
window.localStorage.nomadTokenSecret = managementToken.secretId;
|
||||
});
|
||||
|
||||
test('it passes an accessibility audit', async function(assert) {
|
||||
await Optimize.visit();
|
||||
await a11yAudit(assert);
|
||||
});
|
||||
|
||||
test('lets recommendations be toggled, reports the choices to the recommendations API, and displays task group recommendations serially', async function(assert) {
|
||||
await Optimize.visit();
|
||||
|
||||
const currentTaskGroup = this.job1.taskGroups.models[0];
|
||||
const nextTaskGroup = this.job2.taskGroups.models[0];
|
||||
|
||||
assert.equal(Optimize.breadcrumbFor('optimize').text, 'Recommendations');
|
||||
|
||||
assert.equal(
|
||||
Optimize.recommendationSummaries[0].slug,
|
||||
`${this.job1.name} / ${currentTaskGroup.name}`
|
||||
);
|
||||
|
||||
assert.equal(Optimize.recommendationSummaries[0].namespace, this.job1.namespace);
|
||||
|
||||
assert.equal(
|
||||
Optimize.recommendationSummaries[1].slug,
|
||||
`${this.job2.name} / ${nextTaskGroup.name}`
|
||||
);
|
||||
|
||||
const currentRecommendations = currentTaskGroup.tasks.models.reduce(
|
||||
(recommendations, task) => recommendations.concat(task.recommendations.models),
|
||||
[]
|
||||
);
|
||||
const latestSubmitTime = Math.max(...currentRecommendations.mapBy('submitTime'));
|
||||
|
||||
Optimize.recommendationSummaries[0].as(summary => {
|
||||
assert.equal(
|
||||
summary.date,
|
||||
moment(new Date(latestSubmitTime / 1000000)).format('MMM DD HH:mm:ss ZZ')
|
||||
);
|
||||
|
||||
const currentTaskGroupAllocations = server.schema.allocations.where({
|
||||
jobId: currentTaskGroup.job.name,
|
||||
taskGroup: currentTaskGroup.name,
|
||||
});
|
||||
assert.equal(summary.allocationCount, currentTaskGroupAllocations.length);
|
||||
|
||||
const { currCpu, currMem } = currentTaskGroup.tasks.models.reduce(
|
||||
(currentResources, task) => {
|
||||
currentResources.currCpu += task.resources.CPU;
|
||||
currentResources.currMem += task.resources.MemoryMB;
|
||||
return currentResources;
|
||||
},
|
||||
{ currCpu: 0, currMem: 0 }
|
||||
);
|
||||
|
||||
const { recCpu, recMem } = currentRecommendations.reduce(
|
||||
(recommendedResources, recommendation) => {
|
||||
if (recommendation.resource === 'CPU') {
|
||||
recommendedResources.recCpu += recommendation.value;
|
||||
} else {
|
||||
recommendedResources.recMem += recommendation.value;
|
||||
}
|
||||
|
||||
return recommendedResources;
|
||||
},
|
||||
{ recCpu: 0, recMem: 0 }
|
||||
);
|
||||
|
||||
const cpuDiff = recCpu > 0 ? recCpu - currCpu : 0;
|
||||
const memDiff = recMem > 0 ? recMem - currMem : 0;
|
||||
|
||||
const cpuSign = cpuDiff > 0 ? '+' : '';
|
||||
const memSign = memDiff > 0 ? '+' : '';
|
||||
|
||||
const cpuDiffPercent = Math.round((100 * cpuDiff) / currCpu);
|
||||
const memDiffPercent = Math.round((100 * memDiff) / currMem);
|
||||
|
||||
assert.equal(
|
||||
summary.cpu,
|
||||
cpuDiff ? `${cpuSign}${cpuDiff} MHz ${cpuSign}${cpuDiffPercent}%` : ''
|
||||
);
|
||||
assert.equal(
|
||||
summary.memory,
|
||||
memDiff ? `${memSign}${formattedMemDiff(memDiff)} ${memSign}${memDiffPercent}%` : ''
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
summary.aggregateCpu,
|
||||
cpuDiff ? `${cpuSign}${cpuDiff * currentTaskGroupAllocations.length} MHz` : ''
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
summary.aggregateMemory,
|
||||
memDiff ? `${memSign}${formattedMemDiff(memDiff * currentTaskGroupAllocations.length)}` : ''
|
||||
);
|
||||
});
|
||||
|
||||
assert.ok(Optimize.recommendationSummaries[0].isActive);
|
||||
assert.notOk(Optimize.recommendationSummaries[1].isActive);
|
||||
|
||||
assert.equal(Optimize.card.slug.jobName, this.job1.name);
|
||||
assert.equal(Optimize.card.slug.groupName, currentTaskGroup.name);
|
||||
|
||||
const summaryMemoryBefore = Optimize.recommendationSummaries[0].memory;
|
||||
|
||||
let toggledAnything = true;
|
||||
|
||||
// Toggle off all memory
|
||||
if (Optimize.card.togglesTable.toggleAllMemory.isPresent) {
|
||||
await Optimize.card.togglesTable.toggleAllMemory.toggle();
|
||||
|
||||
assert.notOk(Optimize.card.togglesTable.tasks[0].memory.isActive);
|
||||
assert.notOk(Optimize.card.togglesTable.tasks[1].memory.isActive);
|
||||
} else if (!Optimize.card.togglesTable.tasks[0].cpu.isDisabled) {
|
||||
await Optimize.card.togglesTable.tasks[0].memory.toggle();
|
||||
} else {
|
||||
toggledAnything = false;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
Optimize.recommendationSummaries[0].memory,
|
||||
summaryMemoryBefore,
|
||||
'toggling recommendations doesn’t affect the summary table diffs'
|
||||
);
|
||||
|
||||
const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id');
|
||||
const taskIdFilter = task => currentTaskIds.includes(task.taskId);
|
||||
|
||||
const cpuRecommendationIds = server.schema.recommendations
|
||||
.where({ resource: 'CPU' })
|
||||
.models.filter(taskIdFilter)
|
||||
.mapBy('id');
|
||||
|
||||
const memoryRecommendationIds = server.schema.recommendations
|
||||
.where({ resource: 'MemoryMB' })
|
||||
.models.filter(taskIdFilter)
|
||||
.mapBy('id');
|
||||
|
||||
const appliedIds = toggledAnything ? cpuRecommendationIds : memoryRecommendationIds;
|
||||
const dismissedIds = toggledAnything ? memoryRecommendationIds : [];
|
||||
|
||||
await Optimize.card.acceptButton.click();
|
||||
|
||||
const request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
|
||||
const { Apply, Dismiss } = JSON.parse(request.requestBody);
|
||||
|
||||
assert.equal(request.url, '/v1/recommendations/apply');
|
||||
|
||||
assert.deepEqual(Apply, appliedIds);
|
||||
assert.deepEqual(Dismiss, dismissedIds);
|
||||
|
||||
assert.equal(Optimize.card.slug.jobName, this.job2.name);
|
||||
assert.equal(Optimize.card.slug.groupName, nextTaskGroup.name);
|
||||
|
||||
assert.ok(Optimize.recommendationSummaries[1].isActive);
|
||||
});
|
||||
|
||||
test('can navigate between summaries via the table', async function(assert) {
|
||||
server.createList('job', 10, {
|
||||
createRecommendations: true,
|
||||
groupsCount: 1,
|
||||
groupTaskCount: 2,
|
||||
namespaceId: server.db.namespaces[1].id,
|
||||
});
|
||||
|
||||
await Optimize.visit();
|
||||
await Optimize.recommendationSummaries[1].click();
|
||||
|
||||
assert.equal(
|
||||
`${Optimize.card.slug.jobName} / ${Optimize.card.slug.groupName}`,
|
||||
Optimize.recommendationSummaries[1].slug
|
||||
);
|
||||
assert.ok(Optimize.recommendationSummaries[1].isActive);
|
||||
});
|
||||
|
||||
test('cannot return to already-processed summaries', async function(assert) {
|
||||
await Optimize.visit();
|
||||
await Optimize.card.acceptButton.click();
|
||||
|
||||
assert.ok(Optimize.recommendationSummaries[0].isDisabled);
|
||||
|
||||
await Optimize.recommendationSummaries[0].click();
|
||||
|
||||
assert.ok(Optimize.recommendationSummaries[1].isActive);
|
||||
});
|
||||
|
||||
test('can dismiss a set of recommendations', async function(assert) {
|
||||
await Optimize.visit();
|
||||
|
||||
const currentTaskGroup = this.job1.taskGroups.models[0];
|
||||
const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id');
|
||||
const taskIdFilter = task => currentTaskIds.includes(task.taskId);
|
||||
|
||||
const idsBeforeDismissal = server.schema.recommendations
|
||||
.all()
|
||||
.models.filter(taskIdFilter)
|
||||
.mapBy('id');
|
||||
|
||||
await Optimize.card.dismissButton.click();
|
||||
|
||||
const request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
|
||||
const { Apply, Dismiss } = JSON.parse(request.requestBody);
|
||||
|
||||
assert.equal(request.url, '/v1/recommendations/apply');
|
||||
|
||||
assert.deepEqual(Apply, []);
|
||||
assert.deepEqual(Dismiss, idsBeforeDismissal);
|
||||
});
|
||||
|
||||
test('it displays an error encountered trying to save and proceeds to the next summary when the error is dismiss', async function(assert) {
|
||||
server.post('/recommendations/apply', function() {
|
||||
return new Response(500, {}, null);
|
||||
});
|
||||
|
||||
await Optimize.visit();
|
||||
await Optimize.card.acceptButton.click();
|
||||
|
||||
assert.ok(Optimize.error.isPresent);
|
||||
assert.equal(Optimize.error.headline, 'Recommendation error');
|
||||
assert.equal(
|
||||
Optimize.error.errors,
|
||||
'Error: Ember Data Request POST /v1/recommendations/apply returned a 500 Payload (application/json)'
|
||||
);
|
||||
|
||||
await Optimize.error.dismiss();
|
||||
assert.equal(Optimize.card.slug.jobName, this.job2.name);
|
||||
});
|
||||
|
||||
test('it displays an empty message when there are no recommendations', async function(assert) {
|
||||
server.db.recommendations.remove();
|
||||
await Optimize.visit();
|
||||
|
||||
assert.ok(Optimize.empty.isPresent);
|
||||
assert.equal(Optimize.empty.headline, 'No Recommendations');
|
||||
});
|
||||
|
||||
test('it displays an empty message after all recommendations have been processed', async function(assert) {
|
||||
await Optimize.visit();
|
||||
|
||||
await Optimize.card.acceptButton.click();
|
||||
await Optimize.card.acceptButton.click();
|
||||
|
||||
assert.ok(Optimize.empty.isPresent);
|
||||
});
|
||||
|
||||
test('it redirects to jobs and hides the gutter link when the token lacks permissions', async function(assert) {
|
||||
window.localStorage.nomadTokenSecret = clientToken.secretId;
|
||||
await Optimize.visit();
|
||||
|
||||
assert.equal(currentURL(), '/jobs');
|
||||
assert.ok(PageLayout.gutter.optimize.isHidden);
|
||||
});
|
||||
|
||||
test('it reloads partially-loaded jobs', async function(assert) {
|
||||
await JobsList.visit();
|
||||
await Optimize.visit();
|
||||
|
||||
assert.equal(Optimize.recommendationSummaries.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
function formattedMemDiff(memDiff) {
|
||||
const absMemDiff = Math.abs(memDiff);
|
||||
const negativeSign = memDiff < 0 ? '-' : '';
|
||||
|
||||
if (absMemDiff >= 1024) {
|
||||
const gibDiff = absMemDiff / 1024;
|
||||
|
||||
if (Number.isInteger(gibDiff)) {
|
||||
return `${negativeSign}${gibDiff} GiB`;
|
||||
} else {
|
||||
return `${negativeSign}${gibDiff.toFixed(2)} GiB`;
|
||||
}
|
||||
} else {
|
||||
return `${negativeSign}${absMemDiff} MiB`;
|
||||
}
|
||||
}
|
|
@ -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 }));
|
||||
});
|
||||
});
|
|
@ -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 doesn’t have header toggles when there’s only one task', async function(assert) {
|
||||
const task1 = {
|
||||
name: 'jortle',
|
||||
reservedCPU: 150,
|
||||
reservedMemory: 128,
|
||||
};
|
||||
|
||||
this.set(
|
||||
'summary',
|
||||
new MockRecommendationSummary({
|
||||
recommendations: [
|
||||
{
|
||||
resource: 'CPU',
|
||||
stats: {},
|
||||
task: task1,
|
||||
value: 50,
|
||||
},
|
||||
{
|
||||
resource: 'MemoryMB',
|
||||
stats: {},
|
||||
task: task1,
|
||||
value: 192,
|
||||
},
|
||||
],
|
||||
|
||||
taskGroup: {
|
||||
count: 1,
|
||||
reservedCPU: task1.reservedCPU,
|
||||
reservedMemory: task1.reservedMemory,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await render(hbs`<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 doesn’t show a toggle or chart when there’s no recommendation for that resource', async function(assert) {
|
||||
const task1 = {
|
||||
name: 'jortle',
|
||||
reservedCPU: 150,
|
||||
reservedMemory: 128,
|
||||
};
|
||||
|
||||
this.set(
|
||||
'summary',
|
||||
new MockRecommendationSummary({
|
||||
recommendations: [
|
||||
{
|
||||
resource: 'CPU',
|
||||
stats: {},
|
||||
task: task1,
|
||||
value: 50,
|
||||
},
|
||||
],
|
||||
|
||||
taskGroup: {
|
||||
count: 2,
|
||||
name: 'group-name',
|
||||
job: {
|
||||
name: 'job-name',
|
||||
},
|
||||
reservedCPU: task1.reservedCPU,
|
||||
reservedMemory: task1.reservedMemory,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await render(hbs`<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 resource’s toggle all toggle when there are no recommendations for it', async function(assert) {
|
||||
const task1 = {
|
||||
name: 'jortle',
|
||||
reservedCPU: 150,
|
||||
reservedMemory: 128,
|
||||
};
|
||||
|
||||
const task2 = {
|
||||
name: 'tortle',
|
||||
reservedCPU: 150,
|
||||
reservedMemory: 128,
|
||||
};
|
||||
|
||||
this.set(
|
||||
'summary',
|
||||
new MockRecommendationSummary({
|
||||
recommendations: [
|
||||
{
|
||||
resource: 'CPU',
|
||||
stats: {},
|
||||
task: task1,
|
||||
value: 50,
|
||||
},
|
||||
{
|
||||
resource: 'CPU',
|
||||
stats: {},
|
||||
task: task2,
|
||||
value: 50,
|
||||
},
|
||||
],
|
||||
|
||||
taskGroup: {
|
||||
count: 2,
|
||||
name: 'group-name',
|
||||
job: {
|
||||
name: 'job-name',
|
||||
},
|
||||
reservedCPU: task1.reservedCPU + task2.reservedCPU,
|
||||
reservedMemory: task1.reservedMemory + task2.reservedMemory,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await render(hbs`<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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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'),
|
||||
};
|
||||
}
|
|
@ -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]'),
|
||||
});
|
||||
|
|
|
@ -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]'),
|
||||
|
||||
|
|
|
@ -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"]'),
|
||||
|
|
|
@ -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]'),
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
18
ui/yarn.lock
18
ui/yarn.lock
|
@ -9752,10 +9752,10 @@ ember-decorators@^6.1.1:
|
|||
"@ember-decorators/object" "^6.1.1"
|
||||
ember-cli-babel "^7.7.3"
|
||||
|
||||
ember-destroyable-polyfill@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ember-destroyable-polyfill/-/ember-destroyable-polyfill-2.0.1.tgz#391cd95a99debaf24148ce953054008d00f151a6"
|
||||
integrity sha512-hyK+/GPWOIxM1vxnlVMknNl9E5CAFVbcxi8zPiM0vCRwHiFS8Wuj7PfthZ1/OFitNNv7ITTeU8hxqvOZVsrbnQ==
|
||||
ember-destroyable-polyfill@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/ember-destroyable-polyfill/-/ember-destroyable-polyfill-2.0.2.tgz#2cc7532bd3c00e351b4da9b7fc683f4daff79671"
|
||||
integrity sha512-9t+ya+9c+FkNM5IAyJIv6ETG8jfZQaUnFCO5SeLlV0wkSw7TOexyb61jh5GVee0KmknfRhrRGGAyT4Y0TwkZ+w==
|
||||
dependencies:
|
||||
ember-cli-babel "^7.22.1"
|
||||
ember-cli-version-checker "^5.1.1"
|
||||
|
@ -9889,16 +9889,16 @@ ember-modifier-manager-polyfill@^1.1.0, ember-modifier-manager-polyfill@^1.2.0:
|
|||
ember-cli-version-checker "^2.1.2"
|
||||
ember-compatibility-helpers "^1.2.0"
|
||||
|
||||
ember-modifier@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-2.1.0.tgz#99d85995caad8789220dc3208fb5ded45647dccf"
|
||||
integrity sha512-tVmRcEloYg8AZHheEMhBhzX64r7n6AFLXG69L/jiHePvQzet9mjV18YiIPStQf+fdjTAO25S6yzNPDP3zQjWtQ==
|
||||
ember-modifier@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-2.1.1.tgz#aa3a12e2d6cf1622f774f3f1eab4880982a43fa9"
|
||||
integrity sha512-g9mcpFWgw5lgNU40YNf0USNWqoGTJ+EqjDQKjm7556gaRNDeGnLylFKqx9O3opwLHEt6ZODnRDy9U0S5YEMREg==
|
||||
dependencies:
|
||||
ember-cli-babel "^7.22.1"
|
||||
ember-cli-normalize-entity-name "^1.0.0"
|
||||
ember-cli-string-utils "^1.1.0"
|
||||
ember-cli-typescript "^3.1.3"
|
||||
ember-destroyable-polyfill "^2.0.1"
|
||||
ember-destroyable-polyfill "^2.0.2"
|
||||
ember-modifier-manager-polyfill "^1.2.0"
|
||||
|
||||
ember-moment@^7.8.1:
|
||||
|
|
Loading…
Reference in New Issue