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;