mirror of
https://github.com/portainer/portainer.git
synced 2025-07-21 14:29:40 +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:
parent
da143a7a22
commit
4d5836138b
16 changed files with 322 additions and 8 deletions
|
@ -1,5 +1,7 @@
|
||||||
package migrator
|
package migrator
|
||||||
|
|
||||||
|
import portainer "github.com/portainer/portainer/api"
|
||||||
|
|
||||||
func (m *Migrator) updateSettingsToDB24() error {
|
func (m *Migrator) updateSettingsToDB24() error {
|
||||||
legacySettings, err := m.settingsService.Settings()
|
legacySettings, err := m.settingsService.Settings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -12,3 +14,21 @@ func (m *Migrator) updateSettingsToDB24() error {
|
||||||
|
|
||||||
return m.settingsService.UpdateSettings(legacySettings)
|
return m.settingsService.UpdateSettings(legacySettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Migrator) updateStacksToDB24() error {
|
||||||
|
stacks, err := m.stackService.Stacks()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := range stacks {
|
||||||
|
stack := &stacks[idx]
|
||||||
|
stack.Status = portainer.StackStatusActive
|
||||||
|
err := m.stackService.UpdateStack(stack.ID, stack)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -335,6 +335,11 @@ func (m *Migrator) Migrate() error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = m.updateStacksToDB24()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.versionService.StoreDBVersion(portainer.DBVersion)
|
return m.versionService.StoreDBVersion(portainer.DBVersion)
|
||||||
|
|
|
@ -66,6 +66,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
|
||||||
EndpointID: endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||||
Env: payload.Env,
|
Env: payload.Env,
|
||||||
|
Status: portainer.StackStatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
|
@ -151,6 +152,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||||
EndpointID: endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
EntryPoint: payload.ComposeFilePathInRepository,
|
EntryPoint: payload.ComposeFilePathInRepository,
|
||||||
Env: payload.Env,
|
Env: payload.Env,
|
||||||
|
Status: portainer.StackStatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||||
|
@ -246,6 +248,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
|
||||||
EndpointID: endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||||
Env: payload.Env,
|
Env: payload.Env,
|
||||||
|
Status: portainer.StackStatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
|
|
|
@ -62,6 +62,7 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r
|
||||||
EndpointID: endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||||
Env: payload.Env,
|
Env: payload.Env,
|
||||||
|
Status: portainer.StackStatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
|
@ -151,6 +152,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
|
||||||
EndpointID: endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
EntryPoint: payload.ComposeFilePathInRepository,
|
EntryPoint: payload.ComposeFilePathInRepository,
|
||||||
Env: payload.Env,
|
Env: payload.Env,
|
||||||
|
Status: portainer.StackStatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||||
|
@ -254,6 +256,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r
|
||||||
EndpointID: endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||||
Env: payload.Env,
|
Env: payload.Env,
|
||||||
|
Status: portainer.StackStatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
|
|
|
@ -54,6 +54,10 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet)
|
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet)
|
||||||
h.Handle("/stacks/{id}/migrate",
|
h.Handle("/stacks/{id}/migrate",
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost)
|
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/stacks/{id}/start",
|
||||||
|
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackStart))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/stacks/{id}/stop",
|
||||||
|
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackStop))).Methods(http.MethodPost)
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
61
api/http/handler/stacks/stack_start.go
Normal file
61
api/http/handler/stacks/stack_start.go
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
package stacks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST request on /api/stacks/:id/start
|
||||||
|
func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
|
||||||
|
if err == bolterrors.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stack.Status == portainer.StackStatusActive {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Stack is already active", errors.New("Stack is already active")}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
|
||||||
|
if err == bolterrors.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.startStack(stack, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to stop stack", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.Status = portainer.StackStatusActive
|
||||||
|
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||||
|
switch stack.Type {
|
||||||
|
case portainer.DockerComposeStack:
|
||||||
|
return handler.ComposeStackManager.Up(stack, endpoint)
|
||||||
|
case portainer.DockerSwarmStack:
|
||||||
|
return handler.SwarmStackManager.Deploy(stack, true, endpoint)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
61
api/http/handler/stacks/stack_stop.go
Normal file
61
api/http/handler/stacks/stack_stop.go
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
package stacks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST request on /api/stacks/:id/stop
|
||||||
|
func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
|
||||||
|
if err == bolterrors.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stack.Status == portainer.StackStatusInactive {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Stack is already inactive", errors.New("Stack is already inactive")}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
|
||||||
|
if err == bolterrors.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.stopStack(stack, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to stop stack", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.Status = portainer.StackStatusInactive
|
||||||
|
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||||
|
switch stack.Type {
|
||||||
|
case portainer.DockerComposeStack:
|
||||||
|
return handler.ComposeStackManager.Down(stack, endpoint)
|
||||||
|
case portainer.DockerSwarmStack:
|
||||||
|
return handler.SwarmStackManager.Remove(stack, endpoint)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -547,12 +547,16 @@ type (
|
||||||
EntryPoint string `json:"EntryPoint"`
|
EntryPoint string `json:"EntryPoint"`
|
||||||
Env []Pair `json:"Env"`
|
Env []Pair `json:"Env"`
|
||||||
ResourceControl *ResourceControl `json:"ResourceControl"`
|
ResourceControl *ResourceControl `json:"ResourceControl"`
|
||||||
|
Status StackStatus `json:"Status"`
|
||||||
ProjectPath string
|
ProjectPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
|
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
|
||||||
StackID int
|
StackID int
|
||||||
|
|
||||||
|
// StackStatus represent a status for a stack
|
||||||
|
StackStatus int
|
||||||
|
|
||||||
// StackType represents the type of the stack (compose v2, stack deploy v3)
|
// StackType represents the type of the stack (compose v2, stack deploy v3)
|
||||||
StackType int
|
StackType int
|
||||||
|
|
||||||
|
@ -1302,6 +1306,13 @@ const (
|
||||||
KubernetesStack
|
KubernetesStack
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StackStatus represents a status for a stack
|
||||||
|
const (
|
||||||
|
_ StackStatus = iota
|
||||||
|
StackStatusActive
|
||||||
|
StackStatusInactive
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
_ TemplateType = iota
|
_ TemplateType = iota
|
||||||
// ContainerTemplate represents a container template
|
// ContainerTemplate represents a container template
|
||||||
|
|
|
@ -72,7 +72,7 @@
|
||||||
<table class="table table-hover nowrap-cells">
|
<table class="table table-hover nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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">
|
<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()" />
|
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
|
||||||
<label for="select_all"></label>
|
<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-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>
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
|
||||||
</a>
|
</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>
|
||||||
<th>
|
<th>
|
||||||
<a ng-click="$ctrl.changeOrderBy('Type')">
|
<a ng-click="$ctrl.changeOrderBy('Type')">
|
||||||
|
@ -102,7 +128,7 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<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 }"
|
ng-class="{ active: item.Checked }"
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
|
@ -112,6 +138,7 @@
|
||||||
</span>
|
</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>
|
<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="$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>
|
||||||
<td>{{ item.Type === 1 ? 'Swarm' : 'Compose' }}</td>
|
<td>{{ item.Type === 1 ? 'Swarm' : 'Compose' }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
|
@ -6,6 +6,15 @@ angular.module('portainer.app').controller('StacksDatatableController', [
|
||||||
function ($scope, $controller, DatatableService, Authentication) {
|
function ($scope, $controller, DatatableService, Authentication) {
|
||||||
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
|
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
|
||||||
|
|
||||||
|
this.filters = {
|
||||||
|
state: {
|
||||||
|
open: false,
|
||||||
|
enabled: false,
|
||||||
|
showActiveStacks: true,
|
||||||
|
showUnactiveStacks: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Do not allow external items
|
* Do not allow external items
|
||||||
*/
|
*/
|
||||||
|
@ -17,6 +26,19 @@ angular.module('portainer.app').controller('StacksDatatableController', [
|
||||||
return !(item.External && !this.isAdmin && !this.isEndpointAdmin);
|
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.$onInit = function () {
|
||||||
this.isAdmin = Authentication.isAdmin();
|
this.isAdmin = Authentication.isAdmin();
|
||||||
this.isEndpointAdmin = Authentication.hasAuthorizations(['EndpointResourcesAccess']);
|
this.isEndpointAdmin = Authentication.hasAuthorizations(['EndpointResourcesAccess']);
|
||||||
|
|
|
@ -12,6 +12,7 @@ export function StackViewModel(data) {
|
||||||
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
|
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
|
||||||
}
|
}
|
||||||
this.External = false;
|
this.External = false;
|
||||||
|
this.Status = data.Status;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExternalStackViewModel(name, type) {
|
export function ExternalStackViewModel(name, type) {
|
||||||
|
|
|
@ -15,6 +15,8 @@ angular.module('portainer.app').factory('Stack', [
|
||||||
remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } },
|
remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } },
|
||||||
getStackFile: { method: 'GET', params: { id: '@id', action: 'file' } },
|
getStackFile: { method: 'GET', params: { id: '@id', action: 'file' } },
|
||||||
migrate: { method: 'POST', params: { id: '@id', action: 'migrate', endpointId: '@endpointId' }, ignoreLoadingBar: true },
|
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' } },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -349,6 +349,16 @@ angular.module('portainer.app').factory('StackService', [
|
||||||
return $async(kubernetesDeployAsync, endpointId, namespace, content, compose);
|
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;
|
return service;
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -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) {
|
service.confirm = function (options) {
|
||||||
var box = bootbox.confirm({
|
var box = bootbox.confirm({
|
||||||
title: options.title,
|
title: options.title,
|
||||||
|
|
|
@ -47,6 +47,28 @@
|
||||||
Create template from stack
|
Create template from stack
|
||||||
</a>
|
</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">
|
<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>
|
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>
|
||||||
Delete this stack
|
Delete this stack
|
||||||
|
@ -140,7 +162,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12">
|
<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-hide="state.actionInProgress">Update the stack</span>
|
||||||
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
angular.module('portainer.app').controller('StackController', [
|
angular.module('portainer.app').controller('StackController', [
|
||||||
|
'$async',
|
||||||
'$q',
|
'$q',
|
||||||
'$scope',
|
'$scope',
|
||||||
'$state',
|
'$state',
|
||||||
|
@ -17,6 +18,7 @@ angular.module('portainer.app').controller('StackController', [
|
||||||
'GroupService',
|
'GroupService',
|
||||||
'ModalService',
|
'ModalService',
|
||||||
function (
|
function (
|
||||||
|
$async,
|
||||||
$q,
|
$q,
|
||||||
$scope,
|
$scope,
|
||||||
$state,
|
$state,
|
||||||
|
@ -187,6 +189,46 @@ angular.module('portainer.app').controller('StackController', [
|
||||||
$scope.stackFileContent = cm.getValue();
|
$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) {
|
function loadStack(id) {
|
||||||
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
|
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
|
||||||
|
|
||||||
|
@ -207,18 +249,25 @@ angular.module('portainer.app').controller('StackController', [
|
||||||
$scope.groups = data.groups;
|
$scope.groups = data.groups;
|
||||||
$scope.stack = stack;
|
$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({
|
return $q.all({
|
||||||
stackFile: StackService.getStackFile(id),
|
stackFile: StackService.getStackFile(id),
|
||||||
resources: stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name),
|
resources: resourcesPromise,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
$scope.stackFileContent = data.stackFile;
|
$scope.stackFileContent = data.stackFile;
|
||||||
|
if ($scope.stack.Status === 1) {
|
||||||
if ($scope.stack.Type === 1) {
|
if ($scope.stack.Type === 1) {
|
||||||
assignSwarmStackResources(data.resources, agentProxy);
|
assignSwarmStackResources(data.resources, agentProxy);
|
||||||
} else {
|
} else {
|
||||||
assignComposeStackResources(data.resources);
|
assignComposeStackResources(data.resources);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function error(err) {
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve stack details');
|
Notifications.error('Failure', err, 'Unable to retrieve stack details');
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue