feat(container-details): add the ability to re-create, duplicate and edit a container (#855)

pull/1069/head
Thomas Krzero 2017-08-13 12:17:41 +02:00 committed by Anthony Lapenna
parent d814f3aaa4
commit c85aa0739d
9 changed files with 386 additions and 20 deletions

View File

@ -244,7 +244,7 @@ angular.module('portainer', [
}
})
.state('actions.create.container', {
url: '/container',
url: '/container/:from',
views: {
'content@': {
templateUrl: 'app/components/createContainer/createcontainer.html',

View File

@ -20,6 +20,8 @@
<button class="btn btn-primary" ng-click="pause()" ng-disabled="!container.State.Running || container.State.Paused"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
<button class="btn btn-primary" ng-click="unpause()" ng-disabled="!container.State.Paused"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
<button class="btn btn-danger" ng-click="confirmRemove()"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<button class="btn btn-danger" ng-click="recreate()"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Recreate</button>
<button class="btn btn-primary" ng-click="duplicate()"><i class="fa fa-files-o space-right" aria-hidden="true"></i>Duplicate/Edit</button>
</div>
</rd-widget-body>
</rd-widget>

View File

@ -1,6 +1,6 @@
angular.module('container', [])
.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService',
function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService) {
.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService, ResourceControlService, RegistryService, ImageService) {
$scope.activityTime = 0;
$scope.portBindings = [];
$scope.config = {
@ -196,6 +196,73 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Con
});
};
$scope.duplicate = function() {
ModalService.confirmExperimentalFeature(function (experimental) {
if(!experimental) { return; }
$state.go('actions.create.container', {from: $stateParams.id}, {reload: true});
});
};
$scope.recreate = function() {
ModalService.confirmExperimentalFeature(function (experimental) {
if(!experimental) { return; }
ModalService.confirm({
title: 'Are you sure ?',
message: 'You\'re about to re-create this container, any non-persisted data will be lost. This container will be removed and another one will be created using the same configuration.',
buttons: {
confirm: {
label: 'Recreate',
className: 'btn-danger'
}
},
callback: function onConfirm(confirmed) {
if(!confirmed) { return; }
else {
$('#loadingViewSpinner').show();
var container = $scope.container;
var config = ContainerHelper.configFromContainer(container.Model);
ContainerService.remove(container, true)
.then(function success() {
return RegistryService.retrieveRegistryFromRepository(container.Config.Image);
})
.then(function success(data) {
return ImageService.pullImage(container.Config.Image, data, true);
})
.then(function success() {
return ContainerService.createAndStartContainer(config);
})
.then(function success(data) {
if (!container.ResourceControl) {
return true;
} else {
var containerIdentifier = data.Id;
var resourceControl = container.ResourceControl;
var users = resourceControl.UserAccesses.map(function(u) {
return u.UserId;
});
var teams = resourceControl.TeamAccesses.map(function(t) {
return t.TeamId;
});
return ResourceControlService.createResourceControl(resourceControl.AdministratorsOnly,
users, teams, containerIdentifier, 'container', []);
}
})
.then(function success(data) {
Notifications.success('Container successfully re-created');
$state.go('containers', {}, {reload: true});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to re-create container');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
}
});
});
};
$scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) {
$('#joinNetworkSpinner').show();
Network.connect({id: networkId}, { Container: $stateParams.id }, function (d) {

View File

@ -1,14 +1,13 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference.
angular.module('createContainer', [])
.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator) {
.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService) {
$scope.formValues = {
alwaysPull: true,
Console: 'none',
Volumes: [],
Registry: '',
NetworkContainer: '',
Labels: [],
ExtraHosts: [],
@ -92,6 +91,8 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
$scope.config.HostConfig.Devices.splice(index, 1);
};
$scope.fromContainerMultipleNetworks = false;
function prepareImageConfig(config) {
var image = config.Image;
var registry = $scope.formValues.Registry;
@ -179,6 +180,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
var networkMode = mode;
if (containerName) {
networkMode += ':' + containerName;
config.Hostname = '';
}
config.HostConfig.NetworkMode = networkMode;
@ -233,6 +235,213 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
return config;
}
function confirmCreateContainer() {
var deferred = $q.defer();
Container.query({ all: 1, filters: {name: ['^/' + $scope.config.name + '$'] }}).$promise
.then(function success(data) {
var existingContainer = data[0];
if (existingContainer) {
ModalService.confirm({
title: 'Are you sure ?',
message: 'A container with the same name already exists. Portainer can automatically remove it and re-create one. Do you want to replace it?',
buttons: {
confirm: {
label: 'Replace',
className: 'btn-danger'
}
},
callback: function onConfirm(confirmed) {
if(!confirmed) { deferred.resolve(false); }
else {
// Remove old container
ContainerService.remove(existingContainer, true)
.then(function success(data) {
Notifications.success('Container Removed', existingContainer.Id);
deferred.resolve(true);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to remove container', err: err });
});
}
}
});
} else {
deferred.resolve(true);
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve containers');
return undefined;
});
return deferred.promise;
}
function loadFromContainerCmd(d) {
if ($scope.config.Cmd) {
$scope.config.Cmd = ContainerHelper.commandArrayToString($scope.config.Cmd);
} else {
$scope.config.Cmd = '';
}
}
function loadFromContainerPortBindings(d) {
var bindings = [];
for (var p in $scope.config.HostConfig.PortBindings) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.PortBindings, p)) {
var hostPort = '';
if ($scope.config.HostConfig.PortBindings[p][0].HostIp) {
hostPort = $scope.config.HostConfig.PortBindings[p][0].HostIp + ':';
}
hostPort += $scope.config.HostConfig.PortBindings[p][0].HostPort;
var b = {
'hostPort': hostPort,
'containerPort': p.split('/')[0],
'protocol': p.split('/')[1]
};
bindings.push(b);
}
}
$scope.config.HostConfig.PortBindings = bindings;
}
function loadFromContainerVolumes(d) {
for (var v in d.Mounts) {
if ({}.hasOwnProperty.call(d.Mounts, v)) {
var mount = d.Mounts[v];
var volume = {
'type': mount.Type,
'name': mount.Name || mount.Source,
'containerPath': mount.Destination,
'readOnly': mount.RW === false
};
$scope.formValues.Volumes.push(volume);
}
}
}
function loadFromContainerNetworkConfig(d) {
$scope.config.NetworkingConfig = {
EndpointsConfig: {}
};
var networkMode = d.HostConfig.NetworkMode;
if (networkMode === 'default') {
$scope.config.HostConfig.NetworkMode = 'bridge';
if (!_.find($scope.availableNetworks, {'Name': 'bridge'})) {
$scope.config.HostConfig.NetworkMode = 'nat';
}
}
if ($scope.config.HostConfig.NetworkMode.indexOf('container:') === 0) {
var netContainer = $scope.config.HostConfig.NetworkMode.split(/^container:/)[1];
$scope.config.HostConfig.NetworkMode = 'container';
for (var c in $scope.runningContainers) {
if ($scope.runningContainers[c].Names && $scope.runningContainers[c].Names[0] === '/' + netContainer) {
$scope.formValues.NetworkContainer = $scope.runningContainers[c];
}
}
}
$scope.fromContainerMultipleNetworks = Object.keys(d.NetworkSettings.Networks).length >= 2;
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]) {
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig) {
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address) {
$scope.formValues.IPv4 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address;
}
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address) {
$scope.formValues.IPv6 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address;
}
}
}
$scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode];
// ExtraHosts
for (var h in $scope.config.HostConfig.ExtraHosts) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.ExtraHosts, h)) {
$scope.formValues.ExtraHosts.push({'value': $scope.config.HostConfig.ExtraHosts[h]});
$scope.config.HostConfig.ExtraHosts = [];
}
}
}
function loadFromContainerEnvrionmentVariables(d) {
var envArr = [];
for (var e in $scope.config.Env) {
if ({}.hasOwnProperty.call($scope.config.Env, e)) {
var arr = $scope.config.Env[e].split(/\=(.+)/);
envArr.push({'name': arr[0], 'value': arr[1]});
}
}
$scope.config.Env = envArr;
}
function loadFromContainerLabels(d) {
for (var l in $scope.config.Labels) {
if ({}.hasOwnProperty.call($scope.config.Labels, l)) {
$scope.formValues.Labels.push({ name: l, value: $scope.config.Labels[l]});
}
}
}
function loadFromContainerConsole(d) {
if ($scope.config.OpenStdin && $scope.config.Tty) {
$scope.formValues.Console = 'both';
} else if (!$scope.config.OpenStdin && $scope.config.Tty) {
$scope.formValues.Console = 'tty';
} else if ($scope.config.OpenStdin && !$scope.config.Tty) {
$scope.formValues.Console = 'interactive';
} else if (!$scope.config.OpenStdin && !$scope.config.Tty) {
$scope.formValues.Console = 'none';
}
}
function loadFromContainerDevices(d) {
var path = [];
for (var dev in $scope.config.HostConfig.Devices) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.Devices, dev)) {
var device = $scope.config.HostConfig.Devices[dev];
path.push({'pathOnHost': device.PathOnHost, 'pathInContainer': device.PathInContainer});
}
}
$scope.config.HostConfig.Devices = path;
}
function loadFromContainerImageConfig(d) {
// If no registry found, we let default DockerHub and let full image path
var imageInfo = ImageHelper.extractImageAndRegistryFromRepository($scope.config.Image);
RegistryService.retrieveRegistryFromRepository($scope.config.Image)
.then(function success(data) {
if (data) {
$scope.config.Image = imageInfo.image;
$scope.formValues.Registry = data;
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrive registry');
});
}
function loadFromContainerSpec() {
// Get container
Container.get({ id: $stateParams.from }).$promise
.then(function success(d) {
var fromContainer = new ContainerDetailsViewModel(d);
if (!fromContainer.ResourceControl) {
$scope.formValues.AccessControlData.AccessControlEnabled = false;
}
$scope.fromContainer = fromContainer;
$scope.config = ContainerHelper.configFromContainer(fromContainer.Model);
loadFromContainerCmd(d);
loadFromContainerPortBindings(d);
loadFromContainerVolumes(d);
loadFromContainerNetworkConfig(d);
loadFromContainerEnvrionmentVariables(d);
loadFromContainerLabels(d);
loadFromContainerConsole(d);
loadFromContainerDevices(d);
loadFromContainerImageConfig(d);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve container');
});
}
function initView() {
Volume.query({}, function (d) {
$scope.availableVolumes = d.Volumes;
@ -264,6 +473,12 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
Container.query({}, function (d) {
var containers = d;
$scope.runningContainers = containers;
if ($stateParams.from !== '') {
loadFromContainerSpec();
} else {
$scope.fromContainer = {};
$scope.formValues.Registry = {};
}
}, function(e) {
Notifications.error('Failure', e, 'Unable to retrieve running containers');
});
@ -283,19 +498,27 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
}
$scope.create = function () {
$('#createContainerSpinner').show();
confirmCreateContainer()
.then(function success(confirm) {
if (!confirm) {
return false;
}
$('#createContainerSpinner').show();
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
if (!validateForm(accessControlData, isAdmin)) {
$('#createContainerSpinner').hide();
return;
}
if (!validateForm(accessControlData, isAdmin)) {
$('#createContainerSpinner').hide();
return;
}
var config = prepareConfiguration();
createContainer(config, accessControlData);
var config = prepareConfiguration();
createContainer(config, accessControlData);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create container');
});
};
function createContainer(config, accessControlData) {

View File

@ -23,7 +23,7 @@
</div>
<!-- image-and-registry -->
<div class="form-group">
<por-image-registry image="config.Image" registry="formValues.Registry"></por-image-registry>
<por-image-registry image="config.Image" registry="formValues.Registry" ng-if="formValues.Registry"></por-image-registry>
</div>
<!-- !image-and-registry -->
<!-- always-pull -->
@ -98,7 +98,7 @@
</div>
<!-- !port-mapping -->
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<por-access-control-form form-data="formValues.AccessControlData" resource-control="fromContainer.ResourceControl" ng-if="applicationState.application.authentication && fromContainer"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
@ -110,6 +110,10 @@
<a type="button" class="btn btn-default btn-sm" ui-sref="containers">Cancel</a>
<i id="createContainerSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
<span ng-if="fromContainerMultipleNetworks" style="margin-left: 10px">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
<span class="small text-muted" style="margin-left: 5px;">This container is connected to multiple networks, only one network will be kept at creation time.</span>
</span>
</div>
</div>
<!-- !actions -->

View File

@ -12,7 +12,11 @@ function ($q, RegistryService, DockerHubService, Notifications) {
var dockerhub = data.dockerhub;
var registries = data.registries;
ctrl.availableRegistries = [dockerhub].concat(registries);
ctrl.registry = dockerhub;
if (!ctrl.registry.Id) {
ctrl.registry = dockerhub;
} else {
ctrl.registry = _.find(ctrl.availableRegistries, { 'Id': ctrl.registry.Id });
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve registries');

View File

@ -7,5 +7,56 @@ angular.module('portainer.helpers')
return splitargs(command);
};
helper.commandArrayToString = function(array) {
return array.map(function(elem) {
return '\'' + elem + '\'';
}).join(' ');
};
helper.configFromContainer = function(container) {
var config = container.Config;
// HostConfig
config.HostConfig = container.HostConfig;
// Name
config.name = container.Name.replace(/^\//g, '');
// Network
var mode = config.HostConfig.NetworkMode;
config.NetworkingConfig = {
'EndpointsConfig': {}
};
config.NetworkingConfig.EndpointsConfig = container.NetworkSettings.Networks;
if (mode.indexOf('container:') !== -1) {
delete config.Hostname;
delete config.ExposedPorts;
}
// Set volumes
var binds = [];
var volumes = {};
for (var v in container.Mounts) {
if ({}.hasOwnProperty.call(container.Mounts, v)) {
var mount = container.Mounts[v];
var volume = {
'type': mount.Type,
'name': mount.Name || mount.Source,
'containerPath': mount.Destination,
'readOnly': mount.RW === false
};
var name = mount.Name || mount.Source;
var containerPath = mount.Destination;
if (name && containerPath) {
var bind = name + ':' + containerPath;
volumes[containerPath] = {};
if (mount.RW === false) {
bind += ':ro';
}
binds.push(bind);
}
}
}
config.HostConfig.Binds = binds;
config.Volumes = volumes;
return config;
};
return helper;
}]);

View File

@ -1,4 +1,5 @@
function ContainerDetailsViewModel(data) {
this.Model = data;
this.Id = data.Id;
this.State = data.State;
this.Created = data.Created;

View File

@ -108,5 +108,19 @@ angular.module('portainer.services')
});
};
service.confirmExperimentalFeature = function(callback) {
service.confirm({
title: 'Experimental feature',
message: 'This feature is currently experimental, please use with caution.',
buttons: {
confirm: {
label: 'Continue',
className: 'btn-danger'
}
},
callback: callback
});
};
return service;
}]);