diff --git a/.vscode.example/portainer.code-snippets b/.vscode.example/portainer.code-snippets index fefb732ce..fa3511098 100644 --- a/.vscode.example/portainer.code-snippets +++ b/.vscode.example/portainer.code-snippets @@ -163,5 +163,19 @@ "// @failure 500 \"Server error\"", "// @router /{id} [get]" ] + }, + "analytics": { + "prefix": "nlt", + "body": ["analytics-on", "analytics-category=\"$1\"", "analytics-event=\"$2\""], + "description": "analytics" + }, + "analytics-if": { + "prefix": "nltf", + "body": ["analytics-if=\"$1\""], + "description": "analytics" + }, + "analytics-metadata": { + "prefix": "nltm", + "body": "analytics-properties=\"{ metadata: { $1 } }\"" } } diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go index e01ba01df..dc741da7a 100644 --- a/api/http/handler/customtemplates/customtemplate_create.go +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -105,9 +105,10 @@ type customTemplateFromFileContentPayload struct { Note string `example:"This is my custom template"` // Platform associated to the template. // Valid values are: 1 - 'linux', 2 - 'windows' - Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"` - // Type of created stack (1 - swarm, 2 - compose) - Type portainer.StackType `example:"1" enums:"1,2" validate:"required"` + // Required for Docker stacks + Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"` + // Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes) + Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"` // Content of stack file FileContent string `validate:"required"` } @@ -122,10 +123,10 @@ func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) e if govalidator.IsNull(payload.FileContent) { return errors.New("Invalid file content") } - if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { return errors.New("Invalid custom template platform") } - if payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { + if payload.Type != portainer.KubernetesStack && payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { return errors.New("Invalid custom template type") } return nil @@ -171,7 +172,8 @@ type customTemplateFromGitRepositoryPayload struct { Note string `example:"This is my custom template"` // Platform associated to the template. // Valid values are: 1 - 'linux', 2 - 'windows' - Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"` + // Required for Docker stacks + Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"` // Type of created stack (1 - swarm, 2 - compose) Type portainer.StackType `example:"1" enums:"1,2" validate:"required"` @@ -205,6 +207,11 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) if govalidator.IsNull(payload.ComposeFilePathInRepository) { payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName } + + if payload.Type == portainer.KubernetesStack { + return errors.New("Creating a Kubernetes custom template from git is not supported") + } + if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { return errors.New("Invalid custom template platform") } @@ -278,20 +285,21 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er note, _ := request.RetrieveMultiPartFormValue(r, "Note", true) payload.Note = note - platform, _ := request.RetrieveNumericMultiPartFormValue(r, "Platform", true) - templatePlatform := portainer.CustomTemplatePlatform(platform) - if templatePlatform != portainer.CustomTemplatePlatformLinux && templatePlatform != portainer.CustomTemplatePlatformWindows { - return errors.New("Invalid custom template platform") - } - payload.Platform = templatePlatform - typeNumeral, _ := request.RetrieveNumericMultiPartFormValue(r, "Type", true) templateType := portainer.StackType(typeNumeral) - if templateType != portainer.DockerComposeStack && templateType != portainer.DockerSwarmStack { + if templateType != portainer.KubernetesStack && templateType != portainer.DockerSwarmStack && templateType != portainer.DockerComposeStack { return errors.New("Invalid custom template type") } payload.Type = templateType + platform, _ := request.RetrieveNumericMultiPartFormValue(r, "Platform", true) + templatePlatform := portainer.CustomTemplatePlatform(platform) + if templateType != portainer.KubernetesStack && templatePlatform != portainer.CustomTemplatePlatformLinux && templatePlatform != portainer.CustomTemplatePlatformWindows { + return errors.New("Invalid custom template platform") + } + + payload.Platform = templatePlatform + composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "File") if err != nil { return errors.New("Invalid Compose file. Ensure that the Compose file is uploaded correctly") diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go index 13dd10363..e6d616de3 100644 --- a/api/http/handler/customtemplates/customtemplate_list.go +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -2,7 +2,9 @@ package customtemplates import ( "net/http" + "strconv" + "github.com/pkg/errors" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" @@ -17,10 +19,16 @@ import ( // @tags custom_templates // @security jwt // @produce json +// @param type query []int true "Template types" Enums(1,2,3) // @success 200 {array} portainer.CustomTemplate "Success" // @failure 500 "Server error" // @router /custom_templates [get] func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + templateTypes, err := parseTemplateTypes(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Custom template type", err} + } + customTemplates, err := handler.DataStore.CustomTemplate().CustomTemplates() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve custom templates from the database", err} @@ -52,5 +60,52 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs) } + customTemplates = filterByType(customTemplates, templateTypes) + return response.JSON(w, customTemplates) } + +func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) { + err := r.ParseForm() + if err != nil { + return nil, errors.WithMessage(err, "failed to parse request params") + } + + types, exist := r.Form["type"] + if !exist { + return []portainer.StackType{}, nil + } + + res := []portainer.StackType{} + for _, templateTypeStr := range types { + templateType, err := strconv.Atoi(templateTypeStr) + if err != nil { + return nil, errors.WithMessage(err, "failed parsing template type") + } + + res = append(res, portainer.StackType(templateType)) + } + + return res, nil +} + +func filterByType(customTemplates []portainer.CustomTemplate, templateTypes []portainer.StackType) []portainer.CustomTemplate { + if len(templateTypes) == 0 { + return customTemplates + } + + typeSet := map[portainer.StackType]bool{} + for _, templateType := range templateTypes { + typeSet[templateType] = true + } + + filtered := []portainer.CustomTemplate{} + + for _, template := range customTemplates { + if typeSet[template.Type] { + filtered = append(filtered, template) + } + } + + return filtered +} diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go index ecbd9c48a..dcbb1dd65 100644 --- a/api/http/handler/customtemplates/customtemplate_update.go +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -27,9 +27,10 @@ type customTemplateUpdatePayload struct { Note string `example:"This is my custom template"` // Platform associated to the template. // Valid values are: 1 - 'linux', 2 - 'windows' - Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"` - // Type of created stack (1 - swarm, 2 - compose) - Type portainer.StackType `example:"1" enums:"1,2" validate:"required"` + // Required for Docker stacks + Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"` + // Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes) + Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"` // Content of stack file FileContent string `validate:"required"` } @@ -41,10 +42,10 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.FileContent) { return errors.New("Invalid file content") } - if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { return errors.New("Invalid custom template platform") } - if payload.Type != portainer.DockerComposeStack && payload.Type != portainer.DockerSwarmStack { + if payload.Type != portainer.KubernetesStack && payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { return errors.New("Invalid custom template type") } if govalidator.IsNull(payload.Description) { diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index df78ca965..18d282fb8 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -208,11 +208,11 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err} } - commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) + commitID, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err} } - stack.GitConfig.ConfigHash = commitId + stack.GitConfig.ConfigHash = commitID config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) if configErr != nil { diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 63aff8220..4d8661493 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -17,6 +17,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/api/internal/stackutils" k "github.com/portainer/portainer/api/kubernetes" ) @@ -40,6 +41,12 @@ type kubernetesGitDeploymentPayload struct { AutoUpdate *portainer.StackAutoUpdate } +type kubernetesManifestURLDeploymentPayload struct { + Namespace string + ComposeFormat bool + ManifestURL string +} + func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.StackFileContent) { return errors.New("Invalid stack file content") @@ -72,6 +79,13 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error { return nil } +func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.ManifestURL) || !govalidator.IsURL(payload.ManifestURL) { + return errors.New("Invalid manifest URL") + } + return nil +} + type createKubernetesStackResponse struct { Output string `json:"Output"` } @@ -137,6 +151,8 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit Output: output, } + doCleanUp = false + return response.JSON(w, resp) } @@ -181,17 +197,24 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr AutoUpdate: payload.AutoUpdate, } + if payload.RepositoryAuthentication { + stack.GitConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, + } + } + projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) stack.ProjectPath = projectPath doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) + commitID, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err} } - stack.GitConfig.ConfigHash = commitId + stack.GitConfig.ConfigHash = commitID repositoryUsername := payload.RepositoryUsername repositoryPassword := payload.RepositoryPassword @@ -235,6 +258,70 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr resp := &createKubernetesStackResponse{ Output: output, } + + doCleanUp = false + + return response.JSON(w, resp) +} + +func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + var payload kubernetesManifestURLDeploymentPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} + } + + user, err := handler.DataStore.User().User(userID) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} + } + + stackID := handler.DataStore.Stack().GetNextIdentifier() + stack := &portainer.Stack{ + ID: portainer.StackID(stackID), + Type: portainer.KubernetesStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ManifestFileDefaultName, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), + CreatedBy: user.Username, + IsComposeFormat: payload.ComposeFormat, + } + + var manifestContent []byte + manifestContent, err = client.Get(payload.ManifestURL, 30) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve manifest from URL", Err: err} + } + + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, manifestContent) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + output, err := handler.deployKubernetesStack(r, endpoint, stack, k.KubeAppLabels{ + StackID: stackID, + Name: stack.Name, + Owner: stack.CreatedBy, + Kind: "url", + }) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} + } + + err = handler.DataStore.Stack().CreateStack(stack) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err} + } + + resp := &createKubernetesStackResponse{ + Output: output, + } + return response.JSON(w, resp) } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index b2252b9af..ba2ced868 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -218,11 +218,11 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err} } - commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) + commitID, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err} } - stack.GitConfig.ConfigHash = commitId + stack.GitConfig.ConfigHash = commitID config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) if configErr != nil { diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index 319f31639..fe5a0526f 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -149,6 +149,8 @@ func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Req return handler.createKubernetesStackFromFileContent(w, r, endpoint, userID) case "repository": return handler.createKubernetesStackFromGitRepository(w, r, endpoint, userID) + case "url": + return handler.createKubernetesStackFromManifestURL(w, r, endpoint, userID) } return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string or repository", Err: errors.New(request.ErrInvalidQueryParameter)} } diff --git a/api/http/handler/websocket/pod.go b/api/http/handler/websocket/pod.go index 8fd943890..e917d5a75 100644 --- a/api/http/handler/websocket/pod.go +++ b/api/http/handler/websocket/pod.go @@ -135,21 +135,25 @@ func (handler *Handler) hijackPodExecStartOperation( stdoutReader, stdoutWriter := io.Pipe() defer stdoutWriter.Close() + // errorChan is used to propagate errors from the go routines to the caller. errorChan := make(chan error, 1) go streamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan) go streamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan) - err = cli.StartExecProcess(serviceAccountToken, isAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err} - } + // StartExecProcess is a blocking operation which streams IO to/from pod; + // this must execute in asynchronously, since the websocketConn could return errors (e.g. client disconnects) before + // the blocking operation is completed. + go cli.StartExecProcess(serviceAccountToken, isAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter, errorChan) err = <-errorChan + + // websocket client successfully disconnected if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { log.Printf("websocket error: %s \n", err.Error()) + return nil } - return nil + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err} } func (handler *Handler) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, bool, error) { diff --git a/api/kubernetes/cli/exec.go b/api/kubernetes/cli/exec.go index 55cc38bc9..f15e43c91 100644 --- a/api/kubernetes/cli/exec.go +++ b/api/kubernetes/cli/exec.go @@ -4,7 +4,7 @@ import ( "errors" "io" - "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/remotecommand" @@ -15,10 +15,12 @@ import ( // using the specified command. The stdin parameter will be bound to the stdin process and the stdout process will write // to the stdout parameter. // This function only works against a local endpoint using an in-cluster config with the user's SA token. -func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error { +// This is a blocking operation. +func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) { config, err := rest.InClusterConfig() if err != nil { - return err + errChan <- err + return } if !useAdminToken { @@ -44,7 +46,8 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) if err != nil { - return err + errChan <- err + return } err = exec.Stream(remotecommand.StreamOptions{ @@ -54,9 +57,7 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp }) if err != nil { if _, ok := err.(utilexec.ExitError); !ok { - return errors.New("unable to start exec process") + errChan <- errors.New("unable to start exec process") } } - - return nil } diff --git a/api/portainer.go b/api/portainer.go index 8cfbaf86d..6e7660220 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1232,7 +1232,7 @@ type ( GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error) GetServiceAccountBearerToken(userID int) (string, error) CreateUserShellPod(ctx context.Context, serviceAccountName string) (*KubernetesShellPod, error) - StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error + StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) NamespaceAccessPoliciesDeleteNamespace(namespace string) error GetNodesLimits() (K8sNodesLimits, error) GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) diff --git a/app/angulartics.matomo/index.js b/app/angulartics.matomo/index.js index d9477ad1f..f6a4e528e 100644 --- a/app/angulartics.matomo/index.js +++ b/app/angulartics.matomo/index.js @@ -1,4 +1,5 @@ import angular from 'angular'; +import _ from 'lodash-es'; const basePath = 'http://portainer-ce.app'; @@ -131,7 +132,8 @@ function config($analyticsProvider, $windowProvider) { let metadataString = ''; if (metadata) { - metadataString = JSON.stringify(metadata).toLowerCase(); + const kebabCasedMetadata = Object.fromEntries(Object.entries(metadata).map(([key, value]) => [_.kebabCase(key), value])); + metadataString = JSON.stringify(kebabCasedMetadata).toLowerCase(); } push([ diff --git a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html index c135cca0d..916bcc1a7 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html @@ -199,6 +199,10 @@ ng-click="$ctrl.createStack()" button-spinner="$ctrl.state.actionInProgress" data-cy="edgeStackCreate-createStackButton" + analytics-on + analytics-event="edge-stack-creation" + analytics-category="edge" + analytics-properties="$ctrl.buildAnalyticsProperties()" > Deploy the stack Deployment in progress... diff --git a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js index bccfeddac..a9b229aa4 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js @@ -43,6 +43,30 @@ export class CreateEdgeStackViewController { this.onChangeFormValues = this.onChangeFormValues.bind(this); } + buildAnalyticsProperties() { + const format = 'compose'; + const metadata = { type: methodLabel(this.state.Method), format }; + + if (metadata.type === 'template') { + metadata.templateName = this.selectedTemplate.title; + } + + return { metadata }; + + function methodLabel(method) { + switch (method) { + case 'editor': + return 'web-editor'; + case 'repository': + return 'git'; + case 'upload': + return 'file-upload'; + case 'template': + return 'template'; + } + } + } + async uiCanExit() { if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) { return this.ModalService.confirmWebEditorDiscard(); diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index 2d34c1420..3e854bead 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -1,6 +1,7 @@ import registriesModule from './registries'; +import customTemplateModule from './custom-templates'; -angular.module('portainer.kubernetes', ['portainer.app', registriesModule]).config([ +angular.module('portainer.kubernetes', ['portainer.app', registriesModule, customTemplateModule]).config([ '$stateRegistryProvider', function ($stateRegistryProvider) { 'use strict'; @@ -208,12 +209,15 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule]).conf const deploy = { name: 'kubernetes.deploy', - url: '/deploy', + url: '/deploy?templateId', views: { 'content@': { component: 'kubernetesDeployView', }, }, + params: { + templateId: '', + }, }; const resourcePools = { diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html index 029e58111..3a2517be6 100644 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html +++ b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html @@ -73,7 +73,10 @@ Remove +