1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-08 23:35:31 +02:00

feat(stacks): support compose v2.0 stack (#1963)

This commit is contained in:
Anthony Lapenna 2018-06-11 15:13:19 +02:00 committed by GitHub
parent ef15cd30eb
commit e3d564325b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
174 changed files with 7898 additions and 5849 deletions

View file

@ -253,6 +253,39 @@ angular.module('portainer.app', [])
}
};
var stacks = {
name: 'portainer.stacks',
url: '/stacks',
views: {
'content@': {
templateUrl: 'app/portainer/views/stacks/stacks.html',
controller: 'StacksController'
}
}
};
var stack = {
name: 'portainer.stacks.stack',
url: '/:name?id&type&external',
views: {
'content@': {
templateUrl: 'app/portainer/views/stacks/edit/stack.html',
controller: 'StackController'
}
}
};
var stackCreation = {
name: 'portainer.stacks.new',
url: '/new',
views: {
'content@': {
templateUrl: 'app/portainer/views/stacks/create/createstack.html',
controller: 'CreateStackController'
}
}
};
var support = {
name: 'portainer.support',
url: '/support',
@ -329,6 +362,9 @@ angular.module('portainer.app', [])
$stateRegistryProvider.register(registryCreation);
$stateRegistryProvider.register(settings);
$stateRegistryProvider.register(settingsAuthentication);
$stateRegistryProvider.register(stacks);
$stateRegistryProvider.register(stack);
$stateRegistryProvider.register(stackCreation);
$stateRegistryProvider.register(support);
$stateRegistryProvider.register(users);
$stateRegistryProvider.register(user);

View file

@ -16,7 +16,7 @@
ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.stacks.new">
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.stacks.new">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add stack
</button>
</div>
@ -39,6 +39,14 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Type')">
Type
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Type' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Type' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Control</th>
<th ng-if="$ctrl.showOwnershipColumn">
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
Ownership
@ -49,16 +57,20 @@
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-if="$ctrl.displayExternalStacks || (!$ctrl.displayExternalStacks && !item.External)" ng-class="{active: item.Checked}">
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td>
<span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)" ng-disabled="!item.Id"/>
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)" ng-disabled="item.External && item.Type === 2"/>
<label for="select_{{ $index }}"></label>
</span>
<a ng-if="item.Id" ui-sref="docker.stacks.stack({ id: item.Id })">{{ item.Name }}</a>
<span ng-if="!item.Id">
{{ item.Name }} <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true"></i>
<a ui-sref="portainer.stacks.stack({ name: item.Name, id: item.Id, type: item.Type, external: item.External })">{{ item.Name }}</a>
</td>
<td>{{ item.Type === 1 ? 'Swarm' : 'Compose' }}</td>
<td>
<span ng-if="item.External" class="interactive" tooltip-append-to-body="true" tooltip-placement="bottom" tooltip-class="portainer-tooltip" uib-tooltip="This stack was created outside of Portainer. Control over this stack is limited.">
Limited <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-left: 2px;"></i>
</span>
<span ng-if="!item.External">Total</span>
</td>
<td ng-if="$ctrl.showOwnershipColumn">
<span>
@ -68,10 +80,10 @@
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="2" class="text-center text-muted">Loading...</td>
<td colspan="4" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="2" class="text-center text-muted">No stack available.</td>
<td colspan="4" class="text-center text-muted">No stack available.</td>
</tr>
</tbody>
</table>

View file

@ -10,7 +10,6 @@ angular.module('portainer.app').component('stacksDatatable', {
reverseOrder: '<',
showTextFilter: '<',
showOwnershipColumn: '<',
removeAction: '<',
displayExternalStacks: '<'
removeAction: '<'
}
});

View file

@ -30,7 +30,7 @@ function (PaginationService, DatatableService) {
this.selectAll = function() {
for (var i = 0; i < this.state.filteredDataSet.length; i++) {
var item = this.state.filteredDataSet[i];
if (item.Id && item.Checked !== this.state.selectAll) {
if (!(item.External && item.Type === 2) && item.Checked !== this.state.selectAll) {
item.Checked = this.state.selectAll;
this.selectItem(item);
}

View file

@ -3,6 +3,19 @@ angular.module('portainer.app')
'use strict';
var helper = {};
helper.getExternalStackNamesFromContainers = function(containers) {
var stackNames = [];
for (var i = 0; i < containers.length; i++) {
var container = containers[i];
if (!container.Labels || !container.Labels['com.docker.compose.project']) continue;
var stackName = container.Labels['com.docker.compose.project'];
stackNames.push(stackName);
}
return _.uniq(stackNames);
};
helper.getExternalStackNamesFromServices = function(services) {
var stackNames = [];
@ -16,6 +29,6 @@ angular.module('portainer.app')
return _.uniq(stackNames);
};
return helper;
}]);

View file

@ -0,0 +1,18 @@
function StackViewModel(data) {
this.Id = data.Id;
this.Type = data.Type;
this.Name = data.Name;
this.Checked = false;
this.Env = data.Env ? data.Env : [];
if (data.ResourceControl && data.ResourceControl.Id !== 0) {
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
}
this.External = false;
}
function ExternalStackViewModel(name, type) {
this.Name = name;
this.Type = type;
this.External = true;
this.Checked = false;
}

View file

@ -1,15 +1,13 @@
angular.module('portainer.app')
.factory('Stack', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function StackFactory($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) {
.factory('Stack', ['$resource', 'EndpointProvider', 'API_ENDPOINT_STACKS', function StackFactory($resource, EndpointProvider, API_ENDPOINT_STACKS) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/stacks/:id/:action', {
endpointId: EndpointProvider.endpointID
},
return $resource(API_ENDPOINT_STACKS + '/:id/:action', {},
{
get: { method: 'GET', params: { id: '@id' } },
query: { method: 'GET', isArray: true },
create: { method: 'POST', ignoreLoadingBar: true },
update: { method: 'PUT', params: { id: '@id' }, ignoreLoadingBar: true },
remove: { method: 'DELETE', params: { id: '@id'} },
getStackFile: { method: 'GET', params: { id : '@id', action: 'stackfile' } }
remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } },
getStackFile: { method: 'GET', params: { id : '@id', action: 'file' } }
});
}]);

View file

@ -0,0 +1,276 @@
angular.module('portainer.app')
.factory('StackService', ['$q', 'Stack', 'ResourceControlService', 'FileUploadService', 'StackHelper', 'ServiceService', 'ContainerService', 'SwarmService',
function StackServiceFactory($q, Stack, ResourceControlService, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService) {
'use strict';
var service = {};
service.stack = function(id) {
var deferred = $q.defer();
Stack.get({ id: id }).$promise
.then(function success(data) {
var stack = new StackViewModel(data);
deferred.resolve(stack);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve stack details', err: err });
});
return deferred.promise;
};
service.getStackFile = function(id) {
var deferred = $q.defer();
Stack.getStackFile({ id: id }).$promise
.then(function success(data) {
deferred.resolve(data.StackFileContent);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve stack content', err: err });
});
return deferred.promise;
};
service.stacks = function(compose, swarm, endpointId) {
var deferred = $q.defer();
var queries = [];
if (compose) {
queries.push(service.composeStacks(true, { EndpointID: endpointId }));
}
if (swarm) {
queries.push(service.swarmStacks(true));
}
$q.all(queries)
.then(function success(data) {
var stacks = [];
if (data[0]) {
stacks = stacks.concat(data[0]);
}
if (data[1]) {
stacks = stacks.concat(data[1]);
}
deferred.resolve(stacks);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve stacks', err: err });
});
return deferred.promise;
};
service.externalSwarmStacks = function() {
var deferred = $q.defer();
ServiceService.services()
.then(function success(data) {
var services = data;
var stackNames = StackHelper.getExternalStackNamesFromServices(services);
var stacks = stackNames.map(function (name) {
return new ExternalStackViewModel(name, 1);
});
deferred.resolve(stacks);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve external stacks', err: err });
});
return deferred.promise;
};
service.externalComposeStacks = function() {
var deferred = $q.defer();
ContainerService.containers(1)
.then(function success(data) {
var containers = data;
var stackNames = StackHelper.getExternalStackNamesFromContainers(containers);
var stacks = stackNames.map(function (name) {
return new ExternalStackViewModel(name, 2);
});
deferred.resolve(stacks);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve external stacks', err: err });
});
return deferred.promise;
};
service.composeStacks = function(includeExternalStacks, filters) {
var deferred = $q.defer();
$q.all({
stacks: Stack.query({filters: filters}).$promise,
externalStacks: includeExternalStacks ? service.externalComposeStacks() : []
})
.then(function success(data) {
var stacks = data.stacks.map(function (item) {
item.External = false;
return new StackViewModel(item);
});
var externalStacks = data.externalStacks;
var result = _.unionWith(stacks, externalStacks, function(a, b) { return a.Name === b.Name; });
deferred.resolve(result);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve stacks', err: err });
});
return deferred.promise;
};
service.swarmStacks = function(includeExternalStacks) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
var filters = { SwarmID: swarm.Id };
return $q.all({
stacks: Stack.query({ filters: filters }).$promise,
externalStacks: includeExternalStacks ? service.externalSwarmStacks() : []
});
})
.then(function success(data) {
var stacks = data.stacks.map(function (item) {
item.External = false;
return new StackViewModel(item);
});
var externalStacks = data.externalStacks;
var result = _.unionWith(stacks, externalStacks, function(a, b) { return a.Name === b.Name; });
deferred.resolve(result);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve stacks', err: err });
});
return deferred.promise;
};
service.remove = function(stack, external, endpointId) {
var deferred = $q.defer();
Stack.remove({ id: stack.Id ? stack.Id : stack.Name, external: external, endpointId: endpointId }).$promise
.then(function success(data) {
if (stack.ResourceControl && stack.ResourceControl.Id) {
return ResourceControlService.deleteResourceControl(stack.ResourceControl.Id);
}
})
.then(function success() {
deferred.resolve();
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to remove the stack', err: err });
});
return deferred.promise;
};
service.updateStack = function(id, stackFile, env, prune) {
return Stack.update({ id: id, StackFileContent: stackFile, Env: env, Prune: prune}).$promise;
};
service.createComposeStackFromFileUpload = function(name, stackFile, endpointId) {
return FileUploadService.createComposeStack(name, stackFile, endpointId);
};
service.createSwarmStackFromFileUpload = function(name, stackFile, env, endpointId) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return FileUploadService.createSwarmStack(name, swarm.Id, stackFile, env, endpointId);
})
.then(function success(data) {
deferred.resolve(data.data);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to create the stack', err: err });
});
return deferred.promise;
};
service.createComposeStackFromFileContent = function(name, stackFileContent, endpointId) {
var payload = {
Name: name,
StackFileContent: stackFileContent
};
return Stack.create({ method: 'string', type: 2, endpointId: endpointId }, payload).$promise;
};
service.createSwarmStackFromFileContent = function(name, stackFileContent, env, endpointId) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
var payload = {
Name: name,
SwarmID: swarm.Id,
StackFileContent: stackFileContent,
Env: env
};
return Stack.create({ method: 'string', type: 1, endpointId: endpointId }, payload).$promise;
})
.then(function success(data) {
deferred.resolve(data);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to create the stack', err: err });
});
return deferred.promise;
};
service.createComposeStackFromGitRepository = function(name, repositoryOptions, endpointId) {
var payload = {
Name: name,
RepositoryURL: repositoryOptions.RepositoryURL,
ComposeFilePathInRepository: repositoryOptions.ComposeFilePathInRepository,
RepositoryAuthentication: repositoryOptions.RepositoryAuthentication,
RepositoryUsername: repositoryOptions.RepositoryUsername,
RepositoryPassword: repositoryOptions.RepositoryPassword
};
return Stack.create({ method: 'repository', type: 2, endpointId: endpointId }, payload).$promise;
};
service.createSwarmStackFromGitRepository = function(name, repositoryOptions, env, endpointId) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
var payload = {
Name: name,
SwarmID: swarm.Id,
RepositoryURL: repositoryOptions.RepositoryURL,
ComposeFilePathInRepository: repositoryOptions.ComposeFilePathInRepository,
RepositoryAuthentication: repositoryOptions.RepositoryAuthentication,
RepositoryUsername: repositoryOptions.RepositoryUsername,
RepositoryPassword: repositoryOptions.RepositoryPassword,
Env: env
};
return Stack.create({ method: 'repository', type: 1, endpointId: endpointId }, payload).$promise;
})
.then(function success(data) {
deferred.resolve(data);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to create the stack', err: err });
});
return deferred.promise;
};
return service;
}]);

View file

@ -33,5 +33,21 @@ function DatatableServiceFactory(LocalStorage) {
LocalStorage.storeDataTableOrder(key, filter);
};
service.setDataTableExpandedItems = function(key, expandedItems) {
LocalStorage.storeDataTableExpandedItems(key, expandedItems);
};
service.getDataTableExpandedItems = function(key) {
return LocalStorage.getDataTableExpandedItems(key);
};
service.setDataTableSelectedItems = function(key, selectedItems) {
LocalStorage.storeDataTableSelectedItems(key, selectedItems);
};
service.getDataTableSelectedItems = function(key) {
return LocalStorage.getDataTableSelectedItems(key);
};
return service;
}]);

View file

@ -28,10 +28,9 @@ angular.module('portainer.app')
});
};
service.createStack = function(stackName, swarmId, file, env) {
var endpointID = EndpointProvider.endpointID();
service.createSwarmStack = function(stackName, swarmId, file, env, endpointId) {
return Upload.upload({
url: 'api/endpoints/' + endpointID + '/stacks?method=file',
url: 'api/stacks?method=file&type=1&endpointId=' + endpointId,
data: {
file: file,
Name: stackName,
@ -42,6 +41,17 @@ angular.module('portainer.app')
});
};
service.createComposeStack = function(stackName, file, endpointId) {
return Upload.upload({
url: 'api/stacks?method=file&type=2&endpointId=' + endpointId,
data: {
file: file,
Name: stackName
},
ignoreLoadingBar: true
});
};
service.createEndpoint = function(name, type, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
return Upload.upload({
url: 'api/endpoints',

View file

@ -59,6 +59,18 @@ angular.module('portainer.app')
storeDataTableSettings: function(key, data) {
localStorageService.set('datatable_settings_' + key, data);
},
getDataTableExpandedItems: function(key) {
return localStorageService.get('datatable_expandeditems_' + key);
},
storeDataTableExpandedItems: function(key, data) {
localStorageService.set('datatable_expandeditems_' + key, data);
},
getDataTableSelectedItems: function(key) {
return localStorageService.get('datatable_selecteditems_' + key);
},
storeDataTableSelectedItems: function(key, data) {
localStorageService.set('datatable_selecteditems_' + key, data);
},
storeSwarmVisualizerSettings: function(key, data) {
localStorageService.set('swarmvisualizer_' + key, data);
},

View file

@ -0,0 +1,141 @@
angular.module('portainer.app')
.controller('CreateStackController', ['$scope', '$state', 'StackService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', 'FormHelper', 'EndpointProvider',
function ($scope, $state, StackService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper, EndpointProvider) {
$scope.formValues = {
Name: '',
StackFileContent: '',
StackFile: null,
RepositoryURL: '',
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
Env: [],
ComposeFilePathInRepository: 'docker-compose.yml',
AccessControlData: new AccessControlFormData()
};
$scope.state = {
Method: 'editor',
formValidationError: '',
actionInProgress: false,
StackType: null
};
$scope.addEnvironmentVariable = function() {
$scope.formValues.Env.push({ name: '', value: ''});
};
$scope.removeEnvironmentVariable = function(index) {
$scope.formValues.Env.splice(index, 1);
};
function validateForm(accessControlData, isAdmin) {
$scope.state.formValidationError = '';
var error = '';
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
if (error) {
$scope.state.formValidationError = error;
return false;
}
return true;
}
function createSwarmStack(name, method) {
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
var endpointId = EndpointProvider.endpointID();
if (method === 'editor') {
var stackFileContent = $scope.formValues.StackFileContent;
return StackService.createSwarmStackFromFileContent(name, stackFileContent, env, endpointId);
} else if (method === 'upload') {
var stackFile = $scope.formValues.StackFile;
return StackService.createSwarmStackFromFileUpload(name, stackFile, env, endpointId);
} else if (method === 'repository') {
var repositoryOptions = {
RepositoryURL: $scope.formValues.RepositoryURL,
ComposeFilePathInRepository: $scope.formValues.ComposeFilePathInRepository,
RepositoryAuthentication: $scope.formValues.RepositoryAuthentication,
RepositoryUsername: $scope.formValues.RepositoryUsername,
RepositoryPassword: $scope.formValues.RepositoryPassword
};
return StackService.createSwarmStackFromGitRepository(name, repositoryOptions, env, endpointId);
}
}
function createComposeStack(name, method) {
var endpointId = EndpointProvider.endpointID();
if (method === 'editor') {
var stackFileContent = $scope.formValues.StackFileContent;
return StackService.createComposeStackFromFileContent(name, stackFileContent, endpointId);
} else if (method === 'upload') {
var stackFile = $scope.formValues.StackFile;
return StackService.createComposeStackFromFileUpload(name, stackFile, endpointId);
} else if (method === 'repository') {
var repositoryOptions = {
RepositoryURL: $scope.formValues.RepositoryURL,
ComposeFilePathInRepository: $scope.formValues.ComposeFilePathInRepository,
RepositoryAuthentication: $scope.formValues.RepositoryAuthentication,
RepositoryUsername: $scope.formValues.RepositoryUsername,
RepositoryPassword: $scope.formValues.RepositoryPassword
};
return StackService.createComposeStackFromGitRepository(name, repositoryOptions, endpointId);
}
}
$scope.deployStack = function () {
var name = $scope.formValues.Name;
var method = $scope.state.Method;
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1;
var userId = userDetails.ID;
if (method === 'editor' && $scope.formValues.StackFileContent === '') {
$scope.state.formValidationError = 'Stack file content must not be empty';
return;
}
if (!validateForm(accessControlData, isAdmin)) {
return;
}
var type = $scope.state.StackType;
var action = createSwarmStack;
if (type === 2) {
action = createComposeStack;
}
$scope.state.actionInProgress = true;
action(name, method)
.then(function success(data) {
return ResourceControlService.applyResourceControl('stack', name, userId, accessControlData, []);
})
.then(function success() {
Notifications.success('Stack successfully deployed');
$state.go('portainer.stacks');
})
.catch(function error(err) {
Notifications.warning('Deployment error', type === 1 ? err.err.data.err : err.data.err);
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
$scope.editorUpdate = function(cm) {
$scope.formValues.StackFileContent = cm.getValue();
};
function initView() {
var endpointMode = $scope.applicationState.endpoint.mode;
$scope.state.StackType = 2;
if (endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER') {
$scope.state.StackType = 1;
}
}
initView();
}]);

View file

@ -0,0 +1,217 @@
<rd-header>
<rd-header-title title-text="Create stack"></rd-header-title>
<rd-header-content>
<a ui-sref="portainer.stacks">Stacks</a> &gt; Add stack
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="stack_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="formValues.Name" id="stack_name" placeholder="e.g. myStack" auto-focus>
</div>
</div>
<!-- !name-input -->
<div class="form-group">
<span class="col-sm-12 text-muted small" ng-if="state.StackType === 1">
This stack will be deployed using the equivalent of the <code>docker stack deploy</code> command.
</span>
<span class="col-sm-12 text-muted small" ng-if="state.StackType === 2">
This stack will be deployed using the equivalent of <code>docker-compose</code>. Only Compose file format version <b>2</b> is supported at the moment.
</span>
</div>
<!-- build-method -->
<div class="col-sm-12 form-section-title">
Build method
</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="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="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>
<input type="radio" id="method_repository" ng-model="state.Method" value="repository">
<label for="method_repository">
<div class="boxselector_header">
<i class="fab fa-git" aria-hidden="true" style="margin-right: 2px;"></i>
Repository
</div>
<p>Use a git repository</p>
</label>
</div>
</div>
</div>
<!-- !build-method -->
<!-- web-editor -->
<div ng-show="state.Method === 'editor'">
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
identifier="stack-creation-editor"
placeholder="# Define or paste the content of your docker-compose file here"
yml="true"
on-change="editorUpdate"
></code-editor>
</div>
</div>
</div>
<!-- !web-editor -->
<!-- upload -->
<div ng-show="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 Compose 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="formValues.StackFile">Select file</button>
<span style="margin-left: 5px;">
{{ formValues.StackFile.name }}
<i class="fa fa-times red-icon" ng-if="!formValues.StackFile" aria-hidden="true"></i>
</span>
</div>
</div>
</div>
<!-- !upload -->
<!-- repository -->
<div ng-show="state.Method === 'repository'">
<div class="col-sm-12 form-section-title">
Git repository
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can use the URL of a git repository.
</span>
</div>
<div class="form-group">
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository URL</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.RepositoryURL" id="stack_repository_url" placeholder="https://github.com/portainer/portainer-compose">
</div>
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Indicate the path to the Compose file from the root of your repository.
</span>
</div>
<div class="form-group">
<label for="stack_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.ComposeFilePathInRepository" id="stack_repository_path" placeholder="docker-compose.yml">
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Authentication
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.RepositoryAuthentication"><i></i>
</label>
</div>
</div>
<div class="form-group" ng-if="formValues.RepositoryAuthentication">
<label for="repository_username" class="col-sm-1 control-label text-left">Username</label>
<div class="col-sm-11 col-md-5">
<input type="text" class="form-control" ng-model="formValues.RepositoryUsername" name="repository_username" placeholder="myGitUser">
</div>
<label for="repository_password" class="col-sm-1 margin-sm-top control-label text-left">
Password
</label>
<div class="col-sm-11 col-md-5 margin-sm-top">
<input type="password" class="form-control" ng-model="formValues.RepositoryPassword" name="repository_password" placeholder="myPassword">
</div>
</div>
</div>
<!-- environment-variables -->
<div ng-if="state.StackType === 1">
<div class="col-sm-12 form-section-title">
Environment
</div>
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Environment variables</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in formValues.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
</div>
<!-- !environment-variables -->
<!-- !repository -->
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- 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="state.actionInProgress
|| (state.Method === 'upload' && !formValues.StackFile)
|| (state.Method === 'repository' && ((!formValues.RepositoryURL || !formValues.ComposeFilePathInRepository) || (formValues.RepositoryAuthentication && (!formValues.RepositoryUsername || !formValues.RepositoryPassword))))
|| !formValues.Name" ng-click="deployStack()" button-spinner="state.actionInProgress">
<span ng-hide="state.actionInProgress">Deploy the stack</span>
<span ng-show="state.actionInProgress">Deployment in progress...</span>
</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View file

@ -0,0 +1,158 @@
<rd-header>
<rd-header-title title-text="Stack details">
<a data-toggle="tooltip" title-text="Refresh" ui-sref="portainer.stacks.stack({id: stack.Id})" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>
<a ui-sref="portainer.stacks">Stacks</a> &gt; {{ stackName }}</a>
</rd-header-content>
</rd-header>
<div class="row" ng-if="state.externalStack">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group">
<span class="small">
<p class="text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This stack was created outside of Portainer. Control over this stack is limited.
</p>
</span>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<!-- access-control-panel -->
<por-access-control-panel
ng-if="stack && applicationState.application.authentication"
resource-id="stack.Name"
resource-control="stack.ResourceControl"
resource-type="'stack'">
</por-access-control-panel>
<!-- !access-control-panel -->
<div class="row" ng-if="containers">
<div class="col-sm-12">
<containers-datatable
title-text="Containers" title-icon="fa-server"
dataset="containers" table-key="stack-containers"
order-by="Status" show-text-filter="true"
show-ownership-column="applicationState.application.authentication"
show-host-column="false"
show-add-action="false"
></containers-datatable>
</div>
</div>
<div class="row" ng-if="services">
<div class="col-sm-12">
<services-datatable
title-text="Services" title-icon="fa-list-alt"
dataset="services" table-key="stack-services"
order-by="Name" show-text-filter="true"
nodes="nodes"
agent-proxy="applicationState.endpoint.mode.agentProxy"
show-ownership-column="false"
show-update-action="applicationState.endpoint.apiVersion >= 1.25"
show-task-logs-button="applicationState.endpoint.apiVersion >= 1.30"
show-add-action="false"
show-stack-column="false"
></services-datatable>
</div>
</div>
<div class="row" ng-if="stackFileContent">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-pencil-alt" title-text="Stack editor"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
identifier="stack-editor"
placeholder="# Define or paste the content of your docker-compose file here"
yml="true"
on-change="editorUpdate"
value="stackFileContent"
></code-editor>
</div>
</div>
<!-- environment-variables -->
<div ng-if="stack && stack.Type === 1">
<div class="col-sm-12 form-section-title">
Environment
</div>
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Environment variables</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in stack.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
</div>
<!-- !environment-variables -->
<!-- options -->
<div ng-if="stack.Type === 1 && applicationState.endpoint.apiVersion >= 1.27">
<div class="col-sm-12 form-section-title">
Options
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="prune" class="control-label text-left">
Prune services
<portainer-tooltip position="bottom" message="Prune services that are no longer referenced."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input name="prune" type="checkbox" ng-model="formValues.Prune"><i></i>
</label>
</div>
</div>
</div>
<!-- !options -->
<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-sm btn-primary" ng-disabled="state.actionInProgress" ng-click="deployStack()" button-spinner="state.actionInProgress">
<span ng-hide="state.actionInProgress">Update the stack</span>
<span ng-show="state.actionInProgress">Deployment in progress...</span>
</button>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View file

@ -0,0 +1,171 @@
angular.module('portainer.app')
.controller('StackController', ['$q', '$scope', '$state', '$transition$', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ContainerService', 'ServiceHelper', 'TaskHelper', 'Notifications', 'FormHelper',
function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceService, TaskService, ContainerService, ServiceHelper, TaskHelper, Notifications, FormHelper) {
$scope.state = {
actionInProgress: false,
externalStack: false
};
$scope.formValues = {
Prune: false
};
$scope.deployStack = function () {
var stackFile = $scope.stackFileContent;
var env = FormHelper.removeInvalidEnvVars($scope.stack.Env);
var prune = $scope.formValues.Prune;
$scope.state.actionInProgress = true;
StackService.updateStack($scope.stack.Id, stackFile, env, prune)
.then(function success(data) {
Notifications.success('Stack successfully deployed');
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create stack');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
$scope.addEnvironmentVariable = function() {
$scope.stack.Env.push({ name: '', value: ''});
};
$scope.removeEnvironmentVariable = function(index) {
$scope.stack.Env.splice(index, 1);
};
$scope.editorUpdate = function(cm) {
$scope.stackFileContent = cm.getValue();
};
function loadStack(id) {
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
StackService.stack(id)
.then(function success(data) {
var stack = data;
$scope.stack = stack;
return $q.all({
stackFile: StackService.getStackFile(id),
resources: stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name)
});
})
.then(function success(data) {
$scope.stackFileContent = data.stackFile;
if ($scope.stack.Type === 1) {
assignSwarmStackResources(data.resources, agentProxy);
} else {
assignComposeStackResources(data.resources);
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve stack details');
});
}
function retrieveSwarmStackResources(stackName, agentProxy) {
var stackFilter = {
label: ['com.docker.stack.namespace=' + stackName]
};
return $q.all({
services: ServiceService.services(stackFilter),
tasks: TaskService.tasks(stackFilter),
containers: agentProxy ? ContainerService.containers(1) : [],
nodes: NodeService.nodes()
});
}
function assignSwarmStackResources(resources, agentProxy) {
var services = resources.services;
var tasks = resources.tasks;
if (agentProxy) {
var containers = resources.containers;
for (var j = 0; j < tasks.length; j++) {
var task = tasks[j];
TaskHelper.associateContainerToTask(task, containers);
}
}
for (var i = 0; i < services.length; i++) {
var service = services[i];
ServiceHelper.associateTasksToService(service, tasks);
}
$scope.nodes = resources.nodes;
$scope.tasks = tasks;
$scope.services = services;
}
function retrieveComposeStackResources(stackName) {
var stackFilter = {
label: ['com.docker.compose.project=' + stackName]
};
return $q.all({
containers: ContainerService.containers(1, stackFilter)
});
}
function assignComposeStackResources(resources) {
$scope.containers = resources.containers;
}
function loadExternalStack(name) {
var stackType = $transition$.params().type;
if (!stackType || (stackType !== '1' && stackType !== '2')) {
Notifications.error('Failure', err, 'Invalid type URL parameter.');
return;
}
if (stackType === '1') {
loadExternalSwarmStack(name);
} else {
loadExternalComposeStack(name);
}
}
function loadExternalSwarmStack(name) {
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
retrieveSwarmStackResources(name)
.then(function success(data) {
assignSwarmStackResources(data);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve stack details');
});
}
function loadExternalComposeStack(name) {
retrieveComposeStackResources(name)
.then(function success(data) {
assignComposeStackResources(data);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve stack details');
});
}
function initView() {
var stackName = $transition$.params().name;
$scope.stackName = stackName;
var external = $transition$.params().external;
if (external === 'true') {
$scope.state.externalStack = true;
loadExternalStack(stackName);
} else {
var stackId = $transition$.params().id;
loadStack(stackId);
}
}
initView();
}]);

View file

@ -0,0 +1,20 @@
<rd-header>
<rd-header-title title-text="Stacks list">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.stacks" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Stacks</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<stacks-datatable
title-text="Stacks" title-icon="fa-th-list"
dataset="stacks" table-key="stacks"
order-by="Name" show-text-filter="true"
remove-action="removeAction"
show-ownership-column="applicationState.application.authentication"
></stacks-datatable>
</div>
</div>

View file

@ -0,0 +1,56 @@
angular.module('portainer.app')
.controller('StacksController', ['$scope', '$state', 'Notifications', 'StackService', 'ModalService', 'EndpointProvider',
function ($scope, $state, Notifications, StackService, ModalService, EndpointProvider) {
$scope.removeAction = function(selectedItems) {
ModalService.confirmDeletion(
'Do you want to remove the selected stack(s)? Associated services will be removed as well.',
function onConfirm(confirmed) {
if(!confirmed) { return; }
deleteSelectedStacks(selectedItems);
}
);
};
function deleteSelectedStacks(stacks) {
var endpointId = EndpointProvider.endpointID();
var actionCount = stacks.length;
angular.forEach(stacks, function (stack) {
StackService.remove(stack, stack.External, endpointId)
.then(function success() {
Notifications.success('Stack successfully removed', stack.Name);
var index = $scope.stacks.indexOf(stack);
$scope.stacks.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name);
})
.finally(function final() {
--actionCount;
if (actionCount === 0) {
$state.reload();
}
});
});
}
function initView() {
var endpointMode = $scope.applicationState.endpoint.mode;
var endpointId = EndpointProvider.endpointID();
StackService.stacks(
true,
endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER',
endpointId
)
.then(function success(data) {
var stacks = data;
$scope.stacks = stacks;
})
.catch(function error(err) {
$scope.stacks = [];
Notifications.error('Failure', err, 'Unable to retrieve stacks');
});
}
initView();
}]);