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
pull/2253/head
baron_l 2018-10-28 07:06:50 +01:00 committed by Anthony Lapenna
parent 6ab510e5cb
commit 354fda31f1
37 changed files with 739 additions and 100 deletions

View File

@ -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),

View File

@ -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
}

View File

@ -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
}

View File

@ -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}
}

View File

@ -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}
}

View File

@ -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
}
)

View File

@ -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."

View File

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

View File

@ -0,0 +1,118 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ $ctrl.titleText }}
</div>
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-primary" ng-click="$ctrl.purgeAction()">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Clear job history
</button>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover table-filters nowrap-cells">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Id')">
Id
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open">
<a ng-click="$ctrl.changeOrderBy('Status')">
State
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && $ctrl.state.reverseOrder"></i>
</a>
<div>
<span uib-dropdown-toggle class="table-filter" ng-if="!$ctrl.filters.state.enabled"> Filter
<i class="fa fa-filter" aria-hidden="true"></i></span>
<span uib-dropdown-toggle class="table-filter filter-active" ng-if="$ctrl.filters.state.enabled">Filter
<i class="fa fa-check" aria-hidden="true"></i></span>
</div>
<div class="dropdown-menu" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Filter by state
</div>
<div class="menuContent">
<div class="md-checkbox" ng-repeat="filter in $ctrl.filters.state.values track by $index">
<input id="filter_state_{{ $index }}" type="checkbox" ng-model="filter.display" ng-change="$ctrl.onStateFilterChange()" />
<label for="filter_state_{{ $index }}">{{ filter.label }}</label>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.filters.state.open = false;">Close</a>
</div>
</div>
</div>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Created')">
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i>
Created
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))">
<td>
<a ui-sref="docker.containers.container.logs({ id: item.Id, nodeName: item.NodeName })" title="{{ item.Id }}">
{{ item.Id | truncate: 32}}</a>
</td>
<td>
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) !== -1" class="label label-{{ item.Status|containerstatusbadge }} interactive"
uib-tooltip="This container has a health check">{{ item.Status }}</span>
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) === -1" class="label label-{{ item.Status|containerstatusbadge }}">
{{ item.Status }}</span>
</td>
<td>
{{item.Created | getisodatefromtimestamp}}
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="9" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="9" class="text-center text-muted">No jobs available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>

View File

@ -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: '<'
}
});

View File

@ -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;
}
}
]);

View File

@ -8,14 +8,25 @@
<rd-header-content>Docker</rd-header-content>
</rd-header>
<host-details-panel
host="$ctrl.hostDetails"
<host-details-panel
host="$ctrl.hostDetails"
is-browse-enabled="$ctrl.isAgent && $ctrl.agentApiVersion > 1"
browse-url="{{$ctrl.browseUrl}}"></host-details-panel>
browse-url="{{$ctrl.browseUrl}}"
is-job-enabled="$ctrl.isJobEnabled"
job-url="{{$ctrl.jobUrl}}"
></host-details-panel>
<engine-details-panel engine="$ctrl.engineDetails"></engine-details-panel>
<jobs-datatable
ng-if="$ctrl.isJobEnabled && $ctrl.jobs"
title-text="Jobs" title-icon="fa-tasks"
dataset="$ctrl.jobs"
table-key="jobs"
order-by="Created" reverse-order="true"
></jobs-datatable>
<devices-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1" devices="$ctrl.devices"></devices-panel>
<disks-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1" disks="$ctrl.disks"></disks-panel>
<ng-transclude></ng-transclude>
<ng-transclude></ng-transclude>

View File

@ -8,7 +8,10 @@ angular.module('portainer.docker').component('hostOverview', {
isAgent: '<',
agentApiVersion: '<',
refreshUrl: '@',
browseUrl: '@'
browseUrl: '@',
jobUrl: '@',
isJobEnabled: '<',
jobs: '<'
},
transclude: true
});

View File

@ -26,20 +26,19 @@
<td>Total memory</td>
<td>{{ $ctrl.host.totalMemory | humansize }}</td>
</tr>
<tr ng-if="$ctrl.isBrowseEnabled">
<tr ng-if="$ctrl.isBrowseEnabled || $ctrl.isJobEnabled">
<td colspan="2">
<button
class="btn btn-primary btn-sm"
title="Browse"
ui-sref="{{$ctrl.browseUrl}}">
<button class="btn btn-primary btn-sm" title="Browse" ng-if="$ctrl.isBrowseEnabled" ui-sref="{{$ctrl.browseUrl}}">
Browse
</button>
<button class="btn btn-primary btn-sm" title="Execute job" ng-if="$ctrl.isJobEnabled" ui-sref="{{$ctrl.jobUrl}}">
Execute job
</button>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>

View File

@ -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: '@'
}
});

View File

@ -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' }
}
});
}]);

View File

@ -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;
}]);

View File

@ -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);
});
}
]);
}
]);

View File

@ -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);
});
}
}
]);

View File

@ -0,0 +1,16 @@
<rd-header>
<rd-header-title title-text="Host job execution"></rd-header-title>
<rd-header-content>
Host &gt; <a ui-sref="docker.host">{{ $ctrl.host.Name }}</a> &gt; execute job
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<execute-job-form></execute-job-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,4 @@
angular.module('portainer.docker').component('hostJobView', {
templateUrl: 'app/docker/views/host/host-job/host-job.html',
controller: 'HostJobController'
});

View File

@ -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) {

View File

@ -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"
></host-overview>
is-job-enabled="$ctrl.state.isAdmin"
job-url="docker.host.job"
jobs="$ctrl.jobs"
></host-overview>

View File

@ -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);
});
}
}

View File

@ -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;
});
}
}
]);

View File

@ -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"
>
<swarm-node-details-panel
details="$ctrl.nodeDetails"
original-node="$ctrl.originalNode"
></swarm-node-details-panel>
</host-overview>
</host-overview>

View File

@ -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);
});
}
}
]);

View File

@ -0,0 +1,18 @@
<rd-header>
<rd-header-title title-text="Host job execution"></rd-header-title>
<rd-header-content>
<a ui-sref="docker.swarm">Swarm</a> &gt; <a ui-sref="docker.nodes.node({ id: $ctrl.nodeId })">{{ $ctrl.node.Hostname }}</a> &gt; execute job
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<execute-job-form
node-name="$ctrl.node.Hostname"
></execute-job-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,4 @@
angular.module('portainer.docker').component('nodeJobView', {
templateUrl: 'app/docker/views/nodes/node-job/node-job.html',
controller: 'NodeJobController'
});

View File

@ -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;
});
}
}]);

View File

@ -0,0 +1,110 @@
<form class="form-horizontal" name="executeJobForm">
<!-- image-input -->
<div class="form-group">
<label for="job_image" class="col-sm-1 control-label text-left">Image</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="$ctrl.formValues.Image" id="job_image" name="job_image" placeholder="e.g. ubuntu:latest" required auto-focus>
</div>
</div>
<div class="form-group" ng-show="executeJobForm.job_image.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="executeJobForm.job_image.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- !image-input -->
<div class="form-group">
<span class="col-sm-12 text-muted small">
This job will run inside a privileged container on the host. You can access the host filesystem under the
<code>/host</code> folder.
</span>
</div>
<!-- execution-method -->
<div class="col-sm-12 form-section-title">
Job creation
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="$ctrl.state.Method" value="editor">
<label for="method_editor">
<div class="boxselector_header">
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="$ctrl.state.Method" value="upload">
<label for="method_upload">
<div class="boxselector_header">
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
Upload
</div>
<p>Upload from your computer</p>
</label>
</div>
</div>
</div>
<!-- !execution-method -->
<!-- web-editor -->
<div ng-show="$ctrl.state.Method === 'editor'">
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
identifier="execute-job-editor"
placeholder="# Define or paste the content of your script file here"
on-change="$ctrl.editorUpdate">
</code-editor>
</div>
</div>
</div>
<!-- !web-editor -->
<!-- upload -->
<div ng-show="$ctrl.state.Method === 'upload'">
<div class="col-sm-12 form-section-title">
Upload
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can upload a script file from your computer.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<button class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formValues.JobFile">Select file</button>
<span style="margin-left: 5px;">
{{ $ctrl.formValues.JobFile.name }}
<i class="fa fa-times red-icon" ng-if="!$ctrl.formValues.JobFile" aria-hidden="true"></i>
</span>
</div>
</div>
</div>
<!-- !upload -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm"
ng-disabled="$ctrl.state.actionInProgress || !executeJobForm.$valid
|| ($ctrl.state.Method === 'upload' && !$ctrl.formValues.JobFile)"
ng-click="$ctrl.executeJob()"
button-spinner="$ctrl.state.actionInProgress">
<span ng-hide="$ctrl.state.actionInProgress">Execute</span>
<span ng-show="$ctrl.state.actionInProgress">Starting job...</span>
</button>
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
{{ $ctrl.state.formValidationError }}
</span>
</div>
</div>
<!-- !actions -->
</form>

View File

@ -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: '<'
}
});

View File

@ -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' } }
});
}]);

View File

@ -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;
}]);

View File

@ -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',

View File

@ -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();
}

View File

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