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

feat(stacks): add the ability to stop a stack (#4042)

* feat(stacks): add stack status

* feat(stacks): add empty start/stop handlers

* feat(stacks): show start/stop button

* feat(stacks): implement stack stop

* feat(stacks): implement start stack

* feat(stacks): filter by active/inactive stacks

* fix(stacks): update authorizations for stack start/stop

* feat(stacks): assign default status on create

* fix(bolt): fix import

* fix(stacks): show external stacks

* fix(stacks): reload on stop/start

* feat(stacks): confirm before stop
This commit is contained in:
Chaim Lev-Ari 2020-08-04 01:18:53 +03:00 committed by GitHub
parent da143a7a22
commit 4d5836138b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 322 additions and 8 deletions

View file

@ -72,7 +72,7 @@
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open">
<span class="md-checkbox" ng-if="!$ctrl.offlineMode" authorization="PortainerStackCreate, PortainerStackDelete">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
@ -82,6 +82,32 @@
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
<div>
<span uib-dropdown-toggle ng-class="['table-filter', { 'filter-active': $ctrl.filters.state.enabled }]">
Filter
<i ng-class="['fa', { 'fa-filter': !$ctrl.filters.state.enabled, 'fa-check': $ctrl.filters.state.enabled }]" aria-hidden="true"></i>
</span>
</div>
<div class="dropdown-menu" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Filter by activity
</div>
<div class="menuContent">
<div class="md-checkbox">
<input id="filter_usage_activeStacks" type="checkbox" ng-model="$ctrl.filters.state.showActiveStacks" ng-change="$ctrl.onFilterChange()" />
<label for="filter_usage_activeStacks">Active stacks</label>
</div>
<div class="md-checkbox">
<input id="filter_usage_unactiveStacks" type="checkbox" ng-model="$ctrl.filters.state.showUnactiveStacks" ng-change="$ctrl.onFilterChange()" />
<label for="filter_usage_unactiveStacks">Inactive stacks</label>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.filters.state.open = false;">Close</a>
</div>
</div>
</div>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Type')">
@ -102,7 +128,7 @@
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{ active: item.Checked }"
>
<td>
@ -112,6 +138,7 @@
</span>
<a ng-if="!$ctrl.offlineMode" ui-sref="docker.stacks.stack({ name: item.Name, id: item.Id, type: item.Type, external: item.External })">{{ item.Name }}</a>
<span ng-if="$ctrl.offlineMode">{{ item.Name }}</span>
<span ng-if="item.Status == 2" style="margin-left: 10px;" class="label label-warning image-tag space-left">Inactive</span>
</td>
<td>{{ item.Type === 1 ? 'Swarm' : 'Compose' }}</td>
<td>

View file

@ -6,6 +6,15 @@ angular.module('portainer.app').controller('StacksDatatableController', [
function ($scope, $controller, DatatableService, Authentication) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
this.filters = {
state: {
open: false,
enabled: false,
showActiveStacks: true,
showUnactiveStacks: true,
},
};
/**
* Do not allow external items
*/
@ -17,6 +26,19 @@ angular.module('portainer.app').controller('StacksDatatableController', [
return !(item.External && !this.isAdmin && !this.isEndpointAdmin);
};
this.applyFilters = applyFilters.bind(this);
function applyFilters(stack) {
const { showActiveStacks, showUnactiveStacks } = this.filters.state;
return (stack.Status === 1 && showActiveStacks) || (stack.Status === 2 && showUnactiveStacks) || stack.External;
}
this.onFilterChange = onFilterChange.bind(this);
function onFilterChange() {
const { showActiveStacks, showUnactiveStacks } = this.filters.state;
this.filters.state.enabled = !showActiveStacks || !showUnactiveStacks;
DatatableService.setDataTableFilters(this.tableKey, this.filters);
}
this.$onInit = function () {
this.isAdmin = Authentication.isAdmin();
this.isEndpointAdmin = Authentication.hasAuthorizations(['EndpointResourcesAccess']);

View file

@ -12,6 +12,7 @@ export function StackViewModel(data) {
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
}
this.External = false;
this.Status = data.Status;
}
export function ExternalStackViewModel(name, type) {

View file

@ -15,6 +15,8 @@ angular.module('portainer.app').factory('Stack', [
remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } },
getStackFile: { method: 'GET', params: { id: '@id', action: 'file' } },
migrate: { method: 'POST', params: { id: '@id', action: 'migrate', endpointId: '@endpointId' }, ignoreLoadingBar: true },
start: { method: 'POST', params: { id: '@id', action: 'start' } },
stop: { method: 'POST', params: { id: '@id', action: 'stop' } },
}
);
},

View file

@ -349,6 +349,16 @@ angular.module('portainer.app').factory('StackService', [
return $async(kubernetesDeployAsync, endpointId, namespace, content, compose);
};
service.start = start;
function start(id) {
return Stack.start({ id }).$promise;
}
service.stop = stop;
function stop(id) {
return Stack.stop({ id }).$promise;
}
return service;
},
]);

View file

@ -37,6 +37,13 @@ angular.module('portainer.app').factory('ModalService', [
});
};
service.confirmAsync = confirmAsync;
function confirmAsync(options) {
return new Promise((resolve) => {
service.confirm({ ...options, callback: (confirmed) => resolve(confirmed) });
});
}
service.confirm = function (options) {
var box = bootbox.confirm({
title: options.title,

View file

@ -47,6 +47,28 @@
Create template from stack
</a>
<button
authorization="PortainerStackUpdate"
ng-if="!state.externalStack && stack.Status === 2"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-success"
ng-click="startStack()"
>
<i class="fa fa-play space-right" aria-hidden="true"></i>
Start this stack
</button>
<button
ng-if="!state.externalStack && stack.Status === 1"
authorization="PortainerStackUpdate"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-danger"
ng-click="stopStack()"
>
<i class="fa fa-stop space-right" aria-hidden="true"></i>
Stop this stack
</button>
<button authorization="PortainerStackDelete" class="btn btn-xs btn-danger" ng-click="removeStack()" ng-if="!state.externalStack || stack.Type === 1">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>
Delete this stack
@ -140,7 +162,13 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-sm btn-primary" ng-disabled="state.actionInProgress" ng-click="deployStack()" button-spinner="state.actionInProgress">
<button
type="button"
class="btn btn-sm btn-primary"
ng-disabled="state.actionInProgress || stack.Status === 2"
ng-click="deployStack()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Update the stack</span>
<span ng-show="state.actionInProgress">Deployment in progress...</span>
</button>

View file

@ -1,4 +1,5 @@
angular.module('portainer.app').controller('StackController', [
'$async',
'$q',
'$scope',
'$state',
@ -17,6 +18,7 @@ angular.module('portainer.app').controller('StackController', [
'GroupService',
'ModalService',
function (
$async,
$q,
$scope,
$state,
@ -187,6 +189,46 @@ angular.module('portainer.app').controller('StackController', [
$scope.stackFileContent = cm.getValue();
};
$scope.stopStack = stopStack;
function stopStack() {
return $async(stopStackAsync);
}
async function stopStackAsync() {
const confirmed = await ModalService.confirmAsync({
title: 'Are you sure?',
message: 'Are you sure you want to stop this stack?',
buttons: { confirm: { label: 'Stop', className: 'btn-danger' } },
});
if (!confirmed) {
return;
}
$scope.state.actionInProgress = true;
try {
await StackService.stop($scope.stack.Id);
$state.reload();
} catch (err) {
Notifications.error('Failure', err, 'Unable to stop stack');
}
$scope.state.actionInProgress = false;
}
$scope.startStack = startStack;
function startStack() {
return $async(startStackAsync);
}
async function startStackAsync() {
$scope.state.actionInProgress = true;
const id = $scope.stack.Id;
try {
await StackService.start(id);
$state.reload();
} catch (err) {
Notifications.error('Failure', err, 'Unable to start stack');
}
$scope.state.actionInProgress = false;
}
function loadStack(id) {
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
@ -207,17 +249,24 @@ angular.module('portainer.app').controller('StackController', [
$scope.groups = data.groups;
$scope.stack = stack;
let resourcesPromise = Promise.resolve({});
if (stack.Status === 1) {
resourcesPromise = stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name);
}
return $q.all({
stackFile: StackService.getStackFile(id),
resources: stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name),
resources: resourcesPromise,
});
})
.then(function success(data) {
$scope.stackFileContent = data.stackFile;
if ($scope.stack.Type === 1) {
assignSwarmStackResources(data.resources, agentProxy);
} else {
assignComposeStackResources(data.resources);
if ($scope.stack.Status === 1) {
if ($scope.stack.Type === 1) {
assignSwarmStackResources(data.resources, agentProxy);
} else {
assignComposeStackResources(data.resources);
}
}
})
.catch(function error(err) {