Compare commits
9 Commits
epic/CE-30
...
Chore--Add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47fb7f0aae | ||
|
|
bca32b02c7 | ||
|
|
a7ed6222b0 | ||
|
|
d0d38990c7 | ||
|
|
32a9a2e46b | ||
|
|
660bc2dadf | ||
|
|
4cbd231a5f | ||
|
|
6d5877ca1c | ||
|
|
dbb9a21384 |
26
ATTRIBUTIONS.md
Normal file
26
ATTRIBUTIONS.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Open Source License Attribution
|
||||
|
||||
This application uses Open Source components. You can find the source
|
||||
code of their open source projects along with license information below.
|
||||
We acknowledge and are grateful to these developers for their contributions
|
||||
to open source.
|
||||
|
||||
### [angular-json-tree](https://github.com/awendland/angular-json-tree)
|
||||
|
||||
by [Alex Wendland](https://github.com/awendland) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
### [caniuse-db](https://github.com/Fyrd/caniuse)
|
||||
|
||||
by [caniuse.com](caniuse.com) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
### [caniuse-lite](https://github.com/ben-eb/caniuse-lite)
|
||||
|
||||
by [caniuse.com](caniuse.com) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
### [spdx-exceptions](https://github.com/jslicense/spdx-exceptions.json)
|
||||
|
||||
by Kyle Mitchell using [SPDX](https://spdx.dev/) from Linux Foundation licensed under [CC BY 3.0 License](https://creativecommons.org/licenses/by/3.0/)
|
||||
|
||||
### [fontawesome-free](https://github.com/FortAwesome/Font-Awesome) Icons
|
||||
|
||||
by [Fort Awesome](https://fortawesome.com/) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/)
|
||||
@@ -36,6 +36,11 @@ func (w *ComposeWrapper) ComposeSyntaxMaxVersion() string {
|
||||
return portainer.ComposeSyntaxMaxVersion
|
||||
}
|
||||
|
||||
// NormalizeStackName returns a new stack name with unsupported characters replaced
|
||||
func (w *ComposeWrapper) NormalizeStackName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command
|
||||
func (w *ComposeWrapper) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
_, err := w.command([]string{"up", "-d"}, stack, endpoint)
|
||||
|
||||
@@ -66,6 +66,11 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err}
|
||||
}
|
||||
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
@@ -108,6 +113,9 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
for idx := range paginatedEndpoints {
|
||||
hideFields(&paginatedEndpoints[idx])
|
||||
paginatedEndpoints[idx].ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
|
||||
if paginatedEndpoints[idx].EdgeCheckinInterval == 0 {
|
||||
paginatedEndpoints[idx].EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("X-Total-Count", strconv.Itoa(filteredEndpointCount))
|
||||
|
||||
@@ -26,6 +26,8 @@ type registryCreatePayload struct {
|
||||
Password string `example:"registry_password"`
|
||||
// Gitlab specific details, required when type = 4
|
||||
Gitlab portainer.GitlabRegistryData
|
||||
// Quay specific details, required when type = 1
|
||||
Quay portainer.QuayRegistryData
|
||||
}
|
||||
|
||||
func (payload *registryCreatePayload) Validate(r *http.Request) error {
|
||||
@@ -74,6 +76,7 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Gitlab: payload.Gitlab,
|
||||
Quay: payload.Quay,
|
||||
}
|
||||
|
||||
err = handler.DataStore.Registry().CreateRegistry(registry)
|
||||
|
||||
@@ -24,6 +24,7 @@ type registryUpdatePayload struct {
|
||||
Password *string `example:"registry_password"`
|
||||
UserAccessPolicies portainer.UserAccessPolicies
|
||||
TeamAccessPolicies portainer.TeamAccessPolicies
|
||||
Quay *portainer.QuayRegistryData
|
||||
}
|
||||
|
||||
func (payload *registryUpdatePayload) Validate(r *http.Request) error {
|
||||
@@ -110,6 +111,10 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
registry.TeamAccessPolicies = payload.TeamAccessPolicies
|
||||
}
|
||||
|
||||
if payload.Quay != nil {
|
||||
registry.Quay = *payload.Quay
|
||||
}
|
||||
|
||||
err = handler.DataStore.Registry().UpdateRegistry(registry.ID, registry)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err}
|
||||
|
||||
@@ -5,9 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
@@ -18,13 +16,6 @@ import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
// this is coming from libcompose
|
||||
// https://github.com/portainer/libcompose/blob/master/project/context.go#L117-L120
|
||||
func normalizeStackName(name string) string {
|
||||
r := regexp.MustCompile("[^a-z0-9]+")
|
||||
return r.ReplaceAllString(strings.ToLower(name), "")
|
||||
}
|
||||
|
||||
type composeStackFromFileContentPayload struct {
|
||||
// Name of the stack
|
||||
Name string `example:"myStack" validate:"required"`
|
||||
@@ -38,7 +29,7 @@ func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) err
|
||||
if govalidator.IsNull(payload.Name) {
|
||||
return errors.New("Invalid stack name")
|
||||
}
|
||||
payload.Name = normalizeStackName(payload.Name)
|
||||
|
||||
if govalidator.IsNull(payload.StackFileContent) {
|
||||
return errors.New("Invalid stack file content")
|
||||
}
|
||||
@@ -49,9 +40,11 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
|
||||
var payload composeStackFromFileContentPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name)
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
@@ -76,7 +69,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Compose file on disk", Err: err}
|
||||
}
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
@@ -90,14 +83,14 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
|
||||
|
||||
err = handler.deployComposeStack(config)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
stack.CreatedBy = config.user.Username
|
||||
|
||||
err = handler.DataStore.Stack().CreateStack(stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
|
||||
}
|
||||
|
||||
doCleanUp = false
|
||||
@@ -129,16 +122,14 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
|
||||
if govalidator.IsNull(payload.Name) {
|
||||
return errors.New("Invalid stack name")
|
||||
}
|
||||
payload.Name = normalizeStackName(payload.Name)
|
||||
|
||||
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) {
|
||||
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
|
||||
}
|
||||
if govalidator.IsNull(payload.ComposeFilePathInRepository) {
|
||||
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -146,7 +137,12 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
var payload composeStackFromGitRepositoryPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name)
|
||||
if payload.ComposeFilePathInRepository == "" {
|
||||
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false)
|
||||
@@ -154,7 +150,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
}
|
||||
if !isUnique {
|
||||
errorMessage := fmt.Sprintf("A stack with the name '%s' is already running", payload.Name)
|
||||
errorMessage := fmt.Sprintf("A stack with the name '%s' already exists", payload.Name)
|
||||
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)}
|
||||
}
|
||||
|
||||
@@ -187,7 +183,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
|
||||
}
|
||||
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint)
|
||||
@@ -197,14 +193,14 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
|
||||
err = handler.deployComposeStack(config)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
stack.CreatedBy = config.user.Username
|
||||
|
||||
err = handler.DataStore.Stack().CreateStack(stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
|
||||
}
|
||||
|
||||
doCleanUp = false
|
||||
@@ -217,41 +213,43 @@ type composeStackFromFileUploadPayload struct {
|
||||
Env []portainer.Pair
|
||||
}
|
||||
|
||||
func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) error {
|
||||
func decodeRequestForm(r *http.Request) (*composeStackFromFileUploadPayload, error) {
|
||||
payload := &composeStackFromFileUploadPayload{}
|
||||
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
|
||||
if err != nil {
|
||||
return errors.New("Invalid stack name")
|
||||
return nil, errors.New("Invalid stack name")
|
||||
}
|
||||
payload.Name = normalizeStackName(name)
|
||||
payload.Name = name
|
||||
|
||||
composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file")
|
||||
if err != nil {
|
||||
return errors.New("Invalid Compose file. Ensure that the Compose file is uploaded correctly")
|
||||
return nil, errors.New("Invalid Compose file. Ensure that the Compose file is uploaded correctly")
|
||||
}
|
||||
payload.StackFileContent = composeFileContent
|
||||
|
||||
var env []portainer.Pair
|
||||
err = request.RetrieveMultiPartFormJSONValue(r, "Env", &env, true)
|
||||
if err != nil {
|
||||
return errors.New("Invalid Env parameter")
|
||||
return nil, errors.New("Invalid Env parameter")
|
||||
}
|
||||
payload.Env = env
|
||||
return nil
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
|
||||
payload := &composeStackFromFileUploadPayload{}
|
||||
err := payload.Validate(r)
|
||||
payload, err := decodeRequestForm(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name)
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
}
|
||||
if !isUnique {
|
||||
errorMessage := fmt.Sprintf("A stack with the name '%s' is already running", payload.Name)
|
||||
errorMessage := fmt.Sprintf("A stack with the name '%s' already exists", payload.Name)
|
||||
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)}
|
||||
}
|
||||
|
||||
@@ -270,7 +268,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, payload.StackFileContent)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Compose file on disk", Err: err}
|
||||
}
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
@@ -284,14 +282,14 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
|
||||
|
||||
err = handler.deployComposeStack(config)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
stack.CreatedBy = config.user.Username
|
||||
|
||||
err = handler.DataStore.Stack().CreateStack(stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
|
||||
}
|
||||
|
||||
doCleanUp = false
|
||||
@@ -310,23 +308,23 @@ type composeStackDeploymentConfig struct {
|
||||
func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) (*composeStackDeploymentConfig, *httperror.HandlerError) {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
|
||||
}
|
||||
|
||||
dockerhub, err := handler.DataStore.DockerHub().DockerHub()
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err}
|
||||
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve DockerHub details from the database", Err: err}
|
||||
}
|
||||
|
||||
registries, err := handler.DataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
|
||||
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve registries from the database", Err: err}
|
||||
}
|
||||
filteredRegistries := security.FilterRegistries(registries, securityContext)
|
||||
|
||||
user, err := handler.DataStore.User().User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
|
||||
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
|
||||
}
|
||||
|
||||
config := &composeStackDeploymentConfig{
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/libcompose/config"
|
||||
"github.com/portainer/libcompose/docker"
|
||||
@@ -64,6 +66,14 @@ func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string {
|
||||
return composeSyntaxMaxVersion
|
||||
}
|
||||
|
||||
// NormalizeStackName returns a new stack name with unsupported characters replaced
|
||||
func (manager *ComposeStackManager) NormalizeStackName(name string) string {
|
||||
// this is coming from libcompose
|
||||
// https://github.com/portainer/libcompose/blob/master/project/context.go#L117-L120
|
||||
r := regexp.MustCompile("[^a-z0-9]+")
|
||||
return r.ReplaceAllString(strings.ToLower(name), "")
|
||||
}
|
||||
|
||||
// Up will deploy a compose stack (equivalent of docker-compose up)
|
||||
func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
|
||||
|
||||
@@ -379,6 +379,12 @@ type (
|
||||
ProjectPath string `json:"ProjectPath"`
|
||||
}
|
||||
|
||||
// QuayRegistryData represents data required for Quay registry to work
|
||||
QuayRegistryData struct {
|
||||
UseOrganisation bool `json:"UseOrganisation"`
|
||||
OrganisationName string `json:"OrganisationName"`
|
||||
}
|
||||
|
||||
// JobType represents a job type
|
||||
JobType int
|
||||
|
||||
@@ -508,6 +514,7 @@ type (
|
||||
Password string `json:"Password,omitempty" example:"registry_password"`
|
||||
ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"`
|
||||
Gitlab GitlabRegistryData `json:"Gitlab"`
|
||||
Quay QuayRegistryData `json:"Quay"`
|
||||
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
|
||||
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
|
||||
|
||||
@@ -966,6 +973,7 @@ type (
|
||||
// ComposeStackManager represents a service to manage Compose stacks
|
||||
ComposeStackManager interface {
|
||||
ComposeSyntaxMaxVersion() string
|
||||
NormalizeStackName(name string) string
|
||||
Up(stack *Stack, endpoint *Endpoint) error
|
||||
Down(stack *Stack, endpoint *Endpoint) error
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ angular.module('portainer.docker').factory('ImageHelper', [
|
||||
if (registry.Registry.Type === RegistryTypes.GITLAB) {
|
||||
const slash = _.startsWith(registry.Image, ':') ? '' : '/';
|
||||
fullImageName = registry.Registry.URL + '/' + registry.Registry.Gitlab.ProjectPath + slash + registry.Image;
|
||||
}
|
||||
if (registry.Registry.Type === RegistryTypes.QUAY) {
|
||||
const name = registry.Registry.Quay.UseOrganisation ? registry.Registry.Quay.OrganisationName : registry.Registry.Username;
|
||||
const url = registry.Registry.URL ? registry.Registry.URL + '/' : '';
|
||||
fullImageName = url + name + '/' + registry.Image;
|
||||
} else {
|
||||
const url = registry.Registry.URL ? registry.Registry.URL + '/' : '';
|
||||
fullImageName = url + registry.Image;
|
||||
|
||||
@@ -5,9 +5,11 @@ import angular from 'angular';
|
||||
|
||||
class CreateConfigController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $transition$, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService) {
|
||||
constructor($async, $state, $transition$, $window, ModalService, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService) {
|
||||
this.$state = $state;
|
||||
this.$transition$ = $transition$;
|
||||
this.$window = $window;
|
||||
this.ModalService = ModalService;
|
||||
this.Notifications = Notifications;
|
||||
this.ConfigService = ConfigService;
|
||||
this.Authentication = Authentication;
|
||||
@@ -24,6 +26,7 @@ class CreateConfigController {
|
||||
|
||||
this.state = {
|
||||
formValidationError: '',
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
this.editorUpdate = this.editorUpdate.bind(this);
|
||||
@@ -31,6 +34,12 @@ class CreateConfigController {
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.formValues.displayCodeEditor && this.formValues.ConfigContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
if (!this.$transition$.params().id) {
|
||||
this.formValues.displayCodeEditor = true;
|
||||
return;
|
||||
@@ -53,6 +62,12 @@ class CreateConfigController {
|
||||
}
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.formValues.displayCodeEditor && this.formValues.ConfigContent && this.state.isEditorDirty) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
addLabel() {
|
||||
this.formValues.Labels.push({ name: '', value: '' });
|
||||
}
|
||||
@@ -122,6 +137,7 @@ class CreateConfigController {
|
||||
const userId = userDetails.ID;
|
||||
await this.ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
|
||||
this.Notifications.success('Config successfully created');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('docker.configs', {}, { reload: true });
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to create config');
|
||||
@@ -130,6 +146,7 @@ class CreateConfigController {
|
||||
|
||||
editorUpdate(cm) {
|
||||
this.formValues.ConfigContent = cm.getValue();
|
||||
this.state.isEditorDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
angular.module('portainer.docker').controller('BuildImageController', [
|
||||
'$scope',
|
||||
'$state',
|
||||
'$window',
|
||||
'ModalService',
|
||||
'BuildService',
|
||||
'Notifications',
|
||||
'HttpRequestHelper',
|
||||
function ($scope, $state, BuildService, Notifications, HttpRequestHelper) {
|
||||
function ($scope, $window, ModalService, BuildService, Notifications, HttpRequestHelper) {
|
||||
$scope.state = {
|
||||
BuildType: 'editor',
|
||||
actionInProgress: false,
|
||||
activeTab: 0,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
@@ -20,6 +22,12 @@ angular.module('portainer.docker').controller('BuildImageController', [
|
||||
NodeName: null,
|
||||
};
|
||||
|
||||
$window.onbeforeunload = () => {
|
||||
if ($scope.state.BuildType === 'editor' && $scope.formValues.DockerFileContent && $scope.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
$scope.addImageName = function () {
|
||||
$scope.formValues.ImageNames.push({ Name: '' });
|
||||
};
|
||||
@@ -93,6 +101,13 @@ angular.module('portainer.docker').controller('BuildImageController', [
|
||||
|
||||
$scope.editorUpdate = function (cm) {
|
||||
$scope.formValues.DockerFileContent = cm.getValue();
|
||||
$scope.state.isEditorDirty = true;
|
||||
};
|
||||
|
||||
this.uiCanExit = async function () {
|
||||
if ($scope.state.BuildType === 'editor' && $scope.formValues.DockerFileContent && $scope.state.isEditorDirty) {
|
||||
return ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
};
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -72,6 +72,7 @@ export class EdgeJobFormController {
|
||||
|
||||
editorUpdate(cm) {
|
||||
this.model.FileContent = cm.getValue();
|
||||
this.isEditorDirty = true;
|
||||
}
|
||||
|
||||
associateEndpoint(endpoint) {
|
||||
|
||||
@@ -14,5 +14,6 @@ angular.module('portainer.edge').component('edgeJobForm', {
|
||||
formAction: '<',
|
||||
formActionLabel: '@',
|
||||
actionInProgress: '<',
|
||||
isEditorDirty: '=',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,5 +6,6 @@ export class EditEdgeStackFormController {
|
||||
|
||||
editorUpdate(cm) {
|
||||
this.model.StackFileContent = cm.getValue();
|
||||
this.isEditorDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ angular.module('portainer.edge').component('editEdgeStackForm', {
|
||||
actionInProgress: '<',
|
||||
submitAction: '<',
|
||||
edgeGroups: '<',
|
||||
isEditorDirty: '=',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
form-action="$ctrl.create"
|
||||
form-action-label="Create edge job"
|
||||
action-in-progress="$ctrl.state.actionInProgress"
|
||||
is-editor-dirty="$ctrl.state.isEditorDirty"
|
||||
></edge-job-form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export class CreateEdgeJobViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $q, $state, EdgeJobService, GroupService, Notifications, TagService) {
|
||||
constructor($async, $q, $state, $window, ModalService, EdgeJobService, GroupService, Notifications, TagService) {
|
||||
this.state = {
|
||||
actionInProgress: false,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
this.model = {
|
||||
@@ -17,6 +18,8 @@ export class CreateEdgeJobViewController {
|
||||
this.$async = $async;
|
||||
this.$q = $q;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
this.ModalService = ModalService;
|
||||
this.Notifications = Notifications;
|
||||
this.GroupService = GroupService;
|
||||
this.EdgeJobService = EdgeJobService;
|
||||
@@ -37,6 +40,7 @@ export class CreateEdgeJobViewController {
|
||||
try {
|
||||
await this.createEdgeJob(method, this.model);
|
||||
this.Notifications.success('Edge job successfully created');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('edge.jobs', {}, { reload: true });
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to create Edge job');
|
||||
@@ -52,6 +56,12 @@ export class CreateEdgeJobViewController {
|
||||
return this.EdgeJobService.createEdgeJobFromFileUpload(model);
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.model.FileContent && this.state.isEditorDirty) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
try {
|
||||
const [groups, tags] = await Promise.all([this.GroupService.groups(), this.TagService.tags()]);
|
||||
@@ -60,5 +70,11 @@ export class CreateEdgeJobViewController {
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve page data');
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.model.FileContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
form-action="$ctrl.update"
|
||||
form-action-label="Update Edge job"
|
||||
action-in-progress="$ctrl.state.actionInProgress"
|
||||
is-editor-dirty="$ctrl.state.isEditorDirty"
|
||||
></edge-job-form>
|
||||
</uib-tab>
|
||||
|
||||
|
||||
@@ -2,15 +2,18 @@ import _ from 'lodash-es';
|
||||
|
||||
export class EdgeJobController {
|
||||
/* @ngInject */
|
||||
constructor($async, $q, $state, EdgeJobService, EndpointService, FileSaver, GroupService, HostBrowserService, Notifications, TagService) {
|
||||
constructor($async, $q, $state, $window, ModalService, EdgeJobService, EndpointService, FileSaver, GroupService, HostBrowserService, Notifications, TagService) {
|
||||
this.state = {
|
||||
actionInProgress: false,
|
||||
showEditorTab: false,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
this.$async = $async;
|
||||
this.$q = $q;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
this.ModalService = ModalService;
|
||||
this.EdgeJobService = EdgeJobService;
|
||||
this.EndpointService = EndpointService;
|
||||
this.FileSaver = FileSaver;
|
||||
@@ -43,6 +46,7 @@ export class EdgeJobController {
|
||||
try {
|
||||
await this.EdgeJobService.updateEdgeJob(model);
|
||||
this.Notifications.success('Edge job successfully updated');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('edge.jobs', {}, { reload: true });
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to update Edge job');
|
||||
@@ -121,6 +125,12 @@ export class EdgeJobController {
|
||||
this.state.showEditorTab = true;
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.edgeJob.FileContent !== this.oldFileContent && this.state.isEditorDirty) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
const { id, tab } = this.$state.params;
|
||||
this.state.activeTab = tab;
|
||||
@@ -138,6 +148,7 @@ export class EdgeJobController {
|
||||
]);
|
||||
|
||||
edgeJob.FileContent = file.FileContent;
|
||||
this.oldFileContent = edgeJob.FileContent;
|
||||
this.edgeJob = edgeJob;
|
||||
this.groups = groups;
|
||||
this.tags = tags;
|
||||
@@ -152,5 +163,11 @@ export class EdgeJobController {
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve endpoint list');
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.edgeJob.FileContent !== this.oldFileContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import _ from 'lodash-es';
|
||||
|
||||
export class CreateEdgeStackViewController {
|
||||
/* @ngInject */
|
||||
constructor($state, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async) {
|
||||
Object.assign(this, { $state, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async });
|
||||
constructor($state, $window, ModalService, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async) {
|
||||
Object.assign(this, { $state, $window, ModalService, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async });
|
||||
|
||||
this.formValues = {
|
||||
Name: '',
|
||||
@@ -24,6 +24,7 @@ export class CreateEdgeStackViewController {
|
||||
formValidationError: '',
|
||||
actionInProgress: false,
|
||||
StackType: null,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
this.edgeGroups = null;
|
||||
@@ -41,6 +42,12 @@ export class CreateEdgeStackViewController {
|
||||
this.onChangeMethod = this.onChangeMethod.bind(this);
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
try {
|
||||
this.edgeGroups = await this.EdgeGroupService.groups();
|
||||
@@ -55,6 +62,12 @@ export class CreateEdgeStackViewController {
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve Templates');
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
createStack() {
|
||||
@@ -97,6 +110,7 @@ export class CreateEdgeStackViewController {
|
||||
await this.createStackByMethod(name, method);
|
||||
|
||||
this.Notifications.success('Stack successfully deployed');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('edge.stacks');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Deployment error', err, 'Unable to deploy stack');
|
||||
@@ -149,5 +163,6 @@ export class CreateEdgeStackViewController {
|
||||
|
||||
editorUpdate(cm) {
|
||||
this.formValues.StackFileContent = cm.getValue();
|
||||
this.state.isEditorDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
model="$ctrl.formValues"
|
||||
action-in-progress="$ctrl.state.actionInProgress"
|
||||
submit-action="$ctrl.deployStack"
|
||||
is-editor-dirty="$ctrl.state.isEditorDirty"
|
||||
></edit-edge-stack-form>
|
||||
</div>
|
||||
</uib-tab>
|
||||
|
||||
@@ -2,9 +2,11 @@ import _ from 'lodash-es';
|
||||
|
||||
export class EditEdgeStackViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, EdgeGroupService, EdgeStackService, EndpointService, Notifications) {
|
||||
constructor($async, $state, $window, ModalService, EdgeGroupService, EdgeStackService, EndpointService, Notifications) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
this.ModalService = ModalService;
|
||||
this.EdgeGroupService = EdgeGroupService;
|
||||
this.EdgeStackService = EdgeStackService;
|
||||
this.EndpointService = EndpointService;
|
||||
@@ -16,6 +18,7 @@ export class EditEdgeStackViewController {
|
||||
this.state = {
|
||||
actionInProgress: false,
|
||||
activeTab: 0,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
this.deployStack = this.deployStack.bind(this);
|
||||
@@ -38,9 +41,22 @@ export class EditEdgeStackViewController {
|
||||
EdgeGroups: this.stack.EdgeGroups,
|
||||
Prune: this.stack.Prune,
|
||||
};
|
||||
this.oldFileContent = this.formValues.StackFileContent;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve stack data');
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.formValues.StackFileContent !== this.oldFileContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.formValues.StackFileContent !== this.oldFileContent && this.state.isEditorDirty) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
filterStackEndpoints(groupIds, groups) {
|
||||
@@ -64,6 +80,7 @@ export class EditEdgeStackViewController {
|
||||
}
|
||||
await this.EdgeStackService.updateStack(this.stack.Id, this.formValues);
|
||||
this.Notifications.success('Stack successfully deployed');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('edge.stacks');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Deployment error', err, 'Unable to deploy stack');
|
||||
|
||||
@@ -5,5 +5,6 @@ angular.module('portainer.kubernetes').component('kubernetesConfigurationData',
|
||||
formValues: '=',
|
||||
isValid: '=',
|
||||
isCreation: '=',
|
||||
isEditorDirty: '=',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ class KubernetesConfigurationDataController {
|
||||
|
||||
async editorUpdateAsync(cm) {
|
||||
this.formValues.DataYaml = cm.getValue();
|
||||
this.isEditorDirty = true;
|
||||
}
|
||||
|
||||
editorUpdate(cm) {
|
||||
|
||||
@@ -69,7 +69,7 @@ class KubernetesServiceConverter {
|
||||
payload.metadata.namespace = service.Namespace;
|
||||
payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = service.StackName;
|
||||
payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = service.ApplicationName;
|
||||
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = service.Application;
|
||||
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = service.ApplicationOwner;
|
||||
payload.spec.ports = service.Ports;
|
||||
payload.spec.selector.app = service.ApplicationName;
|
||||
if (service.Headless) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import { KubernetesNode, KubernetesNodeDetails, KubernetesNodeTaint } from 'Kubernetes/node/models';
|
||||
import { KubernetesNode, KubernetesNodeDetails, KubernetesNodeTaint, KubernetesNodeAvailabilities, KubernetesPortainerNodeDrainLabel } from 'Kubernetes/node/models';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import { KubernetesNodeFormValues, KubernetesNodeTaintFormValues, KubernetesNodeLabelFormValues } from 'Kubernetes/node/formValues';
|
||||
import { KubernetesNodeCreatePayload, KubernetesNodeTaintPayload } from 'Kubernetes/node/payload';
|
||||
@@ -30,6 +30,11 @@ class KubernetesNodeConverter {
|
||||
NetworkUnavailable: networkUnavailable && networkUnavailable.status === 'True',
|
||||
};
|
||||
|
||||
res.Availability = KubernetesNodeAvailabilities.ACTIVE;
|
||||
if (data.spec.unschedulable === true) {
|
||||
res.Availability = _.has(data.metadata.labels, KubernetesPortainerNodeDrainLabel) ? KubernetesNodeAvailabilities.DRAIN : KubernetesNodeAvailabilities.PAUSE;
|
||||
}
|
||||
|
||||
if (ready.status === 'False') {
|
||||
res.Status = 'Unhealthy';
|
||||
} else if (ready.status === 'Unknown' || res.Conditions.MemoryPressure || res.Conditions.PIDPressure || res.Conditions.DiskPressure || res.Conditions.NetworkUnavailable) {
|
||||
@@ -67,6 +72,8 @@ class KubernetesNodeConverter {
|
||||
static nodeToFormValues(node) {
|
||||
const res = new KubernetesNodeFormValues();
|
||||
|
||||
res.Availability = node.Availability;
|
||||
|
||||
res.Taints = _.map(node.Taints, (taint) => {
|
||||
const res = new KubernetesNodeTaintFormValues();
|
||||
res.Key = taint.Key;
|
||||
@@ -92,6 +99,8 @@ class KubernetesNodeConverter {
|
||||
static formValuesToNode(node, formValues) {
|
||||
const res = angular.copy(node);
|
||||
|
||||
res.Availability = formValues.Availability;
|
||||
|
||||
const filteredTaints = _.filter(formValues.Taints, (taint) => !taint.NeedsDeletion);
|
||||
res.Taints = _.map(filteredTaints, (item) => {
|
||||
const taint = new KubernetesNodeTaint();
|
||||
@@ -130,6 +139,15 @@ class KubernetesNodeConverter {
|
||||
|
||||
payload.metadata.labels = node.Labels;
|
||||
|
||||
if (node.Availability !== KubernetesNodeAvailabilities.ACTIVE) {
|
||||
payload.spec.unschedulable = true;
|
||||
if (node.Availability === KubernetesNodeAvailabilities.DRAIN) {
|
||||
payload.metadata.labels[KubernetesPortainerNodeDrainLabel] = '';
|
||||
} else {
|
||||
delete payload.metadata.labels[KubernetesPortainerNodeDrainLabel];
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const _KubernetesNodeFormValues = Object.freeze({
|
||||
Taints: [],
|
||||
Labels: [],
|
||||
Availability: '',
|
||||
});
|
||||
|
||||
export class KubernetesNodeFormValues {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const KubernetesPortainerNodeDrainLabel = 'io.portainer/node-status-drain';
|
||||
|
||||
/**
|
||||
* KubernetesNode Model
|
||||
*/
|
||||
@@ -14,6 +16,7 @@ const _KubernetesNode = Object.freeze({
|
||||
Api: false,
|
||||
Taints: [],
|
||||
Port: 0,
|
||||
Availability: '',
|
||||
});
|
||||
|
||||
export class KubernetesNode {
|
||||
@@ -58,6 +61,12 @@ export class KubernetesNodeTaint {
|
||||
}
|
||||
}
|
||||
|
||||
export const KubernetesNodeAvailabilities = Object.freeze({
|
||||
ACTIVE: 'Active',
|
||||
PAUSE: 'Pause',
|
||||
DRAIN: 'Drain',
|
||||
});
|
||||
|
||||
export const KubernetesNodeTaintEffects = Object.freeze({
|
||||
NOSCHEDULE: 'NoSchedule',
|
||||
PREFERNOSCHEDULE: 'PreferNoSchedule',
|
||||
|
||||
@@ -57,7 +57,8 @@ class KubernetesNodeService {
|
||||
const newNode = KubernetesNodeConverter.formValuesToNode(node, nodeFormValues);
|
||||
const payload = KubernetesNodeConverter.patchPayload(node, newNode);
|
||||
const data = await this.KubernetesNodes().patch(params, payload).$promise;
|
||||
return data;
|
||||
const patchedNode = KubernetesNodeConverter.apiToNodeDetails(data);
|
||||
return patchedNode;
|
||||
} catch (err) {
|
||||
throw { msg: 'Unable to patch node', err: err };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from 'Kubernetes/models/application/models';
|
||||
|
||||
import { createPayloadFactory } from './payloads/create';
|
||||
import { KubernetesPod, KubernetesPodToleration, KubernetesPodAffinity, KubernetesPodContainer, KubernetesPodContainerTypes } from './models';
|
||||
import { KubernetesPod, KubernetesPodToleration, KubernetesPodAffinity, KubernetesPodContainer, KubernetesPodContainerTypes, KubernetesPodEviction } from 'Kubernetes/pod/models';
|
||||
|
||||
function computeStatus(statuses) {
|
||||
const containerStatuses = _.map(statuses, 'state');
|
||||
@@ -117,6 +117,13 @@ export default class KubernetesPodConverter {
|
||||
return res;
|
||||
}
|
||||
|
||||
static evictionPayload(pod) {
|
||||
const res = new KubernetesPodEviction();
|
||||
res.metadata.name = pod.Name;
|
||||
res.metadata.namespace = pod.Namespace;
|
||||
return res;
|
||||
}
|
||||
|
||||
static patchPayload(oldPod, newPod) {
|
||||
const oldPayload = createPayload(oldPod);
|
||||
const newPayload = createPayload(newPod);
|
||||
|
||||
@@ -65,6 +65,21 @@ export class KubernetesPodContainer {
|
||||
}
|
||||
}
|
||||
|
||||
const _KubernetesPodEviction = Object.freeze({
|
||||
apiVersion: 'policy/v1beta1',
|
||||
kind: 'Eviction',
|
||||
metadata: {
|
||||
name: '',
|
||||
namespace: '',
|
||||
},
|
||||
});
|
||||
|
||||
export class KubernetesPodEviction {
|
||||
constructor() {
|
||||
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPodEviction)));
|
||||
}
|
||||
}
|
||||
|
||||
export const KubernetesPodContainerTypes = {
|
||||
INIT: 1,
|
||||
APP: 2,
|
||||
|
||||
@@ -2,7 +2,7 @@ import angular from 'angular';
|
||||
import PortainerError from 'Portainer/error';
|
||||
|
||||
import { KubernetesCommonParams } from 'Kubernetes/models/common/params';
|
||||
import KubernetesPodConverter from './converter';
|
||||
import KubernetesPodConverter from 'Kubernetes/pod/converter';
|
||||
|
||||
class KubernetesPodService {
|
||||
/* @ngInject */
|
||||
@@ -15,6 +15,7 @@ class KubernetesPodService {
|
||||
this.logsAsync = this.logsAsync.bind(this);
|
||||
this.deleteAsync = this.deleteAsync.bind(this);
|
||||
this.patchAsync = this.patchAsync.bind(this);
|
||||
this.evictionAsync = this.evictionAsync.bind(this);
|
||||
}
|
||||
|
||||
async getAsync(namespace, name) {
|
||||
@@ -116,6 +117,26 @@ class KubernetesPodService {
|
||||
delete(pod) {
|
||||
return this.$async(this.deleteAsync, pod);
|
||||
}
|
||||
|
||||
/**
|
||||
* EVICT
|
||||
*/
|
||||
async evictionAsync(pod) {
|
||||
try {
|
||||
const params = new KubernetesCommonParams();
|
||||
params.id = pod.Name;
|
||||
params.action = 'eviction';
|
||||
const namespace = pod.Namespace;
|
||||
const podEvictionPayload = KubernetesPodConverter.evictionPayload(pod);
|
||||
await this.KubernetesPods(namespace).evict(params, podEvictionPayload).$promise;
|
||||
} catch (err) {
|
||||
throw new PortainerError('Unable to evict pod', err);
|
||||
}
|
||||
}
|
||||
|
||||
eviction(pod) {
|
||||
return this.$async(this.evictionAsync, pod);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesPodService;
|
||||
|
||||
@@ -42,6 +42,7 @@ angular.module('portainer.kubernetes').factory('KubernetesPods', [
|
||||
params: { action: 'log' },
|
||||
transformResponse: logsHandler,
|
||||
},
|
||||
evict: { method: 'POST' },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,6 +52,26 @@
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-xs-3">
|
||||
Availability
|
||||
</td>
|
||||
<td class="col-xs-9">
|
||||
<select class="form-control" name="availability" style="display: inline-block; width: 16rem;" ng-model="ctrl.formValues.Availability">
|
||||
<option>{{ ctrl.availabilities.ACTIVE }}</option>
|
||||
<option>{{ ctrl.availabilities.PAUSE }}</option>
|
||||
<option>{{ ctrl.availabilities.DRAIN }}</option>
|
||||
</select>
|
||||
<span class="small text-warning" ng-if="ctrl.state.isDrainOperation && ctrl.formValues.Availability === ctrl.availabilities.DRAIN">
|
||||
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Cannot use this action while another node is currently being drained.
|
||||
</span>
|
||||
<span class="small text-warning" ng-if="ctrl.state.isContainPortainer && ctrl.formValues.Availability === ctrl.availabilities.DRAIN">
|
||||
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Cannot drain a node where this Portainer instance is running.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reserv
|
||||
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
|
||||
import KubernetesNodeConverter from 'Kubernetes/node/converter';
|
||||
import { KubernetesNodeLabelFormValues, KubernetesNodeTaintFormValues } from 'Kubernetes/node/formValues';
|
||||
import { KubernetesNodeTaintEffects } from 'Kubernetes/node/models';
|
||||
import { KubernetesNodeTaintEffects, KubernetesNodeAvailabilities } from 'Kubernetes/node/models';
|
||||
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
||||
import { KubernetesNodeHelper } from 'Kubernetes/node/helper';
|
||||
|
||||
@@ -35,12 +35,13 @@ class KubernetesNodeController {
|
||||
this.KubernetesEndpointService = KubernetesEndpointService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.getNodeAsync = this.getNodeAsync.bind(this);
|
||||
this.getNodesAsync = this.getNodesAsync.bind(this);
|
||||
this.getEvents = this.getEvents.bind(this);
|
||||
this.getEventsAsync = this.getEventsAsync.bind(this);
|
||||
this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
|
||||
this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
|
||||
this.updateNodeAsync = this.updateNodeAsync.bind(this);
|
||||
this.drainNodeAsync = this.drainNodeAsync.bind(this);
|
||||
}
|
||||
|
||||
selectTab(index) {
|
||||
@@ -152,6 +153,47 @@ class KubernetesNodeController {
|
||||
|
||||
/* #endregion */
|
||||
|
||||
/* #region cordon */
|
||||
|
||||
computeCordonWarning() {
|
||||
return this.formValues.Availability === this.availabilities.PAUSE;
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
/* #region drain */
|
||||
|
||||
computeDrainWarning() {
|
||||
return this.formValues.Availability === this.availabilities.DRAIN;
|
||||
}
|
||||
|
||||
async drainNodeAsync() {
|
||||
const pods = _.flatten(_.map(this.applications, (app) => app.Pods));
|
||||
let actionCount = pods.length;
|
||||
for (const pod of pods) {
|
||||
try {
|
||||
await this.KubernetesPodService.eviction(pod);
|
||||
this.Notifications.success('Pod successfully evicted', pod.Name);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to evict pod');
|
||||
this.formValues.Availability = this.availabilities.PAUSE;
|
||||
await this.KubernetesNodeService.patch(this.node, this.formValues);
|
||||
} finally {
|
||||
--actionCount;
|
||||
if (actionCount === 0) {
|
||||
this.formValues.Availability = this.availabilities.PAUSE;
|
||||
await this.KubernetesNodeService.patch(this.node, this.formValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drainNode() {
|
||||
return this.$async(this.drainNodeAsync);
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
/* #region actions */
|
||||
|
||||
isNoChangesMade() {
|
||||
@@ -160,8 +202,12 @@ class KubernetesNodeController {
|
||||
return !payload.length;
|
||||
}
|
||||
|
||||
isDrainError() {
|
||||
return (this.state.isDrainOperation || this.state.isContainPortainer) && this.formValues.Availability === this.availabilities.DRAIN;
|
||||
}
|
||||
|
||||
isFormValid() {
|
||||
return !this.state.hasDuplicateTaintKeys && !this.state.hasDuplicateLabelKeys && !this.isNoChangesMade();
|
||||
return !this.state.hasDuplicateTaintKeys && !this.state.hasDuplicateLabelKeys && !this.isNoChangesMade() && !this.isDrainError();
|
||||
}
|
||||
|
||||
resetFormValues() {
|
||||
@@ -196,7 +242,10 @@ class KubernetesNodeController {
|
||||
|
||||
async updateNodeAsync() {
|
||||
try {
|
||||
await this.KubernetesNodeService.patch(this.node, this.formValues);
|
||||
this.node = await this.KubernetesNodeService.patch(this.node, this.formValues);
|
||||
if (this.formValues.Availability === 'Drain') {
|
||||
await this.drainNode();
|
||||
}
|
||||
this.Notifications.success('Node updated successfully');
|
||||
this.$state.reload();
|
||||
} catch (err) {
|
||||
@@ -207,6 +256,8 @@ class KubernetesNodeController {
|
||||
updateNode() {
|
||||
const taintsWarning = this.computeTaintsWarning();
|
||||
const labelsWarning = this.computeLabelsWarning();
|
||||
const cordonWarning = this.computeCordonWarning();
|
||||
const drainWarning = this.computeDrainWarning();
|
||||
|
||||
if (taintsWarning && !labelsWarning) {
|
||||
this.ModalService.confirmUpdate(
|
||||
@@ -235,16 +286,36 @@ class KubernetesNodeController {
|
||||
}
|
||||
}
|
||||
);
|
||||
} else if (cordonWarning) {
|
||||
this.ModalService.confirmUpdate(
|
||||
'Marking this node as unschedulable will effectively cordon the node and prevent any new workload from being scheduled on that node. Are you sure?',
|
||||
(confirmed) => {
|
||||
if (confirmed) {
|
||||
return this.$async(this.updateNodeAsync);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else if (drainWarning) {
|
||||
this.ModalService.confirmUpdate(
|
||||
'Draining this node will cause all workloads to be evicted from that node. This might lead to some service interruption. Are you sure?',
|
||||
(confirmed) => {
|
||||
if (confirmed) {
|
||||
return this.$async(this.updateNodeAsync);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return this.$async(this.updateNodeAsync);
|
||||
}
|
||||
}
|
||||
|
||||
async getNodeAsync() {
|
||||
async getNodesAsync() {
|
||||
try {
|
||||
this.state.dataLoading = true;
|
||||
const nodeName = this.$transition$.params().name;
|
||||
this.node = await this.KubernetesNodeService.get(nodeName);
|
||||
this.nodes = await this.KubernetesNodeService.get();
|
||||
this.node = _.find(this.nodes, { Name: nodeName });
|
||||
this.state.isDrainOperation = _.find(this.nodes, { Availability: this.availabilities.DRAIN });
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve node');
|
||||
} finally {
|
||||
@@ -252,8 +323,8 @@ class KubernetesNodeController {
|
||||
}
|
||||
}
|
||||
|
||||
getNode() {
|
||||
return this.$async(this.getNodeAsync);
|
||||
getNodes() {
|
||||
return this.$async(this.getNodesAsync);
|
||||
}
|
||||
|
||||
hasEventWarnings() {
|
||||
@@ -303,6 +374,7 @@ class KubernetesNodeController {
|
||||
});
|
||||
this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(this.resourceReservation.Memory);
|
||||
this.memoryLimit = KubernetesResourceReservationHelper.megaBytesValue(this.node.Memory);
|
||||
this.state.isContainPortainer = _.find(this.applications, { ApplicationName: 'portainer' });
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
|
||||
} finally {
|
||||
@@ -328,11 +400,15 @@ class KubernetesNodeController {
|
||||
hasDuplicateTaintKeys: false,
|
||||
duplicateLabelKeys: [],
|
||||
hasDuplicateLabelKeys: false,
|
||||
isDrainOperation: false,
|
||||
isContainPortainer: false,
|
||||
};
|
||||
|
||||
this.availabilities = KubernetesNodeAvailabilities;
|
||||
|
||||
this.state.activeTab = this.LocalStorage.getActiveTab('node');
|
||||
|
||||
await this.getNode();
|
||||
await this.getNodes();
|
||||
await this.getEvents();
|
||||
await this.getApplications();
|
||||
await this.getEndpoints();
|
||||
|
||||
@@ -114,11 +114,13 @@
|
||||
<a href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">official documentation</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<kubernetes-configuration-data
|
||||
ng-if="ctrl.formValues"
|
||||
form-values="ctrl.formValues"
|
||||
is-valid="ctrl.state.isDataValid"
|
||||
is-creation="true"
|
||||
is-editor-dirty="ctrl.state.isEditorDirty"
|
||||
></kubernetes-configuration-data>
|
||||
|
||||
<!-- actions -->
|
||||
|
||||
@@ -6,9 +6,11 @@ import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelpe
|
||||
|
||||
class KubernetesCreateConfigurationController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, KubernetesNamespaceHelper) {
|
||||
constructor($async, $state, $window, ModalService, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, KubernetesNamespaceHelper) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
this.ModalService = ModalService;
|
||||
this.Notifications = Notifications;
|
||||
this.Authentication = Authentication;
|
||||
this.KubernetesConfigurationService = KubernetesConfigurationService;
|
||||
@@ -47,6 +49,7 @@ class KubernetesCreateConfigurationController {
|
||||
}
|
||||
await this.KubernetesConfigurationService.create(this.formValues);
|
||||
this.Notifications.success('Configuration succesfully created');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('kubernetes.configurations');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to create configuration');
|
||||
@@ -71,12 +74,19 @@ class KubernetesCreateConfigurationController {
|
||||
return this.$async(this.getConfigurationsAsync);
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (!this.formValues.IsSimple && this.formValues.DataYaml && this.state.isEditorDirty) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
this.state = {
|
||||
actionInProgress: false,
|
||||
viewReady: false,
|
||||
alreadyExist: false,
|
||||
isDataValid: true,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
this.formValues = new KubernetesConfigurationFormValues();
|
||||
@@ -93,6 +103,12 @@ class KubernetesCreateConfigurationController {
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (!this.formValues.IsSimple && this.formValues.DataYaml && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
form-values="ctrl.formValues"
|
||||
is-valid="ctrl.state.isDataValid"
|
||||
is-creation="false"
|
||||
is-editor-dirty="ctrl.state.isEditorDirty"
|
||||
></kubernetes-configuration-data>
|
||||
|
||||
<!-- actions -->
|
||||
|
||||
@@ -11,6 +11,7 @@ class KubernetesConfigurationController {
|
||||
constructor(
|
||||
$async,
|
||||
$state,
|
||||
$window,
|
||||
clipboard,
|
||||
Notifications,
|
||||
LocalStorage,
|
||||
@@ -25,6 +26,7 @@ class KubernetesConfigurationController {
|
||||
) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
this.clipboard = clipboard;
|
||||
this.Notifications = Notifications;
|
||||
this.LocalStorage = LocalStorage;
|
||||
@@ -143,6 +145,7 @@ class KubernetesConfigurationController {
|
||||
this.formValues.Id = this.configuration.Id;
|
||||
this.formValues.Name = this.configuration.Name;
|
||||
this.formValues.Type = this.configuration.Type;
|
||||
this.oldDataYaml = this.formValues.DataYaml;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve configuration');
|
||||
} finally {
|
||||
@@ -221,6 +224,12 @@ class KubernetesConfigurationController {
|
||||
});
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (!this.formValues.IsSimple && this.formValues.DataYaml !== this.oldDataYaml && this.state.isEditorDirty) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
try {
|
||||
this.state = {
|
||||
@@ -234,6 +243,7 @@ class KubernetesConfigurationController {
|
||||
activeTab: 0,
|
||||
currentName: this.$state.$current.name,
|
||||
isDataValid: true,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
this.state.activeTab = this.LocalStorage.getActiveTab('configuration');
|
||||
@@ -252,6 +262,12 @@ class KubernetesConfigurationController {
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (!this.formValues.IsSimple && this.formValues.DataYaml !== this.oldDataYaml && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
|
||||
@@ -5,9 +5,11 @@ import { KubernetesDeployManifestTypes } from 'Kubernetes/models/deploy';
|
||||
|
||||
class KubernetesDeployController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Notifications, EndpointProvider, KubernetesResourcePoolService, StackService) {
|
||||
constructor($async, $state, $window, ModalService, Notifications, EndpointProvider, KubernetesResourcePoolService, StackService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
this.ModalService = ModalService;
|
||||
this.Notifications = Notifications;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
@@ -26,6 +28,7 @@ class KubernetesDeployController {
|
||||
|
||||
async editorUpdateAsync(cm) {
|
||||
this.formValues.EditorContent = cm.getValue();
|
||||
this.state.isEditorDirty = true;
|
||||
}
|
||||
|
||||
editorUpdate(cm) {
|
||||
@@ -46,6 +49,7 @@ class KubernetesDeployController {
|
||||
const compose = this.state.DeployType === this.ManifestDeployTypes.COMPOSE;
|
||||
await this.StackService.kubernetesDeploy(this.endpointId, this.formValues.Namespace, this.formValues.EditorContent, compose);
|
||||
this.Notifications.success('Manifest successfully deployed');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('kubernetes.applications');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Unable to deploy manifest', err, 'Unable to deploy resources');
|
||||
@@ -73,12 +77,19 @@ class KubernetesDeployController {
|
||||
return this.$async(this.getNamespacesAsync);
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.formValues.EditorContent && this.state.isEditorDirty) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
this.state = {
|
||||
DeployType: KubernetesDeployManifestTypes.KUBERNETES,
|
||||
tabLogsDisabled: true,
|
||||
activeTab: 0,
|
||||
viewReady: false,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
this.formValues = {};
|
||||
@@ -88,6 +99,12 @@ class KubernetesDeployController {
|
||||
await this.getNamespaces();
|
||||
|
||||
this.state.viewReady = true;
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.formValues.EditorContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
|
||||
@@ -32,6 +32,35 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-password -->
|
||||
|
||||
<!-- organisation-checkbox -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Use organisation registry
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.model.Quay.useOrganisation" /><i></i> </label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !organisation-checkbox -->
|
||||
<div ng-if="$ctrl.model.Quay.useOrganisation">
|
||||
<!-- organisation_name -->
|
||||
<div class="form-group">
|
||||
<label for="organisation_name" class="col-sm-3 col-lg-2 control-label text-left">Organisation name</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="organisation_name" name="organisation_name" ng-model="$ctrl.model.Quay.organisationName" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="registryFormQuay.organisation_name.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="registryFormQuay.organisation_name.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !organisation_name -->
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
|
||||
@@ -15,6 +15,7 @@ export function RegistryViewModel(data) {
|
||||
this.TeamAccessPolicies = data.TeamAccessPolicies;
|
||||
this.Checked = false;
|
||||
this.Gitlab = data.Gitlab;
|
||||
this.Quay = data.Quay;
|
||||
}
|
||||
|
||||
export function RegistryManagementConfigurationDefaultModel(registry) {
|
||||
@@ -64,4 +65,10 @@ export function RegistryCreateRequest(model) {
|
||||
ProjectPath: model.Gitlab.ProjectPath,
|
||||
};
|
||||
}
|
||||
if (model.Type === RegistryTypes.QUAY) {
|
||||
this.Quay = {
|
||||
useOrganisation: model.Quay.useOrganisation,
|
||||
organisationName: model.Quay.organisationName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,9 @@ angular.module('portainer.app').factory('RegistryService', [
|
||||
let url = reg.URL;
|
||||
if (reg.Type === RegistryTypes.GITLAB) {
|
||||
url = reg.URL + '/' + reg.Gitlab.ProjectPath;
|
||||
} else if (reg.Type === RegistryTypes.QUAY) {
|
||||
const name = reg.Quay.UseOrganisation ? reg.Quay.OrganisationName : reg.Username;
|
||||
url = reg.URL + '/' + name;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,23 @@ angular.module('portainer.app').factory('ModalService', [
|
||||
});
|
||||
};
|
||||
|
||||
service.confirmWebEditorDiscard = confirmWebEditorDiscard;
|
||||
function confirmWebEditorDiscard() {
|
||||
const options = {
|
||||
title: 'Are you sure ?',
|
||||
message: 'You currently have unsaved changes in the editor. Are you sure you want to leave?',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes',
|
||||
className: 'btn-danger',
|
||||
},
|
||||
},
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
service.confirm({ ...options, callback: (confirmed) => resolve(confirmed) });
|
||||
});
|
||||
}
|
||||
|
||||
service.confirmAsync = confirmAsync;
|
||||
function confirmAsync(options) {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -3,8 +3,20 @@ import { AccessControlFormData } from 'Portainer/components/accessControlForm/po
|
||||
|
||||
class CreateCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager) {
|
||||
Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager });
|
||||
constructor($async, $state, $window, Authentication, ModalService, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager) {
|
||||
Object.assign(this, {
|
||||
$async,
|
||||
$state,
|
||||
$window,
|
||||
Authentication,
|
||||
ModalService,
|
||||
CustomTemplateService,
|
||||
FormValidator,
|
||||
Notifications,
|
||||
ResourceControlService,
|
||||
StackService,
|
||||
StateManager,
|
||||
});
|
||||
|
||||
this.formValues = {
|
||||
Title: '',
|
||||
@@ -29,6 +41,7 @@ class CreateCustomTemplateViewController {
|
||||
actionInProgress: false,
|
||||
fromStack: false,
|
||||
loading: true,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
this.templates = [];
|
||||
|
||||
@@ -73,6 +86,7 @@ class CreateCustomTemplateViewController {
|
||||
await this.ResourceControlService.applyResourceControl(userId, accessControlData, customTemplate.ResourceControl);
|
||||
|
||||
this.Notifications.success('Custom template successfully created');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('docker.templates.custom');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'A template with the same name already exists');
|
||||
@@ -133,6 +147,7 @@ class CreateCustomTemplateViewController {
|
||||
|
||||
editorUpdate(cm) {
|
||||
this.formValues.FileContent = cm.getValue();
|
||||
this.state.isEditorDirty = true;
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
@@ -161,6 +176,18 @@ class CreateCustomTemplateViewController {
|
||||
}
|
||||
|
||||
this.state.loading = false;
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.state.Method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.state.Method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resou
|
||||
|
||||
class EditCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
|
||||
Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
|
||||
constructor($async, $state, $window, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
|
||||
Object.assign(this, { $async, $state, $window, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
|
||||
|
||||
this.formValues = null;
|
||||
this.state = {
|
||||
formValidationError: '',
|
||||
isEditorDirty: false,
|
||||
};
|
||||
this.templates = [];
|
||||
|
||||
@@ -32,6 +33,7 @@ class EditCustomTemplateViewController {
|
||||
]);
|
||||
template.FileContent = file;
|
||||
this.formValues = template;
|
||||
this.oldFileContent = this.formValues.FileContent;
|
||||
this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl);
|
||||
this.formValues.AccessControlData = new AccessControlFormData();
|
||||
} catch (err) {
|
||||
@@ -84,6 +86,7 @@ class EditCustomTemplateViewController {
|
||||
await this.ResourceControlService.applyResourceControl(userId, this.formValues.AccessControlData, this.formValues.ResourceControl);
|
||||
|
||||
this.Notifications.success('Custom template successfully updated');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('docker.templates.custom');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to update custom template');
|
||||
@@ -93,7 +96,14 @@ class EditCustomTemplateViewController {
|
||||
}
|
||||
|
||||
editorUpdate(cm) {
|
||||
this.formValues.fileContent = cm.getValue();
|
||||
this.formValues.FileContent = cm.getValue();
|
||||
this.state.isEditorDirty = true;
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
@@ -104,6 +114,12 @@ class EditCustomTemplateViewController {
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,10 @@ angular
|
||||
payload.URL = 'tcp://' + endpoint.URL;
|
||||
}
|
||||
|
||||
if (endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment) {
|
||||
payload.URL = endpoint.URL;
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
EndpointService.updateEndpoint(endpoint.Id, payload).then(
|
||||
function success() {
|
||||
|
||||
@@ -13,8 +13,7 @@ angular
|
||||
EndpointProvider,
|
||||
StateManager,
|
||||
ModalService,
|
||||
MotdService,
|
||||
SettingsService
|
||||
MotdService
|
||||
) {
|
||||
$scope.state = {
|
||||
connectingToEdgeEndpoint: false,
|
||||
@@ -83,7 +82,7 @@ angular
|
||||
var groups = data.groups;
|
||||
EndpointHelper.mapGroupNameToEndpoint(endpoints, groups);
|
||||
EndpointProvider.setEndpoints(endpoints);
|
||||
deferred.resolve({ endpoints: decorateEndpoints(endpoints), totalCount: data.endpoints.totalCount });
|
||||
deferred.resolve({ endpoints: endpoints, totalCount: data.endpoints.totalCount });
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve endpoint information');
|
||||
@@ -99,15 +98,14 @@ angular
|
||||
});
|
||||
|
||||
try {
|
||||
const [{ totalCount, endpoints }, tags, settings] = await Promise.all([getPaginatedEndpoints(0, 100), TagService.tags(), SettingsService.settings()]);
|
||||
const [{ totalCount, endpoints }, tags] = await Promise.all([getPaginatedEndpoints(0, 100), TagService.tags()]);
|
||||
$scope.tags = tags;
|
||||
$scope.defaultEdgeCheckInInterval = settings.EdgeAgentCheckinInterval;
|
||||
|
||||
$scope.totalCount = totalCount;
|
||||
if (totalCount > 100) {
|
||||
$scope.endpoints = [];
|
||||
} else {
|
||||
$scope.endpoints = decorateEndpoints(endpoints);
|
||||
$scope.endpoints = endpoints;
|
||||
}
|
||||
} catch (err) {
|
||||
Notifications.error('Failed loading page data', err);
|
||||
@@ -115,10 +113,4 @@ angular
|
||||
}
|
||||
|
||||
initView();
|
||||
|
||||
function decorateEndpoints(endpoints) {
|
||||
return endpoints.map((endpoint) => {
|
||||
return { ...endpoint, EdgeCheckinInterval: endpoint.EdgeAgentCheckinInterval || $scope.defaultEdgeCheckInInterval };
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,10 +28,17 @@ angular.module('portainer.app').controller('CreateRegistryController', [
|
||||
},
|
||||
};
|
||||
|
||||
function useDefaultQuayConfiguration() {
|
||||
$scope.model.Quay.useOrganisation = false;
|
||||
$scope.model.Quay.organisationName = '';
|
||||
}
|
||||
|
||||
function selectQuayRegistry() {
|
||||
$scope.model.Name = 'Quay';
|
||||
$scope.model.URL = 'quay.io';
|
||||
$scope.model.Authentication = true;
|
||||
$scope.model.Quay = {};
|
||||
useDefaultQuayConfiguration();
|
||||
}
|
||||
|
||||
function useDefaultGitlabConfiguration() {
|
||||
|
||||
@@ -63,12 +63,36 @@
|
||||
<!-- !credentials-password -->
|
||||
</div>
|
||||
<!-- !authentication-credentials -->
|
||||
|
||||
<div ng-if="registry.Type == RegistryTypes.QUAY">
|
||||
<!-- organisation-checkbox -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Use organisation registry
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="registry.Quay.UseOrganisation" /><i></i> </label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !organisation-checkbox -->
|
||||
<div ng-if="registry.Quay.UseOrganisation">
|
||||
<!-- organisation_name -->
|
||||
<div class="form-group">
|
||||
<label for="organisation_name" class="col-sm-3 col-lg-2 control-label text-left">Organisation name</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="organisation_name" name="organisation_name" ng-model="registry.Quay.OrganisationName" required />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !organisation_name -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="state.actionInProgress || !registry.Name || !registry.URL"
|
||||
ng-disabled="state.actionInProgress || !registry.Name || !registry.URL || (registry.Type == RegistryTypes.QUAY && registry.Quay.UseOrganisation && !registry.Quay.OrganisationName)"
|
||||
ng-click="updateRegistry()"
|
||||
button-spinner="state.actionInProgress"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RegistryTypes } from '@/portainer/models/registryTypes';
|
||||
|
||||
angular.module('portainer.app').controller('RegistryController', [
|
||||
'$scope',
|
||||
'$state',
|
||||
@@ -14,6 +16,8 @@ angular.module('portainer.app').controller('RegistryController', [
|
||||
Password: '',
|
||||
};
|
||||
|
||||
$scope.RegistryTypes = RegistryTypes;
|
||||
|
||||
$scope.updateRegistry = function () {
|
||||
var registry = $scope.registry;
|
||||
registry.Password = $scope.formValues.Password;
|
||||
|
||||
@@ -9,6 +9,8 @@ angular
|
||||
$scope,
|
||||
$state,
|
||||
$async,
|
||||
$window,
|
||||
ModalService,
|
||||
StackService,
|
||||
Authentication,
|
||||
Notifications,
|
||||
@@ -42,6 +44,13 @@ angular
|
||||
StackType: null,
|
||||
editorYamlValidationError: '',
|
||||
uploadYamlValidationError: '',
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
$window.onbeforeunload = () => {
|
||||
if ($scope.state.Method === 'editor' && $scope.formValues.StackFileContent && $scope.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
$scope.addEnvironmentVariable = function () {
|
||||
@@ -148,6 +157,7 @@ angular
|
||||
})
|
||||
.then(function success() {
|
||||
Notifications.success('Stack successfully deployed');
|
||||
$scope.state.isEditorDirty = false;
|
||||
$state.go('docker.stacks');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
@@ -161,6 +171,7 @@ angular
|
||||
$scope.editorUpdate = function (cm) {
|
||||
$scope.formValues.StackFileContent = cm.getValue();
|
||||
$scope.state.editorYamlValidationError = StackHelper.validateYAML($scope.formValues.StackFileContent, $scope.containerNames);
|
||||
$scope.state.isEditorDirty = true;
|
||||
};
|
||||
|
||||
async function onFileLoadAsync(event) {
|
||||
@@ -221,5 +232,11 @@ angular
|
||||
}
|
||||
}
|
||||
|
||||
this.uiCanExit = async function () {
|
||||
if ($scope.state.Method === 'editor' && $scope.formValues.StackFileContent && $scope.state.isEditorDirty) {
|
||||
return ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
};
|
||||
|
||||
initView();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ angular.module('portainer.app').controller('StackController', [
|
||||
'$q',
|
||||
'$scope',
|
||||
'$state',
|
||||
'$window',
|
||||
'$transition$',
|
||||
'StackService',
|
||||
'NodeService',
|
||||
@@ -18,11 +19,13 @@ angular.module('portainer.app').controller('StackController', [
|
||||
'GroupService',
|
||||
'ModalService',
|
||||
'StackHelper',
|
||||
'ContainerHelper',
|
||||
function (
|
||||
$async,
|
||||
$q,
|
||||
$scope,
|
||||
$state,
|
||||
$window,
|
||||
$transition$,
|
||||
StackService,
|
||||
NodeService,
|
||||
@@ -46,6 +49,7 @@ angular.module('portainer.app').controller('StackController', [
|
||||
externalStack: false,
|
||||
showEditorTab: false,
|
||||
yamlError: false,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
@@ -53,6 +57,12 @@ angular.module('portainer.app').controller('StackController', [
|
||||
Endpoint: null,
|
||||
};
|
||||
|
||||
$window.onbeforeunload = () => {
|
||||
if ($scope.stackFileContent && $scope.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
$scope.duplicateStack = function duplicateStack(name, endpointId) {
|
||||
var stack = $scope.stack;
|
||||
var env = FormHelper.removeInvalidEnvVars(stack.Env);
|
||||
@@ -171,6 +181,7 @@ angular.module('portainer.app').controller('StackController', [
|
||||
StackService.updateStack(stack, stackFile, env, prune)
|
||||
.then(function success() {
|
||||
Notifications.success('Stack successfully deployed');
|
||||
$scope.state.isEditorDirty = false;
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
@@ -190,8 +201,12 @@ angular.module('portainer.app').controller('StackController', [
|
||||
};
|
||||
|
||||
$scope.editorUpdate = function (cm) {
|
||||
if ($scope.stackFileContent !== cm.getValue()) {
|
||||
$scope.state.isEditorDirty = true;
|
||||
}
|
||||
$scope.stackFileContent = cm.getValue();
|
||||
$scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames);
|
||||
$scope.state.isEditorDirty = true;
|
||||
};
|
||||
|
||||
$scope.stopStack = stopStack;
|
||||
@@ -369,6 +384,12 @@ angular.module('portainer.app').controller('StackController', [
|
||||
});
|
||||
}
|
||||
|
||||
this.uiCanExit = async function () {
|
||||
if ($scope.stackFileContent && $scope.state.isEditorDirty) {
|
||||
return ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
};
|
||||
|
||||
async function initView() {
|
||||
var stackName = $transition$.params().name;
|
||||
$scope.stackName = stackName;
|
||||
|
||||
@@ -3,4 +3,4 @@ class {{properCase name}}Controller {
|
||||
constructor() {}
|
||||
}
|
||||
|
||||
export default {{properCase name}}Controller;
|
||||
export default {{properCase name}}Controller;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {{properCase name}}Controller from './{{dashCase name}}/{{camelCase name}}Controller.js'
|
||||
import angular from 'angular';
|
||||
import controller from './{{dashCase name}}.controller.js'
|
||||
|
||||
angular.module('portainer.{{module}}').component('{{camelCase name}}', {
|
||||
templateUrl: './{{camelCase name}}.html',
|
||||
controller: {{properCase name}}Controller,
|
||||
});
|
||||
export const {{camelCase name}} = {
|
||||
templateUrl: './{{dashCase name}}.html',
|
||||
controller,
|
||||
};
|
||||
|
||||
angular.module('portainer.{{module}}').component('{{camelCase name}}', {{camelCase name}})
|
||||
|
||||
@@ -25,12 +25,12 @@ module.exports = function (plop) {
|
||||
},
|
||||
{
|
||||
type: 'add',
|
||||
path: `{{cwd}}/{{dashCase name}}/{{camelCase name}}Controller.js`,
|
||||
path: `{{cwd}}/{{dashCase name}}/{{dashCase name}}.controller.js`,
|
||||
templateFile: './plop-templates/component-controller.js.hbs',
|
||||
},
|
||||
{
|
||||
type: 'add',
|
||||
path: `{{cwd}}/{{dashCase name}}/{{camelCase name}}.html`,
|
||||
path: `{{cwd}}/{{dashCase name}}/{{dashCase name}}.html`,
|
||||
templateFile: './plop-templates/component.html.hbs',
|
||||
},
|
||||
], // array of actions
|
||||
|
||||
Reference in New Issue
Block a user