horizon/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/source/source.controller.js
Rob Cresswell 32b9d5c748 Make the "Select Boot Source" order deterministic
This patch makes the "Select Boot Source" dropdown in the Source step of
the new Launch Instance have a deterministic order.

The slight downside to this is that the order needs to be recalculated
as new possible boot sources are loaded. However, given that this is the
second step of the workflow, this is unlikely to ever be noticed by a
user.

Change-Id: Iaa2e37bae02c9ce5ab654ac8a3327c0834e7b49e
Closes-Bug: 1640493
(cherry picked from commit b0099bbb87)
2017-06-12 12:49:47 +00:00

579 lines
18 KiB
JavaScript

/*
* Copyright 2015 IBM Corp.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
'use strict';
/**
* @ngdoc controller
* @name LaunchInstanceSourceController
* @description
* The `LaunchInstanceSourceController` controller provides functions for
* configuring the source step of the Launch Instance Wizard.
*
*/
var push = [].push;
angular
.module('horizon.dashboard.project.workflow.launch-instance')
.controller('LaunchInstanceSourceController', LaunchInstanceSourceController);
LaunchInstanceSourceController.$inject = [
'$scope',
'horizon.dashboard.project.workflow.launch-instance.boot-source-types',
'bytesFilter',
'dateFilter',
'decodeFilter',
'diskFormatFilter',
'gbFilter',
'horizon.dashboard.project.workflow.launch-instance.basePath',
'horizon.framework.widgets.transfer-table.events',
'horizon.framework.widgets.magic-search.events'
];
function LaunchInstanceSourceController($scope,
bootSourceTypes,
bytesFilter,
dateFilter,
decodeFilter,
diskFormatFilter,
gbFilter,
basePath,
events,
magicSearchEvents
) {
var ctrl = this;
// Error text for invalid fields
/*eslint-disable max-len */
ctrl.bootSourceTypeError = gettext('Volumes can only be attached to 1 active instance at a time. Please either set your instance count to 1 or select a different source type.');
/*eslint-enable max-len */
// toggle button label/value defaults
ctrl.toggleButtonOptions = [
{ label: gettext('Yes'), value: true },
{ label: gettext('No'), value: false }
];
/*
* Boot Sources
*/
ctrl.updateBootSourceSelection = updateBootSourceSelection;
var selection = ctrl.selection = $scope.model.newInstanceSpec.source;
/*
* Transfer table
*/
ctrl.tableHeadCells = [];
ctrl.tableBodyCells = [];
ctrl.tableData = {
available: [],
allocated: selection,
displayedAvailable: [],
displayedAllocated: []
};
ctrl.helpText = {};
ctrl.sourceDetails = basePath + 'source/source-details.html';
var bootSources = {
image: {
available: $scope.model.images,
allocated: selection,
displayedAvailable: $scope.model.images,
displayedAllocated: selection
},
snapshot: {
available: $scope.model.imageSnapshots,
allocated: selection,
displayedAvailable: [],
displayedAllocated: selection
},
volume: {
available: $scope.model.volumes,
allocated: selection,
displayedAvailable: [],
displayedAllocated: selection
},
volume_snapshot: {
available: $scope.model.volumeSnapshots,
allocated: selection,
displayedAvailable: [],
displayedAllocated: selection
}
};
var diskFormats = [
{ label: gettext('AKI'), key: 'aki' },
{ label: gettext('AMI'), key: 'ami' },
{ label: gettext('ARI'), key: 'ari' },
{ label: gettext('Docker'), key: 'docker' },
{ label: gettext('ISO'), key: 'iso' },
{ label: gettext('OVA'), key: 'ova' },
{ label: gettext('QCOW2'), key: 'qcow2' },
{ label: gettext('RAW'), key: 'raw' },
{ label: gettext('VDI'), key: 'vdi' },
{ label: gettext('VHD'), key: 'vhd' },
{ label: gettext('VMDK'), key: 'vmdk' }
];
// Mapping for dynamic table headers
var tableHeadCellsMap = {
image: [
{ text: gettext('Name'), sortable: true, sortDefault: true },
{ text: gettext('Updated'), sortable: true },
{ text: gettext('Size'), classList: ['number'], sortable: true },
{ text: gettext('Type'), sortable: true },
{ text: gettext('Visibility'), sortable: true }
],
snapshot: [
{ text: gettext('Name'), sortable: true, sortDefault: true },
{ text: gettext('Updated'), sortable: true },
{ text: gettext('Size'), classList: ['number'], sortable: true },
{ text: gettext('Type'), sortable: true },
{ text: gettext('Visibility'), sortable: true }
],
volume: [
{ text: gettext('Name'), sortable: true, sortDefault: true },
{ text: gettext('Description'), sortable: true },
{ text: gettext('Size'), classList: ['number'], sortable: true },
{ text: gettext('Type'), sortable: true },
{ text: gettext('Availability Zone'), sortable: true }
],
volume_snapshot: [
{ text: gettext('Name'), sortable: true, sortDefault: true },
{ text: gettext('Description'), sortable: true },
{ text: gettext('Size'), classList: ['number'], sortable: true },
{ text: gettext('Created'), sortable: true },
{ text: gettext('Status'), sortable: true }
]
};
// Map Visibility data so we can decode true/false to Public/Private
var _visibilitymap = { true: gettext('Public'), false: gettext('Private') };
// Mapping for dynamic table data
var tableBodyCellsMap = {
image: [
{ key: 'name', classList: ['hi-light', 'word-break'] },
{ key: 'updated_at', filter: dateFilter, filterArg: 'short' },
{ key: 'size', filter: bytesFilter, classList: ['number'] },
{ key: 'disk_format', filter: diskFormatFilter, filterRawData: true },
{ key: 'is_public', filter: decodeFilter, filterArg: _visibilitymap }
],
snapshot: [
{ key: 'name', classList: ['hi-light', 'word-break'] },
{ key: 'updated_at', filter: dateFilter, filterArg: 'short' },
{ key: 'size', filter: bytesFilter, classList: ['number'] },
{ key: 'disk_format', filter: diskFormatFilter, filterRawData: true },
{ key: 'is_public', filter: decodeFilter, filterArg: _visibilitymap }
],
volume: [
{ key: 'name', classList: ['hi-light', 'word-break'] },
{ key: 'description' },
{ key: 'size', filter: gbFilter, classList: ['number'] },
{ key: 'volume_image_metadata', filter: diskFormatFilter },
{ key: 'availability_zone' }
],
volume_snapshot: [
{ key: 'name', classList: ['hi-light', 'word-break'] },
{ key: 'description' },
{ key: 'size', filter: gbFilter, classList: ['number'] },
{ key: 'created_at', filter: dateFilter, filterArg: 'short' },
{ key: 'status' }
]
};
/**
* Filtering - client-side MagicSearch
*/
ctrl.sourceFacets = [];
// All facets for source step
var facets = {
created: {
label: gettext('Created'),
name: 'created_at',
singleton: true
},
description: {
label: gettext('Description'),
name: 'description',
singleton: true
},
encrypted: {
label: gettext('Encrypted'),
name: 'encrypted',
singleton: true,
options: [
{ label: gettext('Yes'), key: 'true' },
{ label: gettext('No'), key: 'false' }
]
},
name: {
label: gettext('Name'),
name: 'name',
singleton: true
},
size: {
label: gettext('Size'),
name: 'size',
singleton: true
},
status: {
label: gettext('Status'),
name: 'status',
singleton: true,
options: [
{ label: gettext('Available'), key: 'available' },
{ label: gettext('Creating'), key: 'creating' },
{ label: gettext('Deleting'), key: 'deleting' },
{ label: gettext('Error'), key: 'error' },
{ label: gettext('Error Deleting'), key: 'error_deleting' }
]
},
type: {
label: gettext('Type'),
name: 'disk_format',
singleton: true,
options: diskFormats
},
updated: {
label: gettext('Updated'),
name: 'updated_at',
singleton: true
},
visibility: {
label: gettext('Visibility'),
name: 'is_public',
singleton: true,
options: [
{ label: gettext('Public'), key: 'true' },
{ label: gettext('Private'), key: 'false' }
]
},
volumeType: {
label: gettext('Type'),
name: 'volume_image_metadata.disk_format',
singleton: true,
options: diskFormats
}
};
// Mapping for filter facets based on boot source type
var sourceTypeFacets = {
image: [
facets.name, facets.updated, facets.size, facets.type, facets.visibility
],
snapshot: [
facets.name, facets.updated, facets.size, facets.type, facets.visibility
],
volume: [
facets.name, facets.description, facets.size, facets.volumeType, facets.encrypted
],
volume_snapshot: [
facets.name, facets.description, facets.size, facets.created, facets.status
]
};
var newSpecWatcher = $scope.$watch(
function () {
return $scope.model.newInstanceSpec.instance_count;
},
function (newValue, oldValue) {
if (newValue !== oldValue) {
validateBootSourceType();
}
}
);
var allocatedWatcher = $scope.$watch(
function () {
return ctrl.tableData.allocated.length;
},
function (newValue) {
checkVolumeForImage(newValue);
}
);
// Since available transfer table for Launch Instance Source step is
// dynamically selected based on Boot Source, we need to update the
// model here accordingly. Otherwise it will only calculate the items
// available based on the original selection Boot Source: Image.
var bootSourceWatcher = $scope.$watch(
function getBootSource() {
return ctrl.currentBootSource;
},
function onBootSourceChange(newValue, oldValue) {
if (newValue !== oldValue) {
$scope.$broadcast(events.AVAIL_CHANGED, {
'data': bootSources[newValue]
});
}
}
);
var imagesWatcher = $scope.$watchCollection(
function getImages() {
return $scope.model.images;
},
function onImagesChange() {
$scope.initPromise.then(function () {
$scope.$applyAsync(function () {
if ($scope.launchContext.imageId) {
setSourceImageWithId($scope.launchContext.imageId);
}
});
});
}
);
var imageSnapshotsWatcher = $scope.$watchCollection(
function getImageSnapshots() {
return $scope.model.imageSnapshots;
},
function onImageSnapshotsChange() {
$scope.initPromise.then(function () {
$scope.$applyAsync(function () {
if ($scope.launchContext.imageId) {
setSourceImageSnapshotWithId($scope.launchContext.imageId);
}
});
});
}
);
var volumeWatcher = $scope.$watchCollection(
function getVolumes() {
return $scope.model.volumes;
},
function onVolumesChange() {
$scope.initPromise.then(function onInit() {
$scope.$applyAsync(function setDefaultVolume() {
if ($scope.launchContext.volumeId) {
setSourceVolumeWithId($scope.launchContext.volumeId);
}
});
});
}
);
var snapshotWatcher = $scope.$watchCollection(
function getSnapshots() {
return $scope.model.volumeSnapshots;
},
function onSnapshotsChange() {
$scope.initPromise.then(function onInit() {
$scope.$applyAsync(function setDefaultSnapshot() {
if ($scope.launchContext.snapshotId) {
setSourceSnapshotWithId($scope.launchContext.snapshotId);
}
});
});
}
);
// When the allowedboot list changes, change the source_type
// and update the table for the new source selection. Only done
// with the first item for the list
var allowedBootSourcesWatcher = $scope.$watchCollection(
function getAllowedBootSources() {
return $scope.model.allowedBootSources;
},
function changeBootSource(newValue) {
if (angular.isArray(newValue) && newValue.length > 0) {
updateBootSourceSelection(newValue[0].type);
$scope.model.newInstanceSpec.source_type = newValue[0];
}
}
);
// Explicitly remove watchers on destruction of this controller
$scope.$on('$destroy', function() {
allowedBootSourcesWatcher();
newSpecWatcher();
allocatedWatcher();
bootSourceWatcher();
imagesWatcher();
imageSnapshotsWatcher();
volumeWatcher();
snapshotWatcher();
});
////////////////////
function updateBootSourceSelection(selectedSource) {
ctrl.currentBootSource = selectedSource;
if ((selectedSource === bootSourceTypes.IMAGE ||
selectedSource === bootSourceTypes.INSTANCE_SNAPSHOT) && $scope.model.volumeBootable) {
$scope.model.newInstanceSpec.vol_create = true;
} else {
$scope.model.newInstanceSpec.vol_create = false;
}
$scope.model.newInstanceSpec.vol_delete_on_instance_delete = false;
changeBootSource(selectedSource);
validateBootSourceType();
}
// Dynamically update page based on boot source selection
function changeBootSource(key, preSelection) {
updateDataSource(key, preSelection);
updateHelpText(key);
updateTableHeadCells(key);
updateTableBodyCells(key);
updateFacets(key);
}
function updateDataSource(key, preSelection) {
selection.length = 0;
if (preSelection) {
push.apply(selection, preSelection);
}
angular.extend(ctrl.tableData, bootSources[key]);
}
function updateHelpText() {
angular.extend(ctrl.helpText, {
noneAllocText: gettext('Select a source from those listed below.'),
availHelpText: gettext('Select one'),
/*eslint-disable max-len */
volumeAZHelpText: gettext('When selecting volume as boot source, please ensure the instance\'s availability zone is compatible with your volume\'s availability zone.')
/*eslint-enable max-len */
});
}
function updateTableHeadCells(key) {
refillArray(ctrl.tableHeadCells, tableHeadCellsMap[key]);
}
function updateTableBodyCells(key) {
refillArray(ctrl.tableBodyCells, tableBodyCellsMap[key]);
}
function updateFacets(key) {
refillArray(ctrl.sourceFacets, sourceTypeFacets[key]);
$scope.$broadcast(magicSearchEvents.FACETS_CHANGED);
}
function refillArray(arrayToRefill, contentArray) {
arrayToRefill.length = 0;
Array.prototype.push.apply(arrayToRefill, contentArray);
}
/*
* Validation
*/
/*
* If boot source type is 'image' and 'Create New Volume' is checked, set the minimum volume
* size for validating vol_size field
*/
function checkVolumeForImage() {
var source = selection[0];
if (source && ctrl.currentBootSource === bootSourceTypes.IMAGE) {
var imageGb = source.size * 1e-9;
var imageDisk = source.min_disk;
ctrl.minVolumeSize = Math.ceil(Math.max(imageGb, imageDisk));
if ($scope.model.newInstanceSpec.vol_size < ctrl.minVolumeSize) {
$scope.model.newInstanceSpec.vol_size = ctrl.minVolumeSize;
}
var volumeSizeText = gettext('The volume size must be at least %(minVolumeSize)s GB');
var volumeSizeObj = { minVolumeSize: ctrl.minVolumeSize };
ctrl.volumeSizeError = interpolate(volumeSizeText, volumeSizeObj, true);
} else {
ctrl.minVolumeSize = 0;
ctrl.volumeSizeError = gettext('Volume size is required and must be an integer');
}
}
// Validator for boot source type. Instance count must to be 1 if volume selected
function validateBootSourceType() {
var bootSourceType = ctrl.currentBootSource;
var instanceCount = $scope.model.newInstanceSpec.instance_count;
/*
* Field is valid if boot source type is not volume, instance count is blank/undefined
* (this is an error with instance count) or instance count is 1
*/
var isValid = bootSourceType !== bootSourceTypes.VOLUME ||
!instanceCount ||
instanceCount === 1;
$scope.launchInstanceSourceForm['boot-source-type']
.$setValidity('bootSourceType', isValid);
}
function findSourceById(sources, id) {
var len = sources.length;
var source;
for (var i = 0; i < len; i++) {
source = sources[i];
if (source.id === id) {
return source;
}
}
}
function setSourceImageWithId(id) {
var pre = findSourceById($scope.model.images, id);
if (pre) {
changeBootSource(bootSourceTypes.IMAGE, [pre]);
$scope.model.newInstanceSpec.source_type = {
type: bootSourceTypes.IMAGE,
label: gettext('Image')
};
ctrl.currentBootSource = bootSourceTypes.IMAGE;
}
}
function setSourceImageSnapshotWithId(id) {
var pre = findSourceById($scope.model.imageSnapshots, id);
if (pre) {
changeBootSource(bootSourceTypes.INSTANCE_SNAPSHOT, [pre]);
$scope.model.newInstanceSpec.source_type = {
type: bootSourceTypes.INSTANCE_SNAPSHOT,
label: gettext('Snapshot')
};
ctrl.currentBootSource = bootSourceTypes.INSTANCE_SNAPSHOT;
}
}
function setSourceVolumeWithId(id) {
var pre = findSourceById($scope.model.volumes, id);
if (pre) {
changeBootSource(bootSourceTypes.VOLUME, [pre]);
$scope.model.newInstanceSpec.source_type = {
type: bootSourceTypes.VOLUME,
label: gettext('Volume')
};
ctrl.currentBootSource = bootSourceTypes.VOLUME;
}
}
function setSourceSnapshotWithId(id) {
var pre = findSourceById($scope.model.volumeSnapshots, id);
if (pre) {
changeBootSource(bootSourceTypes.VOLUME_SNAPSHOT, [pre]);
$scope.model.newInstanceSpec.source_type = {
type: bootSourceTypes.VOLUME_SNAPSHOT,
label: gettext('Snapshot')
};
ctrl.currentBootSource = bootSourceTypes.VOLUME_SNAPSHOT;
}
}
}
})();