Compare commits

...

9 Commits

Author SHA1 Message Date
Stéphane Busso
47fb7f0aae Chore: Add Licenses attributions 2021-03-22 13:15:36 +13:00
Alice Groux
bca32b02c7 fix(k8s/endpoint): update endpoint URL (#4484)
* fix(k8s/endpoint): update endpoint URL

* fix(endpoints): handle kube agent url

* fix(endpoints): fix handling endpoint urls

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
2021-03-20 23:35:54 +01:00
Alice Groux
a7ed6222b0 feat(app): Prevent web editor related views from being accidentally closed (#4715)
* feat(app): when leaving a view with unsaved changed, a modal prompt the user with a confirmation message

feat(app): when leaving a view with unsaved changes, a modal prompt the user with a confirmation message

* feat(app/web-editor): fix the modal behaviour when editing a stack details

* feat(app/web-editor): add a reusable function confirmWebEditorDiscard in modal service

* feat(docker/stack): fix missing dependency
2021-03-20 22:13:27 +01:00
Chaim Lev-Ari
d0d38990c7 chore(plop): use templates as in style guide (#4916)
* chore(plop): use templates as in style guide

fix [CE-483]

* chore(plop): export component and add to module
2021-03-19 09:03:26 +13:00
Maxime Bajeux
32a9a2e46b Enable the ability to cordon/uncordon/drain nodes (#4723)
* feat(node): Enable the ability to cordon/uncordon/drain nodes

* feat(cluster): check if there is a drain operation somewhere

* feat(kubernetes): allow to cordon, uncordon, drain nodes

* refacto(kubernetes): set a constant for drain label name

* fix(node): Relocate the warning message next to the dropdown and change the information message
2021-03-15 22:36:14 +01:00
Maxime Bajeux
660bc2dadf fix(service): change application owner label in createPayload (#4841) 2021-03-14 22:48:17 +01:00
Dmitry Salakhov
4cbd231a5f fix: normalize stack name only for libcompose (#4862)
* fix: normilize stack name only for libcompose

* fix
2021-03-14 20:08:31 +01:00
cong meng
6d5877ca1c fix(registry): #4371 cannot push to quay.io registry (#4868)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-03-13 12:47:35 +13:00
Chaim Lev-Ari
dbb9a21384 fix(endpoints): use default edge checkin interval if n/a (#4909) 2021-03-11 21:00:05 +01:00
56 changed files with 656 additions and 90 deletions

26
ATTRIBUTIONS.md Normal file
View 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/)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)

View File

@@ -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}

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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();
}
};
},
]);

View File

@@ -72,6 +72,7 @@ export class EdgeJobFormController {
editorUpdate(cm) {
this.model.FileContent = cm.getValue();
this.isEditorDirty = true;
}
associateEndpoint(endpoint) {

View File

@@ -14,5 +14,6 @@ angular.module('portainer.edge').component('edgeJobForm', {
formAction: '<',
formActionLabel: '@',
actionInProgress: '<',
isEditorDirty: '=',
},
});

View File

@@ -6,5 +6,6 @@ export class EditEdgeStackFormController {
editorUpdate(cm) {
this.model.StackFileContent = cm.getValue();
this.isEditorDirty = true;
}
}

View File

@@ -10,5 +10,6 @@ angular.module('portainer.edge').component('editEdgeStackForm', {
actionInProgress: '<',
submitAction: '<',
edgeGroups: '<',
isEditorDirty: '=',
},
});

View File

@@ -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>

View File

@@ -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 '';
}
};
}
}

View File

@@ -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>

View File

@@ -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 '';
}
};
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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');

View File

@@ -5,5 +5,6 @@ angular.module('portainer.kubernetes').component('kubernetesConfigurationData',
formValues: '=',
isValid: '=',
isCreation: '=',
isEditorDirty: '=',
},
});

View File

@@ -44,6 +44,7 @@ class KubernetesConfigurationDataController {
async editorUpdateAsync(cm) {
this.formValues.DataYaml = cm.getValue();
this.isEditorDirty = true;
}
editorUpdate(cm) {

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -1,6 +1,7 @@
const _KubernetesNodeFormValues = Object.freeze({
Taints: [],
Labels: [],
Availability: '',
});
export class KubernetesNodeFormValues {

View File

@@ -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',

View File

@@ -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 };
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;

View File

@@ -42,6 +42,7 @@ angular.module('portainer.kubernetes').factory('KubernetesPods', [
params: { action: 'log' },
transformResponse: logsHandler,
},
evict: { method: 'POST' },
}
);
};

View File

@@ -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>

View File

@@ -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();

View File

@@ -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 -->

View File

@@ -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() {

View File

@@ -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 -->

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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

View File

@@ -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,
};
}
}

View File

@@ -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;
}

View File

@@ -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) => {

View File

@@ -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();
}
}
}

View File

@@ -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 '';
}
};
}
}

View File

@@ -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() {

View File

@@ -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 };
});
}
});

View File

@@ -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() {

View File

@@ -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"
>

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -3,4 +3,4 @@ class {{properCase name}}Controller {
constructor() {}
}
export default {{properCase name}}Controller;
export default {{properCase name}}Controller;

View File

@@ -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}})

View File

@@ -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