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:
parent
80827935da
commit
587e2fa673
77 changed files with 3219 additions and 702 deletions
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
119
app/components/createStack/createStackController.js
Normal file
119
app/components/createStack/createStackController.js
Normal 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();
|
||||
}]);
|
156
app/components/createStack/createstack.html
Normal file
156
app/components/createStack/createstack.html
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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() : []
|
||||
});
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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>
|
||||
|
|
54
app/components/stack/stack.html
Normal file
54
app/components/stack/stack.html
Normal 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>
|
78
app/components/stack/stackController.js
Normal file
78
app/components/stack/stackController.js
Normal 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();
|
||||
}]);
|
120
app/components/stacks/stacks.html
Normal file
120
app/components/stacks/stacks.html
Normal 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>
|
103
app/components/stacks/stacksController.js
Normal file
103
app/components/stacks/stacksController.js
Normal 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();
|
||||
}]);
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
8
app/directives/serviceList/por-service-list.js
Normal file
8
app/directives/serviceList/por-service-list.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
angular.module('portainer').component('porServiceList', {
|
||||
templateUrl: 'app/directives/serviceList/porServiceList.html',
|
||||
controller: 'porServiceListController',
|
||||
bindings: {
|
||||
'services': '<',
|
||||
'nodes': '<'
|
||||
}
|
||||
});
|
98
app/directives/serviceList/porServiceList.html
Normal file
98
app/directives/serviceList/porServiceList.html
Normal 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>
|
21
app/directives/serviceList/porServiceList.js
Normal file
21
app/directives/serviceList/porServiceList.js
Normal 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);
|
||||
};
|
||||
|
||||
}]);
|
8
app/directives/taskList/por-task-list.js
Normal file
8
app/directives/taskList/por-task-list.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
angular.module('portainer').component('porTaskList', {
|
||||
templateUrl: 'app/directives/taskList/porTaskList.html',
|
||||
controller: 'porTaskListController',
|
||||
bindings: {
|
||||
'tasks': '<',
|
||||
'nodes': '<'
|
||||
}
|
||||
});
|
78
app/directives/taskList/porTaskList.html
Normal file
78
app/directives/taskList/porTaskList.html
Normal 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>
|
19
app/directives/taskList/porTaskList.js
Normal file
19
app/directives/taskList/porTaskList.js
Normal 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);
|
||||
};
|
||||
}]);
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}]);
|
||||
|
|
21
app/helpers/stackHelper.js
Normal file
21
app/helpers/stackHelper.js
Normal 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
9
app/models/api/stack.js
Normal 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;
|
||||
}
|
|
@ -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 = [];
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
3
app/models/docker/swarm.js
Normal file
3
app/models/docker/swarm.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
function SwarmViewModel(data) {
|
||||
this.Id = data.ID;
|
||||
}
|
|
@ -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
15
app/rest/api/stack.js
Normal 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' } }
|
||||
});
|
||||
}]);
|
|
@ -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 }
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
162
app/services/api/stackService.js
Normal file
162
app/services/api/stackService.js
Normal 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;
|
||||
}]);
|
21
app/services/codeMirror.js
Normal file
21
app/services/codeMirror.js
Normal 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;
|
||||
});
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
22
app/services/docker/swarmService.js
Normal file
22
app/services/docker/swarmService.js
Normal 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;
|
||||
}]);
|
|
@ -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;
|
||||
|
|
|
@ -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 = [];
|
||||
|
||||
|
|
|
@ -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});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue