diff --git a/api/cron/scheduler.go b/api/cron/scheduler.go index 9f65b6d5a..42abee371 100644 --- a/api/cron/scheduler.go +++ b/api/cron/scheduler.go @@ -45,11 +45,7 @@ func (scheduler *JobScheduler) ScheduleEndpointSyncJob(endpointFilePath string, // ScheduleSnapshotJob schedules a cron job to create endpoint snapshots func (scheduler *JobScheduler) ScheduleSnapshotJob(interval string) error { job := newEndpointSnapshotJob(scheduler.endpointService, scheduler.snapshotter) - - err := job.Snapshot() - if err != nil { - return err - } + go job.Snapshot() return scheduler.cron.AddJob("@every "+interval, job) } diff --git a/api/http/handler/file/handler.go b/api/http/handler/file/handler.go index 464062be1..06a3c4737 100644 --- a/api/http/handler/file/handler.go +++ b/api/http/handler/file/handler.go @@ -34,7 +34,6 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") } - w.Header().Add("X-Frame-Options", "DENY") w.Header().Add("X-XSS-Protection", "1; mode=block") w.Header().Add("X-Content-Type-Options", "nosniff") handler.Handler.ServeHTTP(w, r) diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index 8a0ec0c69..704394766 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -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} diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 0b25bd389..de0c75523 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -114,7 +114,6 @@ func (bouncer *RequestBouncer) EndpointAccess(r *http.Request, endpoint *portain // mwSecureHeaders provides secure headers middleware for handlers. func mwSecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("X-Frame-Options", "DENY") w.Header().Add("X-XSS-Protection", "1; mode=block") w.Header().Add("X-Content-Type-Options", "nosniff") next.ServeHTTP(w, r) diff --git a/api/swagger.yaml b/api/swagger.yaml index 1fda7d40f..ba5745801 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -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: diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html index 9da49860c..a79fdc698 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html @@ -13,7 +13,7 @@
diff --git a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html index 20f575f41..0a647868d 100644 --- a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html +++ b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html @@ -68,8 +68,8 @@ - {{ item.Hostname }} - {{ item.Hostname }} + {{ item.Name || item.Hostname }} + {{ item.Name || item.Hostname }} {{ item.Role }} {{ item.CPUs / 1000000000 }} diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index e946afa17..c6d13fb6f 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -309,7 +309,7 @@
diff --git a/app/docker/views/services/create/createservice.html b/app/docker/views/services/create/createservice.html index 40d830f1a..2003ff97f 100644 --- a/app/docker/views/services/create/createservice.html +++ b/app/docker/views/services/create/createservice.html @@ -354,7 +354,7 @@
diff --git a/app/docker/views/swarm/visualizer/swarmvisualizer.html b/app/docker/views/swarm/visualizer/swarmvisualizer.html index 4f9e7f358..612354e25 100644 --- a/app/docker/views/swarm/visualizer/swarmvisualizer.html +++ b/app/docker/views/swarm/visualizer/swarmvisualizer.html @@ -84,7 +84,7 @@
- {{ node.Hostname }} + {{ node.Name || node.Hostname }} @@ -97,7 +97,7 @@
{{ node.Status }}
-
+
{{ task.ServiceName }}
Image: {{ task.Spec.ContainerSpec.Image | hideshasum }}
Status: {{ task.Status.State }}
diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 828c7ef41..d04e12b6e 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -287,8 +287,8 @@ angular.module('portainer.app', []) }; var stackCreation = { - name: 'portainer.stacks.new', - url: '/new', + name: 'portainer.newstack', + url: '/newstack', views: { 'content@': { templateUrl: 'app/portainer/views/stacks/create/createstack.html', diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html index 6c8dbbb79..5b640ea17 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html @@ -11,7 +11,7 @@ ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"> Remove -
diff --git a/app/portainer/components/endpoint-list/endpoint-list-controller.js b/app/portainer/components/endpoint-list/endpoint-list-controller.js new file mode 100644 index 000000000..aab801410 --- /dev/null +++ b/app/portainer/components/endpoint-list/endpoint-list-controller.js @@ -0,0 +1,60 @@ +angular.module('portainer.app').controller('EndpointListController', [ + function EndpointListController() { + var ctrl = this; + ctrl.state = { + textFilter: '', + filteredEndpoints: [] + }; + + ctrl.$onChanges = $onChanges; + ctrl.onFilterChanged = onFilterChanged; + + function $onChanges(changesObj) { + handleEndpointsChange(changesObj.endpoints); + } + + function handleEndpointsChange(endpoints) { + if (!endpoints) { + return; + } + if (!endpoints.currentValue) { + return; + } + + onFilterChanged(); + } + + function onFilterChanged() { + var filterValue = ctrl.state.textFilter; + ctrl.state.filteredEndpoints = filterEndpoints( + ctrl.endpoints, + filterValue + ); + } + + function filterEndpoints(endpoints, filterValue) { + if (!endpoints || !endpoints.length || !filterValue) { + return endpoints; + } + var keywords = filterValue.split(' '); + return _.filter(endpoints, function(endpoint) { + var statusString = convertStatusToString(endpoint.Status); + return _.every(keywords, function(keyword) { + var lowerCaseKeyword = keyword.toLowerCase(); + return ( + _.includes(endpoint.Name.toLowerCase(), lowerCaseKeyword) || + _.includes(endpoint.GroupName.toLowerCase(), lowerCaseKeyword) || + _.some(endpoint.Tags, function(tag) { + return _.includes(tag.toLowerCase(), lowerCaseKeyword); + }) || + _.includes(statusString, keyword) + ); + }); + }); + } + + function convertStatusToString(status) { + return status === 1 ? 'up' : 'down'; + } + } +]); diff --git a/app/portainer/components/endpoint-list/endpoint-list.js b/app/portainer/components/endpoint-list/endpoint-list.js index d6a4bd33e..a622c3db4 100644 --- a/app/portainer/components/endpoint-list/endpoint-list.js +++ b/app/portainer/components/endpoint-list/endpoint-list.js @@ -1,10 +1,6 @@ angular.module('portainer.app').component('endpointList', { templateUrl: 'app/portainer/components/endpoint-list/endpointList.html', - controller: function() { - this.state = { - textFilter: '' - }; - }, + controller: 'EndpointListController', bindings: { titleText: '@', titleIcon: '@', diff --git a/app/portainer/components/endpoint-list/endpointList.html b/app/portainer/components/endpoint-list/endpointList.html index 9b4850dd0..2886916f1 100644 --- a/app/portainer/components/endpoint-list/endpointList.html +++ b/app/portainer/components/endpoint-list/endpointList.html @@ -16,12 +16,17 @@
Loading...
-
+
No endpoint available.
diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js b/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js new file mode 100644 index 000000000..4e96696e2 --- /dev/null +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js @@ -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 + ); + } + } +]); diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form.html b/app/portainer/components/stack-duplication-form/stack-duplication-form.html new file mode 100644 index 000000000..6e270b7b0 --- /dev/null +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form.html @@ -0,0 +1,43 @@ +
+
+ Stack duplication / migration +
+
+ +

+ This feature allows you to duplicate or migrate this stack. +

+
+
+
+ +
+ + + +
+
+
\ No newline at end of file diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form.js b/app/portainer/components/stack-duplication-form/stack-duplication-form.js new file mode 100644 index 000000000..7f6180c39 --- /dev/null +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form.js @@ -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: '<' + } +}); diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index dafbc4379..37b7c10a9 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -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; }]); diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 85defd1e2..7a0c1427e 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -46,31 +46,15 @@
- -
-
- Stack migration -
-
- -

- This feature allows you to migrate this stack to an alternate compatible endpoint. -

-
-
- - -
-
-
- + +
diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js index 50a8651b2..55eba59c4 100644 --- a/app/portainer/views/stacks/edit/stackController.js +++ b/app/portainer/views/stacks/edit/stackController.js @@ -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; diff --git a/app/portainer/views/templates/templates.html b/app/portainer/views/templates/templates.html index 14bed010d..3149b2070 100644 --- a/app/portainer/views/templates/templates.html +++ b/app/portainer/views/templates/templates.html @@ -111,7 +111,7 @@
-