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

feat(stacks): add support for stack deploy (#1280)

This commit is contained in:
Anthony Lapenna 2017-10-15 19:24:40 +02:00 committed by GitHub
parent 80827935da
commit 587e2fa673
77 changed files with 3219 additions and 702 deletions

View file

@ -28,6 +28,7 @@ angular.module('portainer', [
'createSecret',
'createService',
'createVolume',
'createStack',
'engine',
'endpoint',
'endpointAccess',
@ -51,6 +52,8 @@ angular.module('portainer', [
'settings',
'settingsAuthentication',
'sidebar',
'stack',
'stacks',
'swarm',
'swarmVisualizer',
'task',

View file

@ -49,52 +49,59 @@
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ui-sref="containers" ng-click="order('Status')">
<a ng-click="order('Status')">
State
<span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="containers" ng-click="order('Names')">
<a ng-click="order('Names')">
Name
<span ng-show="sortType == 'Names' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Names' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
<a data-toggle="tooltip" title="More" ng-click="truncateMore();" ng-show="showMore">
<i class="fa fa-plus-square" aria-hidden="true"></i>
</a>
<a data-toggle="tooltip" title="More" ng-click="truncateMore();" ng-show="showMore">
<i class="fa fa-plus-square" aria-hidden="true"></i>
</a>
</th>
<th>
<a ui-sref="containers" ng-click="order('Image')">
<a ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Image')">
Image
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="state.displayIP">
<a ui-sref="containers" ng-click="order('IP')">
<a ng-click="order('IP')">
IP Address
<span ng-show="sortType == 'IP' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'IP' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
<a ui-sref="containers" ng-click="order('Host')">
<a ng-click="order('Host')">
Host IP
<span ng-show="sortType == 'Host' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Host' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="containers" ng-click="order('Ports')">
<a ng-click="order('Ports')">
Published Ports
<span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a ui-sref="containers" ng-click="order('ResourceControl.Ownership')">
<a ng-click="order('ResourceControl.Ownership')">
Ownership
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
@ -111,6 +118,7 @@
</td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername|truncate: truncate_size}}</a></td>
<td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: truncate_size}}</a></td>
<td>{{ container.StackName ? container.StackName : '-' }}</td>
<td><a ui-sref="image({id: container.Image})">{{ container.Image | hideshasum }}</a></td>
<td ng-if="state.displayIP">{{ container.IP ? container.IP : '-' }}</td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ container.hostIP }}</td>

View file

@ -0,0 +1,119 @@
angular.module('createStack', [])
.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService',
function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService) {
// Store the editor content when switching builder methods
var editorContent = '';
var editorEnabled = true;
$scope.formValues = {
Name: '',
StackFileContent: '# Define or paste the content of your docker-compose file here',
StackFile: null,
RepositoryURL: '',
RepositoryPath: 'docker-compose.yml',
AccessControlData: new AccessControlFormData()
};
$scope.state = {
Method: 'editor',
formValidationError: ''
};
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 createStack(name) {
var method = $scope.state.Method;
if (method === 'editor') {
// The codemirror editor does not work with ng-model so we need to retrieve
// the value directly from the editor.
var stackFileContent = $scope.editor.getValue();
return StackService.createStackFromFileContent(name, stackFileContent);
} else if (method === 'upload') {
var stackFile = $scope.formValues.StackFile;
return StackService.createStackFromFileUpload(name, stackFile);
} else if (method === 'repository') {
var gitRepository = $scope.formValues.RepositoryURL;
var pathInRepository = $scope.formValues.RepositoryPath;
return StackService.createStackFromGitRepository(name, gitRepository, pathInRepository);
}
}
$scope.deployStack = function () {
$('#createResourceSpinner').show();
var name = $scope.formValues.Name;
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
var userId = userDetails.ID;
if (!validateForm(accessControlData, isAdmin)) {
$('#createResourceSpinner').hide();
return;
}
createStack(name)
.then(function success(data) {
Notifications.success('Stack successfully deployed');
})
.catch(function error(err) {
Notifications.warning('Deployment error', err.err.data.err);
})
.then(function success(data) {
return ResourceControlService.applyResourceControl('stack', name, userId, accessControlData, []);
})
.then(function success() {
$state.go('stacks');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to apply resource control on the stack');
})
.finally(function final() {
$('#createResourceSpinner').hide();
});
};
function enableEditor(value) {
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement);
if (value) {
$scope.editor.setValue(value);
}
}
});
}
$scope.toggleEditor = function() {
if (!editorEnabled) {
enableEditor(editorContent);
editorEnabled = true;
}
};
$scope.saveEditorContent = function() {
editorContent = $scope.editor.getValue();
editorEnabled = false;
};
function initView() {
enableEditor();
}
initView();
}]);

View file

@ -0,0 +1,156 @@
<rd-header>
<rd-header-title title="Create stack">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="display: none;"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="stacks">Stacks</a> > 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">
This stack will be deployed using the equivalent of the <code>docker stack deploy</code> command.
</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" ng-click="toggleEditor(state.Method)">
<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" ng-click="saveEditorContent()">
<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" ng-click="saveEditorContent()">
<label for="method_repository">
<div class="boxselector_header">
<i class="fa 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-if="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">
<textarea id="web-editor" class="form-control" ng-model="formValues.StackFileContent" placeholder='version: "3"'></textarea>
</div>
</div>
</div>
<!-- !web-editor -->
<!-- upload -->
<div ng-if="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-if="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 public 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.RepositoryPath" id="stack_repository_path" placeholder="docker-compose.yml">
</div>
</div>
</div>
<!-- !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.Method === 'editor' && !formValues.StackFileContent)
|| (state.Method === 'upload' && !formValues.StackFile)
|| (state.Method === 'repository' && (!formValues.RepositoryURL || !formValues.RepositoryPath))
|| !formValues.Name" ng-click="deployStack()">Deploy the stack</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="stacks">Cancel</a>
<i id="createResourceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<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

@ -85,6 +85,32 @@
</div>
<div class="row">
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<a ui-sref="stacks">
<rd-widget>
<rd-widget-body>
<div class="widget-icon blue pull-left">
<i class="fa fa-th-list"></i>
</div>
<div class="title">{{ stackCount }}</div>
<div class="comment">Stacks</div>
</rd-widget-body>
</rd-widget>
</a>
</div>
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<a ui-sref="services">
<rd-widget>
<rd-widget-body>
<div class="widget-icon blue pull-left">
<i class="fa fa-list-alt"></i>
</div>
<div class="title">{{ serviceCount }}</div>
<div class="comment">Services</div>
</rd-widget-body>
</rd-widget>
</a>
</div>
<div class="col-xs-12 col-md-6">
<a ui-sref="containers">
<rd-widget>

View file

@ -1,6 +1,6 @@
angular.module('dashboard', [])
.controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'SystemService', 'Notifications',
function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, SystemService, Notifications) {
.controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'SystemService', 'ServiceService', 'StackService', 'Notifications',
function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, SystemService, ServiceService, StackService, Notifications) {
$scope.containerData = {
total: 0
@ -15,6 +15,9 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System
total: 0
};
$scope.serviceCount = 0;
$scope.stackCount = 0;
function prepareContainerData(d) {
var running = 0;
var stopped = 0;
@ -63,18 +66,25 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System
function initView() {
$('#loadingViewSpinner').show();
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
$q.all([
Container.query({all: 1}).$promise,
Image.query({}).$promise,
Volume.query({}).$promise,
Network.query({}).$promise,
SystemService.info()
SystemService.info(),
endpointProvider === 'DOCKER_SWARM_MODE' ? ServiceService.services() : [],
endpointProvider === 'DOCKER_SWARM_MODE' ? StackService.stacks(true) : []
]).then(function (d) {
prepareContainerData(d[0]);
prepareImageData(d[1]);
prepareVolumeData(d[2]);
prepareNetworkData(d[3]);
prepareInfoData(d[4]);
$scope.serviceCount = d[5].length;
$scope.stackCount = d[6].length;
$('#loadingViewSpinner').hide();
}, function(e) {
$('#loadingViewSpinner').hide();

View file

@ -48,10 +48,10 @@
</a>
</th>
<th>
<a ng-click="order('Id')">
Id
<span ng-show="sortType == 'Id' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Id' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
<a ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
@ -101,8 +101,8 @@
<tbody>
<tr dir-paginate="network in ( state.filteredNetworks = (networks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="network.Checked" ng-change="selectItem(network)"/></td>
<td><a ui-sref="network({id: network.Id})">{{ network.Name|truncate:40}}</a></td>
<td class="monospaced">{{ network.Id|truncate:20 }}</td>
<td><a ui-sref="network({id: network.Id})">{{ network.Name | truncate:40 }}</a></td>
<td>{{ network.StackName ? network.StackName : '-' }}</td>
<td>{{ network.Scope }}</td>
<td>{{ network.Driver }}</td>
<td>{{ network.IPAM.Driver }}</td>

View file

@ -321,7 +321,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
originalService = angular.copy(service);
return $q.all({
tasks: TaskService.serviceTasks(service.Name),
tasks: TaskService.tasks({ service: [service.Name] }),
nodes: NodeService.nodes(),
secrets: apiVersion >= 1.25 ? SecretService.secrets() : []
});

View file

@ -38,42 +38,49 @@
<thead>
<th></th>
<th>
<a ui-sref="services" ng-click="order('Name')">
<a ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="services" ng-click="order('Image')">
<a ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Image')">
Image
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="services" ng-click="order('Mode')">
<a ng-click="order('Mode')">
Scheduling mode
<span ng-show="sortType == 'Mode' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Mode' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="services" ng-click="order('Ports')">
<a ng-click="order('Ports')">
Published Ports
<span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="services" ng-click="order('UpdatedAt')">
<a ng-click="order('UpdatedAt')">
Updated at
<span ng-show="sortType == 'UpdatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'UpdatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a ui-sref="services" ng-click="order('ResourceControl.Ownership')">
<a ng-click="order('ResourceControl.Ownership')">
Ownership
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
@ -84,6 +91,7 @@
<tr dir-paginate="service in (state.filteredServices = ( services | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="service.Checked" ng-change="selectItem(service)"/></td>
<td><a ui-sref="service({id: service.Id})">{{ service.Name }}</a></td>
<td>{{ service.StackName ? service.StackName : '-' }}</td>
<td>{{ service.Image | hideshasum }}</td>
<td>
{{ service.Mode }}

View file

@ -25,6 +25,9 @@
<a ui-sref="templates_linuxserver" ui-sref-active="active">LinuxServer.io</a>
</div>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="stacks" ui-sref-active="active">Stacks <span class="menu-icon fa fa-th-list"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="services" ui-sref-active="active">Services <span class="menu-icon fa fa-list-alt"></span></a>
</li>

View file

@ -0,0 +1,54 @@
<rd-header>
<rd-header-title title="Stack details">
<a data-toggle="tooltip" title="Refresh" ui-sref="stack({id: stack.Id})" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="stacks">Stacks</a> > <a ui-sref="stack({id: stack.Id})">{{ stack.Name }}</a>
</rd-header-content>
</rd-header>
<!-- 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 -->
<por-service-list services="services" nodes="nodes"></por-service-list>
<por-task-list tasks="tasks" nodes="nodes"></por-task-list>
<div class="row" ng-if="stackFileContent">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-pencil" title="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">
<textarea id="web-editor" class="form-control" ng-model="stackFileContent" placeholder='version: "3"'></textarea>
</div>
</div>
<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-click="deployStack()">Update stack</button>
<i id="createResourceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View file

@ -0,0 +1,78 @@
angular.module('stack', [])
.controller('StackController', ['$q', '$scope', '$state', '$stateParams', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications',
function ($q, $scope, $state, $stateParams, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications) {
$scope.deployStack = function () {
$('#createResourceSpinner').show();
// The codemirror editor does not work with ng-model so we need to retrieve
// the value directly from the editor.
var stackFile = $scope.editor.getValue();
StackService.updateStack($scope.stack.Id, stackFile)
.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() {
$('#createResourceSpinner').hide();
});
};
function initView() {
$('#loadingViewSpinner').show();
var stackId = $stateParams.id;
StackService.stack(stackId)
.then(function success(data) {
var stack = data;
$scope.stack = stack;
var serviceFilters = {
label: ['com.docker.stack.namespace=' + stack.Name]
};
return $q.all({
stackFile: StackService.getStackFile(stackId),
services: ServiceService.services(serviceFilters),
tasks: TaskService.tasks(serviceFilters),
nodes: NodeService.nodes()
});
})
.then(function success(data) {
$scope.stackFileContent = data.stackFile;
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement);
}
});
$scope.nodes = data.nodes;
var services = data.services;
var tasks = data.tasks;
$scope.tasks = tasks;
for (var i = 0; i < services.length; i++) {
var service = services[i];
ServiceHelper.associateTasksToService(service, tasks);
}
$scope.services = services;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve tasks details');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);

View file

@ -0,0 +1,120 @@
<rd-header>
<rd-header-title title="Stacks list">
<a data-toggle="tooltip" title="Refresh" ui-sref="stacks" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>Stacks</rd-header-content>
</rd-header>
<div class="row" ng-if="state.DisplayInformationPanel">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-info-circle" title="Information"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<span class="col-sm-12 text-muted small">
Stacks marked with the <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true"></i> icon are external stacks that were created outside of Portainer. You'll not be able to execute any actions against these stacks.
</span>
</div>
<div class="col-sm-12 form-section-title">
Filters
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Display external stacks
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="state.DisplayExternalStacks"><i></i>
</label>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-th-list" title="Stacks">
<div class="pull-right">
Items per page:
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
<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>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-lg-12 col-md-12 col-xs-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-primary" type="button" ui-sref="actions.create.stack"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add stack</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a ng-click="order('ResourceControl.Ownership')">
Ownership
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</thead>
<tbody>
<tr dir-paginate="stack in (state.filteredStacks = ( stacks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))" ng-if="state.DisplayExternalStacks || (!state.DisplayExternalStacks && !stack.External)">
<td><input type="checkbox" ng-model="stack.Checked" ng-change="selectItem(stack)" ng-disabled="!stack.Id"/></td>
<td>
<span ng-if="stack.Id">
<a ui-sref="stack({ id: stack.Id })">{{ stack.Name }}</a>
</span>
<span ng-if="!stack.Id">
{{ stack.Name }} <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true"></i>
</span>
</td>
<td ng-if="applicationState.application.authentication">
<span>
<i ng-class="stack.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ stack.ResourceControl.Ownership ? stack.ResourceControl.Ownership : stack.ResourceControl.Ownership = 'public' }}
</span>
</td>
</tr>
<tr ng-if="!stacks">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="stacks.length === 0">
<td colspan="3" class="text-center text-muted">No stacks available.</td>
</tr>
</tbody>
</table>
<div ng-if="stacks" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
<rd-widget>
</div>
</div>

View file

@ -0,0 +1,103 @@
angular.module('stacks', [])
.controller('StacksController', ['$scope', 'Notifications', 'Pagination', 'StackService', 'ModalService',
function ($scope, Notifications, Pagination, StackService, ModalService) {
$scope.state = {};
$scope.state.selectedItemCount = 0;
$scope.state.pagination_count = Pagination.getPaginationCount('stacks');
$scope.sortType = 'Name';
$scope.sortReverse = false;
$scope.state.DisplayInformationPanel = false;
$scope.state.DisplayExternalStacks = true;
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('stacks', $scope.state.pagination_count);
};
$scope.order = function (sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredStacks, function (stack) {
if (stack.Id && stack.Checked !== allSelected) {
stack.Checked = allSelected;
$scope.selectItem(stack);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.removeAction = function () {
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();
}
);
};
function deleteSelectedStacks() {
$('#loadingViewSpinner').show();
var counter = 0;
var complete = function () {
counter = counter - 1;
if (counter === 0) {
$('#loadingViewSpinner').hide();
}
};
angular.forEach($scope.stacks, function (stack) {
if (stack.Checked) {
counter = counter + 1;
StackService.remove(stack)
.then(function success() {
Notifications.success('Stack deleted', 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() {
complete();
});
}
});
}
function initView() {
$('#loadingViewSpinner').show();
StackService.stacks(true)
.then(function success(data) {
var stacks = data;
for (var i = 0; i < stacks.length; i++) {
var stack = stacks[i];
if (stack.External) {
$scope.state.DisplayInformationPanel = true;
break;
}
}
$scope.stacks = stacks;
})
.catch(function error(err) {
$scope.stacks = [];
Notifications.error('Failure', err, 'Unable to retrieve stacks');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);

View file

@ -57,6 +57,13 @@
<span ng-show="sortType == 'Id' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="volumes" ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="volumes" ng-click="order('Driver')">
Driver
@ -87,6 +94,7 @@
<a ui-sref="volume({id: volume.Id})" class="monospaced">{{ volume.Id|truncate:25 }}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="volume.dangling">Unused</span></td>
</td>
<td>{{ volume.StackName ? volume.StackName : '-' }}</td>
<td>{{ volume.Driver }}</td>
<td>{{ volume.Mountpoint | truncatelr }}</td>
<td ng-if="applicationState.application.authentication">

View file

@ -37,6 +37,13 @@
<portainer-tooltip message="Access control applied on a container created using a template is also applied on each volume associated to the container." position="bottom" style="margin-left: 2px;"></portainer-tooltip>
</td>
</tr>
<tr ng-if="$ctrl.resourceControl.Type === 6 && $ctrl.resourceType !== 'stack'">
<td colspan="2">
<i class="fa fa-info-circle" aria-hidden="true" style="margin-right: 2px;"></i>
Access control on this resource is inherited from the following stack: {{ $ctrl.resourceControl.ResourceId }}
<portainer-tooltip message="Access control applied on a stack is also applied on each resource in the stack." position="bottom" style="margin-left: 2px;"></portainer-tooltip>
</td>
</tr>
<!-- authorized-users -->
<tr ng-if="$ctrl.resourceControl.UserAccesses.length > 0">
<td>Authorized users</td>
@ -54,7 +61,11 @@
</tr>
<!-- !authorized-teams -->
<!-- edit-ownership -->
<tr ng-if="!($ctrl.resourceControl.Type === 1 && $ctrl.resourceType === 'volume') && !($ctrl.resourceControl.Type === 2 && $ctrl.resourceType === 'container') && !$ctrl.state.editOwnership && ($ctrl.isAdmin || $ctrl.state.canEditOwnership)">
<tr ng-if="!($ctrl.resourceControl.Type === 1 && $ctrl.resourceType === 'volume')
&& !($ctrl.resourceControl.Type === 2 && $ctrl.resourceType === 'container')
&& !($ctrl.resourceControl.Type === 6 && $ctrl.resourceType !== 'stack')
&& !$ctrl.state.editOwnership
&& ($ctrl.isAdmin || $ctrl.state.canEditOwnership)">
<td colspan="2">
<a class="btn-outline-secondary" ng-click="$ctrl.state.editOwnership = true"><i class="fa fa-edit space-right" aria-hidden="true"></i>Change ownership</a>
</td>

View file

@ -0,0 +1,8 @@
angular.module('portainer').component('porServiceList', {
templateUrl: 'app/directives/serviceList/porServiceList.html',
controller: 'porServiceListController',
bindings: {
'services': '<',
'nodes': '<'
}
});

View file

@ -0,0 +1,98 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-list-alt" title="Associated services">
<div class="pull-right">
Items per page:
<select ng-model="$ctrl.state.pagination_count" ng-change="$ctrl.changePaginationCount()">
<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>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12">
<div class="pull-right">
<input type="text" id="filter" ng-model="$ctrl.state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<a ng-click="$ctrl.order('Name')">
Name
<span ng-show="$ctrl.sortType === 'Name' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Name' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Image')">
Image
<span ng-show="$ctrl.sortType === 'Image' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Image' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Mode')">
Scheduling mode
<span ng-show="$ctrl.sortType === 'Mode' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Mode' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Ports')">
Published Ports
<span ng-show="$ctrl.sortType === 'Ports' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Ports' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('UpdatedAt')">
Updated at
<span ng-show="$ctrl.sortType === 'UpdatedAt' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'UpdatedAt' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="service in $ctrl.services | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage:$ctrl.state.pagination_count" pagination-id="services_list">
<td><a ui-sref="service({id: service.Id})">{{ service.Name }}</a></td>
<td>{{ service.Image | hideshasum }}</td>
<td>
{{ service.Mode }}
<code data-toggle="tooltip" title="Replicas">{{ service.Tasks | runningtaskscount }}</code>
/
<code data-toggle="tooltip" title="Replicas">{{ service.Mode === 'replicated' ? service.Replicas : ($ctrl.nodes | availablenodecount) }}</code>
</td>
<td>
<a ng-if="service.Ports && service.Ports.length > 0" ng-repeat="p in service.Ports" class="image-tag" ng-href="http://{{$ctrl.state.publicURL}}:{{p.PublishedPort}}" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i> {{ p.PublishedPort }}:{{ p.TargetPort }}
</a>
<span ng-if="!service.Ports || service.Ports.length === 0" >-</span>
</td>
<td>
{{ service.UpdatedAt|getisodate }}
</td>
</tr>
<tr ng-if="!$ctrl.services">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="($ctrl.services | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage: $ctrl.state.pagination_count).length === 0">
<td colspan="5" class="text-center text-muted">No services available.</td>
</tr>
</tbody>
</table>
<div ng-if="$ctrl.services" class="pull-left pagination-controls">
<dir-pagination-controls pagination-id="services_list"></dir-pagination-controls >
</div>
</div>
</rd-widget-body>
<rd-widget>
</div>
</div>

View file

@ -0,0 +1,21 @@
angular.module('portainer')
.controller('porServiceListController', ['EndpointProvider', 'Pagination',
function (EndpointProvider, Pagination) {
var ctrl = this;
ctrl.state = {
pagination_count: Pagination.getPaginationCount('services_list'),
publicURL: EndpointProvider.endpointPublicURL()
};
ctrl.sortType = 'Name';
ctrl.sortReverse = false;
ctrl.order = function(sortType) {
ctrl.sortReverse = (ctrl.sortType === sortType) ? !ctrl.sortReverse : false;
ctrl.sortType = sortType;
};
ctrl.changePaginationCount = function() {
Pagination.setPaginationCount('services_list', ctrl.state.pagination_count);
};
}]);

View file

@ -0,0 +1,8 @@
angular.module('portainer').component('porTaskList', {
templateUrl: 'app/directives/taskList/porTaskList.html',
controller: 'porTaskListController',
bindings: {
'tasks': '<',
'nodes': '<'
}
});

View file

@ -0,0 +1,78 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Associated tasks">
<div class="pull-right">
Items per page:
<select ng-model="$ctrl.state.pagination_count" ng-change="$ctrl.changePaginationCount()">
<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>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12">
<div class="pull-right">
<input type="text" id="filter" ng-model="$ctrl.state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>
<a ng-click="$ctrl.order('Status.State')">
Status
<span ng-show="$ctrl.sortType === 'Status.State' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Status.State' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="service.Mode !== 'global'">
<a ng-click="$ctrl.order('Slot')">
Slot
<span ng-show="$ctrl.sortType === 'Slot' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Slot' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('NodeId')">
Node
<span ng-show="$ctrl.sortType === 'NodeId' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'NodeId' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Updated')">
Last update
<span ng-show="$ctrl.sortType === 'Updated' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Updated' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="task in $ctrl.tasks | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage:$ctrl.state.pagination_count">
<td><a ui-sref="task({ id: task.Id })">{{ task.Id }}</a></td>
<td><span class="label label-{{ task.Status.State | taskstatusbadge }}">{{ task.Status.State }}</span></td>
<td>{{ task.Slot ? task.Slot : '-' }}</td>
<td>{{ task.NodeId | tasknodename: $ctrl.nodes }}</td>
<td>{{ task.Updated | getisodate }}</td>
</tr>
<tr ng-if="!$ctrl.tasks">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="($ctrl.tasks | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage: $ctrl.state.pagination_count).length === 0">
<td colspan="5" class="text-center text-muted">No tasks available.</td>
</tr>
</tbody>
</table>
<div class="pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View file

@ -0,0 +1,19 @@
angular.module('portainer')
.controller('porTaskListController', ['Pagination',
function (Pagination) {
var ctrl = this;
ctrl.state = {
pagination_count: Pagination.getPaginationCount('tasks_list')
};
ctrl.sortType = 'Updated';
ctrl.sortReverse = true;
ctrl.order = function(sortType) {
ctrl.sortReverse = (ctrl.sortType === sortType) ? !ctrl.sortReverse : false;
ctrl.sortType = sortType;
};
ctrl.changePaginationCount = function() {
Pagination.setPaginationCount('tasks_list', ctrl.state.pagination_count);
};
}]);

View file

@ -75,7 +75,7 @@ angular.module('portainer.filters', [])
return 'warning';
} else if (includeString(status, ['created'])) {
return 'info';
} else if (includeString(status, ['stopped', 'unhealthy', 'dead'])) {
} else if (includeString(status, ['stopped', 'unhealthy', 'dead', 'exited'])) {
return 'danger';
}
return 'success';
@ -298,6 +298,32 @@ angular.module('portainer.filters', [])
}
};
})
.filter('availablenodecount', function () {
'use strict';
return function (nodes) {
var availableNodes = 0;
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
if (node.Availability === 'active' && node.Status === 'ready') {
availableNodes++;
}
}
return availableNodes;
};
})
.filter('runningtaskscount', function () {
'use strict';
return function (tasks) {
var runningTasks = 0;
for (var i = 0; i < tasks.length; i++) {
var task = tasks[i];
if (task.Status.State === 'running') {
runningTasks++;
}
}
return runningTasks;
};
})
.filter('tasknodename', function () {
'use strict';
return function (nodeId, nodes) {

View file

@ -1,123 +1,145 @@
angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHelperFactory() {
'use strict';
return {
serviceToConfig: function(service) {
return {
Name: service.Spec.Name,
Labels: service.Spec.Labels,
TaskTemplate: service.Spec.TaskTemplate,
Mode: service.Spec.Mode,
UpdateConfig: service.Spec.UpdateConfig,
Networks: service.Spec.Networks,
EndpointSpec: service.Spec.EndpointSpec
};
},
translateKeyValueToPlacementPreferences: function(keyValuePreferences) {
if (keyValuePreferences) {
var preferences = [];
keyValuePreferences.forEach(function(preference) {
if (preference.strategy && preference.strategy !== '' && preference.value && preference.value !== '') {
switch (preference.strategy.toLowerCase()) {
case 'spread':
preferences.push({
'Spread': {
'SpreadDescriptor': preference.value
}
});
break;
}
var helper = {};
helper.associateTasksToService = function(service, tasks) {
service.Tasks = [];
var otherServicesTasks = [];
for (var i = 0; i < tasks.length; i++) {
var task = tasks[i];
if (task.ServiceId === service.Id) {
service.Tasks.push(task);
} else {
otherServicesTasks.push(task);
}
}
tasks = otherServicesTasks;
};
helper.serviceToConfig = function(service) {
return {
Name: service.Spec.Name,
Labels: service.Spec.Labels,
TaskTemplate: service.Spec.TaskTemplate,
Mode: service.Spec.Mode,
UpdateConfig: service.Spec.UpdateConfig,
Networks: service.Spec.Networks,
EndpointSpec: service.Spec.EndpointSpec
};
};
helper.translateKeyValueToPlacementPreferences = function(keyValuePreferences) {
if (keyValuePreferences) {
var preferences = [];
keyValuePreferences.forEach(function(preference) {
if (preference.strategy && preference.strategy !== '' && preference.value && preference.value !== '') {
switch (preference.strategy.toLowerCase()) {
case 'spread':
preferences.push({
'Spread': {
'SpreadDescriptor': preference.value
}
});
break;
}
});
return preferences;
}
return [];
},
translateKeyValueToPlacementConstraints: function(keyValueConstraints) {
if (keyValueConstraints) {
var constraints = [];
keyValueConstraints.forEach(function(constraint) {
if (constraint.key && constraint.key !== '' && constraint.value && constraint.value !== '') {
constraints.push(constraint.key + constraint.operator + constraint.value);
}
});
return constraints;
}
return [];
},
translateEnvironmentVariables: function(env) {
if (env) {
var variables = [];
env.forEach(function(variable) {
var idx = variable.indexOf('=');
var keyValue = [variable.slice(0, idx), variable.slice(idx + 1)];
var originalValue = (keyValue.length > 1) ? keyValue[1] : '';
variables.push({
key: keyValue[0],
value: originalValue,
originalKey: keyValue[0],
originalValue: originalValue,
added: true
});
});
return variables;
}
return [];
},
translateEnvironmentVariablesToEnv: function(env) {
if (env) {
var variables = [];
env.forEach(function(variable) {
if (variable.key && variable.key !== '') {
variables.push(variable.key + '=' + variable.value);
}
});
return variables;
}
return [];
},
translatePreferencesToKeyValue: function(preferences) {
if (preferences) {
var keyValuePreferences = [];
preferences.forEach(function(preference) {
if (preference.Spread) {
keyValuePreferences.push({
strategy: 'Spread',
value: preference.Spread.SpreadDescriptor
});
}
});
return keyValuePreferences;
}
return [];
},
translateConstraintsToKeyValue: function(constraints) {
function getOperator(constraint) {
var indexEquals = constraint.indexOf('==');
if (indexEquals >= 0) {
return [indexEquals, '=='];
}
return [constraint.indexOf('!='), '!='];
}
if (constraints) {
var keyValueConstraints = [];
constraints.forEach(function(constraint) {
var operatorIndices = getOperator(constraint);
});
return preferences;
}
return [];
};
var key = constraint.slice(0, operatorIndices[0]);
var operator = operatorIndices[1];
var value = constraint.slice(operatorIndices[0] + 2);
helper.translateKeyValueToPlacementConstraints = function(keyValueConstraints) {
if (keyValueConstraints) {
var constraints = [];
keyValueConstraints.forEach(function(constraint) {
if (constraint.key && constraint.key !== '' && constraint.value && constraint.value !== '') {
constraints.push(constraint.key + constraint.operator + constraint.value);
}
});
return constraints;
}
return [];
};
keyValueConstraints.push({
key: key,
value: value,
operator: operator,
originalKey: key,
originalValue: value
});
helper.translateEnvironmentVariables = function(env) {
if (env) {
var variables = [];
env.forEach(function(variable) {
var idx = variable.indexOf('=');
var keyValue = [variable.slice(0, idx), variable.slice(idx + 1)];
var originalValue = (keyValue.length > 1) ? keyValue[1] : '';
variables.push({
key: keyValue[0],
value: originalValue,
originalKey: keyValue[0],
originalValue: originalValue,
added: true
});
return keyValueConstraints;
});
return variables;
}
return [];
};
helper.translateEnvironmentVariablesToEnv = function(env) {
if (env) {
var variables = [];
env.forEach(function(variable) {
if (variable.key && variable.key !== '') {
variables.push(variable.key + '=' + variable.value);
}
});
return variables;
}
return [];
};
helper.translatePreferencesToKeyValue = function(preferences) {
if (preferences) {
var keyValuePreferences = [];
preferences.forEach(function(preference) {
if (preference.Spread) {
keyValuePreferences.push({
strategy: 'Spread',
value: preference.Spread.SpreadDescriptor
});
}
});
return keyValuePreferences;
}
return [];
};
helper.translateConstraintsToKeyValue = function(constraints) {
function getOperator(constraint) {
var indexEquals = constraint.indexOf('==');
if (indexEquals >= 0) {
return [indexEquals, '=='];
}
return [];
return [constraint.indexOf('!='), '!='];
}
if (constraints) {
var keyValueConstraints = [];
constraints.forEach(function(constraint) {
var operatorIndices = getOperator(constraint);
var key = constraint.slice(0, operatorIndices[0]);
var operator = operatorIndices[1];
var value = constraint.slice(operatorIndices[0] + 2);
keyValueConstraints.push({
key: key,
value: value,
operator: operator,
originalKey: key,
originalValue: value
});
});
return keyValueConstraints;
}
};
return helper;
}]);

View file

@ -0,0 +1,21 @@
angular.module('portainer.helpers')
.factory('StackHelper', [function StackHelperFactory() {
'use strict';
var helper = {};
helper.getExternalStackNamesFromServices = function(services) {
var stackNames = [];
for (var i = 0; i < services.length; i++) {
var service = services[i];
if (!service.Labels || !service.Labels['com.docker.stack.namespace']) continue;
var stackName = service.Labels['com.docker.stack.namespace'];
stackNames.push(stackName);
}
return _.uniq(stackNames);
};
return helper;
}]);

9
app/models/api/stack.js Normal file
View file

@ -0,0 +1,9 @@
function StackViewModel(data) {
this.Id = data.Id;
this.Name = data.Name;
this.Checked = false;
if (data.ResourceControl && data.ResourceControl.Id !== 0) {
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
}
this.External = data.External;
}

View file

@ -8,9 +8,15 @@ function ContainerViewModel(data) {
this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress;
}
this.Image = data.Image;
this.ImageID = data.ImageID;
this.Command = data.Command;
this.Checked = false;
this.Labels = data.Labels;
if (this.Labels && this.Labels['com.docker.compose.project']) {
this.StackName = this.Labels['com.docker.compose.project'];
} else if (this.Labels && this.Labels['com.docker.stack.namespace']) {
this.StackName = this.Labels['com.docker.stack.namespace'];
}
this.Mounts = data.Mounts;
this.Ports = [];

View file

@ -8,6 +8,13 @@ function NetworkViewModel(data) {
this.Containers = data.Containers;
this.Options = data.Options;
this.Labels = data.Labels;
if (this.Labels && this.Labels['com.docker.compose.project']) {
this.StackName = this.Labels['com.docker.compose.project'];
} else if (this.Labels && this.Labels['com.docker.stack.namespace']) {
this.StackName = this.Labels['com.docker.stack.namespace'];
}
if (data.Portainer) {
if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);

View file

@ -1,6 +1,7 @@
function ServiceViewModel(data, runningTasks, nodes) {
this.Model = data;
this.Id = data.ID;
this.Tasks = [];
this.Name = data.Spec.Name;
this.CreatedAt = data.CreatedAt;
this.UpdatedAt = data.UpdatedAt;
@ -44,6 +45,9 @@ function ServiceViewModel(data, runningTasks, nodes) {
this.Preferences = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Preferences || [] : [];
this.Platforms = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Platforms || [] : [];
this.Labels = data.Spec.Labels;
if (this.Labels && this.Labels['com.docker.stack.namespace']) {
this.StackName = this.Labels['com.docker.stack.namespace'];
}
var containerSpec = data.Spec.TaskTemplate.ContainerSpec;
if (containerSpec) {

View file

@ -0,0 +1,3 @@
function SwarmViewModel(data) {
this.Id = data.ID;
}

View file

@ -3,6 +3,11 @@ function VolumeViewModel(data) {
this.Driver = data.Driver;
this.Options = data.Options;
this.Labels = data.Labels;
if (this.Labels && this.Labels['com.docker.compose.project']) {
this.StackName = this.Labels['com.docker.compose.project'];
} else if (this.Labels && this.Labels['com.docker.stack.namespace']) {
this.StackName = this.Labels['com.docker.stack.namespace'];
}
this.Mountpoint = data.Mountpoint;
if (data.Portainer) {

15
app/rest/api/stack.js Normal file
View file

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

View file

@ -6,7 +6,7 @@ angular.module('portainer.rest')
},
{
get: { method: 'GET', params: {id: '@id'} },
query: { method: 'GET', isArray: true },
query: { method: 'GET', isArray: true, params: {filters: '@filters'} },
create: {
method: 'POST', params: {action: 'create'},
headers: { 'X-Registry-Auth': HttpRequestHelper.registryAuthenticationHeader }

View file

@ -661,5 +661,44 @@ function configureRoutes($stateProvider) {
controller: 'SidebarController'
}
}
})
.state('actions.create.stack', {
url: '/stack',
views: {
'content@': {
templateUrl: 'app/components/createStack/createstack.html',
controller: 'CreateStackController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('stacks', {
url: '/stacks/',
views: {
'content@': {
templateUrl: 'app/components/stacks/stacks.html',
controller: 'StacksController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('stack', {
url: '^/stacks/:id/',
views: {
'content@': {
templateUrl: 'app/components/stack/stack.html',
controller: 'StackController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
});
}

View file

@ -0,0 +1,162 @@
angular.module('portainer.services')
.factory('StackService', ['$q', 'Stack', 'ResourceControlService', 'FileUploadService', 'StackHelper', 'ServiceService', 'SwarmService',
function StackServiceFactory($q, Stack, ResourceControlService, FileUploadService, StackHelper, ServiceService, 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.externalStacks = function() {
var deferred = $q.defer();
ServiceService.services()
.then(function success(data) {
var services = data;
var stackNames = StackHelper.getExternalStackNamesFromServices(services);
var stacks = stackNames.map(function (item) {
return new StackViewModel({ Name: item, External: true });
});
deferred.resolve(stacks);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve external stacks', err: err });
});
return deferred.promise;
};
service.stacks = function(includeExternalStacks) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return $q.all({
stacks: Stack.query({ swarmId: swarm.Id }).$promise,
externalStacks: includeExternalStacks ? service.externalStacks() : []
});
})
.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) {
var deferred = $q.defer();
Stack.remove({ id: stack.Id }).$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.createStackFromFileContent = function(name, stackFileContent) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return Stack.create({ method: 'string' }, { Name: name, SwarmID: swarm.Id, StackFileContent: stackFileContent }).$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.createStackFromGitRepository = function(name, gitRepository, pathInRepository) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return Stack.create({ method: 'repository' }, { Name: name, SwarmID: swarm.Id, GitRepository: gitRepository, PathInRepository: pathInRepository }).$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.createStackFromFileUpload = function(name, stackFile) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return FileUploadService.createStack(name, swarm.Id, stackFile);
})
.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.updateStack = function(id, stackFile) {
return Stack.update({ id: id, StackFileContent: stackFile }).$promise;
};
return service;
}]);

View file

@ -0,0 +1,21 @@
angular.module('portainer.services')
.factory('CodeMirrorService', function CodeMirrorService() {
'use strict';
var codeMirrorOptions = {
lineNumbers: true,
mode: 'text/x-yaml',
gutters: ['CodeMirror-lint-markers'],
lint: true
};
var service = {};
service.applyCodeMirrorOnElement = function(element) {
var cm = CodeMirror.fromTextArea(element, codeMirrorOptions);
cm.setSize('100%', 500);
return cm;
};
return service;
});

View file

@ -18,16 +18,20 @@ angular.module('portainer.services')
return deferred.promise;
};
service.containers = function(all) {
service.containers = function(all, filters) {
var deferred = $q.defer();
Container.query({ all: all }).$promise
Container.query({ all: all, filters: filters ? filters : {} }).$promise
.then(function success(data) {
var containers = data;
var containers = data.map(function (item) {
return new ContainerViewModel(item);
});
deferred.resolve(containers);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve containers', err: err });
});
return deferred.promise;
};

View file

@ -1,12 +1,12 @@
angular.module('portainer.services')
.factory('ServiceService', ['$q', 'Service', 'ResourceControlService', function ServiceServiceFactory($q, Service, ResourceControlService) {
.factory('ServiceService', ['$q', 'Service', 'ServiceHelper', 'TaskService', 'ResourceControlService', function ServiceServiceFactory($q, Service, ServiceHelper, TaskService, ResourceControlService) {
'use strict';
var service = {};
service.services = function() {
service.services = function(filters) {
var deferred = $q.defer();
Service.query().$promise
Service.query({ filters: filters ? filters : {} }).$promise
.then(function success(data) {
var services = data.map(function (item) {
return new ServiceViewModel(item);

View file

@ -0,0 +1,22 @@
angular.module('portainer.services')
.factory('SwarmService', ['$q', 'Swarm', function SwarmServiceFactory($q, Swarm) {
'use strict';
var service = {};
service.swarm = function() {
var deferred = $q.defer();
Swarm.get().$promise
.then(function success(data) {
var swarm = new SwarmViewModel(data);
deferred.resolve(swarm);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve Swarm details', err: err });
});
return deferred.promise;
};
return service;
}]);

View file

@ -3,23 +3,6 @@ angular.module('portainer.services')
'use strict';
var service = {};
service.tasks = function() {
var deferred = $q.defer();
Task.query().$promise
.then(function success(data) {
var tasks = data.map(function (item) {
return new TaskViewModel(item);
});
deferred.resolve(tasks);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve tasks', err: err });
});
return deferred.promise;
};
service.task = function(id) {
var deferred = $q.defer();
@ -35,10 +18,10 @@ angular.module('portainer.services')
return deferred.promise;
};
service.serviceTasks = function(serviceName) {
service.tasks = function(filters) {
var deferred = $q.defer();
Task.query({ filters: { service: [serviceName] } }).$promise
Task.query({ filters: filters ? filters : {} }).$promise
.then(function success(data) {
var tasks = data.map(function (item) {
return new TaskViewModel(item);
@ -46,7 +29,7 @@ angular.module('portainer.services')
deferred.resolve(tasks);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve tasks associated to the service', err: err });
deferred.reject({ msg: 'Unable to retrieve tasks', err: err });
});
return deferred.promise;

View file

@ -1,5 +1,5 @@
angular.module('portainer.services')
.factory('FileUploadService', ['$q', 'Upload', function FileUploadFactory($q, Upload) {
.factory('FileUploadService', ['$q', 'Upload', 'EndpointProvider', function FileUploadFactory($q, Upload, EndpointProvider) {
'use strict';
var service = {};
@ -8,6 +8,11 @@ angular.module('portainer.services')
return Upload.upload({ url: url, data: { file: file }});
}
service.createStack = function(stackName, swarmId, file) {
var endpointID = EndpointProvider.endpointID();
return Upload.upload({ url: 'api/endpoints/' + endpointID + '/stacks?method=file', data: { file: file, Name: stackName, SwarmID: swarmId } });
};
service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) {
var queue = [];

View file

@ -7,6 +7,10 @@ angular.module('portainer.services')
toastr.success($sanitize(text), $sanitize(title));
};
service.warning = function(title, text) {
toastr.warning($sanitize(text), $sanitize(title), {timeOut: 6000});
};
service.error = function(title, e, fallbackText) {
var msg = fallbackText;
if (e.data && e.data.message) {
@ -17,13 +21,14 @@ angular.module('portainer.services')
msg = e.err.data.message;
} else if (e.data && e.data.length > 0 && e.data[0].message) {
msg = e.data[0].message;
} else if (e.err && e.err.data && e.err.data.length > 0 && e.err.data[0].message) {
msg = e.err.data[0].message;
} else if (e.msg) {
msg = e.msg;
} else if (e.err && e.err.data && e.err.data.err) {
msg = e.err.data.err;
} else if (e.data && e.data.err) {
msg = e.data.err;
} else if (e.msg) {
msg = e.msg;
}
if (msg !== 'Invalid JWT token') {
toastr.error($sanitize(msg), $sanitize(title), {timeOut: 6000});
}