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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
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;