From 313c8be997a4d000cb409557bfab0bea4ae26616 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sat, 15 Sep 2018 19:26:03 +0800 Subject: [PATCH 01/93] chore(version): bump version number --- api/portainer.go | 2 +- api/swagger.yaml | 4 ++-- distribution/portainer.spec | 2 +- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/portainer.go b/api/portainer.go index 6367b14e0..2db839506 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -639,7 +639,7 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "1.19.2" + APIVersion = "1.20-dev" // DBVersion is the version number of the Portainer database DBVersion = 14 // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved diff --git a/api/swagger.yaml b/api/swagger.yaml index 890c5fc2a..1fda7d40f 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -54,7 +54,7 @@ info: **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). - version: "1.19.2" + version: "1.20-dev" title: "Portainer API" contact: email: "info@portainer.io" @@ -2816,7 +2816,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.19.2" + example: "1.20-dev" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" diff --git a/distribution/portainer.spec b/distribution/portainer.spec index af2337e91..abbd4b371 100644 --- a/distribution/portainer.spec +++ b/distribution/portainer.spec @@ -1,5 +1,5 @@ Name: portainer -Version: 1.19.2 +Version: 1.20-dev Release: 0 License: Zlib Summary: A lightweight docker management UI diff --git a/package.json b/package.json index ffe21637b..2e60ee1ea 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "1.19.2", + "version": "1.20-dev", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git" From 22450bbdebc47f98071c241a4c74842143683e4a Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 16 Sep 2018 10:34:46 +0800 Subject: [PATCH 02/93] chore(build): update build script and add grunt yarn script (#2276) --- build.sh | 2 +- package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/build.sh b/build.sh index 36e86b8a9..c00f5dd0b 100755 --- a/build.sh +++ b/build.sh @@ -24,7 +24,7 @@ function build_archive() { function build_all() { mkdir -pv "${ARCHIVE_BUILD_FOLDER}" for tag in $@; do - grunt "release:`echo "$tag" | tr '-' ':'`" + yarn grunt "release:`echo "$tag" | tr '-' ':'`" name="portainer"; if [ "$(echo "$tag" | cut -c1)" = "w" ]; then name="${name}.exe"; fi mv dist/portainer-$tag* dist/$name if [ `echo $tag | cut -d \- -f 1` == 'linux' ]; then build_and_push_images "$tag"; fi diff --git a/package.json b/package.json index 2e60ee1ea..6d7486278 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ } ], "scripts": { + "grunt": "grunt", "dev": "yarn grunt run-dev", "clean-all": "yarn grunt clean:all", "build": "yarn grunt build", From b192b098ca0e6b9925f9ce6eb30e8b714961c6d1 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 17 Sep 2018 09:26:37 +0800 Subject: [PATCH 03/93] feat(build-system): update shippedDockerVersion to 18.06.1-ce (#2281) --- gruntfile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gruntfile.js b/gruntfile.js index 79e05c95d..873d55473 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -80,7 +80,7 @@ module.exports = function (grunt) { grunt.initConfig({ root: 'dist', distdir: 'dist/public', - shippedDockerVersion: '18.03.1-ce', + shippedDockerVersion: '18.06.1-ce', shippedDockerVersionWindows: '17.09.0-ce', pkg: grunt.file.readJSON('package.json'), config: gruntfile_cfg.config, From c3d80a1b21b6c6b2f4f824069693bed645929717 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 19 Sep 2018 11:40:06 +0800 Subject: [PATCH 04/93] docs(project): update CONTRIBUTING.md --- CONTRIBUTING.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ddc75e8c..e26559a7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,14 +77,14 @@ The subject contains succinct description of the change: ## Contribution process -Our contribution process is described below. Some of the steps can be visualized inside Github via specific `contrib/` labels, such as `contrib/func-review-in-progress` or `contrib/tech-review-approved`. +Our contribution process is described below. Some of the steps can be visualized inside Github via specific `status/` labels, such as `status/1-functional-review` or `status/2-technical-review`. ### Bug report -![portainer_bugreport_workflow](https://user-images.githubusercontent.com/5485061/43569306-5571b3a0-9637-11e8-8559-786cfc82a14f.png) +![portainer_bugreport_workflow](https://user-images.githubusercontent.com/5485061/45727219-50190a00-bbf5-11e8-9fe8-3a563bb8d5d7.png) ### Feature request -The feature request process is similar to the bug report process but has an extra functional validation before the technical validation. +The feature request process is similar to the bug report process but has an extra functional validation before the technical validation as well as a documentation validation before the testing phase. -![portainer_featurerequest_workflow](https://user-images.githubusercontent.com/5485061/43569315-5d30a308-9637-11e8-8292-3c62b5612925.png) +![portainer_featurerequest_workflow](https://user-images.githubusercontent.com/5485061/45727229-5ad39f00-bbf5-11e8-9550-16ba66c50615.png) From d5dd362d53ae60982b8ea5aa325d49d4f7f497c1 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 24 Sep 2018 12:09:12 +1200 Subject: [PATCH 05/93] =?UTF-8?q?feat(api):=20update=20client.Get=20with?= =?UTF-8?q?=20a=20new=20timeout=20parameter=20and=20default=E2=80=A6=20(#2?= =?UTF-8?q?297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): update client.Get with a new timeout parameter and default to 5s * fix(api): fix invalid type --- api/http/client/client.go | 15 +++++++++++---- api/http/handler/motd/motd.go | 2 +- api/http/handler/templates/template_list.go | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/api/http/client/client.go b/api/http/client/client.go index 541ec8257..8892b6472 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -15,6 +15,7 @@ import ( const ( errInvalidResponseStatus = portainer.Error("Invalid response status (expecting 200)") + defaultHTTPTimeout = 5 ) // HTTPClient represents a client to send HTTP requests. @@ -26,7 +27,7 @@ type HTTPClient struct { func NewHTTPClient() *HTTPClient { return &HTTPClient{ &http.Client{ - Timeout: time.Second * 5, + Timeout: time.Second * time.Duration(defaultHTTPTimeout), }, } } @@ -67,10 +68,16 @@ func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portain } // Get executes a simple HTTP GET to the specified URL and returns -// the content of the response body. -func Get(url string) ([]byte, error) { +// the content of the response body. Timeout can be specified via the timeout parameter, +// will default to defaultHTTPTimeout if set to 0. +func Get(url string, timeout int) ([]byte, error) { + + if timeout == 0 { + timeout = defaultHTTPTimeout + } + client := &http.Client{ - Timeout: time.Second * 3, + Timeout: time.Second * time.Duration(timeout), } response, err := client.Get(url) diff --git a/api/http/handler/motd/motd.go b/api/http/handler/motd/motd.go index fbe8b5acd..ca279d890 100644 --- a/api/http/handler/motd/motd.go +++ b/api/http/handler/motd/motd.go @@ -16,7 +16,7 @@ type motdResponse struct { func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) { - motd, err := client.Get(portainer.MessageOfTheDayURL) + motd, err := client.Get(portainer.MessageOfTheDayURL, 0) if err != nil { w.WriteHeader(http.StatusInternalServerError) return diff --git a/api/http/handler/templates/template_list.go b/api/http/handler/templates/template_list.go index 24ca93bbd..7e802528f 100644 --- a/api/http/handler/templates/template_list.go +++ b/api/http/handler/templates/template_list.go @@ -26,7 +26,7 @@ func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *ht } } else { var templateData []byte - templateData, err = client.Get(settings.TemplatesURL) + templateData, err = client.Get(settings.TemplatesURL, 0) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve external templates", err} } From 94b202fedc3a2bd94a0dbc187f26dd9b5d4373da Mon Sep 17 00:00:00 2001 From: Lukas Joergensen Date: Tue, 25 Sep 2018 01:10:41 +0200 Subject: [PATCH 06/93] fix(authentication): escape LDAP filters (#2209) --- api/ldap/ldap.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/ldap/ldap.go b/api/ldap/ldap.go index 528a92e7f..05c9d55d7 100644 --- a/api/ldap/ldap.go +++ b/api/ldap/ldap.go @@ -22,11 +22,13 @@ type Service struct{} func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) { var userDN string found := false + usernameEscaped := ldap.EscapeFilter(username) + for _, searchSettings := range settings { searchRequest := ldap.NewSearchRequest( searchSettings.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, username), + fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, usernameEscaped), []string{"dn"}, nil, ) @@ -134,12 +136,13 @@ func (*Service) GetUserGroups(username string, settings *portainer.LDAPSettings) // Get a list of group names for specified user from LDAP/AD func getGroups(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string { groups := make([]string, 0) + userDNEscaped := ldap.EscapeFilter(userDN) for _, searchSettings := range settings { searchRequest := ldap.NewSearchRequest( searchSettings.GroupBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&%s(%s=%s))", searchSettings.GroupFilter, searchSettings.GroupAttribute, userDN), + fmt.Sprintf("(&%s(%s=%s))", searchSettings.GroupFilter, searchSettings.GroupAttribute, userDNEscaped), []string{"cn"}, nil, ) From f0f01c33bd9df57de33cb2fc056c6b7cb367a821 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 26 Sep 2018 18:59:50 +1200 Subject: [PATCH 07/93] feat(endpoint-creation): add requirement message for agent endpoint (#2303) --- .../endpoints/create/createEndpointController.js | 10 ++++++++-- .../views/endpoints/create/createendpoint.html | 12 ++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/portainer/views/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js index 13fc1ad16..c682bee1b 100644 --- a/app/portainer/views/endpoints/create/createEndpointController.js +++ b/app/portainer/views/endpoints/create/createEndpointController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('CreateEndpointController', ['$q', '$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'TagService', 'Notifications', -function ($q, $scope, $state, $filter, EndpointService, GroupService, TagService, Notifications) { +.controller('CreateEndpointController', ['$q', '$scope', '$state', '$filter', 'clipboard', 'EndpointService', 'GroupService', 'TagService', 'Notifications', +function ($q, $scope, $state, $filter, clipboard, EndpointService, GroupService, TagService, Notifications) { $scope.state = { EnvironmentType: 'docker', @@ -19,6 +19,12 @@ function ($q, $scope, $state, $filter, EndpointService, GroupService, TagService Tags: [] }; + $scope.copyAgentCommand = function() { + clipboard.copyText('curl -L https://portainer.io/download/agent-stack.yml -o agent-stack.yml && docker stack deploy --compose-file=agent-stack.yml portainer-agent'); + $('#copyNotification').show(); + $('#copyNotification').fadeOut(2000); + }; + $scope.addDockerEndpoint = function() { var name = $scope.formValues.Name; var URL = $filter('stripprotocol')($scope.formValues.URL); diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html index ff8e7c232..4b7a762bd 100644 --- a/app/portainer/views/endpoints/create/createendpoint.html +++ b/app/portainer/views/endpoints/create/createendpoint.html @@ -64,8 +64,16 @@
- If you have started Portainer in the same overlay network as the agent, you can use tasks.AGENT_SERVICE_NAME:AGENT_SERVICE_PORT as the - endpoint URL format. + Ensure that you have deployed the Portainer agent in your cluster first. You can use execute the following command on any manager node to deploy it. +
+ + curl -L https://portainer.io/download/agent-stack.yml -o agent-stack.yml && docker stack deploy --compose-file=agent-stack.yml portainer-agent + + Copy + + + +
From 92b15523f07d85cd577ef7c09e19c496c07f3755 Mon Sep 17 00:00:00 2001 From: Angele Date: Fri, 28 Sep 2018 00:49:30 +0200 Subject: [PATCH 08/93] feat(containers): add container name in error notification * containersDatable: add containers name if error on executeActionOnContainerList * Update containersDatatableActionsController.js * Update containersDatatableActionsController.js --- .../actions/containersDatatableActionsController.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js index 88980f8f3..efacb89e7 100644 --- a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js @@ -72,6 +72,7 @@ function ($state, ContainerService, ModalService, Notifications, HttpRequestHelp Notifications.success(successMessage, container.Names[0]); }) .catch(function error(err) { + errorMessage = errorMessage + ':' + container.Names[0]; Notifications.error('Failure', err, errorMessage); }) .finally(function final() { From 226c45f0355f9897e5e4762db2851b1081a8c8ee Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 28 Sep 2018 06:06:47 +0300 Subject: [PATCH 09/93] fix(template-creation): fix an issue related to the network setting (#2312) * bug(template): pass network name on creation * bug(templates): choose network object on update * fix(templates): set network only when available --- app/portainer/models/template.js | 2 +- app/portainer/views/templates/edit/templateController.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/portainer/models/template.js b/app/portainer/models/template.js index 0218ce8a2..6b9509958 100644 --- a/app/portainer/models/template.js +++ b/app/portainer/models/template.js @@ -25,7 +25,7 @@ function TemplateCreateRequest(model) { this.Image = model.Image; this.Registry = model.Registry.URL; this.Command = model.Command; - this.Network = model.Network; + this.Network = model.Network && model.Network.Name; this.Privileged = model.Privileged; this.Interactive = model.Interactive; this.RestartPolicy = model.RestartPolicy; diff --git a/app/portainer/views/templates/edit/templateController.js b/app/portainer/views/templates/edit/templateController.js index ccf82d228..352764e43 100644 --- a/app/portainer/views/templates/edit/templateController.js +++ b/app/portainer/views/templates/edit/templateController.js @@ -38,6 +38,12 @@ function ($q, $scope, $state, $transition$, TemplateService, TemplateHelper, Net ) }) .then(function success(data) { + var template = data.template; + if (template.Network) { + template.Network = _.find(data.networks, function(o) { return o.Name === template.Network; }); + } else { + template.Network = _.find(data.networks, function(o) { return o.Name === 'bridge'; }); + } $scope.categories = TemplateHelper.getUniqueCategories(data.templates); $scope.template = data.template; $scope.networks = data.networks; From 5be2684442db3716a41067f2eb60a017ec8c5df5 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 30 Sep 2018 01:20:10 +0300 Subject: [PATCH 10/93] feat(home): add the ability to edit an endpoint (#2305) * feat(home): add edit button * feat(home): style edit button * feat(home): make endpoint editable on admin only --- .../endpoint-item/endpoint-item-controller.js | 12 ++++++++++++ .../endpoint-list/endpoint-item/endpointItem.html | 12 ++++++++++-- .../endpoint-list/endpoint-item/endpointItem.js | 7 +++++-- .../components/endpoint-list/endpoint-list.js | 4 +++- .../components/endpoint-list/endpointList.html | 2 ++ app/portainer/views/home/home.html | 2 ++ app/portainer/views/home/homeController.js | 6 ++++++ 7 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 app/portainer/components/endpoint-list/endpoint-item/endpoint-item-controller.js diff --git a/app/portainer/components/endpoint-list/endpoint-item/endpoint-item-controller.js b/app/portainer/components/endpoint-list/endpoint-item/endpoint-item-controller.js new file mode 100644 index 000000000..62433ee25 --- /dev/null +++ b/app/portainer/components/endpoint-list/endpoint-item/endpoint-item-controller.js @@ -0,0 +1,12 @@ +angular.module('portainer.app').controller('EndpointItemController', [ + function EndpointItemController() { + var ctrl = this; + + ctrl.editEndpoint = editEndpoint; + + function editEndpoint(event) { + event.stopPropagation(); + ctrl.onEdit(ctrl.model.Id); + } + } +]); diff --git a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html index 322ab9a90..6e643da5b 100644 --- a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html +++ b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html @@ -21,8 +21,16 @@ - - Group: {{ $ctrl.model.GroupName }} + + + + Group: {{ $ctrl.model.GroupName }} + + diff --git a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.js b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.js index d04fb25cf..450fa089d 100644 --- a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.js +++ b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.js @@ -2,6 +2,9 @@ angular.module('portainer.app').component('endpointItem', { templateUrl: 'app/portainer/components/endpoint-list/endpoint-item/endpointItem.html', bindings: { model: '<', - onSelect: '<' - } + onSelect: '<', + onEdit: '<', + isAdmin:'<' + }, + controller: 'EndpointItemController' }); diff --git a/app/portainer/components/endpoint-list/endpoint-list.js b/app/portainer/components/endpoint-list/endpoint-list.js index d11d7611d..d6a4bd33e 100644 --- a/app/portainer/components/endpoint-list/endpoint-list.js +++ b/app/portainer/components/endpoint-list/endpoint-list.js @@ -11,6 +11,8 @@ angular.module('portainer.app').component('endpointList', { endpoints: '<', dashboardAction: '<', snapshotAction: '<', - showSnapshotAction: '<' + showSnapshotAction: '<', + editAction: '<', + isAdmin:'<' } }); diff --git a/app/portainer/components/endpoint-list/endpointList.html b/app/portainer/components/endpoint-list/endpointList.html index 9110703f9..9b4850dd0 100644 --- a/app/portainer/components/endpoint-list/endpointList.html +++ b/app/portainer/components/endpoint-list/endpointList.html @@ -24,6 +24,8 @@ ng-repeat="endpoint in $ctrl.endpoints | filter:$ctrl.state.textFilter" model="endpoint" on-select="$ctrl.dashboardAction" + on-edit="$ctrl.editAction" + is-admin="$ctrl.isAdmin" >
Loading... diff --git a/app/portainer/views/home/home.html b/app/portainer/views/home/home.html index e9de46386..1ffaa88bd 100644 --- a/app/portainer/views/home/home.html +++ b/app/portainer/views/home/home.html @@ -44,6 +44,8 @@ dashboard-action="goToDashboard" show-snapshot-action="!applicationState.application.authentication || isAdmin" snapshot-action="triggerSnapshot" + edit-action="goToEdit" + is-admin="isAdmin" >
diff --git a/app/portainer/views/home/homeController.js b/app/portainer/views/home/homeController.js index 684c1ff7e..f666e49d7 100644 --- a/app/portainer/views/home/homeController.js +++ b/app/portainer/views/home/homeController.js @@ -62,6 +62,12 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G }); } + $scope.goToEdit = goToEdit; + + function goToEdit(id) { + $state.go('portainer.endpoints.endpoint', { id: id }); + } + function initView() { $scope.isAdmin = Authentication.getUserDetails().role === 1; From 6e262e6e8998a6550d8c477644f5d9b3b40f1123 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 30 Sep 2018 23:06:58 +0300 Subject: [PATCH 11/93] feat(home): support search in multiple fields (name, group, tag, status) (#2285) * feat(home): search multiple fields (group/tag) * feat(home): change search from "OR" to "AND" * feat(home): search only for a tag or a group * feat(home): search by keywords in name,group,tag * feat(home): support case insensitive search * style(home): remove unused $filter * feat(home): search state * style(home): update search input placeholder --- .../endpoint-list/endpoint-list-controller.js | 60 +++++++++++++++++++ .../components/endpoint-list/endpoint-list.js | 6 +- .../endpoint-list/endpointList.html | 11 +++- 3 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 app/portainer/components/endpoint-list/endpoint-list-controller.js 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.
From 9b4870d57e97ec6dedf29147726d335187efb498 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 1 Oct 2018 04:36:49 +0300 Subject: [PATCH 12/93] 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 duplication --- api/http/handler/stacks/stack_migrate.go | 7 ++ api/swagger.yaml | 4 + .../stack-duplication-form-controller.js | 76 +++++++++++++++++++ .../stack-duplication-form.html | 43 +++++++++++ .../stack-duplication-form.js | 12 +++ app/portainer/services/api/stackService.js | 18 +++-- app/portainer/views/stacks/edit/stack.html | 34 +++------ .../views/stacks/edit/stackController.js | 65 ++++++++++------ 8 files changed, 205 insertions(+), 54 deletions(-) create mode 100644 app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js create mode 100644 app/portainer/components/stack-duplication-form/stack-duplication-form.html create mode 100644 app/portainer/components/stack-duplication-form/stack-duplication-form.js 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/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/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; From bad95987ec697e5945755adc7216b5017f85090a Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 1 Oct 2018 04:38:14 +0300 Subject: [PATCH 13/93] feat(backend): trigger startup snapshot job in a goroutine (#2309) * feat(backend): wrap init enpoint with goroutine * feat(backend): wrap job snapshot with goroutine * feat(snapshots): reset changes for main and job_endpoint * feat(snapshot): run first job.snapshot as a goroutine --- api/cron/scheduler.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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) } From 6e8a10d72ff4c833979ca76ee3c9d3c3b1a3dff2 Mon Sep 17 00:00:00 2001 From: Tolik Litovsky Date: Wed, 3 Oct 2018 14:18:03 +1300 Subject: [PATCH 14/93] fix(api): remove x-frame-options header (#2322) --- api/http/handler/file/handler.go | 1 - api/http/security/bouncer.go | 1 - 2 files changed, 2 deletions(-) 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/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) From b7c48fcbed0fc174e313ccc4cf60ff2a2eb0337c Mon Sep 17 00:00:00 2001 From: Brian Kabiro Date: Thu, 4 Oct 2018 01:57:07 +0300 Subject: [PATCH 15/93] feat(visualizer): sort tasks in alphabetical order on refresh (#2329) - sort the tasks on each node in alphabetical order to make it easier to track what has changed --- app/docker/views/swarm/visualizer/swarmvisualizer.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/docker/views/swarm/visualizer/swarmvisualizer.html b/app/docker/views/swarm/visualizer/swarmvisualizer.html index 4f9e7f358..70716f2e6 100644 --- a/app/docker/views/swarm/visualizer/swarmvisualizer.html +++ b/app/docker/views/swarm/visualizer/swarmvisualizer.html @@ -97,7 +97,7 @@
{{ node.Status }}
-
+
{{ task.ServiceName }}
Image: {{ task.Spec.ContainerSpec.Image | hideshasum }}
Status: {{ task.Status.State }}
From 575735a6f751502e856d881467ae362631848137 Mon Sep 17 00:00:00 2001 From: Ricardo Cardona Ramirez Date: Wed, 3 Oct 2018 18:04:38 -0500 Subject: [PATCH 16/93] feat(ux): sort networks alphabetically in network selection dropdowns (#2326) * Sort network lists --- .../containerNetworksDatatable.html | 2 +- app/docker/views/containers/create/createcontainer.html | 2 +- app/docker/views/services/create/createservice.html | 2 +- app/portainer/views/templates/templates.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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/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 @@
-
From f6d9a4c7c120f19033bb90b33c61bb675afb5136 Mon Sep 17 00:00:00 2001 From: Brian Kabiro Date: Thu, 4 Oct 2018 02:07:31 +0300 Subject: [PATCH 17/93] feat(nodes): display node name when available (#2328) - check if the name of a node is available, otherwise default to the Hostname --- .../components/datatables/nodes-datatable/nodesDatatable.html | 4 ++-- app/docker/views/swarm/visualizer/swarmvisualizer.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/swarm/visualizer/swarmvisualizer.html b/app/docker/views/swarm/visualizer/swarmvisualizer.html index 70716f2e6..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 }} From 342266219105a2c056b31e274bf6f1bd86e021e3 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 4 Oct 2018 13:28:39 +1300 Subject: [PATCH 18/93] fix(app): fix invalid state name (#2330) * fix(app): fix invalid state name * fix(app): update ui-sref --- app/portainer/__module.js | 4 ++-- .../datatables/stacks-datatable/stacksDatatable.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 -
From 275fcf558741aadcd056b54eb4639ffb8b9cf9db Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 8 Oct 2018 01:34:47 +0300 Subject: [PATCH 19/93] fix(volume-browser): move volume id to query params (#2338) --- app/agent/rest/browse.js | 10 +++++----- app/agent/services/volumeBrowserService.js | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/agent/rest/browse.js b/app/agent/rest/browse.js index b4e8d53e0..9496768eb 100644 --- a/app/agent/rest/browse.js +++ b/app/agent/rest/browse.js @@ -1,22 +1,22 @@ angular.module('portainer.agent') .factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:id/:action', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:action', { endpointId: EndpointProvider.endpointID }, { ls: { - method: 'GET', isArray: true, params: { id: '@id', action: 'ls' } + method: 'GET', isArray: true, params: { action: 'ls' } }, get: { - method: 'GET', params: { id: '@id', action: 'get' }, + method: 'GET', params: { action: 'get' }, transformResponse: browseGetResponse }, delete: { - method: 'DELETE', params: { id: '@id', action: 'delete' } + method: 'DELETE', params: { action: 'delete' } }, rename: { - method: 'PUT', params: { id: '@id', action: 'rename' } + method: 'PUT', params: { action: 'rename' } } }); }]); diff --git a/app/agent/services/volumeBrowserService.js b/app/agent/services/volumeBrowserService.js index 22b020494..2a5c92f56 100644 --- a/app/agent/services/volumeBrowserService.js +++ b/app/agent/services/volumeBrowserService.js @@ -4,15 +4,15 @@ angular.module('portainer.agent') var service = {}; service.ls = function(volumeId, path) { - return Browse.ls({ 'id': volumeId, 'path': path }).$promise; + return Browse.ls({ volumeID: volumeId, path: path }).$promise; }; service.get = function(volumeId, path) { - return Browse.get({ 'id': volumeId, 'path': path }).$promise; + return Browse.get({ volumeID: volumeId, path: path }).$promise; }; service.delete = function(volumeId, path) { - return Browse.delete({ 'id': volumeId, 'path': path }).$promise; + return Browse.delete({ volumeID: volumeId, path: path }).$promise; }; service.rename = function(volumeId, path, newPath) { @@ -20,7 +20,7 @@ angular.module('portainer.agent') CurrentFilePath: path, NewFilePath: newPath }; - return Browse.rename({ 'id': volumeId }, payload).$promise; + return Browse.rename({ volumeID: volumeId }, payload).$promise; }; return service; From ca08b2fa2a14bcece593fe587535a80a724cb6a4 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 8 Oct 2018 01:44:08 +0300 Subject: [PATCH 20/93] feat(host): replace engine view with host view (#2255) * feat(engine-details): remove old panels * feat(engine-details): add basic engine-details-panel component * feat(engine-details): pass details to the different components * feat(engine-details): replace host-view with host-overview * feat(engine-details): add commaseperated filter * feat(engine-details): add host-view container component * feat(engine-details): add host-details component * feat(engine-details): build host details object * feat(engine-details): format engine version * feat(engine-details): get details for one node * feat(engine-details): pass is-agent from view * feat(engine-details): replace old node view with a new component * feat(engine-details): add swarm-node-details component * feat(engine-details): remove isSwarm binding * feat(engine-details): remove node-details and include in parent * feat(engine-details): add labels-table component * feat(engine-details): add update node service * feat(engine-details): add update label functionality * style(engine-details): remove whitespaces * feat(engine-details): remove old node page * feat(engine-details): pass is agent to host details * feat(host-details): hide missing info * feat(host-details): update node availability * style(host-details): remove obsolete event object * feat(host-details): fix labels not sending * feat(host-details): remove flags for hiding data * feat(host-details): create mock call to server for agent host info * style(host-details): fix spelling mistake in filter's name * feat(host-details): get info from agent * feat(host-details): hide engine labels when empty * feat(node-details): move labels table and save button * feat(host-info): add different urls for refresh * feat(host-details): show disk/devices info for agent * feat(host-view): add loading indicator to devices-panel * feat(host-details): add loading indicator to disks panel * feat(host-details): show devices/disks on standalone agent * refactor(host-details): remove default value * refactor(host-details): remove redundant commaSeperated filter * refactor(host-details): remove unused functions * style(host-details): remove whitespace --- app/agent/rest/host.js | 15 ++ app/agent/services/agentService.js | 50 ++-- app/docker/__module.js | 14 +- .../dockerSidebarContent.html | 2 +- .../host-overview/host-overview.html | 17 ++ .../components/host-overview/host-overview.js | 12 + .../devices-panel/devices-panel.html | 31 +++ .../devices-panel/devices-panel.js | 7 + .../disks-panel/disks-panel.html | 31 +++ .../disks-panel/disks-panel.js | 7 + .../engine-details-panel.html | 37 +++ .../engine-details-panel.js | 7 + .../host-details-panel.html | 34 +++ .../host-details-panel/host-details-panel.js | 8 + .../node-availability-select-controller.js | 11 + .../node-availability-select.html | 8 + .../node-availability-select.js | 10 + .../node-labels-table-controller.js | 23 ++ .../node-labels-table/node-labels-table.html | 35 +++ .../node-labels-table/node-labels-table.js | 9 + .../swarm-node-details-panel-controller.js | 96 +++++++ .../swarm-node-details-panel.html | 75 ++++++ .../swarm-node-details-panel.js | 9 + app/docker/services/nodeService.js | 64 +++-- app/docker/views/engine/engine.html | 118 --------- app/docker/views/engine/engineController.js | 22 -- app/docker/views/host/host-view-controller.js | 70 +++++ app/docker/views/host/host-view.html | 8 + app/docker/views/host/host-view.js | 4 + app/docker/views/nodes/edit/node.html | 243 ------------------ app/docker/views/nodes/edit/nodeController.js | 96 ------- .../node-details-view-controller.js | 75 ++++++ .../nodes/node-details/node-details-view.html | 13 + .../nodes/node-details/node-details-view.js | 4 + 34 files changed, 737 insertions(+), 528 deletions(-) create mode 100644 app/agent/rest/host.js create mode 100644 app/docker/components/host-overview/host-overview.html create mode 100644 app/docker/components/host-overview/host-overview.js create mode 100644 app/docker/components/host-view-panels/devices-panel/devices-panel.html create mode 100644 app/docker/components/host-view-panels/devices-panel/devices-panel.js create mode 100644 app/docker/components/host-view-panels/disks-panel/disks-panel.html create mode 100644 app/docker/components/host-view-panels/disks-panel/disks-panel.js create mode 100644 app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html create mode 100644 app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.js create mode 100644 app/docker/components/host-view-panels/host-details-panel/host-details-panel.html create mode 100644 app/docker/components/host-view-panels/host-details-panel/host-details-panel.js create mode 100644 app/docker/components/host-view-panels/node-availability-select/node-availability-select-controller.js create mode 100644 app/docker/components/host-view-panels/node-availability-select/node-availability-select.html create mode 100644 app/docker/components/host-view-panels/node-availability-select/node-availability-select.js create mode 100644 app/docker/components/host-view-panels/node-labels-table/node-labels-table-controller.js create mode 100644 app/docker/components/host-view-panels/node-labels-table/node-labels-table.html create mode 100644 app/docker/components/host-view-panels/node-labels-table/node-labels-table.js create mode 100644 app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel-controller.js create mode 100644 app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html create mode 100644 app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.js delete mode 100644 app/docker/views/engine/engine.html delete mode 100644 app/docker/views/engine/engineController.js create mode 100644 app/docker/views/host/host-view-controller.js create mode 100644 app/docker/views/host/host-view.html create mode 100644 app/docker/views/host/host-view.js delete mode 100644 app/docker/views/nodes/edit/node.html delete mode 100644 app/docker/views/nodes/edit/nodeController.js create mode 100644 app/docker/views/nodes/node-details/node-details-view-controller.js create mode 100644 app/docker/views/nodes/node-details/node-details-view.html create mode 100644 app/docker/views/nodes/node-details/node-details-view.js diff --git a/app/agent/rest/host.js b/app/agent/rest/host.js new file mode 100644 index 000000000..a717def30 --- /dev/null +++ b/app/agent/rest/host.js @@ -0,0 +1,15 @@ +angular.module('portainer.agent').factory('Host', [ + '$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', + function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource( + API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/host/:action', + { + endpointId: EndpointProvider.endpointID + }, + { + info: { method: 'GET', params: { action: 'info' } } + } + ); + } +]); diff --git a/app/agent/services/agentService.js b/app/agent/services/agentService.js index 5f011d01c..7143e1e0f 100644 --- a/app/agent/services/agentService.js +++ b/app/agent/services/agentService.js @@ -1,24 +1,34 @@ -angular.module('portainer.agent') -.factory('AgentService', ['$q', 'Agent', function AgentServiceFactory($q, Agent) { - 'use strict'; - var service = {}; +angular.module('portainer.agent').factory('AgentService', [ + '$q', 'Agent','HttpRequestHelper', 'Host', + function AgentServiceFactory($q, Agent, HttpRequestHelper, Host) { + 'use strict'; + var service = {}; - service.agents = function() { - var deferred = $q.defer(); + service.agents = agents; + service.hostInfo = hostInfo; - Agent.query({}).$promise - .then(function success(data) { - var agents = data.map(function (item) { - return new AgentViewModel(item); - }); - deferred.resolve(agents); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve agents', err: err }); - }); + function hostInfo(nodeName) { + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + return Host.info().$promise; + } - return deferred.promise; - }; + function agents() { + var deferred = $q.defer(); - return service; -}]); + Agent.query({}) + .$promise.then(function success(data) { + var agents = data.map(function(item) { + return new AgentViewModel(item); + }); + deferred.resolve(agents); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve agents', err: err }); + }); + + return deferred.promise; + } + + return service; + } +]); diff --git a/app/docker/__module.js b/app/docker/__module.js index ffd451fbd..23ce18433 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -129,13 +129,12 @@ angular.module('portainer.docker', ['portainer.app']) } }; - var engine = { - name: 'docker.engine', - url: '/engine', + var host = { + name: 'docker.host', + url: '/host', views: { 'content@': { - templateUrl: 'app/docker/views/engine/engine.html', - controller: 'EngineController' + component: 'hostView' } } }; @@ -239,8 +238,7 @@ angular.module('portainer.docker', ['portainer.app']) url: '/:id', views: { 'content@': { - templateUrl: 'app/docker/views/nodes/edit/node.html', - controller: 'NodeController' + component: 'nodeDetailsView' } } }; @@ -428,7 +426,7 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(containerStats); $stateRegistryProvider.register(docker); $stateRegistryProvider.register(dashboard); - $stateRegistryProvider.register(engine); + $stateRegistryProvider.register(host); $stateRegistryProvider.register(events); $stateRegistryProvider.register(images); $stateRegistryProvider.register(image); diff --git a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html index 870c959ed..2e0eeb497 100644 --- a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html +++ b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html @@ -35,5 +35,5 @@ Swarm diff --git a/app/docker/components/host-overview/host-overview.html b/app/docker/components/host-overview/host-overview.html new file mode 100644 index 000000000..baa43628e --- /dev/null +++ b/app/docker/components/host-overview/host-overview.html @@ -0,0 +1,17 @@ + + + + + + + Docker + + + + + + + + + + \ No newline at end of file diff --git a/app/docker/components/host-overview/host-overview.js b/app/docker/components/host-overview/host-overview.js new file mode 100644 index 000000000..add9515b4 --- /dev/null +++ b/app/docker/components/host-overview/host-overview.js @@ -0,0 +1,12 @@ +angular.module('portainer.docker').component('hostOverview', { + templateUrl: 'app/docker/components/host-overview/host-overview.html', + bindings: { + hostDetails: '<', + engineDetails: '<', + devices: '<', + disks: '<', + isAgent: '<', + refreshUrl: '@' + }, + transclude: true +}); diff --git a/app/docker/components/host-view-panels/devices-panel/devices-panel.html b/app/docker/components/host-view-panels/devices-panel/devices-panel.html new file mode 100644 index 000000000..5279f4ac1 --- /dev/null +++ b/app/docker/components/host-view-panels/devices-panel/devices-panel.html @@ -0,0 +1,31 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
NameVendor
{{device.Name}}{{device.Vendor}}
Loading...
+ No device available. +
+
+
+
+
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/devices-panel/devices-panel.js b/app/docker/components/host-view-panels/devices-panel/devices-panel.js new file mode 100644 index 000000000..1bd32e1f6 --- /dev/null +++ b/app/docker/components/host-view-panels/devices-panel/devices-panel.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('devicesPanel', { + templateUrl: + 'app/docker/components/host-view-panels/devices-panel/devices-panel.html', + bindings: { + devices: '<' + } +}); diff --git a/app/docker/components/host-view-panels/disks-panel/disks-panel.html b/app/docker/components/host-view-panels/disks-panel/disks-panel.html new file mode 100644 index 000000000..632f07151 --- /dev/null +++ b/app/docker/components/host-view-panels/disks-panel/disks-panel.html @@ -0,0 +1,31 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
VendorSize
{{disk.Vendor}}{{disk.Size | humansize}}
Loading...
+ No disks available. +
+
+
+
+
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/disks-panel/disks-panel.js b/app/docker/components/host-view-panels/disks-panel/disks-panel.js new file mode 100644 index 000000000..ae96224ee --- /dev/null +++ b/app/docker/components/host-view-panels/disks-panel/disks-panel.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('disksPanel', { + templateUrl: + 'app/docker/components/host-view-panels/disks-panel/disks-panel.html', + bindings: { + disks: '<' + } +}); diff --git a/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html new file mode 100644 index 000000000..4eebf6bcd --- /dev/null +++ b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html @@ -0,0 +1,37 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Version{{ $ctrl.engine.releaseVersion }} (API: {{ $ctrl.engine.apiVersion }})
Root directory{{ $ctrl.engine.rootDirectory }}
Storage Driver{{ $ctrl.engine.storageDriver }}
Logging Driver{{ $ctrl.engine.loggingDriver }}
Volume Plugins{{ $ctrl.engine.volumePlugins | arraytostr: ', ' }}
Network Plugins{{ $ctrl.engine.networkPlugins | arraytostr: ', ' }}
+
+
+
+
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.js b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.js new file mode 100644 index 000000000..666dd5254 --- /dev/null +++ b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('engineDetailsPanel', { + templateUrl: + 'app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html', + bindings: { + engine: '<' + } +}); diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html new file mode 100644 index 000000000..2ea05f88f --- /dev/null +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html @@ -0,0 +1,34 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Hostname{{ $ctrl.host.name }}
OS Information{{ $ctrl.host.os.type }} {{$ctrl.host.os.arch}} + {{$ctrl.host.os.name}}
Kernel Version{{ $ctrl.host.kernelVersion }}
Total CPU{{ $ctrl.host.totalCPU }}
Total memory{{ $ctrl.host.totalMemory | humansize }}
+
+
+
+
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js new file mode 100644 index 000000000..6865b5872 --- /dev/null +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js @@ -0,0 +1,8 @@ +angular.module('portainer.docker').component('hostDetailsPanel', { + templateUrl: + 'app/docker/components/host-view-panels/host-details-panel/host-details-panel.html', + bindings: { + host: '<', + isAgent: '<' + } +}); diff --git a/app/docker/components/host-view-panels/node-availability-select/node-availability-select-controller.js b/app/docker/components/host-view-panels/node-availability-select/node-availability-select-controller.js new file mode 100644 index 000000000..52df40cd6 --- /dev/null +++ b/app/docker/components/host-view-panels/node-availability-select/node-availability-select-controller.js @@ -0,0 +1,11 @@ +angular + .module('portainer.docker') + .controller('NodeAvailabilitySelectController', [ + function NodeAvailabilitySelectController() { + this.onChange = onChange; + + function onChange() { + this.onSave({ availability: this.availability }); + } + } + ]); diff --git a/app/docker/components/host-view-panels/node-availability-select/node-availability-select.html b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.html new file mode 100644 index 000000000..94e086127 --- /dev/null +++ b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.html @@ -0,0 +1,8 @@ +
+ +
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/node-availability-select/node-availability-select.js b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.js new file mode 100644 index 000000000..b09730f37 --- /dev/null +++ b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.js @@ -0,0 +1,10 @@ +angular.module('portainer.docker').component('nodeAvailabilitySelect', { + templateUrl: + 'app/docker/components/host-view-panels/node-availability-select/node-availability-select.html', + controller: 'NodeAvailabilitySelectController', + bindings: { + availability: '<', + originalValue: '<', + onSave: '&' + } +}); diff --git a/app/docker/components/host-view-panels/node-labels-table/node-labels-table-controller.js b/app/docker/components/host-view-panels/node-labels-table/node-labels-table-controller.js new file mode 100644 index 000000000..a9ad8ab58 --- /dev/null +++ b/app/docker/components/host-view-panels/node-labels-table/node-labels-table-controller.js @@ -0,0 +1,23 @@ +angular.module('portainer.docker').controller('NodeLabelsTableController', [ + function NodeLabelsTableController() { + var ctrl = this; + ctrl.removeLabel = removeLabel; + ctrl.updateLabel = updateLabel; + + function removeLabel(index) { + var label = ctrl.labels.splice(index, 1); + if (label !== null) { + ctrl.onChangedLabels({ labels: ctrl.labels }); + } + } + + function updateLabel(label) { + if ( + label.value !== label.originalValue || + label.key !== label.originalKey + ) { + ctrl.onChangedLabels({ labels: ctrl.labels }); + } + } + } +]); diff --git a/app/docker/components/host-view-panels/node-labels-table/node-labels-table.html b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.html new file mode 100644 index 000000000..86eee9356 --- /dev/null +++ b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.html @@ -0,0 +1,35 @@ +
+ There are no labels for this node. +
+ + + + + + + + + + + + + + +
LabelValue
+
+ Name + +
+
+
+ Value + + + + +
+
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/node-labels-table/node-labels-table.js b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.js new file mode 100644 index 000000000..5d6d6c320 --- /dev/null +++ b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.js @@ -0,0 +1,9 @@ +angular.module('portainer.docker').component('nodeLabelsTable', { + templateUrl: + 'app/docker/components/host-view-panels/node-labels-table/node-labels-table.html', + controller: 'NodeLabelsTableController', + bindings: { + labels: '<', + onChangedLabels: '&' + } +}); diff --git a/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel-controller.js b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel-controller.js new file mode 100644 index 000000000..65e376d99 --- /dev/null +++ b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel-controller.js @@ -0,0 +1,96 @@ +angular + .module('portainer.docker') + .controller('SwarmNodeDetailsPanelController', [ + 'NodeService', 'LabelHelper', 'Notifications', '$state', + function SwarmNodeDetailsPanelController(NodeService, LabelHelper, Notifications, $state) { + var ctrl = this; + ctrl.state = { + managerAddress: '', + hasChanges: false + }; + ctrl.$onChanges = $onChanges; + ctrl.addLabel = addLabel; + ctrl.updateNodeLabels = updateNodeLabels; + ctrl.updateNodeAvailability = updateNodeAvailability; + ctrl.saveChanges = saveChanges; + ctrl.cancelChanges = cancelChanges; + + var managerRole = 'manager'; + + function $onChanges() { + if (!ctrl.details) { + return; + } + if (ctrl.details.role === managerRole) { + ctrl.state.managerAddress = '(' + ctrl.details.managerAddress + ')'; + } + } + + function addLabel() { + ctrl.details.nodeLabels.push({ + key: '', + value: '', + originalValue: '', + originalKey: '' + }); + } + + function updateNodeLabels(labels) { + ctrl.details.nodeLabels = labels; + ctrl.state.hasChanges = true; + } + + function updateNodeAvailability(availability) { + ctrl.details.availability = availability; + ctrl.state.hasChanges = true; + } + + function saveChanges() { + var originalNode = ctrl.originalNode; + var config = { + Name: originalNode.Name, + Availability: ctrl.details.availability, + Role: originalNode.Role, + Labels: LabelHelper.fromKeyValueToLabelHash(ctrl.details.nodeLabels), + Id: originalNode.Id, + Version: originalNode.Version + }; + + NodeService.updateNode(config) + .then(onUpdateSuccess) + .catch(notifyOnError); + + function onUpdateSuccess() { + Notifications.success('Node successfully updated', 'Node updated'); + $state.go( + 'docker.nodes.node', + { id: originalNode.Id }, + { reload: true } + ); + } + + function notifyOnError(error) { + Notifications.error('Failure', error, 'Failed to update node'); + } + } + + function cancelChanges() { + cancelLabelChanges(); + ctrl.details.availability = ctrl.originalNode.Availability; + ctrl.state.hasChanges = false; + } + + function cancelLabelChanges() { + ctrl.details.nodeLabels = ctrl.details.nodeLabels + .filter(function(label) { + return label.originalValue || label.originalKey; + }) + .map(function(label) { + return Object.assign(label, { + value: label.originalValue, + key: label.originalKey + }); + }); + } + } + ]); diff --git a/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html new file mode 100644 index 000000000..291025e13 --- /dev/null +++ b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html @@ -0,0 +1,75 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Node name{{ $ctrl.details.name }}
Role{{ $ctrl.details.role }} {{ $ctrl.state.managerAddress }}
Availability + + +
Status{{ + $ctrl.details.status }}
Engine Labels{{ $ctrl.details.engineLabels | arraytostr:', ' }}
+ + Node Labels +
+ +
+ +
+ +
+
+
+
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.js b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.js new file mode 100644 index 000000000..7eea3e708 --- /dev/null +++ b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.js @@ -0,0 +1,9 @@ +angular.module('portainer.docker').component('swarmNodeDetailsPanel', { + templateUrl: + 'app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html', + controller: 'SwarmNodeDetailsPanelController', + bindings: { + details: '<', + originalNode: '<' + } +}); diff --git a/app/docker/services/nodeService.js b/app/docker/services/nodeService.js index 706f26647..5ebfeeee4 100644 --- a/app/docker/services/nodeService.js +++ b/app/docker/services/nodeService.js @@ -1,24 +1,48 @@ -angular.module('portainer.docker') -.factory('NodeService', ['$q', 'Node', function NodeServiceFactory($q, Node) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('NodeService', [ + '$q', 'Node', + function NodeServiceFactory($q, Node) { + 'use strict'; + var service = {}; - service.nodes = function() { - var deferred = $q.defer(); + service.nodes = nodes; + service.node = node; + service.updateNode = updateNode; - Node.query({}).$promise - .then(function success(data) { - var nodes = data.map(function (item) { - return new NodeViewModel(item); - }); - deferred.resolve(nodes); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve nodes', err: err }); - }); + function node(id) { + var deferred = $q.defer(); + Node.get({ id: id }) + .$promise.then(function onNodeLoaded(rawNode) { + var node = new NodeViewModel(rawNode); + return deferred.resolve(node); + }) + .catch(function onFailed(err) { + deferred.reject({ msg: 'Unable to retrieve node', err: err }); + }); - return deferred.promise; - }; + return deferred.promise; + } - return service; -}]); + function nodes() { + var deferred = $q.defer(); + + Node.query({}) + .$promise.then(function success(data) { + var nodes = data.map(function(item) { + return new NodeViewModel(item); + }); + deferred.resolve(nodes); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve nodes', err: err }); + }); + + return deferred.promise; + } + + function updateNode(node) { + return Node.update({ id: node.Id, version: node.Version }, node).$promise; + } + + return service; + } +]); diff --git a/app/docker/views/engine/engine.html b/app/docker/views/engine/engine.html deleted file mode 100644 index 73b1248b2..000000000 --- a/app/docker/views/engine/engine.html +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - Docker - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Version{{ version.Version }}
API version{{ version.ApiVersion }}
Go version{{ version.GoVersion }}
OS type{{ version.Os }}
OS{{ info.OperatingSystem }}
Architecture{{ version.Arch }}
Kernel version{{ version.KernelVersion }}
-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Total CPU{{ info.NCPU }}
Total memory{{ info.MemTotal|humansize }}
Docker root directory{{ info.DockerRootDir }}
Storage driver{{ info.Driver }}
Logging driver{{ info.LoggingDriver }}
Cgroup driver{{ info.CgroupDriver }}
Execution driver{{ info.ExecutionDriver }}
-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - -
Volume{{ info.Plugins.Volume|arraytostr: ', '}}
Network{{ info.Plugins.Network|arraytostr: ', '}}
Authorization{{ info.Plugins.Authorization|arraytostr: ', '}}
-
-
-
-
diff --git a/app/docker/views/engine/engineController.js b/app/docker/views/engine/engineController.js deleted file mode 100644 index f106273a5..000000000 --- a/app/docker/views/engine/engineController.js +++ /dev/null @@ -1,22 +0,0 @@ -angular.module('portainer.docker') -.controller('EngineController', ['$q', '$scope', 'SystemService', 'Notifications', -function ($q, $scope, SystemService, Notifications) { - - function initView() { - $q.all({ - version: SystemService.version(), - info: SystemService.info() - }) - .then(function success(data) { - $scope.version = data.version; - $scope.info = data.info; - }) - .catch(function error(err) { - $scope.info = {}; - $scope.version = {}; - Notifications.error('Failure', err, 'Unable to retrieve engine details'); - }); - } - - initView(); -}]); diff --git a/app/docker/views/host/host-view-controller.js b/app/docker/views/host/host-view-controller.js new file mode 100644 index 000000000..43bc49391 --- /dev/null +++ b/app/docker/views/host/host-view-controller.js @@ -0,0 +1,70 @@ +angular.module('portainer.docker').controller('HostViewController', [ + '$q', 'SystemService', 'Notifications', 'StateManager', 'AgentService', + function HostViewController($q, SystemService, Notifications, StateManager, AgentService) { + var ctrl = this; + this.$onInit = initView; + + ctrl.state = { + isAgent: false + }; + + this.engineDetails = {}; + this.hostDetails = {}; + + function initView() { + var applicationState = StateManager.getState(); + ctrl.state.isAgent = applicationState.endpoint.mode.agentProxy; + + $q.all({ + version: SystemService.version(), + info: SystemService.info() + }) + .then(function success(data) { + ctrl.engineDetails = buildEngineDetails(data); + ctrl.hostDetails = buildHostDetails(data.info); + + if (ctrl.state.isAgent) { + return AgentService.hostInfo(data.info.Hostname).then(function onHostInfoLoad(agentHostInfo) { + ctrl.devices = agentHostInfo.PCIDevices; + ctrl.disks = agentHostInfo.PhysicalDisks; + }); + } + }) + .catch(function error(err) { + Notifications.error( + 'Failure', + err, + 'Unable to retrieve engine details' + ); + }); + } + + function buildEngineDetails(data) { + var versionDetails = data.version; + var info = data.info; + return { + releaseVersion: versionDetails.Version, + apiVersion: versionDetails.ApiVersion, + rootDirectory: info.DockerRootDir, + storageDriver: info.Driver, + loggingDriver: info.LoggingDriver, + volumePlugins: info.Plugins.Volume, + networkPlugins: info.Plugins.Network + }; + } + + function buildHostDetails(info) { + return { + os: { + arch: info.Architecture, + type: info.OSType, + name: info.OperatingSystem + }, + name: info.Name, + kernelVersion: info.KernelVersion, + totalCPU: info.NCPU, + totalMemory: info.MemTotal + }; + } + } +]); diff --git a/app/docker/views/host/host-view.html b/app/docker/views/host/host-view.html new file mode 100644 index 000000000..419e96995 --- /dev/null +++ b/app/docker/views/host/host-view.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/app/docker/views/host/host-view.js b/app/docker/views/host/host-view.js new file mode 100644 index 000000000..e321dccb4 --- /dev/null +++ b/app/docker/views/host/host-view.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('hostView', { + templateUrl: 'app/docker/views/host/host-view.html', + controller: 'HostViewController' +}); diff --git a/app/docker/views/nodes/edit/node.html b/app/docker/views/nodes/edit/node.html deleted file mode 100644 index b7d733206..000000000 --- a/app/docker/views/nodes/edit/node.html +++ /dev/null @@ -1,243 +0,0 @@ - - - - - - - - Swarm nodes > {{ node.Hostname }} - - - -
-
-
- Loading... -
- - - - -

It looks like the node you wish to inspect does not exist.

-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Name - -
Host name{{ node.Hostname }}
Role{{ node.Role }}
Availability -
- -
-
Status{{ node.Status }}
-
- -

- View the Docker Swarm mode Node documentation here. -

- -
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - -
Leader - Yes - No -
Reachability{{ node.Reachability }}
Manager address{{ node.ManagerAddr }}
-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - -
CPU{{ node.CPUs / 1000000000 }}
Memory{{ node.Memory|humansize: 2 }}
Platform{{ node.PlatformOS }} {{ node.PlatformArchitecture }}
Docker Engine version{{ node.EngineVersion }}
-
-
-
-
- -
-
- - - -

There are no engine labels for this node.

-
- - - - - - - - - - - - - - -
LabelValue
{{ engineLabel.key }}{{ engineLabel.value }}
-
-
-
-
- -
-
- - - - - -

There are no labels for this node.

-
- - - - - - - - - - - - - - -
LabelValue
-
- name - -
-
-
- value - - - - -
-
-
- - - -
-
-
- -
-
- -
-
diff --git a/app/docker/views/nodes/edit/nodeController.js b/app/docker/views/nodes/edit/nodeController.js deleted file mode 100644 index 30f210b2a..000000000 --- a/app/docker/views/nodes/edit/nodeController.js +++ /dev/null @@ -1,96 +0,0 @@ -angular.module('portainer.docker') -.controller('NodeController', ['$scope', '$state', '$transition$', 'LabelHelper', 'Node', 'NodeHelper', 'Task', 'Notifications', -function ($scope, $state, $transition$, LabelHelper, Node, NodeHelper, Task, Notifications) { - - $scope.loading = true; - $scope.tasks = []; - - var originalNode = {}; - var editedKeys = []; - - $scope.updateNodeAttribute = function updateNodeAttribute(node, key) { - editedKeys.push(key); - }; - $scope.addLabel = function addLabel(node) { - node.Labels.push({ key: '', value: '', originalValue: '', originalKey: '' }); - $scope.updateNodeAttribute(node, 'Labels'); - }; - $scope.removeLabel = function removeLabel(node, index) { - var removedElement = node.Labels.splice(index, 1); - if (removedElement !== null) { - $scope.updateNodeAttribute(node, 'Labels'); - } - }; - $scope.updateLabel = function updateLabel(node, label) { - if (label.value !== label.originalValue || label.key !== label.originalKey) { - $scope.updateNodeAttribute(node, 'Labels'); - } - }; - - $scope.hasChanges = function(node, elements) { - if (!elements) { - elements = Object.keys(originalNode); - } - var hasChanges = false; - elements.forEach(function(key) { - hasChanges = hasChanges || ((editedKeys.indexOf(key) >= 0) && node[key] !== originalNode[key]); - }); - return hasChanges; - }; - - $scope.cancelChanges = function(node) { - editedKeys.forEach(function(key) { - node[key] = originalNode[key]; - }); - editedKeys = []; - }; - - $scope.updateNode = function updateNode(node) { - var config = NodeHelper.nodeToConfig(node.Model); - config.Name = node.Name; - config.Availability = node.Availability; - config.Role = node.Role; - config.Labels = LabelHelper.fromKeyValueToLabelHash(node.Labels); - - Node.update({ id: node.Id, version: node.Version }, config, function () { - Notifications.success('Node successfully updated', 'Node updated'); - $state.go('docker.nodes.node', {id: node.Id}, {reload: true}); - }, function (e) { - Notifications.error('Failure', e, 'Failed to update node'); - }); - }; - - function loadNodeAndTasks() { - $scope.loading = true; - if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { - Node.get({ id: $transition$.params().id}, function(d) { - if (d.message) { - Notifications.error('Failure', e, 'Unable to inspect the node'); - } else { - var node = new NodeViewModel(d); - originalNode = angular.copy(node); - $scope.node = node; - getTasks(d); - } - $scope.loading = false; - }); - } else { - $scope.loading = false; - } - } - - function getTasks(node) { - if (node) { - Task.query({filters: {node: [node.ID]}}, function (tasks) { - $scope.tasks = tasks.map(function (task) { - return new TaskViewModel(task); - }); - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve tasks associated to the node'); - }); - } - } - - loadNodeAndTasks(); - -}]); diff --git a/app/docker/views/nodes/node-details/node-details-view-controller.js b/app/docker/views/nodes/node-details/node-details-view-controller.js new file mode 100644 index 000000000..cde111986 --- /dev/null +++ b/app/docker/views/nodes/node-details/node-details-view-controller.js @@ -0,0 +1,75 @@ +angular.module('portainer.docker').controller('NodeDetailsViewController', [ + '$stateParams', 'NodeService', 'StateManager', 'AgentService', + function NodeDetailsViewController($stateParams, NodeService, StateManager, AgentService) { + var ctrl = this; + + ctrl.$onInit = initView; + + ctrl.state = { + isAgent: false + }; + + function initView() { + var applicationState = StateManager.getState(); + ctrl.state.isAgent = applicationState.endpoint.mode.agentProxy; + + var nodeId = $stateParams.id; + NodeService.node(nodeId).then(function(node) { + ctrl.originalNode = node; + ctrl.hostDetails = buildHostDetails(node); + ctrl.engineDetails = buildEngineDetails(node); + ctrl.nodeDetails = buildNodeDetails(node); + if (ctrl.state.isAgent) { + AgentService.hostInfo(node.Hostname).then(function onHostInfoLoad( + agentHostInfo + ) { + ctrl.devices = agentHostInfo.PCIDevices; + ctrl.disks = agentHostInfo.PhysicalDisks; + }); + } + }); + } + + function buildHostDetails(node) { + return { + os: { + arch: node.PlatformArchitecture, + type: node.PlatformOS + }, + name: node.Hostname, + totalCPU: node.CPUs / 1e9, + totalMemory: node.Memory + }; + } + + function buildEngineDetails(node) { + return { + releaseVersion: node.EngineVersion, + volumePlugins: transformPlugins(node.Plugins, 'Volume'), + networkPlugins: transformPlugins(node.Plugins, 'Network') + }; + } + + function buildNodeDetails(node) { + return { + name: node.Name, + role: node.Role, + managerAddress: node.ManagerAddr, + availability: node.Availability, + status: node.Status, + engineLabels: node.EngineLabels, + nodeLabels: node.Labels + }; + } + + function transformPlugins(pluginsList, type) { + return pluginsList + .filter(function(plugin) { + return plugin.Type === type; + }) + .map(function(plugin) { + return plugin.Name; + }); + } + } +]); diff --git a/app/docker/views/nodes/node-details/node-details-view.html b/app/docker/views/nodes/node-details/node-details-view.html new file mode 100644 index 000000000..13df99558 --- /dev/null +++ b/app/docker/views/nodes/node-details/node-details-view.html @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/app/docker/views/nodes/node-details/node-details-view.js b/app/docker/views/nodes/node-details/node-details-view.js new file mode 100644 index 000000000..5b1c76e2b --- /dev/null +++ b/app/docker/views/nodes/node-details/node-details-view.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('nodeDetailsView', { + templateUrl: 'app/docker/views/nodes/node-details/node-details-view.html', + controller: 'NodeDetailsViewController' +}); From e948d606f403bca894d2cb2618678804a943a145 Mon Sep 17 00:00:00 2001 From: baron_l Date: Mon, 8 Oct 2018 22:28:26 +0200 Subject: [PATCH 21/93] fix(container-creation): set a default runtime value (#2325) * fix(containers): creating a container with default runtime let the docker daemon assume the correct value * refactor(containers): implementation simplification of default runtime value --- .../containers/create/createContainerController.js | 10 ++-------- .../views/containers/create/createcontainer.html | 10 +++++----- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index 83b153e44..ddddda3ad 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -544,14 +544,8 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai SystemService.info() .then(function success(data) { - var runtimes = data.Runtimes; - $scope.availableRuntimes = runtimes; - if ('runc' in runtimes) { - $scope.config.HostConfig.Runtime = 'runc'; - } - else if (Object.keys(runtimes).length !== 0) { - $scope.config.HostConfig.Runtime = Object.keys(runtimes)[0]; - } + $scope.availableRuntimes = Object.keys(data.Runtimes); + $scope.config.HostConfig.Runtime = ''; $scope.state.sliderMaxCpu = 32; if (data.NCPU) { $scope.state.sliderMaxCpu = data.NCPU; diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index c6d13fb6f..a14c8f1d4 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -503,11 +503,11 @@
- -
- +
From 5341ad33af24fb54f1c2fe947b7530a145ff2d16 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 11 Oct 2018 13:09:51 +1300 Subject: [PATCH 22/93] docs(swagger): update StackUpdateRequest model (#2360) --- api/swagger.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/swagger.yaml b/api/swagger.yaml index ba5745801..5a6e7ff87 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -4273,7 +4273,7 @@ definitions: Prune: type: "boolean" example: false - description: "Prune services that are no longer referenced" + description: "Prune services that are no longer referenced (only available for Swarm stacks)" StackFileInspectResponse: type: "object" properties: From c5aecfe6f3205db177b04a1fb3c3c9ee6ae405bf Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 12 Oct 2018 01:32:17 +0300 Subject: [PATCH 23/93] feat(host): Add host file browser with upload/download files (#2337) * feat(agent): add new host page * feat(agent): convert volume-browser to files-datatable * fix(agent): browse folders in file-datatable * feat(engine-details): replace engine view with host view * feat(engine-details): remove old panels * feat(engine-details): add basic engine-details-panel component * feat(engine-details): pass details to the different components * feat(engine-details): replace host-view with host-overview * feat(engine-details): add commaseperated filter * feat(engine-details): add host-view container component * feat(engine-details): add host-details component * feat(engine-details): build host details object * feat(engine-details): format engine version * feat(engine-details): get details for one node * feat(engine-details): pass is-agent from view * feat(engine-details): replace old node view with a new component * feat(engine-details): add swarm-node-details component * feat(engine-details): remove isSwarm binding * feat(engine-details): remove node-details and include in parent * feat(engine-details): add labels-table component * feat(engine-details): add update node service * feat(engine-details): add update label functionality * style(engine-details): remove whitespaces * feat(engine-details): remove old node page * feat(engine-details): pass is agent to host details * feat(host-details): hide missing info * feat(host-details): update node availability * style(host-details): remove obsolete event object * feat(host-details): fix labels not sending * feat(host-details): remove flags for hiding data * feat(host-details): create mock call to server for agent host info * style(host-details): fix spelling mistake in filter's name * feat(host-details): get info from agent * feat(host-details): hide engine labels when empty * feat(node-details): move labels table and save button * feat(host-info): add different urls for refresh * feat(host-details): show disk/devices info for agent * feat(host-view): add loading indicator to devices-panel * feat(host-details): add loading indicator to disks panel * feat(agent): fix browse volume * feat(agent): browse files * feat(agent): enable rename * feat(agent): download file * fix(agent): download file from root * feat(agent): delete file * style(agent): remove whitespaces * fix(agent): fix link on node browser * feat(agent): basic file uploader * feat(agent): add basic file upload * fix(volume-browser): move volume id to query params * feat(node-browser): moved uploader into browser * feat(node-browser): add upload spinner * feat(agent): browse files relative to root * feat(agent): browse standalone agent * feat(agent): move browse button from header * fix(agent): fix url of browser view * fix(agent): fix breadcrumb on title of host-browser * feat(agent): fix url on node-browser breadcrumb * refactor(agent): remove unused controller * refactor(docker): remove unused filter * refactor(docker): remove unused controllers * refactor(docker): remove isAgent binding --- .../file-uploader/file-uploader-controller.js | 23 +++ .../file-uploader/file-uploader.html | 6 + .../components/file-uploader/file-uploader.js | 7 + .../files-datatable.html} | 37 +++-- .../files-datatable/files-datatable.js | 22 +++ .../host-browser/host-browser-controller.js | 147 ++++++++++++++++++ .../components/host-browser/host-browser.html | 16 ++ .../components/host-browser/host-browser.js | 5 + .../volume-browser-datatable.js | 15 -- .../volume-browser/volumeBrowser.html | 10 +- app/agent/services/hostBrowserService.js | 44 ++++++ app/agent/services/volumeBrowserService.js | 50 +++--- app/docker/__module.js | 24 +++ .../docker-sidebar-content.js | 8 +- .../host-overview/host-overview.html | 8 +- .../components/host-overview/host-overview.js | 3 +- .../host-details-panel.html | 11 ++ .../host-details-panel/host-details-panel.js | 3 +- .../host-browser-view-controller.js | 21 +++ .../host-browser-view/host-browser-view.html | 14 ++ .../host-browser-view/host-browser-view.js | 4 + app/docker/views/host/host-view.html | 4 +- .../node-browser/node-browser-controller.js | 20 +++ .../nodes/node-browser/node-browser.html | 14 ++ .../views/nodes/node-browser/node-browser.js | 4 + .../nodes/node-details/node-details-view.html | 4 +- 26 files changed, 458 insertions(+), 66 deletions(-) create mode 100644 app/agent/components/file-uploader/file-uploader-controller.js create mode 100644 app/agent/components/file-uploader/file-uploader.html create mode 100644 app/agent/components/file-uploader/file-uploader.js rename app/agent/components/{volume-browser/volume-browser-datatable/volumeBrowserDatatable.html => files-datatable/files-datatable.html} (76%) create mode 100644 app/agent/components/files-datatable/files-datatable.js create mode 100644 app/agent/components/host-browser/host-browser-controller.js create mode 100644 app/agent/components/host-browser/host-browser.html create mode 100644 app/agent/components/host-browser/host-browser.js delete mode 100644 app/agent/components/volume-browser/volume-browser-datatable/volume-browser-datatable.js create mode 100644 app/agent/services/hostBrowserService.js create mode 100644 app/docker/views/host/host-browser-view/host-browser-view-controller.js create mode 100644 app/docker/views/host/host-browser-view/host-browser-view.html create mode 100644 app/docker/views/host/host-browser-view/host-browser-view.js create mode 100644 app/docker/views/nodes/node-browser/node-browser-controller.js create mode 100644 app/docker/views/nodes/node-browser/node-browser.html create mode 100644 app/docker/views/nodes/node-browser/node-browser.js diff --git a/app/agent/components/file-uploader/file-uploader-controller.js b/app/agent/components/file-uploader/file-uploader-controller.js new file mode 100644 index 000000000..e6516c67c --- /dev/null +++ b/app/agent/components/file-uploader/file-uploader-controller.js @@ -0,0 +1,23 @@ +angular.module('portainer.agent').controller('FileUploaderController', [ + '$q', + function FileUploaderController($q) { + var ctrl = this; + + ctrl.state = { + uploadInProgress: false + }; + + ctrl.onFileSelected = onFileSelected; + + function onFileSelected(file) { + if (!file) { + return; + } + + ctrl.state.uploadInProgress = true; + $q.when(ctrl.uploadFile(file)).finally(function toggleProgress() { + ctrl.state.uploadInProgress = false; + }); + } + } +]); diff --git a/app/agent/components/file-uploader/file-uploader.html b/app/agent/components/file-uploader/file-uploader.html new file mode 100644 index 000000000..e092ce5d6 --- /dev/null +++ b/app/agent/components/file-uploader/file-uploader.html @@ -0,0 +1,6 @@ + diff --git a/app/agent/components/file-uploader/file-uploader.js b/app/agent/components/file-uploader/file-uploader.js new file mode 100644 index 000000000..58ba7b04b --- /dev/null +++ b/app/agent/components/file-uploader/file-uploader.js @@ -0,0 +1,7 @@ +angular.module('portainer.agent').component('fileUploader', { + templateUrl: 'app/agent/components/file-uploader/file-uploader.html', + controller: 'FileUploaderController', + bindings: { + uploadFile: ' + + + + -
-
- {{ $ctrl.titleText }} -
-
@@ -41,23 +41,29 @@ - + @@ -65,13 +71,14 @@ {{ item.ModTime | getisodatefromtimestamp }} @@ -87,4 +94,4 @@ - + \ No newline at end of file diff --git a/app/agent/components/files-datatable/files-datatable.js b/app/agent/components/files-datatable/files-datatable.js new file mode 100644 index 000000000..4e90e1d00 --- /dev/null +++ b/app/agent/components/files-datatable/files-datatable.js @@ -0,0 +1,22 @@ +angular.module('portainer.agent').component('filesDatatable', { + templateUrl: 'app/agent/components/files-datatable/files-datatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + + isRoot: '<', + goToParent: '&', + browse: '&', + rename: '&', + download: '&', + delete: '&', + + isUploadAllowed: '<', + onFileSelectedForUpload: '<' + } +}); diff --git a/app/agent/components/host-browser/host-browser-controller.js b/app/agent/components/host-browser/host-browser-controller.js new file mode 100644 index 000000000..a50cb6b6e --- /dev/null +++ b/app/agent/components/host-browser/host-browser-controller.js @@ -0,0 +1,147 @@ +angular.module('portainer.agent').controller('HostBrowserController', [ + 'HostBrowserService', 'Notifications', 'FileSaver', 'ModalService', + function HostBrowserController(HostBrowserService, Notifications, FileSaver, ModalService) { + var ctrl = this; + var ROOT_PATH = '/host'; + ctrl.state = { + path: ROOT_PATH + }; + + ctrl.goToParent = goToParent; + ctrl.browse = browse; + ctrl.renameFile = renameFile; + ctrl.downloadFile = downloadFile; + ctrl.deleteFile = confirmDeleteFile; + ctrl.isRoot = isRoot; + ctrl.onFileSelectedForUpload = onFileSelectedForUpload; + ctrl.$onInit = $onInit; + ctrl.getRelativePath = getRelativePath; + + function getRelativePath(path) { + path = path || ctrl.state.path; + var rootPathRegex = new RegExp('^' + ROOT_PATH + '\/?'); + var relativePath = path.replace(rootPathRegex, '/'); + return relativePath; + } + + function goToParent() { + getFilesForPath(parentPath(this.state.path)); + } + + function isRoot() { + return ctrl.state.path === ROOT_PATH; + } + + function browse(folder) { + getFilesForPath(buildPath(ctrl.state.path, folder)); + } + + function getFilesForPath(path) { + HostBrowserService.ls(path) + .then(function onFilesLoaded(files) { + ctrl.state.path = path; + ctrl.files = files; + }) + .catch(function onLoadingFailed(err) { + Notifications.error('Failure', err, 'Unable to browse'); + }); + } + + function renameFile(name, newName) { + var filePath = buildPath(ctrl.state.path, name); + var newFilePath = buildPath(ctrl.state.path, newName); + + HostBrowserService.rename(filePath, newFilePath) + .then(function onRenameSuccess() { + Notifications.success('File successfully renamed', getRelativePath(newFilePath)); + return HostBrowserService.ls(ctrl.state.path); + }) + .then(function onFilesLoaded(files) { + ctrl.files = files; + }) + .catch(function notifyOnError(err) { + Notifications.error('Failure', err, 'Unable to rename file'); + }); + } + + function downloadFile(file) { + var filePath = buildPath(ctrl.state.path, file); + HostBrowserService.get(filePath) + .then(function onFileReceived(data) { + var downloadData = new Blob([data.file], { + type: 'text/plain;charset=utf-8' + }); + FileSaver.saveAs(downloadData, file); + }) + .catch(function notifyOnError(err) { + Notifications.error('Failure', err, 'Unable to download file'); + }); + } + + function confirmDeleteFile(name) { + var filePath = buildPath(ctrl.state.path, name); + + ModalService.confirmDeletion( + 'Are you sure that you want to delete ' + getRelativePath(filePath) + ' ?', + function onConfirm(confirmed) { + if (!confirmed) { + return; + } + return deleteFile(filePath); + } + ); + } + + function deleteFile(path) { + HostBrowserService.delete(path) + .then(function onDeleteSuccess() { + Notifications.success('File successfully deleted', getRelativePath(path)); + return HostBrowserService.ls(ctrl.state.path); + }) + .then(function onFilesLoaded(data) { + ctrl.files = data; + }) + .catch(function notifyOnError(err) { + Notifications.error('Failure', err, 'Unable to delete file'); + }); + } + + function $onInit() { + getFilesForPath(ROOT_PATH); + } + + function parentPath(path) { + if (path === ROOT_PATH) { + return ROOT_PATH; + } + + var split = _.split(path, '/'); + return _.join(_.slice(split, 0, split.length - 1), '/'); + } + + function buildPath(parent, file) { + if (parent.lastIndexOf('/') === parent.length - 1) { + return parent + file; + } + return parent + '/' + file; + } + + function onFileSelectedForUpload(file) { + HostBrowserService.upload(ctrl.state.path, file) + .then(function onFileUpload() { + onFileUploaded(); + }) + .catch(function onFileUpload(err) { + Notifications.error('Failure', err, 'Unable to upload file'); + }); + } + + function onFileUploaded() { + refreshList(); + } + + function refreshList() { + getFilesForPath(ctrl.state.path); + } + } +]); diff --git a/app/agent/components/host-browser/host-browser.html b/app/agent/components/host-browser/host-browser.html new file mode 100644 index 000000000..cf7b59306 --- /dev/null +++ b/app/agent/components/host-browser/host-browser.html @@ -0,0 +1,16 @@ + + + diff --git a/app/agent/components/host-browser/host-browser.js b/app/agent/components/host-browser/host-browser.js new file mode 100644 index 000000000..a136730ee --- /dev/null +++ b/app/agent/components/host-browser/host-browser.js @@ -0,0 +1,5 @@ +angular.module('portainer.agent').component('hostBrowser', { + controller: 'HostBrowserController', + templateUrl: 'app/agent/components/host-browser/host-browser.html', + bindings: {} +}); diff --git a/app/agent/components/volume-browser/volume-browser-datatable/volume-browser-datatable.js b/app/agent/components/volume-browser/volume-browser-datatable/volume-browser-datatable.js deleted file mode 100644 index e3139974d..000000000 --- a/app/agent/components/volume-browser/volume-browser-datatable/volume-browser-datatable.js +++ /dev/null @@ -1,15 +0,0 @@ -angular.module('portainer.agent').component('volumeBrowserDatatable', { - templateUrl: 'app/agent/components/volume-browser/volume-browser-datatable/volumeBrowserDatatable.html', - controller: 'GenericDatatableController', - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - orderBy: '@', - reverseOrder: '<' - }, - require: { - volumeBrowser: '^^volumeBrowser' - } -}); diff --git a/app/agent/components/volume-browser/volumeBrowser.html b/app/agent/components/volume-browser/volumeBrowser.html index 643d8c88b..3759f22d2 100644 --- a/app/agent/components/volume-browser/volumeBrowser.html +++ b/app/agent/components/volume-browser/volumeBrowser.html @@ -1,5 +1,11 @@ - + is-root="$ctrl.state.path === '/'" + go-to-parent="$ctrl.up()" + browse="$ctrl.browse(name)" + rename="$ctrl.rename(name, newName)" + download="$ctrl.download(name)" + delete="$ctrl.delete(name)" +> diff --git a/app/agent/services/hostBrowserService.js b/app/agent/services/hostBrowserService.js new file mode 100644 index 000000000..935d34be4 --- /dev/null +++ b/app/agent/services/hostBrowserService.js @@ -0,0 +1,44 @@ +angular.module('portainer.agent').factory('HostBrowserService', [ + 'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', + function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) { + var service = {}; + + service.ls = ls; + service.get = get; + service.delete = deletePath; + service.rename = rename; + service.upload = upload; + + function ls(path) { + return Browse.ls({ path: path }).$promise; + } + + function get(path) { + return Browse.get({ path: path }).$promise; + } + + function deletePath(path) { + return Browse.delete({ path: path }).$promise; + } + + function rename(path, newPath) { + var payload = { + CurrentFilePath: path, + NewFilePath: newPath + }; + return Browse.rename({}, payload).$promise; + } + + function upload(path, file, onProgress) { + var deferred = $q.defer(); + var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/browse/put'; + Upload.upload({ + url: url, + data: { file: file, Path: path } + }).then(deferred.resolve, deferred.reject, onProgress); + return deferred.promise; + } + + return service; + } +]); diff --git a/app/agent/services/volumeBrowserService.js b/app/agent/services/volumeBrowserService.js index 2a5c92f56..1da020c38 100644 --- a/app/agent/services/volumeBrowserService.js +++ b/app/agent/services/volumeBrowserService.js @@ -1,27 +1,29 @@ -angular.module('portainer.agent') -.factory('VolumeBrowserService', ['$q', 'Browse', function VolumeBrowserServiceFactory($q, Browse) { - 'use strict'; - var service = {}; +angular.module('portainer.agent').factory('VolumeBrowserService', [ + '$q', 'Browse', + function VolumeBrowserServiceFactory($q, Browse) { + 'use strict'; + var service = {}; - service.ls = function(volumeId, path) { - return Browse.ls({ volumeID: volumeId, path: path }).$promise; - }; - - service.get = function(volumeId, path) { - return Browse.get({ volumeID: volumeId, path: path }).$promise; - }; - - service.delete = function(volumeId, path) { - return Browse.delete({ volumeID: volumeId, path: path }).$promise; - }; - - service.rename = function(volumeId, path, newPath) { - var payload = { - CurrentFilePath: path, - NewFilePath: newPath + service.ls = function(volumeId, path) { + return Browse.ls({ volumeID: volumeId, path: path }).$promise; }; - return Browse.rename({ volumeID: volumeId }, payload).$promise; - }; - return service; -}]); + service.get = function(volumeId, path) { + return Browse.get({ volumeID: volumeId, path: path }).$promise; + }; + + service.delete = function(volumeId, path) { + return Browse.delete({ volumeID: volumeId, path: path }).$promise; + }; + + service.rename = function(volumeId, path, newPath) { + var payload = { + CurrentFilePath: path, + NewFilePath: newPath + }; + return Browse.rename({ volumeID: volumeId }, payload).$promise; + }; + + return service; + } +]); diff --git a/app/docker/__module.js b/app/docker/__module.js index 23ce18433..4b4bdfc74 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -139,6 +139,16 @@ angular.module('portainer.docker', ['portainer.app']) } }; + var hostBrowser = { + name: 'docker.host.browser', + url: '/browser', + views: { + 'content@': { + component: 'hostBrowserView' + } + } + }; + var events = { name: 'docker.events', url: '/events', @@ -243,6 +253,16 @@ angular.module('portainer.docker', ['portainer.app']) } }; + var nodeBrowser = { + name: 'docker.nodes.node.browse', + url: '/browse', + views: { + 'content@': { + component: 'nodeBrowserView' + } + } + }; + var secrets = { name: 'docker.secrets', url: '/secrets', @@ -414,6 +434,8 @@ angular.module('portainer.docker', ['portainer.app']) } }; + + $stateRegistryProvider.register(configs); $stateRegistryProvider.register(config); $stateRegistryProvider.register(configCreation); @@ -427,6 +449,7 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(docker); $stateRegistryProvider.register(dashboard); $stateRegistryProvider.register(host); + $stateRegistryProvider.register(hostBrowser); $stateRegistryProvider.register(events); $stateRegistryProvider.register(images); $stateRegistryProvider.register(image); @@ -437,6 +460,7 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(networkCreation); $stateRegistryProvider.register(nodes); $stateRegistryProvider.register(node); + $stateRegistryProvider.register(nodeBrowser); $stateRegistryProvider.register(secrets); $stateRegistryProvider.register(secret); $stateRegistryProvider.register(secretCreation); diff --git a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js index 325a3497d..5031a44a7 100644 --- a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js +++ b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js @@ -1,9 +1,9 @@ angular.module('portainer.docker').component('dockerSidebarContent', { templateUrl: 'app/docker/components/dockerSidebarContent/dockerSidebarContent.html', bindings: { - 'endpointApiVersion': '<', - 'swarmManagement': '<', - 'standaloneManagement': '<', - 'adminAccess': '<' + endpointApiVersion: '<', + swarmManagement: '<', + standaloneManagement: '<', + adminAccess: '<' } }); diff --git a/app/docker/components/host-overview/host-overview.html b/app/docker/components/host-overview/host-overview.html index baa43628e..81ac13c62 100644 --- a/app/docker/components/host-overview/host-overview.html +++ b/app/docker/components/host-overview/host-overview.html @@ -1,13 +1,17 @@ - + Docker - + diff --git a/app/docker/components/host-overview/host-overview.js b/app/docker/components/host-overview/host-overview.js index add9515b4..78883e4e8 100644 --- a/app/docker/components/host-overview/host-overview.js +++ b/app/docker/components/host-overview/host-overview.js @@ -6,7 +6,8 @@ angular.module('portainer.docker').component('hostOverview', { devices: '<', disks: '<', isAgent: '<', - refreshUrl: '@' + refreshUrl: '@', + browseUrl: '@' }, transclude: true }); diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html index 2ea05f88f..9bb9b7f85 100644 --- a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html @@ -26,6 +26,17 @@ + + + +
- Go to parent + Go + to parent
- + - + - {{ item.Name }} + {{ item.Name }} - {{ item.Name }} + {{ + item.Name }} {{ item.Size | humansize }} - + Download Rename - + Delete Total memory {{ $ctrl.host.totalMemory | humansize }}
+ +
diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js index 6865b5872..328832c42 100644 --- a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js @@ -3,6 +3,7 @@ angular.module('portainer.docker').component('hostDetailsPanel', { 'app/docker/components/host-view-panels/host-details-panel/host-details-panel.html', bindings: { host: '<', - isAgent: '<' + isAgent: '<', + browseUrl: '@' } }); diff --git a/app/docker/views/host/host-browser-view/host-browser-view-controller.js b/app/docker/views/host/host-browser-view/host-browser-view-controller.js new file mode 100644 index 000000000..9638e966e --- /dev/null +++ b/app/docker/views/host/host-browser-view/host-browser-view-controller.js @@ -0,0 +1,21 @@ +angular + .module('portainer.docker') + .controller('HostBrowserViewController', [ + 'SystemService', 'HttpRequestHelper', + function HostBrowserViewController(SystemService, HttpRequestHelper) { + var ctrl = this; + + ctrl.$onInit = $onInit; + + function $onInit() { + loadInfo(); + } + + function loadInfo() { + SystemService.info().then(function onInfoLoaded(host) { + HttpRequestHelper.setPortainerAgentTargetHeader(host.Name); + ctrl.host = host; + }); + } + } + ]); diff --git a/app/docker/views/host/host-browser-view/host-browser-view.html b/app/docker/views/host/host-browser-view/host-browser-view.html new file mode 100644 index 000000000..2d87e4b2e --- /dev/null +++ b/app/docker/views/host/host-browser-view/host-browser-view.html @@ -0,0 +1,14 @@ + + + + Host > {{ $ctrl.host.Name }} > browse + + + +
+
+ +
+
diff --git a/app/docker/views/host/host-browser-view/host-browser-view.js b/app/docker/views/host/host-browser-view/host-browser-view.js new file mode 100644 index 000000000..7ce93994a --- /dev/null +++ b/app/docker/views/host/host-browser-view/host-browser-view.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('hostBrowserView', { + templateUrl: 'app/docker/views/host/host-browser-view/host-browser-view.html', + controller: 'HostBrowserViewController' +}); diff --git a/app/docker/views/host/host-view.html b/app/docker/views/host/host-view.html index 419e96995..ada86fef8 100644 --- a/app/docker/views/host/host-view.html +++ b/app/docker/views/host/host-view.html @@ -1,8 +1,10 @@ \ No newline at end of file diff --git a/app/docker/views/nodes/node-browser/node-browser-controller.js b/app/docker/views/nodes/node-browser/node-browser-controller.js new file mode 100644 index 000000000..d846ac8f9 --- /dev/null +++ b/app/docker/views/nodes/node-browser/node-browser-controller.js @@ -0,0 +1,20 @@ +angular.module('portainer.docker').controller('NodeBrowserController', [ + 'NodeService', 'HttpRequestHelper', '$stateParams', + function NodeBrowserController(NodeService, HttpRequestHelper, $stateParams) { + var ctrl = this; + + ctrl.$onInit = $onInit; + + function $onInit() { + ctrl.nodeId = $stateParams.id; + loadNode(); + } + + function loadNode() { + NodeService.node(ctrl.nodeId).then(function onNodeLoaded(node) { + HttpRequestHelper.setPortainerAgentTargetHeader(node.Hostname); + ctrl.node = node; + }); + } + } +]); diff --git a/app/docker/views/nodes/node-browser/node-browser.html b/app/docker/views/nodes/node-browser/node-browser.html new file mode 100644 index 000000000..2edeae199 --- /dev/null +++ b/app/docker/views/nodes/node-browser/node-browser.html @@ -0,0 +1,14 @@ + + + + Swarm > {{ $ctrl.node.Hostname }} > browse + + + +
+
+ +
+
diff --git a/app/docker/views/nodes/node-browser/node-browser.js b/app/docker/views/nodes/node-browser/node-browser.js new file mode 100644 index 000000000..3e7269384 --- /dev/null +++ b/app/docker/views/nodes/node-browser/node-browser.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('nodeBrowserView', { + templateUrl: 'app/docker/views/nodes/node-browser/node-browser.html', + controller: 'NodeBrowserController' +}); diff --git a/app/docker/views/nodes/node-details/node-details-view.html b/app/docker/views/nodes/node-details/node-details-view.html index 13df99558..17d0dc470 100644 --- a/app/docker/views/nodes/node-details/node-details-view.html +++ b/app/docker/views/nodes/node-details/node-details-view.html @@ -2,9 +2,11 @@ is-agent="$ctrl.state.isAgent" host-details="$ctrl.hostDetails" engine-details="$ctrl.engineDetails" - refresh-url="docker.nodes.node" disks="$ctrl.disks" devices="$ctrl.devices" + + refresh-url="docker.nodes.node" + browse-url="docker.nodes.node.browse" > Date: Sat, 13 Oct 2018 08:29:44 +0200 Subject: [PATCH 24/93] feat(ux): Redirect from init/admin to home when admin already exists (#2340) Fixes #1853 --- .../views/init/admin/initAdminController.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js index 569bb1491..d8f9585d0 100644 --- a/app/portainer/views/init/admin/initAdminController.js +++ b/app/portainer/views/init/admin/initAdminController.js @@ -41,4 +41,16 @@ function ($scope, $state, Notifications, Authentication, StateManager, UserServi }); }; + function createAdministratorFlow() { + UserService.administratorExists() + .then(function success(exists) { + if (exists) { + $state.go('portainer.home'); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to verify administrator account existence'); + }); + } + createAdministratorFlow(); }]); From 719299d75bc322ee5a097da915a47828018c8314 Mon Sep 17 00:00:00 2001 From: Yassir Hannoun Date: Wed, 17 Oct 2018 23:00:45 +0200 Subject: [PATCH 25/93] =?UTF-8?q?fix(container-stat)=20:=20exclude=20cache?= =?UTF-8?q?=20from=20the=20Memory=20Usage=20chart=20to=20avoid=20misinterp?= =?UTF-8?q?ret=E2=80=A6=20(#2371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/docker/models/container.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/docker/models/container.js b/app/docker/models/container.js index 015223ad8..627ebec86 100644 --- a/app/docker/models/container.js +++ b/app/docker/models/container.js @@ -64,7 +64,7 @@ function ContainerViewModel(data) { function ContainerStatsViewModel(data) { this.Date = data.read; - this.MemoryUsage = data.memory_stats.usage; + this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache; this.PreviousCPUTotalUsage = data.precpu_stats.cpu_usage.total_usage; this.PreviousCPUSystemUsage = data.precpu_stats.system_cpu_usage; this.CurrentCPUTotalUsage = data.cpu_stats.cpu_usage.total_usage; From 65291c68e9411dd128e21e97150dde55229e7618 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 23 Oct 2018 00:03:30 +0300 Subject: [PATCH 26/93] feat(jobs): add the job execution API * feat(jobs): add job service interface * feat(jobs): create job execution api * style(jobs): remove comment * feat(jobs): add bindings * feat(jobs): validate payload different cases * refactor(jobs): rename endpointJob method * refactor(jobs): return original error * feat(jobs): pull image before creating container * feat(jobs): run jobs with sh * style(jobs): remove comment * refactor(jobs): change error names * feat(jobs): sync pull image * fix(jobs): close image reader after error check * style(jobs): remove comment and add docs * refactor(jobs): inline script command * fix(jobs): handle pul image error * refactor(jobs): handle image pull output * fix(docker): set http client timeout to 100s * fix(client): remove timeout from http client --- api/archive/tar.go | 4 +- api/cmd/portainer/main.go | 7 ++ api/docker/client.go | 2 - api/docker/jobservice.go | 103 +++++++++++++++++++ api/http/handler/endpoints/endpoint_job.go | 114 +++++++++++++++++++++ api/http/handler/endpoints/handler.go | 4 +- api/http/proxy/build.go | 2 +- api/http/server.go | 2 + api/portainer.go | 5 + 9 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 api/docker/jobservice.go create mode 100644 api/http/handler/endpoints/endpoint_job.go diff --git a/api/archive/tar.go b/api/archive/tar.go index 4040a9ec7..3beccec8a 100644 --- a/api/archive/tar.go +++ b/api/archive/tar.go @@ -7,13 +7,13 @@ import ( // TarFileInBuffer will create a tar archive containing a single file named via fileName and using the content // specified in fileContent. Returns the archive as a byte array. -func TarFileInBuffer(fileContent []byte, fileName string) ([]byte, error) { +func TarFileInBuffer(fileContent []byte, fileName string, mode int64) ([]byte, error) { var buffer bytes.Buffer tarWriter := tar.NewWriter(&buffer) header := &tar.Header{ Name: fileName, - Mode: 0600, + Mode: mode, Size: int64(len(fileContent)), } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index cecf986b7..7602b2a90 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -383,6 +383,10 @@ func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointS return createUnsecuredEndpoint(*flags.EndpointURL, endpointService, snapshotter) } +func initJobService(dockerClientFactory *docker.ClientFactory) portainer.JobService { + return docker.NewJobService(dockerClientFactory) +} + func main() { flags := initCLI() @@ -408,6 +412,8 @@ func main() { clientFactory := initClientFactory(digitalSignatureService) + jobService := initJobService(clientFactory) + snapshotter := initSnapshotter(clientFactory) jobScheduler, err := initJobScheduler(store.EndpointService, snapshotter, flags) @@ -520,6 +526,7 @@ func main() { SSLCert: *flags.SSLCert, SSLKey: *flags.SSLKey, DockerClientFactory: clientFactory, + JobService: jobService, } log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr) diff --git a/api/docker/client.go b/api/docker/client.go index af9f08c46..a0a65a11d 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -3,7 +3,6 @@ package docker import ( "net/http" "strings" - "time" "github.com/docker/docker/client" "github.com/portainer/portainer" @@ -97,7 +96,6 @@ func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) { } return &http.Client{ - Timeout: time.Second * 10, Transport: transport, }, nil } diff --git a/api/docker/jobservice.go b/api/docker/jobservice.go new file mode 100644 index 000000000..ce6f64832 --- /dev/null +++ b/api/docker/jobservice.go @@ -0,0 +1,103 @@ +package docker + +import ( + "bytes" + "context" + "io" + "io/ioutil" + "strconv" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/strslice" + "github.com/docker/docker/client" + "github.com/portainer/portainer" + "github.com/portainer/portainer/archive" +) + +// JobService represnts a service that handles jobs on the host +type JobService struct { + DockerClientFactory *ClientFactory +} + +// NewJobService returns a pointer to a new job service +func NewJobService(dockerClientFactory *ClientFactory) *JobService { + return &JobService{ + DockerClientFactory: dockerClientFactory, + } +} + +// Execute will execute a script on the endpoint host with the supplied image as a container +func (service *JobService) Execute(endpoint *portainer.Endpoint, image string, script []byte) error { + buffer, err := archive.TarFileInBuffer(script, "script.sh", 0700) + if err != nil { + return err + } + + cli, err := service.DockerClientFactory.CreateClient(endpoint) + if err != nil { + return err + } + defer cli.Close() + + err = pullImage(cli, image) + if err != nil { + return err + } + + containerConfig := &container.Config{ + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + WorkingDir: "/tmp", + Image: image, + Labels: map[string]string{ + "io.portainer.job.endpoint": strconv.Itoa(int(endpoint.ID)), + }, + Cmd: strslice.StrSlice([]string{"sh", "/tmp/script.sh"}), + } + + hostConfig := &container.HostConfig{ + Binds: []string{"/:/host", "/etc:/etc:ro", "/usr:/usr:ro", "/run:/run:ro", "/sbin:/sbin:ro", "/var:/var:ro"}, + NetworkMode: "host", + Privileged: true, + } + + networkConfig := &network.NetworkingConfig{} + + body, err := cli.ContainerCreate(context.Background(), containerConfig, hostConfig, networkConfig, "") + if err != nil { + return err + } + + copyOptions := types.CopyToContainerOptions{} + err = cli.CopyToContainer(context.Background(), body.ID, "/tmp", bytes.NewReader(buffer), copyOptions) + if err != nil { + return err + } + + startOptions := types.ContainerStartOptions{} + err = cli.ContainerStart(context.Background(), body.ID, startOptions) + if err != nil { + return err + } + + return nil +} + +func pullImage(cli *client.Client, image string) error { + imageReadCloser, err := cli.ImagePull(context.Background(), image, types.ImagePullOptions{}) + if err != nil { + return err + } + + defer imageReadCloser.Close() + _, err = io.Copy(ioutil.Discard, imageReadCloser) + if err != nil { + return err + } + + return nil +} diff --git a/api/http/handler/endpoints/endpoint_job.go b/api/http/handler/endpoints/endpoint_job.go new file mode 100644 index 000000000..87be7b5a0 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_job.go @@ -0,0 +1,114 @@ +package endpoints + +import ( + "errors" + "net/http" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +type endpointJobFromFilePayload struct { + Image string + File []byte +} + +type endpointJobFromFileContentPayload struct { + Image string + FileContent string +} + +func (payload *endpointJobFromFilePayload) Validate(r *http.Request) error { + file, _, err := request.RetrieveMultiPartFormFile(r, "File") + if err != nil { + return portainer.Error("Invalid Script file. Ensure that the file is uploaded correctly") + } + payload.File = file + + image, err := request.RetrieveMultiPartFormValue(r, "Image", false) + if err != nil { + return portainer.Error("Invalid image name") + } + payload.Image = image + + return nil +} + +func (payload *endpointJobFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.FileContent) { + return portainer.Error("Invalid script file content") + } + + if govalidator.IsNull(payload.Image) { + return portainer.Error("Invalid image name") + } + + return nil +} + +// POST request on /api/endpoints/:id/job?method +func (handler *Handler) endpointJob(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + method, err := request.RetrieveQueryParameter(r, "method", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + err = handler.requestBouncer.EndpointAccess(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } + + switch method { + case "file": + return handler.executeJobFromFile(w, r, endpoint) + case "string": + return handler.executeJobFromFileContent(w, r, endpoint) + } + + return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string or file", errors.New(request.ErrInvalidQueryParameter)} +} + +func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + payload := &endpointJobFromFilePayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + err = handler.JobService.Execute(endpoint, payload.Image, payload.File) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} + } + + return response.Empty(w) +} + +func (handler *Handler) executeJobFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload endpointJobFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + err = handler.JobService.Execute(endpoint, payload.Image, []byte(payload.FileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 779cd9390..1ef8d1727 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -31,6 +31,7 @@ type Handler struct { FileService portainer.FileService ProxyManager *proxy.Manager Snapshotter portainer.Snapshotter + JobService portainer.JobService } // NewHandler creates a handler to manage endpoint operations. @@ -59,6 +60,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/extensions/{extensionType}", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) - + h.Handle("/endpoints/{id}/job", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost) return h } diff --git a/api/http/proxy/build.go b/api/http/proxy/build.go index 0deab93b9..aaa486f07 100644 --- a/api/http/proxy/build.go +++ b/api/http/proxy/build.go @@ -43,7 +43,7 @@ func buildOperation(request *http.Request) error { dockerfileContent = []byte(req.Content) } - buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile") + buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile", 0600) if err != nil { return err } diff --git a/api/http/server.go b/api/http/server.go index 2258e86d3..bd5b9fe30 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -68,6 +68,7 @@ type Server struct { SSLCert string SSLKey string DockerClientFactory *docker.ClientFactory + JobService portainer.JobService } // Start starts the HTTP server @@ -109,6 +110,7 @@ func (server *Server) Start() error { endpointHandler.FileService = server.FileService endpointHandler.ProxyManager = proxyManager endpointHandler.Snapshotter = server.Snapshotter + endpointHandler.JobService = server.JobService var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) endpointGroupHandler.EndpointGroupService = server.EndpointGroupService diff --git a/api/portainer.go b/api/portainer.go index 2db839506..eb956a500 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -635,6 +635,11 @@ type ( Up(stack *Stack, endpoint *Endpoint) error Down(stack *Stack, endpoint *Endpoint) error } + + // JobService represtents a service that manages job execution on hosts + JobService interface { + Execute(endpoint *Endpoint, image string, script []byte) error + } ) const ( From 14d2bf4ebb9934bc8dc634a4e91bf5fd877f330d Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 23 Oct 2018 10:07:39 +1300 Subject: [PATCH 27/93] refactor(api): fix typo (#2391) * refactor(api): fix typo * refactor(api): remove newline --- api/docker/jobservice.go | 2 +- api/portainer.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/docker/jobservice.go b/api/docker/jobservice.go index ce6f64832..246124639 100644 --- a/api/docker/jobservice.go +++ b/api/docker/jobservice.go @@ -92,8 +92,8 @@ func pullImage(cli *client.Client, image string) error { if err != nil { return err } - defer imageReadCloser.Close() + _, err = io.Copy(ioutil.Discard, imageReadCloser) if err != nil { return err diff --git a/api/portainer.go b/api/portainer.go index eb956a500..1422d15bb 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -636,7 +636,7 @@ type ( Down(stack *Stack, endpoint *Endpoint) error } - // JobService represtents a service that manages job execution on hosts + // JobService represents a service to manage job execution on hosts JobService interface { Execute(endpoint *Endpoint, image string, script []byte) error } From 4f9a8180f978e95de784139bf48c7aa6b56e1f00 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 23 Oct 2018 11:59:43 +1300 Subject: [PATCH 28/93] docs(swagger): document the endpoint job execution (#2392) --- api/swagger.yaml | 79 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/api/swagger.yaml b/api/swagger.yaml index 5a6e7ff87..1c7f9ac38 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -495,6 +495,71 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /endpoints/{id}/job: + post: + tags: + - "endpoints" + summary: "Execute a job on the endpoint host" + description: | + Execute a job (script) on the underlying host of the endpoint. + **Access policy**: administrator + operationId: "EndpointJob" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + - name: "method" + in: "query" + description: "Job execution method. Possible values: file or string." + required: true + type: "string" + - in: "body" + name: "body" + description: "Job details. Required when method equals string." + required: true + schema: + $ref: "#/definitions/EndpointJobRequest" + - name: "Image" + in: "formData" + type: "string" + description: "Container image which will be used to execute the job. Required when method equals file." + - name: "file" + in: "formData" + type: "file" + description: "Job script file. Required when method equals file." + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Endpoint" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Endpoint not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" /endpoint_groups: get: tags: @@ -4164,6 +4229,20 @@ definitions: type: "string" example: "new-stack" description: "If provided will rename the migrated stack" + EndpointJobRequest: + type: "object" + required: + - "Image" + - "FileContent" + properties: + Image: + type: "string" + example: "ubuntu:latest" + description: "Container image which will be used to execute the job" + FileContent: + type: "string" + example: "ls -lah /host/tmp" + description: "Content of the job script" StackCreateRequest: type: "object" required: From b5dfaff29277e087fe58e165a63d90292dbdb9fd Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 23 Oct 2018 17:28:59 +1300 Subject: [PATCH 29/93] refactor(app): refactor unauthenticated state management (#2393) * refactor(app): refactor Authentication service * refactor(app): refactor unauthenticated state management --- app/app.js | 8 ++- app/config.js | 3 - app/portainer/services/authentication.js | 90 ++++++++++++++---------- 3 files changed, 59 insertions(+), 42 deletions(-) diff --git a/app/app.js b/app/app.js index a32633fe2..3c54bc3bf 100644 --- a/app/app.js +++ b/app/app.js @@ -37,9 +37,13 @@ function ($rootScope, $state, Authentication, authManager, StateManager, Endpoin function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); - authManager.redirectWhenUnauthenticated(); Authentication.init(); - $rootScope.$on('tokenHasExpired', function() { + + // The unauthenticated event is broadcasted by the jwtInterceptor when + // hitting a 401. We're using this instead of the usual combination of + // authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector + // to have more controls on which URL should trigger the unauthenticated state. + $rootScope.$on('unauthenticated', function () { $state.go('portainer.auth', {error: 'Your session has expired'}); }); } diff --git a/app/config.js b/app/config.js index 8e3366856..4cdd7fa8c 100644 --- a/app/config.js +++ b/app/config.js @@ -14,9 +14,6 @@ angular.module('portainer') jwtOptionsProvider.config({ tokenGetter: ['LocalStorage', function(LocalStorage) { return LocalStorage.getJWT(); - }], - unauthenticatedRedirector: ['$state', function($state) { - $state.go('portainer.auth', {error: 'Your session has expired'}); }] }); $httpProvider.interceptors.push('jwtInterceptor'); diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index e0fb48cc1..0e9f19a93 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -2,43 +2,59 @@ angular.module('portainer.app') .factory('Authentication', ['$q', 'Auth', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', function AuthenticationFactory($q, Auth, jwtHelper, LocalStorage, StateManager, EndpointProvider) { 'use strict'; + var service = {}; var user = {}; - return { - init: function() { - var jwt = LocalStorage.getJWT(); - if (jwt) { - var tokenPayload = jwtHelper.decodeToken(jwt); - user.username = tokenPayload.username; - user.ID = tokenPayload.id; - user.role = tokenPayload.role; - } - }, - login: function(username, password) { - return $q(function (resolve, reject) { - Auth.login({username: username, password: password}).$promise - .then(function(data) { - LocalStorage.storeJWT(data.jwt); - var tokenPayload = jwtHelper.decodeToken(data.jwt); - user.username = username; - user.ID = tokenPayload.id; - user.role = tokenPayload.role; - resolve(); - }, function() { - reject(); - }); - }); - }, - logout: function() { - StateManager.clean(); - EndpointProvider.clean(); - LocalStorage.clean(); - }, - isAuthenticated: function() { - var jwt = LocalStorage.getJWT(); - return jwt && !jwtHelper.isTokenExpired(jwt); - }, - getUserDetails: function() { - return user; + + service.init = init; + service.login = login; + service.logout = logout; + service.isAuthenticated = isAuthenticated; + service.getUserDetails = getUserDetails; + + function init() { + var jwt = LocalStorage.getJWT(); + + if (jwt) { + var tokenPayload = jwtHelper.decodeToken(jwt); + user.username = tokenPayload.username; + user.ID = tokenPayload.id; + user.role = tokenPayload.role; } - }; + } + + function login(username, password) { + var deferred = $q.defer(); + + Auth.login({username: username, password: password}).$promise + .then(function success(data) { + LocalStorage.storeJWT(data.jwt); + var tokenPayload = jwtHelper.decodeToken(data.jwt); + user.username = username; + user.ID = tokenPayload.id; + user.role = tokenPayload.role; + deferred.resolve(); + }) + .catch(function error() { + deferred.reject(); + }); + + return deferred.promise; + } + + function logout() { + StateManager.clean(); + EndpointProvider.clean(); + LocalStorage.clean(); + } + + function isAuthenticated() { + var jwt = LocalStorage.getJWT(); + return jwt && !jwtHelper.isTokenExpired(jwt); + } + + function getUserDetails() { + return user; + } + + return service; }]); From cca378b2e805b1846d9d913bf1516bfbe75875e9 Mon Sep 17 00:00:00 2001 From: Yassir Hannoun Date: Tue, 23 Oct 2018 21:55:30 +0200 Subject: [PATCH 30/93] docs(README): fix semaphore badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7b2e9e096..cee536856 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Docker Pulls](https://img.shields.io/docker/pulls/portainer/portainer.svg)](https://hub.docker.com/r/portainer/portainer/) [![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size") [![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/stable/?badge=stable) -[![Build Status](https://semaphoreci.com/api/v1/portainer/portainer/branches/develop/badge.svg)](https://semaphoreci.com/portainer/portainer) +[![Build Status](https://semaphoreci.com/api/v1/portainer/portainer-ci/branches/develop/badge.svg)](https://semaphoreci.com/portainer/portainer-ci) [![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer) [![Slack](https://portainer.io/slack/badge.svg)](https://portainer.io/slack/) [![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) From 9813099aa416311e7c6bdd7a037375e953c39ff5 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 26 Oct 2018 06:16:29 +0300 Subject: [PATCH 31/93] feat(app): toggle features based on agent API version (#2378) * feat(agent): get agent's version from ping * feat(agent): add version to api url * feat(agent): query agent with api version * feat(agent): rename agent api version name on state * feat(agent): disable feature based on agent's api version * style(agent): rename ping rest service + remove whitespaces * style(state): remove whitespace * style(agent): add whitespace * fix(agent): remove check for error status 403 * refactor(agent): rename ping file name * refactor(agent): move old services to v1 folder * refactor(agent): turn ping service to usual pattern * refactor(agent): change version to a global variable * refactor(agent): move ping to version2 * refactor(agent): restore ping to use root ping * fix(volumes): add volumeID to browse api path * feat(volume): add upload button to volume browser --- .../volume-browser/volume-browser.js | 3 +- .../volume-browser/volumeBrowser.html | 3 ++ .../volume-browser/volumeBrowserController.js | 20 ++++++++ app/agent/rest/agent.js | 8 ++-- app/agent/rest/browse.js | 8 ++-- app/agent/rest/host.js | 9 ++-- app/agent/rest/ping.js | 33 +++++++++++++ app/agent/rest/v1/agent.js | 10 ++++ app/agent/rest/v1/browse.js | 22 +++++++++ app/agent/services/agentService.js | 14 ++++-- app/agent/services/hostBrowserService.js | 16 +++++-- app/agent/services/pingService.js | 14 ++++++ app/agent/services/volumeBrowserService.js | 47 ++++++++++++++++--- .../host-overview/host-overview.html | 6 +-- .../components/host-overview/host-overview.js | 1 + .../host-details-panel.html | 2 +- .../host-details-panel/host-details-panel.js | 2 +- app/docker/views/host/host-view-controller.js | 6 ++- app/docker/views/host/host-view.html | 1 + .../node-details-view-controller.js | 16 ++++--- .../nodes/node-details/node-details-view.html | 1 + .../volumes/browse/browseVolumeController.js | 5 +- .../views/volumes/browse/browsevolume.html | 2 + app/portainer/services/stateManager.js | 16 ++++++- 24 files changed, 224 insertions(+), 41 deletions(-) create mode 100644 app/agent/rest/ping.js create mode 100644 app/agent/rest/v1/agent.js create mode 100644 app/agent/rest/v1/browse.js create mode 100644 app/agent/services/pingService.js diff --git a/app/agent/components/volume-browser/volume-browser.js b/app/agent/components/volume-browser/volume-browser.js index 964803da0..78e963604 100644 --- a/app/agent/components/volume-browser/volume-browser.js +++ b/app/agent/components/volume-browser/volume-browser.js @@ -3,6 +3,7 @@ angular.module('portainer.agent').component('volumeBrowser', { controller: 'VolumeBrowserController', bindings: { volumeId: '<', - nodeName: '<' + nodeName: '<', + isUploadEnabled: '<' } }); diff --git a/app/agent/components/volume-browser/volumeBrowser.html b/app/agent/components/volume-browser/volumeBrowser.html index 3759f22d2..97c8a4da6 100644 --- a/app/agent/components/volume-browser/volumeBrowser.html +++ b/app/agent/components/volume-browser/volumeBrowser.html @@ -8,4 +8,7 @@ rename="$ctrl.rename(name, newName)" download="$ctrl.download(name)" delete="$ctrl.delete(name)" + + is-upload-allowed="$ctrl.isUploadEnabled" + on-file-selected-for-upload="$ctrl.onFileSelectedForUpload" > diff --git a/app/agent/components/volume-browser/volumeBrowserController.js b/app/agent/components/volume-browser/volumeBrowserController.js index 2fa4426b9..8db735a3b 100644 --- a/app/agent/components/volume-browser/volumeBrowserController.js +++ b/app/agent/components/volume-browser/volumeBrowserController.js @@ -84,6 +84,16 @@ function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService }); } + this.onFileSelectedForUpload = function onFileSelectedForUpload(file) { + VolumeBrowserService.upload(ctrl.state.path, file, ctrl.volumeId) + .then(function onFileUpload() { + onFileUploaded(); + }) + .catch(function onFileUpload(err) { + Notifications.error('Failure', err, 'Unable to upload file'); + }); + }; + function parentPath(path) { if (path.lastIndexOf('/') === 0) { return '/'; @@ -112,4 +122,14 @@ function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService }); }; + function onFileUploaded() { + refreshList(); + } + + function refreshList() { + browse(ctrl.state.path); + } + + + }]); diff --git a/app/agent/rest/agent.js b/app/agent/rest/agent.js index a7800717c..522296c3a 100644 --- a/app/agent/rest/agent.js +++ b/app/agent/rest/agent.js @@ -1,8 +1,10 @@ angular.module('portainer.agent') -.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { +.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager', + function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', { - endpointId: EndpointProvider.endpointID + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/agents', { + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion }, { query: { method: 'GET', isArray: true } diff --git a/app/agent/rest/browse.js b/app/agent/rest/browse.js index 9496768eb..e78cd3206 100644 --- a/app/agent/rest/browse.js +++ b/app/agent/rest/browse.js @@ -1,8 +1,10 @@ angular.module('portainer.agent') -.factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { +.factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager', + function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:action', { - endpointId: EndpointProvider.endpointID + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/browse/:action', { + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion }, { ls: { diff --git a/app/agent/rest/host.js b/app/agent/rest/host.js index a717def30..f184d2544 100644 --- a/app/agent/rest/host.js +++ b/app/agent/rest/host.js @@ -1,11 +1,12 @@ angular.module('portainer.agent').factory('Host', [ - '$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', - function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + '$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager', + function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { 'use strict'; return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/host/:action', + API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/host/:action', { - endpointId: EndpointProvider.endpointID + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion }, { info: { method: 'GET', params: { action: 'info' } } diff --git a/app/agent/rest/ping.js b/app/agent/rest/ping.js new file mode 100644 index 000000000..7eeb93f2e --- /dev/null +++ b/app/agent/rest/ping.js @@ -0,0 +1,33 @@ +angular.module('portainer.agent').factory('AgentPing', [ + '$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', + function AgentPingFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) { + 'use strict'; + return $resource( + API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/ping', + { + endpointId: EndpointProvider.endpointID + }, + { + ping: { + method: 'GET', + interceptor: { + response: function versionInterceptor(response) { + var instance = response.resource; + var version = + response.headers('Portainer-Agent-Api-Version') || 1; + instance.version = Number(version); + return instance; + }, + responseError: function versionResponseError(error) { + // 404 - agent is up - set version to 1 + if (error.status === 404) { + return { version: 1 }; + } + return $q.reject(error); + } + } + } + } + ); + } +]); diff --git a/app/agent/rest/v1/agent.js b/app/agent/rest/v1/agent.js new file mode 100644 index 000000000..a78755b35 --- /dev/null +++ b/app/agent/rest/v1/agent.js @@ -0,0 +1,10 @@ +angular.module('portainer.agent') +.factory('AgentVersion1', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', { + endpointId: EndpointProvider.endpointID + }, + { + query: { method: 'GET', isArray: true } + }); +}]); diff --git a/app/agent/rest/v1/browse.js b/app/agent/rest/v1/browse.js new file mode 100644 index 000000000..d576433fa --- /dev/null +++ b/app/agent/rest/v1/browse.js @@ -0,0 +1,22 @@ +angular.module('portainer.agent') +.factory('BrowseVersion1', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:volumeID/:action', { + endpointId: EndpointProvider.endpointID + }, + { + ls: { + method: 'GET', isArray: true, params: { action: 'ls' } + }, + get: { + method: 'GET', params: { action: 'get' }, + transformResponse: browseGetResponse + }, + delete: { + method: 'DELETE', params: { action: 'delete' } + }, + rename: { + method: 'PUT', params: { action: 'rename' } + } + }); +}]); diff --git a/app/agent/services/agentService.js b/app/agent/services/agentService.js index 7143e1e0f..17a2c3166 100644 --- a/app/agent/services/agentService.js +++ b/app/agent/services/agentService.js @@ -1,12 +1,17 @@ angular.module('portainer.agent').factory('AgentService', [ - '$q', 'Agent','HttpRequestHelper', 'Host', - function AgentServiceFactory($q, Agent, HttpRequestHelper, Host) { + '$q', 'Agent', 'AgentVersion1', 'HttpRequestHelper', 'Host', 'StateManager', + function AgentServiceFactory($q, Agent, AgentVersion1, HttpRequestHelper, Host, StateManager) { 'use strict'; var service = {}; service.agents = agents; service.hostInfo = hostInfo; + function getAgentApiVersion() { + var state = StateManager.getState(); + return state.endpoint.agentApiVersion; + } + function hostInfo(nodeName) { HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); return Host.info().$promise; @@ -15,7 +20,10 @@ angular.module('portainer.agent').factory('AgentService', [ function agents() { var deferred = $q.defer(); - Agent.query({}) + var agentVersion = getAgentApiVersion(); + var service = agentVersion > 1 ? Agent : AgentVersion1; + + service.query({ version: agentVersion }) .$promise.then(function success(data) { var agents = data.map(function(item) { return new AgentViewModel(item); diff --git a/app/agent/services/hostBrowserService.js b/app/agent/services/hostBrowserService.js index 935d34be4..6f292c36a 100644 --- a/app/agent/services/hostBrowserService.js +++ b/app/agent/services/hostBrowserService.js @@ -1,6 +1,6 @@ angular.module('portainer.agent').factory('HostBrowserService', [ - 'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', - function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) { + 'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', 'StateManager', + function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q, StateManager) { var service = {}; service.ls = ls; @@ -31,9 +31,17 @@ angular.module('portainer.agent').factory('HostBrowserService', [ function upload(path, file, onProgress) { var deferred = $q.defer(); - var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/browse/put'; + var agentVersion = StateManager.getAgentApiVersion(); + var url = + API_ENDPOINT_ENDPOINTS + + '/' + + EndpointProvider.endpointID() + + '/docker' + + (agentVersion > 1 ? '/v' + agentVersion : '') + + '/browse/put'; + Upload.upload({ - url: url, + url: url, data: { file: file, Path: path } }).then(deferred.resolve, deferred.reject, onProgress); return deferred.promise; diff --git a/app/agent/services/pingService.js b/app/agent/services/pingService.js new file mode 100644 index 000000000..765d47a5f --- /dev/null +++ b/app/agent/services/pingService.js @@ -0,0 +1,14 @@ +angular.module('portainer.agent').service('AgentPingService', [ + 'AgentPing', + function AgentPingService(AgentPing) { + var service = {}; + + service.ping = ping; + + function ping() { + return AgentPing.ping().$promise; + } + + return service; + } +]); diff --git a/app/agent/services/volumeBrowserService.js b/app/agent/services/volumeBrowserService.js index 1da020c38..a4ffdbf9d 100644 --- a/app/agent/services/volumeBrowserService.js +++ b/app/agent/services/volumeBrowserService.js @@ -1,27 +1,60 @@ angular.module('portainer.agent').factory('VolumeBrowserService', [ - '$q', 'Browse', - function VolumeBrowserServiceFactory($q, Browse) { + 'StateManager', 'Browse', 'BrowseVersion1', '$q', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'Upload', + function VolumeBrowserServiceFactory(StateManager, Browse, BrowseVersion1, $q, API_ENDPOINT_ENDPOINTS, EndpointProvider, Upload) { 'use strict'; var service = {}; + function getAgentApiVersion() { + var state = StateManager.getState(); + return state.endpoint.agentApiVersion; + } + + function getBrowseService() { + var agentVersion = getAgentApiVersion(); + return agentVersion > 1 ? Browse : BrowseVersion1; + } + service.ls = function(volumeId, path) { - return Browse.ls({ volumeID: volumeId, path: path }).$promise; + return getBrowseService().ls({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; }; service.get = function(volumeId, path) { - return Browse.get({ volumeID: volumeId, path: path }).$promise; + return getBrowseService().get({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; }; service.delete = function(volumeId, path) { - return Browse.delete({ volumeID: volumeId, path: path }).$promise; + return getBrowseService().delete({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; }; service.rename = function(volumeId, path, newPath) { var payload = { - CurrentFilePath: path, + CurrentFilePath: path, NewFilePath: newPath }; - return Browse.rename({ volumeID: volumeId }, payload).$promise; + return getBrowseService().rename({ volumeID: volumeId, version: getAgentApiVersion() }, payload).$promise; + }; + + service.upload = function upload(path, file, volumeId, onProgress) { + var deferred = $q.defer(); + var agentVersion = StateManager.getAgentApiVersion(); + if (agentVersion <2) { + deferred.reject('upload is not supported on this agent version'); + return; + } + var url = + API_ENDPOINT_ENDPOINTS + + '/' + + EndpointProvider.endpointID() + + '/docker' + + '/v' + agentVersion + + '/browse/put?volumeID=' + + volumeId; + + Upload.upload({ + url: url, + data: { file: file, Path: path } + }).then(deferred.resolve, deferred.reject, onProgress); + return deferred.promise; }; return service; diff --git a/app/docker/components/host-overview/host-overview.html b/app/docker/components/host-overview/host-overview.html index 81ac13c62..e38ccabb0 100644 --- a/app/docker/components/host-overview/host-overview.html +++ b/app/docker/components/host-overview/host-overview.html @@ -10,12 +10,12 @@ - - + + \ No newline at end of file diff --git a/app/docker/components/host-overview/host-overview.js b/app/docker/components/host-overview/host-overview.js index 78883e4e8..36ab4087a 100644 --- a/app/docker/components/host-overview/host-overview.js +++ b/app/docker/components/host-overview/host-overview.js @@ -6,6 +6,7 @@ angular.module('portainer.docker').component('hostOverview', { devices: '<', disks: '<', isAgent: '<', + agentApiVersion: '<', refreshUrl: '@', browseUrl: '@' }, diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html index 9bb9b7f85..8b46f466f 100644 --- a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html @@ -26,7 +26,7 @@ Total memory {{ $ctrl.host.totalMemory | humansize }} - +
diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index 787f67328..36c7dadb5 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.factory('StateManager', ['$q', 'SystemService', 'InfoHelper', 'LocalStorage', 'SettingsService', 'StatusService', 'APPLICATION_CACHE_VALIDITY', -function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, SettingsService, StatusService, APPLICATION_CACHE_VALIDITY) { +.factory('StateManager', ['$q', 'SystemService', 'InfoHelper', 'LocalStorage', 'SettingsService', 'StatusService', 'APPLICATION_CACHE_VALIDITY', 'AgentPingService', +function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, SettingsService, StatusService, APPLICATION_CACHE_VALIDITY, AgentPingService) { 'use strict'; var manager = {}; @@ -157,6 +157,14 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin state.endpoint.name = name; state.endpoint.apiVersion = endpointAPIVersion; state.endpoint.extensions = assignExtensions(extensions); + + if (endpointMode.agentProxy) { + return AgentPingService.ping().then(function onPingSuccess(data) { + state.endpoint.agentApiVersion = data.version; + }); + } + + }).then(function () { LocalStorage.storeEndpointState(state.endpoint); deferred.resolve(); }) @@ -170,5 +178,9 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin return deferred.promise; }; + manager.getAgentApiVersion = function getAgentApiVersion() { + return state.endpoint.agentApiVersion; + }; + return manager; }]); From fe6ca042f37e912974916e027823fa180d6afee4 Mon Sep 17 00:00:00 2001 From: Ricardo Cardona Ramirez Date: Sat, 27 Oct 2018 21:39:09 -0500 Subject: [PATCH 32/93] feat(ux): Alphabetically sort configs and secrets in service details/creation (#2396) * fix(sorting): Alphabetically sort configs in service details select box * fix(sorting): Alphabetically sort configs and secrets for service creation --- app/docker/views/services/create/includes/config.html | 2 +- app/docker/views/services/create/includes/secret.html | 2 +- app/docker/views/services/edit/includes/configs.html | 2 +- app/docker/views/services/edit/includes/secrets.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/docker/views/services/create/includes/config.html b/app/docker/views/services/create/includes/config.html index 8083ae2f2..eac52f780 100644 --- a/app/docker/views/services/create/includes/config.html +++ b/app/docker/views/services/create/includes/config.html @@ -10,7 +10,7 @@
config -
diff --git a/app/docker/views/services/create/includes/secret.html b/app/docker/views/services/create/includes/secret.html index aca1f5264..aa6a42375 100644 --- a/app/docker/views/services/create/includes/secret.html +++ b/app/docker/views/services/create/includes/secret.html @@ -15,7 +15,7 @@
secret -
diff --git a/app/docker/views/services/edit/includes/configs.html b/app/docker/views/services/edit/includes/configs.html index db36d0edb..21e4805ba 100644 --- a/app/docker/views/services/edit/includes/configs.html +++ b/app/docker/views/services/edit/includes/configs.html @@ -5,7 +5,7 @@
Add a config: - diff --git a/app/docker/views/services/edit/includes/secrets.html b/app/docker/views/services/edit/includes/secrets.html index 04444aa6d..b6e680881 100644 --- a/app/docker/views/services/edit/includes/secrets.html +++ b/app/docker/views/services/edit/includes/secrets.html @@ -5,7 +5,7 @@
Add a secret: -
From 07c1e1bc3e4e90ca3f12c2bfb7a18a7de4fc4be6 Mon Sep 17 00:00:00 2001 From: Yassir Hannoun Date: Sun, 28 Oct 2018 03:45:02 +0100 Subject: [PATCH 33/93] feat(container-stats): display cache in memory usage chart (#2383) --- app/docker/models/container.js | 1 + .../stats/containerStatsController.js | 3 +- app/portainer/services/chartService.js | 310 ++++++++++-------- 3 files changed, 180 insertions(+), 134 deletions(-) diff --git a/app/docker/models/container.js b/app/docker/models/container.js index 627ebec86..ee8837498 100644 --- a/app/docker/models/container.js +++ b/app/docker/models/container.js @@ -65,6 +65,7 @@ function ContainerViewModel(data) { function ContainerStatsViewModel(data) { this.Date = data.read; this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache; + this.MemoryCache = data.memory_stats.stats.cache; this.PreviousCPUTotalUsage = data.precpu_stats.cpu_usage.total_usage; this.PreviousCPUSystemUsage = data.precpu_stats.system_cpu_usage; this.CurrentCPUTotalUsage = data.cpu_stats.cpu_usage.total_usage; diff --git a/app/docker/views/containers/stats/containerStatsController.js b/app/docker/views/containers/stats/containerStatsController.js index ed161c8d1..d14aeaca1 100644 --- a/app/docker/views/containers/stats/containerStatsController.js +++ b/app/docker/views/containers/stats/containerStatsController.js @@ -31,9 +31,8 @@ function ($q, $scope, $transition$, $document, $interval, ContainerService, Char function updateMemoryChart(stats, chart) { var label = moment(stats.Date).format('HH:mm:ss'); - var value = stats.MemoryUsage; - ChartService.UpdateMemoryChart(label, value, chart); + ChartService.UpdateMemoryChart(label, stats.MemoryUsage, stats.MemoryCache, chart); } function updateCPUChart(stats, chart) { diff --git a/app/portainer/services/chartService.js b/app/portainer/services/chartService.js index 4b10c9a83..818ed6e3d 100644 --- a/app/portainer/services/chartService.js +++ b/app/portainer/services/chartService.js @@ -1,14 +1,14 @@ angular.module('portainer.app') -.factory('ChartService', [function ChartService() { - 'use strict'; + .factory('ChartService', [function ChartService() { + 'use strict'; - // Max. number of items to display on a chart - var CHART_LIMIT = 600; + // Max. number of items to display on a chart + var CHART_LIMIT = 600; - var service = {}; + var service = {}; - function defaultChartOptions(pos, tooltipCallback, scalesCallback) { - return { + function defaultChartOptions(pos, tooltipCallback, scalesCallback, isStacked) { + return { animation: { duration: 0 }, responsiveAnimationDuration: 0, responsive: true, @@ -17,7 +17,7 @@ angular.module('portainer.app') intersect: false, position: pos, callbacks: { - label: function(tooltipItem, data) { + label: function (tooltipItem, data) { var datasetLabel = data.datasets[tooltipItem.datasetIndex].label; return tooltipCallback(datasetLabel, tooltipItem.yLabel); } @@ -26,142 +26,188 @@ angular.module('portainer.app') hover: { animationDuration: 0 }, scales: { yAxes: [{ - ticks: { - beginAtZero: true, - callback: scalesCallback - } + stacked: isStacked, + ticks: { + beginAtZero: true, + callback: scalesCallback + } }] } }; - } - - function CreateChart (context, label, tooltipCallback, scalesCallback) { - return new Chart(context, { - type: 'line', - data: { - labels: [], - datasets: [ - { - label: label, - data: [], - fill: true, - backgroundColor: 'rgba(151,187,205,0.4)', - borderColor: 'rgba(151,187,205,0.6)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointBorderColor: 'rgba(151,187,205,1)', - pointRadius: 2, - borderWidth: 2 - } - ] - }, - options: defaultChartOptions('nearest', tooltipCallback, scalesCallback) - }); - } - - service.CreateCPUChart = function(context) { - return CreateChart(context, 'CPU', percentageBasedTooltipLabel, percentageBasedAxisLabel); - }; - - service.CreateMemoryChart = function(context) { - return CreateChart(context, 'Memory', byteBasedTooltipLabel, byteBasedAxisLabel); - }; - - service.CreateNetworkChart = function(context) { - return new Chart(context, { - type: 'line', - data: { - labels: [], - datasets: [ - { - label: 'RX on eth0', - data: [], - fill: false, - backgroundColor: 'rgba(151,187,205,0.4)', - borderColor: 'rgba(151,187,205,0.6)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointBorderColor: 'rgba(151,187,205,1)', - pointRadius: 2, - borderWidth: 2 - }, - { - label: 'TX on eth0', - data: [], - fill: false, - backgroundColor: 'rgba(255,180,174,0.4)', - borderColor: 'rgba(255,180,174,0.6)', - pointBackgroundColor: 'rgba(255,180,174,1)', - pointBorderColor: 'rgba(255,180,174,1)', - pointRadius: 2, - borderWidth: 2 - } - ] - }, - options: defaultChartOptions('average', byteBasedTooltipLabel, byteBasedAxisLabel) - }); - }; - - function UpdateChart(label, value, chart) { - chart.data.labels.push(label); - chart.data.datasets[0].data.push(value); - - if (chart.data.datasets[0].data.length > CHART_LIMIT) { - chart.data.labels.pop(); - chart.data.datasets[0].data.pop(); } - chart.update(0); - } - - service.UpdateMemoryChart = UpdateChart; - service.UpdateCPUChart = UpdateChart; - - service.UpdateNetworkChart = function(label, rx, tx, chart) { - chart.data.labels.push(label); - chart.data.datasets[0].data.push(rx); - chart.data.datasets[1].data.push(tx); - - if (chart.data.datasets[0].data.length > CHART_LIMIT) { - chart.data.labels.pop(); - chart.data.datasets[0].data.pop(); - chart.data.datasets[1].data.pop(); + function CreateChart(context, label, tooltipCallback, scalesCallback) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: label, + data: [], + fill: true, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2 + } + ] + }, + options: defaultChartOptions('nearest', tooltipCallback, scalesCallback) + }); } - chart.update(0); - }; - - function byteBasedTooltipLabel(label, value) { - var processedValue = 0; - if (value > 5) { - processedValue = filesize(value, {base: 10, round: 1}); - } else { - processedValue = value.toFixed(1) + 'B'; + function CreateMemoryChart(context, tooltipCallback, scalesCallback) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'Memory', + data: [], + fill: true, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2 + }, + { + label: 'Cache', + data: [], + fill: true, + backgroundColor: 'rgba(255,180,174,0.4)', + borderColor: 'rgba(255,180,174,0.6)', + pointBackgroundColor: 'rgba(255,180,174,1)', + pointBorderColor: 'rgba(255,180,174,1)', + pointRadius: 2, + borderWidth: 2 + } + ] + }, + options: defaultChartOptions('nearest', tooltipCallback, scalesCallback, true) + }); } - return label + ': ' + processedValue; - } - function byteBasedAxisLabel(value) { - if (value > 5) { - return filesize(value, {base: 10, round: 1}); + service.CreateCPUChart = function (context) { + return CreateChart(context, 'CPU', percentageBasedTooltipLabel, percentageBasedAxisLabel); + }; + + service.CreateMemoryChart = function (context) { + return CreateMemoryChart(context, byteBasedTooltipLabel, byteBasedAxisLabel); + }; + + service.CreateNetworkChart = function (context) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'RX on eth0', + data: [], + fill: false, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2 + }, + { + label: 'TX on eth0', + data: [], + fill: false, + backgroundColor: 'rgba(255,180,174,0.4)', + borderColor: 'rgba(255,180,174,0.6)', + pointBackgroundColor: 'rgba(255,180,174,1)', + pointBorderColor: 'rgba(255,180,174,1)', + pointRadius: 2, + borderWidth: 2 + } + ] + }, + options: defaultChartOptions('average', byteBasedTooltipLabel, byteBasedAxisLabel) + }); + }; + + function UpdateChart(label, value, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(value); + + if (chart.data.datasets[0].data.length > CHART_LIMIT) { + chart.data.labels.pop(); + chart.data.datasets[0].data.pop(); + } + + chart.update(0); } - return value.toFixed(1) + 'B'; - } - function percentageBasedAxisLabel(value) { - if (value > 1) { - return Math.round(value) + '%'; + service.UpdateMemoryChart = function UpdateChart(label, memoryValue, cacheValue, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(memoryValue); + chart.data.datasets[1].data.push(cacheValue); + + if (chart.data.datasets[0].data.length > CHART_LIMIT) { + chart.data.labels.pop(); + chart.data.datasets[0].data.pop(); + } + + chart.update(0); + }; + service.UpdateCPUChart = UpdateChart; + + service.UpdateNetworkChart = function (label, rx, tx, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(rx); + chart.data.datasets[1].data.push(tx); + + if (chart.data.datasets[0].data.length > CHART_LIMIT) { + chart.data.labels.pop(); + chart.data.datasets[0].data.pop(); + chart.data.datasets[1].data.pop(); + } + + chart.update(0); + }; + + function byteBasedTooltipLabel(label, value) { + var processedValue = 0; + if (value > 5) { + processedValue = filesize(value, { base: 10, round: 1 }); + } else { + processedValue = value.toFixed(1) + 'B'; + } + return label + ': ' + processedValue; } - return value.toFixed(1) + '%'; - } - function percentageBasedTooltipLabel(label, value) { - var processedValue = 0; - if (value > 1) { - processedValue = Math.round(value); - } else { - processedValue = value.toFixed(1); + function byteBasedAxisLabel(value) { + if (value > 5) { + return filesize(value, { base: 10, round: 1 }); + } + return value.toFixed(1) + 'B'; } - return label + ': ' + processedValue + '%'; - } - return service; -}]); + function percentageBasedAxisLabel(value) { + if (value > 1) { + return Math.round(value) + '%'; + } + return value.toFixed(1) + '%'; + } + + function percentageBasedTooltipLabel(label, value) { + var processedValue = 0; + if (value > 1) { + processedValue = Math.round(value); + } else { + processedValue = value.toFixed(1); + } + return label + ': ' + processedValue + '%'; + } + + return service; + }]); From 7e6c647e93026de89593f3f5883721516444992f Mon Sep 17 00:00:00 2001 From: Damian Czaja Date: Sun, 28 Oct 2018 04:00:56 +0100 Subject: [PATCH 34/93] feat(container-creation): add the ability to override the logging driver (#2384) --- .../create/createContainerController.js | 39 ++++++++++++- .../containers/create/createcontainer.html | 57 ++++++++++++++++++- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index ddddda3ad..2498c4245 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'HttpRequestHelper', -function ($q, $scope, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, HttpRequestHelper) { +.controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'PluginService', 'HttpRequestHelper', +function ($q, $scope, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, PluginService, HttpRequestHelper) { $scope.create = create; @@ -19,7 +19,9 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai MemoryLimit: 0, MemoryReservation: 0, NodeName: null, - capabilities: [] + capabilities: [], + LogDriverName: '', + LogDriverOpts: [] }; $scope.extraNetworks = {}; @@ -110,6 +112,14 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai $scope.config.HostConfig.Devices.splice(index, 1); }; + $scope.addLogDriverOpt = function() { + $scope.formValues.LogDriverOpts.push({ name: '', value: ''}); + }; + + $scope.removeLogDriverOpt = function(index) { + $scope.formValues.LogDriverOpts.splice(index, 1); + }; + $scope.fromContainerMultipleNetworks = false; function prepareImageConfig(config) { @@ -257,6 +267,23 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } } + function prepareLogDriver(config) { + var logOpts = {}; + if ($scope.formValues.LogDriverName) { + config.HostConfig.LogConfig = { Type: $scope.formValues.LogDriverName }; + if ($scope.formValues.LogDriverName !== 'none') { + $scope.formValues.LogDriverOpts.forEach(function (opt) { + if (opt.name) { + logOpts[opt.name] = opt.value; + } + }); + if (Object.keys(logOpts).length !== 0 && logOpts.constructor === Object) { + config.HostConfig.LogConfig.Config = logOpts; + } + } + } + } + function prepareCapabilities(config) { var allowed = $scope.formValues.capabilities.filter(function(item) {return item.allowed === true;}); var notAllowed = $scope.formValues.capabilities.filter(function(item) {return item.allowed === false;}); @@ -278,6 +305,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai prepareLabels(config); prepareDevices(config); prepareResources(config); + prepareLogDriver(config); prepareCapabilities(config); return config; } @@ -568,6 +596,11 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai Notifications.error('Failure', err, 'Unable to retrieve application settings'); }); + PluginService.loggingPlugins(apiVersion < 1.25) + .then(function success(loggingDrivers) { + $scope.availableLoggingDrivers = loggingDrivers; + }); + var userDetails = Authentication.getUserDetails(); $scope.isAdmin = userDetails.role === 1; } diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index a14c8f1d4..3f61e44fb 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -146,7 +146,7 @@
+ +
+ Logging +
+ +
+ +
+ +
+
+

+ Logging driver that will override the default docker daemon driver. Select Default logging driver if you don't want to override it. Supported logging drivers can be found in the Docker documentation. +

+
+
+ + +
+
+ + + add logging driver option + +
+ +
+
+
+ option + +
+
+ value + +
+ +
+
+ +
+ + +
From 6ab510e5cb93f996346879911f7cc5b2f856e89d Mon Sep 17 00:00:00 2001 From: Mark Stansberry Date: Sat, 27 Oct 2018 23:05:54 -0400 Subject: [PATCH 35/93] docs(api): update swagger related files to support swagger-codegen (#2404) * Linting updates to api/swagger.yaml * Security updates to api/swagger.yml * Add api/swagger_config.json for swagger-codegen * Add swagger_config.json packageVersion to match swagger.yml --- api/swagger.yaml | 138 ++++++++++++++++++++++++++++++++++++++-- api/swagger_config.json | 5 ++ 2 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 api/swagger_config.json diff --git a/api/swagger.yaml b/api/swagger.yaml index 1c7f9ac38..6a97dbde0 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -153,6 +153,8 @@ paths: operationId: "DockerHubInspect" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -175,6 +177,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -211,6 +215,8 @@ paths: operationId: "EndpointList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -233,6 +239,8 @@ paths: - "multipart/form-data" produces: - "application/json" + security: + - jwt: [] parameters: - name: "Name" in: "formData" @@ -265,7 +273,7 @@ paths: - name: "TLSSkipVerify" in: "formData" type: "string" - description: "Skip server verification when using TLS" (example: false) + description: "Skip server verification when using TLS (example: false)" - name: "TLSCACertFile" in: "formData" type: "file" @@ -324,6 +332,8 @@ paths: operationId: "EndpointInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -365,6 +375,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -413,6 +425,8 @@ paths: Remove an endpoint. **Access policy**: administrator operationId: "EndpointDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -460,6 +474,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -508,6 +524,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -573,6 +591,8 @@ paths: operationId: "EndpointGroupList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -595,6 +615,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -629,6 +651,8 @@ paths: operationId: "EndpointGroupInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -670,6 +694,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -720,6 +746,8 @@ paths: Remove an endpoint group. **Access policy**: administrator operationId: "EndpointGroupDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -767,6 +795,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -815,6 +845,8 @@ paths: operationId: "RegistryList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -837,6 +869,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -878,6 +912,8 @@ paths: operationId: "RegistryInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -919,6 +955,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -969,6 +1007,8 @@ paths: Remove a registry. **Access policy**: administrator operationId: "RegistryDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1009,6 +1049,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1057,6 +1099,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1107,6 +1151,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1157,6 +1203,8 @@ paths: Remove a resource control. **Access policy**: restricted operationId: "ResourceControlDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1202,6 +1250,8 @@ paths: operationId: "SettingsInspect" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1224,6 +1274,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1258,6 +1310,8 @@ paths: operationId: "PublicSettingsInspect" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1281,6 +1335,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1313,6 +1369,8 @@ paths: operationId: "StatusInspect" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1336,6 +1394,8 @@ paths: operationId: "StackList" produces: - "application/json" + security: + - jwt: [] parameters: - name: "filters" in: "query" @@ -1368,6 +1428,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "type" in: "query" @@ -1447,6 +1509,8 @@ paths: operationId: "StackInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1492,6 +1556,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1544,6 +1610,8 @@ paths: Remove a stack. **Access policy**: restricted operationId: "StackDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1594,6 +1662,8 @@ paths: operationId: "StackFileInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1639,6 +1709,8 @@ paths: operationId: "StackMigrate" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1693,6 +1765,8 @@ paths: operationId: "UserList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1716,6 +1790,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1764,6 +1840,8 @@ paths: operationId: "UserInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1805,6 +1883,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1855,6 +1935,8 @@ paths: Remove a user. **Access policy**: administrator operationId: "UserDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1893,6 +1975,8 @@ paths: operationId: "UserMembershipsInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1936,6 +2020,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1983,6 +2069,8 @@ paths: operationId: "UserAdminCheck" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 204: @@ -2012,6 +2100,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2056,6 +2146,8 @@ paths: - multipart/form-data produces: - "application/json" + security: + - jwt: [] parameters: - in: "path" name: "certificate" @@ -2097,6 +2189,8 @@ paths: operationId: "TagList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -2119,6 +2213,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2158,6 +2254,8 @@ paths: Remove a tag. **Access policy**: administrator operationId: "TagDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2190,6 +2288,8 @@ paths: operationId: "TeamList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -2212,6 +2312,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2260,6 +2362,8 @@ paths: operationId: "TeamInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2308,6 +2412,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2349,6 +2455,8 @@ paths: Remove a team. **Access policy**: administrator operationId: "TeamDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2388,6 +2496,8 @@ paths: operationId: "TeamMembershipsInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2429,6 +2539,8 @@ paths: operationId: "TeamMembershipList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -2458,6 +2570,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2508,6 +2622,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2558,6 +2674,8 @@ paths: Remove a team membership. Access is only available to administrators leaders of the associated team. **Access policy**: restricted operationId: "TeamMembershipDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2604,6 +2722,8 @@ paths: operationId: "TemplateList" produces: - "application/json" + security: + - jwt: [] parameters: responses: 200: @@ -2626,6 +2746,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2667,6 +2789,8 @@ paths: operationId: "TemplateInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2715,6 +2839,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2763,6 +2889,8 @@ paths: Remove a template. **Access policy**: administrator operationId: "TemplateDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -3848,9 +3976,9 @@ definitions: TemplateCreateRequest: type: "object" required: - - "type" - - "title" - - "description" + - "type" + - "title" + - "description" properties: type: type: "integer" @@ -4202,7 +4330,7 @@ definitions: TemplateRepository: type: "object" required: - - "URL" + - "URL" properties: URL: type: "string" diff --git a/api/swagger_config.json b/api/swagger_config.json new file mode 100644 index 000000000..957f1358e --- /dev/null +++ b/api/swagger_config.json @@ -0,0 +1,5 @@ +{ + "packageName": "portainer", + "packageVersion": "1.20-dev", + "projectName": "portainer" +} From 354fda31f1c34cdb3810bebc5211e1e33620e5cb Mon Sep 17 00:00:00 2001 From: baron_l Date: Sun, 28 Oct 2018 07:06:50 +0100 Subject: [PATCH 36/93] feat(jobs): add the ability to run a job on a target endpoint #2374 * feat(jobs): adding the ability to run scripts on endpoints fix(job): click on containerId in JobsDatatable redirects to container's logs refactor(job): remove the jobs datatable settings + texts changes on JobCreation view fix(jobs): jobs payloads are now following API rules and case feat(jobs): adding the capability to run scripts on hosts * feat(jobs): adding the ability to purge jobs containers * refactor(job): apply review changes * feat(job-creation): store image name in local storage * feat(host): disable job exec link in non-agent Swarm setup * feat(host): only display execute job in agent setups or standalone * feat(job): job execution overhaul * docs(swagger): update EndpointJob documentation --- api/docker/client.go | 13 +- api/docker/jobservice.go | 4 +- api/docker/snapshotter.go | 2 +- api/http/handler/endpoints/endpoint_job.go | 16 +- api/http/handler/webhooks/webhook_execute.go | 2 +- api/portainer.go | 2 +- api/swagger.yaml | 5 + app/docker/__module.js | 24 ++- .../host-jobs-datatable/jobsDatatable.html | 118 +++++++++++++++ .../host-jobs-datatable/jobsDatatable.js | 12 ++ .../jobsDatatableController.js | 140 ++++++++++++++++++ .../host-overview/host-overview.html | 19 ++- .../components/host-overview/host-overview.js | 5 +- .../host-details-panel.html | 13 +- .../host-details-panel/host-details-panel.js | 7 +- app/docker/rest/container.js | 3 + app/docker/services/containerService.js | 4 + .../host-browser-view-controller.js | 34 ++--- .../host/host-job/host-job-controller.js | 17 +++ app/docker/views/host/host-job/host-job.html | 16 ++ app/docker/views/host/host-job/host-job.js | 4 + app/docker/views/host/host-view-controller.js | 49 +++--- app/docker/views/host/host-view.html | 6 +- .../node-browser/node-browser-controller.js | 14 +- .../node-details-view-controller.js | 39 +++-- .../nodes/node-details/node-details-view.html | 6 +- .../nodes/node-job/node-job-controller.js | 20 +++ app/docker/views/nodes/node-job/node-job.html | 18 +++ app/docker/views/nodes/node-job/node-job.js | 4 + .../execute-job-form-controller.js | 69 +++++++++ .../execute-job-form/execute-job-form.html | 110 ++++++++++++++ .../execute-job-form/execute-job-form.js | 7 + app/portainer/rest/endpoint.js | 3 +- app/portainer/services/api/endpointService.js | 13 ++ app/portainer/services/fileUpload.js | 11 ++ app/portainer/services/localStorage.js | 6 + app/portainer/services/notifications.js | 4 +- 37 files changed, 739 insertions(+), 100 deletions(-) create mode 100644 app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html create mode 100644 app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js create mode 100644 app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js create mode 100644 app/docker/views/host/host-job/host-job-controller.js create mode 100644 app/docker/views/host/host-job/host-job.html create mode 100644 app/docker/views/host/host-job/host-job.js create mode 100644 app/docker/views/nodes/node-job/node-job-controller.js create mode 100644 app/docker/views/nodes/node-job/node-job.html create mode 100644 app/docker/views/nodes/node-job/node-job.js create mode 100644 app/portainer/components/forms/execute-job-form/execute-job-form-controller.js create mode 100644 app/portainer/components/forms/execute-job-form/execute-job-form.html create mode 100644 app/portainer/components/forms/execute-job-form/execute-job-form.js diff --git a/api/docker/client.go b/api/docker/client.go index a0a65a11d..9c608a19b 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -26,12 +26,13 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService) *Clien } // CreateClient is a generic function to create a Docker client based on -// a specific endpoint configuration -func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*client.Client, error) { +// a specific endpoint configuration. The nodeName parameter can be used +// with an agent enabled endpoint to target a specific node in an agent cluster. +func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) { if endpoint.Type == portainer.AzureEnvironment { return nil, unsupportedEnvironmentType } else if endpoint.Type == portainer.AgentOnDockerEnvironment { - return createAgentClient(endpoint, factory.signatureService) + return createAgentClient(endpoint, factory.signatureService, nodeName) } if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { @@ -60,7 +61,7 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) { ) } -func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService) (*client.Client, error) { +func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string) (*client.Client, error) { httpCli, err := httpClient(endpoint) if err != nil { return nil, err @@ -76,6 +77,10 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer. portainer.PortainerAgentSignatureHeader: signature, } + if nodeName != "" { + headers[portainer.PortainerAgentTargetHeader] = nodeName + } + return client.NewClientWithOpts( client.WithHost(endpoint.URL), client.WithVersion(portainer.SupportedDockerAPIVersion), diff --git a/api/docker/jobservice.go b/api/docker/jobservice.go index 246124639..d2559d7d2 100644 --- a/api/docker/jobservice.go +++ b/api/docker/jobservice.go @@ -29,13 +29,13 @@ func NewJobService(dockerClientFactory *ClientFactory) *JobService { } // Execute will execute a script on the endpoint host with the supplied image as a container -func (service *JobService) Execute(endpoint *portainer.Endpoint, image string, script []byte) error { +func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image string, script []byte) error { buffer, err := archive.TarFileInBuffer(script, "script.sh", 0700) if err != nil { return err } - cli, err := service.DockerClientFactory.CreateClient(endpoint) + cli, err := service.DockerClientFactory.CreateClient(endpoint, nodeName) if err != nil { return err } diff --git a/api/docker/snapshotter.go b/api/docker/snapshotter.go index 34cb35def..ee095f6d5 100644 --- a/api/docker/snapshotter.go +++ b/api/docker/snapshotter.go @@ -18,7 +18,7 @@ func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter { // CreateSnapshot creates a snapshot of a specific endpoint func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.Snapshot, error) { - cli, err := snapshotter.clientFactory.CreateClient(endpoint) + cli, err := snapshotter.clientFactory.CreateClient(endpoint, "") if err != nil { return nil, err } diff --git a/api/http/handler/endpoints/endpoint_job.go b/api/http/handler/endpoints/endpoint_job.go index 87be7b5a0..025f6ab3a 100644 --- a/api/http/handler/endpoints/endpoint_job.go +++ b/api/http/handler/endpoints/endpoint_job.go @@ -49,7 +49,7 @@ func (payload *endpointJobFromFileContentPayload) Validate(r *http.Request) erro return nil } -// POST request on /api/endpoints/:id/job?method +// POST request on /api/endpoints/:id/job?method&nodeName func (handler *Handler) endpointJob(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { @@ -61,6 +61,8 @@ func (handler *Handler) endpointJob(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err} } + nodeName, _ := request.RetrieveQueryParameter(r, "nodeName", true) + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} @@ -75,22 +77,22 @@ func (handler *Handler) endpointJob(w http.ResponseWriter, r *http.Request) *htt switch method { case "file": - return handler.executeJobFromFile(w, r, endpoint) + return handler.executeJobFromFile(w, r, endpoint, nodeName) case "string": - return handler.executeJobFromFileContent(w, r, endpoint) + return handler.executeJobFromFileContent(w, r, endpoint, nodeName) } return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string or file", errors.New(request.ErrInvalidQueryParameter)} } -func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError { payload := &endpointJobFromFilePayload{} err := payload.Validate(r) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - err = handler.JobService.Execute(endpoint, payload.Image, payload.File) + err = handler.JobService.Execute(endpoint, nodeName, payload.Image, payload.File) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} } @@ -98,14 +100,14 @@ func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Reques return response.Empty(w) } -func (handler *Handler) executeJobFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) executeJobFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError { var payload endpointJobFromFileContentPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - err = handler.JobService.Execute(endpoint, payload.Image, []byte(payload.FileContent)) + err = handler.JobService.Execute(endpoint, nodeName, payload.Image, []byte(payload.FileContent)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} } diff --git a/api/http/handler/webhooks/webhook_execute.go b/api/http/handler/webhooks/webhook_execute.go index f43045899..9126942e7 100644 --- a/api/http/handler/webhooks/webhook_execute.go +++ b/api/http/handler/webhooks/webhook_execute.go @@ -49,7 +49,7 @@ func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) * } func (handler *Handler) executeServiceWebhook(w http.ResponseWriter, endpoint *portainer.Endpoint, resourceID string) *httperror.HandlerError { - dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint) + dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "") if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err} } diff --git a/api/portainer.go b/api/portainer.go index 1422d15bb..c1cdbab4f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -638,7 +638,7 @@ type ( // JobService represents a service to manage job execution on hosts JobService interface { - Execute(endpoint *Endpoint, image string, script []byte) error + Execute(endpoint *Endpoint, nodeName, image string, script []byte) error } ) diff --git a/api/swagger.yaml b/api/swagger.yaml index 6a97dbde0..51d8a9f68 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -537,6 +537,11 @@ paths: description: "Job execution method. Possible values: file or string." required: true type: "string" + - name: "nodeName" + in: "query" + description: "Optional. Hostname of a node when targeting a Portainer agent cluster." + required: true + type: "string" - in: "body" name: "body" description: "Job details. Required when method equals string." diff --git a/app/docker/__module.js b/app/docker/__module.js index 4b4bdfc74..9bd99a7cd 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -149,6 +149,16 @@ angular.module('portainer.docker', ['portainer.app']) } }; + var hostJob = { + name: 'docker.host.job', + url: '/job', + views: { + 'content@': { + component: 'hostJobView' + } + } + }; + var events = { name: 'docker.events', url: '/events', @@ -263,6 +273,16 @@ angular.module('portainer.docker', ['portainer.app']) } }; + var nodeJob = { + name: 'docker.nodes.node.job', + url: '/job', + views: { + 'content@': { + component: 'nodeJobView' + } + } + }; + var secrets = { name: 'docker.secrets', url: '/secrets', @@ -434,7 +454,7 @@ angular.module('portainer.docker', ['portainer.app']) } }; - + $stateRegistryProvider.register(configs); $stateRegistryProvider.register(config); @@ -450,6 +470,7 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(dashboard); $stateRegistryProvider.register(host); $stateRegistryProvider.register(hostBrowser); + $stateRegistryProvider.register(hostJob); $stateRegistryProvider.register(events); $stateRegistryProvider.register(images); $stateRegistryProvider.register(image); @@ -461,6 +482,7 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(nodes); $stateRegistryProvider.register(node); $stateRegistryProvider.register(nodeBrowser); + $stateRegistryProvider.register(nodeJob); $stateRegistryProvider.register(secrets); $stateRegistryProvider.register(secret); $stateRegistryProvider.register(secretCreation); diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html new file mode 100644 index 000000000..38f58e6a9 --- /dev/null +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html @@ -0,0 +1,118 @@ +
+
+
+ + +
+
+ + {{ $ctrl.titleText }} +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + Id + + + + + + State + + + +
+ Filter + + Filter + +
+ +
+ + + + Created + +
+ + {{ item.Id | truncate: 32}} + + {{ item.Status }} + + {{ item.Status }} + + {{item.Created | getisodatefromtimestamp}} +
Loading...
No jobs available.
+
+ +
+
+
+
+
diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js new file mode 100644 index 000000000..8c599cae3 --- /dev/null +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js @@ -0,0 +1,12 @@ +angular.module('portainer.docker').component('jobsDatatable', { + templateUrl: 'app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html', + controller: 'JobsDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<' + } +}); \ No newline at end of file diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js new file mode 100644 index 000000000..1a02da763 --- /dev/null +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js @@ -0,0 +1,140 @@ +angular.module('portainer.docker') + .controller('JobsDatatableController', ['$q', '$state', 'PaginationService', 'DatatableService', 'ContainerService', 'ModalService', 'Notifications', + function ($q, $state, PaginationService, DatatableService, ContainerService, ModalService, Notifications) { + var ctrl = this; + + this.state = { + orderBy: this.orderBy, + paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey), + displayTextFilter: false + }; + + this.filters = { + state: { + open: false, + enabled: false, + values: [] + } + }; + + this.changeOrderBy = function (orderField) { + this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; + this.state.orderBy = orderField; + DatatableService.setDataTableOrder(this.tableKey, orderField, this.state.reverseOrder); + }; + + this.changePaginationLimit = function () { + PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit); + }; + + this.applyFilters = function (value) { + var container = value; + var filters = ctrl.filters; + for (var i = 0; i < filters.state.values.length; i++) { + var filter = filters.state.values[i]; + if (container.Status === filter.label && filter.display) { + return true; + } + } + return false; + }; + + this.onStateFilterChange = function () { + var filters = this.filters.state.values; + var filtered = false; + for (var i = 0; i < filters.length; i++) { + var filter = filters[i]; + if (!filter.display) { + filtered = true; + } + } + this.filters.state.enabled = filtered; + DatatableService.setDataTableFilters(this.tableKey, this.filters); + }; + + this.prepareTableFromDataset = function () { + var availableStateFilters = []; + for (var i = 0; i < this.dataset.length; i++) { + var item = this.dataset[i]; + availableStateFilters.push({ + label: item.Status, + display: true + }); + } + this.filters.state.values = _.uniqBy(availableStateFilters, 'label'); + }; + + this.updateStoredFilters = function (storedFilters) { + var datasetFilters = this.filters.state.values; + + for (var i = 0; i < datasetFilters.length; i++) { + var filter = datasetFilters[i]; + existingFilter = _.find(storedFilters, ['label', filter.label]); + if (existingFilter && !existingFilter.display) { + filter.display = existingFilter.display; + this.filters.state.enabled = true; + } + } + }; + + function confirmPurgeJobs() { + return showConfirmationModal(); + + function showConfirmationModal() { + var deferred = $q.defer(); + + ModalService.confirm({ + title: 'Are you sure ?', + message: 'Clearing job history will remove all stopped jobs containers.', + buttons: { + confirm: { + label: 'Purge', + className: 'btn-danger' + } + }, + callback: function onConfirm(confirmed) { + deferred.resolve(confirmed); + } + }); + + return deferred.promise; + } + } + + this.purgeAction = function () { + confirmPurgeJobs().then(function success(confirmed) { + if (!confirmed) { + return $q.when(); + } + ContainerService.prune({ label: ['io.portainer.job.endpoint'] }).then(function success() { + Notifications.success('Success', 'Job hisotry cleared'); + $state.reload(); + }).catch(function error(err) { + Notifications.error('Failure', err.message, 'Unable to clear job history'); + }); + }); + }; + + this.$onInit = function () { + setDefaults(this); + this.prepareTableFromDataset(); + + var storedOrder = DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + + var storedFilters = DatatableService.getDataTableFilters(this.tableKey); + if (storedFilters !== null) { + this.updateStoredFilters(storedFilters.state.values); + } + this.filters.state.open = false; + }; + + function setDefaults(ctrl) { + ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false; + ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false; + } + } + ]); diff --git a/app/docker/components/host-overview/host-overview.html b/app/docker/components/host-overview/host-overview.html index e38ccabb0..6ca96d1b3 100644 --- a/app/docker/components/host-overview/host-overview.html +++ b/app/docker/components/host-overview/host-overview.html @@ -8,14 +8,25 @@ Docker - + browse-url="{{$ctrl.browseUrl}}" + is-job-enabled="$ctrl.isJobEnabled" + job-url="{{$ctrl.jobUrl}}" +> + + - \ No newline at end of file + diff --git a/app/docker/components/host-overview/host-overview.js b/app/docker/components/host-overview/host-overview.js index 36ab4087a..2d5af5f5b 100644 --- a/app/docker/components/host-overview/host-overview.js +++ b/app/docker/components/host-overview/host-overview.js @@ -8,7 +8,10 @@ angular.module('portainer.docker').component('hostOverview', { isAgent: '<', agentApiVersion: '<', refreshUrl: '@', - browseUrl: '@' + browseUrl: '@', + jobUrl: '@', + isJobEnabled: '<', + jobs: '<' }, transclude: true }); diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html index 8b46f466f..9b5b006d7 100644 --- a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html @@ -26,20 +26,19 @@ Total memory {{ $ctrl.host.totalMemory | humansize }} - + - + -
-
\ No newline at end of file +
diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js index 1a946c7c0..8f1b55270 100644 --- a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js @@ -1,9 +1,10 @@ angular.module('portainer.docker').component('hostDetailsPanel', { - templateUrl: - 'app/docker/components/host-view-panels/host-details-panel/host-details-panel.html', + templateUrl: 'app/docker/components/host-view-panels/host-details-panel/host-details-panel.html', bindings: { host: '<', + isJobEnabled: '<', isBrowseEnabled: '<', - browseUrl: '@' + browseUrl: '@', + jobUrl: '@' } }); diff --git a/app/docker/rest/container.js b/app/docker/rest/container.js index d46d9e474..e6a25f7bb 100644 --- a/app/docker/rest/container.js +++ b/app/docker/rest/container.js @@ -68,6 +68,9 @@ function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { }, update: { method: 'POST', params: { id: '@id', action: 'update'} + }, + prune: { + method: 'POST', params: { action: 'prune', filters: '@filters' } } }); }]); diff --git a/app/docker/services/containerService.js b/app/docker/services/containerService.js index ce81cd8c5..f7c2cb085 100644 --- a/app/docker/services/containerService.js +++ b/app/docker/services/containerService.js @@ -186,5 +186,9 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe return Container.inspect({ id: id }).$promise; }; + service.prune = function(filters) { + return Container.prune({ filters: filters }).$promise; + }; + return service; }]); diff --git a/app/docker/views/host/host-browser-view/host-browser-view-controller.js b/app/docker/views/host/host-browser-view/host-browser-view-controller.js index 9638e966e..3f45fcc48 100644 --- a/app/docker/views/host/host-browser-view/host-browser-view-controller.js +++ b/app/docker/views/host/host-browser-view/host-browser-view-controller.js @@ -1,21 +1,17 @@ -angular - .module('portainer.docker') - .controller('HostBrowserViewController', [ - 'SystemService', 'HttpRequestHelper', - function HostBrowserViewController(SystemService, HttpRequestHelper) { - var ctrl = this; +angular.module('portainer.docker').controller('HostBrowserViewController', [ + 'SystemService', 'Notifications', + function HostBrowserViewController(SystemService, Notifications) { + var ctrl = this; + ctrl.$onInit = $onInit; - ctrl.$onInit = $onInit; - - function $onInit() { - loadInfo(); - } - - function loadInfo() { - SystemService.info().then(function onInfoLoaded(host) { - HttpRequestHelper.setPortainerAgentTargetHeader(host.Name); - ctrl.host = host; - }); - } + function $onInit() { + SystemService.info() + .then(function onInfoLoaded(host) { + ctrl.host = host; + }) + .catch(function onError(err) { + Notifications.error('Unable to retrieve host information', err); + }); } - ]); + } +]); diff --git a/app/docker/views/host/host-job/host-job-controller.js b/app/docker/views/host/host-job/host-job-controller.js new file mode 100644 index 000000000..811509f7b --- /dev/null +++ b/app/docker/views/host/host-job/host-job-controller.js @@ -0,0 +1,17 @@ +angular.module('portainer.docker').controller('HostJobController', [ + 'SystemService', 'Notifications', + function HostJobController(SystemService, Notifications) { + var ctrl = this; + ctrl.$onInit = $onInit; + + function $onInit() { + SystemService.info() + .then(function onInfoLoaded(host) { + ctrl.host = host; + }) + .catch(function onError(err) { + Notifications.error('Unable to retrieve host information', err); + }); + } + } +]); diff --git a/app/docker/views/host/host-job/host-job.html b/app/docker/views/host/host-job/host-job.html new file mode 100644 index 000000000..adfbd970b --- /dev/null +++ b/app/docker/views/host/host-job/host-job.html @@ -0,0 +1,16 @@ + + + + Host > {{ $ctrl.host.Name }} > execute job + + + +
+
+ + + + + +
+
diff --git a/app/docker/views/host/host-job/host-job.js b/app/docker/views/host/host-job/host-job.js new file mode 100644 index 000000000..c23070c46 --- /dev/null +++ b/app/docker/views/host/host-job/host-job.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('hostJobView', { + templateUrl: 'app/docker/views/host/host-job/host-job.html', + controller: 'HostJobController' +}); diff --git a/app/docker/views/host/host-view-controller.js b/app/docker/views/host/host-view-controller.js index 008eb87a3..fedeff8c2 100644 --- a/app/docker/views/host/host-view-controller.js +++ b/app/docker/views/host/host-view-controller.js @@ -1,13 +1,15 @@ angular.module('portainer.docker').controller('HostViewController', [ - '$q', 'SystemService', 'Notifications', 'StateManager', 'AgentService', - function HostViewController($q, SystemService, Notifications, StateManager, AgentService) { + '$q', 'SystemService', 'Notifications', 'StateManager', 'AgentService', 'ContainerService', 'Authentication', + function HostViewController($q, SystemService, Notifications, StateManager, AgentService, ContainerService, Authentication) { var ctrl = this; + this.$onInit = initView; ctrl.state = { - isAgent: false + isAgent: false, + isAdmin : false }; - + this.engineDetails = {}; this.hostDetails = {}; this.devices = null; @@ -16,31 +18,34 @@ angular.module('portainer.docker').controller('HostViewController', [ function initView() { var applicationState = StateManager.getState(); ctrl.state.isAgent = applicationState.endpoint.mode.agentProxy; + ctrl.state.isAdmin = Authentication.getUserDetails().role === 1; var agentApiVersion = applicationState.endpoint.agentApiVersion; ctrl.state.agentApiVersion = agentApiVersion; $q.all({ version: SystemService.version(), - info: SystemService.info() + info: SystemService.info(), + jobs: ctrl.state.isAdmin ? ContainerService.containers(true, { label: ['io.portainer.job.endpoint'] }) : [] }) - .then(function success(data) { - ctrl.engineDetails = buildEngineDetails(data); - ctrl.hostDetails = buildHostDetails(data.info); + .then(function success(data) { + ctrl.engineDetails = buildEngineDetails(data); + ctrl.hostDetails = buildHostDetails(data.info); + ctrl.jobs = data.jobs; - if (ctrl.state.isAgent && agentApiVersion > 1) { - return AgentService.hostInfo(data.info.Hostname).then(function onHostInfoLoad(agentHostInfo) { - ctrl.devices = agentHostInfo.PCIDevices; - ctrl.disks = agentHostInfo.PhysicalDisks; - }); - } - }) - .catch(function error(err) { - Notifications.error( - 'Failure', - err, - 'Unable to retrieve engine details' - ); - }); + if (ctrl.state.isAgent && agentApiVersion > 1) { + return AgentService.hostInfo(data.info.Hostname).then(function onHostInfoLoad(agentHostInfo) { + ctrl.devices = agentHostInfo.PCIDevices; + ctrl.disks = agentHostInfo.PhysicalDisks; + }); + } + }) + .catch(function error(err) { + Notifications.error( + 'Failure', + err, + 'Unable to retrieve engine details' + ); + }); } function buildEngineDetails(data) { diff --git a/app/docker/views/host/host-view.html b/app/docker/views/host/host-view.html index 7d76b025e..35a4f513e 100644 --- a/app/docker/views/host/host-view.html +++ b/app/docker/views/host/host-view.html @@ -5,7 +5,9 @@ agent-api-version="$ctrl.state.agentApiVersion" disks="$ctrl.disks" devices="$ctrl.devices" - refresh-url="docker.host" browse-url="docker.host.browser" -> \ No newline at end of file + is-job-enabled="$ctrl.state.isAdmin" + job-url="docker.host.job" + jobs="$ctrl.jobs" +> diff --git a/app/docker/views/nodes/node-browser/node-browser-controller.js b/app/docker/views/nodes/node-browser/node-browser-controller.js index d846ac8f9..5c55c3e1b 100644 --- a/app/docker/views/nodes/node-browser/node-browser-controller.js +++ b/app/docker/views/nodes/node-browser/node-browser-controller.js @@ -1,19 +1,19 @@ angular.module('portainer.docker').controller('NodeBrowserController', [ - 'NodeService', 'HttpRequestHelper', '$stateParams', - function NodeBrowserController(NodeService, HttpRequestHelper, $stateParams) { + '$stateParams', 'NodeService', 'HttpRequestHelper', 'Notifications', + function NodeBrowserController($stateParams, NodeService, HttpRequestHelper, Notifications) { var ctrl = this; - ctrl.$onInit = $onInit; function $onInit() { ctrl.nodeId = $stateParams.id; - loadNode(); - } - function loadNode() { - NodeService.node(ctrl.nodeId).then(function onNodeLoaded(node) { + NodeService.node(ctrl.nodeId) + .then(function onNodeLoaded(node) { HttpRequestHelper.setPortainerAgentTargetHeader(node.Hostname); ctrl.node = node; + }) + .catch(function onError(err) { + Notifications.error('Unable to retrieve host information', err); }); } } diff --git a/app/docker/views/nodes/node-details/node-details-view-controller.js b/app/docker/views/nodes/node-details/node-details-view-controller.js index 9be1770f0..25abc6285 100644 --- a/app/docker/views/nodes/node-details/node-details-view-controller.js +++ b/app/docker/views/nodes/node-details/node-details-view-controller.js @@ -1,35 +1,46 @@ angular.module('portainer.docker').controller('NodeDetailsViewController', [ - '$stateParams', 'NodeService', 'StateManager', 'AgentService', - function NodeDetailsViewController($stateParams, NodeService, StateManager, AgentService) { + '$q', '$stateParams', 'NodeService', 'StateManager', 'AgentService', 'ContainerService', 'Authentication', + function NodeDetailsViewController($q, $stateParams, NodeService, StateManager, AgentService, ContainerService, Authentication) { var ctrl = this; ctrl.$onInit = initView; ctrl.state = { - isAgent: false + isAgent: false, + isAdmin: false }; function initView() { var applicationState = StateManager.getState(); ctrl.state.isAgent = applicationState.endpoint.mode.agentProxy; + ctrl.state.isAdmin = Authentication.getUserDetails().role === 1; + + var fetchJobs = ctrl.state.isAdmin && ctrl.state.isAgent; var nodeId = $stateParams.id; - NodeService.node(nodeId).then(function(node) { + $q.all({ + node: NodeService.node(nodeId), + jobs: fetchJobs ? ContainerService.containers(true, { label: ['io.portainer.job.endpoint'] }) : [] + }) + .then(function (data) { + var node = data.node; ctrl.originalNode = node; ctrl.hostDetails = buildHostDetails(node); ctrl.engineDetails = buildEngineDetails(node); ctrl.nodeDetails = buildNodeDetails(node); + ctrl.jobs = data.jobs; if (ctrl.state.isAgent) { var agentApiVersion = applicationState.endpoint.agentApiVersion; ctrl.state.agentApiVersion = agentApiVersion; if (agentApiVersion < 2) { return; } + AgentService.hostInfo(node.Hostname) - .then(function onHostInfoLoad(agentHostInfo) { - ctrl.devices = agentHostInfo.PCIDevices; - ctrl.disks = agentHostInfo.PhysicalDisks; - }); + .then(function onHostInfoLoad(agentHostInfo) { + ctrl.devices = agentHostInfo.PCIDevices; + ctrl.disks = agentHostInfo.PhysicalDisks; + }); } }); } @@ -68,12 +79,12 @@ angular.module('portainer.docker').controller('NodeDetailsViewController', [ function transformPlugins(pluginsList, type) { return pluginsList - .filter(function(plugin) { - return plugin.Type === type; - }) - .map(function(plugin) { - return plugin.Name; - }); + .filter(function(plugin) { + return plugin.Type === type; + }) + .map(function(plugin) { + return plugin.Name; + }); } } ]); diff --git a/app/docker/views/nodes/node-details/node-details-view.html b/app/docker/views/nodes/node-details/node-details-view.html index 3f7679972..6544e1aac 100644 --- a/app/docker/views/nodes/node-details/node-details-view.html +++ b/app/docker/views/nodes/node-details/node-details-view.html @@ -5,12 +5,14 @@ engine-details="$ctrl.engineDetails" disks="$ctrl.disks" devices="$ctrl.devices" - refresh-url="docker.nodes.node" browse-url="docker.nodes.node.browse" + is-job-enabled="$ctrl.state.isAdmin && $ctrl.state.isAgent" + job-url="docker.nodes.node.job" + jobs="$ctrl.jobs" > - \ No newline at end of file + diff --git a/app/docker/views/nodes/node-job/node-job-controller.js b/app/docker/views/nodes/node-job/node-job-controller.js new file mode 100644 index 000000000..9f1173d09 --- /dev/null +++ b/app/docker/views/nodes/node-job/node-job-controller.js @@ -0,0 +1,20 @@ +angular.module('portainer.docker').controller('NodeJobController', [ + '$stateParams', 'NodeService', 'HttpRequestHelper', 'Notifications', + function NodeJobController($stateParams, NodeService, HttpRequestHelper, Notifications) { + var ctrl = this; + ctrl.$onInit = $onInit; + + function $onInit() { + ctrl.nodeId = $stateParams.id; + + NodeService.node(ctrl.nodeId) + .then(function onNodeLoaded(node) { + HttpRequestHelper.setPortainerAgentTargetHeader(node.Hostname); + ctrl.node = node; + }) + .catch(function onError(err) { + Notifications.error('Unable to retrieve host information', err); + }); + } + } +]); diff --git a/app/docker/views/nodes/node-job/node-job.html b/app/docker/views/nodes/node-job/node-job.html new file mode 100644 index 000000000..90ae92d14 --- /dev/null +++ b/app/docker/views/nodes/node-job/node-job.html @@ -0,0 +1,18 @@ + + + + Swarm > {{ $ctrl.node.Hostname }} > execute job + + + +
+
+ + + + + +
+
diff --git a/app/docker/views/nodes/node-job/node-job.js b/app/docker/views/nodes/node-job/node-job.js new file mode 100644 index 000000000..0b25f9b2c --- /dev/null +++ b/app/docker/views/nodes/node-job/node-job.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('nodeJobView', { + templateUrl: 'app/docker/views/nodes/node-job/node-job.html', + controller: 'NodeJobController' +}); diff --git a/app/portainer/components/forms/execute-job-form/execute-job-form-controller.js b/app/portainer/components/forms/execute-job-form/execute-job-form-controller.js new file mode 100644 index 000000000..8e3b3d196 --- /dev/null +++ b/app/portainer/components/forms/execute-job-form/execute-job-form-controller.js @@ -0,0 +1,69 @@ +angular.module('portainer.app') +.controller('JobFormController', ['$state', 'LocalStorage', 'EndpointService', 'EndpointProvider', 'Notifications', +function ($state, LocalStorage, EndpointService, EndpointProvider, Notifications) { + var ctrl = this; + + ctrl.$onInit = onInit; + ctrl.editorUpdate = editorUpdate; + ctrl.executeJob = executeJob; + + ctrl.state = { + Method: 'editor', + formValidationError: '', + actionInProgress: false + }; + + ctrl.formValues = { + Image: 'ubuntu:latest', + JobFileContent: '', + JobFile: null + }; + + function onInit() { + var storedImage = LocalStorage.getJobImage(); + if (storedImage) { + ctrl.formValues.Image = storedImage; + } + } + + function editorUpdate(cm) { + ctrl.formValues.JobFileContent = cm.getValue(); + } + + function createJob(image, method) { + var endpointId = EndpointProvider.endpointID(); + var nodeName = ctrl.nodeName; + + if (method === 'editor') { + var jobFileContent = ctrl.formValues.JobFileContent; + return EndpointService.executeJobFromFileContent(image, jobFileContent, endpointId, nodeName); + } + + var jobFile = ctrl.formValues.JobFile; + return EndpointService.executeJobFromFileUpload(image, jobFile, endpointId, nodeName); + } + + function executeJob() { + var method = ctrl.state.Method; + if (method === 'editor' && ctrl.formValues.JobFileContent === '') { + ctrl.state.formValidationError = 'Script file content must not be empty'; + return; + } + + var image = ctrl.formValues.Image; + LocalStorage.storeJobImage(image); + + ctrl.state.actionInProgress = true; + createJob(image, method) + .then(function success() { + Notifications.success('Job successfully created'); + $state.go('^'); + }) + .catch(function error(err) { + Notifications.error('Job execution failure', err); + }) + .finally(function final() { + ctrl.state.actionInProgress = false; + }); + } +}]); diff --git a/app/portainer/components/forms/execute-job-form/execute-job-form.html b/app/portainer/components/forms/execute-job-form/execute-job-form.html new file mode 100644 index 000000000..19230f46d --- /dev/null +++ b/app/portainer/components/forms/execute-job-form/execute-job-form.html @@ -0,0 +1,110 @@ +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+ + This job will run inside a privileged container on the host. You can access the host filesystem under the + /host folder. + +
+ +
+ Job creation +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ Web editor +
+
+
+ + +
+
+
+ + +
+
+ Upload +
+
+ + You can upload a script file from your computer. + +
+
+
+ + + {{ $ctrl.formValues.JobFile.name }} + + +
+
+
+ + +
+ Actions +
+
+
+ + + {{ $ctrl.state.formValidationError }} + +
+
+ +
diff --git a/app/portainer/components/forms/execute-job-form/execute-job-form.js b/app/portainer/components/forms/execute-job-form/execute-job-form.js new file mode 100644 index 000000000..9dbbf5a52 --- /dev/null +++ b/app/portainer/components/forms/execute-job-form/execute-job-form.js @@ -0,0 +1,7 @@ +angular.module('portainer.app').component('executeJobForm', { + templateUrl: 'app/portainer/components/forms/execute-job-form/execute-job-form.html', + controller: 'JobFormController', + bindings: { + nodeName: '<' + } +}); diff --git a/app/portainer/rest/endpoint.js b/app/portainer/rest/endpoint.js index 455a27448..7b50372e9 100644 --- a/app/portainer/rest/endpoint.js +++ b/app/portainer/rest/endpoint.js @@ -7,6 +7,7 @@ angular.module('portainer.app') update: { method: 'PUT', params: { id: '@id' } }, updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, remove: { method: 'DELETE', params: { id: '@id'} }, - snapshot: { method: 'POST', params: { id: 'snapshot' }} + snapshot: { method: 'POST', params: { id: 'snapshot' }}, + executeJob: { method: 'POST', ignoreLoadingBar: true, params: { id: '@id', action: 'job' } } }); }]); diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index aa3d57bb6..fa17052e5 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -100,5 +100,18 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return deferred.promise; }; + service.executeJobFromFileUpload = function (image, jobFile, endpointId, nodeName) { + return FileUploadService.executeEndpointJob(image, jobFile, endpointId, nodeName); + }; + + service.executeJobFromFileContent = function (image, jobFileContent, endpointId, nodeName) { + var payload = { + Image: image, + FileContent: jobFileContent + }; + + return Endpoints.executeJob({ id: endpointId, method: 'string', nodeName: nodeName }, payload).$promise; + }; + return service; }]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index fdbf9ff54..2d43cc44e 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -64,6 +64,17 @@ angular.module('portainer.app') }); }; + service.executeEndpointJob = function (imageName, file, endpointId, nodeName) { + return Upload.upload({ + url: 'api/endpoints/' + endpointId + '/job?method=file&nodeName=' + nodeName, + data: { + File: file, + Image: imageName + }, + ignoreLoadingBar: true + }); + }; + service.createEndpoint = function(name, type, URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { return Upload.upload({ url: 'api/endpoints', diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js index b104b4076..a676b33a4 100644 --- a/app/portainer/services/localStorage.js +++ b/app/portainer/services/localStorage.js @@ -89,6 +89,12 @@ angular.module('portainer.app') getColumnVisibilitySettings: function(key) { return localStorageService.get('col_visibility_' + key); }, + storeJobImage: function(data) { + localStorageService.set('job_image', data); + }, + getJobImage: function() { + return localStorageService.get('job_image'); + }, clean: function() { localStorageService.clearAll(); } diff --git a/app/portainer/services/notifications.js b/app/portainer/services/notifications.js index 85de16def..d1cd8eb80 100644 --- a/app/portainer/services/notifications.js +++ b/app/portainer/services/notifications.js @@ -13,7 +13,9 @@ angular.module('portainer.app') service.error = function(title, e, fallbackText) { var msg = fallbackText; - if (e.data && e.data.message) { + if (e.data && e.data.details) { + msg = e.data.details; + } else if (e.data && e.data.message) { msg = e.data.message; } else if (e.message) { msg = e.message; From a61654a35d87d0b0311569e745f808a9cf5d4e2c Mon Sep 17 00:00:00 2001 From: baron_l Date: Sun, 28 Oct 2018 10:27:06 +0100 Subject: [PATCH 37/93] feat(endpoints): add the ability to browse offline endpoints (#2253) * feat(back): saved data in snapshot * feat(endpoints): adding interceptors to retrieve saved data on offline endpoints * feat(endpoints): offline dashboard working - need tests on offline views * refactor(endpoints): interceptors cleaning and saving/loading offline endpoints data in/from localstorage * feat(endpoints): browsing offline endpoints * feat(endpoints): removing all the link in offline mode - sidebar not working when switching between off and on modes w/ stateManager logic * feat(endpoints): endpoint status detection in real time * fix(endpoints): offline swarm endpoint are not accessible anymore * fix(endpoints): refactor message + disable offline browsing for an endpoint when no snapshot is available for it * fix(endpoints): adding timeout and enabling loading bar for offline requests * fix(endpoints): trying to access a down endpoint wont remove sidebar items if it fails * feat(endpoints): disable checkboxes on offline views for offline mode * feat(endpoints): updating endpoint status when detecting a change * refactor(host): moved offline status panel from engine view to new host view * fix(endpoints): missing endpoint update on ping from home view * fix(api): rework EndpointUpdate operation * refactor(offline): moved endpoint status to EndpointProvider and refactor the status-changed detection * fix(offline): moved status detection to callback on views -> prevent displaying the offline message when endpoint is back online on view change * fix(offline): offline message is now displayed online when browsing an offline endpoint * fix(offline): sidebar updates correctly on endpoint status change * fix(offline): offline panel not displayed and hidden on online mode * refactor(offline): rework of OfflineMode management * refactor(offline): extract information-panel for offlineMode into a component * refactor(offline): remove redundant binding of informationPanel + endpointStatusInterceptor patter as service * refactor(interceptors): moved interceptors pattern to service pattern * feat(stacks): prevent inspection of a stack in offline mode * feat(host): hide devices/disk panels in offline mode * feat(host): disable browse action in offline mode * refactor(home): remove comments --- api/docker/snapshot.go | 32 +++++ api/http/handler/endpoints/endpoint_update.go | 135 +++++++++++------- api/portainer.go | 33 +++-- app/config.js | 2 + .../containersDatatable.html | 18 ++- .../containersDatatable.js | 3 +- .../containersDatatableController.js | 1 - .../images-datatable/imagesDatatable.html | 9 +- .../images-datatable/imagesDatatable.js | 3 +- .../networks-datatable/networksDatatable.html | 9 +- .../networks-datatable/networksDatatable.js | 3 +- .../volumes-datatable/volumesDatatable.html | 11 +- .../volumes-datatable/volumesDatatable.js | 3 +- .../docker-sidebar-content.js | 3 +- .../dockerSidebarContent.html | 4 +- .../host-overview/host-overview.html | 12 +- .../components/host-overview/host-overview.js | 1 + .../interceptors/containersInterceptor.js | 21 +++ app/docker/interceptors/imagesInterceptor.js | 21 +++ app/docker/interceptors/infoInterceptor.js | 21 +++ .../interceptors/networksInterceptor.js | 21 +++ app/docker/interceptors/versionInterceptor.js | 21 +++ app/docker/interceptors/volumesInterceptor.js | 21 +++ app/docker/rest/container.js | 6 +- app/docker/rest/image.js | 6 +- app/docker/rest/network.js | 6 +- app/docker/rest/system.js | 8 +- app/docker/rest/systemEndpoint.js | 13 ++ app/docker/rest/volume.js | 5 +- app/docker/services/systemService.js | 6 +- app/docker/views/containers/containers.html | 3 +- .../views/containers/containersController.js | 7 +- app/docker/views/dashboard/dashboard.html | 2 +- .../views/dashboard/dashboardController.js | 3 + app/docker/views/host/host-view-controller.js | 8 +- app/docker/views/host/host-view.html | 3 +- app/docker/views/images/images.html | 5 +- app/docker/views/images/imagesController.js | 7 +- app/docker/views/networks/networks.html | 3 +- .../views/networks/networksController.js | 7 +- app/docker/views/volumes/volumes.html | 3 +- app/docker/views/volumes/volumesController.js | 7 +- .../stacks-datatable/stacksDatatable.html | 9 +- .../stacks-datatable/stacksDatatable.js | 3 +- .../informationPanelOffline.html | 8 ++ .../informationPanelOffline.js | 4 + .../information-panel/information-panel.js | 2 +- .../information-panel/informationPanel.html | 2 +- .../interceptors/endpointStatusInterceptor.js | 41 ++++++ app/portainer/services/endpointProvider.js | 50 +++++++ app/portainer/services/extensionManager.js | 7 +- app/portainer/services/localStorage.js | 12 ++ app/portainer/services/stateManager.js | 18 +-- app/portainer/views/home/homeController.js | 106 ++++++++++---- app/portainer/views/main/mainController.js | 5 +- app/portainer/views/sidebar/sidebar.html | 1 + .../views/sidebar/sidebarController.js | 59 ++++---- app/portainer/views/stacks/stacks.html | 3 +- .../views/stacks/stacksController.js | 3 + 59 files changed, 637 insertions(+), 212 deletions(-) create mode 100644 app/docker/interceptors/containersInterceptor.js create mode 100644 app/docker/interceptors/imagesInterceptor.js create mode 100644 app/docker/interceptors/infoInterceptor.js create mode 100644 app/docker/interceptors/networksInterceptor.js create mode 100644 app/docker/interceptors/versionInterceptor.js create mode 100644 app/docker/interceptors/volumesInterceptor.js create mode 100644 app/docker/rest/systemEndpoint.js create mode 100644 app/portainer/components/information-panel-offline/informationPanelOffline.html create mode 100644 app/portainer/components/information-panel-offline/informationPanelOffline.js create mode 100644 app/portainer/interceptors/endpointStatusInterceptor.js diff --git a/api/docker/snapshot.go b/api/docker/snapshot.go index d8c481fb7..342465b1f 100644 --- a/api/docker/snapshot.go +++ b/api/docker/snapshot.go @@ -52,6 +52,16 @@ func snapshot(cli *client.Client) (*portainer.Snapshot, error) { return nil, err } + err = snapshotNetworks(snapshot, cli) + if err != nil { + return nil, err + } + + err = snapshotVersion(snapshot, cli) + if err != nil { + return nil, err + } + snapshot.Time = time.Now().Unix() return snapshot, nil } @@ -66,6 +76,7 @@ func snapshotInfo(snapshot *portainer.Snapshot, cli *client.Client) error { snapshot.DockerVersion = info.ServerVersion snapshot.TotalCPU = info.NCPU snapshot.TotalMemory = info.MemTotal + snapshot.SnapshotRaw.Info = info return nil } @@ -132,6 +143,7 @@ func snapshotContainers(snapshot *portainer.Snapshot, cli *client.Client) error snapshot.RunningContainerCount = runningContainers snapshot.StoppedContainerCount = stoppedContainers snapshot.StackCount += len(stacks) + snapshot.SnapshotRaw.Containers = containers return nil } @@ -142,6 +154,7 @@ func snapshotImages(snapshot *portainer.Snapshot, cli *client.Client) error { } snapshot.ImageCount = len(images) + snapshot.SnapshotRaw.Images = images return nil } @@ -152,5 +165,24 @@ func snapshotVolumes(snapshot *portainer.Snapshot, cli *client.Client) error { } snapshot.VolumeCount = len(volumes.Volumes) + snapshot.SnapshotRaw.Volumes = volumes + return nil +} + +func snapshotNetworks(snapshot *portainer.Snapshot, cli *client.Client) error { + networks, err := cli.NetworkList(context.Background(), types.NetworkListOptions{}) + if err != nil { + return err + } + snapshot.SnapshotRaw.Networks = networks + return nil +} + +func snapshotVersion(snapshot *portainer.Snapshot, cli *client.Client) error { + version, err := cli.ServerVersion(context.Background()) + if err != nil { + return err + } + snapshot.SnapshotRaw.Version = version return nil } diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 5244cdc98..405f6ce38 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -12,16 +12,17 @@ import ( ) type endpointUpdatePayload struct { - Name string - URL string - PublicURL string - GroupID int - TLS bool - TLSSkipVerify bool - TLSSkipClientVerify bool - AzureApplicationID string - AzureTenantID string - AzureAuthenticationKey string + Name *string + URL *string + PublicURL *string + GroupID *int + TLS *bool + TLSSkipVerify *bool + TLSSkipClientVerify *bool + Status *int + AzureApplicationID *string + AzureTenantID *string + AzureAuthenticationKey *string Tags []string } @@ -53,36 +54,49 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - if payload.Name != "" { - endpoint.Name = payload.Name + if payload.Name != nil { + endpoint.Name = *payload.Name } - if payload.URL != "" { - endpoint.URL = payload.URL + if payload.URL != nil { + endpoint.URL = *payload.URL } - if payload.PublicURL != "" { - endpoint.PublicURL = payload.PublicURL + if payload.PublicURL != nil { + endpoint.PublicURL = *payload.PublicURL } - if payload.GroupID != 0 { - endpoint.GroupID = portainer.EndpointGroupID(payload.GroupID) + if payload.GroupID != nil { + endpoint.GroupID = portainer.EndpointGroupID(*payload.GroupID) } if payload.Tags != nil { endpoint.Tags = payload.Tags } + if payload.Status != nil { + switch *payload.Status { + case 1: + endpoint.Status = portainer.EndpointStatusUp + break + case 2: + endpoint.Status = portainer.EndpointStatusDown + break + default: + break + } + } + if endpoint.Type == portainer.AzureEnvironment { credentials := endpoint.AzureCredentials - if payload.AzureApplicationID != "" { - credentials.ApplicationID = payload.AzureApplicationID + if payload.AzureApplicationID != nil { + credentials.ApplicationID = *payload.AzureApplicationID } - if payload.AzureTenantID != "" { - credentials.TenantID = payload.AzureTenantID + if payload.AzureTenantID != nil { + credentials.TenantID = *payload.AzureTenantID } - if payload.AzureAuthenticationKey != "" { - credentials.AuthenticationKey = payload.AzureAuthenticationKey + if payload.AzureAuthenticationKey != nil { + credentials.AuthenticationKey = *payload.AzureAuthenticationKey } httpClient := client.NewHTTPClient() @@ -93,44 +107,55 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint.AzureCredentials = credentials } - folder := strconv.Itoa(endpointID) - if payload.TLS { - endpoint.TLSConfig.TLS = true - endpoint.TLSConfig.TLSSkipVerify = payload.TLSSkipVerify - if !payload.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) - endpoint.TLSConfig.TLSCACertPath = caCertPath - } else { - endpoint.TLSConfig.TLSCACertPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA) - } + if payload.TLS != nil { + folder := strconv.Itoa(endpointID) + + if *payload.TLS { + endpoint.TLSConfig.TLS = true + if payload.TLSSkipVerify != nil { + endpoint.TLSConfig.TLSSkipVerify = *payload.TLSSkipVerify + + if !*payload.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) + endpoint.TLSConfig.TLSCACertPath = caCertPath + } else { + endpoint.TLSConfig.TLSCACertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA) + } + } + + if payload.TLSSkipClientVerify != nil { + if !*payload.TLSSkipClientVerify { + certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSCertPath = certPath + keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) + endpoint.TLSConfig.TLSKeyPath = keyPath + } else { + endpoint.TLSConfig.TLSCertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSKeyPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey) + } + } - if !payload.TLSSkipClientVerify { - certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSCertPath = certPath - keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) - endpoint.TLSConfig.TLSKeyPath = keyPath } else { + endpoint.TLSConfig.TLS = false + endpoint.TLSConfig.TLSSkipVerify = false + endpoint.TLSConfig.TLSCACertPath = "" endpoint.TLSConfig.TLSCertPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert) endpoint.TLSConfig.TLSKeyPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey) - } - } else { - endpoint.TLSConfig.TLS = false - endpoint.TLSConfig.TLSSkipVerify = false - endpoint.TLSConfig.TLSCACertPath = "" - endpoint.TLSConfig.TLSCertPath = "" - endpoint.TLSConfig.TLSKeyPath = "" - err = handler.FileService.DeleteTLSFiles(folder) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + err = handler.FileService.DeleteTLSFiles(folder) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + } } } - _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err} + if payload.URL != nil || payload.TLS != nil || endpoint.Type == portainer.AzureEnvironment { + _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err} + } } err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) diff --git a/api/portainer.go b/api/portainer.go index c1cdbab4f..2c866f3da 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -245,17 +245,28 @@ type ( // Snapshot represents a snapshot of a specific endpoint at a specific time Snapshot struct { - Time int64 `json:"Time"` - DockerVersion string `json:"DockerVersion"` - Swarm bool `json:"Swarm"` - TotalCPU int `json:"TotalCPU"` - TotalMemory int64 `json:"TotalMemory"` - RunningContainerCount int `json:"RunningContainerCount"` - StoppedContainerCount int `json:"StoppedContainerCount"` - VolumeCount int `json:"VolumeCount"` - ImageCount int `json:"ImageCount"` - ServiceCount int `json:"ServiceCount"` - StackCount int `json:"StackCount"` + Time int64 `json:"Time"` + DockerVersion string `json:"DockerVersion"` + Swarm bool `json:"Swarm"` + TotalCPU int `json:"TotalCPU"` + TotalMemory int64 `json:"TotalMemory"` + RunningContainerCount int `json:"RunningContainerCount"` + StoppedContainerCount int `json:"StoppedContainerCount"` + VolumeCount int `json:"VolumeCount"` + ImageCount int `json:"ImageCount"` + ServiceCount int `json:"ServiceCount"` + StackCount int `json:"StackCount"` + SnapshotRaw SnapshotRaw `json:"SnapshotRaw"` + } + + // SnapshotRaw represents all the information related to a snapshot as returned by the Docker API + SnapshotRaw struct { + Containers interface{} `json:"Containers"` + Volumes interface{} `json:"Volumes"` + Networks interface{} `json:"Networks"` + Images interface{} `json:"Images"` + Info interface{} `json:"Info"` + Version interface{} `json:"Version"` } // EndpointGroupID represents an endpoint group identifier diff --git a/app/config.js b/app/config.js index 4cdd7fa8c..cd21e1f08 100644 --- a/app/config.js +++ b/app/config.js @@ -17,6 +17,7 @@ angular.module('portainer') }] }); $httpProvider.interceptors.push('jwtInterceptor'); + $httpProvider.interceptors.push('EndpointStatusInterceptor'); $httpProvider.defaults.headers.post['Content-Type'] = 'application/json'; $httpProvider.defaults.headers.put['Content-Type'] = 'application/json'; $httpProvider.defaults.headers.patch['Content-Type'] = 'application/json'; @@ -48,6 +49,7 @@ angular.module('portainer') cfpLoadingBarProvider.includeSpinner = false; cfpLoadingBarProvider.parentSelector = '#loadingbar-placeholder'; + cfpLoadingBarProvider.latencyThreshold = 600; $urlRouterProvider.otherwise('/auth'); }]); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 19cd2a0c0..5b30f1473 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -99,7 +99,7 @@
- - + @@ -210,17 +210,18 @@ - + - {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} + {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} + {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} {{ item.Status }} {{ item.Status }} - +
@@ -228,8 +229,13 @@
+ + {{ item.StackName ? item.StackName : '-' }} - {{ item.Image | trimshasum }} + + {{ item.Image | trimshasum }} + {{ item.Image | trimshasum }} + {{item.Created | getisodatefromtimestamp}} diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.js b/app/docker/components/datatables/containers-datatable/containersDatatable.js index 18a693511..478f17367 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.js @@ -10,6 +10,7 @@ angular.module('portainer.docker').component('containersDatatable', { reverseOrder: '<', showOwnershipColumn: '<', showHostColumn: '<', - showAddAction: '<' + showAddAction: '<', + offlineMode: '<' } }); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatableController.js b/app/docker/components/datatables/containers-datatable/containersDatatableController.js index 7151e5d64..2fd6ffe1e 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatableController.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatableController.js @@ -204,7 +204,6 @@ function (PaginationService, DatatableService, EndpointProvider) { this.$onInit = function() { setDefaults(this); this.prepareTableFromDataset(); - var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html index 87cba5507..a509ebd75 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.html +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.html @@ -6,7 +6,7 @@ {{ $ctrl.titleText }}
-
+
-
+
-
+
- + 1) { diff --git a/app/docker/views/host/host-view.html b/app/docker/views/host/host-view.html index 35a4f513e..61597ecca 100644 --- a/app/docker/views/host/host-view.html +++ b/app/docker/views/host/host-view.html @@ -7,7 +7,8 @@ devices="$ctrl.devices" refresh-url="docker.host" browse-url="docker.host.browser" - is-job-enabled="$ctrl.state.isAdmin" + offline-mode="$ctrl.state.offlineMode" + is-job-enabled="$ctrl.state.isAdmin && !$ctrl.state.offlineMode" job-url="docker.host.job" jobs="$ctrl.jobs" > diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html index 3c8df020a..8637e4e25 100644 --- a/app/docker/views/images/images.html +++ b/app/docker/views/images/images.html @@ -7,7 +7,7 @@ Images -
+
@@ -51,7 +51,7 @@
- +
diff --git a/app/docker/views/images/imagesController.js b/app/docker/views/images/imagesController.js index f495f4185..0504ceaea 100644 --- a/app/docker/views/images/imagesController.js +++ b/app/docker/views/images/imagesController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ImagesController', ['$scope', '$state', 'ImageService', 'Notifications', 'ModalService', 'HttpRequestHelper', 'FileSaver', 'Blob', -function ($scope, $state, ImageService, Notifications, ModalService, HttpRequestHelper, FileSaver, Blob) { +.controller('ImagesController', ['$scope', '$state', 'ImageService', 'Notifications', 'ModalService', 'HttpRequestHelper', 'FileSaver', 'Blob', 'EndpointProvider', +function ($scope, $state, ImageService, Notifications, ModalService, HttpRequestHelper, FileSaver, Blob, EndpointProvider) { $scope.state = { actionInProgress: false, exportInProgress: false @@ -113,10 +113,13 @@ function ($scope, $state, ImageService, Notifications, ModalService, HttpRequest }); }; + $scope.offlineMode = false; + function initView() { ImageService.images(true) .then(function success(data) { $scope.images = data; + $scope.offlineMode = EndpointProvider.offlineMode(); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve images'); diff --git a/app/docker/views/networks/networks.html b/app/docker/views/networks/networks.html index ff0f6edd6..c547d54ba 100644 --- a/app/docker/views/networks/networks.html +++ b/app/docker/views/networks/networks.html @@ -6,7 +6,7 @@ Networks - +
diff --git a/app/docker/views/networks/networksController.js b/app/docker/views/networks/networksController.js index c730dc337..77c7647d9 100644 --- a/app/docker/views/networks/networksController.js +++ b/app/docker/views/networks/networksController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper', -function ($scope, $state, NetworkService, Notifications, HttpRequestHelper) { +.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', +function ($scope, $state, NetworkService, Notifications, HttpRequestHelper, EndpointProvider) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; @@ -24,10 +24,13 @@ function ($scope, $state, NetworkService, Notifications, HttpRequestHelper) { }); }; + $scope.offlineMode = false; + function initView() { NetworkService.networks(true, true, true) .then(function success(data) { $scope.networks = data; + $scope.offlineMode = EndpointProvider.offlineMode(); }) .catch(function error(err) { $scope.networks = []; diff --git a/app/docker/views/volumes/volumes.html b/app/docker/views/volumes/volumes.html index 072394f45..958246e49 100644 --- a/app/docker/views/volumes/volumes.html +++ b/app/docker/views/volumes/volumes.html @@ -6,7 +6,7 @@ Volumes - +
diff --git a/app/docker/views/volumes/volumesController.js b/app/docker/views/volumes/volumesController.js index 362c54b4b..3ab620266 100644 --- a/app/docker/views/volumes/volumesController.js +++ b/app/docker/views/volumes/volumesController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper', -function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper) { +.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', +function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper, EndpointProvider) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; @@ -24,6 +24,8 @@ function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notif }); }; + $scope.offlineMode = false; + function initView() { var endpointProvider = $scope.applicationState.endpoint.mode.provider; var endpointRole = $scope.applicationState.endpoint.mode.role; @@ -35,6 +37,7 @@ function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notif }) .then(function success(data) { var services = data.services; + $scope.offlineMode = EndpointProvider.offlineMode(); $scope.volumes = data.attached.map(function(volume) { volume.dangling = false; return volume; diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html index 5b640ea17..b91a8e1da 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html @@ -6,7 +6,7 @@ {{ $ctrl.titleText }}
-
+
diff --git a/app/portainer/interceptors/endpointStatusInterceptor.js b/app/portainer/interceptors/endpointStatusInterceptor.js new file mode 100644 index 000000000..46ca7a42b --- /dev/null +++ b/app/portainer/interceptors/endpointStatusInterceptor.js @@ -0,0 +1,41 @@ +angular.module('portainer.app') + .factory('EndpointStatusInterceptor', ['$q', '$injector', 'EndpointProvider', function ($q, $injector, EndpointProvider) { + 'use strict'; + var interceptor = {}; + + interceptor.response = responseInterceptor; + interceptor.responseError = responseErrorInterceptor; + + function canBeOffline(url) { + return (_.startsWith(url, 'api/') && ( + _.includes(url, '/containers') || + _.includes(url, '/images') || + _.includes(url, '/volumes') || + _.includes(url, '/networks') || + _.includes(url, '/info') || + _.includes(url, '/version') + )); + } + + function responseInterceptor(response) { + var EndpointService = $injector.get('EndpointService'); + var url = response.config.url; + if (response.status === 200 && canBeOffline(url) && EndpointProvider.offlineMode()) { + EndpointProvider.setOfflineMode(false); + EndpointService.updateEndpoint(EndpointProvider.endpointID(), {Status: EndpointProvider.endpointStatusFromOfflineMode(false)}); + } + return response || $q.when(response); + } + + function responseErrorInterceptor(rejection) { + var EndpointService = $injector.get('EndpointService'); + var url = rejection.config.url; + if ((rejection.status === 502 || rejection.status === -1) && canBeOffline(url) && !EndpointProvider.offlineMode()) { + EndpointProvider.setOfflineMode(true); + EndpointService.updateEndpoint(EndpointProvider.endpointID(), {Status: EndpointProvider.endpointStatusFromOfflineMode(true)}); + } + return $q.reject(rejection); + } + + return interceptor; + }]); \ No newline at end of file diff --git a/app/portainer/services/endpointProvider.js b/app/portainer/services/endpointProvider.js index 21d635d64..ebd89497e 100644 --- a/app/portainer/services/endpointProvider.js +++ b/app/portainer/services/endpointProvider.js @@ -7,19 +7,31 @@ angular.module('portainer.app') service.initialize = function() { var endpointID = LocalStorage.getEndpointID(); var endpointPublicURL = LocalStorage.getEndpointPublicURL(); + var offlineMode = LocalStorage.getOfflineMode(); + if (endpointID) { endpoint.ID = endpointID; } if (endpointPublicURL) { endpoint.PublicURL = endpointPublicURL; } + if (offlineMode) { + endpoint.OfflineMode = offlineMode; + } }; service.clean = function() { endpoint = {}; }; + service.endpoint = function() { + return endpoint; + }; + service.endpointID = function() { + if (endpoint.ID === undefined) { + endpoint.ID = LocalStorage.getEndpointID(); + } return endpoint.ID; }; @@ -29,6 +41,9 @@ angular.module('portainer.app') }; service.endpointPublicURL = function() { + if (endpoint.PublicURL === undefined) { + endpoint.PublicURL = LocalStorage.getEndpointPublicURL(); + } return endpoint.PublicURL; }; @@ -37,5 +52,40 @@ angular.module('portainer.app') LocalStorage.storeEndpointPublicURL(publicURL); }; + service.endpoints = function() { + return LocalStorage.getEndpoints(); + }; + + service.setEndpoints = function(data) { + LocalStorage.storeEndpoints(data); + }; + + service.offlineMode = function() { + return endpoint.OfflineMode; + }; + + service.endpointStatusFromOfflineMode = function(isOffline) { + return isOffline ? 2 : 1; + }; + + service.setOfflineMode = function(isOffline) { + endpoint.OfflineMode = isOffline; + LocalStorage.storeOfflineMode(isOffline); + }; + + service.setOfflineModeFromStatus = function(status) { + var isOffline = status !== 1; + endpoint.OfflineMode = isOffline; + LocalStorage.storeOfflineMode(isOffline); + }; + + service.currentEndpoint = function() { + var endpointId = endpoint.ID; + var endpoints = LocalStorage.getEndpoints(); + return _.find(endpoints, function (item) { + return item.Id === endpointId; + }); + }; + return service; }]); diff --git a/app/portainer/services/extensionManager.js b/app/portainer/services/extensionManager.js index b0cde467e..e9f622746 100644 --- a/app/portainer/services/extensionManager.js +++ b/app/portainer/services/extensionManager.js @@ -4,9 +4,14 @@ function ExtensionManagerFactory($q, PluginService, SystemService, ExtensionServ 'use strict'; var service = {}; - service.initEndpointExtensions = function() { + service.initEndpointExtensions = function(endpoint) { var deferred = $q.defer(); + if (endpoint.Status !== 1) { + deferred.resolve([]); + return deferred.promise; + } + SystemService.version() .then(function success(data) { var endpointAPIVersion = parseFloat(data.ApiVersion); diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js index a676b33a4..2988ebd9d 100644 --- a/app/portainer/services/localStorage.js +++ b/app/portainer/services/localStorage.js @@ -14,6 +14,18 @@ angular.module('portainer.app') getEndpointPublicURL: function() { return localStorageService.get('ENDPOINT_PUBLIC_URL'); }, + storeOfflineMode: function(isOffline) { + localStorageService.set('ENDPOINT_OFFLINE_MODE', isOffline); + }, + getOfflineMode: function() { + return localStorageService.get('ENDPOINT_OFFLINE_MODE'); + }, + storeEndpoints: function(data) { + localStorageService.set('ENDPOINTS_DATA', data); + }, + getEndpoints: function() { + return localStorageService.get('ENDPOINTS_DATA'); + }, storeEndpointState: function(state) { localStorageService.set('ENDPOINT_STATE', state); }, diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index 36c7dadb5..987022476 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -135,11 +135,11 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin return extensions; } - manager.updateEndpointState = function(name, type, extensions) { + manager.updateEndpointState = function(endpoint, extensions) { var deferred = $q.defer(); - if (type === 3) { - state.endpoint.name = name; + if (endpoint.Type === 3) { + state.endpoint.name = endpoint.Name; state.endpoint.mode = { provider: 'AZURE' }; LocalStorage.storeEndpointState(state.endpoint); deferred.resolve(); @@ -147,23 +147,23 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin } $q.all({ - version: SystemService.version(), - info: SystemService.info() + version: endpoint.Status === 1 ? SystemService.version() : $q.when(endpoint.Snapshots[0].SnapshotRaw.Version), + info: endpoint.Status === 1 ? SystemService.info() : $q.when(endpoint.Snapshots[0].SnapshotRaw.Info) }) .then(function success(data) { - var endpointMode = InfoHelper.determineEndpointMode(data.info, type); + var endpointMode = InfoHelper.determineEndpointMode(data.info, endpoint.Type); var endpointAPIVersion = parseFloat(data.version.ApiVersion); state.endpoint.mode = endpointMode; - state.endpoint.name = name; + state.endpoint.name = endpoint.Name; state.endpoint.apiVersion = endpointAPIVersion; state.endpoint.extensions = assignExtensions(extensions); - if (endpointMode.agentProxy) { + if (endpointMode.agentProxy && endpoint.Status === 1) { return AgentPingService.ping().then(function onPingSuccess(data) { state.endpoint.agentApiVersion = data.version; }); } - + }).then(function () { LocalStorage.storeEndpointState(state.endpoint); deferred.resolve(); diff --git a/app/portainer/views/home/homeController.js b/app/portainer/views/home/homeController.js index f666e49d7..083c75625 100644 --- a/app/portainer/views/home/homeController.js +++ b/app/portainer/views/home/homeController.js @@ -1,44 +1,71 @@ angular.module('portainer.app') -.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'ExtensionManager', 'ModalService', 'MotdService', -function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, ExtensionManager, ModalService, MotdService) { +.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'ExtensionManager', 'ModalService', 'MotdService', 'SystemService', +function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, ExtensionManager, ModalService, MotdService, SystemService) { - $scope.goToDashboard = function(endpoint) { - EndpointProvider.setEndpointID(endpoint.Id); - EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); - if (endpoint.Type === 3) { - switchToAzureEndpoint(endpoint); - } else { - switchToDockerEndpoint(endpoint); - } + $scope.goToEdit = function(id) { + $state.go('portainer.endpoints.endpoint', { id: id }); }; - $scope.dismissImportantInformation = function(hash) { + $scope.goToDashboard = function (endpoint) { + if (endpoint.Type === 3) { + return switchToAzureEndpoint(endpoint); + } + + checkEndpointStatus(endpoint) + .then(function sucess() { + return switchToDockerEndpoint(endpoint); + }).catch(function error(err) { + Notifications.error('Failure', err, 'Unable to verify endpoint status'); + }); + }; + + $scope.dismissImportantInformation = function (hash) { StateManager.dismissImportantInformation(hash); }; - $scope.dismissInformationPanel = function(id) { + $scope.dismissInformationPanel = function (id) { StateManager.dismissInformationPanel(id); }; - function triggerSnapshot() { - EndpointService.snapshot() - .then(function success() { - Notifications.success('Success', 'Endpoints updated'); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'An error occured during endpoint snapshot'); - }); - } - - $scope.triggerSnapshot = function() { + $scope.triggerSnapshot = function () { ModalService.confirmEndpointSnapshot(function (result) { - if(!result) { return; } + if (!result) { + return; + } triggerSnapshot(); }); }; + function checkEndpointStatus(endpoint) { + var deferred = $q.defer(); + + var status = 1; + SystemService.ping(endpoint.Id) + .then(function sucess() { + status = 1; + }).catch(function error() { + status = 2; + }).finally(function () { + if (endpoint.Status === status) { + deferred.resolve(endpoint); + return deferred.promise; + } + + EndpointService.updateEndpoint(endpoint.Id, { Status: status }) + .then(function sucess() { + deferred.resolve(endpoint); + }).catch(function error(err) { + deferred.reject({msg: 'Unable to update endpoint status', err: err}); + }); + }); + + return deferred.promise; + } + function switchToAzureEndpoint(endpoint) { + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + EndpointProvider.setOfflineModeFromStatus(endpoint.Status); StateManager.updateEndpointState(endpoint.Name, endpoint.Type, []) .then(function success() { $state.go('azure.dashboard'); @@ -49,10 +76,21 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G } function switchToDockerEndpoint(endpoint) { - ExtensionManager.initEndpointExtensions(endpoint.Id) + if (endpoint.Status === 2 && endpoint.Snapshots[0] && endpoint.Snapshots[0].Swarm === true) { + Notifications.error('Failure', '', 'Endpoint is unreachable. Connect to another swarm manager.'); + return; + } else if (endpoint.Status === 2 && !endpoint.Snapshots[0]) { + Notifications.error('Failure', '', 'Endpoint is unreachable and there is no snapshot available for offline browsing.'); + return; + } + + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + EndpointProvider.setOfflineModeFromStatus(endpoint.Status); + ExtensionManager.initEndpointExtensions(endpoint) .then(function success(data) { var extensions = data; - return StateManager.updateEndpointState(endpoint.Name, endpoint.Type, extensions); + return StateManager.updateEndpointState(endpoint, extensions); }) .then(function success() { $state.go('docker.dashboard'); @@ -62,10 +100,15 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G }); } - $scope.goToEdit = goToEdit; - - function goToEdit(id) { - $state.go('portainer.endpoints.endpoint', { id: id }); + function triggerSnapshot() { + EndpointService.snapshot() + .then(function success() { + Notifications.success('Success', 'Endpoints updated'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'An error occured during endpoint snapshot'); + }); } function initView() { @@ -85,6 +128,7 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G var groups = data.groups; EndpointHelper.mapGroupNameToEndpoint(endpoints, groups); $scope.endpoints = endpoints; + EndpointProvider.setEndpoints(endpoints); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); diff --git a/app/portainer/views/main/mainController.js b/app/portainer/views/main/mainController.js index 3eb3e9cde..d6c76f982 100644 --- a/app/portainer/views/main/mainController.js +++ b/app/portainer/views/main/mainController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('MainController', ['$scope', '$cookieStore', 'StateManager', -function ($scope, $cookieStore, StateManager) { +.controller('MainController', ['$scope', '$cookieStore', 'StateManager', 'EndpointProvider', +function ($scope, $cookieStore, StateManager, EndpointProvider) { /** * Sidebar Toggle & Cookie Control @@ -11,6 +11,7 @@ function ($scope, $cookieStore, StateManager) { }; $scope.applicationState = StateManager.getState(); + $scope.endpointState = EndpointProvider.endpoint(); $scope.$watch($scope.getWidth, function(newValue) { if (newValue >= mobileView) { diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index d105549c5..af042c575 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -22,6 +22,7 @@ swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'" standalone-management="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'" admin-access="!applicationState.application.authentication || isAdmin" + offline-mode="endpointState.OfflineMode" >
+
+ +
+ +
+
+ + + + diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.js b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.js new file mode 100644 index 000000000..13a9bd34f --- /dev/null +++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.app').component('schedulesDatatable', { + templateUrl: 'app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html', + controller: 'SchedulesDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + removeAction: '<' + } +}); diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js b/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js new file mode 100644 index 000000000..bac777070 --- /dev/null +++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js @@ -0,0 +1,58 @@ +angular.module('portainer.app') +.controller('SchedulesDatatableController', ['PaginationService', 'DatatableService', +function (PaginationService, DatatableService) { + + this.state = { + selectAll: false, + orderBy: this.orderBy, + paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey), + displayTextFilter: false, + selectedItemCount: 0, + selectedItems: [] + }; + + this.changeOrderBy = function(orderField) { + this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; + this.state.orderBy = orderField; + DatatableService.setDataTableOrder(this.tableKey, orderField, this.state.reverseOrder); + }; + + this.selectItem = function(item) { + if (item.Checked) { + this.state.selectedItemCount++; + this.state.selectedItems.push(item); + } else { + this.state.selectedItems.splice(this.state.selectedItems.indexOf(item), 1); + this.state.selectedItemCount--; + } + }; + + this.selectAll = function() { + for (var i = 0; i < this.state.filteredDataSet.length; i++) { + var item = this.state.filteredDataSet[i]; + if (item.JobType ===1 && item.Checked !== this.state.selectAll) { + item.Checked = this.state.selectAll; + this.selectItem(item); + } + } + }; + + this.changePaginationLimit = function() { + PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit); + }; + + this.$onInit = function() { + setDefaults(this); + + var storedOrder = DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + }; + + function setDefaults(ctrl) { + ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false; + ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false; + } +}]); diff --git a/app/portainer/components/forms/schedule-form/schedule-form.js b/app/portainer/components/forms/schedule-form/schedule-form.js new file mode 100644 index 000000000..27b14790f --- /dev/null +++ b/app/portainer/components/forms/schedule-form/schedule-form.js @@ -0,0 +1,35 @@ +angular.module('portainer.app').component('scheduleForm', { + templateUrl: 'app/portainer/components/forms/schedule-form/scheduleForm.html', + controller: function() { + var ctrl = this; + + ctrl.state = { + formValidationError: '' + }; + + this.action = function() { + ctrl.state.formValidationError = ''; + + if (ctrl.model.Job.Method === 'editor' && ctrl.model.Job.FileContent === '') { + ctrl.state.formValidationError = 'Script file content must not be empty'; + return; + } + + ctrl.formAction(); + }; + + this.editorUpdate = function(cm) { + ctrl.model.Job.FileContent = cm.getValue(); + }; + }, + bindings: { + model: '=', + endpoints: '<', + groups: '<', + addLabelAction: '<', + removeLabelAction: '<', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<' + } +}); diff --git a/app/portainer/components/forms/schedule-form/scheduleForm.html b/app/portainer/components/forms/schedule-form/scheduleForm.html new file mode 100644 index 000000000..1f287ece1 --- /dev/null +++ b/app/portainer/components/forms/schedule-form/scheduleForm.html @@ -0,0 +1,165 @@ +
+
+ Schedule configuration +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+
+ + You can refer to the following documentation to get more information about the supported cron expression format. + +
+ +
+ Job configuration +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+
+ + This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the + /host folder. + +
+ +
+ Job content +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ Web editor +
+
+
+ +
+
+
+ + +
+
+ Upload +
+
+ + You can upload a script file from your computer. + +
+
+
+ + + {{ $ctrl.model.Job.File.name }} + + +
+
+
+ +
+
+ Target endpoints +
+ + + + +
+ Actions +
+
+
+ + + {{ $ctrl.state.formValidationError }} + +
+
+ +
diff --git a/app/portainer/components/multi-endpoint-selector/multi-endpoint-selector.js b/app/portainer/components/multi-endpoint-selector/multi-endpoint-selector.js new file mode 100644 index 000000000..ecec019c4 --- /dev/null +++ b/app/portainer/components/multi-endpoint-selector/multi-endpoint-selector.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('multiEndpointSelector', { + templateUrl: 'app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html', + controller: 'MultiEndpointSelectorController', + bindings: { + 'model': '=', + 'endpoints': '<', + 'groups': '<' + } +}); diff --git a/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html b/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html new file mode 100644 index 000000000..5acf2149d --- /dev/null +++ b/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html @@ -0,0 +1,14 @@ + + + + {{ $item.Name }} + ({{ $item.Tags | arraytostr }}) + + + + + {{ endpoint.Name }} + ({{ endpoint.Tags | arraytostr }}) + + + diff --git a/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js b/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js new file mode 100644 index 000000000..418682771 --- /dev/null +++ b/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js @@ -0,0 +1,35 @@ +angular.module('portainer.app') +.controller('MultiEndpointSelectorController', function () { + var ctrl = this; + + this.sortGroups = function(groups) { + return _.sortBy(groups, ['name']); + }; + + this.groupEndpoints = function(endpoint) { + for (var i = 0; i < ctrl.availableGroups.length; i++) { + var group = ctrl.availableGroups[i]; + + if (endpoint.GroupId === group.Id) { + return group.Name; + } + } + }; + + this.$onInit = function() { + this.availableGroups = filterEmptyGroups(this.groups, this.endpoints); + }; + + function filterEmptyGroups(groups, endpoints) { + return groups.filter(function f(group) { + for (var i = 0; i < endpoints.length; i++) { + + var endpoint = endpoints[i]; + if (endpoint.GroupId === group.Id) { + return true; + } + } + return false; + }); + } +}); diff --git a/app/portainer/models/schedule.js b/app/portainer/models/schedule.js new file mode 100644 index 000000000..a3bb7cd45 --- /dev/null +++ b/app/portainer/models/schedule.js @@ -0,0 +1,47 @@ +function ScheduleDefaultModel() { + this.Name = ''; + this.CronExpression = ''; + this.JobType = 1; + this.Job = new ScriptExecutionDefaultJobModel(); +} + +function ScriptExecutionDefaultJobModel() { + this.Image = ''; + this.Endpoints = []; + this.FileContent = ''; + this.File = null; + this.Method = 'editor'; +} + +function ScheduleModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.JobType = data.JobType; + this.CronExpression = data.CronExpression; + this.Created = data.Created; + if (this.JobType === 1) { + this.Job = new ScriptExecutionJobModel(data.ScriptExecutionJob); + } +} + +function ScriptExecutionJobModel(data) { + this.Image = data.Image; + this.Endpoints = data.Endpoints; +} + +function ScheduleCreateRequest(model) { + this.Name = model.Name; + this.CronExpression = model.CronExpression; + this.Image = model.Job.Image; + this.Endpoints = model.Job.Endpoints; + this.FileContent = model.Job.FileContent; + this.File = model.Job.File; +} + +function ScheduleUpdateRequest(model) { + this.id = model.Id; + this.Name = model.Name; + this.CronExpression = model.CronExpression; + this.Image = model.Job.Image; + this.Endpoints = model.Job.Endpoints; +} diff --git a/app/portainer/rest/schedule.js b/app/portainer/rest/schedule.js new file mode 100644 index 000000000..8bd2fa624 --- /dev/null +++ b/app/portainer/rest/schedule.js @@ -0,0 +1,12 @@ +angular.module('portainer.app') +.factory('Schedules', ['$resource', 'API_ENDPOINT_SCHEDULES', +function SchedulesFactory($resource, API_ENDPOINT_SCHEDULES) { + 'use strict'; + return $resource(API_ENDPOINT_SCHEDULES + '/:id', {}, { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} } + }); +}]); diff --git a/app/portainer/services/api/scheduleService.js b/app/portainer/services/api/scheduleService.js new file mode 100644 index 000000000..f7723ef09 --- /dev/null +++ b/app/portainer/services/api/scheduleService.js @@ -0,0 +1,59 @@ +angular.module('portainer.app') +.factory('ScheduleService', ['$q', 'Schedules', 'FileUploadService', +function ScheduleService($q, Schedules, FileUploadService) { + 'use strict'; + var service = {}; + + service.schedule = function(scheduleId) { + var deferred = $q.defer(); + + Schedules.get({ id: scheduleId }).$promise + .then(function success(data) { + var schedule = new ScheduleModel(data); + deferred.resolve(schedule); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve schedule', err: err }); + }); + + return deferred.promise; + }; + + service.schedules = function() { + var deferred = $q.defer(); + + Schedules.query().$promise + .then(function success(data) { + var schedules = data.map(function (item) { + return new ScheduleModel(item); + }); + deferred.resolve(schedules); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve schedules', err: err }); + }); + + return deferred.promise; + }; + + service.createScheduleFromFileContent = function(model) { + var payload = new ScheduleCreateRequest(model); + return Schedules.create({ method: 'string' }, payload).$promise; + }; + + service.createScheduleFromFileUpload = function(model) { + var payload = new ScheduleCreateRequest(model); + return FileUploadService.createSchedule(payload); + }; + + service.updateSchedule = function(model) { + var payload = new ScheduleUpdateRequest(model); + return Schedules.update(payload).$promise; + }; + + service.deleteSchedule = function(scheduleId) { + return Schedules.remove({ id: scheduleId }).$promise; + }; + + return service; +}]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index 2d43cc44e..15b9ffbd3 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -39,6 +39,19 @@ angular.module('portainer.app') }); }; + service.createSchedule = function(payload) { + return Upload.upload({ + url: 'api/schedules?method=file', + data: { + file: payload.File, + Name: payload.Name, + CronExpression: payload.CronExpression, + Image: payload.Image, + Endpoints: Upload.json(payload.Endpoints) + } + }); + }; + service.createSwarmStack = function(stackName, swarmId, file, env, endpointId) { return Upload.upload({ url: 'api/stacks?method=file&type=1&endpointId=' + endpointId, diff --git a/app/portainer/views/schedules/create/createScheduleController.js b/app/portainer/views/schedules/create/createScheduleController.js new file mode 100644 index 000000000..03211d804 --- /dev/null +++ b/app/portainer/views/schedules/create/createScheduleController.js @@ -0,0 +1,52 @@ +angular.module('portainer.app') +.controller('CreateScheduleController', ['$q', '$scope', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService', +function ($q, $scope, $state, Notifications, EndpointService, GroupService, ScheduleService) { + + $scope.state = { + actionInProgress: false + }; + + $scope.create = create; + + function create() { + var model = $scope.model; + + $scope.state.actionInProgress = true; + createSchedule(model) + .then(function success() { + Notifications.success('Schedule successfully created'); + $state.go('portainer.schedules', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create schedule'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + function createSchedule(model) { + if (model.Job.Method === 'editor') { + return ScheduleService.createScheduleFromFileContent(model); + } + return ScheduleService.createScheduleFromFileUpload(model); + } + + function initView() { + $scope.model = new ScheduleDefaultModel(); + + $q.all({ + endpoints: EndpointService.endpoints(), + groups: GroupService.groups() + }) + .then(function success(data) { + $scope.endpoints = data.endpoints; + $scope.groups = data.groups; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint list'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/schedules/create/createschedule.html b/app/portainer/views/schedules/create/createschedule.html new file mode 100644 index 000000000..801cc41ca --- /dev/null +++ b/app/portainer/views/schedules/create/createschedule.html @@ -0,0 +1,23 @@ + + + + Schedules > Add schedule + + + +
+
+ + + + + +
+
diff --git a/app/portainer/views/schedules/edit/schedule.html b/app/portainer/views/schedules/edit/schedule.html new file mode 100644 index 000000000..f049459d6 --- /dev/null +++ b/app/portainer/views/schedules/edit/schedule.html @@ -0,0 +1,27 @@ + + + + + + + + Schedules > {{ ::schedule.Name }} + + + +
+
+ + + + + +
+
diff --git a/app/portainer/views/schedules/edit/scheduleController.js b/app/portainer/views/schedules/edit/scheduleController.js new file mode 100644 index 000000000..628f2d5c3 --- /dev/null +++ b/app/portainer/views/schedules/edit/scheduleController.js @@ -0,0 +1,47 @@ +angular.module('portainer.app') +.controller('ScheduleController', ['$q', '$scope', '$transition$', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService', +function ($q, $scope, $transition$, $state, Notifications, EndpointService, GroupService, ScheduleService) { + + $scope.state = { + actionInProgress: false + }; + + $scope.update = update; + + function update() { + var model = $scope.schedule; + + $scope.state.actionInProgress = true; + ScheduleService.updateSchedule(model) + .then(function success() { + Notifications.success('Schedule successfully updated'); + $state.go('portainer.schedules', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update schedule'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + function initView() { + var id = $transition$.params().id; + + $q.all({ + schedule: ScheduleService.schedule(id), + endpoints: EndpointService.endpoints(), + groups: GroupService.groups() + }) + .then(function success(data) { + $scope.schedule = data.schedule; + $scope.endpoints = data.endpoints; + $scope.groups = data.groups; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint list'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/schedules/schedules.html b/app/portainer/views/schedules/schedules.html new file mode 100644 index 000000000..507640db8 --- /dev/null +++ b/app/portainer/views/schedules/schedules.html @@ -0,0 +1,19 @@ + + + + + + + Schedules + + +
+
+ +
+
diff --git a/app/portainer/views/schedules/schedulesController.js b/app/portainer/views/schedules/schedulesController.js new file mode 100644 index 000000000..2a8632613 --- /dev/null +++ b/app/portainer/views/schedules/schedulesController.js @@ -0,0 +1,50 @@ +angular.module('portainer.app') +.controller('SchedulesController', ['$scope', '$state', 'Notifications', 'ModalService', 'ScheduleService', +function ($scope, $state, Notifications, ModalService, ScheduleService) { + + $scope.removeAction = removeAction; + + function removeAction(selectedItems) { + ModalService.confirmDeletion( + 'Do you want to remove the selected schedule(s) ?', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteSelectedSchedules(selectedItems); + } + ); + } + + function deleteSelectedSchedules(schedules) { + var actionCount = schedules.length; + angular.forEach(schedules, function (schedule) { + ScheduleService.deleteSchedule(schedule.Id) + .then(function success() { + Notifications.success('Schedule successfully removed', schedule.Name); + var index = $scope.schedules.indexOf(schedule); + $scope.schedules.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove schedule ' + schedule.Name); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } + + function initView() { + ScheduleService.schedules() + .then(function success(data) { + $scope.schedules = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve schedules'); + $scope.schedules = []; + }); + } + + initView(); +}]); diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index af042c575..031cb586f 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -12,6 +12,9 @@ + From 695c28d4f8664940ad1847c7f3ae39841325d346 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 7 Nov 2018 16:06:27 +1300 Subject: [PATCH 45/93] fix(host): fix a typo in job history clear notification --- .../datatables/host-jobs-datatable/jobsDatatableController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js index 1a02da763..11be51be5 100644 --- a/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js @@ -107,7 +107,7 @@ angular.module('portainer.docker') return $q.when(); } ContainerService.prune({ label: ['io.portainer.job.endpoint'] }).then(function success() { - Notifications.success('Success', 'Job hisotry cleared'); + Notifications.success('Success', 'Job history cleared'); $state.reload(); }).catch(function error(err) { Notifications.error('Failure', err.message, 'Unable to clear job history'); From 807c830db06282bf2b92a72b6fbbc73192beefbb Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 7 Nov 2018 17:19:10 +1300 Subject: [PATCH 46/93] feat(schedules): add the ability to update a schedule script (#2438) --- api/filesystem/filesystem.go | 10 +-- api/http/handler/schedules/handler.go | 3 +- api/http/handler/schedules/schedule_create.go | 3 +- api/http/handler/schedules/schedule_delete.go | 3 +- api/http/handler/schedules/schedule_file.go | 41 ++++++++++ api/http/handler/schedules/schedule_update.go | 12 ++- api/portainer.go | 4 +- .../forms/schedule-form/scheduleForm.html | 82 +++++++++---------- app/portainer/models/schedule.js | 3 + app/portainer/rest/schedule.js | 5 +- app/portainer/services/api/scheduleService.js | 4 + .../views/schedules/edit/schedule.html | 1 + .../schedules/edit/scheduleController.js | 9 +- 13 files changed, 124 insertions(+), 56 deletions(-) create mode 100644 api/http/handler/schedules/schedule_file.go diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index a0c85727d..acbb12db5 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -5,7 +5,6 @@ import ( "encoding/json" "encoding/pem" "io/ioutil" - "strconv" "github.com/portainer/portainer" @@ -322,16 +321,15 @@ func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) { return block.Bytes, nil } -// GetScheduleFolder returns the absolute path on the FS for a schedule based +// GetScheduleFolder returns the absolute path on the filesystem for a schedule based // on its identifier. -func (service *Service) GetScheduleFolder(scheduleIdentifier portainer.ScheduleID) string { - return path.Join(service.fileStorePath, ScheduleStorePath, strconv.Itoa(int(scheduleIdentifier))) +func (service *Service) GetScheduleFolder(identifier string) string { + return path.Join(service.fileStorePath, ScheduleStorePath, identifier) } // StoreScheduledJobFileFromBytes creates a subfolder in the ScheduleStorePath and stores a new file from bytes. // It returns the path to the folder where the file is stored. -func (service *Service) StoreScheduledJobFileFromBytes(scheduleIdentifier portainer.ScheduleID, data []byte) (string, error) { - identifier := strconv.Itoa(int(scheduleIdentifier)) +func (service *Service) StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error) { scheduleStorePath := path.Join(ScheduleStorePath, identifier) err := service.createDirectoryInStore(scheduleStorePath) if err != nil { diff --git a/api/http/handler/schedules/handler.go b/api/http/handler/schedules/handler.go index 408c8c65c..073c05606 100644 --- a/api/http/handler/schedules/handler.go +++ b/api/http/handler/schedules/handler.go @@ -35,6 +35,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut) h.Handle("/schedules/{id}", bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete) - + h.Handle("/schedules/{id}/file", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/schedules/schedule_create.go b/api/http/handler/schedules/schedule_create.go index 4d8fbd740..625d707ca 100644 --- a/api/http/handler/schedules/schedule_create.go +++ b/api/http/handler/schedules/schedule_create.go @@ -3,6 +3,7 @@ package schedules import ( "errors" "net/http" + "strconv" "time" "github.com/asaskevich/govalidator" @@ -138,7 +139,7 @@ func (handler *Handler) createScheduleFromFile(w http.ResponseWriter, r *http.Re func (handler *Handler) createSchedule(name, image, cronExpression string, endpoints []portainer.EndpointID, file []byte) (*portainer.Schedule, error) { scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier()) - scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(scheduleIdentifier, file) + scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(scheduleIdentifier)), file) if err != nil { return nil, err } diff --git a/api/http/handler/schedules/schedule_delete.go b/api/http/handler/schedules/schedule_delete.go index 502f7d7e4..51c01eec9 100644 --- a/api/http/handler/schedules/schedule_delete.go +++ b/api/http/handler/schedules/schedule_delete.go @@ -3,6 +3,7 @@ package schedules import ( "errors" "net/http" + "strconv" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -27,7 +28,7 @@ func (handler *Handler) scheduleDelete(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusBadRequest, "Cannot remove system schedules", errors.New("Cannot remove system schedule")} } - scheduleFolder := handler.FileService.GetScheduleFolder(portainer.ScheduleID(scheduleID)) + scheduleFolder := handler.FileService.GetScheduleFolder(strconv.Itoa(scheduleID)) err = handler.FileService.RemoveDirectory(scheduleFolder) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err} diff --git a/api/http/handler/schedules/schedule_file.go b/api/http/handler/schedules/schedule_file.go new file mode 100644 index 000000000..790f4d2e4 --- /dev/null +++ b/api/http/handler/schedules/schedule_file.go @@ -0,0 +1,41 @@ +package schedules + +import ( + "errors" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +type scheduleFileResponse struct { + ScheduleFileContent string `json:"ScheduleFileContent"` +} + +// GET request on /api/schedules/:id/file +func (handler *Handler) scheduleFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err} + } + + schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err} + } + + if schedule.JobType != portainer.ScriptExecutionJobType { + return &httperror.HandlerError{http.StatusBadRequest, "Unable to retrieve script file", errors.New("This type of schedule do not have any associated script file")} + } + + scheduleFileContent, err := handler.FileService.GetFileContent(schedule.ScriptExecutionJob.ScriptPath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve schedule script file from disk", err} + } + + return response.JSON(w, &scheduleFileResponse{ScheduleFileContent: string(scheduleFileContent)}) +} diff --git a/api/http/handler/schedules/schedule_update.go b/api/http/handler/schedules/schedule_update.go index 209c47da0..e4de5b5f1 100644 --- a/api/http/handler/schedules/schedule_update.go +++ b/api/http/handler/schedules/schedule_update.go @@ -2,6 +2,7 @@ package schedules import ( "net/http" + "strconv" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -15,6 +16,7 @@ type scheduleUpdatePayload struct { Image *string CronExpression *string Endpoints []portainer.EndpointID + FileContent *string } func (payload *scheduleUpdatePayload) Validate(r *http.Request) error { @@ -41,8 +43,16 @@ func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) * } updateJobSchedule := updateSchedule(schedule, &payload) - if updateJobSchedule { + if payload.FileContent != nil { + _, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(scheduleID), []byte(*payload.FileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist script file changes on the filesystem", err} + } + updateJobSchedule = true + } + + if updateJobSchedule { jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService) jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext) err := handler.JobScheduler.UpdateSchedule(schedule, jobRunner) diff --git a/api/portainer.go b/api/portainer.go index b337c6d4b..9e3efb8e8 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -654,8 +654,8 @@ type ( LoadKeyPair() ([]byte, []byte, error) WriteJSONToFile(path string, content interface{}) error FileExists(path string) (bool, error) - StoreScheduledJobFileFromBytes(scheduleIdentifier ScheduleID, data []byte) (string, error) - GetScheduleFolder(scheduleIdentifier ScheduleID) string + StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error) + GetScheduleFolder(identifier string) string } // GitService represents a service for managing Git diff --git a/app/portainer/components/forms/schedule-form/scheduleForm.html b/app/portainer/components/forms/schedule-form/scheduleForm.html index 1f287ece1..02ed2ebbe 100644 --- a/app/portainer/components/forms/schedule-form/scheduleForm.html +++ b/app/portainer/components/forms/schedule-form/scheduleForm.html @@ -55,14 +55,14 @@ +
+ + This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the + /host folder. + +
+
-
- - This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the - /host folder. - -
-
Job content
@@ -91,46 +91,46 @@
- - -
-
- Web editor -
-
-
- -
+
+ + +
+
+ Web editor +
+
+
+
- - -
-
- Upload -
-
- - You can upload a script file from your computer. +
+ + +
+
+ Upload +
+
+ + You can upload a script file from your computer. + +
+
+
+ + + {{ $ctrl.model.Job.File.name }} +
-
-
- - - {{ $ctrl.model.Job.File.name }} - - -
-
-
+
Target endpoints
diff --git a/app/portainer/models/schedule.js b/app/portainer/models/schedule.js index a3bb7cd45..7dcdde119 100644 --- a/app/portainer/models/schedule.js +++ b/app/portainer/models/schedule.js @@ -27,6 +27,8 @@ function ScheduleModel(data) { function ScriptExecutionJobModel(data) { this.Image = data.Image; this.Endpoints = data.Endpoints; + this.FileContent = ''; + this.Method = 'editor'; } function ScheduleCreateRequest(model) { @@ -44,4 +46,5 @@ function ScheduleUpdateRequest(model) { this.CronExpression = model.CronExpression; this.Image = model.Job.Image; this.Endpoints = model.Job.Endpoints; + this.FileContent = model.Job.FileContent; } diff --git a/app/portainer/rest/schedule.js b/app/portainer/rest/schedule.js index 8bd2fa624..df57ef68e 100644 --- a/app/portainer/rest/schedule.js +++ b/app/portainer/rest/schedule.js @@ -2,11 +2,12 @@ angular.module('portainer.app') .factory('Schedules', ['$resource', 'API_ENDPOINT_SCHEDULES', function SchedulesFactory($resource, API_ENDPOINT_SCHEDULES) { 'use strict'; - return $resource(API_ENDPOINT_SCHEDULES + '/:id', {}, { + return $resource(API_ENDPOINT_SCHEDULES + '/:id/:action', {}, { create: { method: 'POST' }, query: { method: 'GET', isArray: true }, get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, - remove: { method: 'DELETE', params: { id: '@id'} } + remove: { method: 'DELETE', params: { id: '@id'} }, + file: { method: 'GET', params: { id : '@id', action: 'file' } } }); }]); diff --git a/app/portainer/services/api/scheduleService.js b/app/portainer/services/api/scheduleService.js index f7723ef09..e1698b9c1 100644 --- a/app/portainer/services/api/scheduleService.js +++ b/app/portainer/services/api/scheduleService.js @@ -55,5 +55,9 @@ function ScheduleService($q, Schedules, FileUploadService) { return Schedules.remove({ id: scheduleId }).$promise; }; + service.getScriptFile = function(scheduleId) { + return Schedules.file({ id: scheduleId }).$promise; + }; + return service; }]); diff --git a/app/portainer/views/schedules/edit/schedule.html b/app/portainer/views/schedules/edit/schedule.html index f049459d6..654ac8d50 100644 --- a/app/portainer/views/schedules/edit/schedule.html +++ b/app/portainer/views/schedules/edit/schedule.html @@ -14,6 +14,7 @@ Date: Thu, 8 Nov 2018 01:53:19 +0500 Subject: [PATCH 47/93] feat(container-creation): allow escaped quotes in command field (#2419) --- app/docker/helpers/containerHelper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/docker/helpers/containerHelper.js b/app/docker/helpers/containerHelper.js index 1e5d45ff6..f683bf3ff 100644 --- a/app/docker/helpers/containerHelper.js +++ b/app/docker/helpers/containerHelper.js @@ -4,7 +4,7 @@ angular.module('portainer.docker') var helper = {}; helper.commandStringToArray = function(command) { - return splitargs(command); + return splitargs(command, undefined, true); }; helper.commandArrayToString = function(array) { From 309620545c5bf1c2acb6a07caa53638214ba195b Mon Sep 17 00:00:00 2001 From: Yassir Hannoun Date: Thu, 8 Nov 2018 01:31:33 +0100 Subject: [PATCH 48/93] fix(container-stat): fix cpu/mem charts on Windows containers * Fixing the CPU and Memory charts on Windows containers * Fixing the CPU and Memory charts on Windows containers --- app/docker/models/container.js | 14 +++++++--- .../stats/containerStatsController.js | 19 +++++++++++--- app/portainer/services/chartService.js | 26 ++++++++++++------- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/app/docker/models/container.js b/app/docker/models/container.js index ee8837498..a28e978cb 100644 --- a/app/docker/models/container.js +++ b/app/docker/models/container.js @@ -63,9 +63,17 @@ function ContainerViewModel(data) { } function ContainerStatsViewModel(data) { - this.Date = data.read; - this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache; - this.MemoryCache = data.memory_stats.stats.cache; + this.read = data.read; + this.preread = data.preread; + if(data.memory_stats.privateworkingset !== undefined) { // Windows + this.MemoryUsage = data.memory_stats.privateworkingset; + this.MemoryCache = 0; + this.NumProcs = data.num_procs; + this.isWindows = true; + } else { // Linux + this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache; + this.MemoryCache = data.memory_stats.stats.cache; + } this.PreviousCPUTotalUsage = data.precpu_stats.cpu_usage.total_usage; this.PreviousCPUSystemUsage = data.precpu_stats.system_cpu_usage; this.CurrentCPUTotalUsage = data.cpu_stats.cpu_usage.total_usage; diff --git a/app/docker/views/containers/stats/containerStatsController.js b/app/docker/views/containers/stats/containerStatsController.js index d14aeaca1..5f62b09d4 100644 --- a/app/docker/views/containers/stats/containerStatsController.js +++ b/app/docker/views/containers/stats/containerStatsController.js @@ -23,21 +23,21 @@ function ($q, $scope, $transition$, $document, $interval, ContainerService, Char if (stats.Networks.length > 0) { var rx = stats.Networks[0].rx_bytes; var tx = stats.Networks[0].tx_bytes; - var label = moment(stats.Date).format('HH:mm:ss'); + var label = moment(stats.read).format('HH:mm:ss'); ChartService.UpdateNetworkChart(label, rx, tx, chart); } } function updateMemoryChart(stats, chart) { - var label = moment(stats.Date).format('HH:mm:ss'); + var label = moment(stats.read).format('HH:mm:ss'); ChartService.UpdateMemoryChart(label, stats.MemoryUsage, stats.MemoryCache, chart); } function updateCPUChart(stats, chart) { - var label = moment(stats.Date).format('HH:mm:ss'); - var value = calculateCPUPercentUnix(stats); + var label = moment(stats.read).format('HH:mm:ss'); + var value = stats.isWindows ? calculateCPUPercentWindows(stats) : calculateCPUPercentUnix(stats); ChartService.UpdateCPUChart(label, value, chart); } @@ -54,6 +54,17 @@ function ($q, $scope, $transition$, $document, $interval, ContainerService, Char return cpuPercent; } + function calculateCPUPercentWindows(stats) { + var possIntervals = stats.NumProcs * parseFloat( + moment(stats.read, 'YYYY-MM-DDTHH:mm:ss.SSSSSSSSSZ').valueOf() - moment(stats.preread, 'YYYY-MM-DDTHH:mm:ss.SSSSSSSSSZ').valueOf()); + var windowsCpuUsage = 0.0; + if(possIntervals > 0) { + windowsCpuUsage = parseFloat(stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage) / parseFloat(possIntervals * 100); + } + return windowsCpuUsage; + } + + $scope.changeUpdateRepeater = function() { var networkChart = $scope.networkChart; var cpuChart = $scope.cpuChart; diff --git a/app/portainer/services/chartService.js b/app/portainer/services/chartService.js index 818ed6e3d..079180ab2 100644 --- a/app/portainer/services/chartService.js +++ b/app/portainer/services/chartService.js @@ -135,6 +135,14 @@ angular.module('portainer.app') }); }; + function LimitChartItems(chart, CHART_LIMIT) { + if (chart.data.datasets[0].data.length > CHART_LIMIT) { + chart.data.labels.pop(); + chart.data.datasets[0].data.pop(); + chart.data.datasets[1].data.pop(); + } + } + function UpdateChart(label, value, chart) { chart.data.labels.push(label); chart.data.datasets[0].data.push(value); @@ -150,13 +158,15 @@ angular.module('portainer.app') service.UpdateMemoryChart = function UpdateChart(label, memoryValue, cacheValue, chart) { chart.data.labels.push(label); chart.data.datasets[0].data.push(memoryValue); - chart.data.datasets[1].data.push(cacheValue); - - if (chart.data.datasets[0].data.length > CHART_LIMIT) { - chart.data.labels.pop(); - chart.data.datasets[0].data.pop(); + + if(cacheValue) { + chart.data.datasets[1].data.push(cacheValue); + } else { // cache values are not available for Windows + chart.data.datasets.splice(1, 1); } + LimitChartItems(chart); + chart.update(0); }; service.UpdateCPUChart = UpdateChart; @@ -166,11 +176,7 @@ angular.module('portainer.app') chart.data.datasets[0].data.push(rx); chart.data.datasets[1].data.push(tx); - if (chart.data.datasets[0].data.length > CHART_LIMIT) { - chart.data.labels.pop(); - chart.data.datasets[0].data.pop(); - chart.data.datasets[1].data.pop(); - } + LimitChartItems(chart); chart.update(0); }; From e7ab057c81897bba78bfd7d7fbfd1d0859a62d68 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 8 Nov 2018 14:09:21 +1300 Subject: [PATCH 49/93] feat(sidebar): add a new Scheduler top entry --- app/portainer/views/sidebar/sidebar.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index 031cb586f..031cfe91e 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -12,9 +12,6 @@ - @@ -39,6 +36,12 @@ Profiles
+ + From a2d9f591a7108e205d625d0c55d449279823324f Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 9 Nov 2018 15:22:08 +1300 Subject: [PATCH 50/93] feat(schedules): add retry policy to script schedules (#2445) --- api/cron/job_script_execution.go | 29 ++++- api/docker/job.go | 5 + api/errors.go | 5 + api/http/handler/schedules/schedule_create.go | 103 +++++++++++++----- api/http/handler/schedules/schedule_update.go | 12 ++ api/portainer.go | 10 +- .../forms/schedule-form/scheduleForm.html | 30 ++++- app/portainer/models/schedule.js | 6 + app/portainer/services/fileUpload.js | 4 +- 9 files changed, 160 insertions(+), 44 deletions(-) diff --git a/api/cron/job_script_execution.go b/api/cron/job_script_execution.go index aafbc4892..b1b53d878 100644 --- a/api/cron/job_script_execution.go +++ b/api/cron/job_script_execution.go @@ -2,6 +2,7 @@ package cron import ( "log" + "time" "github.com/portainer/portainer" ) @@ -46,6 +47,7 @@ func (runner *ScriptExecutionJobRunner) Run() { return } + targets := make([]*portainer.Endpoint, 0) for _, endpointID := range runner.job.Endpoints { endpoint, err := runner.context.endpointService.Endpoint(endpointID) if err != nil { @@ -53,11 +55,32 @@ func (runner *ScriptExecutionJobRunner) Run() { return } - err = runner.context.jobService.Execute(endpoint, "", runner.job.Image, scriptFile) - if err != nil { - log.Printf("scheduled job error (script execution). Unable to execute scrtip (endpoint=%s) (err=%s)\n", endpoint.Name, err) + targets = append(targets, endpoint) + } + + runner.executeAndRetry(targets, scriptFile, 0) +} + +func (runner *ScriptExecutionJobRunner) executeAndRetry(endpoints []*portainer.Endpoint, script []byte, retryCount int) { + retryTargets := make([]*portainer.Endpoint, 0) + + for _, endpoint := range endpoints { + err := runner.context.jobService.Execute(endpoint, "", runner.job.Image, script) + if err == portainer.ErrUnableToPingEndpoint { + retryTargets = append(retryTargets, endpoint) + } else if err != nil { + log.Printf("scheduled job error (script execution). Unable to execute script (endpoint=%s) (err=%s)\n", endpoint.Name, err) } } + + retryCount++ + if retryCount >= runner.job.RetryCount { + return + } + + time.Sleep(time.Duration(runner.job.RetryInterval) * time.Second) + + runner.executeAndRetry(retryTargets, script, retryCount) } // GetScheduleID returns the schedule identifier associated to the runner diff --git a/api/docker/job.go b/api/docker/job.go index eba82e3e5..ed721cd4b 100644 --- a/api/docker/job.go +++ b/api/docker/job.go @@ -41,6 +41,11 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image } defer cli.Close() + _, err = cli.Ping(context.Background()) + if err != nil { + return portainer.ErrUnableToPingEndpoint + } + err = pullImage(cli, image) if err != nil { return err diff --git a/api/errors.go b/api/errors.go index e348aaf48..da6c4edfe 100644 --- a/api/errors.go +++ b/api/errors.go @@ -88,6 +88,11 @@ const ( ErrUndefinedTLSFileType = Error("Undefined TLS file type") ) +// Docker errors. +const ( + ErrUnableToPingEndpoint = Error("Unable to communicate with the endpoint") +) + // Error represents an application error. type Error string diff --git a/api/http/handler/schedules/schedule_create.go b/api/http/handler/schedules/schedule_create.go index 625d707ca..03db5203e 100644 --- a/api/http/handler/schedules/schedule_create.go +++ b/api/http/handler/schedules/schedule_create.go @@ -14,23 +14,27 @@ import ( "github.com/portainer/portainer/cron" ) -type scheduleFromFilePayload struct { +type scheduleCreateFromFilePayload struct { Name string Image string CronExpression string Endpoints []portainer.EndpointID File []byte + RetryCount int + RetryInterval int } -type scheduleFromFileContentPayload struct { +type scheduleCreateFromFileContentPayload struct { Name string CronExpression string Image string Endpoints []portainer.EndpointID FileContent string + RetryCount int + RetryInterval int } -func (payload *scheduleFromFilePayload) Validate(r *http.Request) error { +func (payload *scheduleCreateFromFilePayload) Validate(r *http.Request) error { name, err := request.RetrieveMultiPartFormValue(r, "Name", false) if err != nil { return errors.New("Invalid name") @@ -62,10 +66,16 @@ func (payload *scheduleFromFilePayload) Validate(r *http.Request) error { } payload.File = file + retryCount, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryCount", true) + payload.RetryCount = retryCount + + retryInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryInterval", true) + payload.RetryInterval = retryInterval + return nil } -func (payload *scheduleFromFileContentPayload) Validate(r *http.Request) error { +func (payload *scheduleCreateFromFileContentPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { return portainer.Error("Invalid schedule name") } @@ -86,6 +96,10 @@ func (payload *scheduleFromFileContentPayload) Validate(r *http.Request) error { return portainer.Error("Invalid script file content") } + if payload.RetryCount != 0 && payload.RetryInterval == 0 { + return portainer.Error("RetryInterval must be set") + } + return nil } @@ -107,71 +121,100 @@ func (handler *Handler) scheduleCreate(w http.ResponseWriter, r *http.Request) * } func (handler *Handler) createScheduleFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload scheduleFromFileContentPayload + var payload scheduleCreateFromFileContentPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - schedule, err := handler.createSchedule(payload.Name, payload.Image, payload.CronExpression, payload.Endpoints, []byte(payload.FileContent)) + schedule := handler.createScheduleObjectFromFileContentPayload(&payload) + + err = handler.addAndPersistSchedule(schedule, []byte(payload.FileContent)) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err} } return response.JSON(w, schedule) } func (handler *Handler) createScheduleFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - payload := &scheduleFromFilePayload{} + payload := &scheduleCreateFromFilePayload{} err := payload.Validate(r) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - schedule, err := handler.createSchedule(payload.Name, payload.Image, payload.CronExpression, payload.Endpoints, payload.File) + schedule := handler.createScheduleObjectFromFilePayload(payload) + + err = handler.addAndPersistSchedule(schedule, payload.File) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err} } return response.JSON(w, schedule) } -func (handler *Handler) createSchedule(name, image, cronExpression string, endpoints []portainer.EndpointID, file []byte) (*portainer.Schedule, error) { +func (handler *Handler) createScheduleObjectFromFilePayload(payload *scheduleCreateFromFilePayload) *portainer.Schedule { scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier()) - scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(scheduleIdentifier)), file) - if err != nil { - return nil, err - } - job := &portainer.ScriptExecutionJob{ - Endpoints: endpoints, - Image: image, - ScriptPath: scriptPath, - ScheduleID: scheduleIdentifier, + Endpoints: payload.Endpoints, + Image: payload.Image, + ScheduleID: scheduleIdentifier, + RetryCount: payload.RetryCount, + RetryInterval: payload.RetryInterval, } schedule := &portainer.Schedule{ ID: scheduleIdentifier, - Name: name, - CronExpression: cronExpression, + Name: payload.Name, + CronExpression: payload.CronExpression, JobType: portainer.ScriptExecutionJobType, ScriptExecutionJob: job, Created: time.Now().Unix(), } + return schedule +} + +func (handler *Handler) createScheduleObjectFromFileContentPayload(payload *scheduleCreateFromFileContentPayload) *portainer.Schedule { + scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier()) + + job := &portainer.ScriptExecutionJob{ + Endpoints: payload.Endpoints, + Image: payload.Image, + ScheduleID: scheduleIdentifier, + RetryCount: payload.RetryCount, + RetryInterval: payload.RetryInterval, + } + + schedule := &portainer.Schedule{ + ID: scheduleIdentifier, + Name: payload.Name, + CronExpression: payload.CronExpression, + JobType: portainer.ScriptExecutionJobType, + ScriptExecutionJob: job, + Created: time.Now().Unix(), + } + + return schedule +} + +func (handler *Handler) addAndPersistSchedule(schedule *portainer.Schedule, file []byte) error { + scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(schedule.ID)), file) + if err != nil { + return err + } + + schedule.ScriptExecutionJob.ScriptPath = scriptPath + jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService) - jobRunner := cron.NewScriptExecutionJobRunner(job, jobContext) + jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext) err = handler.JobScheduler.CreateSchedule(schedule, jobRunner) if err != nil { - return nil, err + return err } - err = handler.ScheduleService.CreateSchedule(schedule) - if err != nil { - return nil, err - } - - return schedule, nil + return handler.ScheduleService.CreateSchedule(schedule) } diff --git a/api/http/handler/schedules/schedule_update.go b/api/http/handler/schedules/schedule_update.go index e4de5b5f1..2c3b376f4 100644 --- a/api/http/handler/schedules/schedule_update.go +++ b/api/http/handler/schedules/schedule_update.go @@ -17,6 +17,8 @@ type scheduleUpdatePayload struct { CronExpression *string Endpoints []portainer.EndpointID FileContent *string + RetryCount *int + RetryInterval *int } func (payload *scheduleUpdatePayload) Validate(r *http.Request) error { @@ -91,5 +93,15 @@ func updateSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload updateJobSchedule = true } + if payload.RetryCount != nil { + schedule.ScriptExecutionJob.RetryCount = *payload.RetryCount + updateJobSchedule = true + } + + if payload.RetryInterval != nil { + schedule.ScriptExecutionJob.RetryInterval = *payload.RetryInterval + updateJobSchedule = true + } + return updateJobSchedule } diff --git a/api/portainer.go b/api/portainer.go index 9e3efb8e8..30ac5983d 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -228,10 +228,12 @@ type ( // ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container ScriptExecutionJob struct { - ScheduleID ScheduleID `json:"ScheduleId"` - Endpoints []EndpointID - Image string - ScriptPath string + ScheduleID ScheduleID `json:"ScheduleId"` + Endpoints []EndpointID + Image string + ScriptPath string + RetryCount int + RetryInterval int } // SnapshotJob represents a scheduled job that can create endpoint snapshots diff --git a/app/portainer/components/forms/schedule-form/scheduleForm.html b/app/portainer/components/forms/schedule-form/scheduleForm.html index 02ed2ebbe..4cdae3d96 100644 --- a/app/portainer/components/forms/schedule-form/scheduleForm.html +++ b/app/portainer/components/forms/schedule-form/scheduleForm.html @@ -42,8 +42,8 @@
- -
+ +
@@ -55,12 +55,24 @@
+
- - This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the - /host folder. - + +
+ +
+ +
+ +
+
@@ -98,6 +110,12 @@
Web editor
+
+ + This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the + /host folder. + +
Date: Tue, 13 Nov 2018 14:39:26 +1300 Subject: [PATCH 51/93] feat(schedules): add the ability to list tasks from snapshots (#2458) * feat(schedules): add the ability to list tasks from snapshots * feat(schedules): update schedules * refactor(schedules): fix linting issue --- api/cmd/portainer/main.go | 12 +-- api/cron/job_endpoint_sync.go | 26 ++--- api/cron/job_script_execution.go | 36 +++---- api/cron/job_snapshot.go | 34 +++---- api/cron/scheduler.go | 69 +++++++++---- api/docker/job.go | 29 +++--- api/http/handler/endpoints/endpoint_job.go | 4 +- api/http/handler/schedules/handler.go | 2 + api/http/handler/schedules/schedule_create.go | 16 +-- api/http/handler/schedules/schedule_delete.go | 2 + api/http/handler/schedules/schedule_tasks.go | 87 +++++++++++++++++ api/http/handler/schedules/schedule_update.go | 4 +- api/http/handler/settings/settings_update.go | 7 +- api/portainer.go | 22 ++--- .../host-jobs-datatable/jobsDatatable.html | 2 +- .../scheduleTasksDatatable.html | 97 +++++++++++++++++++ .../scheduleTasksDatatable.js | 13 +++ app/portainer/models/schedule.js | 7 ++ app/portainer/rest/schedule.js | 3 +- app/portainer/services/api/scheduleService.js | 17 ++++ .../views/schedules/edit/schedule.html | 56 +++++++++-- .../schedules/edit/scheduleController.js | 44 +++++++-- 22 files changed, 440 insertions(+), 149 deletions(-) create mode 100644 api/http/handler/schedules/schedule_tasks.go create mode 100644 app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html create mode 100644 app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 2fffc2a69..0e0c189ac 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -141,9 +141,9 @@ func loadSnapshotSystemSchedule(jobScheduler portainer.JobScheduler, snapshotter } snapshotJobContext := cron.NewSnapshotJobContext(endpointService, snapshotter) - snapshotJobRunner := cron.NewSnapshotJobRunner(snapshotJob, snapshotJobContext) + snapshotJobRunner := cron.NewSnapshotJobRunner(snapshotSchedule, snapshotJobContext) - err = jobScheduler.CreateSchedule(snapshotSchedule, snapshotJobRunner) + err = jobScheduler.ScheduleJob(snapshotJobRunner) if err != nil { return err } @@ -179,9 +179,9 @@ func loadEndpointSyncSystemSchedule(jobScheduler portainer.JobScheduler, schedul } endpointSyncJobContext := cron.NewEndpointSyncJobContext(endpointService, *flags.ExternalEndpoints) - endpointSyncJobRunner := cron.NewEndpointSyncJobRunner(endpointSyncJob, endpointSyncJobContext) + endpointSyncJobRunner := cron.NewEndpointSyncJobRunner(endointSyncSchedule, endpointSyncJobContext) - err = jobScheduler.CreateSchedule(endointSyncSchedule, endpointSyncJobRunner) + err = jobScheduler.ScheduleJob(endpointSyncJobRunner) if err != nil { return err } @@ -199,9 +199,9 @@ func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService p if schedule.JobType == portainer.ScriptExecutionJobType { jobContext := cron.NewScriptExecutionJobContext(jobService, endpointService, fileService) - jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext) + jobRunner := cron.NewScriptExecutionJobRunner(&schedule, jobContext) - err = jobScheduler.CreateSchedule(&schedule, jobRunner) + err = jobScheduler.ScheduleJob(jobRunner) if err != nil { return err } diff --git a/api/cron/job_endpoint_sync.go b/api/cron/job_endpoint_sync.go index d6ff92173..ef13a4970 100644 --- a/api/cron/job_endpoint_sync.go +++ b/api/cron/job_endpoint_sync.go @@ -11,8 +11,8 @@ import ( // EndpointSyncJobRunner is used to run a EndpointSyncJob type EndpointSyncJobRunner struct { - job *portainer.EndpointSyncJob - context *EndpointSyncJobContext + schedule *portainer.Schedule + context *EndpointSyncJobContext } // EndpointSyncJobContext represents the context of execution of a EndpointSyncJob @@ -30,10 +30,10 @@ func NewEndpointSyncJobContext(endpointService portainer.EndpointService, endpoi } // NewEndpointSyncJobRunner returns a new runner that can be scheduled -func NewEndpointSyncJobRunner(job *portainer.EndpointSyncJob, context *EndpointSyncJobContext) *EndpointSyncJobRunner { +func NewEndpointSyncJobRunner(schedule *portainer.Schedule, context *EndpointSyncJobContext) *EndpointSyncJobRunner { return &EndpointSyncJobRunner{ - job: job, - context: context, + schedule: schedule, + context: context, } } @@ -53,19 +53,9 @@ type fileEndpoint struct { TLSKey string `json:"TLSKey,omitempty"` } -// GetScheduleID returns the schedule identifier associated to the runner -func (runner *EndpointSyncJobRunner) GetScheduleID() portainer.ScheduleID { - return runner.job.ScheduleID -} - -// SetScheduleID sets the schedule identifier associated to the runner -func (runner *EndpointSyncJobRunner) SetScheduleID(ID portainer.ScheduleID) { - runner.job.ScheduleID = ID -} - -// GetJobType returns the job type associated to the runner -func (runner *EndpointSyncJobRunner) GetJobType() portainer.JobType { - return portainer.EndpointSyncJobType +// GetSchedule returns the schedule associated to the runner +func (runner *EndpointSyncJobRunner) GetSchedule() *portainer.Schedule { + return runner.schedule } // Run triggers the execution of the endpoint synchronization process. diff --git a/api/cron/job_script_execution.go b/api/cron/job_script_execution.go index b1b53d878..6f984e8fd 100644 --- a/api/cron/job_script_execution.go +++ b/api/cron/job_script_execution.go @@ -9,8 +9,8 @@ import ( // ScriptExecutionJobRunner is used to run a ScriptExecutionJob type ScriptExecutionJobRunner struct { - job *portainer.ScriptExecutionJob - context *ScriptExecutionJobContext + schedule *portainer.Schedule + context *ScriptExecutionJobContext } // ScriptExecutionJobContext represents the context of execution of a ScriptExecutionJob @@ -30,10 +30,10 @@ func NewScriptExecutionJobContext(jobService portainer.JobService, endpointServi } // NewScriptExecutionJobRunner returns a new runner that can be scheduled -func NewScriptExecutionJobRunner(job *portainer.ScriptExecutionJob, context *ScriptExecutionJobContext) *ScriptExecutionJobRunner { +func NewScriptExecutionJobRunner(schedule *portainer.Schedule, context *ScriptExecutionJobContext) *ScriptExecutionJobRunner { return &ScriptExecutionJobRunner{ - job: job, - context: context, + schedule: schedule, + context: context, } } @@ -41,14 +41,14 @@ func NewScriptExecutionJobRunner(job *portainer.ScriptExecutionJob, context *Scr // It will iterate through all the endpoints specified in the context to // execute the script associated to the job. func (runner *ScriptExecutionJobRunner) Run() { - scriptFile, err := runner.context.fileService.GetFileContent(runner.job.ScriptPath) + scriptFile, err := runner.context.fileService.GetFileContent(runner.schedule.ScriptExecutionJob.ScriptPath) if err != nil { log.Printf("scheduled job error (script execution). Unable to retrieve script file (err=%s)\n", err) return } targets := make([]*portainer.Endpoint, 0) - for _, endpointID := range runner.job.Endpoints { + for _, endpointID := range runner.schedule.ScriptExecutionJob.Endpoints { endpoint, err := runner.context.endpointService.Endpoint(endpointID) if err != nil { log.Printf("scheduled job error (script execution). Unable to retrieve information about endpoint (id=%d) (err=%s)\n", endpointID, err) @@ -65,7 +65,7 @@ func (runner *ScriptExecutionJobRunner) executeAndRetry(endpoints []*portainer.E retryTargets := make([]*portainer.Endpoint, 0) for _, endpoint := range endpoints { - err := runner.context.jobService.Execute(endpoint, "", runner.job.Image, script) + err := runner.context.jobService.ExecuteScript(endpoint, "", runner.schedule.ScriptExecutionJob.Image, script, runner.schedule) if err == portainer.ErrUnableToPingEndpoint { retryTargets = append(retryTargets, endpoint) } else if err != nil { @@ -74,26 +74,16 @@ func (runner *ScriptExecutionJobRunner) executeAndRetry(endpoints []*portainer.E } retryCount++ - if retryCount >= runner.job.RetryCount { + if retryCount >= runner.schedule.ScriptExecutionJob.RetryCount { return } - time.Sleep(time.Duration(runner.job.RetryInterval) * time.Second) + time.Sleep(time.Duration(runner.schedule.ScriptExecutionJob.RetryInterval) * time.Second) runner.executeAndRetry(retryTargets, script, retryCount) } -// GetScheduleID returns the schedule identifier associated to the runner -func (runner *ScriptExecutionJobRunner) GetScheduleID() portainer.ScheduleID { - return runner.job.ScheduleID -} - -// SetScheduleID sets the schedule identifier associated to the runner -func (runner *ScriptExecutionJobRunner) SetScheduleID(ID portainer.ScheduleID) { - runner.job.ScheduleID = ID -} - -// GetJobType returns the job type associated to the runner -func (runner *ScriptExecutionJobRunner) GetJobType() portainer.JobType { - return portainer.ScriptExecutionJobType +// GetSchedule returns the schedule associated to the runner +func (runner *ScriptExecutionJobRunner) GetSchedule() *portainer.Schedule { + return runner.schedule } diff --git a/api/cron/job_snapshot.go b/api/cron/job_snapshot.go index 5918b47aa..153d3c5b7 100644 --- a/api/cron/job_snapshot.go +++ b/api/cron/job_snapshot.go @@ -8,8 +8,8 @@ import ( // SnapshotJobRunner is used to run a SnapshotJob type SnapshotJobRunner struct { - job *portainer.SnapshotJob - context *SnapshotJobContext + schedule *portainer.Schedule + context *SnapshotJobContext } // SnapshotJobContext represents the context of execution of a SnapshotJob @@ -27,35 +27,25 @@ func NewSnapshotJobContext(endpointService portainer.EndpointService, snapshotte } // NewSnapshotJobRunner returns a new runner that can be scheduled -func NewSnapshotJobRunner(job *portainer.SnapshotJob, context *SnapshotJobContext) *SnapshotJobRunner { +func NewSnapshotJobRunner(schedule *portainer.Schedule, context *SnapshotJobContext) *SnapshotJobRunner { return &SnapshotJobRunner{ - job: job, - context: context, + schedule: schedule, + context: context, } } -// GetScheduleID returns the schedule identifier associated to the runner -func (runner *SnapshotJobRunner) GetScheduleID() portainer.ScheduleID { - return runner.job.ScheduleID +// GetSchedule returns the schedule associated to the runner +func (runner *SnapshotJobRunner) GetSchedule() *portainer.Schedule { + return runner.schedule } -// SetScheduleID sets the schedule identifier associated to the runner -func (runner *SnapshotJobRunner) SetScheduleID(ID portainer.ScheduleID) { - runner.job.ScheduleID = ID -} - -// GetJobType returns the job type associated to the runner -func (runner *SnapshotJobRunner) GetJobType() portainer.JobType { - return portainer.EndpointSyncJobType -} - -// Run triggers the execution of the job. +// Run triggers the execution of the schedule. // It will iterate through all the endpoints available in the database to // create a snapshot of each one of them. func (runner *SnapshotJobRunner) Run() { endpoints, err := runner.context.endpointService.Endpoints() if err != nil { - log.Printf("background job error (endpoint snapshot). Unable to retrieve endpoint list (err=%s)\n", err) + log.Printf("background schedule error (endpoint snapshot). Unable to retrieve endpoint list (err=%s)\n", err) return } @@ -67,7 +57,7 @@ func (runner *SnapshotJobRunner) Run() { snapshot, err := runner.context.snapshotter.CreateSnapshot(&endpoint) endpoint.Status = portainer.EndpointStatusUp if err != nil { - log.Printf("background job error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) endpoint.Status = portainer.EndpointStatusDown } @@ -77,7 +67,7 @@ func (runner *SnapshotJobRunner) Run() { err = runner.context.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { - log.Printf("background job error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + log.Printf("background schedule error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) return } } diff --git a/api/cron/scheduler.go b/api/cron/scheduler.go index f329d00bd..2cff329e8 100644 --- a/api/cron/scheduler.go +++ b/api/cron/scheduler.go @@ -17,31 +17,25 @@ func NewJobScheduler() *JobScheduler { } } -// CreateSchedule schedules the execution of a job via a runner -func (scheduler *JobScheduler) CreateSchedule(schedule *portainer.Schedule, runner portainer.JobRunner) error { - runner.SetScheduleID(schedule.ID) - return scheduler.cron.AddJob(schedule.CronExpression, runner) +// ScheduleJob schedules the execution of a job via a runner +func (scheduler *JobScheduler) ScheduleJob(runner portainer.JobRunner) error { + return scheduler.cron.AddJob(runner.GetSchedule().CronExpression, runner) } -// UpdateSchedule updates a specific scheduled job by re-creating a new cron +// UpdateSystemJobSchedule updates the first occurence of the specified +// scheduled job based on the specified job type. +// It does so by re-creating a new cron // and adding all the existing jobs. It will then re-schedule the new job -// via the specified JobRunner parameter. +// with the update cron expression passed in parameter. // NOTE: the cron library do not support updating schedules directly // hence the work-around -func (scheduler *JobScheduler) UpdateSchedule(schedule *portainer.Schedule, runner portainer.JobRunner) error { +func (scheduler *JobScheduler) UpdateSystemJobSchedule(jobType portainer.JobType, newCronExpression string) error { cronEntries := scheduler.cron.Entries() newCron := cron.New() for _, entry := range cronEntries { - - if entry.Job.(portainer.JobRunner).GetScheduleID() == schedule.ID { - - var jobRunner cron.Job = runner - if entry.Job.(portainer.JobRunner).GetJobType() == portainer.SnapshotJobType { - jobRunner = entry.Job - } - - err := newCron.AddJob(schedule.CronExpression, jobRunner) + if entry.Job.(portainer.JobRunner).GetSchedule().JobType == jobType { + err := newCron.AddJob(newCronExpression, entry.Job) if err != nil { return err } @@ -56,17 +50,50 @@ func (scheduler *JobScheduler) UpdateSchedule(schedule *portainer.Schedule, runn return nil } -// RemoveSchedule remove a scheduled job by re-creating a new cron -// and adding all the existing jobs except for the one specified via scheduleID. -// NOTE: the cron library do not support removing schedules directly +// UpdateJobSchedule updates a specific scheduled job by re-creating a new cron +// and adding all the existing jobs. It will then re-schedule the new job +// via the specified JobRunner parameter. +// NOTE: the cron library do not support updating schedules directly // hence the work-around -func (scheduler *JobScheduler) RemoveSchedule(scheduleID portainer.ScheduleID) { +func (scheduler *JobScheduler) UpdateJobSchedule(runner portainer.JobRunner) error { cronEntries := scheduler.cron.Entries() newCron := cron.New() for _, entry := range cronEntries { - if entry.Job.(portainer.JobRunner).GetScheduleID() == scheduleID { + if entry.Job.(portainer.JobRunner).GetSchedule().ID == runner.GetSchedule().ID { + + var jobRunner cron.Job = runner + if entry.Job.(portainer.JobRunner).GetSchedule().JobType == portainer.SnapshotJobType { + jobRunner = entry.Job + } + + err := newCron.AddJob(runner.GetSchedule().CronExpression, jobRunner) + if err != nil { + return err + } + } + + newCron.Schedule(entry.Schedule, entry.Job) + } + + scheduler.cron.Stop() + scheduler.cron = newCron + scheduler.cron.Start() + return nil +} + +// UnscheduleJob remove a scheduled job by re-creating a new cron +// and adding all the existing jobs except for the one specified via scheduleID. +// NOTE: the cron library do not support removing schedules directly +// hence the work-around +func (scheduler *JobScheduler) UnscheduleJob(scheduleID portainer.ScheduleID) { + cronEntries := scheduler.cron.Entries() + newCron := cron.New() + + for _, entry := range cronEntries { + + if entry.Job.(portainer.JobRunner).GetSchedule().ID == scheduleID { continue } diff --git a/api/docker/job.go b/api/docker/job.go index ed721cd4b..ea343e91c 100644 --- a/api/docker/job.go +++ b/api/docker/job.go @@ -18,24 +18,25 @@ import ( // JobService represents a service that handles the execution of jobs type JobService struct { - DockerClientFactory *ClientFactory + dockerClientFactory *ClientFactory } // NewJobService returns a pointer to a new job service func NewJobService(dockerClientFactory *ClientFactory) *JobService { return &JobService{ - DockerClientFactory: dockerClientFactory, + dockerClientFactory: dockerClientFactory, } } -// Execute will execute a script on the endpoint host with the supplied image as a container -func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image string, script []byte) error { +// ExecuteScript will leverage a privileged container to execute a script against the specified endpoint/nodename. +// It will copy the script content specified as a parameter inside a container based on the specified image and execute it. +func (service *JobService) ExecuteScript(endpoint *portainer.Endpoint, nodeName, image string, script []byte, schedule *portainer.Schedule) error { buffer, err := archive.TarFileInBuffer(script, "script.sh", 0700) if err != nil { return err } - cli, err := service.DockerClientFactory.CreateClient(endpoint, nodeName) + cli, err := service.dockerClientFactory.CreateClient(endpoint, nodeName) if err != nil { return err } @@ -64,6 +65,10 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image Cmd: strslice.StrSlice([]string{"sh", "/tmp/script.sh"}), } + if schedule != nil { + containerConfig.Labels["io.portainer.schedule.id"] = strconv.Itoa(int(schedule.ID)) + } + hostConfig := &container.HostConfig{ Binds: []string{"/:/host", "/etc:/etc:ro", "/usr:/usr:ro", "/run:/run:ro", "/sbin:/sbin:ro", "/var:/var:ro"}, NetworkMode: "host", @@ -77,6 +82,13 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image return err } + if schedule != nil { + err = cli.ContainerRename(context.Background(), body.ID, endpoint.Name+"_"+schedule.Name+"_"+body.ID) + if err != nil { + return err + } + } + copyOptions := types.CopyToContainerOptions{} err = cli.CopyToContainer(context.Background(), body.ID, "/tmp", bytes.NewReader(buffer), copyOptions) if err != nil { @@ -84,12 +96,7 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image } startOptions := types.ContainerStartOptions{} - err = cli.ContainerStart(context.Background(), body.ID, startOptions) - if err != nil { - return err - } - - return nil + return cli.ContainerStart(context.Background(), body.ID, startOptions) } func pullImage(cli *client.Client, image string) error { diff --git a/api/http/handler/endpoints/endpoint_job.go b/api/http/handler/endpoints/endpoint_job.go index 025f6ab3a..565418f42 100644 --- a/api/http/handler/endpoints/endpoint_job.go +++ b/api/http/handler/endpoints/endpoint_job.go @@ -92,7 +92,7 @@ func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Reques return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - err = handler.JobService.Execute(endpoint, nodeName, payload.Image, payload.File) + err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, payload.File, nil) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} } @@ -107,7 +107,7 @@ func (handler *Handler) executeJobFromFileContent(w http.ResponseWriter, r *http return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - err = handler.JobService.Execute(endpoint, nodeName, payload.Image, []byte(payload.FileContent)) + err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, []byte(payload.FileContent), nil) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} } diff --git a/api/http/handler/schedules/handler.go b/api/http/handler/schedules/handler.go index 073c05606..4c5b64bbd 100644 --- a/api/http/handler/schedules/handler.go +++ b/api/http/handler/schedules/handler.go @@ -37,5 +37,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete) h.Handle("/schedules/{id}/file", bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet) + h.Handle("/schedules/{id}/tasks", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleTasks))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/schedules/schedule_create.go b/api/http/handler/schedules/schedule_create.go index 03db5203e..d09f94860 100644 --- a/api/http/handler/schedules/schedule_create.go +++ b/api/http/handler/schedules/schedule_create.go @@ -158,9 +158,9 @@ func (handler *Handler) createScheduleObjectFromFilePayload(payload *scheduleCre scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier()) job := &portainer.ScriptExecutionJob{ - Endpoints: payload.Endpoints, - Image: payload.Image, - ScheduleID: scheduleIdentifier, + Endpoints: payload.Endpoints, + Image: payload.Image, + // ScheduleID: scheduleIdentifier, RetryCount: payload.RetryCount, RetryInterval: payload.RetryInterval, } @@ -181,9 +181,9 @@ func (handler *Handler) createScheduleObjectFromFileContentPayload(payload *sche scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier()) job := &portainer.ScriptExecutionJob{ - Endpoints: payload.Endpoints, - Image: payload.Image, - ScheduleID: scheduleIdentifier, + Endpoints: payload.Endpoints, + Image: payload.Image, + // ScheduleID: scheduleIdentifier, RetryCount: payload.RetryCount, RetryInterval: payload.RetryInterval, } @@ -209,9 +209,9 @@ func (handler *Handler) addAndPersistSchedule(schedule *portainer.Schedule, file schedule.ScriptExecutionJob.ScriptPath = scriptPath jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService) - jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext) + jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext) - err = handler.JobScheduler.CreateSchedule(schedule, jobRunner) + err = handler.JobScheduler.ScheduleJob(jobRunner) if err != nil { return err } diff --git a/api/http/handler/schedules/schedule_delete.go b/api/http/handler/schedules/schedule_delete.go index 51c01eec9..7597fb6c0 100644 --- a/api/http/handler/schedules/schedule_delete.go +++ b/api/http/handler/schedules/schedule_delete.go @@ -34,6 +34,8 @@ func (handler *Handler) scheduleDelete(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err} } + handler.JobScheduler.UnscheduleJob(schedule.ID) + err = handler.ScheduleService.DeleteSchedule(portainer.ScheduleID(scheduleID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the schedule from the database", err} diff --git a/api/http/handler/schedules/schedule_tasks.go b/api/http/handler/schedules/schedule_tasks.go new file mode 100644 index 000000000..da88fdac7 --- /dev/null +++ b/api/http/handler/schedules/schedule_tasks.go @@ -0,0 +1,87 @@ +package schedules + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +type taskContainer struct { + ID string `json:"Id"` + EndpointID portainer.EndpointID `json:"EndpointId"` + Status string `json:"Status"` + Created float64 `json:"Created"` + Labels map[string]string `json:"Labels"` +} + +// GET request on /api/schedules/:id/tasks +func (handler *Handler) scheduleTasks(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err} + } + + schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err} + } + + if schedule.JobType != portainer.ScriptExecutionJobType { + return &httperror.HandlerError{http.StatusBadRequest, "Unable to retrieve schedule tasks", errors.New("This type of schedule do not have any associated tasks")} + } + + tasks := make([]taskContainer, 0) + + for _, endpointID := range schedule.ScriptExecutionJob.Endpoints { + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err == portainer.ErrObjectNotFound { + continue + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + endpointTasks, err := extractTasksFromContainerSnasphot(endpoint, schedule.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find extract schedule tasks from endpoint snapshot", err} + } + + tasks = append(tasks, endpointTasks...) + } + + return response.JSON(w, tasks) +} + +func extractTasksFromContainerSnasphot(endpoint *portainer.Endpoint, scheduleID portainer.ScheduleID) ([]taskContainer, error) { + endpointTasks := make([]taskContainer, 0) + if len(endpoint.Snapshots) == 0 { + return endpointTasks, nil + } + + b, err := json.Marshal(endpoint.Snapshots[0].SnapshotRaw.Containers) + if err != nil { + return nil, err + } + + var containers []taskContainer + err = json.Unmarshal(b, &containers) + if err != nil { + return nil, err + } + + for _, container := range containers { + if container.Labels["io.portainer.schedule.id"] == strconv.Itoa(int(scheduleID)) { + container.EndpointID = endpoint.ID + endpointTasks = append(endpointTasks, container) + } + } + + return endpointTasks, nil +} diff --git a/api/http/handler/schedules/schedule_update.go b/api/http/handler/schedules/schedule_update.go index 2c3b376f4..12284181c 100644 --- a/api/http/handler/schedules/schedule_update.go +++ b/api/http/handler/schedules/schedule_update.go @@ -56,8 +56,8 @@ func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) * if updateJobSchedule { jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService) - jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext) - err := handler.JobScheduler.UpdateSchedule(schedule, jobRunner) + jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext) + err := handler.JobScheduler.UpdateJobSchedule(jobRunner) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update job scheduler", err} } diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 472a34ada..bef9cee9e 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -108,7 +108,12 @@ func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, sna snapshotSchedule := schedules[0] snapshotSchedule.CronExpression = "@every " + snapshotInterval - err := handler.JobScheduler.UpdateSchedule(&snapshotSchedule, nil) + err := handler.JobScheduler.UpdateSystemJobSchedule(portainer.SnapshotJobType, snapshotSchedule.CronExpression) + if err != nil { + return err + } + + err = handler.ScheduleService.UpdateSchedule(snapshotSchedule.ID, &snapshotSchedule) if err != nil { return err } diff --git a/api/portainer.go b/api/portainer.go index 30ac5983d..6682017b7 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -228,7 +228,6 @@ type ( // ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container ScriptExecutionJob struct { - ScheduleID ScheduleID `json:"ScheduleId"` Endpoints []EndpointID Image string ScriptPath string @@ -237,14 +236,10 @@ type ( } // SnapshotJob represents a scheduled job that can create endpoint snapshots - SnapshotJob struct { - ScheduleID ScheduleID `json:"ScheduleId"` - } + SnapshotJob struct{} // EndpointSyncJob represents a scheduled job that synchronize endpoints based on an external file - EndpointSyncJob struct { - ScheduleID ScheduleID `json:"ScheduleId"` - } + EndpointSyncJob struct{} // Schedule represents a scheduled job. // It only contains a pointer to one of the JobRunner implementations @@ -668,18 +663,17 @@ type ( // JobScheduler represents a service to run jobs on a periodic basis JobScheduler interface { - CreateSchedule(schedule *Schedule, runner JobRunner) error - UpdateSchedule(schedule *Schedule, runner JobRunner) error - RemoveSchedule(ID ScheduleID) + ScheduleJob(runner JobRunner) error + UpdateJobSchedule(runner JobRunner) error + UpdateSystemJobSchedule(jobType JobType, newCronExpression string) error + UnscheduleJob(ID ScheduleID) Start() } // JobRunner represents a service that can be used to run a job JobRunner interface { Run() - GetScheduleID() ScheduleID - SetScheduleID(ID ScheduleID) - GetJobType() JobType + GetSchedule() *Schedule } // Snapshotter represents a service used to create endpoint snapshots @@ -710,7 +704,7 @@ type ( // JobService represents a service to manage job execution on hosts JobService interface { - Execute(endpoint *Endpoint, nodeName, image string, script []byte) error + ExecuteScript(endpoint *Endpoint, nodeName, image string, script []byte, schedule *Schedule) error } ) diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html index 38f58e6a9..e3fe91b87 100644 --- a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html @@ -71,7 +71,7 @@ - {{ item.Id | truncate: 32}} + {{ item | containername }} +
+
+ + +
+
+ + {{ $ctrl.titleText }} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Endpoint + + + + Id + + + + + + State + + + + + + + + Created + +
+ {{ item.Endpoint.Name }} + + {{ item.Id | truncate: 32 }} + + {{ item.Status }} + + {{ item.Created | getisodatefromtimestamp}} +
Loading...
No tasks available.
+
+ +
+
+
+
+
diff --git a/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js new file mode 100644 index 000000000..cdd7344c6 --- /dev/null +++ b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.docker').component('scheduleTasksDatatable', { + templateUrl: 'app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + goToContainerLogs: '<' + } +}); diff --git a/app/portainer/models/schedule.js b/app/portainer/models/schedule.js index f48fd42bc..874ebc8d5 100644 --- a/app/portainer/models/schedule.js +++ b/app/portainer/models/schedule.js @@ -33,6 +33,13 @@ function ScriptExecutionJobModel(data) { this.RetryInterval = data.RetryInterval; } +function ScriptExecutionTaskModel(data) { + this.Id = data.Id; + this.EndpointId = data.EndpointId; + this.Status = createStatus(data.Status); + this.Created = data.Created; +} + function ScheduleCreateRequest(model) { this.Name = model.Name; this.CronExpression = model.CronExpression; diff --git a/app/portainer/rest/schedule.js b/app/portainer/rest/schedule.js index df57ef68e..eae5a218f 100644 --- a/app/portainer/rest/schedule.js +++ b/app/portainer/rest/schedule.js @@ -8,6 +8,7 @@ function SchedulesFactory($resource, API_ENDPOINT_SCHEDULES) { get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, remove: { method: 'DELETE', params: { id: '@id'} }, - file: { method: 'GET', params: { id : '@id', action: 'file' } } + file: { method: 'GET', params: { id : '@id', action: 'file' } }, + tasks: { method: 'GET', isArray: true, params: { id : '@id', action: 'tasks' } } }); }]); diff --git a/app/portainer/services/api/scheduleService.js b/app/portainer/services/api/scheduleService.js index e1698b9c1..3f5d81de2 100644 --- a/app/portainer/services/api/scheduleService.js +++ b/app/portainer/services/api/scheduleService.js @@ -36,6 +36,23 @@ function ScheduleService($q, Schedules, FileUploadService) { return deferred.promise; }; + service.scriptExecutionTasks = function(scheduleId) { + var deferred = $q.defer(); + + Schedules.tasks({ id: scheduleId }).$promise + .then(function success(data) { + var tasks = data.map(function (item) { + return new ScriptExecutionTaskModel(item); + }); + deferred.resolve(tasks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve tasks associated to the schedule', err: err }); + }); + + return deferred.promise; + }; + service.createScheduleFromFileContent = function(model) { var payload = new ScheduleCreateRequest(model); return Schedules.create({ method: 'string' }, payload).$promise; diff --git a/app/portainer/views/schedules/edit/schedule.html b/app/portainer/views/schedules/edit/schedule.html index 654ac8d50..fb7aa5625 100644 --- a/app/portainer/views/schedules/edit/schedule.html +++ b/app/portainer/views/schedules/edit/schedule.html @@ -13,15 +13,53 @@
- + + + + + Configuration + + + + + + + + + Tasks + + +
+ Information +
+
+ + Tasks are retrieved across all endpoints via snapshots. Data available in this view might not be up-to-date. + +
+ +
+ Tasks +
+ +
+ +
diff --git a/app/portainer/views/schedules/edit/scheduleController.js b/app/portainer/views/schedules/edit/scheduleController.js index ce05d528d..06313f3ab 100644 --- a/app/portainer/views/schedules/edit/scheduleController.js +++ b/app/portainer/views/schedules/edit/scheduleController.js @@ -1,12 +1,13 @@ angular.module('portainer.app') -.controller('ScheduleController', ['$q', '$scope', '$transition$', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService', -function ($q, $scope, $transition$, $state, Notifications, EndpointService, GroupService, ScheduleService) { +.controller('ScheduleController', ['$q', '$scope', '$transition$', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService', 'EndpointProvider', +function ($q, $scope, $transition$, $state, Notifications, EndpointService, GroupService, ScheduleService, EndpointProvider) { $scope.state = { actionInProgress: false }; $scope.update = update; + $scope.goToContainerLogs = goToContainerLogs; function update() { var model = $scope.schedule; @@ -25,25 +26,48 @@ function ($q, $scope, $transition$, $state, Notifications, EndpointService, Grou }); } + function goToContainerLogs(endpointId, containerId) { + EndpointProvider.setEndpointID(endpointId); + $state.go('docker.containers.container.logs', { id: containerId }); + } + + function associateEndpointsToTasks(tasks, endpoints) { + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + + for (var j = 0; j < endpoints.length; j++) { + var endpoint = endpoints[j]; + + if (task.EndpointId === endpoint.Id) { + task.Endpoint = endpoint; + break; + } + } + } + } + function initView() { var id = $transition$.params().id; - var schedule = null; $q.all({ schedule: ScheduleService.schedule(id), + file: ScheduleService.getScriptFile(id), + tasks: ScheduleService.scriptExecutionTasks(id), endpoints: EndpointService.endpoints(), groups: GroupService.groups() }) .then(function success(data) { - schedule = data.schedule; + var schedule = data.schedule; + schedule.Job.FileContent = data.file.ScheduleFileContent; + + var endpoints = data.endpoints; + var tasks = data.tasks; + associateEndpointsToTasks(tasks, endpoints); + + $scope.schedule = schedule; + $scope.tasks = data.tasks; $scope.endpoints = data.endpoints; $scope.groups = data.groups; - - return ScheduleService.getScriptFile(schedule.Id); - }) - .then(function success(data) { - schedule.Job.FileContent = data.ScheduleFileContent; - $scope.schedule = schedule; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoint list'); From 381ab81fddc79caa12b50409fe670c4f2db3d7a0 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 13 Nov 2018 15:18:38 +1300 Subject: [PATCH 52/93] fix(endpoints): ensure endpoint is up to date after snapshot (#2460) * feat(snapshots): fix a potential concurrency issue with endpoint snapshots * fix(endpoints): ensure endpoint is up to date after snapshot --- api/cron/job_snapshot.go | 23 ++++++++++++------ .../handler/endpoints/endpoint_snapshot.go | 24 +++++++++++++------ 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/api/cron/job_snapshot.go b/api/cron/job_snapshot.go index 153d3c5b7..aaeff3212 100644 --- a/api/cron/job_snapshot.go +++ b/api/cron/job_snapshot.go @@ -42,6 +42,8 @@ func (runner *SnapshotJobRunner) GetSchedule() *portainer.Schedule { // Run triggers the execution of the schedule. // It will iterate through all the endpoints available in the database to // create a snapshot of each one of them. +// As a snapshot can be a long process, to avoid any concurrency issue we +// retrieve the latest version of the endpoint right after a snapshot. func (runner *SnapshotJobRunner) Run() { endpoints, err := runner.context.endpointService.Endpoints() if err != nil { @@ -54,18 +56,25 @@ func (runner *SnapshotJobRunner) Run() { continue } - snapshot, err := runner.context.snapshotter.CreateSnapshot(&endpoint) - endpoint.Status = portainer.EndpointStatusUp - if err != nil { - log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) - endpoint.Status = portainer.EndpointStatusDown + snapshot, snapshotError := runner.context.snapshotter.CreateSnapshot(&endpoint) + + latestEndpointReference, err := runner.context.endpointService.Endpoint(endpoint.ID) + if latestEndpointReference == nil { + log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + continue + } + + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) + latestEndpointReference.Status = portainer.EndpointStatusDown } if snapshot != nil { - endpoint.Snapshots = []portainer.Snapshot{*snapshot} + latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} } - err = runner.context.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = runner.context.endpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { log.Printf("background schedule error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) return diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index 720f4c072..53e4c3339 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -3,6 +3,7 @@ package endpoints import ( "log" "net/http" + "time" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" @@ -21,18 +22,27 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) continue } - snapshot, err := handler.Snapshotter.CreateSnapshot(&endpoint) - endpoint.Status = portainer.EndpointStatusUp - if err != nil { - log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) - endpoint.Status = portainer.EndpointStatusDown + time.Sleep(10 * time.Second) + + snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint) + + latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) + if latestEndpointReference == nil { + log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + continue + } + + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) + latestEndpointReference.Status = portainer.EndpointStatusDown } if snapshot != nil { - endpoint.Snapshots = []portainer.Snapshot{*snapshot} + latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} } - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } From cf370f6a4c6481313ec02477977770669294e4d5 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 13 Nov 2018 15:19:29 +1300 Subject: [PATCH 53/93] refactor(endpoints): remove time.Sleep call --- api/http/handler/endpoints/endpoint_snapshot.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index 53e4c3339..0a457a383 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -3,7 +3,6 @@ package endpoints import ( "log" "net/http" - "time" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" @@ -22,8 +21,6 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) continue } - time.Sleep(10 * time.Second) - snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint) latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) From 0825d0554675480c651670f402c8489809e23aaf Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 13 Nov 2018 16:02:49 +1300 Subject: [PATCH 54/93] feat(endpoints): improve offline banner UX (#2462) * feat(endpoints): add the last snapshot timestamp in offline banner * feat(endpoints): add the ability to refresh a snapshot in the offline banner --- .../handler/endpoints/endpoint_snapshot.go | 55 ++++++++++--------- .../handler/endpoints/endpoint_snapshots.go | 49 +++++++++++++++++ api/http/handler/endpoints/handler.go | 4 +- .../informationPanelOffline.html | 9 ++- .../informationPanelOffline.js | 2 +- .../informationPanelOfflineController.js | 34 ++++++++++++ app/portainer/rest/endpoint.js | 3 +- app/portainer/services/api/endpointService.js | 8 ++- app/portainer/views/home/homeController.js | 2 +- 9 files changed, 133 insertions(+), 33 deletions(-) create mode 100644 api/http/handler/endpoints/endpoint_snapshots.go create mode 100644 app/portainer/components/information-panel-offline/informationPanelOfflineController.js diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index 0a457a383..559cf127c 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -1,48 +1,51 @@ package endpoints import ( - "log" "net/http" httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer" ) -// POST request on /api/endpoints/snapshot +// POST request on /api/endpoints/:id/snapshot func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - endpoints, err := handler.EndpointService.Endpoints() + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - for _, endpoint := range endpoints { - if endpoint.Type == portainer.AzureEnvironment { - continue - } + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } - snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint) + if endpoint.Type == portainer.AzureEnvironment { + return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for Azure endpoints", err} + } - latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) - if latestEndpointReference == nil { - log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) - continue - } + snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(endpoint) - latestEndpointReference.Status = portainer.EndpointStatusUp - if snapshotError != nil { - log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) - latestEndpointReference.Status = portainer.EndpointStatusDown - } + latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) + if latestEndpointReference == nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } - if snapshot != nil { - latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} - } + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + latestEndpointReference.Status = portainer.EndpointStatusDown + } - err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} - } + if snapshot != nil { + latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} + } + + err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } return response.Empty(w) diff --git a/api/http/handler/endpoints/endpoint_snapshots.go b/api/http/handler/endpoints/endpoint_snapshots.go new file mode 100644 index 000000000..f59b7a40d --- /dev/null +++ b/api/http/handler/endpoints/endpoint_snapshots.go @@ -0,0 +1,49 @@ +package endpoints + +import ( + "log" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +// POST request on /api/endpoints/snapshot +func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + for _, endpoint := range endpoints { + if endpoint.Type == portainer.AzureEnvironment { + continue + } + + snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint) + + latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) + if latestEndpointReference == nil { + log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + continue + } + + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) + latestEndpointReference.Status = portainer.EndpointStatusDown + } + + if snapshot != nil { + latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} + } + + err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 1ef8d1727..d8a94d360 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -45,7 +45,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo h.Handle("/endpoints", bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost) h.Handle("/endpoints/snapshot", - bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost) h.Handle("/endpoints", bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet) h.Handle("/endpoints/{id}", @@ -62,5 +62,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) h.Handle("/endpoints/{id}/job", bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost) + h.Handle("/endpoints/{id}/snapshot", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) return h } diff --git a/app/portainer/components/information-panel-offline/informationPanelOffline.html b/app/portainer/components/information-panel-offline/informationPanelOffline.html index a7c51a3e5..17942ee5a 100644 --- a/app/portainer/components/information-panel-offline/informationPanelOffline.html +++ b/app/portainer/components/information-panel-offline/informationPanelOffline.html @@ -4,5 +4,12 @@ This endpoint is currently offline (read-only). Data shown is based on the latest available snapshot.

+

+ + Last snapshot: {{ $ctrl.snapshotTime | getisodatefromtimestamp }} +

+ - \ No newline at end of file + diff --git a/app/portainer/components/information-panel-offline/informationPanelOffline.js b/app/portainer/components/information-panel-offline/informationPanelOffline.js index c3f6e9517..e788dfe29 100644 --- a/app/portainer/components/information-panel-offline/informationPanelOffline.js +++ b/app/portainer/components/information-panel-offline/informationPanelOffline.js @@ -1,4 +1,4 @@ angular.module('portainer.app').component('informationPanelOffline', { templateUrl: 'app/portainer/components/information-panel-offline/informationPanelOffline.html', - transclude: true + controller: 'InformationPanelOfflineController' }); diff --git a/app/portainer/components/information-panel-offline/informationPanelOfflineController.js b/app/portainer/components/information-panel-offline/informationPanelOfflineController.js new file mode 100644 index 000000000..efe75545e --- /dev/null +++ b/app/portainer/components/information-panel-offline/informationPanelOfflineController.js @@ -0,0 +1,34 @@ +angular.module('portainer.app').controller('InformationPanelOfflineController', ['$state', 'EndpointProvider', 'EndpointService', 'Authentication', 'Notifications', +function StackDuplicationFormController($state, EndpointProvider, EndpointService, Authentication, Notifications) { + var ctrl = this; + + this.$onInit = onInit; + this.triggerSnapshot = triggerSnapshot; + + function triggerSnapshot() { + var endpointId = EndpointProvider.endpointID(); + + EndpointService.snapshotEndpoint(endpointId) + .then(function onSuccess() { + $state.reload(); + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'An error occured during endpoint snapshot'); + }); + } + + function onInit() { + var endpointId = EndpointProvider.endpointID(); + ctrl.showRefreshButton = Authentication.getUserDetails().role === 1; + + + EndpointService.endpoint(endpointId) + .then(function onSuccess(data) { + ctrl.snapshotTime = data.Snapshots[0].Time; + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); + }); + } + +}]); diff --git a/app/portainer/rest/endpoint.js b/app/portainer/rest/endpoint.js index 7b50372e9..068a8ef66 100644 --- a/app/portainer/rest/endpoint.js +++ b/app/portainer/rest/endpoint.js @@ -7,7 +7,8 @@ angular.module('portainer.app') update: { method: 'PUT', params: { id: '@id' } }, updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, remove: { method: 'DELETE', params: { id: '@id'} }, - snapshot: { method: 'POST', params: { id: 'snapshot' }}, + snapshots: { method: 'POST', params: { action: 'snapshot' }}, + snapshot: { method: 'POST', params: { id: '@id', action: 'snapshot' }}, executeJob: { method: 'POST', ignoreLoadingBar: true, params: { id: '@id', action: 'job' } } }); }]); diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index fa17052e5..f7098ae51 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -12,8 +12,12 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return Endpoints.query({}).$promise; }; - service.snapshot = function() { - return Endpoints.snapshot({}, {}).$promise; + service.snapshotEndpoints = function() { + return Endpoints.snapshots({}, {}).$promise; + }; + + service.snapshotEndpoint = function(endpointID) { + return Endpoints.snapshot({ id: endpointID }, {}).$promise; }; service.endpointsByGroup = function(groupId) { diff --git a/app/portainer/views/home/homeController.js b/app/portainer/views/home/homeController.js index 083c75625..b422a7e86 100644 --- a/app/portainer/views/home/homeController.js +++ b/app/portainer/views/home/homeController.js @@ -101,7 +101,7 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G } function triggerSnapshot() { - EndpointService.snapshot() + EndpointService.snapshotEndpoints() .then(function success() { Notifications.success('Success', 'Endpoints updated'); $state.reload(); From d455ab3fc74ffe287a2fca824209964de0a0c842 Mon Sep 17 00:00:00 2001 From: baron_l Date: Tue, 13 Nov 2018 04:08:12 +0100 Subject: [PATCH 55/93] feat(endpoints): enhance offline browsing (#2454) * feat(api): rewrite error response when trying to query a down endpoint * feat(interceptors): adding custom backend return code on offline fastfail --- api/http/handler/endpointproxy/proxy_docker.go | 5 +++++ app/docker/interceptors/containersInterceptor.js | 2 +- app/docker/interceptors/imagesInterceptor.js | 2 +- app/docker/interceptors/infoInterceptor.js | 2 +- app/docker/interceptors/networksInterceptor.js | 2 +- app/docker/interceptors/versionInterceptor.js | 2 +- app/docker/interceptors/volumesInterceptor.js | 2 +- app/portainer/interceptors/endpointStatusInterceptor.js | 2 +- 8 files changed, 12 insertions(+), 7 deletions(-) diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go index f03ca8e67..9ca932a97 100644 --- a/api/http/handler/endpointproxy/proxy_docker.go +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -1,6 +1,7 @@ package endpointproxy import ( + "errors" "strconv" httperror "github.com/portainer/libhttp/error" @@ -23,6 +24,10 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } + if endpoint.Status == portainer.EndpointStatusDown { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to query endpoint", errors.New("Endpoint is down")} + } + err = handler.requestBouncer.EndpointAccess(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} diff --git a/app/docker/interceptors/containersInterceptor.js b/app/docker/interceptors/containersInterceptor.js index f717ea211..066ffe4fe 100644 --- a/app/docker/interceptors/containersInterceptor.js +++ b/app/docker/interceptors/containersInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Containers; diff --git a/app/docker/interceptors/imagesInterceptor.js b/app/docker/interceptors/imagesInterceptor.js index bdfcdd6de..9d79fbc86 100644 --- a/app/docker/interceptors/imagesInterceptor.js +++ b/app/docker/interceptors/imagesInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Images; diff --git a/app/docker/interceptors/infoInterceptor.js b/app/docker/interceptors/infoInterceptor.js index 716d4fbfd..17f310641 100644 --- a/app/docker/interceptors/infoInterceptor.js +++ b/app/docker/interceptors/infoInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Info; diff --git a/app/docker/interceptors/networksInterceptor.js b/app/docker/interceptors/networksInterceptor.js index 85bce4ba2..b5068534c 100644 --- a/app/docker/interceptors/networksInterceptor.js +++ b/app/docker/interceptors/networksInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Networks; diff --git a/app/docker/interceptors/versionInterceptor.js b/app/docker/interceptors/versionInterceptor.js index 4b6ed5776..95a5d56c4 100644 --- a/app/docker/interceptors/versionInterceptor.js +++ b/app/docker/interceptors/versionInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Version; diff --git a/app/docker/interceptors/volumesInterceptor.js b/app/docker/interceptors/volumesInterceptor.js index 268f6a4fd..a42addd2c 100644 --- a/app/docker/interceptors/volumesInterceptor.js +++ b/app/docker/interceptors/volumesInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Volumes; diff --git a/app/portainer/interceptors/endpointStatusInterceptor.js b/app/portainer/interceptors/endpointStatusInterceptor.js index 46ca7a42b..9411f8d62 100644 --- a/app/portainer/interceptors/endpointStatusInterceptor.js +++ b/app/portainer/interceptors/endpointStatusInterceptor.js @@ -30,7 +30,7 @@ angular.module('portainer.app') function responseErrorInterceptor(rejection) { var EndpointService = $injector.get('EndpointService'); var url = rejection.config.url; - if ((rejection.status === 502 || rejection.status === -1) && canBeOffline(url) && !EndpointProvider.offlineMode()) { + if ((rejection.status === 502 || rejection.status === 503 || rejection.status === -1) && canBeOffline(url) && !EndpointProvider.offlineMode()) { EndpointProvider.setOfflineMode(true); EndpointService.updateEndpoint(EndpointProvider.endpointID(), {Status: EndpointProvider.endpointStatusFromOfflineMode(true)}); } From 40e0c3879cd3c9fa54f9329e4ad253ea8e30034d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christer=20War=C3=A9n?= Date: Tue, 13 Nov 2018 23:01:36 +0200 Subject: [PATCH 56/93] style(dashboard): change blocklist-item border color (#2465) Changing blocklist-item border color to more confortable color that makes UI look more consistence --- assets/css/app.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 2be345ca2..0ab4b52cd 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -168,13 +168,13 @@ a[ng-click]{ padding: 0.7rem; margin-bottom: 0.7rem; cursor: pointer; - border: 1px solid #333333; + border: 1px solid #cccccc; border-radius: 2px; box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); } .blocklist-item--selected { - border: 2px solid #333333; + border: 2px solid #bbbbbb; background-color: #ececec; color: #2d3e63; } From 94d3d7bde252693e5c1f84c5339aef0bc3db48b6 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 14 Nov 2018 12:20:33 +1300 Subject: [PATCH 57/93] feat(motd): relocate motd file URL and always return 200 (#2466) --- api/http/handler/motd/motd.go | 2 +- api/portainer.go | 2 +- app/portainer/views/home/home.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/http/handler/motd/motd.go b/api/http/handler/motd/motd.go index ca279d890..53246fec1 100644 --- a/api/http/handler/motd/motd.go +++ b/api/http/handler/motd/motd.go @@ -18,7 +18,7 @@ func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) { motd, err := client.Get(portainer.MessageOfTheDayURL, 0) if err != nil { - w.WriteHeader(http.StatusInternalServerError) + response.JSON(w, &motdResponse{Message: ""}) return } diff --git a/api/portainer.go b/api/portainer.go index 6682017b7..f75574723 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -714,7 +714,7 @@ const ( // DBVersion is the version number of the Portainer database DBVersion = 14 // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved - MessageOfTheDayURL = "https://raw.githubusercontent.com/portainer/motd/master/message.html" + MessageOfTheDayURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/motd.html" // PortainerAgentHeader represents the name of the header available in any agent response PortainerAgentHeader = "Portainer-Agent" // PortainerAgentTargetHeader represent the name of the header containing the target node name diff --git a/app/portainer/views/home/home.html b/app/portainer/views/home/home.html index 1ffaa88bd..86767459e 100644 --- a/app/portainer/views/home/home.html +++ b/app/portainer/views/home/home.html @@ -8,7 +8,7 @@ From 0ef25a4cbda2118b069505d0b1b0dfdab25e01bd Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 14 Nov 2018 16:10:49 +1300 Subject: [PATCH 58/93] fix(schedules): add schedule name validation and remove endpoint name prefix (#2470) --- api/docker/job.go | 2 +- api/http/handler/schedules/schedule_create.go | 12 ++++++++++-- api/http/handler/schedules/schedule_update.go | 5 +++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/api/docker/job.go b/api/docker/job.go index ea343e91c..7765f5f2e 100644 --- a/api/docker/job.go +++ b/api/docker/job.go @@ -83,7 +83,7 @@ func (service *JobService) ExecuteScript(endpoint *portainer.Endpoint, nodeName, } if schedule != nil { - err = cli.ContainerRename(context.Background(), body.ID, endpoint.Name+"_"+schedule.Name+"_"+body.ID) + err = cli.ContainerRename(context.Background(), body.ID, schedule.Name+"_"+body.ID) if err != nil { return err } diff --git a/api/http/handler/schedules/schedule_create.go b/api/http/handler/schedules/schedule_create.go index d09f94860..4dca4c3f3 100644 --- a/api/http/handler/schedules/schedule_create.go +++ b/api/http/handler/schedules/schedule_create.go @@ -37,13 +37,17 @@ type scheduleCreateFromFileContentPayload struct { func (payload *scheduleCreateFromFilePayload) Validate(r *http.Request) error { name, err := request.RetrieveMultiPartFormValue(r, "Name", false) if err != nil { - return errors.New("Invalid name") + return errors.New("Invalid schedule name") + } + + if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { + return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]") } payload.Name = name image, err := request.RetrieveMultiPartFormValue(r, "Image", false) if err != nil { - return errors.New("Invalid image") + return errors.New("Invalid schedule image") } payload.Image = image @@ -80,6 +84,10 @@ func (payload *scheduleCreateFromFileContentPayload) Validate(r *http.Request) e return portainer.Error("Invalid schedule name") } + if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { + return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]") + } + if govalidator.IsNull(payload.Image) { return portainer.Error("Invalid schedule image") } diff --git a/api/http/handler/schedules/schedule_update.go b/api/http/handler/schedules/schedule_update.go index 12284181c..ed680dfa1 100644 --- a/api/http/handler/schedules/schedule_update.go +++ b/api/http/handler/schedules/schedule_update.go @@ -1,9 +1,11 @@ package schedules import ( + "errors" "net/http" "strconv" + "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -22,6 +24,9 @@ type scheduleUpdatePayload struct { } func (payload *scheduleUpdatePayload) Validate(r *http.Request) error { + if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { + return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]") + } return nil } From 488dc5f9db7feaf52c81a94545c16810dd64a362 Mon Sep 17 00:00:00 2001 From: baron_l Date: Fri, 16 Nov 2018 01:26:56 +0100 Subject: [PATCH 59/93] fix(network-creation): macvlan availability for standalone endpoints (#2441) --- .../views/networks/create/createNetworkController.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/docker/views/networks/create/createNetworkController.js b/app/docker/views/networks/create/createNetworkController.js index 9f5dd6493..a2893f353 100644 --- a/app/docker/views/networks/create/createNetworkController.js +++ b/app/docker/views/networks/create/createNetworkController.js @@ -105,7 +105,11 @@ angular.module('portainer.docker') config.ConfigFrom = { Network: selectedNetworkConfig.Name }; - config.Scope = 'swarm'; + if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { + config.Scope = 'swarm'; + } else { + config.Scope = 'local'; + } } function validateForm(accessControlData, isAdmin) { @@ -192,9 +196,6 @@ angular.module('portainer.docker') PluginService.networkPlugins(apiVersion < 1.25) .then(function success(data) { - if ($scope.applicationState.endpoint.mode.provider !== 'DOCKER_SWARM_MODE') { - data.splice(data.indexOf('macvlan'), 1); - } $scope.availableNetworkDrivers = data; }) .catch(function error(err) { From fe8dfee69a611256193576b94ff896f2905a3e50 Mon Sep 17 00:00:00 2001 From: baron_l Date: Mon, 19 Nov 2018 07:07:38 +0100 Subject: [PATCH 60/93] feat(home): display each endpoint URL (#2471) --- .../components/endpoint-list/endpoint-item/endpointItem.html | 3 +++ .../components/endpoint-list/endpoint-list-controller.js | 1 + app/portainer/components/endpoint-list/endpointList.html | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html index 6e643da5b..f9c720cf6 100644 --- a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html +++ b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html @@ -82,6 +82,9 @@ + + {{ $ctrl.model.URL | stripprotocol }} +
diff --git a/app/portainer/components/endpoint-list/endpoint-list-controller.js b/app/portainer/components/endpoint-list/endpoint-list-controller.js index aab801410..bffbacc1d 100644 --- a/app/portainer/components/endpoint-list/endpoint-list-controller.js +++ b/app/portainer/components/endpoint-list/endpoint-list-controller.js @@ -44,6 +44,7 @@ angular.module('portainer.app').controller('EndpointListController', [ return ( _.includes(endpoint.Name.toLowerCase(), lowerCaseKeyword) || _.includes(endpoint.GroupName.toLowerCase(), lowerCaseKeyword) || + _.includes(endpoint.URL.toLowerCase(), lowerCaseKeyword) || _.some(endpoint.Tags, function(tag) { return _.includes(tag.toLowerCase(), lowerCaseKeyword); }) || diff --git a/app/portainer/components/endpoint-list/endpointList.html b/app/portainer/components/endpoint-list/endpointList.html index 2886916f1..9deecf8ca 100644 --- a/app/portainer/components/endpoint-list/endpointList.html +++ b/app/portainer/components/endpoint-list/endpointList.html @@ -21,7 +21,7 @@ class="searchInput" ng-model="$ctrl.state.textFilter" ng-change="$ctrl.onFilterChanged()" - placeholder="Search by name, group, tag, status..." auto-focus> + placeholder="Search by name, group, tag, status, URL..." auto-focus>
From d03fd5805a56949992f6d9af5de5c69a9952cec5 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 23 Nov 2018 11:46:51 +1300 Subject: [PATCH 61/93] feat(api): support AGENT_SECRET environment variable (#2486) --- api/cmd/portainer/main.go | 3 ++- api/crypto/ecdsa.go | 23 +++++++++++++++++--- api/docker/client.go | 2 +- api/exec/swarm_stack.go | 2 +- api/http/handler/websocket/websocket_exec.go | 3 ++- api/http/proxy/docker_transport.go | 2 +- api/portainer.go | 2 +- 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 0e0c189ac..f7890c2c4 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -2,6 +2,7 @@ package main // import "github.com/portainer/portainer" import ( "encoding/json" + "os" "strings" "time" @@ -88,7 +89,7 @@ func initJWTService(authenticationEnabled bool) portainer.JWTService { } func initDigitalSignatureService() portainer.DigitalSignatureService { - return &crypto.ECDSAService{} + return crypto.NewECDSAService(os.Getenv("AGENT_SECRET")) } func initCryptoService() portainer.CryptoService { diff --git a/api/crypto/ecdsa.go b/api/crypto/ecdsa.go index 003547531..f2b87d639 100644 --- a/api/crypto/ecdsa.go +++ b/api/crypto/ecdsa.go @@ -8,6 +8,8 @@ import ( "encoding/base64" "encoding/hex" "math/big" + + "github.com/portainer/portainer" ) const ( @@ -26,6 +28,15 @@ type ECDSAService struct { privateKey *ecdsa.PrivateKey publicKey *ecdsa.PublicKey encodedPubKey string + secret string +} + +// NewECDSAService returns a pointer to a ECDSAService. +// An optional secret can be specified +func NewECDSAService(secret string) *ECDSAService { + return &ECDSAService{ + secret: secret, + } } // EncodedPublicKey returns the encoded version of the public that can be used @@ -91,11 +102,17 @@ func (service *ECDSAService) GenerateKeyPair() ([]byte, []byte, error) { return private, public, nil } -// Sign creates a signature from a message. -// It automatically hash the message using MD5 and creates a signature from +// CreateSignature creates a digital signature. +// It automatically hash a specific message using MD5 and creates a signature from // that hash. // It then encodes the generated signature in base64. -func (service *ECDSAService) Sign(message string) (string, error) { +func (service *ECDSAService) CreateSignature() (string, error) { + + message := portainer.PortainerAgentSignatureMessage + if service.secret != "" { + message = service.secret + } + hash := HashFromBytes([]byte(message)) r := big.NewInt(0) diff --git a/api/docker/client.go b/api/docker/client.go index 9c608a19b..913e5dd87 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -67,7 +67,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer. return nil, err } - signature, err := signatureService.Sign(portainer.PortainerAgentSignatureMessage) + signature, err := signatureService.CreateSignature() if err != nil { return nil, err } diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index aa32bfe54..f41a581db 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -140,7 +140,7 @@ func (manager *SwarmStackManager) updateDockerCLIConfiguration(dataPath string) return err } - signature, err := manager.signatureService.Sign(portainer.PortainerAgentSignatureMessage) + signature, err := manager.signatureService.CreateSignature() if err != nil { return err } diff --git a/api/http/handler/websocket/websocket_exec.go b/api/http/handler/websocket/websocket_exec.go index d797e020a..6f2d07d22 100644 --- a/api/http/handler/websocket/websocket_exec.go +++ b/api/http/handler/websocket/websocket_exec.go @@ -111,12 +111,13 @@ func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Req } } - signature, err := handler.SignatureService.Sign(portainer.PortainerAgentSignatureMessage) + signature, err := handler.SignatureService.CreateSignature() if err != nil { return err } proxy.Director = func(incoming *http.Request, out http.Header) { + out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey()) out.Set(portainer.PortainerAgentSignatureHeader, signature) out.Set(portainer.PortainerAgentTargetHeader, params.nodeName) } diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index 37e46c2dd..2476685cc 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -64,7 +64,7 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon request.URL.Path = path if p.enableSignature { - signature, err := p.SignatureService.Sign(portainer.PortainerAgentSignatureMessage) + signature, err := p.SignatureService.CreateSignature() if err != nil { return nil, err } diff --git a/api/portainer.go b/api/portainer.go index f75574723..ee2d6687f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -626,7 +626,7 @@ type ( GenerateKeyPair() ([]byte, []byte, error) EncodedPublicKey() string PEMHeaders() (string, string) - Sign(message string) (string, error) + CreateSignature() (string, error) } // JWTService represents a service for managing JWT tokens From 5e49f934b9176577de30bd66df9a4c43e86cf797 Mon Sep 17 00:00:00 2001 From: baron_l Date: Fri, 23 Nov 2018 09:44:34 +0100 Subject: [PATCH 62/93] fix(containers-stats): accessing a down container stats wont display a js error anymore (#2484) --- app/docker/models/container.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/docker/models/container.js b/app/docker/models/container.js index a28e978cb..6666f1e03 100644 --- a/app/docker/models/container.js +++ b/app/docker/models/container.js @@ -71,8 +71,12 @@ function ContainerStatsViewModel(data) { this.NumProcs = data.num_procs; this.isWindows = true; } else { // Linux - this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache; - this.MemoryCache = data.memory_stats.stats.cache; + if (data.memory_stats.stats === undefined || data.memory_stats.usage === undefined) { + this.MemoryUsage = this.MemoryCache = 0; + } else { + this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache; + this.MemoryCache = data.memory_stats.stats.cache; + } } this.PreviousCPUTotalUsage = data.precpu_stats.cpu_usage.total_usage; this.PreviousCPUSystemUsage = data.precpu_stats.system_cpu_usage; From 17d63ae3caf4344cf32a8124ad189f37f5dca60b Mon Sep 17 00:00:00 2001 From: Olli Janatuinen Date: Fri, 23 Nov 2018 11:00:30 +0200 Subject: [PATCH 63/93] chore(dependencies): updated xterm to 3.8.0 version (#2452) --- package.json | 3 +-- yarn.lock | 7 ++++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 6d7486278..71aa19cc9 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "clean-all": "yarn grunt clean:all", "build": "yarn grunt build", "build-offline": "cd ./api/cmd/portainer && CGO_ENABLED=0 go build -a --installsuffix cgo --ldflags '-s' && mv -b portainer ../../../dist/portainer-linux-amd64" - }, "engines": { "node": ">= 0.8.4" @@ -65,7 +64,7 @@ "splitargs": "github:deviantony/splitargs#~0.2.0", "toastr": "github:CodeSeven/toastr#~2.1.3", "ui-select": "^0.19.8", - "xterm": "^3.1.0" + "xterm": "^3.8.0" }, "devDependencies": { "autoprefixer": "^7.1.1", diff --git a/yarn.lock b/yarn.lock index 275854f70..7592562cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4506,9 +4506,10 @@ xtend@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xtend/-/xtend-3.0.0.tgz#5cce7407baf642cba7becda568111c493f59665a" -xterm@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.1.0.tgz#7f7e1c8cf4b80bd881a4e8891213b851423e90c9" +xterm@^3.8.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.8.0.tgz#55d1de518bdc9c9793823f5e4e97d6898972938d" + integrity sha512-rS3HLryuMWbLsv98+jVVSUXCxmoyXPwqwJNC0ad0VSMdXgl65LefPztQVwfurkaF7kM7ZSgM8eJjnJ9kkdoR1w== yargs@~3.10.0: version "3.10.0" From d510bbbcfd470651cb62256dc547211f5b73b6fc Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sat, 24 Nov 2018 08:40:56 +1300 Subject: [PATCH 64/93] feat(api): filter LDAP password from settings response (#2488) --- api/http/handler/settings/handler.go | 4 ++++ api/http/handler/settings/settings_inspect.go | 1 + api/portainer.go | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index d20c388d7..0acbd2ca6 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -9,6 +9,10 @@ import ( "github.com/portainer/portainer/http/security" ) +func hideFields(settings *portainer.Settings) { + settings.LDAPSettings.Password = "" +} + // Handler is the HTTP handler used to handle settings operations. type Handler struct { *mux.Router diff --git a/api/http/handler/settings/settings_inspect.go b/api/http/handler/settings/settings_inspect.go index c922e1f47..a28b1ef09 100644 --- a/api/http/handler/settings/settings_inspect.go +++ b/api/http/handler/settings/settings_inspect.go @@ -14,5 +14,6 @@ func (handler *Handler) settingsInspect(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} } + hideFields(settings) return response.JSON(w, settings) } diff --git a/api/portainer.go b/api/portainer.go index ee2d6687f..634d8635c 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -47,7 +47,7 @@ type ( // LDAPSettings represents the settings used to connect to a LDAP server LDAPSettings struct { ReaderDN string `json:"ReaderDN"` - Password string `json:"Password"` + Password string `json:"Password,omitempty"` URL string `json:"URL"` TLSConfig TLSConfiguration `json:"TLSConfig"` StartTLS bool `json:"StartTLS"` From 52788029ede8de40c2313bffefc2839c3a7198f9 Mon Sep 17 00:00:00 2001 From: baron_l Date: Fri, 23 Nov 2018 23:11:58 +0100 Subject: [PATCH 65/93] feat(container-details): add visual feedback when creating image from container (#2487) --- app/docker/views/containers/edit/containerController.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index f687b116c..63902e77e 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -154,6 +154,7 @@ function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, Co Commit.commitContainer({id: $transition$.params().id, tag: imageConfig.tag, repo: imageConfig.repo}, function () { update(); Notifications.success('Container commited', $transition$.params().id); + $scope.config.Image = ''; }, function (e) { update(); Notifications.error('Failure', e, 'Unable to commit container'); From b809177147f29ba987164479fa962832ac91e24f Mon Sep 17 00:00:00 2001 From: Andreas Roussos Date: Sat, 24 Nov 2018 22:46:13 +0200 Subject: [PATCH 66/93] feat(dashboard): use plural form only when required * fix(endpoint-item): use plural form only when required * refactor(endpoint-item): use clearer patterns * refactor(dashboard): use clearer patterns --- app/docker/views/dashboard/dashboard.html | 12 ++++++------ .../endpoint-list/endpoint-item/endpointItem.html | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/docker/views/dashboard/dashboard.html b/app/docker/views/dashboard/dashboard.html index 9d2751010..89cee5867 100644 --- a/app/docker/views/dashboard/dashboard.html +++ b/app/docker/views/dashboard/dashboard.html @@ -84,7 +84,7 @@
{{ stackCount }}
-
Stacks
+
{{ stackCount === 1 ? 'Stack' : 'Stacks' }}
@@ -97,7 +97,7 @@
{{ serviceCount }}
-
Services
+
{{ serviceCount === 1 ? 'Service' : 'Services' }}
@@ -114,7 +114,7 @@
{{ containers | stoppedcontainers }} stopped
{{ containers.length }}
-
Containers
+
{{ containers.length === 1 ? 'Container' : 'Containers' }}
@@ -130,7 +130,7 @@
{{ images | imagestotalsize | humansize }}
{{ images.length }}
-
Images
+
{{ images.length === 1 ? 'Image' : 'Images' }}
@@ -143,7 +143,7 @@
{{ volumeCount }}
-
Volumes
+
{{ volumeCount === 1 ? 'Volume' : 'Volumes' }}
@@ -156,7 +156,7 @@
{{ networkCount }}
-
Networks
+
{{ networkCount === 1 ? 'Network' : 'Networks' }}
diff --git a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html index f9c720cf6..a2f96a392 100644 --- a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html +++ b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html @@ -38,13 +38,13 @@ - {{ $ctrl.model.Snapshots[0].StackCount }} stacks + {{ $ctrl.model.Snapshots[0].StackCount }} {{ $ctrl.model.Snapshots[0].StackCount === 1 ? 'stack' : 'stacks' }} - {{ $ctrl.model.Snapshots[0].ServiceCount }} services + {{ $ctrl.model.Snapshots[0].ServiceCount }} {{ $ctrl.model.Snapshots[0].ServiceCount === 1 ? 'service' : 'services' }} - {{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount }} containers + {{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount }} {{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount === 1 ? 'container' : 'containers' }} - {{ $ctrl.model.Snapshots[0].RunningContainerCount }} @@ -52,10 +52,10 @@ - {{ $ctrl.model.Snapshots[0].VolumeCount }} volumes + {{ $ctrl.model.Snapshots[0].VolumeCount }} {{ $ctrl.model.Snapshots[0].VolumeCount === 1 ? 'volume' : 'volumes' }} - {{ $ctrl.model.Snapshots[0].ImageCount }} images + {{ $ctrl.model.Snapshots[0].ImageCount }} {{ $ctrl.model.Snapshots[0].ImageCount === 1 ? 'image' : 'images' }} From 34b886d69097ba671e789d209b008df776ddcbb6 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 26 Nov 2018 23:05:13 +0200 Subject: [PATCH 67/93] chore(build-system): add start and start:server scripts (#2495) --- package.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 71aa19cc9..e5ca71a22 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,11 @@ ], "scripts": { "grunt": "grunt", - "dev": "yarn grunt run-dev", - "clean-all": "yarn grunt clean:all", - "build": "yarn grunt build", - "build-offline": "cd ./api/cmd/portainer && CGO_ENABLED=0 go build -a --installsuffix cgo --ldflags '-s' && mv -b portainer ../../../dist/portainer-linux-amd64" + "start": "grunt run-dev", + "start:server": "yarn build:server:offline && grunt shell:run:amd64", + "clean:all": "grunt clean:all", + "build": "grunt build", + "build:server:offline": "cd ./api/cmd/portainer && CGO_ENABLED=0 go build -a --installsuffix cgo --ldflags '-s' && mv -f portainer ../../../dist/portainer-linux-amd64" }, "engines": { "node": ">= 0.8.4" From c778e7900491332a3549c35f7dc672b6b54617e8 Mon Sep 17 00:00:00 2001 From: baron_l Date: Wed, 28 Nov 2018 18:57:36 +0100 Subject: [PATCH 68/93] fix(container-console): close the console when selected shell does not exist inside the container (#2502) --- .../views/containers/console/containerConsoleController.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/docker/views/containers/console/containerConsoleController.js b/app/docker/views/containers/console/containerConsoleController.js index 812e5b567..9501075f9 100644 --- a/app/docker/views/containers/console/containerConsoleController.js +++ b/app/docker/views/containers/console/containerConsoleController.js @@ -52,6 +52,7 @@ function ($scope, $transition$, ContainerService, ImageService, EndpointProvider }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to exec into container'); + $scope.disconnect(); }); }; From 969f70edeb39d8a52f3a9e44984105436596a8b9 Mon Sep 17 00:00:00 2001 From: baron_l Date: Wed, 28 Nov 2018 19:00:58 +0100 Subject: [PATCH 69/93] fix(image-upload): uploading a tar with multiple images wont display an error anymore (#2503) --- app/portainer/services/fileUpload.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index cd75e7bbb..d00271dcb 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -35,7 +35,8 @@ angular.module('portainer.app') 'Content-Type': file.type }, data: file, - ignoreLoadingBar: true + ignoreLoadingBar: true, + transformResponse: genericHandler }); }; From dc9a878f4bce8df45bab2f9832249d687c8b7e07 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 3 Dec 2018 12:10:55 +1300 Subject: [PATCH 70/93] chore(docker): update docker binary version to 18.09.0 (#2510) --- gruntfile.js | 91 ++++++++++++++++++++++++++-------------------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/gruntfile.js b/gruntfile.js index 873d55473..a73549c7f 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -2,9 +2,9 @@ var gruntfile_cfg = {}; var loadGruntTasks = require('load-grunt-tasks'); var os = require('os'); var arch = os.arch(); -if ( arch === 'x64' ) arch = 'amd64'; +if (arch === 'x64') arch = 'amd64'; -module.exports = function (grunt) { +module.exports = function(grunt) { loadGruntTasks(grunt, { pattern: ['grunt-*', 'gruntify-*'] @@ -48,46 +48,46 @@ module.exports = function (grunt) { 'after-copy' ]); grunt.task.registerTask('release', 'release::', function(p, a) { - grunt.task.run(['config:prod', 'clean:all', 'shell:buildBinary:'+p+':'+a, 'shell:downloadDockerBinary:'+p+':'+a, 'before-copy', 'copy:assets', 'after-copy' ]); + grunt.task.run(['config:prod', 'clean:all', 'shell:buildBinary:' + p + ':' + a, 'shell:downloadDockerBinary:' + p + ':' + a, 'before-copy', 'copy:assets', 'after-copy']); }); grunt.registerTask('lint', ['eslint']); - grunt.registerTask('run-dev', ['build', 'shell:run:'+arch, 'watch:build']); + grunt.registerTask('run-dev', ['build', 'shell:run:' + arch, 'watch:build']); grunt.registerTask('clear', ['clean:app']); // Load content of `vendor.yml` to src.jsVendor, src.cssVendor and src.angularVendor grunt.registerTask('vendor', function() { - var vendorFile = grunt.file.readYAML('vendor.yml'); - for (var filelist in vendorFile) { - if (vendorFile.hasOwnProperty(filelist)) { - var list = vendorFile[filelist]; - // Check if any of the files is missing - for (var itemIndex in list) { - if (list.hasOwnProperty(itemIndex)) { - var item = 'node_modules/'+list[itemIndex]; - if (!grunt.file.exists(item)) { - grunt.fail.warn('Dependency file ' + item + ' not found.'); - } - list[itemIndex] = item; - } - } - // If none is missing, save the list - grunt.config('src.' + filelist + 'Vendor', list); + var vendorFile = grunt.file.readYAML('vendor.yml'); + for (var filelist in vendorFile) { + if (vendorFile.hasOwnProperty(filelist)) { + var list = vendorFile[filelist]; + // Check if any of the files is missing + for (var itemIndex in list) { + if (list.hasOwnProperty(itemIndex)) { + var item = 'node_modules/' + list[itemIndex]; + if (!grunt.file.exists(item)) { + grunt.fail.warn('Dependency file ' + item + ' not found.'); + } + list[itemIndex] = item; } + } + // If none is missing, save the list + grunt.config('src.' + filelist + 'Vendor', list); } + } }); // Project configuration. grunt.initConfig({ root: 'dist', distdir: 'dist/public', - shippedDockerVersion: '18.06.1-ce', + shippedDockerVersion: '18.09.0', shippedDockerVersionWindows: '17.09.0-ce', pkg: grunt.file.readJSON('package.json'), config: gruntfile_cfg.config, src: gruntfile_cfg.src, clean: gruntfile_cfg.clean, useminPrepare: gruntfile_cfg.useminPrepare, - filerev: { files: { src: ['<%= distdir %>/js/*.js', '<%= distdir %>/css/*.css'] }}, + filerev: { files: { src: ['<%= distdir %>/js/*.js', '<%= distdir %>/css/*.css'] } }, usemin: { html: ['<%= distdir %>/index.html'] }, copy: gruntfile_cfg.copy, eslint: gruntfile_cfg.eslint, @@ -108,8 +108,8 @@ var autoprefixer = require('autoprefixer'); var cssnano = require('cssnano'); gruntfile_cfg.config = { - dev: { options: { variables: { 'environment': 'development' }}}, - prod: { options: { variables: { 'environment': 'production' }}} + dev: { options: { variables: { 'environment': 'development' } } }, + prod: { options: { variables: { 'environment': 'production' } } } }; gruntfile_cfg.src = { @@ -152,18 +152,18 @@ gruntfile_cfg.useminPrepare = { gruntfile_cfg.copy = { bundle: { files: [ - {dest:'<%= distdir %>/js/', src: ['app.js'], expand: true, cwd: '.tmp/concat/js/' }, - {dest:'<%= distdir %>/css/', src: ['app.css'], expand: true, cwd: '.tmp/concat/css/' } + { dest: '<%= distdir %>/js/', src: ['app.js'], expand: true, cwd: '.tmp/concat/js/' }, + { dest: '<%= distdir %>/css/', src: ['app.css'], expand: true, cwd: '.tmp/concat/css/' } ] }, assets: { files: [ - {dest: '<%= distdir %>/fonts/', src: '*.{ttf,woff,woff2,eof,svg}', expand: true, cwd: 'node_modules/bootstrap/fonts/'}, - {dest: '<%= distdir %>/fonts/', src: '*.{ttf,woff,woff2,eof,eot,svg}', expand: true, cwd: 'node_modules/@fortawesome/fontawesome-free-webfonts/webfonts/'}, - {dest: '<%= distdir %>/fonts/', src: '*.{ttf,woff,woff2,eof,svg}', expand: true, cwd: 'node_modules/rdash-ui/dist/fonts/'}, - {dest: '<%= distdir %>/images/', src: '**', expand: true, cwd: 'assets/images/'}, - {dest: '<%= distdir %>/ico', src: '**', expand: true, cwd: 'assets/ico'}, - {dest: '<%= root %>/', src: 'templates.json', cwd: ''} + { dest: '<%= distdir %>/fonts/', src: '*.{ttf,woff,woff2,eof,svg}', expand: true, cwd: 'node_modules/bootstrap/fonts/' }, + { dest: '<%= distdir %>/fonts/', src: '*.{ttf,woff,woff2,eof,eot,svg}', expand: true, cwd: 'node_modules/@fortawesome/fontawesome-free-webfonts/webfonts/' }, + { dest: '<%= distdir %>/fonts/', src: '*.{ttf,woff,woff2,eof,svg}', expand: true, cwd: 'node_modules/rdash-ui/dist/fonts/' }, + { dest: '<%= distdir %>/images/', src: '**', expand: true, cwd: 'assets/images/' }, + { dest: '<%= distdir %>/ico', src: '**', expand: true, cwd: 'assets/ico' }, + { dest: '<%= root %>/', src: 'templates.json', cwd: '' } ] } }; @@ -205,8 +205,9 @@ gruntfile_cfg.uglify = { }, vendor: { options: { preserveComments: 'some' }, // Preserve license comments - files: { '<%= distdir %>/js/vendor.js': ['<%= src.jsVendor %>'] , - '<%= distdir %>/js/angular.js': ['<%= src.angularVendor %>'] + files: { + '<%= distdir %>/js/vendor.js': ['<%= src.jsVendor %>'], + '<%= distdir %>/js/angular.js': ['<%= src.angularVendor %>'] } } }; @@ -215,7 +216,7 @@ gruntfile_cfg.postcss = { build: { options: { processors: [ - autoprefixer({browsers: 'last 2 versions'}), // add vendor prefixes + autoprefixer({ browsers: 'last 2 versions' }), // add vendor prefixes cssnano() // minify the result ] }, @@ -235,9 +236,9 @@ gruntfile_cfg.replace = { concat: { options: { patterns: [ - { match: 'ENVIRONMENT', replacement: '<%= grunt.config.get("environment") %>' }, + { match: 'ENVIRONMENT', replacement: '<%= grunt.config.get("environment") %>' }, { match: 'CONFIG_GA_ID', replacement: '<%= pkg.config.GA_ID %>' }, - { match: /..\/webfonts\//g, replacement: '../fonts/'} + { match: /..\/webfonts\//g, replacement: '../fonts/' } ] }, files: [ @@ -258,12 +259,12 @@ gruntfile_cfg.replace = { }; function shell_buildBinary(p, a) { - var binfile = 'dist/portainer-'+p+'-'+a; + var binfile = 'dist/portainer-' + p + '-' + a; return [ - 'if [ -f '+(( p === 'windows' ) ? binfile+'.exe' : binfile)+' ]; then', - 'echo "Portainer binary exists";', + 'if [ -f ' + ((p === 'windows') ? binfile + '.exe' : binfile) + ' ]; then', + 'echo "Portainer binary exists";', 'else', - 'build/build_in_container.sh ' + p + ' ' + a + ';', + 'build/build_in_container.sh ' + p + ' ' + a + ';', 'fi' ].join(' '); } @@ -280,12 +281,12 @@ function shell_downloadDockerBinary(p, a) { var as = { 'amd64': 'x86_64', 'arm': 'armhf', 'arm64': 'aarch64' }; var ip = ((ps[p] === undefined) ? p : ps[p]); var ia = ((as[a] === undefined) ? a : as[a]); - var binaryVersion = (( p === 'windows' ? '<%= shippedDockerVersionWindows %>' : '<%= shippedDockerVersion %>' )); + var binaryVersion = ((p === 'windows' ? '<%= shippedDockerVersionWindows %>' : '<%= shippedDockerVersion %>')); return [ - 'if [ -f '+(( p === 'windows' ) ? 'dist/docker.exe' : 'dist/docker')+' ]; then', - 'echo "Docker binary exists";', + 'if [ -f ' + ((p === 'windows') ? 'dist/docker.exe' : 'dist/docker') + ' ]; then', + 'echo "Docker binary exists";', 'else', - 'build/download_docker_binary.sh ' + ip + ' ' + ia + ' ' + binaryVersion + ';', + 'build/download_docker_binary.sh ' + ip + ' ' + ia + ' ' + binaryVersion + ';', 'fi' ].join(' '); } From 5fa4403d204a9a47d5199f99649140b76ef7b324 Mon Sep 17 00:00:00 2001 From: linquize Date: Mon, 3 Dec 2018 16:49:02 +0800 Subject: [PATCH 71/93] feat(container-stats): add refresh rate of 1 and 3 seconds (#2493) --- app/docker/views/containers/stats/containerstats.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/docker/views/containers/stats/containerstats.html b/app/docker/views/containers/stats/containerstats.html index 79adc3029..13fe72145 100644 --- a/app/docker/views/containers/stats/containerstats.html +++ b/app/docker/views/containers/stats/containerstats.html @@ -26,6 +26,8 @@
+ +
+
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index e1b7156e2..81ed2588e 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -12,7 +12,8 @@ function ($scope, $state, Notifications, SettingsService, StateManager) { restrictBindMounts: false, restrictPrivilegedMode: false, labelName: '', - labelValue: '' + labelValue: '', + enableHostManagementFeatures: false }; $scope.removeFilteredContainerLabel = function(index) { @@ -46,6 +47,7 @@ function ($scope, $state, Notifications, SettingsService, StateManager) { settings.AllowBindMountsForRegularUsers = !$scope.formValues.restrictBindMounts; settings.AllowPrivilegedModeForRegularUsers = !$scope.formValues.restrictPrivilegedMode; + settings.EnableHostManagementFeatures = $scope.formValues.enableHostManagementFeatures; $scope.state.actionInProgress = true; updateSettings(settings); @@ -57,6 +59,7 @@ function ($scope, $state, Notifications, SettingsService, StateManager) { Notifications.success('Settings updated'); StateManager.updateLogo(settings.LogoURL); StateManager.updateSnapshotInterval(settings.SnapshotInterval); + StateManager.updateEnableHostManagementFeatures(settings.EnableHostManagementFeatures); $state.reload(); }) .catch(function error(err) { @@ -80,6 +83,7 @@ function ($scope, $state, Notifications, SettingsService, StateManager) { } $scope.formValues.restrictBindMounts = !settings.AllowBindMountsForRegularUsers; $scope.formValues.restrictPrivilegedMode = !settings.AllowPrivilegedModeForRegularUsers; + $scope.formValues.enableHostManagementFeatures = settings.EnableHostManagementFeatures; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve application settings'); diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index 031cfe91e..a78c4f986 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -36,10 +36,10 @@ Profiles
- - +