mirror of https://github.com/portainer/portainer
feat(stack-details): Add the ability to duplicate a stack (#2278)
* feat(stack-details): add duplicate-stack button * feat(stack-details): add stack-duplication-form component * feat(stack-details): add duplicate stack method on controller * feat(stack-details): add duplicate stack method * feat(stack-details): remove old duplication in progress flag * feat(stack-details): combine migration and duplication forms * feat(stack-details): pass new stack name to server * feat(stack-details): add option to rename migrated stack * feat(stack-details): disable both migrate/duplicate buttons * feat(stack-details): disable migration button on same endpoint * feat(stack-details): change duplicate icon * style(stack-details): remove whitespaces and fix pattern * feat(stack-details): add name to migration payload in swagger.yml * style(stack-details): add semicolon * bug(stack-details): toggle endpoints before and after duplicationpull/2322/head
parent
6e262e6e89
commit
9b4870d57e
|
@ -14,6 +14,7 @@ import (
|
|||
type stackMigratePayload struct {
|
||||
EndpointID int
|
||||
SwarmID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (payload *stackMigratePayload) Validate(r *http.Request) error {
|
||||
|
@ -89,11 +90,17 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
|
|||
stack.SwarmID = payload.SwarmID
|
||||
}
|
||||
|
||||
oldName := stack.Name
|
||||
if payload.Name != "" {
|
||||
stack.Name = payload.Name
|
||||
}
|
||||
|
||||
migrationError := handler.migrateStack(r, stack, targetEndpoint)
|
||||
if migrationError != nil {
|
||||
return migrationError
|
||||
}
|
||||
|
||||
stack.Name = oldName
|
||||
err = handler.deleteStack(stack, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
|
|
|
@ -4160,6 +4160,10 @@ definitions:
|
|||
type: "string"
|
||||
example: "jpofkc0i9uo9wtx1zesuk649w"
|
||||
description: "Swarm cluster identifier, must match the identifier of the cluster where the stack will be relocated"
|
||||
Name:
|
||||
type: "string"
|
||||
example: "new-stack"
|
||||
description: "If provided will rename the migrated stack"
|
||||
StackCreateRequest:
|
||||
type: "object"
|
||||
required:
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
angular.module('portainer.app').controller('StackDuplicationFormController', [
|
||||
'Notifications',
|
||||
function StackDuplicationFormController(Notifications) {
|
||||
var ctrl = this;
|
||||
|
||||
ctrl.state = {
|
||||
duplicationInProgress: false,
|
||||
migrationInProgress: false
|
||||
};
|
||||
|
||||
ctrl.formValues = {
|
||||
endpoint: null,
|
||||
newName: ''
|
||||
};
|
||||
|
||||
ctrl.isFormValidForDuplication = isFormValidForDuplication;
|
||||
ctrl.isFormValidForMigration = isFormValidForMigration;
|
||||
ctrl.duplicateStack = duplicateStack;
|
||||
ctrl.migrateStack = migrateStack;
|
||||
ctrl.isMigrationButtonDisabled = isMigrationButtonDisabled;
|
||||
|
||||
function isFormValidForMigration() {
|
||||
return ctrl.formValues.endpoint && ctrl.formValues.endpoint.Id;
|
||||
}
|
||||
|
||||
function isFormValidForDuplication() {
|
||||
return isFormValidForMigration() && ctrl.formValues.newName;
|
||||
}
|
||||
|
||||
function duplicateStack() {
|
||||
if (!ctrl.formValues.newName) {
|
||||
Notifications.error(
|
||||
'Failure',
|
||||
null,
|
||||
'Stack name is required for duplication'
|
||||
);
|
||||
return;
|
||||
}
|
||||
ctrl.state.duplicationInProgress = true;
|
||||
ctrl.onDuplicate({
|
||||
endpointId: ctrl.formValues.endpoint.Id,
|
||||
name: ctrl.formValues.newName ? ctrl.formValues.newName : undefined
|
||||
})
|
||||
.finally(function() {
|
||||
ctrl.state.duplicationInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
function migrateStack() {
|
||||
ctrl.state.migrationInProgress = true;
|
||||
ctrl.onMigrate({
|
||||
endpointId: ctrl.formValues.endpoint.Id,
|
||||
name: ctrl.formValues.newName ? ctrl.formValues.newName : undefined
|
||||
})
|
||||
.finally(function() {
|
||||
ctrl.state.migrationInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
function isMigrationButtonDisabled() {
|
||||
return (
|
||||
!ctrl.isFormValidForMigration() ||
|
||||
ctrl.state.duplicationInProgress ||
|
||||
ctrl.state.migrationInProgress ||
|
||||
isTargetEndpointAndCurrentEquals()
|
||||
);
|
||||
}
|
||||
|
||||
function isTargetEndpointAndCurrentEquals() {
|
||||
return (
|
||||
ctrl.formValues.endpoint &&
|
||||
ctrl.formValues.endpoint.Id === ctrl.currentEndpointId
|
||||
);
|
||||
}
|
||||
}
|
||||
]);
|
|
@ -0,0 +1,43 @@
|
|||
<div>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Stack duplication / migration
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="small" style="margin-top: 10px;">
|
||||
<p class="text-muted">
|
||||
This feature allows you to duplicate or migrate this stack.
|
||||
</p>
|
||||
</span>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<input class="form-control" placeholder="Stack name (optional for migration)"
|
||||
aria-placeholder="Stack name"
|
||||
ng-model="$ctrl.formValues.newName" />
|
||||
</div>
|
||||
<endpoint-selector ng-if="$ctrl.endpoints && $ctrl.groups" model="$ctrl.formValues.endpoint"
|
||||
endpoints="$ctrl.endpoints" groups="$ctrl.groups"></endpoint-selector>
|
||||
<button class="btn btn-sm btn-primary" ng-click="$ctrl.migrateStack()"
|
||||
ng-disabled="$ctrl.isMigrationButtonDisabled()"
|
||||
style="margin-top: 7px; margin-left: 0;"
|
||||
button-spinner="$ctrl.state.migrationInProgress">
|
||||
<span ng-hide="$ctrl.state.migrationInProgress">
|
||||
<i class="fa fa-long-arrow-alt-right space-right"
|
||||
aria-hidden="true"></i> Migrate
|
||||
</span>
|
||||
<span ng-show="$ctrl.state.migrationInProgress">Migration in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="$ctrl.duplicateStack()"
|
||||
ng-disabled="!$ctrl.isFormValidForDuplication() || $ctrl.state.duplicationInProgress || $ctrl.state.migrationInProgress"
|
||||
style="margin-top: 7px; margin-left: 0;"
|
||||
button-spinner="$ctrl.state.duplicationInProgress">
|
||||
<span ng-hide="$ctrl.state.duplicationInProgress">
|
||||
<i class="fa fa-clone space-right"
|
||||
aria-hidden="true"></i> Duplicate
|
||||
</span>
|
||||
<span ng-show="$ctrl.state.duplicationInProgress">Duplication in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,12 @@
|
|||
angular.module('portainer.app').component('stackDuplicationForm', {
|
||||
templateUrl:
|
||||
'app/portainer/components/stack-duplication-form/stack-duplication-form.html',
|
||||
controller: 'StackDuplicationFormController',
|
||||
bindings: {
|
||||
onDuplicate: '&',
|
||||
onMigrate: '&',
|
||||
endpoints: '<',
|
||||
groups: '<',
|
||||
currentEndpointId: '<'
|
||||
}
|
||||
});
|
|
@ -4,6 +4,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic
|
|||
'use strict';
|
||||
var service = {};
|
||||
|
||||
|
||||
service.stack = function(id) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
|
@ -33,7 +34,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic
|
|||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.migrateSwarmStack = function(stack, targetEndpointId) {
|
||||
service.migrateSwarmStack = function(stack, targetEndpointId, newName) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
EndpointProvider.setEndpointID(targetEndpointId);
|
||||
|
@ -45,8 +46,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic
|
|||
deferred.reject({ msg: 'Target endpoint is located in the same Swarm cluster as the current endpoint', err: null });
|
||||
return;
|
||||
}
|
||||
|
||||
return Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.Id }).$promise;
|
||||
return Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.Id, Name: newName }).$promise;
|
||||
})
|
||||
.then(function success() {
|
||||
deferred.resolve();
|
||||
|
@ -61,12 +61,12 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic
|
|||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.migrateComposeStack = function(stack, targetEndpointId) {
|
||||
service.migrateComposeStack = function(stack, targetEndpointId, newName) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
EndpointProvider.setEndpointID(targetEndpointId);
|
||||
|
||||
Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId }).$promise
|
||||
Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, Name: newName }).$promise
|
||||
.then(function success() {
|
||||
deferred.resolve();
|
||||
})
|
||||
|
@ -258,8 +258,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic
|
|||
var deferred = $q.defer();
|
||||
|
||||
SwarmService.swarm()
|
||||
.then(function success(data) {
|
||||
var swarm = data;
|
||||
.then(function success(swarm) {
|
||||
var payload = {
|
||||
Name: name,
|
||||
SwarmID: swarm.Id,
|
||||
|
@ -321,5 +320,10 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic
|
|||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.duplicateStack = function duplicateStack(name, stackFileContent, env, endpointId, type) {
|
||||
var action = type === 1 ? service.createSwarmStackFromFileContent : service.createComposeStackFromFileContent;
|
||||
return action(name, stackFileContent, env, endpointId);
|
||||
};
|
||||
|
||||
return service;
|
||||
}]);
|
||||
|
|
|
@ -46,31 +46,15 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- !stack-details -->
|
||||
<!-- stack-migration -->
|
||||
<div ng-if="!state.externalStack && endpoints.length > 0">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Stack migration
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="small" style="margin-top: 10px;">
|
||||
<p class="text-muted">
|
||||
This feature allows you to migrate this stack to an alternate compatible endpoint.
|
||||
</p>
|
||||
</span>
|
||||
<div>
|
||||
<endpoint-selector ng-if="endpoints && groups"
|
||||
model="formValues.Endpoint"
|
||||
endpoints="endpoints"
|
||||
groups="groups"
|
||||
></endpoint-selector>
|
||||
<button class="btn btn-sm btn-primary" ng-click="migrateStack()" ng-disabled="!formValues.Endpoint || state.migrationInProgress" style="margin-top: 7px; margin-left: 0;" button-spinner="state.migrationInProgress">
|
||||
<span ng-hide="state.migrationInProgress"><i class="fa fa-long-arrow-alt-right space-right" aria-hidden="true"></i> Migrate</span>
|
||||
<span ng-show="state.migrationInProgress">Migration in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !stack-migration -->
|
||||
<stack-duplication-form
|
||||
ng-if="!state.externalStack && endpoints.length > 0"
|
||||
endpoints="endpoints"
|
||||
groups="groups"
|
||||
current-endpoint-id="currentEndpointId"
|
||||
on-duplicate="duplicateStack(name, endpointId)"
|
||||
on-migrate="migrateStack(name, endpointId)"
|
||||
>
|
||||
</stack-duplication-form>
|
||||
</div>
|
||||
</uib-tab>
|
||||
<!-- !tab-info -->
|
||||
|
|
|
@ -14,24 +14,47 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe
|
|||
Endpoint: null
|
||||
};
|
||||
|
||||
$scope.duplicateStack = function duplicateStack(name, endpointId) {
|
||||
var stack = $scope.stack;
|
||||
var env = FormHelper.removeInvalidEnvVars(stack.Env);
|
||||
EndpointProvider.setEndpointID(endpointId);
|
||||
|
||||
return StackService.duplicateStack(name, $scope.stackFileContent, env, endpointId, stack.Type)
|
||||
.then(onDuplicationSuccess)
|
||||
.catch(notifyOnError);
|
||||
|
||||
function onDuplicationSuccess() {
|
||||
Notifications.success('Stack successfully duplicated');
|
||||
$state.go('portainer.stacks', {}, { reload: true });
|
||||
EndpointProvider.setEndpointID(stack.EndpointId);
|
||||
|
||||
}
|
||||
|
||||
function notifyOnError(err) {
|
||||
Notifications.error('Failure', err, 'Unable to duplicate stack');
|
||||
}
|
||||
};
|
||||
|
||||
$scope.showEditor = function() {
|
||||
$scope.state.showEditorTab = true;
|
||||
};
|
||||
|
||||
$scope.migrateStack = function() {
|
||||
ModalService.confirm({
|
||||
title: 'Are you sure?',
|
||||
message: 'This action will deploy a new instance of this stack on the target endpoint, please note that this does NOT relocate the content of any persistent volumes that may be attached to this stack.',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Migrate',
|
||||
className: 'btn-danger'
|
||||
$scope.migrateStack = function (name, endpointId) {
|
||||
return $q(function (resolve) {
|
||||
ModalService.confirm({
|
||||
title: 'Are you sure?',
|
||||
message: 'This action will deploy a new instance of this stack on the target endpoint, please note that this does NOT relocate the content of any persistent volumes that may be attached to this stack.',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Migrate',
|
||||
className: 'btn-danger'
|
||||
}
|
||||
},
|
||||
callback: function onConfirm(confirmed) {
|
||||
if (!confirmed) { return resolve(); }
|
||||
return resolve(migrateStack(name, endpointId));
|
||||
}
|
||||
},
|
||||
callback: function onConfirm(confirmed) {
|
||||
if(!confirmed) { return; }
|
||||
migrateStack();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -45,9 +68,9 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe
|
|||
);
|
||||
};
|
||||
|
||||
function migrateStack() {
|
||||
function migrateStack(name, endpointId) {
|
||||
var stack = $scope.stack;
|
||||
var targetEndpointId = $scope.formValues.Endpoint.Id;
|
||||
var targetEndpointId = endpointId;
|
||||
|
||||
var migrateRequest = StackService.migrateSwarmStack;
|
||||
if (stack.Type === 2) {
|
||||
|
@ -58,13 +81,13 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe
|
|||
// The EndpointID property is not available for these stacks, we can pass
|
||||
// the current endpoint identifier as a part of the migrate request. It will be used if
|
||||
// the EndpointID property is not defined on the stack.
|
||||
var endpointId = EndpointProvider.endpointID();
|
||||
var originalEndpointId = EndpointProvider.endpointID();
|
||||
if (stack.EndpointId === 0) {
|
||||
stack.EndpointId = endpointId;
|
||||
stack.EndpointId = originalEndpointId;
|
||||
}
|
||||
|
||||
$scope.state.migrationInProgress = true;
|
||||
migrateRequest(stack, targetEndpointId)
|
||||
return migrateRequest(stack, targetEndpointId, name)
|
||||
.then(function success() {
|
||||
Notifications.success('Stack successfully migrated', stack.Name);
|
||||
$state.go('portainer.stacks', {}, {reload: true});
|
||||
|
@ -134,7 +157,6 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe
|
|||
|
||||
function loadStack(id) {
|
||||
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
|
||||
var endpointId = EndpointProvider.endpointID();
|
||||
|
||||
$q.all({
|
||||
stack: StackService.stack(id),
|
||||
|
@ -143,9 +165,7 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe
|
|||
})
|
||||
.then(function success(data) {
|
||||
var stack = data.stack;
|
||||
$scope.endpoints = data.endpoints.filter(function(endpoint) {
|
||||
return endpoint.Id !== endpointId;
|
||||
});
|
||||
$scope.endpoints = data.endpoints;
|
||||
$scope.groups = data.groups;
|
||||
$scope.stack = stack;
|
||||
|
||||
|
@ -256,6 +276,7 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe
|
|||
var stackName = $transition$.params().name;
|
||||
$scope.stackName = stackName;
|
||||
var external = $transition$.params().external;
|
||||
$scope.currentEndpointId = EndpointProvider.endpointID();
|
||||
|
||||
if (external === 'true') {
|
||||
$scope.state.externalStack = true;
|
||||
|
|
Loading…
Reference in New Issue