Add charts to show volume quotas on Angular launch instance modal

Added two charts for Number of Volumes and Total Volume Storage quotas
on Angular launch instance modal when cinder is enabled.

The charts reflect the volume usage of the new instances to be created
as the user changes the configuration on the modal.

Updated the chart styling for the charts to align better.

Change-Id: Ie744ada2317624153fcfdf9abdf4d7b26996a35e
Partially-implements: blueprint launch-instance-volume-quotas
This commit is contained in:
Ying Zuo 2016-12-17 15:28:36 -08:00
parent c01c4d9873
commit 336d0a0525
11 changed files with 241 additions and 21 deletions

View File

@ -170,26 +170,30 @@
}
});
function getChartLabel(type, total, unit) {
if (unit) {
var totalWithUnit = total + " " + unit;
}
return interpolate(
gettext('%(total)s %(type)s'),
{ total: totalWithUnit || total,
type: type },
true
);
}
// set labels depending on whether this is a max or total chart
if (!showChart) {
scope.model.total = null;
scope.model.totalLabel = gettext('No Limit');
} else if (angular.isDefined(scope.chartData.maxLimit)) {
scope.model.total = scope.chartData.maxLimit;
scope.model.totalLabel = interpolate(
gettext('%(total)s Max'),
{ total: scope.model.total },
true
);
scope.model.totalLabel = getChartLabel("Max", scope.model.total, scope.chartData.unit);
} else {
scope.model.total = d3.sum(scope.chartData.data, function (d) {
return d.value;
});
scope.model.totalLabel = interpolate(
gettext('%(total)s Total'),
{ total: scope.model.total },
true
);
scope.model.totalLabel = getChartLabel("Total", scope.model.total, scope.chartData.unit);
}
scope.model.tooltipData.enabled = false;

View File

@ -2,7 +2,7 @@
<chart-tooltip tooltip-data="model.tooltipData"></chart-tooltip>
<div class="pie-chart-title" ng-if="::model.settings.showTitle && chartData.title">
{$ ::chartData.title $} ({$ model.totalLabel $})
{$ ::chartData.title $} <br/>({$ model.totalLabel $})
</div>
<svg class="svg-pie-chart"

View File

@ -24,6 +24,7 @@
.pie-chart-label {
font-size: $font-size-large;
text-anchor: middle;
width: 100%;
text {
font-size: $font-size-large;

View File

@ -25,7 +25,7 @@
describe('pie chart directive', function () {
var $scope, $elementMax, $elementTotal, $elementOverMax,
var $scope, $elementMax, $elementTotal, $elementOverMax, $elementMaxWithUnit,
$elementNoQuota, quotaChartDefaults;
beforeEach(module('templates'));
@ -57,6 +57,24 @@
]
};
$scope.testDataMaxWithUnit = {
title: 'Total Volume Storage',
maxLimit: 1000,
unit: "GiB",
data: [
{ label: quotaChartDefaults.usageLabel,
value: 50,
colorClass: quotaChartDefaults.usageColorClass },
{ label: quotaChartDefaults.addedLabel,
value: 10,
colorClass: quotaChartDefaults.addedColorClass },
{ label: quotaChartDefaults.remainingLabel,
value: 940,
colorClass: quotaChartDefaults.remainingColorClass,
hideKey: true }
]
};
$scope.testDataMax = {};
$scope.testDataOverMax = {};
$scope.testDataNoQuota = {};
@ -111,6 +129,12 @@
$elementNoQuota = angular.element(markupNoQuota);
$compile($elementNoQuota)($scope);
// Max chart with unit markup
var markupMaxWithUnit = '<pie-chart chart-data="testDataMaxWithUnit" ' +
' chart-settings="chartSettings">' +
'</pie-chart>';
$elementMaxWithUnit = angular.element(markupMaxWithUnit);
$compile($elementMaxWithUnit)($scope);
$scope.$apply();
}));
@ -126,6 +150,10 @@
expect($elementTotal.html().trim()).not.toBe('');
});
it('Max chart with unit should be compiled', function () {
expect($elementMaxWithUnit.html().trim()).not.toBe('');
});
it('Max chart should have svg element', function () {
expect($elementMax.find('svg').length).toBe(1);
});
@ -255,6 +283,11 @@
expect(cleanSpaces(firstKeyLabel.textContent)).toEqual('1 Current Usage');
expect(cleanSpaces(secondKeyLabel.textContent)).toEqual('1 Added');
});
it('Max chart with unit should have the unit in its title', function () {
var title = $elementMaxWithUnit.find('.pie-chart-title').text().trim();
expect(title).toBe('Total Volume Storage (1000 GiB Max)');
});
});
})();

View File

@ -40,6 +40,8 @@
ctrl.chartTotalInstancesLabel = gettext('Total Instances');
ctrl.chartTotalVcpusLabel = gettext('Total VCPUs');
ctrl.chartTotalRamLabel = gettext('Total RAM');
ctrl.chartTotalVolumeLabel = gettext('Total Volumes');
ctrl.chartTotalVolumeStorageLabel = gettext('Total Volume Storage');
ctrl.filterFacets = [
{
@ -160,6 +162,25 @@
ctrl.validateFlavor();
});
var cinderLimitsWatcher = $scope.$watch(function () {
return launchInstanceModel.cinderLimits;
}, function (newValue, oldValue, scope) {
var ctrl = scope.selectFlavorCtrl;
ctrl.cinderLimits = newValue;
ctrl.updateFlavorFacades();
}, true);
var volumeSizeWatcher = $scope.$watchCollection(function () {
return [launchInstanceModel.newInstanceSpec.source_type,
launchInstanceModel.newInstanceSpec.vol_size,
launchInstanceModel.newInstanceSpec.vol_create];
}, function (newValue, oldValue) {
if (!angular.equals(newValue, oldValue)) {
ctrl.updateFlavorFacades();
ctrl.validateFlavor();
}
});
//
$scope.$on('$destroy', function() {
novaLimitsWatcher();
@ -167,6 +188,8 @@
instanceCountWatcher();
facadesWatcher();
sourceWatcher();
cinderLimitsWatcher();
volumeSizeWatcher();
});
//////////
@ -240,6 +263,7 @@
*/
for (var i = 0; i < ctrl.availableFlavorFacades.length; i++) {
var facade = ctrl.availableFlavorFacades[i];
var createVolume = launchInstanceModel.newInstanceSpec.vol_create;
facade.instancesChartData = instancesChartData;
@ -253,7 +277,27 @@
ctrl.chartTotalRamLabel,
ctrl.instanceCount * facade.ram,
launchInstanceModel.novaLimits.totalRAMUsed,
launchInstanceModel.novaLimits.maxTotalRAMSize);
launchInstanceModel.novaLimits.maxTotalRAMSize,
"MB"
);
if (launchInstanceModel.cinderLimits) {
facade.volumeChartData = ctrl.getChartData(
ctrl.chartTotalVolumeLabel,
createVolume ? ctrl.instanceCount : 0,
launchInstanceModel.cinderLimits.totalVolumesUsed,
launchInstanceModel.cinderLimits.maxTotalVolumes
);
facade.volumeStorageChartData = ctrl.getChartData(
ctrl.chartTotalVolumeStorageLabel,
createVolume ? (ctrl.instanceCount * Math.max(facade.totalDisk,
launchInstanceModel.newInstanceSpec.vol_size)) : 0,
launchInstanceModel.cinderLimits.totalGigabytesUsed,
launchInstanceModel.cinderLimits.maxTotalVolumeGigabytes,
"GiB"
);
}
var errors = ctrl.getErrors(facade.flavor);
facade.errors = errors;
@ -261,7 +305,7 @@
}
}
function getChartData(title, added, totalUsed, maxAllowed) {
function getChartData(title, added, totalUsed, maxAllowed, unit) {
var used = ctrl.defaultIfUndefined(totalUsed, 0);
var allowed = ctrl.defaultIfUndefined(maxAllowed, 1);
@ -288,7 +332,8 @@
maxLimit: allowed,
label: quotaCalc + '%',
overMax: overMax,
data: [usageData, addedData, remainingData]
data: [usageData, addedData, remainingData],
unit: unit
};
return chartData;

View File

@ -113,6 +113,16 @@ limitations under the License.
chart-settings="chartSettings"></pie-chart>
</div>
</div>
<div class="row" ng-if="selectFlavorCtrl.cinderLimits">
<div class="col-xs-4">
<pie-chart chart-data="item.volumeChartData"
chart-settings="chartSettings"></pie-chart>
</div>
<div class="col-xs-4">
<pie-chart chart-data="item.volumeStorageChartData"
chart-settings="chartSettings"></pie-chart>
</div>
</div>
<div ng-if="selectFlavorCtrl.metadataDefs.flavor">
<div class="row" ng-if="item.extras">
<div class="col-sm-12">
@ -224,6 +234,16 @@ limitations under the License.
chart-settings="chartSettings"></pie-chart>
</div>
</div>
<div class="row" ng-if="selectFlavorCtrl.cinderLimits">
<div class="col-xs-4">
<pie-chart chart-data="item.volumeChartData"
chart-settings="chartSettings"></pie-chart>
</div>
<div class="col-xs-4">
<pie-chart chart-data="item.volumeStorageChartData"
chart-settings="chartSettings"></pie-chart>
</div>
</div>
<div ng-if="selectFlavorCtrl.metadataDefs.flavor">
<div class="row" ng-if="item.extras">
<div class="col-sm-12">

View File

@ -35,6 +35,7 @@
model = { newInstanceSpec: { },
novaLimits: { },
cinderLimits: { },
flavors: []
};
defaults = { usageLabel: "label",
@ -54,7 +55,10 @@
it('defines expected labels', function () {
var props = [
'chartTotalInstancesLabel',
'chartTotalVcpusLabel', 'chartTotalRamLabel'
'chartTotalVcpusLabel',
'chartTotalRamLabel',
'chartTotalVolumeLabel',
'chartTotalVolumeStorageLabel'
];
props.forEach(function (prop) {
@ -75,6 +79,11 @@
});
});
it('includes the unit if it is provided', function () {
var data = ctrl.getChartData('fakeTitle', 1, 2, 3, "MB");
expect(data.unit).toBe('MB');
});
describe("watches", function () {
beforeEach(function() {
@ -93,14 +102,14 @@
ctrl.validateFlavor.calls.reset();
});
it("establishes five watches", function () {
it("establishes seven watches", function () {
// Count calls to $watch (note: $watchCollection
// also calls $watch)
expect(scope.$watch.calls.count()).toBe(5);
expect(scope.$watch.calls.count()).toBe(7);
});
it("establishes three watch collections", function () {
expect(scope.$watchCollection.calls.count()).toBe(3);
it("establishes four watch collections", function () {
expect(scope.$watchCollection.calls.count()).toBe(4);
});
describe("novaLimits watch", function () {
@ -245,6 +254,40 @@
});
});
describe("cinderLimits watcher", function () {
it("initializes cinderLimits", function () {
expect(ctrl.cinderLimits).toEqual({});
});
it("should call updateFlavorFacades", function () {
model.cinderLimits = {test: "test"};
scope.$apply();
expect(ctrl.cinderLimits).toEqual({test: "test"});
expect(ctrl.updateFlavorFacades.calls.count()).toBe(1);
});
});
describe("volume size watcher", function () {
it("should call updateFlavorFacades when source type is changed", function () {
model.newInstanceSpec.source_type = "image";
scope.$apply();
expect(ctrl.updateFlavorFacades.calls.count()).toBe(1);
});
it("should call updateFlavorFacades when volume size is changed", function () {
model.newInstanceSpec.vol_size = 10;
scope.$apply();
expect(ctrl.updateFlavorFacades.calls.count()).toBe(1);
});
it("should call updateFlavorFacades when volume create is changed", function () {
model.newInstanceSpec.vol_create = true;
scope.$apply();
expect(ctrl.updateFlavorFacades.calls.count()).toBe(1);
});
});
});
describe("when having allocated flavors", function () {
@ -412,6 +455,36 @@
});
});
describe("test updateFlavorFacades", function () {
beforeEach(function () {
ctrl.flavors = [{name: "tiny"}];
});
it("should set volumeChartData and volumeStorageChartData", function () {
ctrl.updateFlavorFacades();
expect(ctrl.availableFlavorFacades.length).toBe(1);
expect(ctrl.availableFlavorFacades[0].volumeChartData).toBeDefined();
expect(ctrl.availableFlavorFacades[0].volumeStorageChartData).toBeDefined();
});
it("should call getChartData", function() {
spyOn(ctrl, 'getChartData');
ctrl.updateFlavorFacades();
expect(ctrl.getChartData.calls.count()).toBe(5);
});
});
describe("test validateFlavor", function () {
it("should call validateFlavor when source type is changed", function () {
spyOn(ctrl, 'validateFlavor');
model.newInstanceSpec.source_type = "image";
scope.$apply();
expect(ctrl.validateFlavor.calls.count()).toBe(1);
});
});
});
});

View File

@ -561,6 +561,7 @@
function addVolumeSourcesIfEnabled(config) {
var volumeDeferred = $q.defer();
var volumeSnapshotDeferred = $q.defer();
var absoluteLimitsDeferred = $q.defer();
serviceCatalog
.ifTypeEnabled('volumev2')
.then(onVolumeServiceEnabled, onCheckVolumeV3);
@ -576,8 +577,10 @@
.then(onBootToVolumeSupported);
if (!config || !config.disable_volume) {
getVolumes().then(resolveVolumes, failVolumes);
getAbsoluteLimits().then(resolveAbsoluteLimitsDeferred, resolveAbsoluteLimitsDeferred);
} else {
resolveVolumes();
resolveAbsoluteLimitsDeferred();
}
if (!config || !config.disable_volume_snapshot) {
getVolumeSnapshots().then(resolveVolumeSnapshots, failVolumeSnapshots);
@ -592,6 +595,9 @@
return cinderAPI.getVolumes({status: 'available', bootable: 1})
.then(onGetVolumes);
}
function getAbsoluteLimits() {
return cinderAPI.getAbsoluteLimits().then(onGetCinderLimits);
}
function getVolumeSnapshots() {
return cinderAPI.getVolumeSnapshots({status: 'available'})
.then(onGetVolumeSnapshots);
@ -599,6 +605,7 @@
function resolvePromises() {
volumeDeferred.resolve();
volumeSnapshotDeferred.resolve();
absoluteLimitsDeferred.resolve();
}
function resolveVolumes() {
volumeDeferred.resolve();
@ -612,10 +619,14 @@
function failVolumeSnapshots() {
volumeSnapshotDeferred.resolve();
}
function resolveAbsoluteLimitsDeferred() {
absoluteLimitsDeferred.resolve();
}
return $q.all(
[
volumeDeferred.promise,
volumeSnapshotDeferred.promise
volumeSnapshotDeferred.promise,
absoluteLimitsDeferred.promise
]);
}
@ -743,6 +754,12 @@
finalSpec.source_id = '';
}
// Cinder Limits
function onGetCinderLimits(response) {
model.cinderLimits = response.data;
}
// Nova Limits
function onGetNovaLimits(data) {

View File

@ -232,6 +232,15 @@
var deferred = $q.defer();
deferred.resolve({ data: { items: snapshots } });
return deferred.promise;
},
getAbsoluteLimits: function() {
var limits = { maxTotalVolumes: 100,
totalVolumesUsed: 2,
maxTotalVolumeGigabytes: 1000,
totalGigabytesUsed: 10 };
var deferred = $q.defer();
deferred.resolve({ data: limits });
return deferred.promise;
}
});
@ -732,6 +741,16 @@
expect(model.allowedBootSources).toContain(VOLUME_SNAPSHOT);
});
it('should have maxTotalVolumes and maxTotalVolumeGigabytes if cinder ' +
'is enabled', function() {
cinderEnabled = true;
model.initialize(true);
scope.$apply();
expect(model.cinderLimits.maxTotalVolumes).toBe(100);
expect(model.cinderLimits.maxTotalVolumeGigabytes).toBe(1000);
});
});
describe('Post Initialization Model - Initializing', function() {

View File

@ -10,6 +10,10 @@
.transfer-section {
margin-top: $padding-large-vertical;
.row .pie-chart {
margin-top: 10px;
}
}
.magic-search-bar, .basic-search-bar {

View File

@ -0,0 +1,4 @@
---
features:
- Added two charts to show the Number of Volumes and Total Volume Storage
quotas on launch instance modal when cinder is enabled.